diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 6c60d0c75..69e019442 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -17,9 +17,10 @@ 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, unhandled - shortcuts cross from both ordinary and editable page targets, digit wildcard - shortcuts cross, and background automation stays in the guest. + preload, then proves that page window handlers get first refusal even when they + register after preload initialization, 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: diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 6db9f9461..ce6155a5a 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -15,7 +15,7 @@ import { } from "@/keyboard/keyboard-shortcuts"; import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { - buildBrowserShortcutPolicy, + buildBrowserKeyboardPolicy, parseBrowserShortcutInput, shouldPublishBrowserShortcutPolicy, } from "@/keyboard/browser-shortcuts"; @@ -68,16 +68,16 @@ export function useKeyboardShortcuts({ const publishBrowserShortcutPolicy = useCallback( (chordState?: ChordState) => { - const prefixes = + const policy = enabled && !isMobile - ? buildBrowserShortcutPolicy({ + ? buildBrowserKeyboardPolicy({ bindings, chordState, isMac, isDesktop: isDesktopApp, }) - : []; - void getDesktopHost()?.browser?.setShortcutPolicy?.({ prefixes }); + : { menuPrefixes: [], prefixes: [] }; + void getDesktopHost()?.browser?.setShortcutPolicy?.(policy); }, [bindings, enabled, isDesktopApp, isMac, isMobile], ); @@ -348,29 +348,6 @@ export function useKeyboardShortcuts({ }); }) : null; - const browserReservedShortcutSubscription = isElectronRuntime() - ? getDesktopHost()?.events?.on?.("browser-shortcut", (payload) => { - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return; - } - if (!("action" in payload) || payload.action !== "new-tab") { - return; - } - if ( - !("browserId" in payload) || - typeof payload.browserId !== "string" || - payload.browserId.length === 0 - ) { - return; - } - routeAndPerformShortcut({ - action: "workspace.tab.new", - payload: null, - domEvent: null, - }); - }) - : null; - return () => { if (chordStateRef.current.timeoutId !== null) { clearTimeout(chordStateRef.current.timeoutId); @@ -389,11 +366,6 @@ export function useKeyboardShortcuts({ } else { void browserShortcutSubscription?.then((dispose) => dispose()); } - if (typeof browserReservedShortcutSubscription === "function") { - browserReservedShortcutSubscription(); - } else { - void browserReservedShortcutSubscription?.then((dispose) => dispose()); - } }; }, [ bindings, diff --git a/packages/app/src/keyboard/browser-shortcuts.test.ts b/packages/app/src/keyboard/browser-shortcuts.test.ts index 75f777d93..e6d1911cc 100644 --- a/packages/app/src/keyboard/browser-shortcuts.test.ts +++ b/packages/app/src/keyboard/browser-shortcuts.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from "vitest"; import { - buildBrowserShortcutPolicy, + buildBrowserKeyboardPolicy, parseBrowserShortcutInput, shouldPublishBrowserShortcutPolicy, } from "./browser-shortcuts"; import { buildEffectiveBindings, resolveKeyboardShortcut } from "./keyboard-shortcuts"; -describe("buildBrowserShortcutPolicy", () => { +describe("buildBrowserKeyboardPolicy", () => { it("publishes only chord starts while no browser chord is pending", () => { const bindings = buildEffectiveBindings({ "workspace-tab-new-ctrl-t-non-mac": "Ctrl+Y", "workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+F12 Ctrl+F11", }); - const policy = buildBrowserShortcutPolicy({ bindings, isMac: false, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true }); - expect(policy).toContainEqual({ + expect(policy.prefixes).toContainEqual({ alt: false, code: "KeyY", control: true, @@ -23,14 +23,14 @@ describe("buildBrowserShortcutPolicy", () => { meta: false, shift: false, }); - expect(policy).toContainEqual({ + expect(policy.prefixes).toContainEqual({ alt: false, code: "F12", control: true, meta: false, shift: false, }); - expect(policy).not.toContainEqual({ + expect(policy.prefixes).not.toContainEqual({ alt: false, code: "F11", control: true, @@ -47,14 +47,14 @@ describe("buildBrowserShortcutPolicy", () => { (binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac", ); - const policy = buildBrowserShortcutPolicy({ + const policy = buildBrowserKeyboardPolicy({ bindings, chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null }, isMac: false, isDesktop: true, }); - expect(policy).toEqual([ + expect(policy.prefixes).toEqual([ { alt: false, code: "F11", @@ -63,6 +63,14 @@ describe("buildBrowserShortcutPolicy", () => { shift: false, }, ]); + expect(policy.menuPrefixes).toContainEqual({ + alt: false, + code: "KeyW", + control: true, + key: "w", + meta: false, + shift: false, + }); const result = resolveKeyboardShortcut({ event: { @@ -93,16 +101,16 @@ describe("buildBrowserShortcutPolicy", () => { "settings-toggle-ctrl-comma-non-mac": "Ctrl+F10 F9", }); - const policy = buildBrowserShortcutPolicy({ bindings, isMac: false, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true }); - expect(policy).not.toContainEqual({ + expect(policy.prefixes).not.toContainEqual({ alt: false, code: "F10", control: true, meta: false, shift: false, }); - expect(policy).not.toContainEqual({ + expect(policy.prefixes).not.toContainEqual({ alt: false, code: "F9", control: false, @@ -116,7 +124,9 @@ describe("buildBrowserShortcutPolicy", () => { "workspace-tab-new-cmd-t-mac": "Mod+Y", }); - expect(buildBrowserShortcutPolicy({ bindings, isMac: true, isDesktop: true })).toContainEqual({ + expect( + buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }).prefixes, + ).toContainEqual({ alt: false, code: "KeyY", control: false, @@ -128,16 +138,16 @@ describe("buildBrowserShortcutPolicy", () => { it("does not publish plain browser keys", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserShortcutPolicy({ bindings, isMac: false, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true }); - expect(policy).not.toContainEqual({ + expect(policy.prefixes).not.toContainEqual({ alt: false, code: "Enter", control: false, meta: false, shift: false, }); - expect(policy).not.toContainEqual({ + expect(policy.prefixes).not.toContainEqual({ alt: false, code: "Slash", control: false, @@ -148,9 +158,9 @@ describe("buildBrowserShortcutPolicy", () => { it("publishes Cmd+B with its logical key for non-QWERTY layouts", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserShortcutPolicy({ bindings, isMac: true, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }); - expect(policy).toContainEqual({ + expect(policy.prefixes).toContainEqual({ alt: false, code: "KeyB", control: false, @@ -162,9 +172,9 @@ describe("buildBrowserShortcutPolicy", () => { it("publishes the physical code needed for macOS Option shortcuts", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserShortcutPolicy({ bindings, isMac: true, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }); - expect(policy).toContainEqual({ + expect(policy.prefixes).toContainEqual({ alt: true, code: "KeyT", control: false, diff --git a/packages/app/src/keyboard/browser-shortcuts.ts b/packages/app/src/keyboard/browser-shortcuts.ts index cff48d5ca..d03467eed 100644 --- a/packages/app/src/keyboard/browser-shortcuts.ts +++ b/packages/app/src/keyboard/browser-shortcuts.ts @@ -22,6 +22,11 @@ export interface BrowserShortcutInput extends KeyboardShortcutInput { browserId: string; } +export interface BrowserKeyboardPolicy { + menuPrefixes: BrowserShortcutPrefix[]; + prefixes: BrowserShortcutPrefix[]; +} + interface BrowserShortcutPolicyInput { bindings: readonly ParsedShortcutBinding[]; chordState?: ChordState; @@ -117,9 +122,7 @@ function prefixKey(prefix: BrowserShortcutPrefix): string { ].join(":"); } -export function buildBrowserShortcutPolicy( - input: BrowserShortcutPolicyInput, -): BrowserShortcutPrefix[] { +function buildBrowserShortcutPrefixes(input: BrowserShortcutPolicyInput): BrowserShortcutPrefix[] { const prefixes = new Map(); const context = { isMac: input.isMac, @@ -158,3 +161,14 @@ export function buildBrowserShortcutPolicy( return [...prefixes.values()]; } + +export function buildBrowserKeyboardPolicy( + input: BrowserShortcutPolicyInput, +): BrowserKeyboardPolicy { + const menuPrefixes = buildBrowserShortcutPrefixes({ ...input, chordState: undefined }); + const prefixes = + input.chordState && input.chordState.step > 0 + ? buildBrowserShortcutPrefixes(input) + : menuPrefixes; + return { menuPrefixes, prefixes }; +} diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index a51928617..22efc27e4 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1774,6 +1774,46 @@ function installBrowserKeyboardSentinels() { }; } +async function verifyLateBrowserShortcutFirstRefusal({ guest, sentinel }) { + const { shortcutInputs } = sentinel; + await guest.executeJavaScript( + `window.addEventListener("keydown", function preventLateBrowserShortcut(event) { + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "b" + ) { + window.removeEventListener("keydown", preventLateBrowserShortcut); + event.preventDefault(); + window.fixtureLog.push({ + event: "late-shortcut-b", + defaultPrevented: event.defaultPrevented, + trusted: event.isTrusted, + }); + } + });`, + 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 preventDefault keeps browser shortcut in the guest"); + return { group: "automation", check: "browser-shortcut-late-page-prevented", pass: true }; +} + async function verifyBrowserKeyboardIsolation({ guest, win, browserId, usesMeta, sentinel }) { const checks = []; const { shortcutInputs } = sentinel; @@ -1812,8 +1852,11 @@ 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 }); + await guest.executeJavaScript("window.preventBrowserShortcut = false", true); + checks.push(await verifyLateBrowserShortcutFirstRefusal({ guest, sentinel })); + const unhandledTarget = await guest.executeJavaScript( - "window.preventBrowserShortcut = false; document.getElementById('save').focus(); document.activeElement.id", + "document.getElementById('save').focus(); document.activeElement.id", true, ); if (unhandledTarget !== "save") { @@ -2011,6 +2054,17 @@ async function runAutomationGroup() { }); browserKeyboard.attach({ contents: guest, hostContents: win.webContents }); browserKeyboard.publish(win.webContents.id, { + menuPrefixes: [ + { + alt: false, + code: "KeyB", + control: !usesMeta, + key: "b", + meta: usesMeta, + repeat: false, + shift: false, + }, + ], prefixes: [ { alt: false, diff --git a/packages/desktop/src/features/browser-keyboard/guest-preload.ts b/packages/desktop/src/features/browser-keyboard/guest-preload.ts index 5ef7256d5..6faa70c83 100644 --- a/packages/desktop/src/features/browser-keyboard/guest-preload.ts +++ b/packages/desktop/src/features/browser-keyboard/guest-preload.ts @@ -53,18 +53,23 @@ function installKeydownListener(): void { if (!event.isTrusted || event.defaultPrevented || !browserId || !matchesPolicy(event)) { return; } - event.preventDefault(); - event.stopPropagation(); - 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; + setTimeout(() => { + if (event.defaultPrevented) { + return; + } + event.preventDefault(); + ipcRenderer.send(SHORTCUT_INPUT_CHANNEL, { + alt: event.altKey, + browserId: shortcutBrowserId, + code: event.code, + control: event.ctrlKey, + key: event.key, + meta: event.metaKey, + repeat: event.repeat, + shift: event.shiftKey, + }); + }, 0); }); } diff --git a/packages/desktop/src/features/browser-keyboard/index.test.ts b/packages/desktop/src/features/browser-keyboard/index.test.ts index 3c0a6edec..72676de59 100644 --- a/packages/desktop/src/features/browser-keyboard/index.test.ts +++ b/packages/desktop/src/features/browser-keyboard/index.test.ts @@ -193,6 +193,16 @@ describe("BrowserKeyboard", () => { const guest = new FakeBrowserContents(61); const host = new FakeBrowserContents(62); const initialPolicy = { + menuPrefixes: [ + { + alt: false, + code: "KeyB", + control: true, + meta: false, + repeat: false as const, + shift: false, + }, + ], prefixes: [ { alt: false, @@ -204,7 +214,7 @@ describe("BrowserKeyboard", () => { }, ], }; - const latestPolicy = { prefixes: [] }; + const latestPolicy = { menuPrefixes: [], prefixes: [] }; keyboard.publish(host.id, initialPolicy); attach({ browserId: "browser-a", contents: guest, hostContents: host }); keyboard.publish(host.id, latestPolicy); @@ -235,7 +245,7 @@ describe("BrowserKeyboard", () => { const guest = new FakeBrowserContents(71); const host = new FakeBrowserContents(72); attach({ browserId: "browser-a", contents: guest, hostContents: host }); - keyboard.publish(host.id, { prefixes: [] }); + keyboard.publish(host.id, { menuPrefixes: [], prefixes: [] }); host.destroy(); guest.domReady(); @@ -243,28 +253,32 @@ describe("BrowserKeyboard", () => { expect(guest.sent).toEqual([ { channel: "paseo:browser-keyboard-policy", - payload: { browserId: "browser-a", prefixes: [] }, + payload: { browserId: "browser-a", menuPrefixes: [], prefixes: [] }, }, ]); }); - test("owns reserved shortcuts and leaves plain guest input contained", () => { + test("owns browser chrome shortcuts and leaves customizable shortcuts to policy", () => { const { attach } = createBrowserKeyboard(); const guest = new FakeBrowserContents(81); const host = new FakeBrowserContents(82); attach({ browserId: "browser-a", contents: guest, hostContents: host }); const command = process.platform === "darwin" ? { meta: true } : { control: true }; - const reservedWasPrevented = guest.input(electronInput({ ...command, code: "KeyT", key: "t" })); + const reservedWasPrevented = guest.input(electronInput({ ...command, code: "KeyL", key: "l" })); + const customizableWasPrevented = guest.input( + electronInput({ ...command, code: "KeyT", key: "t" }), + ); const enterWasPrevented = guest.input(electronInput({ code: "Enter", key: "Enter" })); expect(reservedWasPrevented).toBe(true); + expect(customizableWasPrevented).toBe(false); expect(enterWasPrevented).toBe(false); - expect(guest.ignoredMenuShortcuts).toEqual([false, true]); + expect(guest.ignoredMenuShortcuts).toEqual([false, false, true]); expect(host.sent).toEqual([ { channel: "paseo:event:browser-shortcut", - payload: { action: "new-tab", browserId: "browser-a" }, + payload: { action: "focus-url", browserId: "browser-a" }, }, ]); }); @@ -274,6 +288,9 @@ describe("BrowserKeyboard", () => { const guest = new FakeBrowserContents(91); const host = new FakeBrowserContents(92); keyboard.publish(host.id, { + menuPrefixes: [ + { alt: false, code: "KeyW", control: true, meta: false, repeat: false, shift: false }, + ], prefixes: [ { alt: false, code: "KeyW", control: true, meta: false, repeat: false, shift: false }, ], @@ -285,4 +302,24 @@ describe("BrowserKeyboard", () => { expect(wasPrevented).toBe(false); expect(guest.ignoredMenuShortcuts).toEqual([true]); }); + + test("keeps idle policy shortcuts out of the application menu while a chord is pending", () => { + const { attach, keyboard } = createBrowserKeyboard(); + const guest = new FakeBrowserContents(101); + const host = new FakeBrowserContents(102); + keyboard.publish(host.id, { + menuPrefixes: [ + { alt: false, code: "KeyW", control: true, meta: false, repeat: false, shift: false }, + ], + prefixes: [ + { alt: false, code: "F11", control: true, meta: false, repeat: false, shift: false }, + ], + }); + attach({ browserId: "browser-a", contents: guest, hostContents: host }); + + const wasPrevented = guest.input(electronInput({ code: "KeyW", control: true, key: "w" })); + + expect(wasPrevented).toBe(false); + expect(guest.ignoredMenuShortcuts).toEqual([true]); + }); }); diff --git a/packages/desktop/src/features/browser-keyboard/index.ts b/packages/desktop/src/features/browser-keyboard/index.ts index 13c244a95..38cbaa61b 100644 --- a/packages/desktop/src/features/browser-keyboard/index.ts +++ b/packages/desktop/src/features/browser-keyboard/index.ts @@ -3,6 +3,7 @@ import { PaseoBrowserWebviewRegistry } from "../browser-webviews/registry.js"; import { type BrowserKeyboardPolicy, classifyBrowserReservedShortcut, + matchesBrowserShortcutPrefixes, matchesBrowserShortcutPolicy, parseBrowserKeyboardPolicy, parseBrowserShortcutInput, @@ -150,19 +151,21 @@ export class BrowserKeyboard { input: Electron.Input, ): void { const policy = this.policiesByHostWebContentsId.get(registration.hostWebContentsId); + const matchInput = { + alt: input.alt, + code: input.code, + control: input.control, + key: input.key, + meta: input.meta, + repeat: input.isAutoRepeat, + shift: input.shift, + }; const belongsToBrowserPolicy = - policy !== undefined && - matchesBrowserShortcutPolicy(policy, { - alt: input.alt, - code: input.code, - control: input.control, - key: input.key, - meta: input.meta, - repeat: input.isAutoRepeat, - shift: input.shift, - }); + policy !== undefined && matchesBrowserShortcutPolicy(policy, matchInput); + const belongsToMenuPolicy = + policy !== undefined && matchesBrowserShortcutPrefixes(policy.menuPrefixes, matchInput); guest.contents.setIgnoreMenuShortcuts( - (!input.control && !input.meta) || belongsToBrowserPolicy, + (!input.control && !input.meta) || belongsToBrowserPolicy || belongsToMenuPolicy, ); const reservedShortcut = classifyBrowserReservedShortcut(input, { isMac: process.platform === "darwin", @@ -182,7 +185,6 @@ export class BrowserKeyboard { } return; case "focus-url": - case "new-tab": event.preventDefault(); if (!guest.hostContents.isDestroyed()) { guest.hostContents.send(RESERVED_SHORTCUT_OUTPUT_CHANNEL, { diff --git a/packages/desktop/src/features/browser-keyboard/policy.test.ts b/packages/desktop/src/features/browser-keyboard/policy.test.ts index 3b832c7e9..53b89377f 100644 --- a/packages/desktop/src/features/browser-keyboard/policy.test.ts +++ b/packages/desktop/src/features/browser-keyboard/policy.test.ts @@ -22,10 +22,10 @@ describe("browser keyboard policy", () => { expect( macInputs.map((input) => classifyBrowserReservedShortcut(input, { isMac: true })), - ).toEqual(["new-tab", "focus-url", "reload", "force-reload"]); + ).toEqual([null, "focus-url", "reload", "force-reload"]); expect( nonMacInputs.map((input) => classifyBrowserReservedShortcut(input, { isMac: false })), - ).toEqual(["new-tab", "focus-url", "reload", "force-reload"]); + ).toEqual([null, "focus-url", "reload", "force-reload"]); }); test("rejects the wrong or ambiguous command modifier for reserved shortcuts", () => { @@ -65,11 +65,17 @@ describe("browser keyboard policy", () => { test("accepts only complete modifier prefixes from the host renderer", () => { expect( parseBrowserKeyboardPolicy({ + menuPrefixes: [ + { code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false }, + ], prefixes: [ { code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false }, ], }), ).toEqual({ + menuPrefixes: [ + { code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false }, + ], prefixes: [ { code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false }, ], @@ -80,6 +86,7 @@ describe("browser keyboard policy", () => { test("rejects a false code fallback instead of treating it as absent", () => { expect( parseBrowserKeyboardPolicy({ + menuPrefixes: [], prefixes: [ { alt: false, @@ -110,6 +117,7 @@ describe("browser keyboard policy", () => { test("matches digit shortcuts for the top row and numeric keypad", () => { const policy = parseBrowserKeyboardPolicy({ + menuPrefixes: [], prefixes: [ { alt: false, code: "Digit", control: true, meta: false, repeat: false, shift: false }, ], diff --git a/packages/desktop/src/features/browser-keyboard/policy.ts b/packages/desktop/src/features/browser-keyboard/policy.ts index b7981a74a..2f972cbd4 100644 --- a/packages/desktop/src/features/browser-keyboard/policy.ts +++ b/packages/desktop/src/features/browser-keyboard/policy.ts @@ -11,6 +11,7 @@ export interface BrowserShortcutPrefix { } export interface BrowserKeyboardPolicy { + menuPrefixes: BrowserShortcutPrefix[]; prefixes: BrowserShortcutPrefix[]; } @@ -35,7 +36,7 @@ export interface BrowserShortcutMatchInput { shift: boolean; } -export type BrowserReservedShortcut = "new-tab" | "focus-url" | "reload" | "force-reload"; +export type BrowserReservedShortcut = "focus-url" | "reload" | "force-reload"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -72,19 +73,28 @@ function parsePrefix(value: unknown): BrowserShortcutPrefix | null { }; } -export function parseBrowserKeyboardPolicy(value: unknown): BrowserKeyboardPolicy | null { - if (!isRecord(value) || !Array.isArray(value.prefixes)) { +function parsePrefixes(value: unknown): BrowserShortcutPrefix[] | null { + if (!Array.isArray(value)) { return null; } const prefixes: BrowserShortcutPrefix[] = []; - for (const entry of value.prefixes) { + for (const entry of value) { const prefix = parsePrefix(entry); if (!prefix) { return null; } prefixes.push(prefix); } - return { prefixes }; + return prefixes; +} + +export function parseBrowserKeyboardPolicy(value: unknown): BrowserKeyboardPolicy | null { + if (!isRecord(value)) { + return null; + } + const menuPrefixes = parsePrefixes(value.menuPrefixes); + const prefixes = parsePrefixes(value.prefixes); + return menuPrefixes && prefixes ? { menuPrefixes, prefixes } : null; } export function parseBrowserShortcutInput(value: unknown): BrowserShortcutInput | null { @@ -145,11 +155,18 @@ function matchesPrefix(prefix: BrowserShortcutPrefix, input: BrowserShortcutMatc return (prefix.alt || prefix.codeFallback === true) && matchesCode(prefix.code, input.code); } +export function matchesBrowserShortcutPrefixes( + prefixes: BrowserShortcutPrefix[], + input: BrowserShortcutMatchInput, +): boolean { + return prefixes.some((prefix) => matchesPrefix(prefix, input)); +} + export function matchesBrowserShortcutPolicy( policy: BrowserKeyboardPolicy, input: BrowserShortcutMatchInput, ): boolean { - return policy.prefixes.some((prefix) => matchesPrefix(prefix, input)); + return matchesBrowserShortcutPrefixes(policy.prefixes, input); } export function classifyBrowserReservedShortcut( @@ -172,7 +189,6 @@ export function classifyBrowserReservedShortcut( return null; } const key = input.key.toLowerCase(); - if (!input.shift && key === "t") return "new-tab"; if (!input.shift && key === "l") return "focus-url"; if (key !== "r") return null; return input.shift ? "force-reload" : "reload";