From 28a07978dca77c124ebea2376008099983ed72db Mon Sep 17 00:00:00 2001 From: Wendel Toews Date: Tue, 10 Feb 2026 18:03:23 -0500 Subject: [PATCH] fix(linux): hide overlay before paste on GNOME Wayland to prevent focus steal On GNOME Wayland with FLOATING_ICON_AUTO_HIDE enabled, BrowserWindow.show() steals focus from the target window. Since ydotool sends keystrokes to the focused window, this causes paste keystrokes to go to the OpenWhispr overlay instead of the user's application (e.g. terminal). Fix by temporarily hiding the overlay before pasting, allowing GNOME to return focus to the target window. The overlay is re-shown after paste if auto-hide is not enabled. Also restores AT-SPI2 terminal detection and GNOME-specific ydotool-first paste tool ordering that were removed in #221, and adds 'open-whispr' to the AT-SPI2 skip set as a safety net. Co-Authored-By: Claude Opus 4.6 --- src/helpers/clipboard.js | 56 ++++++++++++++++++++++++++++++++++++-- src/helpers/ipcHandlers.js | 26 +++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/helpers/clipboard.js b/src/helpers/clipboard.js index 8d0a23e9..e19fca5f 100644 --- a/src/helpers/clipboard.js +++ b/src/helpers/clipboard.js @@ -651,6 +651,48 @@ class ClipboardManager { "yakuake", ]; + // On GNOME Wayland, xdotool can't see native Wayland windows and returns + // OpenWhispr's own window instead. Use AT-SPI2 accessibility API first, + // which works for both native Wayland and XWayland windows. + if (isGnome && isWayland) { + try { + const atspiScript = `import gi +gi.require_version('Atspi','2.0') +from gi.repository import Atspi +d=Atspi.get_desktop(0) +skip={'gnome-shell','open-whispr',''} +for i in range(d.get_child_count()): + a=d.get_child_at_index(i) + if not a:continue + n=a.get_name() + if n.lower() in skip:continue + for j in range(a.get_child_count()): + w=a.get_child_at_index(j) + if w and w.get_state_set().contains(Atspi.StateType.ACTIVE): + print(n.lower());raise SystemExit(0)`; + const result = spawnSync("python3", ["-c", atspiScript], { + timeout: 1000, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.status === 0) { + const appName = result.stdout.toString().trim(); + if (appName) { + const isTerminalWindow = terminalClasses.some((term) => appName.includes(term)); + this.safeLog( + isTerminalWindow + ? `🖥️ Terminal detected via AT-SPI2: ${appName}` + : `🪟 Non-terminal detected via AT-SPI2: ${appName}` + ); + return isTerminalWindow; + } + } + } catch { + // AT-SPI2 detection failed, continue to other methods + } + } + + // xdotool window class detection (reliable on X11 and for XWayland apps + // on non-GNOME Wayland; skipped on GNOME Wayland where AT-SPI2 is used above) if (xdotoolWindowClass) { const isTerminalWindow = terminalClasses.some((term) => xdotoolWindowClass.includes(term)); if (isTerminalWindow) { @@ -704,6 +746,9 @@ class ClipboardManager { ? ["key", "29:1", "42:1", "47:1", "47:0", "42:0", "29:0"] : ["key", "29:1", "47:1", "47:0", "29:0"]; + // On GNOME Wayland, prefer ydotool over xdotool because xdotool can only + // interact with XWayland windows and may target the wrong window (e.g. OpenWhispr + // itself) while reporting success, preventing fallback to ydotool. const candidates = [ ...(canUseWtype ? [ @@ -715,8 +760,15 @@ class ClipboardManager { : { cmd: "wtype", args: ["-M", "ctrl", "-k", "v", "-m", "ctrl"] }, ] : []), - ...(canUseXdotool ? [{ cmd: "xdotool", args: xdotoolArgs }] : []), - ...(canUseYdotool ? [{ cmd: "ydotool", args: ydotoolArgs }] : []), + ...(isGnome && isWayland + ? [ + ...(canUseYdotool ? [{ cmd: "ydotool", args: ydotoolArgs }] : []), + ...(canUseXdotool ? [{ cmd: "xdotool", args: xdotoolArgs }] : []), + ] + : [ + ...(canUseXdotool ? [{ cmd: "xdotool", args: xdotoolArgs }] : []), + ...(canUseYdotool ? [{ cmd: "ydotool", args: ydotoolArgs }] : []), + ]), ]; const available = candidates.filter((c) => this.commandExists(c.cmd)); diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index a90a03aa..65a84794 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -175,7 +175,31 @@ class IPCHandlers { // Clipboard handlers ipcMain.handle("paste-text", async (event, text, options) => { - return this.clipboardManager.pasteText(text, { ...options, webContents: event.sender }); + // On GNOME Wayland, the overlay's show() steals focus from the target window. + // ydotool sends keystrokes to the focused window, so we must temporarily hide + // the overlay to return focus to the target before pasting. + const mainWin = this.windowManager?.mainWindow; + const needsHideRestore = + process.platform === "linux" && + mainWin && + !mainWin.isDestroyed() && + mainWin.isVisible(); + + if (needsHideRestore) { + mainWin.hide(); + await new Promise((r) => setTimeout(r, 200)); + } + + try { + return await this.clipboardManager.pasteText(text, { ...options, webContents: event.sender }); + } finally { + // Re-show the overlay if auto-hide is not enabled + if (needsHideRestore && !this.windowManager._floatingIconAutoHide) { + if (mainWin && !mainWin.isDestroyed()) { + mainWin.show(); + } + } + } }); ipcMain.handle("read-clipboard", async (event) => {