From d34e0ac435f72d03356c17972cedee043ace747a Mon Sep 17 00:00:00 2001 From: jSydorowicz21 Date: Mon, 8 Jun 2026 13:31:11 -0500 Subject: [PATCH 1/3] fix(browser-tab): paste into webview form fields via guest.paste() on cmd/ctrl+v --- .../main/app-lifecycle/window-manager.test.ts | 113 ++++++++++++++++++ src/main/app-lifecycle/window-manager.ts | 24 +++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/src/__tests__/main/app-lifecycle/window-manager.test.ts b/src/__tests__/main/app-lifecycle/window-manager.test.ts index 02fc055c80..6ed15d6497 100644 --- a/src/__tests__/main/app-lifecycle/window-manager.test.ts +++ b/src/__tests__/main/app-lifecycle/window-manager.test.ts @@ -1263,6 +1263,119 @@ describe('app-lifecycle/window-manager', () => { ); }); + it('pastes into browser-tab form fields via guest.paste() on Cmd/Ctrl+V (#1063)', async () => { + // The permission handler denies `clipboard-read` to webviews, so + // Chromium's native Cmd/Ctrl+V silently fails inside browser-tab form + // fields. The before-input-event handler must intercept the paste chord + // and drive the privileged guest.paste() instead. + const { createWindowManager } = await import('../../../main/app-lifecycle/window-manager'); + + const windowManager = createWindowManager({ + windowStateStore: mockWindowStateStore as unknown as Parameters< + typeof createWindowManager + >[0]['windowStateStore'], + isDevelopment: false, + preloadPath: '/path/to/preload.js', + rendererProductionUrl: 'app://app/index.html', + devServerUrl: 'http://localhost:5173', + useNativeTitleBar: false, + autoHideMenuBar: false, + }); + + windowManager.createWindow(); + + const attachHandler = webContentsEventHandlers.get('did-attach-webview'); + attachHandler?.({} as any, mockGuestWebContents as any); + + const beforeInputHandler = guestWebContentsEventHandlers.get('before-input-event'); + expect(beforeInputHandler).toBeDefined(); + + // Cmd+V (macOS) + const metaEvent = { preventDefault: vi.fn() }; + beforeInputHandler?.(metaEvent, { + type: 'keyDown', + key: 'v', + code: 'KeyV', + meta: true, + control: false, + alt: false, + shift: false, + }); + expect(metaEvent.preventDefault).toHaveBeenCalled(); + expect(mockGuestWebContents.paste).toHaveBeenCalledTimes(1); + + // Ctrl+V (Windows/Linux) + const ctrlEvent = { preventDefault: vi.fn() }; + beforeInputHandler?.(ctrlEvent, { + type: 'keyDown', + key: 'v', + code: 'KeyV', + meta: false, + control: true, + alt: false, + shift: false, + }); + expect(ctrlEvent.preventDefault).toHaveBeenCalled(); + expect(mockGuestWebContents.paste).toHaveBeenCalledTimes(2); + + // The paste chord must NOT also be forwarded to the renderer as an app + // shortcut - it is fully consumed here. + expect(mockWebContents.send).not.toHaveBeenCalledWith( + 'browser-tab:shortcutKey', + expect.objectContaining({ key: 'v' }) + ); + }); + + it('does not hijack non-paste edit chords or plain "v" on browser-tab guests', async () => { + const { createWindowManager } = await import('../../../main/app-lifecycle/window-manager'); + + const windowManager = createWindowManager({ + windowStateStore: mockWindowStateStore as unknown as Parameters< + typeof createWindowManager + >[0]['windowStateStore'], + isDevelopment: false, + preloadPath: '/path/to/preload.js', + rendererProductionUrl: 'app://app/index.html', + devServerUrl: 'http://localhost:5173', + useNativeTitleBar: false, + autoHideMenuBar: false, + }); + + windowManager.createWindow(); + + const attachHandler = webContentsEventHandlers.get('did-attach-webview'); + attachHandler?.({} as any, mockGuestWebContents as any); + + const beforeInputHandler = guestWebContentsEventHandlers.get('before-input-event'); + + // Plain "v" (no modifier) is normal typing - must pass through untouched. + const plainEvent = { preventDefault: vi.fn() }; + beforeInputHandler?.(plainEvent, { + type: 'keyDown', + key: 'v', + code: 'KeyV', + meta: false, + control: false, + alt: false, + shift: false, + }); + expect(plainEvent.preventDefault).not.toHaveBeenCalled(); + + // Cmd+Shift+V (paste-and-match-style etc.) is not the plain paste chord. + const shiftEvent = { preventDefault: vi.fn() }; + beforeInputHandler?.(shiftEvent, { + type: 'keyDown', + key: 'v', + code: 'KeyV', + meta: true, + control: false, + alt: false, + shift: true, + }); + + expect(mockGuestWebContents.paste).not.toHaveBeenCalled(); + }); + // Electron 41 removed the legacy `'crashed'` event in favor of // `'render-process-gone'`. These tests pin the wiring so a future // revert can't silently drop renderer-crash reporting. diff --git a/src/main/app-lifecycle/window-manager.ts b/src/main/app-lifecycle/window-manager.ts index fb8b59d086..d467e758c1 100644 --- a/src/main/app-lifecycle/window-manager.ts +++ b/src/main/app-lifecycle/window-manager.ts @@ -54,6 +54,9 @@ interface BrowserTabGuestContents { ): void; on(event: string, handler: (...args: any[]) => void): void; executeJavaScript(code: string): Promise; + // Privileged Electron paste: bypasses the web-facing `clipboard-read` + // permission that the permission handler denies to webviews (issue #1063). + paste(): void; } function isAllowedBrowserTabUrl(rawUrl: string): boolean { @@ -354,11 +357,24 @@ export function createWindowManager(deps: WindowManagerDependencies): WindowMana if (!input.meta && !input.control && !input.alt) return; if (input.type !== 'keyDown') return; const k = input.key.toLowerCase(); - // Let standard text-editing shortcuts pass through to the page. - // `f` is intentionally NOT in this list: Cmd+F must reach the - // renderer so the in-page find bar can open. + // Cmd/Ctrl+V: drive paste through the trusted guest webContents API. + // Chromium's native paste needs the `clipboard-read` permission, which + // the permission handler denies to webviews as a security boundary, so + // native paste silently fails inside browser-tab form fields (issue + // #1063). guest.paste() is a privileged Electron call that bypasses + // that web-facing permission, mirroring the right-click Paste menu + // item (issue #1065). + const isPaste = (input.meta || input.control) && !input.alt && !input.shift && k === 'v'; + if (isPaste) { + event.preventDefault(); + guest.paste(); + return; + } + // Let the remaining standard text-editing shortcuts pass through to + // the page. `f` is intentionally NOT in this list: Cmd+F must reach + // the renderer so the in-page find bar can open. const isTextEditing = - (input.meta || input.control) && !input.alt && !input.shift && 'acvxz'.includes(k); + (input.meta || input.control) && !input.alt && !input.shift && 'acxz'.includes(k); const isRedo = (input.meta || input.control) && !input.alt && input.shift && k === 'z'; if (isTextEditing || isRedo) return; event.preventDefault(); From 26893e0f455c09bca89c59c5601d9f273b5a4bc7 Mon Sep 17 00:00:00 2001 From: jSydorowicz21 Date: Mon, 8 Jun 2026 14:35:42 -0500 Subject: [PATCH 2/3] fix(browser-tab): drop v from injected-JS passthrough list to match before-input-event --- src/__tests__/main/app-lifecycle/window-manager.test.ts | 8 ++++++++ src/main/app-lifecycle/window-manager.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/__tests__/main/app-lifecycle/window-manager.test.ts b/src/__tests__/main/app-lifecycle/window-manager.test.ts index 6ed15d6497..ddebbb8046 100644 --- a/src/__tests__/main/app-lifecycle/window-manager.test.ts +++ b/src/__tests__/main/app-lifecycle/window-manager.test.ts @@ -22,6 +22,7 @@ const mockGuestWebContents = { guestWebContentsEventHandlers.set(event, handler); }), executeJavaScript: vi.fn().mockResolvedValue(undefined), + paste: vi.fn(), }; // Mock BrowserWindow instance methods @@ -1324,6 +1325,13 @@ describe('app-lifecycle/window-manager', () => { 'browser-tab:shortcutKey', expect.objectContaining({ key: 'v' }) ); + + // The page-level fallback must also exclude V from its passthrough + // list. Otherwise it can race the privileged paste path above. + guestWebContentsEventHandlers.get('dom-ready')?.(); + const injectedScript = mockGuestWebContents.executeJavaScript.mock.calls.at(-1)?.[0]; + expect(injectedScript).toContain("'acxz'.indexOf(k)"); + expect(injectedScript).not.toContain("'acvxz'.indexOf(k)"); }); it('does not hijack non-paste edit chords or plain "v" on browser-tab guests', async () => { diff --git a/src/main/app-lifecycle/window-manager.ts b/src/main/app-lifecycle/window-manager.ts index d467e758c1..6575eee00f 100644 --- a/src/main/app-lifecycle/window-manager.ts +++ b/src/main/app-lifecycle/window-manager.ts @@ -399,7 +399,7 @@ export function createWindowManager(deps: WindowManagerDependencies): WindowMana var hasAlt=e.altKey; if(!hasMod&&!hasAlt)return; var k=e.key.toLowerCase(); - var te=hasMod&&!hasAlt&&!e.shiftKey&&'acvxz'.indexOf(k)!==-1; + var te=hasMod&&!hasAlt&&!e.shiftKey&&'acxz'.indexOf(k)!==-1; var re=hasMod&&!hasAlt&&e.shiftKey&&k==='z'; if(te||re)return; e.preventDefault(); From 2e1b36a49cb61c502341a5b5e844d7a03f53866b Mon Sep 17 00:00:00 2001 From: jSydorowicz21 Date: Tue, 9 Jun 2026 13:01:02 -0500 Subject: [PATCH 3/3] fix(browser-tab): align capture-phase keydown allowlist (drop v) in BrowserTabView --- .../main/app-lifecycle/window-manager.test.ts | 9 +++++++++ src/renderer/components/MainPanel/BrowserTabView.tsx | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/__tests__/main/app-lifecycle/window-manager.test.ts b/src/__tests__/main/app-lifecycle/window-manager.test.ts index ddebbb8046..f2c107f2de 100644 --- a/src/__tests__/main/app-lifecycle/window-manager.test.ts +++ b/src/__tests__/main/app-lifecycle/window-manager.test.ts @@ -1381,7 +1381,16 @@ describe('app-lifecycle/window-manager', () => { shift: true, }); + // Cmd+Shift+V is not the plain paste chord, so the handler must NOT + // drive the privileged paste path. It is also not a text-editing + // passthrough, so it is consumed (preventDefault) and forwarded to the + // renderer as an app shortcut rather than reaching the page. expect(mockGuestWebContents.paste).not.toHaveBeenCalled(); + expect(shiftEvent.preventDefault).toHaveBeenCalled(); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'browser-tab:shortcutKey', + expect.objectContaining({ key: 'v', shift: true }) + ); }); // Electron 41 removed the legacy `'crashed'` event in favor of diff --git a/src/renderer/components/MainPanel/BrowserTabView.tsx b/src/renderer/components/MainPanel/BrowserTabView.tsx index 8a373b37ea..0fe7bce3d7 100644 --- a/src/renderer/components/MainPanel/BrowserTabView.tsx +++ b/src/renderer/components/MainPanel/BrowserTabView.tsx @@ -378,9 +378,11 @@ export const BrowserTabView = React.memo( // Uses stopImmediatePropagation to prevent any other listener // (including the main-process-injected bubble-phase one) from // double-firing. - // `f` is intentionally NOT in the text-editing pass-through list: Cmd+F - // must reach the app so the in-page find bar can open. The remaining - // letters (a/c/v/x/z) keep their native text-editing behavior inside + // `f` and `v` are intentionally NOT in the text-editing pass-through + // list. Cmd+F must reach the app so the in-page find bar can open, and + // Cmd/Ctrl+V is handled separately via the privileged paste path + // (guest.paste()) so paste works inside the webview. The remaining + // letters (a/c/x/z) keep their native text-editing behavior inside // page inputs. const keyboardInjection = `(function(){ if(window.__maestroShortcutCaptureInstalled)return; @@ -390,7 +392,7 @@ export const BrowserTabView = React.memo( var hasAlt=e.altKey; if(!hasMod&&!hasAlt)return; var k=e.key.toLowerCase(); - var te=hasMod&&!hasAlt&&!e.shiftKey&&'acvxz'.indexOf(k)!==-1; + var te=hasMod&&!hasAlt&&!e.shiftKey&&'acxz'.indexOf(k)!==-1; var re=hasMod&&!hasAlt&&e.shiftKey&&k==='z'; if(te||re)return; e.preventDefault();