authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 17:04:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 17:04:23-07:00
loge0e0bcb115fa29a2afc670b817fcb4463b620923
treea943c187043cb78b9c973faae6e1b1ee39e9c109
parent8de9169f078fdafedf47d8c299cb8e2c79fda6fb
signature Signed by PGP key B5690EEEBB952194

fix(desktop): preserve browser editing shortcuts (#10621)


2 files changed, 155 insertions(+), 8 deletions(-)

apps/desktop/src/preview/Manager.test.ts+117-6
......@@ -67,6 +67,70 @@ describe("isPreviewRefreshShortcut", () => {
6767 });
6868});
6969
70describe("isPreviewEditingShortcut", () => {
71 const input = (platform: NodeJS.Platform, key: string, overrides: Partial<Electron.Input> = {}) =>
72 ({
73 type: "keyDown",
74 key,
75 meta: platform === "darwin",
76 control: platform !== "darwin",
77 shift: false,
78 alt: false,
79 ...overrides,
80 }) as Electron.Input;
81
82 it.each(["darwin", "linux", "win32"] as const)(
83 "allows native editing chords on %s without allowing host shortcuts",
84 (platform) => {
85 for (const key of ["a", "c", "v", "x", "z", "V"]) {
86 expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(true);
87 }
88 const redo =
89 platform === "win32" ? input(platform, "y") : input(platform, "z", { shift: true });
90 expect(PreviewManager.isPreviewEditingShortcut(redo, platform)).toBe(true);
91 expect(
92 PreviewManager.isPreviewEditingShortcut(
93 input(platform, "v", { shift: true, alt: platform === "darwin" }),
94 platform,
95 ),
96 ).toBe(true);
97
98 for (const key of ["k", ",", "w", "j", "q", "+", "=", "-", "0", "r", "F12"]) {
99 expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(false);
100 }
101 for (const modifiers of [
102 { meta: false, control: false },
103 { meta: true, control: true },
104 { meta: platform !== "darwin", control: platform === "darwin" },
105 { alt: true },
106 { shift: true, alt: platform !== "darwin" },
107 ]) {
108 expect(
109 PreviewManager.isPreviewEditingShortcut(input(platform, "v", modifiers), platform),
110 ).toBe(false);
111 }
112 expect(
113 PreviewManager.isPreviewEditingShortcut(input(platform, "a", { shift: true }), platform),
114 ).toBe(false);
115 },
116 );
117
118 it("recognizes macOS Paste and Match Style when Option changes the key to a symbol", () => {
119 const pasteAndMatchStyle = input("darwin", "◊", { code: "KeyV", alt: true, shift: true });
120 expect(PreviewManager.isPreviewEditingShortcut(pasteAndMatchStyle, "darwin")).toBe(true);
121 for (const modifiers of [
122 { code: "KeyC" },
123 { alt: false },
124 { shift: false },
125 { control: true },
126 ]) {
127 expect(
128 PreviewManager.isPreviewEditingShortcut({ ...pasteAndMatchStyle, ...modifiers }, "darwin"),
129 ).toBe(false);
130 }
131 });
132});
133
70134describe("previewWindowOpenAction", () => {
71135 const details = (overrides: {
72136 readonly url?: string;
......@@ -484,6 +548,7 @@ describe("PreviewManager", () => {
484548 const hostWebContents = { sendInputEvent };
485549 Object.assign(preview.webContents, { hostWebContents });
486550 fromId.mockReturnValue(preview.webContents);
551 getFocusedWebContents.mockReturnValue(preview.webContents as never);
487552 yield* manager.setMainWindow({
488553 isDestroyed: () => false,
489554 once: vi.fn(),
......@@ -497,7 +562,7 @@ describe("PreviewManager", () => {
497562 ).toHaveBeenCalledWith(true);
498563 const beforeInput = preview.listeners.get("before-input-event")!;
499564 for (const control of [false, true]) {
500 for (const key of ["k", ",", "w", "j", "q", "+", "a", "c", "v", "x"]) {
565 for (const key of ["k", ",", "w", "j", "q", "+"]) {
501566 for (const type of ["keyDown", "keyUp"]) {
502567 const preventDefault = vi.fn();
503568 beforeInput(
......@@ -506,6 +571,9 @@ describe("PreviewManager", () => {
506571 );
507572 yield* Effect.yieldNow;
508573 expect(preventDefault).not.toHaveBeenCalled();
574 expect(
575 (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts,
576 ).toHaveBeenLastCalledWith(true);
509577 }
510578 }
511579 }
......@@ -527,12 +595,55 @@ describe("PreviewManager", () => {
527595 expect(preventDefault).toHaveBeenCalledOnce();
528596 expect(preview.reload).toHaveBeenCalledOnce();
529597 expect(sendInputEvent).not.toHaveBeenCalled();
598 }),
599 ),
600 );
530601
531 const setIgnoreMenuShortcuts = vi.fn();
532 preview.listeners.get("did-create-window")!({
533 webContents: { setIgnoreMenuShortcuts, setWindowOpenHandler: vi.fn() },
534 } as never);
535 expect(setIgnoreMenuShortcuts).toHaveBeenCalledWith(true);
602 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>
603 withManager((manager) =>
604 Effect.gen(function* () {
605 const preview = makeFaviconWebContents();
606 fromId.mockReturnValue(preview.webContents);
607 yield* manager.createTab("tab_editing");
608 yield* manager.registerWebview("tab_editing", 42);
609
610 const popup = makeFaviconWebContents({ id: 43 });
611 preview.listeners.get("did-create-window")!({ webContents: popup.webContents } as never);
612 expect(
613 (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts,
614 ).toHaveBeenCalledWith(true);
615
616 for (const browser of [preview, popup]) {
617 const contents = browser.webContents as Electron.WebContents;
618 getFocusedWebContents.mockReturnValue(browser.webContents as never);
619 const beforeInput = browser.listeners.get("before-input-event")!;
620 const preventDefault = vi.fn();
621 const input = {
622 type: "keyDown",
623 key: "v",
624 meta: true,
625 control: false,
626 shift: false,
627 alt: false,
628 };
629 beforeInput({ preventDefault } as never, input as never);
630 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false);
631 // Releasing Command must not disable native fallback for the pending paste.
632 beforeInput(
633 { preventDefault } as never,
634 { ...input, type: "keyUp", key: "Meta", meta: false } as never,
635 );
636 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false);
637
638 beforeInput({ preventDefault } as never, { ...input, key: "w" } as never);
639 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true);
640
641 // An injected paste in an unfocused guest cannot edit the active renderer.
642 getFocusedWebContents.mockReturnValue(null);
643 beforeInput({ preventDefault } as never, input as never);
644 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true);
645 expect(preventDefault).not.toHaveBeenCalled();
646 }
536647 }),
537648 ),
538649 );
apps/desktop/src/preview/Manager.ts+38-2
......@@ -535,6 +535,29 @@ export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
535535 !input.shift &&
536536 !input.alt;
537537
538export const isPreviewEditingShortcut = (
539 input: Electron.Input,
540 platform: NodeJS.Platform,
541): boolean => {
542 const isMac = platform === "darwin";
543 if (isMac ? !input.meta || input.control : !input.control || input.meta) return false;
544
545 const key = input.key.toLowerCase();
546 // Option changes the DOM key for macOS Paste and Match Style (for example, to ◊).
547 if (isMac && input.alt && input.shift && input.code === "KeyV") return true;
548 if (key === "v" && input.shift) return input.alt === isMac;
549 if (input.alt) return false;
550 if (key === "z") return !input.shift || platform !== "win32";
551 if (input.shift) return false;
552 return (
553 key === "a" ||
554 key === "c" ||
555 key === "v" ||
556 key === "x" ||
557 (key === "y" && platform === "win32")
558 );
559};
560
538561const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
539562 if (typeof value !== "object" || value === null || !("kind" in value)) return false;
540563 if (value.kind === "pointer") {
......@@ -1837,14 +1860,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18371860 }).pipe(Effect.ignore),
18381861 );
18391862 };
1863 const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => {
1864 if (input.type !== "keyDown") return;
1865 // Native editing roles must remain available after the page handles the key.
1866 // Background automation must not edit whichever other renderer has focus.
1867 contents.setIgnoreMenuShortcuts(
1868 !isPreviewEditingShortcut(input, hostPlatform) ||
1869 webContents.getFocusedWebContents() !== contents,
1870 );
1871 };
18401872 // A popup opens with Electron's default handler, so the page inside it could
18411873 // otherwise spawn native windows without limit. Nothing in an OAuth flow
18421874 // opens a second popup, so the chain stops at the first one.
18431875 const windowCreated = (window: Electron.BrowserWindow): void => {
18441876 window.webContents.setIgnoreMenuShortcuts(true);
18451877 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
1878 window.webContents.on("before-input-event", (_event, input) => {
1879 syncMenuShortcuts(window.webContents, input);
1880 });
18461881 };
18471882 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1883 syncMenuShortcuts(wc, input);
18481884 if (isPreviewRefreshShortcut(input)) {
18491885 event.preventDefault();
18501886 runFork(
......@@ -1876,8 +1912,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18761912 );
18771913 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
18781914 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
1879 // Preview input belongs to the page, including keys injected through CDP.
1880 // Never let it invoke the host application's menu accelerators.
1915 // Only focused native editing shortcuts may reach the application menu.
1916 // Other preview input, including CDP keys, belongs to the page.
18811917 wc.setIgnoreMenuShortcuts(true);
18821918 wc.on("did-start-navigation", navigationStarted);
18831919 wc.on("did-navigate", syncNavigation);