diff --git a/main.js b/main.js index ed83ef086..f45d0852a 100644 --- a/main.js +++ b/main.js @@ -574,18 +574,12 @@ async function startApp() { const savedAgentKey = environmentManager.getAgentKey?.() || ""; if (savedAgentKey) { - hotkeyManager.registerSlot("agent", savedAgentKey, agentHotkeyCallback); + const result = await hotkeyManager.registerSlot("agent", savedAgentKey, agentHotkeyCallback); + if (!result.success) { + debugLogger.warn("Failed to restore agent hotkey", { hotkey: savedAgentKey }, "hotkey"); + } } - ipcMain.on("agent-hotkey-changed", (_event, hotkey) => { - if (hotkey) { - hotkeyManager.registerSlot("agent", hotkey, agentHotkeyCallback); - environmentManager.saveAgentKey(hotkey); - } else { - hotkeyManager.unregisterSlot("agent"); - environmentManager.saveAgentKey(""); - } - }); // Phase 2: Initialize remaining managers after windows are visible initializeDeferredManagers(); diff --git a/preload.js b/preload.js index 9e12ea101..affadeb95 100644 --- a/preload.js +++ b/preload.js @@ -544,7 +544,7 @@ contextBridge.exposeInMainWorld("electronAPI", { setAutoStartEnabled: (enabled) => ipcRenderer.invoke("set-auto-start-enabled", enabled), // Agent mode - notifyAgentHotkeyChanged: (hotkey) => ipcRenderer.send("agent-hotkey-changed", hotkey), + updateAgentHotkey: (hotkey) => ipcRenderer.invoke("update-agent-hotkey", hotkey), getAgentKey: () => ipcRenderer.invoke("get-agent-key"), saveAgentKey: (key) => ipcRenderer.invoke("save-agent-key", key), onAgentStartRecording: registerListener("agent-start-recording", (callback) => () => callback()), diff --git a/src/helpers/gnomeShortcut.js b/src/helpers/gnomeShortcut.js index e548c8d3d..68028ad0c 100644 --- a/src/helpers/gnomeShortcut.js +++ b/src/helpers/gnomeShortcut.js @@ -5,8 +5,18 @@ const DBUS_SERVICE_NAME = "com.openwhispr.App"; const DBUS_OBJECT_PATH = "/com/openwhispr/App"; const DBUS_INTERFACE = "com.openwhispr.App"; -const KEYBINDING_PATH = - "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr/"; +// Per-slot gsettings paths and display names +const SLOT_CONFIG = { + dictation: { + path: "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr/", + name: "OpenWhispr Toggle", + }, + agent: { + path: "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr-agent/", + name: "OpenWhispr Agent", + }, +}; + const KEYBINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"; // Valid pattern for GNOME shortcut format (e.g., "r", "space") @@ -40,11 +50,21 @@ function getDBus() { } } +function getSlotConfig(slotName) { + const config = SLOT_CONFIG[slotName]; + if (!config) { + throw new Error(`[GnomeShortcut] Unknown slot: "${slotName}"`); + } + return config; +} + class GnomeShortcutManager { constructor() { this.bus = null; - this.callback = null; - this.isRegistered = false; + this.dictationCallback = null; + this.agentCallback = null; + // Track which slots have been registered in gsettings + this.registeredSlots = new Set(); } static isGnome() { @@ -60,8 +80,21 @@ class GnomeShortcutManager { return process.env.XDG_SESSION_TYPE === "wayland"; } - async initDBusService(callback) { - this.callback = callback; + /** + * Set or update the agent callback after initial D-Bus service initialisation. + * This supports the case where the dictation hotkey is set up first and the + * agent callback is only available later (after agent window creation). + */ + setAgentCallback(callback) { + this.agentCallback = callback; + if (this._ifaceRef) { + this._ifaceRef._agentCallback = callback; + } + debugLogger.log("[GnomeShortcut] Agent callback registered"); + } + + async initDBusService(dictationCallback) { + this.dictationCallback = dictationCallback; const dbusModule = getDBus(); if (!dbusModule) { @@ -72,8 +105,10 @@ class GnomeShortcutManager { this.bus = dbusModule.sessionBus(); await this.bus.requestName(DBUS_SERVICE_NAME, 0); - const InterfaceClass = this._createInterfaceClass(dbusModule, callback); - const iface = new InterfaceClass(); + const InterfaceClass = this._createInterfaceClass(dbusModule); + const iface = new InterfaceClass(dictationCallback, this.agentCallback); + // Keep a reference so setAgentCallback() can update it later + this._ifaceRef = iface; this.bus.export(DBUS_OBJECT_PATH, iface); debugLogger.log("[GnomeShortcut] D-Bus service initialized successfully"); @@ -88,16 +123,23 @@ class GnomeShortcutManager { } } - _createInterfaceClass(dbusModule, callback) { + _createInterfaceClass(dbusModule) { class OpenWhisprInterface extends dbusModule.interface.Interface { - constructor() { + constructor(dictationCallback, agentCallback) { super(DBUS_INTERFACE); - this._callback = callback; + this._dictationCallback = dictationCallback; + this._agentCallback = agentCallback || null; } Toggle() { - if (this._callback) { - this._callback(); + if (this._dictationCallback) { + this._dictationCallback(); + } + } + + ToggleAgent() { + if (this._agentCallback) { + this._agentCallback(); } } } @@ -105,6 +147,7 @@ class GnomeShortcutManager { OpenWhisprInterface.configureMembers({ methods: { Toggle: { inSignature: "", outSignature: "" }, + ToggleAgent: { inSignature: "", outSignature: "" }, }, }); @@ -118,41 +161,47 @@ class GnomeShortcutManager { return VALID_SHORTCUT_PATTERN.test(shortcut); } - async registerKeybinding(shortcut = "r") { + async registerKeybinding(shortcut = "r", slotName = "dictation") { if (!GnomeShortcutManager.isGnome()) { debugLogger.log("[GnomeShortcut] Not running on GNOME, skipping registration"); return false; } if (!GnomeShortcutManager.isValidShortcut(shortcut)) { - debugLogger.log(`[GnomeShortcut] Invalid shortcut format: "${shortcut}"`); + debugLogger.log( + `[GnomeShortcut] Invalid shortcut format: "${shortcut}" for slot "${slotName}"` + ); return false; } + const { path: keybindingPath, name: keybindingName } = getSlotConfig(slotName); + + // The dbus-send command for agent uses ToggleAgent, dictation uses Toggle + const dbusMethod = slotName === "agent" ? "ToggleAgent" : "Toggle"; + const command = `dbus-send --session --type=method_call --dest=${DBUS_SERVICE_NAME} ${DBUS_OBJECT_PATH} ${DBUS_INTERFACE}.${dbusMethod}`; + try { const existing = this.getExistingKeybindings(); - const alreadyRegistered = existing.includes(KEYBINDING_PATH); - - const command = `dbus-send --session --type=method_call --dest=${DBUS_SERVICE_NAME} ${DBUS_OBJECT_PATH} ${DBUS_INTERFACE}.Toggle`; + const alreadyRegistered = existing.includes(keybindingPath); execFileSync( "gsettings", - ["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "name", "OpenWhispr Toggle"], + ["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "name", keybindingName], { stdio: "pipe" } ); execFileSync( "gsettings", - ["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "binding", shortcut], + ["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "binding", shortcut], { stdio: "pipe" } ); execFileSync( "gsettings", - ["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "command", command], + ["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "command", command], { stdio: "pipe" } ); if (!alreadyRegistered) { - const newBindings = [...existing, KEYBINDING_PATH]; + const newBindings = [...existing, keybindingPath]; const bindingsStr = "['" + newBindings.join("', '") + "']"; execFileSync( "gsettings", @@ -166,43 +215,57 @@ class GnomeShortcutManager { ); } - this.isRegistered = true; - debugLogger.log(`[GnomeShortcut] Keybinding "${shortcut}" registered successfully`); + this.registeredSlots.add(slotName); + debugLogger.log( + `[GnomeShortcut] Keybinding "${shortcut}" registered for slot "${slotName}" successfully` + ); return true; } catch (err) { - debugLogger.log("[GnomeShortcut] Failed to register keybinding:", err.message); + debugLogger.log( + `[GnomeShortcut] Failed to register keybinding for slot "${slotName}":`, + err.message + ); return false; } } - async updateKeybinding(shortcut) { - if (!this.isRegistered) { - return this.registerKeybinding(shortcut); + async updateKeybinding(shortcut, slotName = "dictation") { + if (!this.registeredSlots.has(slotName)) { + return this.registerKeybinding(shortcut, slotName); } if (!GnomeShortcutManager.isValidShortcut(shortcut)) { - debugLogger.log(`[GnomeShortcut] Invalid shortcut format for update: "${shortcut}"`); + debugLogger.log( + `[GnomeShortcut] Invalid shortcut format for update: "${shortcut}" (slot "${slotName}")` + ); return false; } + const { path: keybindingPath } = getSlotConfig(slotName); + try { execFileSync( "gsettings", - ["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "binding", shortcut], + ["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "binding", shortcut], { stdio: "pipe" } ); - debugLogger.log(`[GnomeShortcut] Keybinding updated to "${shortcut}"`); + debugLogger.log(`[GnomeShortcut] Keybinding updated to "${shortcut}" for slot "${slotName}"`); return true; } catch (err) { - debugLogger.log("[GnomeShortcut] Failed to update keybinding:", err.message); + debugLogger.log( + `[GnomeShortcut] Failed to update keybinding for slot "${slotName}":`, + err.message + ); return false; } } - async unregisterKeybinding() { + async unregisterKeybinding(slotName = "dictation") { + const { path: keybindingPath } = getSlotConfig(slotName); + try { const existing = this.getExistingKeybindings(); - const filtered = existing.filter((p) => p !== KEYBINDING_PATH); + const filtered = existing.filter((p) => p !== keybindingPath); if (filtered.length === 0) { execFileSync( @@ -224,21 +287,26 @@ class GnomeShortcutManager { ); } - execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "name"], { + execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "name"], { stdio: "pipe", }); - execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "binding"], { + execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "binding"], { stdio: "pipe", }); - execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "command"], { + execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "command"], { stdio: "pipe", }); - this.isRegistered = false; - debugLogger.log("[GnomeShortcut] Keybinding unregistered successfully"); + this.registeredSlots.delete(slotName); + debugLogger.log( + `[GnomeShortcut] Keybinding unregistered for slot "${slotName}" successfully` + ); return true; } catch (err) { - debugLogger.log("[GnomeShortcut] Failed to unregister keybinding:", err.message); + debugLogger.log( + `[GnomeShortcut] Failed to unregister keybinding for slot "${slotName}":`, + err.message + ); return false; } } diff --git a/src/helpers/hotkeyManager.js b/src/helpers/hotkeyManager.js index 577b71551..9c23d4f78 100644 --- a/src/helpers/hotkeyManager.js +++ b/src/helpers/hotkeyManager.js @@ -139,8 +139,49 @@ class HotkeyManager { return suggestions.filter((s) => s !== failedHotkey).slice(0, 3); } - registerSlot(slotName, hotkey, callback) { + async registerSlot(slotName, hotkey, callback) { this.unregisterSlot(slotName); + + // On GNOME Wayland, route non-dictation slots (e.g. "agent") through the + // native GNOME keybinding system instead of Electron's globalShortcut, + // which does not work under the Wayland security model. + if (this.useGnome && this.gnomeManager && slotName !== "dictation") { + const gnomeHotkey = GnomeShortcutManager.convertToGnomeFormat(hotkey); + if (!gnomeHotkey) { + debugLogger.log( + `[HotkeyManager] Could not convert hotkey "${hotkey}" to GNOME format for slot "${slotName}"` + ); + return { success: false, error: `Invalid hotkey format for GNOME: "${hotkey}"` }; + } + + // Register (or update) the agent callback on the shared D-Bus interface + if (slotName === "agent") { + this.gnomeManager.setAgentCallback(callback); + } + + const success = await this.gnomeManager.registerKeybinding(gnomeHotkey, slotName); + if (!success) { + debugLogger.log( + `[HotkeyManager] GNOME keybinding registration failed for slot "${slotName}" ("${hotkey}")` + ); + return { + success: false, + error: `Failed to register GNOME 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] GNOME slot "${slotName}" set to "${hotkey}" (GNOME format: "${gnomeHotkey}")` + ); + return { success: true, hotkey }; + } + const result = this.setupShortcuts(hotkey, callback, slotName); if (result.success) { const slot = this.slots.get(slotName) || {}; @@ -154,6 +195,19 @@ class HotkeyManager { const slot = this.slots.get(slotName); if (!slot || !slot.hotkey) 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) => { + debugLogger.warn( + `[HotkeyManager] Error unregistering GNOME keybinding for slot "${slotName}":`, + err.message + ); + }); + slot.hotkey = null; + slot.accelerator = null; + return; + } + const hk = slot.hotkey; if (!isGlobeLikeHotkey(hk) && !isRightSideModifier(hk) && !isModifierOnlyHotkey(hk)) { const accel = normalizeToAccelerator(hk); @@ -591,9 +645,16 @@ class HotkeyManager { unregisterAll() { if (this.gnomeManager) { - this.gnomeManager.unregisterKeybinding().catch((err) => { - debugLogger.warn("[HotkeyManager] Error unregistering GNOME keybinding:", err.message); - }); + // Unregister every slot that was registered via GNOME + const gnomeSlots = [...this.gnomeManager.registeredSlots]; + for (const slotName of gnomeSlots) { + this.gnomeManager.unregisterKeybinding(slotName).catch((err) => { + debugLogger.warn( + `[HotkeyManager] Error unregistering GNOME keybinding for slot "${slotName}":`, + err.message + ); + }); + } this.gnomeManager.close(); this.gnomeManager = null; this.useGnome = false; diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index d95008a0f..f21433a2d 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -3663,7 +3663,23 @@ class IPCHandlers { if (!agentCallback) { return { success: false, message: "Agent hotkey callback not initialized" }; } - return hotkeyManager.registerSlot("agent", hotkey, agentCallback); + + if (!hotkey) { + hotkeyManager.unregisterSlot("agent"); + this.environmentManager.saveAgentKey?.(""); + return { success: true, message: "Agent hotkey cleared" }; + } + + const result = await hotkeyManager.registerSlot("agent", hotkey, agentCallback); + if (result.success) { + this.environmentManager.saveAgentKey?.(hotkey); + return { success: true, message: `Agent hotkey updated to: ${hotkey}` }; + } + + return { + success: false, + message: result.error || `Failed to update agent hotkey to: ${hotkey}`, + }; }); ipcMain.handle("get-agent-key", async () => { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index bfb84313c..233927032 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -486,12 +486,44 @@ export const useSettingsStore = create()((set, get) => ({ setAgentModel: createStringSetter("agentModel"), setAgentProvider: createStringSetter("agentProvider"), setAgentKey: (key: string) => { - if (isBrowser) localStorage.setItem("agentKey", key); - useSettingsStore.setState({ agentKey: key }); - if (isBrowser) { - window.electronAPI?.notifyAgentHotkeyChanged?.(key); + if (!isBrowser) { + useSettingsStore.setState({ agentKey: key }); + return; + } + + const updateAgentHotkey = window.electronAPI?.updateAgentHotkey; + if (!updateAgentHotkey) { + localStorage.setItem("agentKey", key); + useSettingsStore.setState({ agentKey: key }); window.electronAPI?.saveAgentKey?.(key); + return; } + + const previousKey = get().agentKey; + + void updateAgentHotkey(key) + .then((result) => { + if (!result?.success) { + localStorage.setItem("agentKey", previousKey); + useSettingsStore.setState({ agentKey: previousKey }); + logger.warn( + "Failed to update agent hotkey", + { hotkey: key, message: result?.message }, + "settings" + ); + return; + } + + localStorage.setItem("agentKey", key); + useSettingsStore.setState({ agentKey: key }); + }) + .catch((error) => { + logger.warn( + "Failed to update agent hotkey", + { hotkey: key, error: error instanceof Error ? error.message : String(error) }, + "settings" + ); + }); }, setAgentSystemPrompt: createStringSetter("agentSystemPrompt"), setAgentEnabled: createBooleanSetter("agentEnabled"), diff --git a/src/types/electron.ts b/src/types/electron.ts index c7815b2b3..cf6d2e42b 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -933,7 +933,7 @@ declare global { }>; // Agent Mode - notifyAgentHotkeyChanged?: (hotkey: string) => void; + updateAgentHotkey?: (hotkey: string) => Promise<{ success: boolean; message: string }>; getAgentKey?: () => Promise; saveAgentKey?: (key: string) => Promise; createAgentConversation?: (title: string) => Promise<{