From c18f3046670e792cba029f4247a88ffd7876cbb6 Mon Sep 17 00:00:00 2001 From: Eclock2000 <127169032+Eclock2000@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:57:51 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(hotkeys):=20=E6=94=AF=E6=8C=81=E4=B8=93?= =?UTF-8?q?=E7=94=A8=20F20=20=E4=B8=8E=20Mac=20=E5=8E=9F=E7=94=9F=E5=90=AC?= =?UTF-8?q?=E5=86=99=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crates/openless-core/src/shared_types.rs | 31 ++ .../openless-core/src/shortcut_types.rs | 21 +- .../app/src-tauri/src/combo_hotkey.rs | 49 +++ .../app/src-tauri/src/commands/hotkeys.rs | 108 ++++-- .../app/src-tauri/src/commands/settings.rs | 49 +++ openless-all/app/src-tauri/src/coordinator.rs | 29 +- .../src-tauri/src/coordinator/hotkey_loops.rs | 58 +++- .../src/coordinator/native_dictation_key.rs | 107 ++++++ openless-all/app/src-tauri/src/lib.rs | 6 + .../app/src-tauri/src/macos_dictation_key.rs | 308 ++++++++++++++++++ .../app/src-tauri/src/shortcut_binding.rs | 55 ++++ .../app/src-tauri/src/side_aware_combo.rs | 43 ++- .../src/components/MacDictationKeySetup.tsx | 120 +++++++ .../app/src/components/ShortcutRecorder.tsx | 3 + openless-all/app/src/i18n/en.ts | 21 ++ openless-all/app/src/i18n/zh-CN.ts | 21 ++ openless-all/app/src/i18n/zh-TW.ts | 21 ++ openless-all/app/src/lib/hotkey.ts | 1 + .../app/src/lib/hotkeyRecorder.test.ts | 9 + openless-all/app/src/lib/hotkeyRecorder.ts | 8 + openless-all/app/src/lib/types.ts | 1 + .../src/pages/settings/ShortcutsSection.tsx | 4 +- 22 files changed, 1029 insertions(+), 44 deletions(-) create mode 100644 openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs create mode 100644 openless-all/app/src-tauri/src/macos_dictation_key.rs create mode 100644 openless-all/app/src/components/MacDictationKeySetup.tsx diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 6266a809b..338b8e077 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -320,6 +320,7 @@ fn resolve_windows_sendinput_insertion_only_legacy( pub struct UserPreferences { pub hotkey: HotkeyBinding, pub dictation_hotkey: ShortcutBinding, + pub previous_dictation_hotkey: Option, pub default_mode: PolishMode, pub enabled_modes: Vec, #[serde(default = "default_active_style_pack_id")] @@ -749,6 +750,7 @@ fn default_active_asr_provider() -> String { struct UserPreferencesWire { hotkey: HotkeyBinding, dictation_hotkey: Option, + previous_dictation_hotkey: Option, default_mode: PolishMode, enabled_modes: Vec, #[serde(default)] @@ -980,6 +982,7 @@ impl Default for UserPreferencesWire { Self { hotkey: prefs.hotkey, dictation_hotkey: None, + previous_dictation_hotkey: None, default_mode: prefs.default_mode, enabled_modes: prefs.enabled_modes, active_style_pack_id: Some(prefs.active_style_pack_id), @@ -1125,6 +1128,7 @@ impl<'de> Deserialize<'de> for UserPreferences { Ok(Self { hotkey: wire.hotkey, dictation_hotkey, + previous_dictation_hotkey: wire.previous_dictation_hotkey, default_mode: wire.default_mode, enabled_modes: wire.enabled_modes, active_style_pack_id: wire @@ -1479,6 +1483,7 @@ impl Default for UserPreferences { &None, ) .expect("default legacy hotkey is not custom"), + previous_dictation_hotkey: None, default_mode: PolishMode::Structured, enabled_modes: vec![ PolishMode::Raw, @@ -3170,6 +3175,32 @@ mod tests { assert_eq!(prefs.dictation_hotkey.modifiers, vec!["cmd", "shift"]); } + #[test] + fn native_dictation_fallback_survives_preferences_roundtrip() { + let mut prefs = UserPreferences::default(); + let fallback = ShortcutBinding { + primary: "F20".into(), + modifiers: vec![], + }; + prefs.dictation_hotkey = ShortcutBinding { + primary: "MacDictationKey".into(), + modifiers: vec![], + }; + prefs.previous_dictation_hotkey = Some(fallback.clone()); + prefs.hotkey.trigger = HotkeyTrigger::Custom; + let json = serde_json::to_value(&prefs).unwrap(); + let restored: UserPreferences = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(restored.previous_dictation_hotkey, Some(fallback)); + assert_eq!(restored.dictation_hotkey.primary, "MacDictationKey"); + let mut old_json = json; + old_json + .as_object_mut() + .unwrap() + .remove("previousDictationHotkey"); + let legacy: UserPreferences = serde_json::from_value(old_json).unwrap(); + assert!(legacy.previous_dictation_hotkey.is_none()); + } + #[test] fn custom_hotkey_with_dictation_hotkey_preserves_dictation_binding() { let prefs: UserPreferences = serde_json::from_str( diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index c870db840..f56c66d69 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -51,6 +51,9 @@ pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str = "Side-specific modifier shortcuts are only supported for dictation start/stop."; pub fn reject_side_specific_non_dictation(binding: &ShortcutBinding) -> Result<(), String> { + if binding.primary == "MacDictationKey" { + return Err("The Mac Dictation key is only supported for dictation start/stop.".into()); + } if binding_requires_side_aware_hook(binding) { return Err(SIDE_SPECIFIC_NON_DICTATION_MSG.to_string()); } @@ -193,6 +196,10 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { } pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + #[cfg(target_os = "macos")] + if binding.primary == "MacDictationKey" && binding.modifiers.is_empty() { + return Ok(()); + } if legacy_modifier_trigger(binding).is_some() { return Ok(()); } @@ -278,6 +285,14 @@ fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> { | "F10" | "F11" | "F12" + | "F13" + | "F14" + | "F15" + | "F16" + | "F17" + | "F18" + | "F19" + | "F20" ) { return Ok(()); } @@ -785,7 +800,11 @@ mod tests { fn validates_shared_shortcut_grammar_without_a_native_hotkey_crate() { assert!(validate_shortcut_binding(&combo("D", &["cmd", "shift"])).is_ok()); assert!(validate_shortcut_binding(&combo("?", &["shift"])).is_ok()); - assert!(validate_shortcut_binding(&combo("F12", &[])).is_ok()); + for number in 1..=20 { + assert!(validate_shortcut_binding(&combo(&format!("F{number}"), &[])).is_ok()); + } + assert!(validate_shortcut_binding(&combo(" f20 ", &[])).is_ok()); + assert!(validate_shortcut_binding(&combo("F21", &[])).is_err()); assert_eq!( validate_shortcut_binding(&combo("D", &["hyper"])), Err(ShortcutBindingError::UnsupportedModifier("hyper".into())) diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index 6792160ea..e2daaba0e 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -50,6 +50,8 @@ pub struct ComboHotkeyMonitor { struct Inner { registered: Mutex>, + #[cfg(target_os = "macos")] + native_dictation: Mutex>, tx: Sender, } @@ -67,6 +69,18 @@ impl ComboHotkeyMonitor { binding: ShortcutBinding, tx: Sender, ) -> Result { + #[cfg(target_os = "macos")] + if is_native_dictation(&binding) { + let native = crate::macos_dictation_key::Monitor::start(tx.clone()) + .map_err(ComboHotkeyError::RegisterFailed)?; + return Ok(Self { + inner: Arc::new(Inner { + registered: Mutex::new(None), + native_dictation: Mutex::new(Some(native)), + tx, + }), + }); + } let runtime = GlobalHotkeyRuntime::shared() .map_err(|e| ComboHotkeyError::ManagerInitFailed(e.to_string()))?; @@ -87,6 +101,8 @@ impl ComboHotkeyMonitor { Ok(Self { inner: Arc::new(Inner { registered: Mutex::new(Some(registered)), + #[cfg(target_os = "macos")] + native_dictation: Mutex::new(None), tx, }), }) @@ -94,6 +110,17 @@ impl ComboHotkeyMonitor { /// 替换当前注册的组合键(用户在设置里改了组合键时)。 pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), ComboHotkeyError> { + #[cfg(target_os = "macos")] + if is_native_dictation(&binding) { + if self.native_dictation_active() { + return Ok(()); + } + let native = crate::macos_dictation_key::Monitor::start(self.inner.tx.clone()) + .map_err(ComboHotkeyError::RegisterFailed)?; + *self.inner.native_dictation.lock() = Some(native); + self.inner.registered.lock().take(); + return Ok(()); + } let next = parse_binding(&binding)?; let mut current = self.inner.registered.lock(); if let Some(prev) = current.as_ref() { @@ -115,13 +142,31 @@ impl ComboHotkeyMonitor { }) .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; *current = Some(registered); + #[cfg(target_os = "macos")] + self.inner.native_dictation.lock().take(); Ok(()) } } +#[cfg(target_os = "macos")] +fn is_native_dictation(binding: &ShortcutBinding) -> bool { + binding.primary == crate::macos_dictation_key::PRIMARY && binding.modifiers.is_empty() +} +#[cfg(target_os = "macos")] +impl ComboHotkeyMonitor { + pub fn native_dictation_active(&self) -> bool { + self.inner + .native_dictation + .lock() + .as_ref() + .is_some_and(|m| m.active()) + } +} impl Drop for ComboHotkeyMonitor { fn drop(&mut self) { self.inner.registered.lock().take(); + #[cfg(target_os = "macos")] + self.inner.native_dictation.lock().take(); } } @@ -145,6 +190,10 @@ fn forward_loop(hotkey_id: u32, rx: Receiver, tx: Sender Result<(), ComboHotkeyError> { + #[cfg(target_os = "macos")] + if is_native_dictation(binding) { + return Ok(()); + } parse_binding(binding)?; Ok(()) } diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index f22dbefea..48c63e60c 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -14,17 +14,16 @@ pub fn validate_shortcut_binding(binding: ShortcutBinding) -> Result<(), String> } #[tauri::command] -pub fn set_dictation_hotkey( +pub async fn set_dictation_hotkey( coord: CoordinatorState<'_>, binding: ShortcutBinding, ) -> Result<(), String> { - crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; - reject_bare_shift_dictation_shortcut(&binding)?; - let mut prefs = coord.backend().get_preferences(); - prefs.dictation_hotkey = binding; - sync_dictation_hotkey_legacy_fields(&mut prefs); - reject_hotkey_collisions(&prefs)?; - super::settings::persist_strict_settings(&coord, prefs) + let coord = Arc::clone(coord.inner()); + tauri::async_runtime::spawn_blocking(move || { + super::settings::replace_dictation_hotkey(&coord, binding, None) + }) + .await + .map_err(|error| error.to_string())? } #[tauri::command] @@ -120,19 +119,21 @@ pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { /// 设置自定义录音组合键并热更新 monitor。 #[tauri::command] -pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> Result<(), String> { - let mut prefs = coord.backend().get_preferences(); +pub async fn set_combo_hotkey( + coord: CoordinatorState<'_>, + binding: ComboBinding, +) -> Result<(), String> { let shortcut = ShortcutBinding { - primary: binding.primary.clone(), - modifiers: binding.modifiers.clone(), + primary: binding.primary, + modifiers: binding.modifiers, }; - 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; - sync_dictation_hotkey_legacy_fields(&mut prefs); - reject_hotkey_collisions(&prefs)?; - super::settings::persist_strict_settings(&coord, prefs) + crate::combo_hotkey::validate_binding(&shortcut).map_err(|error| error.to_string())?; + let coord = Arc::clone(coord.inner()); + tauri::async_runtime::spawn_blocking(move || { + super::settings::replace_dictation_hotkey(&coord, shortcut, None) + }) + .await + .map_err(|error| error.to_string())? } #[cfg(test)] @@ -365,3 +366,72 @@ mod tests { assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_ok()); } } + +#[tauri::command] +pub async fn test_macos_dictation_key(coord: CoordinatorState<'_>) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let coord = Arc::clone(coord.inner()); + return tauri::async_runtime::spawn_blocking(move || { + let test = crate::macos_dictation_key::TestGuard::begin()?; + if coord.dictation_key_setup_is_busy() { + return Err("macDictationKeyBusy".into()); + } + test.wait() + }) + .await + .map_err(|e| e.to_string())?; + } + #[cfg(not(target_os = "macos"))] + { + let _ = coord; + Err("macDictationKeyUnavailable".into()) + } +} + +#[tauri::command] +pub fn cancel_macos_dictation_key_test() { + #[cfg(target_os = "macos")] + crate::macos_dictation_key::cancel_test(); +} + +#[tauri::command] +pub async fn activate_macos_dictation_key( + coord: CoordinatorState<'_>, + expected_binding: ShortcutBinding, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let coord = Arc::clone(coord.inner()); + return tauri::async_runtime::spawn_blocking(move || { + super::settings::replace_dictation_hotkey( + &coord, + ShortcutBinding { + primary: crate::macos_dictation_key::PRIMARY.into(), + modifiers: vec![], + }, + Some(expected_binding), + ) + }) + .await + .map_err(|e| e.to_string())?; + } + #[cfg(not(target_os = "macos"))] + { + let _ = (coord, expected_binding); + Err("macDictationKeyUnavailable".into()) + } +} + +#[tauri::command] +pub fn macos_dictation_key_active(coord: CoordinatorState<'_>) -> bool { + #[cfg(target_os = "macos")] + { + return coord.native_dictation_key_active(); + } + #[cfg(not(target_os = "macos"))] + { + let _ = coord; + false + } +} diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2e16703ab..111de16eb 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -576,3 +576,52 @@ pub async fn app_download_and_install_android_update( Err("应用内更新仅支持 Android".to_string()) } } + +/// Read and replace under the same host gate so a setup confirmation cannot +/// overwrite a shortcut changed by another settings window in the meantime. +pub(crate) fn replace_dictation_hotkey( + coord: &Coordinator, + binding: ShortcutBinding, + expected: Option, +) -> Result<(), String> { + let _host_guard = coord.lock_settings_host(); + let mut prefs = coord.backend().get_preferences(); + if expected + .as_ref() + .is_some_and(|old| old != &prefs.dictation_hotkey) + { + return Err("macDictationKeyChanged".into()); + } + crate::shortcut_binding::validate_binding(&binding).map_err(|error| error.to_string())?; + reject_bare_shift_dictation_shortcut(&binding)?; + #[cfg(target_os = "macos")] + { + let native = crate::macos_dictation_key::PRIMARY; + if prefs.dictation_hotkey.primary == native || binding.primary == native { + if coord.dictation_key_setup_is_busy() { + return Err("macDictationKeyBusy".into()); + } + if binding == prefs.dictation_hotkey { + // No settings effect is generated for an unchanged binding. + return coord.try_update_native_dictation_binding(); + } + prefs.previous_dictation_hotkey = if binding.primary == native { + Some(prefs.dictation_hotkey.clone()) + } else { + None + }; + } + } + prefs.dictation_hotkey = binding; + sync_dictation_hotkey_legacy_fields(&mut prefs); + reject_hotkey_collisions(&prefs)?; + coord + .backend() + .update_settings( + prefs, + openless_core::SettingsUpdateOptions::STRICT, + &TauriSettingsRuntime::new(coord), + ) + .map(|_| ()) + .map_err(|error| error.to_string()) +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 6727f108f..410cbae4f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -34,6 +34,8 @@ mod capsule_focus; #[path = "coordinator/dictation_core.rs"] mod dictation; mod hotkey_loops; +#[cfg(target_os = "macos")] +mod native_dictation_key; mod qa; #[cfg(all(not(mobile), target_os = "windows"))] pub(crate) mod selection_voice_session; @@ -1464,11 +1466,28 @@ impl Coordinator { if previous.style_packs != next.style_packs { self.try_update_style_pack_hotkey_bindings()?; } - if previous.dictation != next.dictation || previous.dictation_mode != next.dictation_mode { - self.update_hotkey_binding(); - } - if previous.dictation != next.dictation { - self.update_combo_hotkey_binding(); + #[cfg(target_os = "macos")] + let native_transition = previous.dictation.primary == crate::macos_dictation_key::PRIMARY + || next.dictation.primary == crate::macos_dictation_key::PRIMARY; + #[cfg(not(target_os = "macos"))] + let native_transition = false; + if native_transition { + #[cfg(target_os = "macos")] + if previous.dictation != next.dictation + || previous.dictation_mode != next.dictation_mode + { + self.try_update_native_dictation_binding()?; + self.update_modifier_shortcut_bindings(); + } + } else { + if previous.dictation != next.dictation + || previous.dictation_mode != next.dictation_mode + { + self.update_hotkey_binding(); + } + if previous.dictation != next.dictation { + self.update_combo_hotkey_binding(); + } } if previous.qa != next.qa { self.update_qa_hotkey_binding(); diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 63af12fdb..ca847fdbf 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -30,7 +30,7 @@ fn esc_cancel_bridge_loop_with( cancel: impl Fn(&Arc), ) { while rx.recv().is_ok() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } cancel(&inner); @@ -45,7 +45,7 @@ pub(super) fn combo_abort_bridge_loop( handler: fn(&Arc, crate::hotkey::HotkeyCombinedEdge), ) { while let Ok(edge) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } handler(&inner, edge); @@ -300,7 +300,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { pub(super) fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } let inner_cloned = Arc::clone(&inner); @@ -407,7 +407,7 @@ fn update_selection_polish_hotkey_on_main_thread( #[cfg(not(mobile))] fn selection_polish_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(event) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } match event { @@ -605,7 +605,7 @@ pub(super) fn update_coding_agent_hotkey_binding_now(inner: &Arc) -> Resu let combo_tx = spawn_combo_abort_bridge(inner, cancel_less_computer_press); let monitor = HotkeyMonitor::start(modifier_binding, tx, cancel_tx, combo_tx) .map_err(|error| error.to_string())?; - monitor.set_recording_active(inner.shortcut_recording_active.load(Ordering::SeqCst)); + monitor.set_recording_active(shortcut_recording_is_active(&inner)); let bridge_inner = Arc::clone(inner); std::thread::Builder::new() .name("openless-less-computer-modifier-bridge".into()) @@ -690,7 +690,7 @@ pub(super) fn less_computer_modifier_bridge_loop( rx: mpsc::Receiver, ) { while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } let inner_cloned = Arc::clone(&inner); @@ -1048,7 +1048,7 @@ pub(super) fn less_computer_combo_bridge_loop( ) { let mut owned_session = None; while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } let inner_cloned = Arc::clone(&inner); @@ -1200,6 +1200,30 @@ pub(super) fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc) } } +#[cfg(target_os = "macos")] +pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { + let coord = Coordinator { + inner: Arc::clone(&inner), + }; + let mut attempts = 0_u32; + while !inner.shutdown.load(Ordering::SeqCst) { + match coord.try_update_native_dictation_binding() { + Ok(()) => { + log::info!("[coord] combo hotkey listener installed on main thread"); + return; + } + Err(error) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] combo hotkey registration #{attempts} failed: {error}"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +#[cfg(not(target_os = "macos"))] pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { @@ -1315,7 +1339,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { pub(super) fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { let mut current_press_id = 0; while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } let inner_cloned = Arc::clone(&inner); @@ -1438,7 +1462,7 @@ pub(super) fn translation_hotkey_bridge_loop( rx: mpsc::Receiver, ) { while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { @@ -1535,7 +1559,7 @@ pub(super) fn action_hotkey_bridge_loop( kind: ActionHotkeyKind, ) { while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { @@ -1829,7 +1853,7 @@ pub(super) fn style_pack_hotkey_bridge_loop( pack_id: String, ) { while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { @@ -1950,7 +1974,7 @@ pub(super) async fn arm_translation_if_effective(inner: &Arc) -> bool { pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { // 录制态:仅上报「录制 Fn」事件给前端(recorder 在录入态检测到 Fn 按下, // 浏览器不向网页层下发 Fn keydown,由 CGEventTap 上报),其余热键事件 // 一律跳过,避免录制期间误触发听写。 @@ -2078,7 +2102,7 @@ pub(super) async fn handle_window_hotkey_event( code: String, repeat: bool, ) -> Result<(), String> { - if inner.shortcut_recording_active.load(Ordering::SeqCst) { + if shortcut_recording_is_active(&inner) { return Ok(()); } if event_type == "keydown" && key == "Escape" { @@ -2959,3 +2983,11 @@ mod tests { handle.join().unwrap(); } } + +fn shortcut_recording_is_active(inner: &Inner) -> bool { + #[cfg(target_os = "macos")] + if crate::macos_dictation_key::test_active() { + return true; + } + inner.shortcut_recording_active.load(Ordering::SeqCst) +} diff --git a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs new file mode 100644 index 000000000..8663581fa --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs @@ -0,0 +1,107 @@ +//! Synchronous native-key registration participates in the Core settings transaction. +use super::*; + +impl Coordinator { + pub fn native_dictation_key_active(&self) -> bool { + self.inner + .combo_hotkey + .lock() + .as_ref() + .is_some_and(|monitor| monitor.native_dictation_active()) + } + + pub(crate) fn dictation_key_setup_is_busy(&self) -> bool { + !matches!( + self.backend().snapshot().dictation.phase, + openless_core::DictationPhase::Idle + | openless_core::DictationPhase::Completed + | openless_core::DictationPhase::Cancelled + | openless_core::DictationPhase::Failed + ) + } + + /// Keep the previous listener until replacement registration succeeds. The + /// caller is a worker thread; Carbon ownership changes run on the UI thread. + pub(crate) fn try_update_native_dictation_binding(&self) -> Result<(), String> { + let target = hotkey_runtime_target(&self.inner); + let inner = Arc::clone(&self.inner); + let (done_tx, done_rx) = mpsc::sync_channel(1); + // Cancel queued work on timeout. Once a callback has started, wait for + // its acknowledgement before Core can roll back the runtime target. + // This gate never nests with the target mutex on the calling thread. + let cancelled = Arc::new(Mutex::new(false)); + let callback_cancelled = Arc::clone(&cancelled); + self.inner.host.run_on_main_thread(move || { + let cancelled = callback_cancelled.lock(); + let result = (|| { + if *cancelled || hotkey_runtime_target(&inner) != target { + return Err("macDictationKeyChanged".into()); + } + let binding = target.dictation.clone(); + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&binding); + if trigger.is_some() || is_unconfigured_shortcut(&binding) { + if trigger.is_some() && inner.hotkey.lock().is_none() { + return Err("macDictationKeyUnavailable".into()); + } + inner.combo_hotkey.lock().take(); + inner.side_aware_combo.lock().take(); + } else if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) { + let (tx, rx) = mpsc::channel(); + let monitor = + crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) + .map_err(|error| error.to_string())?; + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-side-combo-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|error| error.to_string())?; + *inner.side_aware_combo.lock() = Some(monitor); + inner.combo_hotkey.lock().take(); + } else { + let mut slot = inner.combo_hotkey.lock(); + if let Some(monitor) = slot.as_ref() { + monitor + .update_binding(binding) + .map_err(|error| error.to_string())?; + } else { + let (tx, rx) = mpsc::channel(); + let monitor = ComboHotkeyMonitor::start(binding, tx) + .map_err(|error| error.to_string())?; + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|error| error.to_string())?; + *slot = Some(monitor); + } + inner.side_aware_combo.lock().take(); + } + if let Some(monitor) = inner.hotkey.lock().as_ref() { + monitor.update_binding(crate::types::HotkeyBinding { + trigger: trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), + mode: target.dictation_mode, + keys: None, + }); + } + reset_shortcut_held_state(&inner); + Ok(()) + })(); + let _ = done_tx.send(result); + })?; + match done_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(result) => result, + Err(_) => { + let mut cancelled = cancelled.lock(); + // A running callback may have finished while we acquired the + // gate. Its real result takes precedence over the timeout. + match done_rx.try_recv() { + Ok(result) => result, + Err(_) => { + *cancelled = true; + Err("macDictationKeyUnavailable".into()) + } + } + } + } + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b450cc70..8bb5aa15f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -31,6 +31,8 @@ mod coordinator; mod coordinator_state; mod core_adapters; mod correction; +#[cfg(target_os = "macos")] +mod macos_dictation_key; mod qa_adapter; mod tauri_coordinator_host; // 托盘麦克风设备变更监听:macOS CoreAudio / Windows MMDevice 原生通知(空闲零唤醒), @@ -312,6 +314,10 @@ macro_rules! app_invoke_handler_desktop { commands::revert_selection_voice_preview, commands::validate_shortcut_binding, commands::set_dictation_hotkey, + commands::test_macos_dictation_key, + commands::cancel_macos_dictation_key_test, + commands::activate_macos_dictation_key, + commands::macos_dictation_key_active, commands::set_translation_hotkey, commands::set_switch_style_hotkey, commands::set_open_app_hotkey, diff --git a/openless-all/app/src-tauri/src/macos_dictation_key.rs b/openless-all/app/src-tauri/src/macos_dictation_key.rs new file mode 100644 index 000000000..0dab7a55e --- /dev/null +++ b/openless-all/app/src-tauri/src/macos_dictation_key.rs @@ -0,0 +1,308 @@ +//! Process-owned capture of the Mac Dictation key. Ordinary F5 is never captured. +use crate::combo_hotkey::ComboHotkeyEvent; +use std::{ + ffi::c_void, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Sender}, + Arc, + }, + thread, + time::{Duration, Instant}, +}; + +pub const PRIMARY: &str = "MacDictationKey"; +const MODIFIERS: u64 = (1 << 17) | (1 << 18) | (1 << 19) | (1 << 20) | (1 << 23); +fn effective_flags(flags: u64, fn_down: bool) -> u64 { + if fn_down { + flags + } else { + flags & !(1 << 23) + } +} +#[derive(Default)] +struct KeyState { + held: bool, +} +#[derive(Debug, PartialEq)] +enum Edge { + Pass, + Repeat, + Press, + Release, +} +impl KeyState { + fn event(&mut self, key: i64, down: bool, repeat: bool, flags: u64) -> Edge { + if key != 176 { + return Edge::Pass; + } + if self.held { + if down { + return Edge::Repeat; + } + self.held = false; + return Edge::Release; + } + if down && !repeat && flags & MODIFIERS == 0 { + self.held = true; + return Edge::Press; + } + Edge::Pass + } +} + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGEventTapCreate( + location: u32, + placement: u32, + options: u32, + mask: u64, + callback: extern "C" fn(*mut c_void, u32, *mut c_void, *mut c_void) -> *mut c_void, + context: *mut c_void, + ) -> *mut c_void; + fn CGEventTapEnable(tap: *mut c_void, enabled: bool); + fn CGEventGetIntegerValueField(event: *mut c_void, field: u32) -> i64; + fn CGEventGetFlags(event: *mut c_void) -> u64; + fn CGEventSourceKeyState(state: i32, key: u16) -> bool; +} +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFMachPortCreateRunLoopSource( + allocator: *const c_void, + port: *mut c_void, + order: isize, + ) -> *mut c_void; + fn CFMachPortInvalidate(port: *mut c_void); + fn CFRunLoopGetCurrent() -> *mut c_void; + fn CFRunLoopAddSource(runloop: *mut c_void, source: *mut c_void, mode: *const c_void); + fn CFRunLoopRemoveSource(runloop: *mut c_void, source: *mut c_void, mode: *const c_void); + fn CFRunLoopRunInMode(mode: *const c_void, seconds: f64, return_after_source: bool) -> i32; + fn CFRelease(value: *const c_void); + static kCFRunLoopDefaultMode: *const c_void; +} +struct Context { + state: KeyState, + tx: Sender, + stop: Arc, +} +extern "C" fn callback( + _: *mut c_void, + kind: u32, + event: *mut c_void, + data: *mut c_void, +) -> *mut c_void { + if data.is_null() { + return event; + } + let ctx = unsafe { &mut *(data as *mut Context) }; + if kind == 0xffff_fffe || kind == 0xffff_ffff { + // Fail open. A disabled hook must not retain a held-key latch. + if ctx.state.held { + let _ = ctx + .tx + .send(ComboHotkeyEvent::Released { at: Instant::now() }); + } + ctx.state.held = false; + ctx.stop.store(true, Ordering::SeqCst); + log::warn!( + "[dictation-key] native interception disabled; select the shortcut again to retry" + ); + return event; + } + if event.is_null() || !matches!(kind, 10 | 11) { + return event; + } + let key = unsafe { CGEventGetIntegerValueField(event, 9) }; + if key != 176 { + return event; + } + // Never start a new capture in a password field / Secure Event Input session. + if !ctx.state.held && crate::unicode_keystroke::is_secure_input_enabled() { + return event; + } + let (flags, repeat) = unsafe { + ( + CGEventGetFlags(event), + CGEventGetIntegerValueField(event, 8) != 0, + ) + }; + let fn_down = unsafe { CGEventSourceKeyState(0, 63) }; + let edge = ctx + .state + .event(key, kind == 10, repeat, effective_flags(flags, fn_down)); + let message = match edge { + Edge::Press => Some(ComboHotkeyEvent::Pressed { at: Instant::now() }), + Edge::Release => Some(ComboHotkeyEvent::Released { at: Instant::now() }), + _ => None, + }; + if let Some(message) = message { + if ctx.tx.send(message).is_err() { + ctx.stop.store(true, Ordering::SeqCst); + return event; + } + } + if edge == Edge::Pass { + event + } else { + std::ptr::null_mut() + } +} + +pub struct Monitor { + stop: Arc, + thread: Option>, +} +impl Monitor { + pub fn start(tx: Sender) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let thread = thread::Builder::new() + .name("openless-dictation-key".into()) + .spawn(move || unsafe { + let mut context = Context { + state: KeyState::default(), + tx, + stop: thread_stop.clone(), + }; + let tap = CGEventTapCreate( + 0, + 0, + 0, + (1 << 10) | (1 << 11), + callback, + &mut context as *mut _ as *mut c_void, + ); + if tap.is_null() { + let _ = ready_tx.send(Err("macDictationKeyPermission".to_string())); + return; + } + let source = CFMachPortCreateRunLoopSource(std::ptr::null(), tap, 0); + if source.is_null() { + CFMachPortInvalidate(tap); + CFRelease(tap); + let _ = ready_tx.send(Err("macDictationKeyUnavailable".to_string())); + return; + } + let runloop = CFRunLoopGetCurrent(); + CFRunLoopAddSource(runloop, source, kCFRunLoopDefaultMode); + CGEventTapEnable(tap, true); + if ready_tx.send(Ok(())).is_ok() { + while !thread_stop.load(Ordering::SeqCst) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.5, false); + } + } + CGEventTapEnable(tap, false); + CFRunLoopRemoveSource(runloop, source, kCFRunLoopDefaultMode); + CFMachPortInvalidate(tap); + CFRelease(source); + CFRelease(tap); + }) + .map_err(|e| e.to_string())?; + match ready_rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(())) => Ok(Self { + stop, + thread: Some(thread), + }), + result => { + stop.store(true, Ordering::SeqCst); + let _ = thread.join(); + Err(match result { + Ok(Err(e)) => e, + _ => "macDictationKeyUnavailable".into(), + }) + } + } + } + pub fn active(&self) -> bool { + !self.stop.load(Ordering::SeqCst) + } +} +impl Drop for Monitor { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn function_layer_flag_is_distinct_from_physical_fn() { + assert_eq!(effective_flags(1 << 23, false), 0); + assert_eq!(effective_flags(1 << 23, true), 1 << 23); + assert_eq!(effective_flags((1 << 19) | (1 << 23), false), 1 << 19); + } + #[test] + fn ordinary_and_modified_keys_pass_through() { + let mut s = KeyState::default(); + for key in [96, 90, 59, 58, 63] { + assert_eq!(s.event(key, true, false, 0), Edge::Pass); + } + for bit in [17, 18, 19, 20, 23] { + assert_eq!(s.event(176, true, false, 1 << bit), Edge::Pass); + } + assert_eq!(s.event(176, false, false, 0), Edge::Pass); + } + #[test] + fn repeat_and_release_pairing() { + let mut s = KeyState::default(); + assert_eq!(s.event(176, true, true, 0), Edge::Pass); + assert_eq!(s.event(176, true, false, 0), Edge::Press); + assert_eq!(s.event(176, true, true, 0), Edge::Repeat); + assert_eq!(s.event(176, false, false, MODIFIERS), Edge::Release); + assert_eq!(s.event(176, false, false, 0), Edge::Pass); + assert_eq!(s.event(176, true, false, 0), Edge::Press); + } +} + +static TEST_RUNNING: AtomicBool = AtomicBool::new(false); +static TEST_CANCELLED: AtomicBool = AtomicBool::new(false); +pub struct TestGuard; +impl TestGuard { + pub fn begin() -> Result { + TEST_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .map_err(|_| "macDictationKeyBusy".to_string())?; + TEST_CANCELLED.store(false, Ordering::SeqCst); + Ok(Self) + } + pub fn wait(&self) -> Result<(), String> { + let (tx, rx) = mpsc::channel(); + let monitor = Monitor::start(tx)?; + let deadline = Instant::now() + Duration::from_secs(30); + let mut pressed = false; + while Instant::now() < deadline { + if TEST_CANCELLED.load(Ordering::SeqCst) { + return Err("macDictationKeyCancelled".into()); + } + if !monitor.active() { + return Err("macDictationKeyUnavailable".into()); + } + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(ComboHotkeyEvent::Pressed { .. }) => pressed = true, + Ok(ComboHotkeyEvent::Released { .. }) if pressed => return Ok(()), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("macDictationKeyUnavailable".into()) + } + _ => {} + } + } + Err("macDictationKeyTimeout".into()) + } +} +impl Drop for TestGuard { + fn drop(&mut self) { + TEST_RUNNING.store(false, Ordering::SeqCst); + } +} +pub fn cancel_test() { + TEST_CANCELLED.store(true, Ordering::SeqCst); +} + +pub fn test_active() -> bool { + TEST_RUNNING.load(Ordering::SeqCst) +} diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs index 00c7a49d3..a635762ce 100644 --- a/openless-all/app/src-tauri/src/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -14,6 +14,10 @@ pub use openless_core::{ pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { openless_core::validate_shortcut_binding(binding)?; + #[cfg(target_os = "macos")] + if binding.primary == crate::macos_dictation_key::PRIMARY { + return Ok(()); + } if legacy_modifier_trigger(binding).is_some() || (binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift")) || binding_requires_side_aware_hook(binding) @@ -95,6 +99,14 @@ pub fn parse_primary(raw: &str) -> Result { "F10" => Code::F10, "F11" => Code::F11, "F12" => Code::F12, + "F13" => Code::F13, + "F14" => Code::F14, + "F15" => Code::F15, + "F16" => Code::F16, + "F17" => Code::F17, + "F18" => Code::F18, + "F19" => Code::F19, + "F20" => Code::F20, _ => return Err(ShortcutBindingError::UnsupportedKey(trimmed.to_string())), }; Ok(named) @@ -160,6 +172,49 @@ fn char_to_code(ch: char) -> Option { mod tests { use super::*; + #[test] + fn extended_function_keys_parse_without_modifiers() { + for (name, code) in [ + ("F13", Code::F13), + ("F14", Code::F14), + ("F15", Code::F15), + ("F16", Code::F16), + ("F17", Code::F17), + ("F18", Code::F18), + ("F19", Code::F19), + ("F20", Code::F20), + ] { + let binding = ShortcutBinding { + primary: name.into(), + modifiers: vec![], + }; + assert!(validate_binding(&binding).is_ok()); + let parsed = parse_global_hotkey(&binding).unwrap(); + assert_eq!(parsed.key, code); + assert!(parsed.mods.is_empty()); + assert!(legacy_modifier_trigger(&binding).is_none()); + } + assert_eq!(parse_primary(" f20 ").unwrap(), Code::F20); + assert!(parse_primary("F21").is_err()); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_dictation_key_is_bare_and_dictation_only() { + let native = ShortcutBinding { + primary: crate::macos_dictation_key::PRIMARY.into(), + modifiers: vec![], + }; + assert!(validate_binding(&native).is_ok()); + assert!(parse_global_hotkey(&native).is_err()); + assert!(reject_side_specific_non_dictation(&native).is_err()); + let modified = ShortcutBinding { + modifiers: vec!["shift".into()], + ..native + }; + assert!(validate_binding(&modified).is_err()); + } + #[test] fn parses_combo_and_single_key() { let combo = ShortcutBinding { diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index e042a85e3..c98487e98 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -270,11 +270,12 @@ pub mod platform { use super::*; use windows::Win32::UI::Input::KeyboardAndMouse::{ - VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F10, VK_F11, VK_F12, VK_F2, - VK_F3, VK_F4, VK_F5, VK_F6, VK_F7, VK_F8, VK_F9, VK_HOME, VK_INSERT, VK_LCONTROL, VK_LEFT, - VK_LMENU, VK_LSHIFT, VK_LWIN, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, - VK_OEM_7, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_RCONTROL, VK_RETURN, - VK_RIGHT, VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP, + VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F10, VK_F11, VK_F12, VK_F13, + VK_F14, VK_F15, VK_F16, VK_F17, VK_F18, VK_F19, VK_F2, VK_F20, VK_F3, VK_F4, VK_F5, VK_F6, + VK_F7, VK_F8, VK_F9, VK_HOME, VK_INSERT, VK_LCONTROL, VK_LEFT, VK_LMENU, VK_LSHIFT, + VK_LWIN, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, + VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_RCONTROL, VK_RETURN, VK_RIGHT, + VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP, }; pub fn dispatch_vk(vk_code: u32, pressed: bool) { @@ -334,6 +335,14 @@ pub mod platform { x if x == VK_F10.0 as u32 => "F10", x if x == VK_F11.0 as u32 => "F11", x if x == VK_F12.0 as u32 => "F12", + x if x == VK_F13.0 as u32 => "F13", + x if x == VK_F14.0 as u32 => "F14", + x if x == VK_F15.0 as u32 => "F15", + x if x == VK_F16.0 as u32 => "F16", + x if x == VK_F17.0 as u32 => "F17", + x if x == VK_F18.0 as u32 => "F18", + x if x == VK_F19.0 as u32 => "F19", + x if x == VK_F20.0 as u32 => "F20", x if x == VK_OEM_1.0 as u32 => ";", x if x == VK_OEM_PLUS.0 as u32 => "=", x if x == VK_OEM_COMMA.0 as u32 => ",", @@ -428,6 +437,14 @@ fn macos_keycode_to_primary(keycode: i64) -> Option<&'static str> { 109 => Some("F10"), 103 => Some("F11"), 111 => Some("F12"), + 105 => Some("F13"), + 107 => Some("F14"), + 113 => Some("F15"), + 106 => Some("F16"), + 64 => Some("F17"), + 79 => Some("F18"), + 80 => Some("F19"), + 90 => Some("F20"), _ => None, } } @@ -535,6 +552,22 @@ pub mod platform { mod tests { use super::*; + #[test] + fn extended_macos_function_keys_use_carbon_keycodes() { + for (keycode, primary) in [ + (105, "F13"), + (107, "F14"), + (113, "F15"), + (106, "F16"), + (64, "F17"), + (79, "F18"), + (80, "F19"), + (90, "F20"), + ] { + assert_eq!(macos_keycode_to_primary(keycode), Some(primary)); + } + } + #[test] fn macos_keycode_2_is_d() { assert_eq!(macos_keycode_to_primary(2), Some("D")); diff --git a/openless-all/app/src/components/MacDictationKeySetup.tsx b/openless-all/app/src/components/MacDictationKeySetup.tsx new file mode 100644 index 000000000..09d825980 --- /dev/null +++ b/openless-all/app/src/components/MacDictationKeySetup.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { formatComboParts } from '../lib/hotkey'; +import { setDictationHotkey } from '../lib/ipc'; +import type { ShortcutBinding } from '../lib/types'; + +const PRIMARY = 'MacDictationKey'; +type Phase = 'idle' | 'testing' | 'cancelling' | 'confirm' | 'saving'; + +/** Explicit, no-audio setup; the existing shortcut survives every failed/cancelled test. */ +export function MacDictationKeySetup({ binding, previousBinding, onChanged }: { + binding: ShortcutBinding; + previousBinding?: ShortcutBinding | null; + onChanged: () => Promise; +}) { + const { t } = useTranslation(); + const [phase, setPhase] = useState('idle'); + const [error, setError] = useState(null); + const [remaining, setRemaining] = useState(30); + const [active, setActive] = useState(null); + const expected = useRef(binding); + const mounted = useRef(true); + const cancelled = useRef(false); + const selected = binding.primary === PRIMARY; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + cancelled.current = true; + void invoke('cancel_macos_dictation_key_test'); + }; + }, []); + useEffect(() => { + if (!selected) { setActive(null); return; } + let cancelled = false; + const read = () => void invoke('macos_dictation_key_active') + .then(value => { if (!cancelled) setActive(value); }) + .catch(() => { if (!cancelled) setActive(false); }); + read(); + const timer = window.setInterval(read, 2000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [selected]); + useEffect(() => { + if (phase !== 'testing') return; + const timer = window.setInterval(() => setRemaining(n => Math.max(0, n - 1)), 1000); + return () => window.clearInterval(timer); + }, [phase]); + const failure = (value: unknown) => { + const text = String(value); + if (text.includes('macDictationKeyCancelled')) return; + const key = ['Permission', 'Timeout', 'Busy', 'Changed'].find(key => text.includes(`macDictationKey${key}`)); + setError(key ?? 'Unavailable'); + }; + const test = async () => { + cancelled.current = false; + expected.current = structuredClone(binding); + setError(null); setRemaining(30); setPhase('testing'); + try { + await invoke('test_macos_dictation_key'); + if (mounted.current) setPhase(cancelled.current ? 'idle' : 'confirm'); + } catch (value) { + if (mounted.current) { failure(value); setPhase('idle'); } + } + }; + const activate = async () => { + setError(null); setPhase('saving'); + try { + await invoke('activate_macos_dictation_key', { expectedBinding: expected.current }); + if (mounted.current) await onChanged(); + } catch (value) { + if (mounted.current) failure(value); + } finally { + if (mounted.current) setPhase('idle'); + } + }; + const cancel = async () => { + cancelled.current = true; + setPhase('cancelling'); + try { await invoke('cancel_macos_dictation_key_test'); } + catch { if (mounted.current) setPhase('idle'); } + }; + const button = { padding: '5px 10px', border: '1px solid var(--ol-border)', borderRadius: 6, + background: 'var(--ol-bg)', color: 'var(--ol-ink)', cursor: 'pointer', fontSize: 12 }; + return
+ {phase === 'idle' && <> + {selected ? <> +
{t(`macDictationKey.${active === null ? 'checking' : active ? 'active' : 'inactive'}`)}
+
{t('macDictationKey.replace')}
+ {previousBinding && } + {active === false && } + : } +
{t('macDictationKey.description')}
+ } + {(phase === 'testing' || phase === 'cancelling') && <> +
{t('macDictationKey.testing', { seconds: remaining })}
+ + } + {phase === 'confirm' && <> +
{t('macDictationKey.confirm')}
+
+ + +
+ } + {phase === 'saving' &&
{t('common.loading')}
} + {error &&
{t(`macDictationKey.${error}`)}
} + {error === 'Permission' && + } +
; +} diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 497949251..3d310dc7e 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; +import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder'; import { KbdGroup } from './Kbd'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; import type { ShortcutBinding } from '../lib/types'; @@ -426,6 +427,8 @@ function modifierPrimaryFromCode(code: string, key: string): string { } function primaryFromKeyboardEvent(e: KeyboardEvent): string { + const functionKey = functionKeyPrimaryFromEvent(e); + if (functionKey) return functionKey; const printable = primaryFromPrintableCode(e.code); if (printable) return printable; if (e.key.length === 1) return e.key; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b41cfd459..6c6651257 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -4,6 +4,27 @@ import type { zhCN } from './zh-CN'; // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { + macDictationKey: { + inactive: 'Interception is not active yet. Retry if it does not start.', + restore: 'Restore {{shortcut}}', + label: 'Mac Dictation key', + use: 'Use the Mac Dictation key', + description: 'While OpenLess is running, the microphone key uses the recording mode shown above. Ordinary F5 and modifier combinations pass through. Quitting releases the key to macOS.', + testing: 'Press and release the microphone key once. This test records no audio. {{seconds}} seconds remaining; your previous shortcut is preserved.', + confirm: 'Key detected; the test has ended. Did macOS Dictation also appear?', + accept: 'No — use this key', + keep: 'Yes — keep my previous shortcut', + active: 'Mac Dictation key interception is active.', + checking: 'Checking keyboard interception…', + replace: 'To stop using this key, record another shortcut above or quit OpenLess.', + retry: 'Retry interception', + Permission: 'Allow OpenLess in macOS Privacy & Security → Accessibility, then retry. This permission permits keyboard observation; this feature only uses the Dictation key and Fn state and does not store or send typed keys.', + Timeout: 'No Dictation key was received. Check whether a keyboard remapper is changing it; your previous shortcut is unchanged.', + Busy: 'Finish the current recording or shortcut test, then retry.', + Changed: 'Your shortcut changed during setup. The newer setting was preserved; test again to replace it.', + Unavailable: 'Native interception is unavailable. Your previous shortcut is preserved if activation failed. Check Accessibility permission and retry.', + permissionSettings: 'Open Accessibility settings' + }, app: { name: 'OpenLess', tagline: 'Speak naturally, write perfectly', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 88a69f24f..0b557eebf 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2,6 +2,27 @@ // 添加新 key 时,必须同步更新 en.ts,否则首次切换到 English 会回落到中文残留。 export const zhCN = { + macDictationKey: { + inactive: '拦截尚未生效;如未自动启动,请重试。', + restore: '恢复 {{shortcut}}', + label: 'Mac 听写键', + use: '使用 Mac 听写键', + description: 'OpenLess 运行时,麦克风图标键按上方录音模式触发听写。普通 F5 和修饰键组合仍正常使用;退出应用后将此键交回 macOS。', + testing: '请按下并松开一次麦克风图标键。本次检测不录音,剩余 {{seconds}} 秒;保留原快捷键。', + confirm: '已收到听写键,检测已结束。刚才 macOS 系统听写窗口也出现了吗?', + accept: '没有,使用此键', + keep: '出现了,保留原快捷键', + active: '正在接管 Mac 听写键。', + checking: '正在检查键盘拦截状态…', + replace: '如需停止使用此键,请在上方录制其他快捷键,或退出 OpenLess。', + retry: '重试接管', + Permission: '请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。此权限允许观察键盘事件;本功能只使用听写键和 Fn 状态,不保存或发送输入的按键。', + Timeout: '未收到听写键。请检查键盘重映射软件是否改变了此键;原快捷键未变。', + Busy: '请先结束当前录音或快捷键检测,再重试。', + Changed: '检测期间快捷键已被更改,已保留新设置。如需替换,请重新检测。', + Unavailable: '原生键盘拦截不可用;若启用失败,原快捷键会保留。请检查辅助功能权限后重试。', + permissionSettings: '打开辅助功能设置' + }, app: { name: 'OpenLess', tagline: '自然说话,完美书写', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 905fd5d24..08eaf0312 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -4,6 +4,27 @@ import type { zhCN } from './zh-CN'; // 新增 key 時,必須同步更新 en.ts,避免切換到 English 後出現中文殘留。 export const zhTW: typeof zhCN = { + macDictationKey: { + inactive: '攔截尚未生效;若未自動啟動,請重試。', + restore: '恢復 {{shortcut}}', + label: 'Mac 聽寫鍵', + use: '使用 Mac 聽寫鍵', + description: 'OpenLess 執行時,麥克風圖示鍵會依上方錄音模式觸發聽寫。一般 F5 和修飾鍵組合仍可正常使用;結束應用程式後,此鍵將交回 macOS。', + testing: '請按下並放開一次麥克風圖示鍵。本次偵測不會錄音,剩餘 {{seconds}} 秒;原快捷鍵會保留。', + confirm: '已收到聽寫鍵,偵測已結束。剛才 macOS 系統聽寫視窗也出現了嗎?', + accept: '沒有,使用此鍵', + keep: '有,保留原快捷鍵', + active: '正在接管 Mac 聽寫鍵。', + checking: '正在檢查鍵盤攔截狀態…', + replace: '若要停止使用此鍵,請在上方錄製其他快捷鍵,或結束 OpenLess。', + retry: '重試接管', + Permission: '請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。此權限允許觀察鍵盤事件;本功能只使用聽寫鍵和 Fn 狀態,不會儲存或傳送輸入的按鍵。', + Timeout: '未收到聽寫鍵。請檢查鍵盤重新對應軟體是否更改了此鍵;原快捷鍵未變更。', + Busy: '請先結束目前的錄音或快捷鍵偵測,再重試。', + Changed: '偵測期間快捷鍵已變更,已保留新設定。若要替換,請重新偵測。', + Unavailable: '原生鍵盤攔截無法使用;若啟用失敗,原快捷鍵會保留。請重試或恢復原快捷鍵。', + permissionSettings: '開啟輔助使用設定', + }, app: { name: 'OpenLess', tagline: '自然說話,完美書寫', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 1196e0188..bf09a0b52 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -368,6 +368,7 @@ function formatPrimary(primary: string): string { const isMac = currentPlatform().isMac; if (isMac) { switch (trimmed.toLowerCase()) { + case 'macdictationkey': return i18n.t('macDictationKey.label'); case 'space': return '\u2423'; case 'enter': case 'return': return '\u21A9'; diff --git a/openless-all/app/src/lib/hotkeyRecorder.test.ts b/openless-all/app/src/lib/hotkeyRecorder.test.ts index a1a5841af..d9d8b333c 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.test.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.test.ts @@ -1,5 +1,6 @@ import { createHotkeyRecorderState, + functionKeyPrimaryFromEvent, orderHotkeyCodes, updateHotkeyRecorderState, } from './hotkeyRecorder'; @@ -83,3 +84,11 @@ function apply( } assertDeepEqual(orderHotkeyCodes(['Mouse4', 'ControlLeft']), ['ControlLeft', 'Mouse4'], 'orders mouse after modifiers'); + +for (let i = 1; i <= 20; i++) { + assertEqual(functionKeyPrimaryFromEvent({ code: `F${i}`, key: `F${i}` }), `F${i}`, 'function key'); +} +assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: '\uF717' }), 'F20', 'WebKit private-use key'); +assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: 'Unidentified' }), 'F20', 'physical F20'); +assertEqual(functionKeyPrimaryFromEvent({ code: '', key: 'F20' }), 'F20', 'named F20 fallback'); +assertEqual(functionKeyPrimaryFromEvent({ code: 'KeyA', key: 'a' }), null, 'printable key preserved'); diff --git a/openless-all/app/src/lib/hotkeyRecorder.ts b/openless-all/app/src/lib/hotkeyRecorder.ts index 8ab692f5d..8473eedcd 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.ts @@ -68,3 +68,11 @@ const HOTKEY_CODE_ORDER = [ 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', 'Mouse4', 'Mouse5', ]; + +/** Prefer physical function-key codes; WebKit can expose a private-use key value. */ +export function functionKeyPrimaryFromEvent(event: { code: string; key: string }): string | null { + const supported = /^F([1-9]|1[0-9]|20)$/; + if (supported.test(event.code)) return event.code; + if (supported.test(event.key)) return event.key; + return null; +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 97f6c2997..7344ff24b 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -342,6 +342,7 @@ export interface StylePackRuntimeDiagnostics { export interface UserPreferences { hotkey: HotkeyBinding; dictationHotkey: ShortcutBinding; + previousDictationHotkey?: ShortcutBinding | null; defaultMode: PolishMode; enabledModes: PolishMode[]; activeStylePackId: string; diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 84daa1bb0..add8495b8 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { MacDictationKeySetup } from '../../components/MacDictationKeySetup'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; import { SelectLite } from '../../components/ui/SelectLite'; import { @@ -30,7 +31,7 @@ import { detectOS } from '../../components/WindowChrome'; export function ShortcutsSection() { const { t } = useTranslation(); const os = detectOS(); - const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, hotkey, refresh, updatePrefs: savePrefs } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); const [stylePacks, setStylePacks] = useState([]); // 新增行的草稿状态:先选风格包、再录快捷键,两者齐了才真正落库。 @@ -112,6 +113,7 @@ export function ShortcutsSection() {
{hotkeyModeSuffix(hotkey.mode)}
+ {os === 'mac' && } From 6a8cd6d06647f65aa282bad15682dde86b7fcd2f Mon Sep 17 00:00:00 2001 From: Eclock2000 <127169032+Eclock2000@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:32:35 -0400 Subject: [PATCH 2/3] fix(hotkeys): make the Mac Dictation key a single trigger option --- .../crates/openless-core/src/shared_types.rs | 31 ----- .../app/src-tauri/src/commands/hotkeys.rs | 60 +-------- .../app/src-tauri/src/commands/settings.rs | 17 +-- .../src-tauri/src/coordinator/hotkey_loops.rs | 58 ++------- .../src/coordinator/native_dictation_key.rs | 31 +++-- openless-all/app/src-tauri/src/lib.rs | 3 - .../app/src-tauri/src/macos_dictation_key.rs | 48 ------- .../app/src-tauri/src/side_aware_combo.rs | 111 ++++++++++++++-- .../src/components/MacDictationKeySetup.tsx | 120 ------------------ .../app/src/components/ShortcutRecorder.tsx | 57 ++++++++- openless-all/app/src/i18n/en.ts | 30 ++--- openless-all/app/src/i18n/zh-CN.ts | 30 ++--- openless-all/app/src/i18n/zh-TW.ts | 30 ++--- openless-all/app/src/lib/types.ts | 1 - .../pages/settings/RecordingInputSection.tsx | 5 +- .../src/pages/settings/ShortcutsSection.tsx | 5 +- 16 files changed, 224 insertions(+), 413 deletions(-) delete mode 100644 openless-all/app/src/components/MacDictationKeySetup.tsx diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 338b8e077..6266a809b 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -320,7 +320,6 @@ fn resolve_windows_sendinput_insertion_only_legacy( pub struct UserPreferences { pub hotkey: HotkeyBinding, pub dictation_hotkey: ShortcutBinding, - pub previous_dictation_hotkey: Option, pub default_mode: PolishMode, pub enabled_modes: Vec, #[serde(default = "default_active_style_pack_id")] @@ -750,7 +749,6 @@ fn default_active_asr_provider() -> String { struct UserPreferencesWire { hotkey: HotkeyBinding, dictation_hotkey: Option, - previous_dictation_hotkey: Option, default_mode: PolishMode, enabled_modes: Vec, #[serde(default)] @@ -982,7 +980,6 @@ impl Default for UserPreferencesWire { Self { hotkey: prefs.hotkey, dictation_hotkey: None, - previous_dictation_hotkey: None, default_mode: prefs.default_mode, enabled_modes: prefs.enabled_modes, active_style_pack_id: Some(prefs.active_style_pack_id), @@ -1128,7 +1125,6 @@ impl<'de> Deserialize<'de> for UserPreferences { Ok(Self { hotkey: wire.hotkey, dictation_hotkey, - previous_dictation_hotkey: wire.previous_dictation_hotkey, default_mode: wire.default_mode, enabled_modes: wire.enabled_modes, active_style_pack_id: wire @@ -1483,7 +1479,6 @@ impl Default for UserPreferences { &None, ) .expect("default legacy hotkey is not custom"), - previous_dictation_hotkey: None, default_mode: PolishMode::Structured, enabled_modes: vec![ PolishMode::Raw, @@ -3175,32 +3170,6 @@ mod tests { assert_eq!(prefs.dictation_hotkey.modifiers, vec!["cmd", "shift"]); } - #[test] - fn native_dictation_fallback_survives_preferences_roundtrip() { - let mut prefs = UserPreferences::default(); - let fallback = ShortcutBinding { - primary: "F20".into(), - modifiers: vec![], - }; - prefs.dictation_hotkey = ShortcutBinding { - primary: "MacDictationKey".into(), - modifiers: vec![], - }; - prefs.previous_dictation_hotkey = Some(fallback.clone()); - prefs.hotkey.trigger = HotkeyTrigger::Custom; - let json = serde_json::to_value(&prefs).unwrap(); - let restored: UserPreferences = serde_json::from_value(json.clone()).unwrap(); - assert_eq!(restored.previous_dictation_hotkey, Some(fallback)); - assert_eq!(restored.dictation_hotkey.primary, "MacDictationKey"); - let mut old_json = json; - old_json - .as_object_mut() - .unwrap() - .remove("previousDictationHotkey"); - let legacy: UserPreferences = serde_json::from_value(old_json).unwrap(); - assert!(legacy.previous_dictation_hotkey.is_none()); - } - #[test] fn custom_hotkey_with_dictation_hotkey_preserves_dictation_binding() { let prefs: UserPreferences = serde_json::from_str( diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index 48c63e60c..14c577a9c 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -20,7 +20,7 @@ pub async fn set_dictation_hotkey( ) -> Result<(), String> { let coord = Arc::clone(coord.inner()); tauri::async_runtime::spawn_blocking(move || { - super::settings::replace_dictation_hotkey(&coord, binding, None) + super::settings::replace_dictation_hotkey(&coord, binding) }) .await .map_err(|error| error.to_string())? @@ -130,7 +130,7 @@ pub async fn set_combo_hotkey( crate::combo_hotkey::validate_binding(&shortcut).map_err(|error| error.to_string())?; let coord = Arc::clone(coord.inner()); tauri::async_runtime::spawn_blocking(move || { - super::settings::replace_dictation_hotkey(&coord, shortcut, None) + super::settings::replace_dictation_hotkey(&coord, shortcut) }) .await .map_err(|error| error.to_string())? @@ -367,62 +367,6 @@ mod tests { } } -#[tauri::command] -pub async fn test_macos_dictation_key(coord: CoordinatorState<'_>) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - let coord = Arc::clone(coord.inner()); - return tauri::async_runtime::spawn_blocking(move || { - let test = crate::macos_dictation_key::TestGuard::begin()?; - if coord.dictation_key_setup_is_busy() { - return Err("macDictationKeyBusy".into()); - } - test.wait() - }) - .await - .map_err(|e| e.to_string())?; - } - #[cfg(not(target_os = "macos"))] - { - let _ = coord; - Err("macDictationKeyUnavailable".into()) - } -} - -#[tauri::command] -pub fn cancel_macos_dictation_key_test() { - #[cfg(target_os = "macos")] - crate::macos_dictation_key::cancel_test(); -} - -#[tauri::command] -pub async fn activate_macos_dictation_key( - coord: CoordinatorState<'_>, - expected_binding: ShortcutBinding, -) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - let coord = Arc::clone(coord.inner()); - return tauri::async_runtime::spawn_blocking(move || { - super::settings::replace_dictation_hotkey( - &coord, - ShortcutBinding { - primary: crate::macos_dictation_key::PRIMARY.into(), - modifiers: vec![], - }, - Some(expected_binding), - ) - }) - .await - .map_err(|e| e.to_string())?; - } - #[cfg(not(target_os = "macos"))] - { - let _ = (coord, expected_binding); - Err("macDictationKeyUnavailable".into()) - } -} - #[tauri::command] pub fn macos_dictation_key_active(coord: CoordinatorState<'_>) -> bool { #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 111de16eb..ccd570820 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -577,39 +577,26 @@ pub async fn app_download_and_install_android_update( } } -/// Read and replace under the same host gate so a setup confirmation cannot -/// overwrite a shortcut changed by another settings window in the meantime. +/// Replace the single dictation binding under the existing settings transaction. pub(crate) fn replace_dictation_hotkey( coord: &Coordinator, binding: ShortcutBinding, - expected: Option, ) -> Result<(), String> { let _host_guard = coord.lock_settings_host(); let mut prefs = coord.backend().get_preferences(); - if expected - .as_ref() - .is_some_and(|old| old != &prefs.dictation_hotkey) - { - return Err("macDictationKeyChanged".into()); - } crate::shortcut_binding::validate_binding(&binding).map_err(|error| error.to_string())?; reject_bare_shift_dictation_shortcut(&binding)?; #[cfg(target_os = "macos")] { let native = crate::macos_dictation_key::PRIMARY; if prefs.dictation_hotkey.primary == native || binding.primary == native { - if coord.dictation_key_setup_is_busy() { + if coord.dictation_shortcut_is_busy() { return Err("macDictationKeyBusy".into()); } if binding == prefs.dictation_hotkey { // No settings effect is generated for an unchanged binding. return coord.try_update_native_dictation_binding(); } - prefs.previous_dictation_hotkey = if binding.primary == native { - Some(prefs.dictation_hotkey.clone()) - } else { - None - }; } } prefs.dictation_hotkey = binding; diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index ca847fdbf..63af12fdb 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -30,7 +30,7 @@ fn esc_cancel_bridge_loop_with( cancel: impl Fn(&Arc), ) { while rx.recv().is_ok() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } cancel(&inner); @@ -45,7 +45,7 @@ pub(super) fn combo_abort_bridge_loop( handler: fn(&Arc, crate::hotkey::HotkeyCombinedEdge), ) { while let Ok(edge) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } handler(&inner, edge); @@ -300,7 +300,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { pub(super) fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } let inner_cloned = Arc::clone(&inner); @@ -407,7 +407,7 @@ fn update_selection_polish_hotkey_on_main_thread( #[cfg(not(mobile))] fn selection_polish_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(event) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } match event { @@ -605,7 +605,7 @@ pub(super) fn update_coding_agent_hotkey_binding_now(inner: &Arc) -> Resu let combo_tx = spawn_combo_abort_bridge(inner, cancel_less_computer_press); let monitor = HotkeyMonitor::start(modifier_binding, tx, cancel_tx, combo_tx) .map_err(|error| error.to_string())?; - monitor.set_recording_active(shortcut_recording_is_active(&inner)); + monitor.set_recording_active(inner.shortcut_recording_active.load(Ordering::SeqCst)); let bridge_inner = Arc::clone(inner); std::thread::Builder::new() .name("openless-less-computer-modifier-bridge".into()) @@ -690,7 +690,7 @@ pub(super) fn less_computer_modifier_bridge_loop( rx: mpsc::Receiver, ) { while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } let inner_cloned = Arc::clone(&inner); @@ -1048,7 +1048,7 @@ pub(super) fn less_computer_combo_bridge_loop( ) { let mut owned_session = None; while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } let inner_cloned = Arc::clone(&inner); @@ -1200,30 +1200,6 @@ pub(super) fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc) } } -#[cfg(target_os = "macos")] -pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { - let coord = Coordinator { - inner: Arc::clone(&inner), - }; - let mut attempts = 0_u32; - while !inner.shutdown.load(Ordering::SeqCst) { - match coord.try_update_native_dictation_binding() { - Ok(()) => { - log::info!("[coord] combo hotkey listener installed on main thread"); - return; - } - Err(error) => { - attempts += 1; - if attempts <= 3 || attempts % 10 == 0 { - log::warn!("[coord] combo hotkey registration #{attempts} failed: {error}"); - } - std::thread::sleep(std::time::Duration::from_secs(3)); - } - } - } -} - -#[cfg(not(target_os = "macos"))] pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { @@ -1339,7 +1315,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { pub(super) fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { let mut current_press_id = 0; while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } let inner_cloned = Arc::clone(&inner); @@ -1462,7 +1438,7 @@ pub(super) fn translation_hotkey_bridge_loop( rx: mpsc::Receiver, ) { while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { @@ -1559,7 +1535,7 @@ pub(super) fn action_hotkey_bridge_loop( kind: ActionHotkeyKind, ) { while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { @@ -1853,7 +1829,7 @@ pub(super) fn style_pack_hotkey_bridge_loop( pack_id: String, ) { while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { @@ -1974,7 +1950,7 @@ pub(super) async fn arm_translation_if_effective(inner: &Arc) -> bool { pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { // 录制态:仅上报「录制 Fn」事件给前端(recorder 在录入态检测到 Fn 按下, // 浏览器不向网页层下发 Fn keydown,由 CGEventTap 上报),其余热键事件 // 一律跳过,避免录制期间误触发听写。 @@ -2102,7 +2078,7 @@ pub(super) async fn handle_window_hotkey_event( code: String, repeat: bool, ) -> Result<(), String> { - if shortcut_recording_is_active(&inner) { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { return Ok(()); } if event_type == "keydown" && key == "Escape" { @@ -2983,11 +2959,3 @@ mod tests { handle.join().unwrap(); } } - -fn shortcut_recording_is_active(inner: &Inner) -> bool { - #[cfg(target_os = "macos")] - if crate::macos_dictation_key::test_active() { - return true; - } - inner.shortcut_recording_active.load(Ordering::SeqCst) -} diff --git a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs index 8663581fa..50db01cef 100644 --- a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs +++ b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs @@ -10,7 +10,7 @@ impl Coordinator { .is_some_and(|monitor| monitor.native_dictation_active()) } - pub(crate) fn dictation_key_setup_is_busy(&self) -> bool { + pub(crate) fn dictation_shortcut_is_busy(&self) -> bool { !matches!( self.backend().snapshot().dictation.phase, openless_core::DictationPhase::Idle @@ -46,16 +46,26 @@ impl Coordinator { inner.combo_hotkey.lock().take(); inner.side_aware_combo.lock().take(); } else if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) { - let (tx, rx) = mpsc::channel(); - let monitor = - crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) + let mut slot = inner.side_aware_combo.lock(); + if let Some(monitor) = slot.as_ref() { + // A failed native registration leaves this route alive. + // Reuse its sender: dropping an old side-aware handle + // after creating another would clear the singleton route. + monitor + .update_binding(binding) .map_err(|error| error.to_string())?; - let bridge_inner = Arc::clone(&inner); - std::thread::Builder::new() - .name("openless-side-combo-bridge".into()) - .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) - .map_err(|error| error.to_string())?; - *inner.side_aware_combo.lock() = Some(monitor); + } else { + let (tx, rx) = mpsc::channel(); + let monitor = + crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) + .map_err(|error| error.to_string())?; + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-side-combo-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|error| error.to_string())?; + *slot = Some(monitor); + } inner.combo_hotkey.lock().take(); } else { let mut slot = inner.combo_hotkey.lock(); @@ -83,7 +93,6 @@ impl Coordinator { keys: None, }); } - reset_shortcut_held_state(&inner); Ok(()) })(); let _ = done_tx.send(result); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8bb5aa15f..bc69ab892 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -314,9 +314,6 @@ macro_rules! app_invoke_handler_desktop { commands::revert_selection_voice_preview, commands::validate_shortcut_binding, commands::set_dictation_hotkey, - commands::test_macos_dictation_key, - commands::cancel_macos_dictation_key_test, - commands::activate_macos_dictation_key, commands::macos_dictation_key_active, commands::set_translation_hotkey, commands::set_switch_style_hotkey, diff --git a/openless-all/app/src-tauri/src/macos_dictation_key.rs b/openless-all/app/src-tauri/src/macos_dictation_key.rs index 0dab7a55e..aa9351e31 100644 --- a/openless-all/app/src-tauri/src/macos_dictation_key.rs +++ b/openless-all/app/src-tauri/src/macos_dictation_key.rs @@ -258,51 +258,3 @@ mod tests { assert_eq!(s.event(176, true, false, 0), Edge::Press); } } - -static TEST_RUNNING: AtomicBool = AtomicBool::new(false); -static TEST_CANCELLED: AtomicBool = AtomicBool::new(false); -pub struct TestGuard; -impl TestGuard { - pub fn begin() -> Result { - TEST_RUNNING - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .map_err(|_| "macDictationKeyBusy".to_string())?; - TEST_CANCELLED.store(false, Ordering::SeqCst); - Ok(Self) - } - pub fn wait(&self) -> Result<(), String> { - let (tx, rx) = mpsc::channel(); - let monitor = Monitor::start(tx)?; - let deadline = Instant::now() + Duration::from_secs(30); - let mut pressed = false; - while Instant::now() < deadline { - if TEST_CANCELLED.load(Ordering::SeqCst) { - return Err("macDictationKeyCancelled".into()); - } - if !monitor.active() { - return Err("macDictationKeyUnavailable".into()); - } - match rx.recv_timeout(Duration::from_millis(100)) { - Ok(ComboHotkeyEvent::Pressed { .. }) => pressed = true, - Ok(ComboHotkeyEvent::Released { .. }) if pressed => return Ok(()), - Err(mpsc::RecvTimeoutError::Disconnected) => { - return Err("macDictationKeyUnavailable".into()) - } - _ => {} - } - } - Err("macDictationKeyTimeout".into()) - } -} -impl Drop for TestGuard { - fn drop(&mut self) { - TEST_RUNNING.store(false, Ordering::SeqCst); - } -} -pub fn cancel_test() { - TEST_CANCELLED.store(true, Ordering::SeqCst); -} - -pub fn test_active() -> bool { - TEST_RUNNING.load(Ordering::SeqCst) -} diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index c98487e98..ab335b454 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -186,19 +186,7 @@ impl SideAwareComboMonitor { #[cfg(not(target_os = "linux"))] { - if binding.modifiers.is_empty() - || binding - .modifiers - .iter() - .any(|tag| !is_side_specific_modifier_tag(tag)) - { - return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( - "binding is not side-specific".into(), - )); - } - crate::shortcut_binding::parse_primary(&binding.primary).map_err(|e| { - crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()) - })?; + validate_side_binding(&binding)?; let slot = ACTIVE_MONITOR.get_or_init(|| RwLock::new(None)); let mut guard = slot.write().expect("side combo monitor lock poisoned"); @@ -211,6 +199,51 @@ impl SideAwareComboMonitor { } } +#[cfg(not(target_os = "linux"))] +fn validate_side_binding( + binding: &ShortcutBinding, +) -> Result<(), crate::combo_hotkey::ComboHotkeyError> { + if binding.modifiers.is_empty() + || binding + .modifiers + .iter() + .any(|tag| !is_side_specific_modifier_tag(tag)) + { + return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( + "binding is not side-specific".into(), + )); + } + crate::shortcut_binding::parse_primary(&binding.primary) + .map_err(|e| crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()))?; + + Ok(()) +} + +#[cfg(target_os = "macos")] +impl SideAwareComboMonitor { + /// Update the existing route without replacing its handle or event sender. + /// Used when a failed native-key switch restores an already-live shortcut. + pub(crate) fn update_binding( + &self, + binding: ShortcutBinding, + ) -> Result<(), crate::combo_hotkey::ComboHotkeyError> { + validate_side_binding(&binding)?; + let slot = ACTIVE_MONITOR.get().ok_or_else(|| { + crate::combo_hotkey::ComboHotkeyError::RegisterFailed( + "side-aware listener is unavailable".into(), + ) + })?; + let active = slot.read().expect("side combo monitor lock poisoned"); + let active = active.as_ref().ok_or_else(|| { + crate::combo_hotkey::ComboHotkeyError::RegisterFailed( + "side-aware listener is unavailable".into(), + ) + })?; + *active.state.lock() = SideAwareComboState::new(binding); + Ok(()) + } +} + impl Drop for SideAwareComboMonitor { fn drop(&mut self) { if let Some(slot) = ACTIVE_MONITOR.get() { @@ -552,6 +585,58 @@ pub mod platform { mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn restoring_live_side_binding_preserves_its_event_route() { + use std::sync::mpsc; + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["ctrl-right".into()], + }; + let (tx, rx) = mpsc::channel(); + let monitor = SideAwareComboMonitor::start(binding.clone(), tx).unwrap(); + let press_and_release = |primary: &str| { + handle_side_modifier(SideModifier::CtrlRight, true); + handle_primary_key(primary, true); + handle_primary_key(primary, false); + handle_side_modifier(SideModifier::CtrlRight, false); + assert!(matches!( + rx.try_recv(), + Ok(ComboHotkeyEvent::Pressed { .. }) + )); + assert!(matches!( + rx.try_recv(), + Ok(ComboHotkeyEvent::Released { .. }) + )); + assert!(rx.try_recv().is_err()); + }; + press_and_release("D"); + // Native registration failed before removing this monitor. The reverse + // transaction must reuse the live route, even on repeated restoration. + monitor.update_binding(binding.clone()).unwrap(); + monitor.update_binding(binding).unwrap(); + press_and_release("D"); + assert!(monitor + .update_binding(ShortcutBinding { + primary: "F21".into(), + modifiers: vec!["ctrl-right".into()] + }) + .is_err()); + press_and_release("D"); + monitor + .update_binding(ShortcutBinding { + primary: "F20".into(), + modifiers: vec!["ctrl-right".into()], + }) + .unwrap(); + press_and_release("F20"); + drop(monitor); + assert!(matches!( + rx.try_recv(), + Err(mpsc::TryRecvError::Disconnected) + )); + } + #[test] fn extended_macos_function_keys_use_carbon_keycodes() { for (keycode, primary) in [ diff --git a/openless-all/app/src/components/MacDictationKeySetup.tsx b/openless-all/app/src/components/MacDictationKeySetup.tsx deleted file mode 100644 index 09d825980..000000000 --- a/openless-all/app/src/components/MacDictationKeySetup.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { useTranslation } from 'react-i18next'; -import { formatComboParts } from '../lib/hotkey'; -import { setDictationHotkey } from '../lib/ipc'; -import type { ShortcutBinding } from '../lib/types'; - -const PRIMARY = 'MacDictationKey'; -type Phase = 'idle' | 'testing' | 'cancelling' | 'confirm' | 'saving'; - -/** Explicit, no-audio setup; the existing shortcut survives every failed/cancelled test. */ -export function MacDictationKeySetup({ binding, previousBinding, onChanged }: { - binding: ShortcutBinding; - previousBinding?: ShortcutBinding | null; - onChanged: () => Promise; -}) { - const { t } = useTranslation(); - const [phase, setPhase] = useState('idle'); - const [error, setError] = useState(null); - const [remaining, setRemaining] = useState(30); - const [active, setActive] = useState(null); - const expected = useRef(binding); - const mounted = useRef(true); - const cancelled = useRef(false); - const selected = binding.primary === PRIMARY; - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - cancelled.current = true; - void invoke('cancel_macos_dictation_key_test'); - }; - }, []); - useEffect(() => { - if (!selected) { setActive(null); return; } - let cancelled = false; - const read = () => void invoke('macos_dictation_key_active') - .then(value => { if (!cancelled) setActive(value); }) - .catch(() => { if (!cancelled) setActive(false); }); - read(); - const timer = window.setInterval(read, 2000); - return () => { cancelled = true; window.clearInterval(timer); }; - }, [selected]); - useEffect(() => { - if (phase !== 'testing') return; - const timer = window.setInterval(() => setRemaining(n => Math.max(0, n - 1)), 1000); - return () => window.clearInterval(timer); - }, [phase]); - const failure = (value: unknown) => { - const text = String(value); - if (text.includes('macDictationKeyCancelled')) return; - const key = ['Permission', 'Timeout', 'Busy', 'Changed'].find(key => text.includes(`macDictationKey${key}`)); - setError(key ?? 'Unavailable'); - }; - const test = async () => { - cancelled.current = false; - expected.current = structuredClone(binding); - setError(null); setRemaining(30); setPhase('testing'); - try { - await invoke('test_macos_dictation_key'); - if (mounted.current) setPhase(cancelled.current ? 'idle' : 'confirm'); - } catch (value) { - if (mounted.current) { failure(value); setPhase('idle'); } - } - }; - const activate = async () => { - setError(null); setPhase('saving'); - try { - await invoke('activate_macos_dictation_key', { expectedBinding: expected.current }); - if (mounted.current) await onChanged(); - } catch (value) { - if (mounted.current) failure(value); - } finally { - if (mounted.current) setPhase('idle'); - } - }; - const cancel = async () => { - cancelled.current = true; - setPhase('cancelling'); - try { await invoke('cancel_macos_dictation_key_test'); } - catch { if (mounted.current) setPhase('idle'); } - }; - const button = { padding: '5px 10px', border: '1px solid var(--ol-border)', borderRadius: 6, - background: 'var(--ol-bg)', color: 'var(--ol-ink)', cursor: 'pointer', fontSize: 12 }; - return
- {phase === 'idle' && <> - {selected ? <> -
{t(`macDictationKey.${active === null ? 'checking' : active ? 'active' : 'inactive'}`)}
-
{t('macDictationKey.replace')}
- {previousBinding && } - {active === false && } - : } -
{t('macDictationKey.description')}
- } - {(phase === 'testing' || phase === 'cancelling') && <> -
{t('macDictationKey.testing', { seconds: remaining })}
- - } - {phase === 'confirm' && <> -
{t('macDictationKey.confirm')}
-
- - -
- } - {phase === 'saving' &&
{t('common.loading')}
} - {error &&
{t(`macDictationKey.${error}`)}
} - {error === 'Permission' && - } -
; -} diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 3d310dc7e..643c4f065 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { ChevronDown } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; import { useTranslation } from 'react-i18next'; import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder'; @@ -29,6 +30,7 @@ export function ShortcutRecorder({ resetLabel, comboOnly = false, sideSpecificModifiers = false, + allowMacDictationKey = false, }: { value: ShortcutBinding | null; onSave: (binding: ShortcutBinding) => Promise; @@ -46,11 +48,26 @@ export function ShortcutRecorder({ comboOnly?: boolean; /** 听写 start/stop 专用:录制 cmd-left / ctrl-right 等侧向修饰键。 */ sideSpecificModifiers?: boolean; + /** macOS dictation only: choose the dedicated key as the single trigger. */ + allowMacDictationKey?: boolean; }) { const { t } = useTranslation(); const [recording, setRecording] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [error, setError] = useState(null); + const nativeSelected = allowMacDictationKey && value?.primary === 'MacDictationKey'; + const [nativeActive, setNativeActive] = useState(null); + const nativeError = error && ['Permission', 'Busy', 'Unavailable', 'Changed'].find(kind => error.includes(`macDictationKey${kind}`)); + useEffect(() => { + if (!nativeSelected) { setNativeActive(null); return; } + let cancelled = false; + const read = () => void invoke('macos_dictation_key_active') + .then(active => { if (!cancelled) setNativeActive(active); }) + .catch(() => { if (!cancelled) setNativeActive(false); }); + read(); + const timer = window.setInterval(read, 2000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [nativeSelected]); const pendingModifier = useRef(null); const pendingTimer = useRef(null); const pressedCodes = useRef>(new Set()); @@ -107,8 +124,9 @@ export function ShortcutRecorder({ resetRecordingState(); setRecording(false); setError(null); - } catch { - setError(t('settings.recording.comboConflict')); + } catch (reason) { + const message = String(reason); + setError(message.includes('macDictationKey') ? message : t('settings.recording.comboConflict')); } }; @@ -206,10 +224,15 @@ export function ShortcutRecorder({ } }; - const doReset = () => { + const doReset = async () => { setMenuOpen(false); setError(null); - if (onReset) void onReset(); + try { + await onReset?.(); + } catch (reason) { + const message = String(reason); + setError(message.includes('macDictationKey') ? message : t('settings.recording.comboConflict')); + } }; const doDisable = () => { @@ -355,11 +378,26 @@ export function ShortcutRecorder({ + {allowMacDictationKey &&
+ +
}
)} - {error &&
{error}
} + {nativeSelected &&
+ {t(`macDictationKey.${nativeActive === null ? 'checking' : nativeActive ? 'active' : 'inactive'}`)} +
} + {error &&
{nativeError ? t(`macDictationKey.${nativeError}`) : error}
} + {nativeError === 'Permission' && }
); } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6c6651257..cbc8a3aba 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -5,25 +5,17 @@ import type { zhCN } from './zh-CN'; // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { macDictationKey: { - inactive: 'Interception is not active yet. Retry if it does not start.', - restore: 'Restore {{shortcut}}', - label: 'Mac Dictation key', - use: 'Use the Mac Dictation key', - description: 'While OpenLess is running, the microphone key uses the recording mode shown above. Ordinary F5 and modifier combinations pass through. Quitting releases the key to macOS.', - testing: 'Press and release the microphone key once. This test records no audio. {{seconds}} seconds remaining; your previous shortcut is preserved.', - confirm: 'Key detected; the test has ended. Did macOS Dictation also appear?', - accept: 'No — use this key', - keep: 'Yes — keep my previous shortcut', - active: 'Mac Dictation key interception is active.', - checking: 'Checking keyboard interception…', - replace: 'To stop using this key, record another shortcut above or quit OpenLess.', - retry: 'Retry interception', - Permission: 'Allow OpenLess in macOS Privacy & Security → Accessibility, then retry. This permission permits keyboard observation; this feature only uses the Dictation key and Fn state and does not store or send typed keys.', - Timeout: 'No Dictation key was received. Check whether a keyboard remapper is changing it; your previous shortcut is unchanged.', - Busy: 'Finish the current recording or shortcut test, then retry.', - Changed: 'Your shortcut changed during setup. The newer setting was preserved; test again to replace it.', - Unavailable: 'Native interception is unavailable. Your previous shortcut is preserved if activation failed. Check Accessibility permission and retry.', - permissionSettings: 'Open Accessibility settings' + Changed: 'The shortcut changed while saving. Please try again.', + label: "Mac Dictation key", + description: "Replaces the current dictation shortcut with the microphone key. Quitting OpenLess releases it to macOS.", + retry: "Retry Mac Dictation key", + active: "Mac Dictation key is the current trigger.", + inactive: "This shortcut is not active. Retry from the menu or choose another key.", + checking: "Checking shortcut availability…", + Permission: "Allow OpenLess in macOS Privacy & Security → Accessibility, then retry.", + Busy: "Finish the current dictation before changing its shortcut.", + Unavailable: "Could not activate this shortcut. The saved binding is unchanged; retry or choose another key.", + permissionSettings: "Open Accessibility settings", }, app: { name: 'OpenLess', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 0b557eebf..bdd244390 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -3,25 +3,17 @@ export const zhCN = { macDictationKey: { - inactive: '拦截尚未生效;如未自动启动,请重试。', - restore: '恢复 {{shortcut}}', - label: 'Mac 听写键', - use: '使用 Mac 听写键', - description: 'OpenLess 运行时,麦克风图标键按上方录音模式触发听写。普通 F5 和修饰键组合仍正常使用;退出应用后将此键交回 macOS。', - testing: '请按下并松开一次麦克风图标键。本次检测不录音,剩余 {{seconds}} 秒;保留原快捷键。', - confirm: '已收到听写键,检测已结束。刚才 macOS 系统听写窗口也出现了吗?', - accept: '没有,使用此键', - keep: '出现了,保留原快捷键', - active: '正在接管 Mac 听写键。', - checking: '正在检查键盘拦截状态…', - replace: '如需停止使用此键,请在上方录制其他快捷键,或退出 OpenLess。', - retry: '重试接管', - Permission: '请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。此权限允许观察键盘事件;本功能只使用听写键和 Fn 状态,不保存或发送输入的按键。', - Timeout: '未收到听写键。请检查键盘重映射软件是否改变了此键;原快捷键未变。', - Busy: '请先结束当前录音或快捷键检测,再重试。', - Changed: '检测期间快捷键已被更改,已保留新设置。如需替换,请重新检测。', - Unavailable: '原生键盘拦截不可用;若启用失败,原快捷键会保留。请检查辅助功能权限后重试。', - permissionSettings: '打开辅助功能设置' + Changed: '保存期间快捷键已改变,请重试。', + label: "Mac 听写键", + description: "用麦克风图标键替换当前听写快捷键。退出 OpenLess 后,此键交回 macOS。", + retry: "重试 Mac 听写键", + active: "当前触发键:Mac 听写键。", + inactive: "此快捷键尚未生效。请在菜单中重试,或选择其他键。", + checking: "正在检查快捷键状态…", + Permission: "请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。", + Busy: "请先结束当前听写,再更改快捷键。", + Unavailable: "无法启用此快捷键,已保存的绑定未改变。请重试或选择其他键。", + permissionSettings: "打开辅助功能设置", }, app: { name: 'OpenLess', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 08eaf0312..5ad4f9c04 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -5,25 +5,17 @@ import type { zhCN } from './zh-CN'; export const zhTW: typeof zhCN = { macDictationKey: { - inactive: '攔截尚未生效;若未自動啟動,請重試。', - restore: '恢復 {{shortcut}}', - label: 'Mac 聽寫鍵', - use: '使用 Mac 聽寫鍵', - description: 'OpenLess 執行時,麥克風圖示鍵會依上方錄音模式觸發聽寫。一般 F5 和修飾鍵組合仍可正常使用;結束應用程式後,此鍵將交回 macOS。', - testing: '請按下並放開一次麥克風圖示鍵。本次偵測不會錄音,剩餘 {{seconds}} 秒;原快捷鍵會保留。', - confirm: '已收到聽寫鍵,偵測已結束。剛才 macOS 系統聽寫視窗也出現了嗎?', - accept: '沒有,使用此鍵', - keep: '有,保留原快捷鍵', - active: '正在接管 Mac 聽寫鍵。', - checking: '正在檢查鍵盤攔截狀態…', - replace: '若要停止使用此鍵,請在上方錄製其他快捷鍵,或結束 OpenLess。', - retry: '重試接管', - Permission: '請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。此權限允許觀察鍵盤事件;本功能只使用聽寫鍵和 Fn 狀態,不會儲存或傳送輸入的按鍵。', - Timeout: '未收到聽寫鍵。請檢查鍵盤重新對應軟體是否更改了此鍵;原快捷鍵未變更。', - Busy: '請先結束目前的錄音或快捷鍵偵測,再重試。', - Changed: '偵測期間快捷鍵已變更,已保留新設定。若要替換,請重新偵測。', - Unavailable: '原生鍵盤攔截無法使用;若啟用失敗,原快捷鍵會保留。請重試或恢復原快捷鍵。', - permissionSettings: '開啟輔助使用設定', + Changed: '儲存期間快捷鍵已變更,請重試。', + label: "Mac 聽寫鍵", + description: "用麥克風圖示鍵替換目前的聽寫快捷鍵。結束 OpenLess 後,此鍵交回 macOS。", + retry: "重試 Mac 聽寫鍵", + active: "目前觸發鍵:Mac 聽寫鍵。", + inactive: "此快捷鍵尚未生效。請在選單中重試,或選擇其他鍵。", + checking: "正在檢查快捷鍵狀態…", + Permission: "請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。", + Busy: "請先結束目前的聽寫,再變更快捷鍵。", + Unavailable: "無法啟用此快捷鍵,已儲存的綁定未變更。請重試或選擇其他鍵。", + permissionSettings: "開啟輔助使用設定", }, app: { name: 'OpenLess', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 7344ff24b..97f6c2997 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -342,7 +342,6 @@ export interface StylePackRuntimeDiagnostics { export interface UserPreferences { hotkey: HotkeyBinding; dictationHotkey: ShortcutBinding; - previousDictationHotkey?: ShortcutBinding | null; defaultMode: PolishMode; enabledModes: PolishMode[]; activeStylePackId: string; diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 8fc63cc78..12639d282 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -265,17 +265,18 @@ export function RecordingInputSection() { { await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} onReset={async () => { const binding = defaultDictationHotkey(); await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} />
diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index add8495b8..a11f4da10 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { MacDictationKeySetup } from '../../components/MacDictationKeySetup'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; import { SelectLite } from '../../components/ui/SelectLite'; import { @@ -102,18 +101,18 @@ export function ShortcutsSection() { { await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} />
{hotkeyModeSuffix(hotkey.mode)}
- {os === 'mac' && } From 4a7c715b484fadf1dc5f3e17e4d92e723604f0a3 Mon Sep 17 00:00:00 2001 From: Eclock2000 <127169032+Eclock2000@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:15:41 -0400 Subject: [PATCH 3/3] refactor(hotkeys): remove optional Dictation key status UI --- .../app/src-tauri/src/commands/hotkeys.rs | 13 ---------- .../src/coordinator/native_dictation_key.rs | 8 ------ openless-all/app/src-tauri/src/lib.rs | 1 - .../app/src/components/ShortcutRecorder.tsx | 25 +++---------------- openless-all/app/src/i18n/en.ts | 5 ---- openless-all/app/src/i18n/zh-CN.ts | 5 ---- openless-all/app/src/i18n/zh-TW.ts | 5 ---- 7 files changed, 3 insertions(+), 59 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index 14c577a9c..aff774813 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -366,16 +366,3 @@ mod tests { assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_ok()); } } - -#[tauri::command] -pub fn macos_dictation_key_active(coord: CoordinatorState<'_>) -> bool { - #[cfg(target_os = "macos")] - { - return coord.native_dictation_key_active(); - } - #[cfg(not(target_os = "macos"))] - { - let _ = coord; - false - } -} diff --git a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs index 50db01cef..f9176f218 100644 --- a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs +++ b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs @@ -2,14 +2,6 @@ use super::*; impl Coordinator { - pub fn native_dictation_key_active(&self) -> bool { - self.inner - .combo_hotkey - .lock() - .as_ref() - .is_some_and(|monitor| monitor.native_dictation_active()) - } - pub(crate) fn dictation_shortcut_is_busy(&self) -> bool { !matches!( self.backend().snapshot().dictation.phase, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index bc69ab892..f6a243d1b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -314,7 +314,6 @@ macro_rules! app_invoke_handler_desktop { commands::revert_selection_voice_preview, commands::validate_shortcut_binding, commands::set_dictation_hotkey, - commands::macos_dictation_key_active, commands::set_translation_hotkey, commands::set_switch_style_hotkey, commands::set_open_app_hotkey, diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 643c4f065..a946a6a52 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { ChevronDown } from 'lucide-react'; -import { invoke } from '@tauri-apps/api/core'; import { useTranslation } from 'react-i18next'; import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder'; @@ -56,18 +55,7 @@ export function ShortcutRecorder({ const [menuOpen, setMenuOpen] = useState(false); const [error, setError] = useState(null); const nativeSelected = allowMacDictationKey && value?.primary === 'MacDictationKey'; - const [nativeActive, setNativeActive] = useState(null); const nativeError = error && ['Permission', 'Busy', 'Unavailable', 'Changed'].find(kind => error.includes(`macDictationKey${kind}`)); - useEffect(() => { - if (!nativeSelected) { setNativeActive(null); return; } - let cancelled = false; - const read = () => void invoke('macos_dictation_key_active') - .then(active => { if (!cancelled) setNativeActive(active); }) - .catch(() => { if (!cancelled) setNativeActive(false); }); - read(); - const timer = window.setInterval(read, 2000); - return () => { cancelled = true; window.clearInterval(timer); }; - }, [nativeSelected]); const pendingModifier = useRef(null); const pendingTimer = useRef(null); const pressedCodes = useRef>(new Set()); @@ -387,15 +375,15 @@ export function ShortcutRecorder({ }
@@ -442,14 +430,7 @@ export function ShortcutRecorder({ )} - {nativeSelected &&
- {t(`macDictationKey.${nativeActive === null ? 'checking' : nativeActive ? 'active' : 'inactive'}`)} -
} {error &&
{nativeError ? t(`macDictationKey.${nativeError}`) : error}
} - {nativeError === 'Permission' && }
); } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cbc8a3aba..5ec3e3f17 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -8,14 +8,9 @@ export const en: typeof zhCN = { Changed: 'The shortcut changed while saving. Please try again.', label: "Mac Dictation key", description: "Replaces the current dictation shortcut with the microphone key. Quitting OpenLess releases it to macOS.", - retry: "Retry Mac Dictation key", - active: "Mac Dictation key is the current trigger.", - inactive: "This shortcut is not active. Retry from the menu or choose another key.", - checking: "Checking shortcut availability…", Permission: "Allow OpenLess in macOS Privacy & Security → Accessibility, then retry.", Busy: "Finish the current dictation before changing its shortcut.", Unavailable: "Could not activate this shortcut. The saved binding is unchanged; retry or choose another key.", - permissionSettings: "Open Accessibility settings", }, app: { name: 'OpenLess', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index bdd244390..73039e3dc 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -6,14 +6,9 @@ export const zhCN = { Changed: '保存期间快捷键已改变,请重试。', label: "Mac 听写键", description: "用麦克风图标键替换当前听写快捷键。退出 OpenLess 后,此键交回 macOS。", - retry: "重试 Mac 听写键", - active: "当前触发键:Mac 听写键。", - inactive: "此快捷键尚未生效。请在菜单中重试,或选择其他键。", - checking: "正在检查快捷键状态…", Permission: "请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。", Busy: "请先结束当前听写,再更改快捷键。", Unavailable: "无法启用此快捷键,已保存的绑定未改变。请重试或选择其他键。", - permissionSettings: "打开辅助功能设置", }, app: { name: 'OpenLess', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5ad4f9c04..39f8e9a10 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -8,14 +8,9 @@ export const zhTW: typeof zhCN = { Changed: '儲存期間快捷鍵已變更,請重試。', label: "Mac 聽寫鍵", description: "用麥克風圖示鍵替換目前的聽寫快捷鍵。結束 OpenLess 後,此鍵交回 macOS。", - retry: "重試 Mac 聽寫鍵", - active: "目前觸發鍵:Mac 聽寫鍵。", - inactive: "此快捷鍵尚未生效。請在選單中重試,或選擇其他鍵。", - checking: "正在檢查快捷鍵狀態…", Permission: "請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。", Busy: "請先結束目前的聽寫,再變更快捷鍵。", Unavailable: "無法啟用此快捷鍵,已儲存的綁定未變更。請重試或選擇其他鍵。", - permissionSettings: "開啟輔助使用設定", }, app: { name: 'OpenLess',