disallow invalid bindings from frontend

This commit is contained in:
CJ Pais 2025-08-02 18:43:59 -07:00
parent dd605e355e
commit 98eb336ab0
3 changed files with 81 additions and 1 deletions

View file

@ -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,

View file

@ -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::<Shortcut>() {
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();

View file

@ -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<HandyShortcutProps> = ({
// 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<HandyShortcutProps> = ({
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<HandyShortcutProps> = ({
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<HandyShortcutProps> = ({
]);
// 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);