diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 59ecc47..68b5b52 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -183,6 +183,8 @@ pub fn run() { shortcut::change_selected_language_setting, shortcut::change_show_overlay_setting, shortcut::change_debug_mode_setting, + shortcut::suspend_binding, + shortcut::resume_binding, trigger_update_check, commands::cancel_operation, commands::get_app_dir_path, diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index d2d609b..cae54f3 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -56,6 +56,9 @@ pub fn change_binding( }); } + // Validate the new shortcut before we touch the current registration + validate_shortcut_string(&binding)?; + // Create an updated binding let mut updated_binding = binding_to_modify; updated_binding.current_binding = binding; @@ -143,13 +146,61 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St Ok(()) } +/// Determine whether a shortcut string contains at least one non-modifier key. +/// We allow single non-modifier keys (e.g. "f5" or "space") but disallow +/// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). +fn validate_shortcut_string(raw: &str) -> Result<(), String> { + let modifiers = [ + "ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win", + "windows", + ]; + let has_non_modifier = raw + .split('+') + .any(|part| !modifiers.contains(&part.trim().to_lowercase().as_str())); + if has_non_modifier { + Ok(()) + } else { + Err("Shortcut must contain at least one non-modifier key".into()) + } +} + +/// Temporarily unregister a binding while the user is editing it in the UI. +/// This avoids firing the action while keys are being recorded. +#[tauri::command] +pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { + if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { + _unregister_shortcut(&app, b)?; + } + Ok(()) +} + +/// Re-register the binding after the user has finished editing. +#[tauri::command] +pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { + if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { + _register_shortcut(&app, b)?; + } + Ok(()) +} + fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + // Validate human-level rules first + validate_shortcut_string(&binding.current_binding)?; + // Parse shortcut and return error if it fails let shortcut = match binding.current_binding.parse::() { Ok(s) => s, Err(e) => return Err(format!("Failed to parse shortcut: {}", e)), }; + // Prevent duplicate registrations that would silently shadow one another + if app.global_shortcut().is_registered(shortcut) { + return Err(format!( + "Shortcut '{}' is already in use", + binding.current_binding + )); + } + // Clone binding.id for use in the closure let binding_id_for_closure = binding.id.clone(); diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 6ea5459..2819a30 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -5,6 +5,7 @@ import { getKeyName } from "../../lib/utils/keyboard"; import ResetIcon from "../icons/ResetIcon"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; +import { invoke } from "@tauri-apps/api/core"; interface HandyShortcutProps { descriptionMode?: "inline" | "tooltip"; @@ -82,6 +83,20 @@ export const HandyShortcut: React.FC = ({ // Keyboard event listeners const handleKeyDown = async (e: KeyboardEvent) => { + if (e.repeat) return; // ignore auto-repeat + if (e.key === "Escape") { + // Cancel recording + if (editingShortcutId) { + await invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); + } + setEditingShortcutId(null); + setKeyPressed([]); + setRecordedKeys([]); + setOriginalBinding(""); + return; + } e.preventDefault(); // Get the key and normalize it (unify left/right modifiers) @@ -118,6 +133,10 @@ export const HandyShortcut: React.FC = ({ if (editingShortcutId && bindings[editingShortcutId]) { try { await updateBinding(editingShortcutId, newShortcut); + // Re-register the shortcut now that recording is finished + await invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); } catch (error) { console.error("Failed to change binding:", error); } @@ -136,6 +155,11 @@ export const HandyShortcut: React.FC = ({ const activeElement = shortcutRefs.current.get(editingShortcutId); if (activeElement && !activeElement.contains(e.target as Node)) { // Cancel shortcut recording - the hook will handle rollback + if (editingShortcutId) { + invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); + } setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); @@ -162,9 +186,12 @@ export const HandyShortcut: React.FC = ({ ]); // Start recording a new shortcut - const startRecording = (id: string) => { + const startRecording = async (id: string) => { if (editingShortcutId === id) return; // Already editing this shortcut + // Suspend current binding to avoid firing while recording + await invoke("suspend_binding", { id }).catch(console.error); + // Store the original binding to restore if canceled setOriginalBinding(bindings[id]?.current_binding || ""); setEditingShortcutId(id);