Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions apps/desktop/src/preload.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

function waitForPointerRelease(): Promise<boolean> {

showContextMenu can hang indefinitely when a right-click drag releases the mouse button outside the window. Without implicit pointer capture, the window may never receive pointerup, pointercancel, or blur for that gesture, so pointerButtonsHeld stays nonzero and waitForPointerRelease never resolves. Every subsequent context-menu call is then permanently gated until an unrelated blur or pointercancel resets the state. Consider adding a timeout fallback in waitForPointerRelease so the menu can still open if the release is not observed within a reasonable window.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/preload.ts around line 51:

`showContextMenu` can hang indefinitely when a right-click drag releases the mouse button outside the window. Without implicit pointer capture, the window may never receive `pointerup`, `pointercancel`, or `blur` for that gesture, so `pointerButtonsHeld` stays nonzero and `waitForPointerRelease` never resolves. Every subsequent context-menu call is then permanently gated until an unrelated `blur` or `pointercancel` resets the state. Consider adding a timeout fallback in `waitForPointerRelease` so the menu can still open if the release is not observed within a reasonable window.

Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,52 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) {
return result as Awaited<ReturnType<DesktopBridge["ensureSshEnvironment"]>>;
}

// 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));
}

// 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<boolean> {
return new Promise((resolve) => {
const buttonsAtRequest = pointerButtonsHeld;
if (buttonsAtRequest === 0) {
resolve(true);
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}
const finish = (proceed: boolean) => {
window.removeEventListener("pointerup", onPointerUp, true);
window.removeEventListener("pointercancel", onAbort, true);
window.removeEventListener("blur", onAbort);
resolve(proceed);
};
const onPointerUp = (event: PointerEvent) => {
if ((event.buttons & buttonsAtRequest) === 0) {
finish(true);
}
Comment thread
cursor[bot] marked this conversation as resolved.
};
const onAbort = () => finish(false);
window.addEventListener("pointerup", onPointerUp, true);
window.addEventListener("pointercancel", onAbort, true);
window.addEventListener("blur", onAbort);
});
}

contextBridge.exposeInMainWorld("desktopBridge", {
getAppBranding: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL);
Expand Down Expand Up @@ -99,11 +145,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 null;
}
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) => {
Expand Down
Loading