From 94840df227da9fc0913ff82e1ad8b0689e5c9ad2 Mon Sep 17 00:00:00 2001 From: xxashxx-svg Date: Sat, 11 Jul 2026 01:46:07 +0530 Subject: [PATCH 1/2] fix(desktop): stop Linux context menus from activating an item on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, Chromium fires contextmenu on right-button PRESS (Windows fires it on release), so the native menu pops up while the button is still physically held. The menu takes the pointer grab, and the tail end of the opening right-click — the button release — lands on whatever item sits under the cursor and activates it. The menu flashes and a random item runs, which also explains the known workaround (hold the right button and drag away before releasing). The earlier fix (#3025) hardened contextMenuFallback.ts, but the desktop app never uses that path — window.desktopBridge routes context menus to the native Electron menu, so the regression survived. Gate the desktopBridge.showContextMenu IPC on pointer release (Linux only): track button state in the preload and defer opening the menu until the press that triggered it is released, matching Windows semantics. Menus opened with no button held (keyboard, click-triggered) are unaffected, as are Windows and macOS. Fixes #3698 Co-Authored-By: Claude Fable 5 --- apps/desktop/src/preload.ts | 49 ++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4b5c0d2f656..960e27d446b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -27,6 +27,45 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { return result as Awaited>; } +// On Linux, Chromium fires `contextmenu` on right-button PRESS (Windows fires it +// on release). Popping the native menu while the button is still held makes the +// menu grab the pointer, and the subsequent release "clicks" whatever item sits +// under the cursor — a random item activates the instant the menu opens (#3698). +// Track pointer-button state and delay opening the menu until the press that +// triggered it has been released, matching the Windows semantics. +// oxlint-disable-next-line t3code/no-global-process-runtime -- Preload script has no Effect runtime. +const gateContextMenuOnPointerRelease = process.platform === "linux"; +let pointerButtonsHeld = 0; +if (gateContextMenuOnPointerRelease) { + window.addEventListener("pointerdown", (event) => (pointerButtonsHeld = event.buttons), true); + window.addEventListener("pointerup", (event) => (pointerButtonsHeld = event.buttons), true); + window.addEventListener("pointercancel", () => (pointerButtonsHeld = 0), true); + window.addEventListener("blur", () => (pointerButtonsHeld = 0)); +} + +function waitForPointerRelease(): Promise { + return new Promise((resolve) => { + if (pointerButtonsHeld === 0) { + resolve(); + return; + } + const settle = () => { + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointercancel", settle, true); + window.removeEventListener("blur", settle); + resolve(); + }; + const onPointerUp = (event: PointerEvent) => { + if (event.buttons === 0) { + settle(); + } + }; + window.addEventListener("pointerup", onPointerUp, true); + window.addEventListener("pointercancel", settle, true); + window.addEventListener("blur", settle); + }); +} + contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); @@ -99,11 +138,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => - ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { + showContextMenu: async (items, position) => { + if (gateContextMenuOnPointerRelease) { + await waitForPointerRelease(); + } + return ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, ...(position === undefined ? {} : { position }), - }), + }); + }, openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { From d82d5685a9cb68f7fccb3497135cdfe629b12d78 Mon Sep 17 00:00:00 2001 From: xxashxx-svg Date: Sat, 11 Jul 2026 17:46:46 +0530 Subject: [PATCH 2/2] fix(desktop): handle partial button release and cancelled gestures in context menu gate Two review findings from Bugbot on the pointer-release gate: - Waiting for event.buttons === 0 meant a second held button (e.g. left held while right-clicking) kept the menu waiting until every button was released. Track the buttons held at request time and settle once those are up, ignoring buttons pressed mid-gesture. - blur/pointercancel previously settled the wait and opened the menu, which could pop it mid-hold (re-triggering the original bug) or at stale coordinates after focus loss. Treat those as gesture cancellation: resolve null without opening the menu, matching the bridge's existing 'no selection' contract. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/preload.ts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 960e27d446b..6fae993a375 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -43,26 +43,33 @@ if (gateContextMenuOnPointerRelease) { window.addEventListener("blur", () => (pointerButtonsHeld = 0)); } -function waitForPointerRelease(): Promise { +// Resolves true once every button held at request time is released (buttons +// pressed mid-gesture don't keep the menu waiting), or false when the gesture +// is cancelled (pointercancel / focus loss) — opening a menu then would either +// land mid-hold (re-triggering #3698) or appear at stale coordinates the user +// no longer expects. +function waitForPointerRelease(): Promise { return new Promise((resolve) => { - if (pointerButtonsHeld === 0) { - resolve(); + const buttonsAtRequest = pointerButtonsHeld; + if (buttonsAtRequest === 0) { + resolve(true); return; } - const settle = () => { + const finish = (proceed: boolean) => { window.removeEventListener("pointerup", onPointerUp, true); - window.removeEventListener("pointercancel", settle, true); - window.removeEventListener("blur", settle); - resolve(); + window.removeEventListener("pointercancel", onAbort, true); + window.removeEventListener("blur", onAbort); + resolve(proceed); }; const onPointerUp = (event: PointerEvent) => { - if (event.buttons === 0) { - settle(); + if ((event.buttons & buttonsAtRequest) === 0) { + finish(true); } }; + const onAbort = () => finish(false); window.addEventListener("pointerup", onPointerUp, true); - window.addEventListener("pointercancel", settle, true); - window.addEventListener("blur", settle); + window.addEventListener("pointercancel", onAbort, true); + window.addEventListener("blur", onAbort); }); } @@ -139,8 +146,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: async (items, position) => { - if (gateContextMenuOnPointerRelease) { - await waitForPointerRelease(); + if (gateContextMenuOnPointerRelease && !(await waitForPointerRelease())) { + return null; } return ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items,