fix(hotkeys): support F13–F20 and the native Mac Dictation key (#1040)

* fix(hotkeys): 支持专用 F20 与 Mac 原生听写键

* fix(hotkeys): make the Mac Dictation key a single trigger option

* refactor(hotkeys): remove optional Dictation key status UI

* fix(hotkeys): re-enable disabled Mac Dictation event tap

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
Eclock2000andChris233 authored and GitHub committed 2026-09-10 02:00:30 +08:00
1 parent bf1237c6b1
commit 7584091464
19 files changed
+821 -53

No files matched your search

@@ -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()))
@@ -50,6 +50,8 @@ pub struct ComboHotkeyMonitor {
struct Inner {
registered: Mutex<Option<RegisteredHotkey>>,
#[cfg(target_os = "macos")]
native_dictation: Mutex<Option<crate::macos_dictation_key::Monitor>>,
tx: Sender<ComboHotkeyEvent>,
}
@@ -67,6 +69,18 @@ impl ComboHotkeyMonitor {
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
) -> Result<Self, ComboHotkeyError> {
#[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<GlobalHotKeyEvent>, tx: Sender<Comb
/// 测试一个组合键是否可以注册(不实际注册,仅验证格式)。
pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ComboHotkeyError> {
#[cfg(target_os = "macos")]
if is_native_dictation(binding) {
return Ok(());
}
parse_binding(binding)?;
Ok(())
}
@@ -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)]
@@ -700,3 +700,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())
}
+24 -5
View File
@@ -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();
@@ -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())
}
}
}
}
}
}
+2
View File
@@ -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 原生通知(空闲零唤醒),
@@ -0,0 +1,288 @@
//! 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<ComboHotkeyEvent>,
stop: Arc<AtomicBool>,
tap: *mut c_void,
}
fn recover_disabled_tap(ctx: &mut Context, enable: impl FnOnce(*mut c_void)) {
if ctx.state.held {
let _ = ctx
.tx
.send(ComboHotkeyEvent::Released { at: Instant::now() });
}
ctx.state.held = false;
enable(ctx.tap);
}
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 for a held key, then restore the selected trigger in place.
recover_disabled_tap(ctx, |tap| unsafe { CGEventTapEnable(tap, true) });
log::warn!("[dictation-key] native interception was disabled and has been re-enabled");
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<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl Monitor {
pub fn start(tx: Sender<ComboHotkeyEvent>) -> Result<Self, String> {
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(),
tap: std::ptr::null_mut(),
};
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;
}
context.tap = tap;
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);
}
#[test]
fn disabled_tap_releases_held_key_and_is_reenabled() {
let (tx, rx) = mpsc::channel();
let stop = Arc::new(AtomicBool::new(false));
let mut context = Context {
state: KeyState { held: true },
tx,
stop: stop.clone(),
tap: std::ptr::null_mut(),
};
let mut reenabled = false;
recover_disabled_tap(&mut context, |_| reenabled = true);
assert!(reenabled);
assert!(!context.state.held);
assert!(!stop.load(Ordering::SeqCst));
assert!(matches!(
rx.try_recv(),
Ok(ComboHotkeyEvent::Released { .. })
));
}
}
@@ -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<Code, ShortcutBindingError> {
"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<Code> {
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 {
@@ -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"));
@@ -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<void>;
@@ -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<string | null>(null);
const nativeSelected = allowMacDictationKey && value?.primary === 'MacDictationKey';
const nativeError = error && ['Permission', 'Busy', 'Unavailable', 'Changed'].find(kind => error.includes(`macDictationKey${kind}`));
const pendingModifier = useRef<ShortcutBinding | null>(null);
const pendingTimer = useRef<number | null>(null);
const pressedCodes = useRef<Set<string>>(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({
<motion.div
key="menu"
initial={{ height: 0, opacity: 0 }}
animate={{ height: MENU_HEIGHT, opacity: 1 }}
animate={{ height: allowMacDictationKey ? MENU_HEIGHT * 2 : MENU_HEIGHT, opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.16, ease: menuEase }}
style={{ overflow: 'hidden' }}
>
{allowMacDictationKey && <div style={menuRowStyle}>
<button
type="button"
aria-pressed={nativeSelected}
disabled={disabled}
title={t('macDictationKey.description')}
onClick={() => {
setMenuOpen(false);
void finish({ primary: 'MacDictationKey', modifiers: [] });
}}
style={disabled ? disabledMenuButtonStyle : nativeSelected ? menuPrimaryStyle : menuButtonStyle}
>
{t('macDictationKey.label')}
</button>
</div>}
<div style={menuRowStyle}>
<motion.button
initial={{ y: 4, opacity: 0 }}
@@ -403,7 +430,7 @@ export function ShortcutRecorder({
</motion.div>
)}
</AnimatePresence>
{error && <div style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>{error}</div>}
{error && <div role="alert" style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>{nativeError ? t(`macDictationKey.${nativeError}`) : error}</div>}
</div>
);
}
@@ -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;
+8
View File
@@ -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',
+8
View File
@@ -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: '自然说话,完美书写',
+8
View File
@@ -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: '自然說話,完美書寫',
+1
View File
@@ -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';
@@ -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');
@@ -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;
}
@@ -265,17 +265,18 @@ export function RecordingInputSection() {
<ShortcutRecorder
value={prefs.dictationHotkey}
sideSpecificModifiers
allowMacDictationKey={os === 'mac'}
// 录音快捷键是核心热键,Rust 端不接受 null,不可停用——置灰并提示。
disableDisabled
disableHint={t('settings.recording.comboDisableHint')}
onSave={async binding => {
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();
}}
/>
</SettingRow>
@@ -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<PlatformCapabilities | null>(null);
const [stylePacks, setStylePacks] = useState<StylePack[]>([]);
// 新增行的草稿状态:先选风格包、再录快捷键,两者齐了才真正落库。
@@ -101,12 +101,13 @@ export function ShortcutsSection() {
<ShortcutRecorder
value={prefs.dictationHotkey}
sideSpecificModifiers
allowMacDictationKey={os === 'mac'}
// 与「录音与输入」页一致:核心热键不可停用,置灰并提示。
disableDisabled
disableHint={t('settings.recording.comboDisableHint')}
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
await refresh();
}}
/>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>