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..aff774813 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) + }) + .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) + }) + .await + .map_err(|error| error.to_string())? } #[cfg(test)] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2e16703ab..ccd570820 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -576,3 +576,39 @@ pub async fn app_download_and_install_android_update( Err("应用内更新仅支持 Android".to_string()) } } + +/// Replace the single dictation binding under the existing settings transaction. +pub(crate) fn replace_dictation_hotkey( + coord: &Coordinator, + binding: ShortcutBinding, +) -> Result<(), String> { + let _host_guard = coord.lock_settings_host(); + let mut prefs = coord.backend().get_preferences(); + 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_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.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/native_dictation_key.rs b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs new file mode 100644 index 000000000..f9176f218 --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs @@ -0,0 +1,108 @@ +//! Synchronous native-key registration participates in the Core settings transaction. +use super::*; + +impl Coordinator { + pub(crate) fn dictation_shortcut_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 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())?; + } 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(); + 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, + }); + } + 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..f6a243d1b 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 原生通知(空闲零唤醒), 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..aa9351e31 --- /dev/null +++ b/openless-all/app/src-tauri/src/macos_dictation_key.rs @@ -0,0 +1,260 @@ +//! 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); + } +} 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..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() { @@ -270,11 +303,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 +368,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 +470,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 +585,74 @@ 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 [ + (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/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 497949251..a946a6a52 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'; @@ -28,6 +29,7 @@ export function ShortcutRecorder({ resetLabel, comboOnly = false, sideSpecificModifiers = false, + allowMacDictationKey = false, }: { value: ShortcutBinding | null; onSave: (binding: ShortcutBinding) => Promise; @@ -45,11 +47,15 @@ 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 nativeError = error && ['Permission', 'Busy', 'Unavailable', 'Changed'].find(kind => error.includes(`macDictationKey${kind}`)); const pendingModifier = useRef(null); const pendingTimer = useRef(null); const pressedCodes = useRef>(new Set()); @@ -106,8 +112,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')); } }; @@ -205,10 +212,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 = () => { @@ -354,11 +366,26 @@ export function ShortcutRecorder({ + {allowMacDictationKey &&
+ +
}
)} - {error &&
{error}
} + {error &&
{nativeError ? t(`macDictationKey.${nativeError}`) : error}
}
); } @@ -426,6 +453,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..5ec3e3f17 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -4,6 +4,14 @@ import type { zhCN } from './zh-CN'; // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { + macDictationKey: { + 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.", + 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.", + }, 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..73039e3dc 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2,6 +2,14 @@ // 添加新 key 时,必须同步更新 en.ts,否则首次切换到 English 会回落到中文残留。 export const zhCN = { + macDictationKey: { + Changed: '保存期间快捷键已改变,请重试。', + label: "Mac 听写键", + description: "用麦克风图标键替换当前听写快捷键。退出 OpenLess 后,此键交回 macOS。", + Permission: "请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。", + Busy: "请先结束当前听写,再更改快捷键。", + Unavailable: "无法启用此快捷键,已保存的绑定未改变。请重试或选择其他键。", + }, 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..39f8e9a10 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -4,6 +4,14 @@ import type { zhCN } from './zh-CN'; // 新增 key 時,必須同步更新 en.ts,避免切換到 English 後出現中文殘留。 export const zhTW: typeof zhCN = { + macDictationKey: { + Changed: '儲存期間快捷鍵已變更,請重試。', + label: "Mac 聽寫鍵", + description: "用麥克風圖示鍵替換目前的聽寫快捷鍵。結束 OpenLess 後,此鍵交回 macOS。", + Permission: "請在 macOS「隱私權與安全性 → 輔助使用」中允許 OpenLess 後重試。", + Busy: "請先結束目前的聽寫,再變更快捷鍵。", + Unavailable: "無法啟用此快捷鍵,已儲存的綁定未變更。請重試或選擇其他鍵。", + }, 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/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 84daa1bb0..a11f4da10 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -30,7 +30,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([]); // 新增行的草稿状态:先选风格包、再录快捷键,两者齐了才真正落库。 @@ -101,12 +101,13 @@ export function ShortcutsSection() { { await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); + await refresh(); }} />