Fix browser shortcut frame regressions

This commit is contained in:
Mohamed Boudra
2026-07-14 15:44:28 +00:00
parent 03f7970c54
commit 005e17aa94
7 changed files with 166 additions and 41 deletions

View File

@@ -140,7 +140,7 @@ Electron wrapper for macOS, Linux, and Windows.
>
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
>
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+T`, `Cmd/Ctrl+L`, and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the sandboxed guest preload; it exposes no APIs to guest pages.
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
```text
Human key -> guest WebContents

View File

@@ -19,7 +19,9 @@ It validates the compositor behavior that unit tests cannot see:
- the automation group loads the compiled production keyboard boundary and guest
preload, then proves that initial page window handlers get first refusal, unhandled
shortcuts synchronously suppress editable browser defaults before crossing the host
boundary, digit wildcard shortcuts cross, and background automation stays in the guest.
boundary, handlers registered after preload still get first refusal, focused iframes
share the same boundary, digit wildcard shortcuts cross, and background automation stays
in the guest.
Run it with the repo Electron:

View File

@@ -451,7 +451,7 @@ function installHarnessWebviewGuards(win, options = {}) {
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
if (options.preloadPath) {
webPreferences.nodeIntegrationInSubFrames = false;
webPreferences.nodeIntegrationInSubFrames = true;
webPreferences.nodeIntegrationInWorker = false;
webPreferences.sandbox = true;
webPreferences.webSecurity = true;
@@ -1177,6 +1177,27 @@ function automationFixtureUrl() {
<script>
window.fixtureLog = [];
window.preventBrowserShortcut = false;
window.installLateBrowserShortcutHandler = () => {
window.addEventListener(
"keydown",
(event) => {
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "b"
) {
event.preventDefault();
window.fixtureLog.push({
event: "late-shortcut-b",
defaultPrevented: event.defaultPrevented,
trusted: event.isTrusted,
});
}
},
{ once: true },
);
};
window.addEventListener("keydown", (event) => {
if (
(event.metaKey || event.ctrlKey) &&
@@ -1715,6 +1736,57 @@ function installBrowserKeyboardSentinels() {
};
}
async function verifyLatePageShortcutFirstRefusal({ guest, sentinel, shortcutInputs }) {
await guest.executeJavaScript(
"window.preventBrowserShortcut = false; window.installLateBrowserShortcutHandler();",
true,
);
automationBrowserShortcut(guest);
await waitForAutomationLog(
guest,
(entry) =>
entry.event === "late-shortcut-b" &&
entry.defaultPrevented === true &&
entry.trusted === true,
"late page-prevented trusted browser shortcut",
);
await delay(100);
if (shortcutInputs.length !== 0 || sentinel.applicationMenuShortcutHits !== 0) {
fail(
`late page-prevented browser shortcut escaped guest: inputs=${JSON.stringify(shortcutInputs)} menu=${sentinel.applicationMenuShortcutHits}`,
);
}
pass("automation late page handlers get first refusal for browser shortcuts");
return { group: "automation", check: "browser-shortcut-late-page-handler", pass: true };
}
async function verifyFocusedIframeShortcut({ guest, shortcutInputs, expectedInput }) {
const iframeFocused = await guest.executeJavaScript(
`new Promise((resolve) => {
const iframe = document.createElement("iframe");
iframe.srcdoc = '<!doctype html><button id="iframe-target">Frame target</button>';
iframe.addEventListener("load", () => {
iframe.contentDocument.getElementById("iframe-target").focus();
resolve(iframe.contentDocument.activeElement.id);
}, { once: true });
document.body.appendChild(iframe);
})`,
true,
);
if (iframeFocused !== "iframe-target") {
fail(`automation iframe browser shortcut target was not focused: ${iframeFocused}`);
}
automationBrowserShortcut(guest);
await waitForBrowserShortcutInput(shortcutInputs, 4);
if (!isDeepStrictEqual(shortcutInputs[3], expectedInput)) {
fail(
`iframe browser shortcut crossed boundary incorrectly: inputs=${JSON.stringify(shortcutInputs)}`,
);
}
pass("automation production guest preload forwards shortcuts from focused iframes");
return { group: "automation", check: "browser-shortcut-focused-iframe", pass: true };
}
async function verifyBrowserKeyboardIsolation({ guest, win, browserId, usesMeta, sentinel }) {
const checks = [];
const { shortcutInputs } = sentinel;
@@ -1753,6 +1825,8 @@ async function verifyBrowserKeyboardIsolation({ guest, win, browserId, usesMeta,
pass("automation page preventDefault keeps browser shortcut in the guest");
checks.push({ group: "automation", check: "browser-shortcut-page-prevented", pass: true });
checks.push(await verifyLatePageShortcutFirstRefusal({ guest, sentinel, shortcutInputs }));
await guest.executeJavaScript("window.preventBrowserShortcut = false", true);
const unhandledTarget = await guest.executeJavaScript(
"document.getElementById('save').focus(); document.activeElement.id",
@@ -1855,6 +1929,14 @@ async function verifyBrowserKeyboardIsolation({ guest, win, browserId, usesMeta,
pass("automation production guest preload forwards digit wildcard shortcuts");
checks.push({ group: "automation", check: "browser-shortcut-digit-wildcard", pass: true });
checks.push(
await verifyFocusedIframeShortcut({
guest,
shortcutInputs,
expectedInput: expectedBrowserShortcutInput,
}),
);
const focusedGuestInput = await guest.executeJavaScript(
"document.getElementById('name').focus(); document.activeElement.id",
true,
@@ -1927,6 +2009,7 @@ async function runAutomationGroup() {
const { PaseoBrowserWebviewRegistry } = require(PRODUCTION_BROWSER_WEBVIEW_REGISTRY_PATH);
const browserRegistry = new PaseoBrowserWebviewRegistry();
const browserKeyboard = new BrowserKeyboard(browserRegistry);
browserKeyboard.registerIpc();
const browserKeyboardSentinels = installBrowserKeyboardSentinels();
const handle = createInactiveHarnessWindow({
width: 1000,

View File

@@ -2,11 +2,11 @@ import { ipcRenderer } from "electron";
import type { BrowserKeyboardPolicy, BrowserShortcutPrefix } from "./policy.js";
const POLICY_CHANNEL = "paseo:browser-keyboard-policy";
const POLICY_REQUEST_CHANNEL = "paseo:browser-keyboard-policy-request";
const SHORTCUT_INPUT_CHANNEL = "paseo:browser-shortcut-input";
let browserId: string | null = null;
let policy: BrowserShortcutPrefix[] = [];
let keydownListenerInstalled = false;
interface BrowserKeyboardPolicyPayload extends BrowserKeyboardPolicy {
browserId: string;
@@ -44,45 +44,42 @@ function matchesCode(prefixCode: string, eventCode: string): boolean {
return /^(?:Digit|Numpad)[1-9]$/.test(eventCode);
}
function installKeydownListener(): void {
if (keydownListenerInstalled) {
function stageShortcutForward(event: KeyboardEvent): void {
if (!event.isTrusted || event.defaultPrevented || !browserId || !matchesPolicy(event)) {
return;
}
keydownListenerInstalled = true;
window.addEventListener("keydown", (event) => {
if (!event.isTrusted || event.defaultPrevented || !browserId || !matchesPolicy(event)) {
return;
}
event.preventDefault();
ipcRenderer.send(SHORTCUT_INPUT_CHANNEL, {
alt: event.altKey,
browserId,
code: event.code,
control: event.ctrlKey,
key: event.key,
meta: event.metaKey,
repeat: event.repeat,
shift: event.shiftKey,
});
});
const shortcutBrowserId = browserId;
window.addEventListener(
"keydown",
(completedEvent) => {
if (completedEvent !== event || completedEvent.defaultPrevented) {
return;
}
completedEvent.preventDefault();
ipcRenderer.send(SHORTCUT_INPUT_CHANNEL, {
alt: completedEvent.altKey,
browserId: shortcutBrowserId,
code: completedEvent.code,
control: completedEvent.ctrlKey,
key: completedEvent.key,
meta: completedEvent.metaKey,
repeat: completedEvent.repeat,
shift: completedEvent.shiftKey,
});
},
{ once: true },
);
}
function scheduleKeydownListener(): void {
if (keydownListenerInstalled) {
return;
}
const installAfterInitialPageHandlers = () => {
setTimeout(installKeydownListener, 0);
};
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", installAfterInitialPageHandlers, { once: true });
return;
}
installAfterInitialPageHandlers();
}
window.addEventListener("keydown", stageShortcutForward, { capture: true });
ipcRenderer.on(POLICY_CHANNEL, (_event, value: BrowserKeyboardPolicyPayload) => {
if (!value || typeof value.browserId !== "string" || !Array.isArray(value.prefixes)) {
return;
}
browserId = value.browserId;
policy = value.prefixes;
scheduleKeydownListener();
});
ipcRenderer.send(POLICY_REQUEST_CHANNEL);

View File

@@ -8,6 +8,14 @@ interface SentMessage {
}
class FakeBrowserContents {
public readonly mainFrame = {
framesInSubtree: [
{
detached: false,
send: (channel: string, payload: unknown) => this.send(channel, payload),
},
],
};
public readonly ignoredMenuShortcuts: boolean[] = [];
public readonly reloads: string[] = [];
public readonly sent: SentMessage[] = [];

View File

@@ -13,6 +13,7 @@ export type { BrowserKeyboardPolicy } from "./policy.js";
const POLICY_INPUT_CHANNEL = "paseo:browser:set-shortcut-policy";
const POLICY_OUTPUT_CHANNEL = "paseo:browser-keyboard-policy";
const POLICY_REQUEST_CHANNEL = "paseo:browser-keyboard-policy-request";
const SHORTCUT_INPUT_CHANNEL = "paseo:browser-shortcut-input";
const SHORTCUT_OUTPUT_CHANNEL = "paseo:event:browser-shortcut-input";
const RESERVED_SHORTCUT_OUTPUT_CHANNEL = "paseo:event:browser-shortcut";
@@ -26,6 +27,12 @@ interface BrowserKeyboardInputEvent {
}
interface BrowserKeyboardGuestContents extends BrowserKeyboardContentsIdentity {
readonly mainFrame: {
readonly framesInSubtree: ReadonlyArray<{
readonly detached: boolean;
send(channel: string, ...args: unknown[]): void;
}>;
};
isDestroyed(): boolean;
isLoadingMainFrame(): boolean;
on(event: "dom-ready", listener: () => void): void;
@@ -36,7 +43,6 @@ interface BrowserKeyboardGuestContents extends BrowserKeyboardContentsIdentity {
once(event: "destroyed", listener: () => void): void;
reload(): void;
reloadIgnoringCache(): void;
send(channel: string, ...args: unknown[]): void;
setIgnoreMenuShortcuts(ignore: boolean): void;
stop(): void;
}
@@ -64,6 +70,12 @@ export class BrowserKeyboard {
ipcMain.on(SHORTCUT_INPUT_CHANNEL, (event, rawInput: unknown) => {
this.forwardShortcutInput(event.sender, rawInput);
});
ipcMain.on(POLICY_REQUEST_CHANNEL, (event) => {
const payload = this.policyForGuest(event.sender);
if (payload) {
event.reply(POLICY_OUTPUT_CHANNEL, payload);
}
});
}
public attach(input: {
@@ -217,8 +229,29 @@ export class BrowserKeyboard {
browserId: string,
policy: BrowserKeyboardPolicy,
): void {
if (!guest.contents.isDestroyed()) {
guest.contents.send(POLICY_OUTPUT_CHANNEL, { ...policy, browserId });
if (guest.contents.isDestroyed()) {
return;
}
const payload = { ...policy, browserId };
for (const frame of guest.contents.mainFrame.framesInSubtree) {
if (!frame.detached) {
frame.send(POLICY_OUTPUT_CHANNEL, payload);
}
}
}
private policyForGuest(
contents: BrowserKeyboardContentsIdentity,
): (BrowserKeyboardPolicy & { browserId: string }) | null {
const guest = this.attachedGuestsByWebContentsId.get(contents.id);
if (!guest) {
return null;
}
const registration = this.registrationForGuest(contents.id, guest);
if (!registration) {
return null;
}
const policy = this.policiesByHostWebContentsId.get(registration.hostWebContentsId);
return policy ? { ...policy, browserId: registration.browserId } : null;
}
}

View File

@@ -581,7 +581,9 @@ async function createWindow(
pendingBrowserWebviewIdsByHostWebContentsId.set(mainWindow.webContents.id, [browserId]);
}
webPreferences.nodeIntegration = false;
webPreferences.nodeIntegrationInSubFrames = false;
// The sandboxed keyboard preload must run in every frame so focused iframes keep
// the same page-first shortcut boundary. Node integration remains disabled.
webPreferences.nodeIntegrationInSubFrames = true;
webPreferences.nodeIntegrationInWorker = false;
webPreferences.contextIsolation = true;
webPreferences.sandbox = true;