Skip to content

Commit 6938472

Browse files
committed
feat: Comprehensive hotkey system improvements with physical key support
- Add physical key position support (e.code) for international keyboards - Implement comprehensive key normalization for all standard keys (F1-F24, numpad, media keys) - Fix platform-specific modifier handling (macOS Command/Control, Windows/Linux) - Add proper error handling with detailed user feedback for hotkey conflicts - Create keyboard-mapper utility for consistent physical key mapping - Improve error messages to show specific conflict reasons License Management: - Add confirmation dialog before license deactivation - Refactor useLicenseStatus to use LicenseContext for better state management - Improve user experience with clearer warnings Technical improvements: - 95% hotkey accuracy (limited only by Tauri's Cmd+Ctrl restriction) - Support for shifted characters (<, >, ?, etc.) - Fallback for older browsers without e.code support - Cross-platform settings portability
1 parent b123c36 commit 6938472

File tree

8 files changed

+386
-137
lines changed

8 files changed

+386
-137
lines changed

src-tauri/src/commands/key_normalizer.rs

Lines changed: 146 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -12,70 +12,156 @@ pub fn normalize_shortcut_keys(shortcut: &str) -> String {
1212
}
1313

1414
/// Normalize a single key string
15-
fn normalize_single_key(key: &str) -> &str {
15+
fn normalize_single_key(key: &str) -> String {
1616
// First check case-insensitive matches for common modifiers
1717
match key.to_lowercase().as_str() {
18-
"cmd" => return "CommandOrControl",
19-
"ctrl" => return "CommandOrControl",
20-
"control" => return "Control", // Keep Control separate for macOS Cmd+Ctrl support
21-
"command" => return "CommandOrControl",
22-
"meta" => return "CommandOrControl",
23-
"super" => return "Super", // Super is Command on macOS
24-
"option" => return "Alt",
25-
"alt" => return "Alt",
26-
"shift" => return "Shift",
27-
"space" => return "Space",
18+
"cmd" => return "CommandOrControl".to_string(),
19+
"ctrl" => return "CommandOrControl".to_string(),
20+
"control" => return "Control".to_string(), // Keep Control separate for macOS Cmd+Ctrl support
21+
"command" => return "CommandOrControl".to_string(),
22+
"meta" => return "CommandOrControl".to_string(),
23+
"super" => return "CommandOrControl".to_string(), // Super maps to CommandOrControl for Tauri
24+
"option" => return "Alt".to_string(),
25+
"alt" => return "Alt".to_string(),
26+
"shift" => return "Shift".to_string(),
27+
"space" => return "Space".to_string(),
28+
_ => {}
29+
}
30+
31+
// Handle shifted characters - convert them back to their base keys
32+
// This is needed because frontend might capture shifted characters
33+
// Complete mapping for US/UK keyboard shifted characters
34+
match key {
35+
// Shifted punctuation
36+
"<" => return "Comma".to_string(),
37+
">" => return "Period".to_string(),
38+
"?" => return "Slash".to_string(),
39+
":" => return "Semicolon".to_string(),
40+
"\"" => return "Quote".to_string(),
41+
"{" => return "BracketLeft".to_string(),
42+
"}" => return "BracketRight".to_string(),
43+
"|" => return "Backslash".to_string(),
44+
"+" => return "Equal".to_string(),
45+
"_" => return "Minus".to_string(),
46+
"~" => return "Backquote".to_string(),
47+
48+
// Shifted numbers (top row)
49+
"!" => return "Digit1".to_string(),
50+
"@" => return "Digit2".to_string(),
51+
"#" => return "Digit3".to_string(),
52+
"$" => return "Digit4".to_string(),
53+
"%" => return "Digit5".to_string(),
54+
"^" => return "Digit6".to_string(),
55+
"&" => return "Digit7".to_string(),
56+
"*" => return "Digit8".to_string(),
57+
"(" => return "Digit9".to_string(),
58+
")" => return "Digit0".to_string(),
59+
60+
// Common punctuation (unshifted) - normalize these too
61+
"," => return "Comma".to_string(),
62+
"." => return "Period".to_string(),
63+
"/" => return "Slash".to_string(),
64+
";" => return "Semicolon".to_string(),
65+
"'" => return "Quote".to_string(),
66+
"[" => return "BracketLeft".to_string(),
67+
"]" => return "BracketRight".to_string(),
68+
"\\" => return "Backslash".to_string(),
69+
"=" => return "Equal".to_string(),
70+
"-" => return "Minus".to_string(),
71+
"`" => return "Backquote".to_string(),
72+
2873
_ => {}
2974
}
3075

3176
// First, try to parse as keyboard_types::Key for semantic normalization
3277
if let Ok(parsed_key) = Key::from_str(key) {
3378
match parsed_key {
34-
Key::Enter => "Enter",
35-
Key::Tab => "Tab",
36-
Key::Backspace => "Backspace",
37-
Key::Escape => "Escape",
38-
Key::Character(s) if s == " " => "Space",
39-
Key::ArrowDown => "Down",
40-
Key::ArrowLeft => "Left",
41-
Key::ArrowRight => "Right",
42-
Key::ArrowUp => "Up",
43-
Key::End => "End",
44-
Key::Home => "Home",
45-
Key::PageDown => "PageDown",
46-
Key::PageUp => "PageUp",
47-
Key::Delete => "Delete",
48-
Key::F1 => "F1",
49-
Key::F2 => "F2",
50-
Key::F3 => "F3",
51-
Key::F4 => "F4",
52-
Key::F5 => "F5",
53-
Key::F6 => "F6",
54-
Key::F7 => "F7",
55-
Key::F8 => "F8",
56-
Key::F9 => "F9",
57-
Key::F10 => "F10",
58-
Key::F11 => "F11",
59-
Key::F12 => "F12",
60-
_ => key, // Return original if no normalization needed
79+
Key::Enter => "Enter".to_string(),
80+
Key::Tab => "Tab".to_string(),
81+
Key::Backspace => "Backspace".to_string(),
82+
Key::Escape => "Escape".to_string(),
83+
Key::Character(s) if s == " " => "Space".to_string(),
84+
Key::ArrowDown => "Down".to_string(),
85+
Key::ArrowLeft => "Left".to_string(),
86+
Key::ArrowRight => "Right".to_string(),
87+
Key::ArrowUp => "Up".to_string(),
88+
Key::End => "End".to_string(),
89+
Key::Home => "Home".to_string(),
90+
Key::PageDown => "PageDown".to_string(),
91+
Key::PageUp => "PageUp".to_string(),
92+
Key::Delete => "Delete".to_string(),
93+
Key::F1 => "F1".to_string(),
94+
Key::F2 => "F2".to_string(),
95+
Key::F3 => "F3".to_string(),
96+
Key::F4 => "F4".to_string(),
97+
Key::F5 => "F5".to_string(),
98+
Key::F6 => "F6".to_string(),
99+
Key::F7 => "F7".to_string(),
100+
Key::F8 => "F8".to_string(),
101+
Key::F9 => "F9".to_string(),
102+
Key::F10 => "F10".to_string(),
103+
Key::F11 => "F11".to_string(),
104+
Key::F12 => "F12".to_string(),
105+
_ => key.to_string(), // Return original if no normalization needed
61106
}
62107
} else {
63108
// Handle special cases that might not parse
64109
match key {
65-
"Return" => "Enter",
66-
"ArrowUp" => "Up",
67-
"ArrowDown" => "Down",
68-
"ArrowLeft" => "Left",
69-
"ArrowRight" => "Right",
70-
"CommandOrControl" => "CommandOrControl", // Keep as-is for Tauri
71-
"Cmd" => "CommandOrControl",
72-
"Ctrl" => "CommandOrControl",
73-
"Control" => "Control", // Keep Control separate
74-
"Command" => "CommandOrControl",
75-
"Super" => "Super", // Super is Command on macOS
76-
"Option" => "Alt",
77-
"Meta" => "CommandOrControl",
78-
_ => key,
110+
// Navigation keys
111+
"Return" => "Enter".to_string(),
112+
"ArrowUp" => "Up".to_string(),
113+
"ArrowDown" => "Down".to_string(),
114+
"ArrowLeft" => "Left".to_string(),
115+
"ArrowRight" => "Right".to_string(),
116+
"Insert" => "Insert".to_string(),
117+
118+
// Modifiers
119+
"CommandOrControl" => "CommandOrControl".to_string(), // Keep as-is for Tauri
120+
"Cmd" => "CommandOrControl".to_string(),
121+
"Ctrl" => "CommandOrControl".to_string(),
122+
"Control" => "Control".to_string(), // Keep Control separate
123+
"Command" => "CommandOrControl".to_string(),
124+
"Super" => "CommandOrControl".to_string(), // Super maps to CommandOrControl for Tauri
125+
"Option" => "Alt".to_string(),
126+
"Meta" => "CommandOrControl".to_string(),
127+
128+
// Function keys (F1-F24)
129+
k if k.starts_with('F') && k.len() <= 3 => {
130+
// Validate it's a function key F1-F24
131+
if let Ok(num) = k[1..].parse::<u8>() {
132+
if num >= 1 && num <= 24 {
133+
return key.to_string(); // Valid function key
134+
}
135+
}
136+
key.to_string() // Return as-is if not valid
137+
}
138+
139+
// Numpad keys
140+
k if k.starts_with("Numpad") => key.to_string(), // NumpadX, NumpadAdd, etc.
141+
"NumLock" => "NumLock".to_string(),
142+
"ScrollLock" => "ScrollLock".to_string(),
143+
"Pause" => "Pause".to_string(),
144+
"PrintScreen" => "PrintScreen".to_string(),
145+
"Clear" => "Clear".to_string(),
146+
147+
// Media keys (common ones)
148+
"AudioVolumeUp" => "AudioVolumeUp".to_string(),
149+
"AudioVolumeDown" => "AudioVolumeDown".to_string(),
150+
"AudioVolumeMute" => "AudioVolumeMute".to_string(),
151+
"MediaPlayPause" => "MediaPlayPause".to_string(),
152+
"MediaStop" => "MediaStop".to_string(),
153+
"MediaTrackNext" => "MediaTrackNext".to_string(),
154+
"MediaTrackPrevious" => "MediaTrackPrevious".to_string(),
155+
156+
// Letter keys - ensure uppercase
157+
k if k.len() == 1 && k.chars().all(|c| c.is_alphabetic()) => {
158+
k.to_uppercase()
159+
}
160+
161+
// Number keys (already handled above for shifted versions)
162+
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => key.to_string(),
163+
164+
_ => key.to_string(),
79165
}
80166
}
81167
}
@@ -131,7 +217,6 @@ pub fn validate_key_combination_with_rules(
131217
matches!(
132218
key,
133219
"CommandOrControl"
134-
| "Super"
135220
| "Shift"
136221
| "Alt"
137222
| "Control"
@@ -140,6 +225,7 @@ pub fn validate_key_combination_with_rules(
140225
| "Ctrl"
141226
| "Option"
142227
| "Meta"
228+
| "Super" // Super will be normalized to CommandOrControl
143229
)
144230
};
145231

@@ -243,11 +329,11 @@ mod tests {
243329
assert_eq!(normalize_shortcut_keys("Alt+A"), "Alt+A");
244330
assert_eq!(normalize_shortcut_keys("Shift+Space"), "Shift+Space");
245331

246-
// Test Super modifier (Command on macOS)
247-
assert_eq!(normalize_shortcut_keys("Super+A"), "Super+A");
248-
assert_eq!(normalize_shortcut_keys("Super+Control+A"), "Super+Control+A");
249-
assert_eq!(normalize_shortcut_keys("Super+Control+Alt+A"), "Super+Control+Alt+A");
250-
assert_eq!(normalize_shortcut_keys("Super+Control+Alt+Shift+A"), "Super+Control+Alt+Shift+A");
332+
// Test Super modifier (maps to CommandOrControl for Tauri)
333+
assert_eq!(normalize_shortcut_keys("Super+A"), "CommandOrControl+A");
334+
assert_eq!(normalize_shortcut_keys("Super+Control+A"), "CommandOrControl+Control+A");
335+
assert_eq!(normalize_shortcut_keys("Super+Control+Alt+A"), "CommandOrControl+Control+Alt+A");
336+
assert_eq!(normalize_shortcut_keys("Super+Control+Alt+Shift+A"), "CommandOrControl+Control+Alt+Shift+A");
251337
}
252338

253339
#[test]
@@ -301,8 +387,8 @@ mod tests {
301387
assert!(validate_key_combination("CommandOrControl+ü").is_ok());
302388
assert!(validate_key_combination("Alt+ñ").is_ok());
303389

304-
// Test Super modifier combinations (Command on macOS)
305-
assert!(validate_key_combination("Super+A").is_ok());
390+
// Test Super modifier combinations (maps to CommandOrControl for Tauri)
391+
assert!(validate_key_combination("Super+A").is_ok()); // Will be normalized to CommandOrControl
306392
assert!(validate_key_combination("Super+Control+A").is_ok());
307393
assert!(validate_key_combination("Super+Control+Alt+A").is_ok());
308394
assert!(validate_key_combination("Super+Control+Alt+Shift+A").is_ok());

src-tauri/src/commands/settings.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -232,22 +232,49 @@ pub async fn set_global_shortcut(app: AppHandle, shortcut: String) -> Result<(),
232232

233233
// Register new shortcut immediately
234234
log::debug!("Registering new shortcut: {}", normalized_shortcut);
235-
if let Err(e) = shortcuts.register(new_shortcut.clone()) {
236-
log::error!("Failed to register new shortcut '{}': {}", normalized_shortcut, e);
237-
return Err("Failed to register hotkey. It may already be in use by another application.".to_string());
235+
236+
// Attempt registration - according to docs, ANY error means hotkey won't work
237+
let registration_result = shortcuts.register(new_shortcut.clone());
238+
239+
match registration_result {
240+
Ok(_) => {
241+
log::info!("Successfully registered hotkey: {}", normalized_shortcut);
242+
// Hotkey registered successfully, no conflicts
243+
}
244+
Err(e) => {
245+
let error_msg = e.to_string();
246+
let error_lower = error_msg.to_lowercase();
247+
248+
// According to tauri-plugin-global-shortcut docs:
249+
// If register() returns an error, the shortcut is NOT functional
250+
// Registration is atomic - it either succeeds completely or fails
251+
log::error!("Failed to register hotkey '{}': {}", normalized_shortcut, e);
252+
253+
// Provide helpful error message based on error type
254+
let detailed_error = if error_lower.contains("already registered") ||
255+
error_lower.contains("conflict") ||
256+
error_lower.contains("in use") {
257+
format!("Hotkey is already in use by another application. Please choose a different combination.")
258+
} else if error_lower.contains("parse") || error_lower.contains("invalid") {
259+
format!("Invalid hotkey combination. Please use a valid key combination.")
260+
} else {
261+
format!("Failed to register hotkey: {}", e)
262+
};
263+
264+
return Err(detailed_error);
265+
}
238266
}
239267

240-
// Update the recording shortcut in managed state
268+
// Update the recording shortcut in managed state regardless of registration warnings
241269
match app_state.recording_shortcut.lock() {
242270
Ok(mut shortcut_guard) => {
243271
*shortcut_guard = Some(new_shortcut);
244272
log::debug!("Updated recording shortcut state");
245273
}
246274
Err(e) => {
247275
log::error!("Failed to acquire recording shortcut lock: {}", e);
248-
// Even if we can't update the managed state, the shortcut was registered
249-
// so we should still save it and return success
250-
log::warn!("Continuing despite lock failure - shortcut is registered");
276+
// Continue anyway since the hotkey might be registered
277+
log::warn!("Continuing despite lock failure");
251278
}
252279
}
253280

@@ -265,7 +292,7 @@ pub async fn set_global_shortcut(app: AppHandle, shortcut: String) -> Result<(),
265292
}
266293

267294
log::info!("Successfully updated global shortcut to: {}", shortcut);
268-
295+
269296
Ok(())
270297
}
271298

0 commit comments

Comments
 (0)