disallow invalid bindings from frontend
This commit is contained in:
parent
dd605e355e
commit
98eb336ab0
3 changed files with 81 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue