From 969299b56a8016196b23f9426feef70016cc146a Mon Sep 17 00:00:00 2001 From: Alcahest Date: Mon, 23 Mar 2026 03:58:24 +0100 Subject: [PATCH 1/2] Add KDE Wayland native hotkey support and fix clipboard paste --- electron-builder.json | 12 +- main.js | 7 +- src/components/SettingsPage.tsx | 25 ++++ src/helpers/clipboard.js | 43 +++++- src/helpers/hotkeyManager.js | 128 ++++++++++++++++- src/helpers/ipcHandlers.js | 10 +- src/helpers/kdeShortcut.js | 238 ++++++++++++++++++++++++++++++++ src/helpers/windowManager.js | 4 + 8 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 src/helpers/kdeShortcut.js diff --git a/electron-builder.json b/electron-builder.json index ca991b9aa..6fe394346 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -174,7 +174,11 @@ "--deb-suggests", "wtype", "--deb-recommends", - "wl-clipboard" + "wl-clipboard", + "--deb-recommends", + "xclip", + "--deb-suggests", + "xsel" ] }, "rpm": { @@ -190,7 +194,11 @@ "--rpm-tag", "Suggests: wtype", "--rpm-tag", - "Recommends: wl-clipboard" + "Recommends: wl-clipboard", + "--rpm-tag", + "Recommends: xclip", + "--rpm-tag", + "Suggests: xsel" ] }, "nsis": { diff --git a/main.js b/main.js index 271623a4c..5bd5431a7 100644 --- a/main.js +++ b/main.js @@ -76,10 +76,9 @@ if (process.platform === "win32") { } // Enable native Wayland support: Ozone platform for native rendering. -// KDE is forced to XWayland because globalShortcut doesn't work on native -// Wayland without the GlobalShortcuts portal, and the portal causes unwanted -// permission dialogs with broken hotkey registration. -// GNOME and Hyprland use their own D-Bus shortcut managers so they're fine. +// KDE uses XWayland for reliable clipboard access (Wayland restricts clipboard +// to focused windows), with KGlobalAccel D-Bus for global shortcuts. +// GNOME and Hyprland run native Wayland with their own D-Bus shortcut managers. if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland") { const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); if (desktop.includes("kde")) { diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 15fef8963..2afe2f6eb 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -794,6 +794,9 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage hasUdevRule: boolean; hasGroup: boolean; allGood: boolean; + isKde?: boolean; + hasXclip?: boolean; + hasXsel?: boolean; } | null>(null); const [ydotoolGuideKey, setYdotoolGuideKey] = useState(null); @@ -2605,6 +2608,28 @@ EOF`, }, ]; + if (ydotoolStatus.isKde) { + checks.push({ + key: "hasXclip", + label: "xclip", + ok: ydotoolStatus.hasXclip || ydotoolStatus.hasXsel || false, + desc: t("settingsPage.general.waylandPaste.xclipDesc", { + defaultValue: "Clipboard tool for KDE Wayland paste (xclip or xsel)", + }), + guide: [ + { + title: t("settingsPage.general.waylandPaste.guide.xclip.step1Title", { + defaultValue: "Install xclip", + }), + cmds: [ + { cmd: "sudo dnf install xclip # Fedora" }, + { cmd: "sudo apt install xclip # Debian/Ubuntu" }, + ], + }, + ], + }); + } + const allOk = checks.every((c) => c.ok); const activeGuide = checks.find((c) => c.key === ydotoolGuideKey); diff --git a/src/helpers/clipboard.js b/src/helpers/clipboard.js index d8f294132..3a89672cd 100644 --- a/src/helpers/clipboard.js +++ b/src/helpers/clipboard.js @@ -98,6 +98,40 @@ class ClipboardManager { } _writeClipboardWayland(text, webContents) { + const { isKde } = getLinuxSessionInfo(); + + // On KDE with XWayland, write to X11 clipboard directly because + // wl-copy targets the Wayland clipboard which is desynced from X11 + if (isKde) { + if (this.commandExists("xclip")) { + try { + const result = spawnSync("xclip", ["-selection", "clipboard"], { + input: text, + timeout: 200, + }); + if (result.status === 0) { + clipboard.writeText(text); + return; + } + } catch {} + } + if (this.commandExists("xsel")) { + try { + const result = spawnSync("xsel", ["--clipboard", "--input"], { + input: text, + timeout: 200, + }); + if (result.status === 0) { + clipboard.writeText(text); + return; + } + } catch {} + } + // Last resort: Electron's clipboard.writeText should work on XWayland + clipboard.writeText(text); + return; + } + if (this.commandExists("wl-copy")) { try { const isHyprland = !!process.env.HYPRLAND_INSTANCE_SIGNATURE; @@ -1112,11 +1146,12 @@ class ClipboardManager { }); if (isWayland) { - // On KDE Wayland, uinput works reliably for all apps (including - // Chromium/Electron which ignore RemoteDesktop portal keystrokes). + // On KDE with XWayland (ozone-platform-hint: x11), portal paste works + // because clipboard and input are both on X11. uinput causes a clipboard + // desync (X11 clipboard vs Wayland input) so portal is preferred. // On GNOME, Mutter doesn't reliably route uinput to native Wayland - // windows (issue #292), so portal is tried first there. - const preferUinput = isKde; + // windows (issue #292), so portal is tried first there too. + const preferUinput = false; const tryUinputPaste = async () => { const args = ["--uinput"]; diff --git a/src/helpers/hotkeyManager.js b/src/helpers/hotkeyManager.js index a20dbc665..5c042965f 100644 --- a/src/helpers/hotkeyManager.js +++ b/src/helpers/hotkeyManager.js @@ -2,6 +2,7 @@ const { globalShortcut } = require("electron"); const debugLogger = require("./debugLogger"); const GnomeShortcutManager = require("./gnomeShortcut"); const HyprlandShortcutManager = require("./hyprlandShortcut"); +const KDEShortcutManager = require("./kdeShortcut"); const { i18nMain } = require("./i18nMain"); // Delay to ensure localStorage is accessible after window load @@ -68,6 +69,8 @@ class HotkeyManager { this.useGnome = false; this.hyprlandManager = null; this.useHyprland = false; + this.kdeManager = null; + this.useKDE = false; } // Backward-compatible property accessors @@ -185,6 +188,35 @@ class HotkeyManager { return { success: true, hotkey }; } + // On KDE Wayland, route all slots through KGlobalAccel D-Bus + if (this.useKDE && this.kdeManager) { + if (slotName === "agent") { + this.kdeManager.setAgentCallback(callback); + } + + const success = await this.kdeManager.registerKeybinding(hotkey, slotName, callback); + if (!success) { + debugLogger.log( + `[HotkeyManager] KDE keybinding registration failed for slot "${slotName}" ("${hotkey}")` + ); + return { + success: false, + error: `Failed to register KDE hotkey "${hotkey}" for ${slotName}`, + }; + } + + const slot = this.slots.get(slotName) || { hotkey: null, callback: null, accelerator: null }; + slot.hotkey = hotkey; + slot.callback = callback; + slot.accelerator = null; + this.slots.set(slotName, slot); + + debugLogger.log( + `[HotkeyManager] KDE slot "${slotName}" set to "${hotkey}"` + ); + return { success: true, hotkey }; + } + const result = this.setupShortcuts(hotkey, callback, slotName); if (result.success) { const slot = this.slots.get(slotName) || {}; @@ -198,6 +230,19 @@ class HotkeyManager { const slot = this.slots.get(slotName); if (!slot || !slot.hotkey) return; + // On KDE Wayland, all slots are managed via KGlobalAccel + if (this.useKDE && this.kdeManager) { + this.kdeManager.unregisterKeybinding(slotName).catch((err) => { + debugLogger.warn( + `[HotkeyManager] Error unregistering KDE keybinding for slot "${slotName}":`, + err.message + ); + }); + slot.hotkey = null; + slot.accelerator = null; + return; + } + // On GNOME Wayland, non-dictation slots are managed via gsettings, not globalShortcut if (this.useGnome && this.gnomeManager && slotName !== "dictation") { this.gnomeManager.unregisterKeybinding(slotName).catch((err) => { @@ -437,6 +482,29 @@ class HotkeyManager { return false; } + async initializeKDEShortcuts(callback) { + if (process.platform !== "linux" || !KDEShortcutManager.isWayland() || !KDEShortcutManager.isKDE()) { + return false; + } + + try { + this.kdeManager = new KDEShortcutManager(); + const ok = await this.kdeManager.init(); + if (ok) { + this.useKDE = true; + this.hotkeyCallback = callback; + debugLogger.log("[HotkeyManager] KDE Wayland shortcuts initialized via KGlobalAccel D-Bus"); + return true; + } + } catch (err) { + debugLogger.log("[HotkeyManager] KDE shortcut init failed:", err.message); + this.kdeManager = null; + this.useKDE = false; + } + + return false; + } + async initializeHyprlandShortcuts(callback) { const isLinux = process.platform === "linux"; const isWayland = HyprlandShortcutManager.isWayland(); @@ -525,7 +593,45 @@ class HotkeyManager { return; } - // Try Hyprland native shortcuts if GNOME path was not applicable + // Try KDE native shortcuts via KGlobalAccel + const kdeOk = await this.initializeKDEShortcuts(callback); + + if (kdeOk) { + const registerKDEHotkey = async () => { + try { + const savedHotkey = await mainWindow.webContents.executeJavaScript(` + localStorage.getItem("dictationKey") || "" + `); + const hotkey = savedHotkey && savedHotkey.trim() !== "" ? savedHotkey : "Control+Super"; + const success = await this.kdeManager.registerKeybinding(hotkey, "dictation", callback); + if (success) { + this.currentHotkey = hotkey; + debugLogger.log( + `[HotkeyManager] KDE hotkey "${hotkey}" registered successfully` + ); + } else { + debugLogger.log( + "[HotkeyManager] KDE keybinding failed, falling back to globalShortcut" + ); + this.useKDE = false; + this.loadSavedHotkeyOrDefault(mainWindow, callback); + } + } catch (err) { + debugLogger.log( + "[HotkeyManager] KDE keybinding failed, falling back to globalShortcut:", + err.message + ); + this.useKDE = false; + this.loadSavedHotkeyOrDefault(mainWindow, callback); + } + }; + + setTimeout(registerKDEHotkey, HOTKEY_REGISTRATION_DELAY_MS); + this.isInitialized = true; + return; + } + + // Try Hyprland native shortcuts if GNOME/KDE paths were not applicable const hyprlandOk = await this.initializeHyprlandShortcuts(callback); if (hyprlandOk) { @@ -796,6 +902,20 @@ class HotkeyManager { this.gnomeManager = null; this.useGnome = false; } + if (this.kdeManager) { + const kdeSlots = [...this.kdeManager.registeredSlots]; + for (const slotName of kdeSlots) { + this.kdeManager.unregisterKeybinding(slotName).catch((err) => { + debugLogger.warn( + `[HotkeyManager] Error unregistering KDE keybinding for slot "${slotName}":`, + err.message + ); + }); + } + this.kdeManager.close(); + this.kdeManager = null; + this.useKDE = false; + } if (this.hyprlandManager) { this.hyprlandManager.unregisterKeybinding().catch((err) => { debugLogger.warn("[HotkeyManager] Error unregistering Hyprland keybinding:", err.message); @@ -822,8 +942,12 @@ class HotkeyManager { return this.useHyprland; } + isUsingKDE() { + return this.useKDE; + } + isUsingNativeShortcut() { - return this.useGnome || this.useHyprland; + return this.useGnome || this.useHyprland || this.useKDE; } isHotkeyRegistered(hotkey) { diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 8e67af09d..1aa0f66cb 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -1384,6 +1384,7 @@ class IPCHandlers { return { isUsingGnome: this.windowManager.isUsingGnomeHotkeys(), isUsingHyprland: this.windowManager.isUsingHyprlandHotkeys(), + isUsingKDE: this.windowManager.isUsingKDEHotkeys?.() || false, isUsingNativeShortcut: this.windowManager.isUsingNativeShortcutHotkeys(), }; }); @@ -3354,7 +3355,14 @@ class IPCHandlers { ipcMain.handle("get-ydotool-status", () => { const { getYdotoolStatus } = require("./ensureYdotool"); - return getYdotoolStatus(); + const status = getYdotoolStatus(); + const { execFileSync } = require("child_process"); + const isKde = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase().includes("kde"); + let hasXclip = false; + let hasXsel = false; + try { execFileSync("which", ["xclip"], { timeout: 1000 }); hasXclip = true; } catch {} + try { execFileSync("which", ["xsel"], { timeout: 1000 }); hasXsel = true; } catch {} + return { ...status, isKde, hasXclip, hasXsel }; }); ipcMain.handle("get-debug-state", async () => { diff --git a/src/helpers/kdeShortcut.js b/src/helpers/kdeShortcut.js new file mode 100644 index 000000000..1746bffd6 --- /dev/null +++ b/src/helpers/kdeShortcut.js @@ -0,0 +1,238 @@ +const debugLogger = require("./debugLogger"); + +let dbus = null; + +function getDBus() { + if (dbus) return dbus; + try { + dbus = require("dbus-next"); + return dbus; + } catch (err) { + debugLogger.log("[KDEShortcut] Failed to load dbus-next:", err.message); + return null; + } +} + +// Qt::Key and Qt::KeyboardModifier values for KGlobalAccel +const QT_MODIFIERS = { + control: 0x04000000, + ctrl: 0x04000000, + alt: 0x08000000, + shift: 0x02000000, + super: 0x10000000, + meta: 0x10000000, + commandorcontrol: 0x04000000, +}; + +const QT_KEYS = { + a: 0x41, b: 0x42, c: 0x43, d: 0x44, e: 0x45, f: 0x46, g: 0x47, h: 0x48, + i: 0x49, j: 0x4a, k: 0x4b, l: 0x4c, m: 0x4d, n: 0x4e, o: 0x4f, p: 0x50, + q: 0x51, r: 0x52, s: 0x53, t: 0x54, u: 0x55, v: 0x56, w: 0x57, x: 0x58, + y: 0x59, z: 0x5a, + 0: 0x30, 1: 0x31, 2: 0x32, 3: 0x33, 4: 0x34, + 5: 0x35, 6: 0x36, 7: 0x37, 8: 0x38, 9: 0x39, + f1: 0x01000030, f2: 0x01000031, f3: 0x01000032, f4: 0x01000033, + f5: 0x01000034, f6: 0x01000035, f7: 0x01000036, f8: 0x01000037, + f9: 0x01000038, f10: 0x01000039, f11: 0x0100003a, f12: 0x0100003b, + space: 0x20, tab: 0x01000001, escape: 0x01000000, backspace: 0x01000003, + enter: 0x01000005, return: 0x01000004, + insert: 0x01000006, delete: 0x01000007, + home: 0x01000010, end: 0x01000011, + pageup: 0x01000016, pagedown: 0x01000017, + up: 0x01000013, down: 0x01000015, left: 0x01000012, right: 0x01000014, + "`": 0x60, grave: 0x60, + print: 0x01000009, scrolllock: 0x01000026, pause: 0x01000008, +}; + +const COMPONENT_NAME = "openwhispr"; + +class KDEShortcutManager { + constructor() { + this.bus = null; + this.kglobalaccel = null; + this.componentProxy = null; + this.callbacks = new Map(); + this.registeredSlots = new Set(); + } + + static isKDE() { + const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); + return desktop.includes("kde"); + } + + static isWayland() { + return process.env.XDG_SESSION_TYPE === "wayland"; + } + + static convertToQtKeyCode(electronHotkey) { + const parts = electronHotkey.split("+"); + let qtKey = 0; + + for (const part of parts) { + const lower = part.toLowerCase().trim(); + if (QT_MODIFIERS[lower]) { + qtKey |= QT_MODIFIERS[lower]; + } else { + const key = QT_KEYS[lower] || QT_KEYS[lower.replace("arrow", "")]; + if (key) { + qtKey |= key; + } else { + debugLogger.log(`[KDEShortcut] Unknown key: "${part}"`); + return null; + } + } + } + + return qtKey; + } + + async init() { + const dbusModule = getDBus(); + if (!dbusModule) return false; + + try { + this.bus = dbusModule.sessionBus(); + const proxy = await this.bus.getProxyObject( + "org.kde.kglobalaccel", + "/kglobalaccel" + ); + this.kglobalaccel = proxy.getInterface("org.kde.KGlobalAccel"); + + debugLogger.log("[KDEShortcut] Connected to KGlobalAccel D-Bus"); + return true; + } catch (err) { + debugLogger.log("[KDEShortcut] Failed to connect:", err.message); + this.bus = null; + this.kglobalaccel = null; + return false; + } + } + + async _listenForComponent() { + if (this.componentProxy) return; + + try { + const componentPath = `/component/${COMPONENT_NAME}`; + const proxy = await this.bus.getProxyObject( + "org.kde.kglobalaccel", + componentPath + ); + const iface = proxy.getInterface("org.kde.kglobalaccel.Component"); + + iface.on("globalShortcutPressed", (componentUnique, shortcutUnique, timestamp) => { + debugLogger.log("[KDEShortcut] Shortcut pressed", { componentUnique, shortcutUnique }); + // KGlobalAccel may send actionUnique or actionFriendly depending on version + const callback = this.callbacks.get(shortcutUnique) + || this._findCallbackByFriendlyName(shortcutUnique); + if (callback) { + callback(); + } else { + debugLogger.log("[KDEShortcut] No callback found for", { shortcutUnique, registered: [...this.callbacks.keys()] }); + } + }); + + this.componentProxy = iface; + debugLogger.log("[KDEShortcut] Listening for globalShortcutPressed on", componentPath); + } catch (err) { + debugLogger.log("[KDEShortcut] Failed to listen on component:", err.message); + } + } + + _findCallbackByFriendlyName(name) { + // Map friendly names back to slot names + const friendlyToSlot = {}; + for (const slotName of this.registeredSlots) { + friendlyToSlot[`OpenWhispr ${slotName}`] = slotName; + friendlyToSlot[`OpenWhispr`] = "dictation"; // legacy compat + } + const slotName = friendlyToSlot[name]; + return slotName ? this.callbacks.get(slotName) : null; + } + + setAgentCallback(callback) { + this.callbacks.set("agent", callback); + debugLogger.log("[KDEShortcut] Agent callback set"); + } + + async registerKeybinding(electronHotkey, slotName = "dictation", callback) { + if (!this.kglobalaccel) return false; + + const qtKey = KDEShortcutManager.convertToQtKeyCode(electronHotkey); + if (qtKey === null) { + debugLogger.log(`[KDEShortcut] Could not convert "${electronHotkey}" to Qt key code`); + return false; + } + + // actionId: [componentUnique, componentFriendly, actionUnique, actionFriendly] + const actionId = [ + COMPONENT_NAME, + "OpenWhispr", + slotName, + `OpenWhispr ${slotName}`, + ]; + + try { + // Register action then set shortcut + await this.kglobalaccel.doRegister(actionId); + const result = await this.kglobalaccel.setShortcut(actionId, [qtKey], 0x02); + + if (callback) this.callbacks.set(slotName, callback); + this.registeredSlots.add(slotName); + + // Start listening for press events on the component + await this._listenForComponent(); + + debugLogger.log("[KDEShortcut] Registered", { + slot: slotName, + hotkey: electronHotkey, + qtKey: `0x${qtKey.toString(16)}`, + result, + }); + return true; + } catch (err) { + debugLogger.log(`[KDEShortcut] Registration failed for "${slotName}":`, err.message); + return false; + } + } + + async unregisterKeybinding(slotName = "dictation") { + if (!this.kglobalaccel) return; + + const actionId = [ + COMPONENT_NAME, + "OpenWhispr", + slotName, + `OpenWhispr ${slotName}`, + ]; + + try { + await this.kglobalaccel.unRegister(actionId); + this.callbacks.delete(slotName); + this.registeredSlots.delete(slotName); + debugLogger.log("[KDEShortcut] Unregistered", { slot: slotName }); + } catch (err) { + debugLogger.log(`[KDEShortcut] Unregister failed for "${slotName}":`, err.message); + } + } + + close() { + // Unregister all before closing + for (const slotName of this.registeredSlots) { + const actionId = [COMPONENT_NAME, "OpenWhispr", slotName, `OpenWhispr ${slotName}`]; + try { + this.kglobalaccel?.unRegister(actionId); + } catch {} + } + + if (this.bus) { + try { this.bus.disconnect(); } catch {} + this.bus = null; + this.kglobalaccel = null; + this.componentProxy = null; + } + this.callbacks.clear(); + this.registeredSlots.clear(); + } +} + +module.exports = KDEShortcutManager; diff --git a/src/helpers/windowManager.js b/src/helpers/windowManager.js index 8d3f64946..4803f0ef2 100644 --- a/src/helpers/windowManager.js +++ b/src/helpers/windowManager.js @@ -495,6 +495,10 @@ class WindowManager { return this.hotkeyManager.isUsingHyprland(); } + isUsingKDEHotkeys() { + return this.hotkeyManager.isUsingKDE(); + } + isUsingNativeShortcutHotkeys() { return this.hotkeyManager.isUsingNativeShortcut(); } From 0c65ecc23b3b3e51b83a499145a0940fa3ebebcd Mon Sep 17 00:00:00 2001 From: Alcahest Date: Mon, 23 Mar 2026 16:34:44 +0100 Subject: [PATCH 2/2] Address review feedback on KDE shortcut manager --- src/helpers/hotkeyManager.js | 4 ++++ src/helpers/ipcHandlers.js | 10 ++++++---- src/helpers/kdeShortcut.js | 36 +++++++++++++++++++++++++----------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/helpers/hotkeyManager.js b/src/helpers/hotkeyManager.js index 5c042965f..0b5f12983 100644 --- a/src/helpers/hotkeyManager.js +++ b/src/helpers/hotkeyManager.js @@ -613,6 +613,8 @@ class HotkeyManager { debugLogger.log( "[HotkeyManager] KDE keybinding failed, falling back to globalShortcut" ); + this.kdeManager.close(); + this.kdeManager = null; this.useKDE = false; this.loadSavedHotkeyOrDefault(mainWindow, callback); } @@ -621,6 +623,8 @@ class HotkeyManager { "[HotkeyManager] KDE keybinding failed, falling back to globalShortcut:", err.message ); + this.kdeManager?.close(); + this.kdeManager = null; this.useKDE = false; this.loadSavedHotkeyOrDefault(mainWindow, callback); } diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 1aa0f66cb..339bf5229 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -1384,7 +1384,7 @@ class IPCHandlers { return { isUsingGnome: this.windowManager.isUsingGnomeHotkeys(), isUsingHyprland: this.windowManager.isUsingHyprlandHotkeys(), - isUsingKDE: this.windowManager.isUsingKDEHotkeys?.() || false, + isUsingKDE: this.windowManager.isUsingKDEHotkeys(), isUsingNativeShortcut: this.windowManager.isUsingNativeShortcutHotkeys(), }; }); @@ -3355,13 +3355,15 @@ class IPCHandlers { ipcMain.handle("get-ydotool-status", () => { const { getYdotoolStatus } = require("./ensureYdotool"); - const status = getYdotoolStatus(); const { execFileSync } = require("child_process"); + const status = getYdotoolStatus(); const isKde = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase().includes("kde"); let hasXclip = false; let hasXsel = false; - try { execFileSync("which", ["xclip"], { timeout: 1000 }); hasXclip = true; } catch {} - try { execFileSync("which", ["xsel"], { timeout: 1000 }); hasXsel = true; } catch {} + if (isKde) { + try { execFileSync("which", ["xclip"], { timeout: 1000 }); hasXclip = true; } catch {} + try { execFileSync("which", ["xsel"], { timeout: 1000 }); hasXsel = true; } catch {} + } return { ...status, isKde, hasXclip, hasXsel }; }); diff --git a/src/helpers/kdeShortcut.js b/src/helpers/kdeShortcut.js index 1746bffd6..9ebf01864 100644 --- a/src/helpers/kdeShortcut.js +++ b/src/helpers/kdeShortcut.js @@ -109,7 +109,7 @@ class KDEShortcutManager { } async _listenForComponent() { - if (this.componentProxy) return; + if (this.componentProxy) return true; try { const componentPath = `/component/${COMPONENT_NAME}`; @@ -121,7 +121,9 @@ class KDEShortcutManager { iface.on("globalShortcutPressed", (componentUnique, shortcutUnique, timestamp) => { debugLogger.log("[KDEShortcut] Shortcut pressed", { componentUnique, shortcutUnique }); - // KGlobalAccel may send actionUnique or actionFriendly depending on version + // Tested on KDE Plasma 6 (Fedora 43): signal sends the actionFriendly + // name ("OpenWhispr") not the actionUnique ("dictation"). The fallback + // maps friendly names back to slot names. const callback = this.callbacks.get(shortcutUnique) || this._findCallbackByFriendlyName(shortcutUnique); if (callback) { @@ -133,8 +135,10 @@ class KDEShortcutManager { this.componentProxy = iface; debugLogger.log("[KDEShortcut] Listening for globalShortcutPressed on", componentPath); + return true; } catch (err) { debugLogger.log("[KDEShortcut] Failed to listen on component:", err.message); + return false; } } @@ -180,7 +184,11 @@ class KDEShortcutManager { this.registeredSlots.add(slotName); // Start listening for press events on the component - await this._listenForComponent(); + const listening = await this._listenForComponent(); + if (!listening) { + debugLogger.log(`[KDEShortcut] Keybinding registered but listener failed for "${slotName}"`); + return false; + } debugLogger.log("[KDEShortcut] Registered", { slot: slotName, @@ -216,20 +224,26 @@ class KDEShortcutManager { } close() { - // Unregister all before closing + // Best-effort cleanup on app shutdown. unRegister calls are async D-Bus + // calls that may not complete before disconnect, but KGlobalAccel will + // clean up stale registrations from dead processes anyway. + const promises = []; for (const slotName of this.registeredSlots) { const actionId = [COMPONENT_NAME, "OpenWhispr", slotName, `OpenWhispr ${slotName}`]; try { - this.kglobalaccel?.unRegister(actionId); + promises.push(this.kglobalaccel?.unRegister(actionId)); } catch {} } - if (this.bus) { - try { this.bus.disconnect(); } catch {} - this.bus = null; - this.kglobalaccel = null; - this.componentProxy = null; - } + Promise.allSettled(promises).finally(() => { + if (this.bus) { + try { this.bus.disconnect(); } catch {} + this.bus = null; + this.kglobalaccel = null; + this.componentProxy = null; + } + }); + this.callbacks.clear(); this.registeredSlots.clear(); }