diff --git a/CLAUDE.md b/CLAUDE.md index c5434ea5..de34f4b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,11 +65,17 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - Auto-fallback to F8/F9 if default hotkey is unavailable - Notifies renderer via IPC when hotkey registration fails - Integrates with GnomeShortcutManager for GNOME Wayland support + - Integrates with HyprlandShortcutManager for Hyprland Wayland support - **gnomeShortcut.js**: GNOME Wayland global shortcut integration - Uses D-Bus service to receive hotkey toggle commands - Registers shortcuts via gsettings (visible in GNOME Settings → Keyboard → Shortcuts) - Converts Electron hotkey format to GNOME keysym format - Only active on Linux + Wayland + GNOME desktop +- **hyprlandShortcut.js**: Hyprland Wayland global shortcut integration + - Uses D-Bus service to receive hotkey toggle commands (same `com.openwhispr.App` service) + - Registers shortcuts via `hyprctl keyword bind` (runtime keybinding) + - Converts Electron hotkey format to Hyprland bind format (`MODS, key`) + - Only active on Linux + Wayland + Hyprland (detected via `HYPRLAND_INSTANCE_SIGNATURE`) - **ipcHandlers.js**: Centralized IPC handler registration - **windowsKeyManager.js**: Windows Push-to-Talk support with native key listener - Spawns native `windows-key-listener.exe` binary for low-level keyboard hooks @@ -271,7 +277,6 @@ Environment variables persisted to `.env` (via `saveAllKeysToEnvFile()`): ### 8. Model Registry Architecture All AI model definitions are centralized in `src/models/modelRegistryData.json` as the single source of truth: - ```json { "cloudProviders": [...], // OpenAI, Anthropic, Gemini API models @@ -409,8 +414,8 @@ On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's - gsettings path: `/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr/` **IPC Integration**: -- `get-hotkey-mode-info`: Returns `{ isUsingGnome: boolean }` to renderer -- UI hides activation mode selector when `isUsingGnome` is true +- `get-hotkey-mode-info`: Returns `{ isUsingGnome, isUsingHyprland, isUsingNativeShortcut }` to renderer +- UI hides activation mode selector when `isUsingNativeShortcut` is true - Forces tap-to-talk mode (push-to-talk not supported) **Hotkey Format Conversion**: @@ -418,7 +423,36 @@ On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's - GNOME format: `r`, `space` - Backtick (`) → `grave` in GNOME keysym format -### 15. Meeting Detection (Event-Driven) +### 15. Hyprland Wayland Global Hotkeys + +On Hyprland (wlroots Wayland compositor), Electron's `globalShortcut` API and the `GlobalShortcutsPortal` feature don't work reliably. OpenWhispr uses native Hyprland keybindings: + +**Architecture**: +1. `main.js` enables `GlobalShortcutsPortal` feature flag for Wayland (fallback) +2. `hotkeyManager.js` detects Hyprland + Wayland and initializes `HyprlandShortcutManager` +3. `hyprlandShortcut.js` creates D-Bus service at `com.openwhispr.App` (same as GNOME) +4. Shortcuts registered via `hyprctl keyword bind` (runtime keybinding) +5. Hyprland triggers `dbus-send` command which calls the D-Bus `Toggle()` method + +**Detection**: +- Primary: `HYPRLAND_INSTANCE_SIGNATURE` environment variable (set by Hyprland) +- Fallback: `XDG_CURRENT_DESKTOP` contains "hyprland" + +**Hotkey Format Conversion**: +- Electron format: `Alt+R`, `CommandOrControl+Shift+Space` +- Hyprland format: `ALT, R`, `CTRL SHIFT, space` +- Modifier-only combos (e.g., `Control+Super`) → `CTRL, Super_L` + +**Bind/Unbind Commands**: +- Register: `hyprctl keyword bind "ALT, R, exec, dbus-send --session ..."` +- Unregister: `hyprctl keyword unbind "ALT, R"` +- Bindings are ephemeral (don't survive Hyprland restart) but re-registered on app startup + +**Limitations**: +- Push-to-talk not supported (Hyprland `bind` fires a single exec, not key-down/key-up) +- Requires `hyprctl` on PATH (ships with Hyprland) + +### 16. Meeting Detection (Event-Driven) Detects meetings via three independent sources, orchestrated by `MeetingDetectionEngine`: @@ -500,7 +534,8 @@ const { t } = useTranslation(); - [ ] Test custom dictionary with uncommon words - [ ] Verify Windows Push-to-Talk with compound hotkeys - [ ] Test GNOME Wayland hotkeys (if on GNOME + Wayland) -- [ ] Verify activation mode selector is hidden on GNOME Wayland +- [ ] Test Hyprland Wayland hotkeys (if on Hyprland + Wayland) +- [ ] Verify activation mode selector is hidden on GNOME Wayland and Hyprland Wayland - [ ] Verify meeting detection works with event-driven mode (check debug logs for "event-driven") - [ ] Test meeting notification suppression during recording - [ ] Test post-recording cooldown (notifications shouldn't flash immediately) diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index c250c003..cadac6a0 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -101,7 +101,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const [skipAuth, setSkipAuth] = useState(false); const [pendingVerificationEmail, setPendingVerificationEmail] = useState(null); const [isModelDownloaded, setIsModelDownloaded] = useState(false); - const [isUsingGnomeHotkeys, setIsUsingGnomeHotkeys] = useState(false); + const [isUsingNativeShortcut, setIsUsingNativeShortcut] = useState(false); const readableHotkey = formatHotkeyLabel(hotkey); const { alertDialog, confirmDialog, showAlertDialog, hideAlertDialog, hideConfirmDialog } = useDialogs(); @@ -150,8 +150,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const checkHotkeyMode = async () => { try { const info = await window.electronAPI?.getHotkeyModeInfo(); - if (info?.isUsingGnome) { - setIsUsingGnomeHotkeys(true); + if (info?.isUsingNativeShortcut) { + setIsUsingNativeShortcut(true); setActivationMode("tap"); } } catch (error) { @@ -652,7 +652,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { {/* Mode section - inline with hotkey */} - {!isUsingGnomeHotkeys && ( + {!isUsingNativeShortcut && (
@@ -680,7 +680,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { {t("onboarding.activation.test")} - {activationMode === "tap" || isUsingGnomeHotkeys + {activationMode === "tap" || isUsingNativeShortcut ? t("onboarding.activation.hotkeyToStartStop", { hotkey: readableHotkey }) : t("onboarding.activation.holdHotkey", { hotkey: readableHotkey })} diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 83410a84..61d8fc18 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -859,7 +859,7 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage [] ); - const [isUsingGnomeHotkeys, setIsUsingGnomeHotkeys] = useState(false); + const [isUsingNativeShortcut, setIsUsingNativeShortcut] = useState(false); const platform = getCachedPlatform(); @@ -925,8 +925,8 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage const checkHotkeyMode = async () => { try { const info = await window.electronAPI?.getHotkeyModeInfo(); - if (info?.isUsingGnome) { - setIsUsingGnomeHotkeys(true); + if (info?.isUsingNativeShortcut) { + setIsUsingNativeShortcut(true); setActivationMode("tap"); } } catch (error) { @@ -2384,7 +2384,7 @@ EOF`, )} - {!isUsingGnomeHotkeys && ( + {!isUsingNativeShortcut && (

{t("settingsPage.general.hotkey.activationMode")} diff --git a/src/helpers/clipboard.js b/src/helpers/clipboard.js index 624a924f..43fc0340 100644 --- a/src/helpers/clipboard.js +++ b/src/helpers/clipboard.js @@ -91,7 +91,8 @@ class ClipboardManager { _writeClipboardWayland(text, webContents) { if (this.commandExists("wl-copy")) { try { - const result = spawnSync("wl-copy", ["--", text], { timeout: 1 }); + const isHyprland = !!process.env.HYPRLAND_INSTANCE_SIGNATURE; + const result = spawnSync("wl-copy", ["--", text], { timeout: isHyprland ? 50 : 1 }); if (result.status === 0) { clipboard.writeText(text); return; diff --git a/src/helpers/hotkeyManager.js b/src/helpers/hotkeyManager.js index 577b7155..4c61957c 100644 --- a/src/helpers/hotkeyManager.js +++ b/src/helpers/hotkeyManager.js @@ -1,6 +1,7 @@ const { globalShortcut } = require("electron"); const debugLogger = require("./debugLogger"); const GnomeShortcutManager = require("./gnomeShortcut"); +const HyprlandShortcutManager = require("./hyprlandShortcut"); const { i18nMain } = require("./i18nMain"); // Delay to ensure localStorage is accessible after window load @@ -65,6 +66,8 @@ class HotkeyManager { this.isListeningMode = false; this.gnomeManager = null; this.useGnome = false; + this.hyprlandManager = null; + this.useHyprland = false; } // Backward-compatible property accessors @@ -349,6 +352,50 @@ class HotkeyManager { return false; } + async initializeHyprlandShortcuts(callback) { + const isLinux = process.platform === "linux"; + const isWayland = HyprlandShortcutManager.isWayland(); + const isHyprland = HyprlandShortcutManager.isHyprland(); + + debugLogger.log("[HotkeyManager] Hyprland detection", { + isLinux, + isWayland, + isHyprland, + XDG_SESSION_TYPE: process.env.XDG_SESSION_TYPE || "(unset)", + HYPRLAND_INSTANCE_SIGNATURE: process.env.HYPRLAND_INSTANCE_SIGNATURE ? "present" : "(unset)", + XDG_CURRENT_DESKTOP: process.env.XDG_CURRENT_DESKTOP || "(unset)", + }); + + if (!isLinux || !isWayland) { + return false; + } + + if (isHyprland) { + if (!HyprlandShortcutManager.isHyprctlAvailable()) { + debugLogger.log("[HotkeyManager] Hyprland detected but hyprctl not available"); + return false; + } + + try { + this.hyprlandManager = new HyprlandShortcutManager(); + + const dbusOk = await this.hyprlandManager.initDBusService(callback); + debugLogger.log("[HotkeyManager] Hyprland D-Bus init result:", dbusOk); + if (dbusOk) { + this.useHyprland = true; + this.hotkeyCallback = callback; + return true; + } + } catch (err) { + debugLogger.log("[HotkeyManager] Hyprland shortcut init failed:", err.message); + this.hyprlandManager = null; + this.useHyprland = false; + } + } + + return false; + } + async initializeHotkey(mainWindow, callback) { if (!mainWindow || !callback) { throw new Error("mainWindow and callback are required"); @@ -392,6 +439,45 @@ class HotkeyManager { this.isInitialized = true; return; } + + // Try Hyprland native shortcuts if GNOME path was not applicable + const hyprlandOk = await this.initializeHyprlandShortcuts(callback); + + if (hyprlandOk) { + const registerHyprlandHotkey = async () => { + try { + const savedHotkey = await mainWindow.webContents.executeJavaScript(` + localStorage.getItem("dictationKey") || "" + `); + const hotkey = savedHotkey && savedHotkey.trim() !== "" ? savedHotkey : "Control+Super"; + + const success = await this.hyprlandManager.registerKeybinding(hotkey); + if (success) { + this.currentHotkey = hotkey; + debugLogger.log( + `[HotkeyManager] Hyprland hotkey "${hotkey}" registered successfully` + ); + } else { + debugLogger.log( + "[HotkeyManager] Hyprland keybinding failed, falling back to globalShortcut" + ); + this.useHyprland = false; + this.loadSavedHotkeyOrDefault(mainWindow, callback); + } + } catch (err) { + debugLogger.log( + "[HotkeyManager] Hyprland keybinding failed, falling back to globalShortcut:", + err.message + ); + this.useHyprland = false; + this.loadSavedHotkeyOrDefault(mainWindow, callback); + } + }; + + setTimeout(registerHyprlandHotkey, HOTKEY_REGISTRATION_DELAY_MS); + this.isInitialized = true; + return; + } } if (process.platform === "linux") { @@ -560,6 +646,28 @@ class HotkeyManager { }; } + if (this.useHyprland && this.hyprlandManager) { + debugLogger.log(`[HotkeyManager] Updating Hyprland hotkey to "${hotkey}"`); + const success = await this.hyprlandManager.updateKeybinding(hotkey); + if (!success) { + return { + success: false, + message: `Failed to update Hyprland hotkey to "${hotkey}". Check the format is valid.`, + }; + } + this.currentHotkey = hotkey; + const saved = await this.saveHotkeyToRenderer(hotkey); + if (!saved) { + debugLogger.warn( + "[HotkeyManager] Hyprland hotkey registered but failed to persist to localStorage" + ); + } + return { + success: true, + message: `Hotkey updated to: ${hotkey} (via Hyprland native shortcut)`, + }; + } + const result = this.setupShortcuts(hotkey, callback); if (result.success) { const saved = await this.saveHotkeyToRenderer(hotkey); @@ -598,6 +706,14 @@ class HotkeyManager { this.gnomeManager = null; this.useGnome = false; } + if (this.hyprlandManager) { + this.hyprlandManager.unregisterKeybinding().catch((err) => { + debugLogger.warn("[HotkeyManager] Error unregistering Hyprland keybinding:", err.message); + }); + this.hyprlandManager.close(); + this.hyprlandManager = null; + this.useHyprland = false; + } for (const slotName of this.slots.keys()) { const slot = this.slots.get(slotName); if (slot) { @@ -612,6 +728,14 @@ class HotkeyManager { return this.useGnome; } + isUsingHyprland() { + return this.useHyprland; + } + + isUsingNativeShortcut() { + return this.useGnome || this.useHyprland; + } + isHotkeyRegistered(hotkey) { return globalShortcut.isRegistered(hotkey); } diff --git a/src/helpers/hyprlandShortcut.js b/src/helpers/hyprlandShortcut.js new file mode 100644 index 00000000..90f95d9b --- /dev/null +++ b/src/helpers/hyprlandShortcut.js @@ -0,0 +1,332 @@ +const { execFileSync } = require("child_process"); +const debugLogger = require("./debugLogger"); + +const DBUS_SERVICE_NAME = "com.openwhispr.App"; +const DBUS_OBJECT_PATH = "/com/openwhispr/App"; +const DBUS_INTERFACE = "com.openwhispr.App"; + +// Map Electron modifier names to Hyprland modifier names +const ELECTRON_TO_HYPRLAND_MOD = { + commandorcontrol: "CTRL", + control: "CTRL", + ctrl: "CTRL", + alt: "ALT", + option: "ALT", + shift: "SHIFT", + super: "SUPER", + meta: "SUPER", + win: "SUPER", + command: "SUPER", + cmd: "SUPER", + cmdorctrl: "CTRL", +}; + +// Map Electron key names to Hyprland key names +const ELECTRON_TO_HYPRLAND_KEY = { + pageup: "Page_Up", + pagedown: "Page_Down", + scrolllock: "Scroll_Lock", + printscreen: "Print", + enter: "Return", + arrowup: "Up", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + backquote: "grave", + "`": "grave", + " ": "space", +}; + +// Valid Electron-format hotkey: optional modifiers joined by +, ending with a key +// Supports: standalone keys (F4, Space), modifier+key combos, and modifier-only combos (Control+Super) +const VALID_HOTKEY_PATTERN = + /^((CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd)(\+(CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd))*(\+)?)?(F([1-9]|1[0-9]|2[0-4])|[A-Za-z0-9]|Space|Escape|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|PrintScreen|ScrollLock|Pause|Backquote|`)?$/i; + +let dbus = null; + +function getDBus() { + if (dbus) return dbus; + try { + dbus = require("dbus-next"); + return dbus; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to load dbus-next:", err.message); + return null; + } +} + +class HyprlandShortcutManager { + constructor() { + this.bus = null; + this.callback = null; + this.isRegistered = false; + this.currentBinding = null; // Store the current Hyprland bind string for unbinding + } + + /** + * Detect if the current session is running on Hyprland. + * Checks the HYPRLAND_INSTANCE_SIGNATURE env var (most reliable) + * and falls back to XDG_CURRENT_DESKTOP. + */ + static isHyprland() { + if (process.env.HYPRLAND_INSTANCE_SIGNATURE) { + return true; + } + const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); + return desktop.includes("hyprland"); + } + + static isWayland() { + return process.env.XDG_SESSION_TYPE === "wayland"; + } + + /** + * Check if hyprctl is available on the system. + */ + static isHyprctlAvailable() { + try { + execFileSync("hyprctl", ["version"], { stdio: "pipe", timeout: 3000 }); + return true; + } catch { + return false; + } + } + + /** + * Initialize a D-Bus service to receive Toggle() calls from Hyprland keybindings. + * Reuses the same D-Bus service name/path as the GNOME integration. + */ + async initDBusService(callback) { + this.callback = callback; + + const dbusModule = getDBus(); + if (!dbusModule) { + return false; + } + + try { + this.bus = dbusModule.sessionBus(); + await this.bus.requestName(DBUS_SERVICE_NAME, 0); + + const InterfaceClass = this._createInterfaceClass(dbusModule, callback); + const iface = new InterfaceClass(); + this.bus.export(DBUS_OBJECT_PATH, iface); + + debugLogger.log("[HyprlandShortcut] D-Bus service initialized successfully"); + return true; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to initialize D-Bus service:", err.message); + if (this.bus) { + this.bus.disconnect(); + this.bus = null; + } + return false; + } + } + + _createInterfaceClass(dbusModule, callback) { + class OpenWhisprInterface extends dbusModule.interface.Interface { + constructor() { + super(DBUS_INTERFACE); + this._callback = callback; + } + + Toggle() { + if (this._callback) { + this._callback(); + } + } + } + + OpenWhisprInterface.configureMembers({ + methods: { + Toggle: { inSignature: "", outSignature: "" }, + }, + }); + + return OpenWhisprInterface; + } + + static isValidHotkey(hotkey) { + if (!hotkey || typeof hotkey !== "string") { + return false; + } + return VALID_HOTKEY_PATTERN.test(hotkey); + } + + /** + * Convert an Electron-format hotkey string to Hyprland bind format. + * + * Electron format: "Control+Super", "Alt+R", "CommandOrControl+Shift+Space" + * Hyprland format: "CTRL SUPER", "ALT, R" (mods space-separated, comma before key) + * + * For modifier-only combos (e.g. "Control+Super"), Hyprland expects: + * bind = CTRL, Super_L, exec, ... + * where the last modifier is treated as the trigger key. + * + * Returns { mods, key } where mods is the modifier string and key is the trigger key, + * or null if the hotkey can't be converted. + */ + static convertToHyprlandFormat(hotkey) { + if (!hotkey || typeof hotkey !== "string") { + return null; + } + + const parts = hotkey + .split("+") + .map((p) => p.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return null; + } + + // Separate modifiers from the key + const modifiers = []; + let key = null; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const modName = ELECTRON_TO_HYPRLAND_MOD[part.toLowerCase()]; + if (modName) { + modifiers.push(modName); + } else { + // This is the actual key (should be the last part) + key = part; + } + } + + // If no key was found (modifier-only combo like "Control+Super"), + // use the last modifier as the trigger key in XKB format + if (!key && modifiers.length >= 2) { + const triggerMod = modifiers.pop(); + const modToXkbKey = { + CTRL: "Control_L", + ALT: "Alt_L", + SHIFT: "Shift_L", + SUPER: "Super_L", + }; + key = modToXkbKey[triggerMod] || triggerMod; + } else if (!key && modifiers.length === 1) { + // Single modifier -- can't create a useful bind + return null; + } + + // Convert special key names + if (key) { + const mappedKey = ELECTRON_TO_HYPRLAND_KEY[key.toLowerCase()]; + if (mappedKey) { + key = mappedKey; + } + } + + // Deduplicate modifiers (e.g. if "Control+Ctrl" was somehow passed) + const uniqueMods = [...new Set(modifiers)]; + + return { + mods: uniqueMods.join(" "), + key: key, + // Full bind key string for hyprctl keyword bind/unbind + bindKey: uniqueMods.length > 0 ? `${uniqueMods.join(" ")}, ${key}` : `, ${key}`, + }; + } + + /** + * Register a keybinding in Hyprland using hyprctl keyword bind. + * The binding executes a dbus-send command that calls our Toggle() method. + */ + async registerKeybinding(hotkey) { + if (!HyprlandShortcutManager.isHyprland()) { + debugLogger.log("[HyprlandShortcut] Not running on Hyprland, skipping registration"); + return false; + } + + if (!HyprlandShortcutManager.isValidHotkey(hotkey)) { + debugLogger.log(`[HyprlandShortcut] Invalid hotkey format: "${hotkey}"`); + return false; + } + + const converted = HyprlandShortcutManager.convertToHyprlandFormat(hotkey); + if (!converted) { + debugLogger.log(`[HyprlandShortcut] Could not convert hotkey "${hotkey}" to Hyprland format`); + return false; + } + + try { + // First unregister any existing binding + if (this.currentBinding) { + await this.unregisterKeybinding(); + } + + const dbusCommand = `dbus-send --session --type=method_call --dest=${DBUS_SERVICE_NAME} ${DBUS_OBJECT_PATH} ${DBUS_INTERFACE}.Toggle`; + + // hyprctl keyword bind "MODS, key, exec, command" + const bindValue = `${converted.bindKey}, exec, ${dbusCommand}`; + + execFileSync("hyprctl", ["keyword", "bind", bindValue], { + stdio: "pipe", + timeout: 5000, + }); + + this.currentBinding = converted.bindKey; + this.isRegistered = true; + debugLogger.log( + `[HyprlandShortcut] Keybinding "${hotkey}" (${converted.bindKey}) registered successfully` + ); + return true; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to register keybinding:", err.message); + return false; + } + } + + /** + * Update the keybinding to a new hotkey. + */ + async updateKeybinding(hotkey) { + // Just unregister old and register new + return this.registerKeybinding(hotkey); + } + + /** + * Unregister the current keybinding from Hyprland. + */ + async unregisterKeybinding() { + if (!this.currentBinding) { + this.isRegistered = false; + return true; + } + + try { + execFileSync("hyprctl", ["keyword", "unbind", this.currentBinding], { + stdio: "pipe", + timeout: 5000, + }); + + debugLogger.log( + `[HyprlandShortcut] Keybinding "${this.currentBinding}" unregistered successfully` + ); + this.currentBinding = null; + this.isRegistered = false; + return true; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to unregister keybinding:", err.message); + // Even if unbind fails, clear state so we don't keep retrying + this.currentBinding = null; + this.isRegistered = false; + return false; + } + } + + /** + * Clean up D-Bus connection. + */ + close() { + if (this.bus) { + this.bus.disconnect(); + this.bus = null; + } + } +} + +module.exports = HyprlandShortcutManager; diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 5f4a2b8f..36dba404 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -6,6 +6,7 @@ const crypto = require("crypto"); const AppUtils = require("../utils"); const debugLogger = require("./debugLogger"); const GnomeShortcutManager = require("./gnomeShortcut"); +const HyprlandShortcutManager = require("./hyprlandShortcut"); const AssemblyAiStreaming = require("./assemblyAiStreaming"); const { i18nMain, changeLanguage } = require("./i18nMain"); const DeepgramStreaming = require("./deepgramStreaming"); @@ -993,7 +994,10 @@ class IPCHandlers { await this.whisperManager.stopServer().catch(() => {}); return { success: true }; } catch (error) { - debugLogger.error("CUDA binary download failed", { error: error.message, stack: error.stack }); + debugLogger.error("CUDA binary download failed", { + error: error.message, + stack: error.stack, + }); return { success: false, error: error.message }; } }); @@ -1191,6 +1195,14 @@ class IPCHandlers { debugLogger.warn("[IPC] Failed to unregister GNOME keybinding:", err.message); }); } + + // On Hyprland Wayland, unregister the keybinding during capture + if (hotkeyManager.isUsingHyprland() && hotkeyManager.hyprlandManager) { + debugLogger.log("[IPC] Unregistering Hyprland keybinding for hotkey capture mode"); + await hotkeyManager.hyprlandManager.unregisterKeybinding().catch((err) => { + debugLogger.warn("[IPC] Failed to unregister Hyprland keybinding:", err.message); + }); + } } else { // Exiting capture mode - re-register globalShortcut if not already registered if (effectiveHotkey && !usesNativeListener(effectiveHotkey)) { @@ -1238,6 +1250,17 @@ class IPCHandlers { hotkeyManager.currentHotkey = effectiveHotkey; } } + + // On Hyprland Wayland, re-register the keybinding with the effective hotkey + if (hotkeyManager.isUsingHyprland() && hotkeyManager.hyprlandManager && effectiveHotkey) { + debugLogger.log( + `[IPC] Re-registering Hyprland keybinding "${effectiveHotkey}" after capture mode` + ); + const success = await hotkeyManager.hyprlandManager.registerKeybinding(effectiveHotkey); + if (success) { + hotkeyManager.currentHotkey = effectiveHotkey; + } + } } return { success: true }; @@ -1246,6 +1269,8 @@ class IPCHandlers { ipcMain.handle("get-hotkey-mode-info", async () => { return { isUsingGnome: this.windowManager.isUsingGnomeHotkeys(), + isUsingHyprland: this.windowManager.isUsingHyprlandHotkeys(), + isUsingNativeShortcut: this.windowManager.isUsingNativeShortcutHotkeys(), }; }); @@ -1791,7 +1816,10 @@ class IPCHandlers { return result; } catch (error) { - debugLogger.error("Vulkan binary download failed", { error: error.message, stack: error.stack }); + debugLogger.error("Vulkan binary download failed", { + error: error.message, + stack: error.stack, + }); return { success: false, error: error.message }; } }); diff --git a/src/helpers/windowManager.js b/src/helpers/windowManager.js index 0e4feab7..7db980d3 100644 --- a/src/helpers/windowManager.js +++ b/src/helpers/windowManager.js @@ -488,6 +488,14 @@ class WindowManager { return this.hotkeyManager.isUsingGnome(); } + isUsingHyprlandHotkeys() { + return this.hotkeyManager.isUsingHyprland(); + } + + isUsingNativeShortcutHotkeys() { + return this.hotkeyManager.isUsingNativeShortcut(); + } + async startWindowDrag() { return await this.dragManager.startWindowDrag(); } diff --git a/src/types/electron.ts b/src/types/electron.ts index 4a6bc71f..92630ded 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -634,7 +634,11 @@ declare global { enabled: boolean, newHotkey?: string | null ) => Promise<{ success: boolean }>; - getHotkeyModeInfo?: () => Promise<{ isUsingGnome: boolean }>; + getHotkeyModeInfo?: () => Promise<{ + isUsingGnome: boolean; + isUsingHyprland: boolean; + isUsingNativeShortcut: boolean; + }>; // Wayland paste diagnostics getYdotoolStatus?: () => Promise<{