diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index 97c9718a2b..e3d2137f2f 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -129,8 +129,13 @@ pub fn init(app: &AppHandle) { }; let global_shortcut = app.global_shortcut(); - for hotkey in store.hotkeys.values() { - global_shortcut.register(Shortcut::from(*hotkey)).ok(); + for (action, hotkey) in store.hotkeys.iter() { + // A shortcut stored on a previous run can stop being registrable, e.g. + // another application claimed it since. Surface it in the log rather + // than starting up with a binding the user believes is active. + if let Err(e) = global_shortcut.register(Shortcut::from(*hotkey)) { + tracing::warn!(?action, ?hotkey, "failed to register stored hotkey: {e}"); + } } app.manage(Mutex::new(store)); @@ -258,27 +263,63 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin #[tauri::command(async)] #[specta::specta] #[instrument(skip(app))] -pub fn set_hotkey(app: AppHandle, action: HotkeyAction, hotkey: Option) -> Result<(), ()> { +pub fn set_hotkey( + app: AppHandle, + action: HotkeyAction, + hotkey: Option, +) -> Result<(), String> { let global_shortcut = app.global_shortcut(); let state = app.state::(); let mut store = state.lock().unwrap(); let prev = store.hotkeys.get(&action).cloned(); + // Apply to the store first so the "is this combination still used by + // another action?" check below sees the post-change state, then reconcile + // the OS registrations. if let Some(hotkey) = hotkey { store.hotkeys.insert(action, hotkey); } else { store.hotkeys.remove(&action); } + // Release the previous combination before registering the new one: the + // underlying global-hotkey layer rejects a shortcut that is already + // registered, so re-binding an action to a combination another action just + // gave up would otherwise fail. if let Some(prev) = prev && !store.hotkeys.values().any(|h| h == &prev) { - global_shortcut.unregister(Shortcut::from(prev)).ok(); + if let Err(e) = global_shortcut.unregister(Shortcut::from(prev)) { + // Not fatal on its own; log it so a stale registration that keeps + // firing the old combination is diagnosable. + tracing::warn!(?action, prev = ?prev, "failed to unregister previous hotkey: {e}"); + } } if let Some(hotkey) = hotkey { - global_shortcut.register(Shortcut::from(hotkey)).ok(); + // The OS can refuse a shortcut: reserved by the system (PrintScreen on + // Windows) or already claimed by another application. Previously this + // was `.ok()`, so the binding was stored and shown in Settings while + // pressing it did nothing. + if let Err(e) = global_shortcut.register(Shortcut::from(hotkey)) { + tracing::warn!(?action, ?hotkey, "failed to register hotkey: {e}"); + + // Roll the store back so it matches what is actually registered. + match prev { + Some(prev) => { + store.hotkeys.insert(action, prev); + global_shortcut.register(Shortcut::from(prev)).ok(); + } + None => { + store.hotkeys.remove(&action); + } + } + + return Err(format!( + "Could not register this shortcut. It may already be in use by another application, or reserved by the system. ({e})" + )); + } } Ok(()) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx index 055d131fa2..14da60eb21 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx @@ -12,6 +12,7 @@ import { Switch, } from "solid-js"; import { createStore } from "solid-js/store"; +import toast from "solid-toast"; import { hotkeysStore } from "~/store"; import { @@ -141,14 +142,29 @@ function Inner(props: { initialStore: HotkeysStore | null }) { class="w-fit" type="button" onBlur={(e) => console.log(e)} - onClick={(e) => { + onClick={async (e) => { e.stopPropagation(); + const action = item(); + const binding = hotkeys[action] ?? null; + const previous = listening()?.prev; + setListening(); - commands.setHotkey( - item(), - hotkeys[item()] ?? null, - ); + + try { + await commands.setHotkey(action, binding); + } catch (error) { + // The OS can refuse a shortcut (reserved, or + // already taken by another app). Roll the row + // back so it doesn't display as bound when + // pressing it will do nothing. + setHotkeys(action, previous); + toast.error( + typeof error === "string" + ? error + : "Could not register this shortcut.", + ); + } }} >