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_selected_language_setting,
|
||||||
shortcut::change_show_overlay_setting,
|
shortcut::change_show_overlay_setting,
|
||||||
shortcut::change_debug_mode_setting,
|
shortcut::change_debug_mode_setting,
|
||||||
|
shortcut::suspend_binding,
|
||||||
|
shortcut::resume_binding,
|
||||||
trigger_update_check,
|
trigger_update_check,
|
||||||
commands::cancel_operation,
|
commands::cancel_operation,
|
||||||
commands::get_app_dir_path,
|
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
|
// Create an updated binding
|
||||||
let mut updated_binding = binding_to_modify;
|
let mut updated_binding = binding_to_modify;
|
||||||
updated_binding.current_binding = binding;
|
updated_binding.current_binding = binding;
|
||||||
|
|
@ -143,13 +146,61 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St
|
||||||
Ok(())
|
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> {
|
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
|
// Parse shortcut and return error if it fails
|
||||||
let shortcut = match binding.current_binding.parse::<Shortcut>() {
|
let shortcut = match binding.current_binding.parse::<Shortcut>() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => return Err(format!("Failed to parse shortcut: {}", e)),
|
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
|
// Clone binding.id for use in the closure
|
||||||
let binding_id_for_closure = binding.id.clone();
|
let binding_id_for_closure = binding.id.clone();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getKeyName } from "../../lib/utils/keyboard";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
import ResetIcon from "../icons/ResetIcon";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
interface HandyShortcutProps {
|
interface HandyShortcutProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
|
@ -82,6 +83,20 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
|
|
||||||
// Keyboard event listeners
|
// Keyboard event listeners
|
||||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
// Get the key and normalize it (unify left/right modifiers)
|
// Get the key and normalize it (unify left/right modifiers)
|
||||||
|
|
@ -118,6 +133,10 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
if (editingShortcutId && bindings[editingShortcutId]) {
|
if (editingShortcutId && bindings[editingShortcutId]) {
|
||||||
try {
|
try {
|
||||||
await updateBinding(editingShortcutId, newShortcut);
|
await updateBinding(editingShortcutId, newShortcut);
|
||||||
|
// Re-register the shortcut now that recording is finished
|
||||||
|
await invoke("resume_binding", { id: editingShortcutId }).catch(
|
||||||
|
console.error,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to change binding:", error);
|
console.error("Failed to change binding:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +155,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
const activeElement = shortcutRefs.current.get(editingShortcutId);
|
const activeElement = shortcutRefs.current.get(editingShortcutId);
|
||||||
if (activeElement && !activeElement.contains(e.target as Node)) {
|
if (activeElement && !activeElement.contains(e.target as Node)) {
|
||||||
// Cancel shortcut recording - the hook will handle rollback
|
// Cancel shortcut recording - the hook will handle rollback
|
||||||
|
if (editingShortcutId) {
|
||||||
|
invoke("resume_binding", { id: editingShortcutId }).catch(
|
||||||
|
console.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
setEditingShortcutId(null);
|
setEditingShortcutId(null);
|
||||||
setKeyPressed([]);
|
setKeyPressed([]);
|
||||||
setRecordedKeys([]);
|
setRecordedKeys([]);
|
||||||
|
|
@ -162,9 +186,12 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Start recording a new shortcut
|
// Start recording a new shortcut
|
||||||
const startRecording = (id: string) => {
|
const startRecording = async (id: string) => {
|
||||||
if (editingShortcutId === id) return; // Already editing this shortcut
|
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
|
// Store the original binding to restore if canceled
|
||||||
setOriginalBinding(bindings[id]?.current_binding || "");
|
setOriginalBinding(bindings[id]?.current_binding || "");
|
||||||
setEditingShortcutId(id);
|
setEditingShortcutId(id);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue