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:
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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '自然说话,完美书写',
|
||||
|
||||
@@ -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: '自然說話,完美書寫',
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
Reference in new issue
Block a user