diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index a91a203e..5e21e394 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -642,9 +642,7 @@ pub fn set_dictation_hotkey( binding: ShortcutBinding, ) -> Result<(), String> { crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; - if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { - return Err("Shift 单键目前只能用于翻译快捷键".into()); - } + reject_bare_shift_dictation_shortcut(&binding)?; let mut prefs = coord.prefs().get(); prefs.dictation_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -706,11 +704,12 @@ fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), #[tauri::command] pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { - crate::combo_hotkey::validate_binding(&ShortcutBinding { + let shortcut = ShortcutBinding { primary: binding.primary, modifiers: binding.modifiers, - }) - .map_err(|e| e.to_string()) + }; + reject_bare_shift_dictation_shortcut(&shortcut)?; + crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string()) } /// 设置自定义录音组合键并热更新 monitor。 @@ -721,6 +720,7 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R primary: binding.primary.clone(), modifiers: binding.modifiers.clone(), }; + reject_bare_shift_dictation_shortcut(&shortcut)?; crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())?; prefs.custom_combo_hotkey = Some(binding); prefs.dictation_hotkey = shortcut; @@ -731,6 +731,13 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R Ok(()) } +fn reject_bare_shift_dictation_shortcut(binding: &ShortcutBinding) -> Result<(), String> { + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Err("Shift 单键目前只能用于翻译快捷键".into()); + } + Ok(()) +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] @@ -845,6 +852,19 @@ mod tests { assert!(result.is_err()); } + #[test] + fn combo_hotkey_bare_shift_rejection_matches_dictation_setter() { + let binding = ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }; + + assert_eq!( + super::reject_bare_shift_dictation_shortcut(&binding), + Err("Shift 单键目前只能用于翻译快捷键".into()) + ); + } + #[tokio::test] async fn fetch_provider_models_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index dd17d7a6..e844722b 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -441,7 +441,7 @@ impl PreferencesStore { ensure_dir(&dir)?; let path = dir.join(PREFERENCES_FILE); let prefs = if path.exists() { - read_or_default::(&path).unwrap_or_default() + read_or_default::(&path)? } else { UserPreferences::default() }; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 93708ea3..2b37f11a 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -192,9 +192,11 @@ impl<'de> Deserialize<'de> for UserPreferences { D: serde::Deserializer<'de>, { let wire = UserPreferencesWire::deserialize(deserializer)?; - let dictation_hotkey = wire.dictation_hotkey.unwrap_or_else(|| { - default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) - }); + let dictation_hotkey = match wire.dictation_hotkey { + Some(binding) => binding, + None => default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) + .map_err(serde::de::Error::custom)?, + }; Ok(Self { hotkey: wire.hotkey, dictation_hotkey, @@ -261,16 +263,21 @@ fn default_app_shortcut_modifiers() -> Vec { fn default_dictation_hotkey_from_legacy( hotkey: &HotkeyBinding, custom_combo_hotkey: &Option, -) -> ShortcutBinding { +) -> Result { if hotkey.trigger == HotkeyTrigger::Custom { if let Some(combo) = custom_combo_hotkey { - return ShortcutBinding { + return Ok(ShortcutBinding { primary: combo.primary.clone(), modifiers: combo.modifiers.clone(), - }; + }); } + return Err( + "hotkey.trigger is custom but dictationHotkey/customComboHotkey is missing".into(), + ); } - crate::shortcut_binding::binding_from_legacy_trigger(hotkey.trigger) + Ok(crate::shortcut_binding::binding_from_legacy_trigger( + hotkey.trigger, + )) } fn default_working_languages() -> Vec { @@ -284,7 +291,8 @@ impl Default for UserPreferences { dictation_hotkey: default_dictation_hotkey_from_legacy( &HotkeyBinding::default(), &None, - ), + ) + .expect("default legacy hotkey is not custom"), default_mode: PolishMode::Light, enabled_modes: vec![ PolishMode::Raw, @@ -759,4 +767,43 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + + #[test] + fn legacy_custom_hotkey_without_custom_binding_is_rejected() { + let result = serde_json::from_str::( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" } + }"#, + ); + + assert!(result.is_err()); + } + + #[test] + fn legacy_custom_hotkey_uses_custom_combo_binding() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" }, + "customComboHotkey": { "primary": "D", "modifiers": ["cmd", "shift"] } + }"#, + ) + .unwrap(); + + assert_eq!(prefs.dictation_hotkey.primary, "D"); + assert_eq!(prefs.dictation_hotkey.modifiers, vec!["cmd", "shift"]); + } + + #[test] + fn custom_hotkey_with_dictation_hotkey_preserves_dictation_binding() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" }, + "dictationHotkey": { "primary": "Space", "modifiers": ["ctrl"] } + }"#, + ) + .unwrap(); + + assert_eq!(prefs.dictation_hotkey.primary, "Space"); + assert_eq!(prefs.dictation_hotkey.modifiers, vec!["ctrl"]); + } }