fix(hotkey): Support Space and Mouse4/5 as dictation shortcuts (#1113)

* fix(hotkey): allow Space and Mouse4/5 as dictation shortcuts

Normalize Space recording to the named primary so validate/parse no longer
trim it to empty (#1109), surface real binding/register errors, and add a
Windows WH_MOUSE_LL path so Mouse4/Mouse5 can be recorded, saved, and trigger
dictation globally (#1099).

* fix(i18n): add mouseSideHint to remaining locales

Unblocks tsc after Mouse4/Mouse5 recording hint was added for en/zh-CN.

* fix(linux-egui): use current time in host_contract history fixture

Hardcoded 2026-08-27 falls outside the test's 30-day retention window as of
2026-09-26, so append_history pruned the entry and list_history returned [].

* fix(linux-egui): restore missing main.rs test helpers

cargo check --all-targets failed because remote UI unit tests still called
disconnected_app/rendered_text after those fixtures were dropped.

* fix(linux-egui): drop obsolete remote_ui unit test

remote_ui was removed from OpenLessEguiApp but the fingerprint rendering test
still called it, breaking cargo check --all-targets on Linux egui CI.

* fix(core): avoid tokio::spawn in e2ee sync store for TaskSpawner seam

Linux egui CI's check-core-runtime-seam rejects production tokio::spawn.
Match the in-task async blocks used by the last green Linux egui PR.

* fix(core): mark e2ee test modules for TaskSpawner seam scanner

The seam gate only recognizes #[cfg(test)], not #![cfg(test)]. Add an
explicit marker module so test-only tokio::spawn usage is ignored.

* fix(ci): exclude desktop registration on mobile and stabilize history fixture

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
浩彬(^-^)andChris233 authored and GitHub committed 2026-09-28 00:20:02 +08:00
1 parent 913279c049
commit 820d7acf7a
29 files changed
+1233 -292

No files matched your search

@@ -9343,6 +9343,42 @@ mod tests {
);
}
#[test]
fn mouse_shortcut_registration_failure_rolls_back_before_retry() {
let (backend, _) = backend();
let previous = backend.get_preferences().dictation_hotkey;
let mut next = backend.get_preferences();
next.dictation_hotkey = crate::shared_types::ShortcutBinding {
primary: "Mouse4".into(),
modifiers: vec!["ctrl".into()],
};
let runtime = RecordingSettingsRuntime {
fail_commit: true,
..RecordingSettingsRuntime::default()
};
assert!(backend
.update_settings(next.clone(), crate::SettingsUpdateOptions::STRICT, &runtime)
.is_err());
assert_eq!(backend.get_preferences().dictation_hotkey, previous);
assert_eq!(backend.snapshot().preferences_revision, 0);
assert_eq!(
runtime.actions.lock().unwrap().as_slice(),
["prepare", "commit", "restore"]
);
backend
.update_settings(
next.clone(),
crate::SettingsUpdateOptions::STRICT,
&RecordingSettingsRuntime::default(),
)
.unwrap();
assert_eq!(
backend.get_preferences().dictation_hotkey,
next.dictation_hotkey
);
assert_eq!(backend.snapshot().preferences_revision, 1);
}
#[test]
fn settings_persistence_failure_restores_prepared_effects() {
let host = Arc::new(FakeHost::default());
@@ -11026,7 +11062,9 @@ mod tests {
let mut preferences = backend.get_preferences();
preferences.translation_target_language = "English".to_string();
preferences.working_languages = vec!["简体中文".to_string()];
preferences.history_retention_days = 30;
// This fixed-clock test checks snapshot persistence, not wall-clock retention.
// Retention is covered independently by history::tests.
preferences.history_retention_days = 0;
preferences.history_max_entries = Some(20);
backend.set_preferences(preferences).unwrap();
backend.start().await.unwrap();
@@ -330,9 +330,10 @@ pub use shared_types::{
LOCAL_ASR_KEEP_LOADED_FOREVER_SECS,
};
pub use shortcut_types::{
binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap,
is_modifier_chord_binding, is_side_specific_modifier_tag, legacy_modifier_trigger,
normalize_side_modifier_tag, reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut,
binding_from_legacy_trigger, binding_requires_mouse_hook, binding_requires_side_aware_hook,
bindings_overlap, is_modifier_chord_binding, is_mouse_button_primary,
is_side_specific_modifier_tag, legacy_modifier_trigger, normalize_side_modifier_tag,
reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut,
reject_dictation_qa_hotkey_overlap, reject_dictation_translation_hotkey_overlap,
reject_hotkey_collisions, reject_modifier_only_action_shortcut,
reject_non_dictation_side_specific_shortcuts, reject_qa_less_computer_hotkey_overlap,
@@ -340,7 +341,7 @@ pub use shortcut_types::{
reject_qa_translation_hotkey_overlap, reject_selection_polish_hotkey_collisions,
reject_side_specific_non_dictation, reject_style_pack_hotkey_conflicts,
sync_dictation_hotkey_legacy_fields, validate_shortcut_binding, ShortcutBindingError,
SIDE_SPECIFIC_NON_DICTATION_MSG,
MOUSE_NON_DICTATION_MSG, SIDE_SPECIFIC_NON_DICTATION_MSG,
};
pub use silence_auto_stop::{SilenceAutoStop, SilenceDecision};
pub use streaming_insert::{
@@ -56,13 +56,30 @@ pub fn binding_requires_side_aware_hook(binding: &ShortcutBinding) -> bool {
.any(|tag| is_side_specific_modifier_tag(tag))
}
pub fn is_mouse_button_primary(primary: &str) -> bool {
matches!(
primary.trim().to_ascii_uppercase().as_str(),
"MOUSE4" | "MOUSE5"
)
}
pub fn binding_requires_mouse_hook(binding: &ShortcutBinding) -> bool {
is_mouse_button_primary(&binding.primary)
}
pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str =
"Side-specific modifier shortcuts are only supported for dictation start/stop.";
pub const MOUSE_NON_DICTATION_MSG: &str =
"Mouse button 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_mouse_hook(binding) {
return Err(MOUSE_NON_DICTATION_MSG.to_string());
}
if binding_requires_side_aware_hook(binding) {
return Err(SIDE_SPECIFIC_NON_DICTATION_MSG.to_string());
}
@@ -234,6 +251,12 @@ pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), Shortc
return Ok(());
}
if binding_requires_mouse_hook(binding) && binding_requires_side_aware_hook(binding) {
return Err(ShortcutBindingError::UnsupportedModifier(
"mouse button bindings do not support side-specific modifiers".into(),
));
}
validate_primary(&binding.primary)?;
for raw in &binding.modifiers {
if binding_requires_side_aware_hook(binding) {
@@ -264,6 +287,8 @@ pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), Shortc
}
fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> {
// Literal space character must not be trimmed to empty (#1109).
let raw = if raw == " " { "Space" } else { raw };
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ShortcutBindingError::UnsupportedKey("(空)".into()));
@@ -300,6 +325,8 @@ fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> {
| "LEFT"
| "ARROWRIGHT"
| "RIGHT"
| "MOUSE4"
| "MOUSE5"
| "F1"
| "F2"
| "F3"
@@ -886,6 +913,24 @@ mod tests {
);
}
#[test]
fn mouse_bindings_are_dictation_only_and_reject_side_modifiers() {
for primary in ["Mouse4", "Mouse5"] {
let binding = combo(primary, &["ctrl"]);
assert!(validate_shortcut_binding(&binding).is_ok());
assert_eq!(
reject_side_specific_non_dictation(&binding).unwrap_err(),
MOUSE_NON_DICTATION_MSG
);
assert!(validate_shortcut_binding(&combo(primary, &["ctrl-left"])).is_err());
let preferences = UserPreferences {
qa_hotkey: Some(binding),
..UserPreferences::default()
};
assert!(reject_hotkey_collisions(&preferences).is_err());
}
}
#[test]
fn side_specific_rules_are_shared_by_all_hosts() {
let side_specific = combo("D", &["cmd-left", "shift-right"]);
@@ -906,7 +951,9 @@ mod tests {
assert!(is_modifier_chord_binding(&chord));
assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl-left"])).is_err());
assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl", "super-left"])).is_err());
assert!(
validate_shortcut_binding(&combo("ModifierChord", &["ctrl", "super-left"])).is_err()
);
assert!(
validate_shortcut_binding(&combo("ModifierChord", &["cmd-left", "super-left"]))
.is_err()
@@ -555,6 +555,20 @@ fn unsupported_primary(primary: &str) -> BackendError {
mod tests {
use super::*;
#[test]
fn mouse_shortcuts_are_rejected_by_the_linux_host() {
for primary in ["Mouse4", "Mouse5"] {
let binding = ShortcutBinding {
primary: primary.into(),
modifiers: vec!["ctrl".into()],
};
assert_eq!(
shortcut_to_raw(&binding).unwrap_err().code,
BackendErrorCode::Unsupported
);
}
}
#[test]
fn raw_shortcut_conversion_covers_default_linux_actions() {
let qa = ShortcutBinding {
+74 -7
View File
@@ -789,7 +789,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
"windows-core 0.62.2",
]
[[package]]
@@ -1173,6 +1173,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"log",
"openless-core",
"rcgen",
"rustls",
@@ -1180,9 +1181,11 @@ dependencies = [
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
"uuid",
"windows",
"x509-parser",
]
@@ -2468,17 +2471,51 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link",
"windows-result",
"windows-strings",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -2492,6 +2529,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -2516,8 +2564,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
@@ -2529,6 +2586,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
@@ -20,3 +20,10 @@ tempfile = "3"
base64 = "0.22"
sha2 = "0.10"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
log = "0.4"
thiserror = "2"
[target.'cfg(windows)'.dev-dependencies]
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] }
@@ -0,0 +1,17 @@
//! Run the production mouse monitor without loading Tauri's ASR native DLLs.
mod types {
pub use openless_core::shared_types::ShortcutBinding;
}
mod shortcut_binding {
pub use openless_core::binding_requires_mouse_hook;
}
// Only the shared event/error DTOs are used, never the stub's registration methods.
#[allow(dead_code)]
#[path = "../../src/mobile_stubs/combo_hotkey.rs"]
mod combo_hotkey;
#[allow(dead_code)]
#[path = "../../src/mouse_dictation.rs"]
mod mouse_dictation;
@@ -16,6 +16,23 @@ struct TauriSettingsRuntime<'a> {
coord: &'a Coordinator,
}
fn settings_save_error(error: openless_core::BackendError) -> String {
let mut message = error.message;
if let Some(failures) = error
.details
.as_ref()
.and_then(|details| details.get("compensationErrors"))
.and_then(serde_json::Value::as_array)
{
for failure in failures {
if let Some(reason) = failure.get("message").and_then(serde_json::Value::as_str) {
message.push_str(&format!("; rollback failed: {reason}"));
}
}
}
message
}
impl<'a> TauriSettingsRuntime<'a> {
fn new(coord: &'a Coordinator) -> Self {
Self { coord }
@@ -167,7 +184,7 @@ fn persist_settings_with_host_lock_held(
);
}
})
.map_err(|error| error.to_string())
.map_err(settings_save_error)
}
fn persist_settings_preserving_update_channel(
@@ -194,7 +211,7 @@ pub(crate) fn persist_strict_settings(
&TauriSettingsRuntime::new(coord),
)
.map(|_| ())
.map_err(|error| error.to_string())
.map_err(settings_save_error)
}
async fn invalidate_llm_tests_if_thinking_changed(
@@ -313,6 +330,21 @@ pub async fn set_settings(
mod tests {
use super::*;
#[test]
fn shortcut_save_reports_registration_and_rollback_failure() {
let mut error = openless_core::BackendError::new(
openless_core::BackendErrorCode::Platform,
"hook installation failed",
);
error.details = Some(serde_json::json!({
"compensationErrors": [{"message": "old binding restore failed"}]
}));
assert_eq!(
settings_save_error(error),
"hook installation failed; rollback failed: old binding restore failed"
);
}
#[test]
fn settings_save_preserves_current_style_preferences_before_write() {
let packs = crate::types::builtin_style_packs();
@@ -721,12 +753,12 @@ pub(crate) fn replace_dictation_hotkey(
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();
}
}
}
if binding == prefs.dictation_hotkey {
// Re-saving an unchanged binding must retry a failed startup listener.
return coord.try_update_native_dictation_binding();
}
prefs.dictation_hotkey = binding;
sync_dictation_hotkey_legacy_fields(&mut prefs);
reject_hotkey_collisions(&prefs)?;
@@ -738,5 +770,5 @@ pub(crate) fn replace_dictation_hotkey(
&TauriSettingsRuntime::new(coord),
)
.map(|_| ())
.map_err(|error| error.to_string())
.map_err(settings_save_error)
}
+9 -122
View File
@@ -30,7 +30,6 @@ mod capsule_focus;
#[path = "coordinator/dictation_core.rs"]
mod dictation;
mod hotkey_loops;
#[cfg(target_os = "macos")]
mod native_dictation_key;
mod qa;
mod restore_runtime;
@@ -42,6 +41,7 @@ pub(crate) use capsule_focus::{
restore_focus_target_if_possible,
};
use hotkey_loops::*;
use native_dictation_key::try_install_mouse_dictation;
// Instance-local Less Computer replay source used by the compatibility command.
pub(crate) use dictation::{less_computer_event_replay_after, LessComputerEventReplay};
@@ -519,6 +519,8 @@ struct Inner {
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
side_aware_combo: Mutex<Option<crate::side_aware_combo::SideAwareComboMonitor>>,
/// Mouse4/Mouse5 听写监听(WH_MOUSE_LL);与 combo / side-aware 互斥。
mouse_dictation: Mutex<Option<crate::mouse_dictation::MouseDictationMonitor>>,
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
@@ -676,6 +678,7 @@ impl Coordinator {
less_computer_combo_pending_press: Mutex::new(None),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
mouse_dictation: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
@@ -804,6 +807,7 @@ impl Coordinator {
less_computer_combo_pending_press: Mutex::new(None),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
mouse_dictation: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
@@ -979,6 +983,7 @@ impl Coordinator {
#[allow(dead_code)]
pub fn request_shutdown(&self) {
self.inner.shutdown.store(true, Ordering::SeqCst);
self.inner.mouse_dictation.lock().take();
}
/// Call once from RunEvent::Ready, even when recovery is still pending.
@@ -1207,82 +1212,6 @@ impl Coordinator {
try_sync_style_pack_hotkeys_on_main_thread(&self.inner)
}
/// 用户在设置里改了自定义组合键时调用。
pub(crate) fn update_combo_hotkey_binding(&self) {
let target = hotkey_runtime_target(&self.inner);
if crate::shortcut_binding::legacy_modifier_trigger(&target.dictation).is_some() {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
log::info!("[coord] combo hotkey 已关闭(modifier-only)");
return;
}
let binding = target.dictation;
if is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
log::info!("[coord] combo hotkey 已关闭(无绑定)");
return;
}
if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined);
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_tx) {
Ok(monitor) => {
*self.inner.side_aware_combo.lock() = Some(monitor);
let bridge_inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || hotkey_bridge_loop(bridge_inner, rx))
.ok();
log::info!("[coord] side-aware combo hotkey listener installed (via update)");
}
Err(e) => {
log::warn!("[coord] update side-aware combo binding 失败: {e}");
}
}
return;
}
self.inner.side_aware_combo.lock().take();
let inner_clone = Arc::clone(&self.inner);
let binding_for_main = binding.clone();
if self
.inner
.host
.run_on_main_thread(move || {
if let Some(monitor) = inner_clone.combo_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding_for_main.clone()) {
log::warn!("[coord] update combo hotkey binding 失败: {e}");
}
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match ComboHotkeyMonitor::start(binding_for_main, tx) {
Ok(monitor) => {
*inner_clone.combo_hotkey.lock() = Some(monitor);
log::info!(
"[coord] combo hotkey listener installed on main thread (via update)"
);
let bridge_inner = Arc::clone(&inner_clone);
std::thread::Builder::new()
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.ok();
}
Err(e) => {
log::warn!("[coord] update combo hotkey binding 失败: {e}");
}
}
})
.is_err()
{
log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过");
}
}
/// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成),
/// 然后通知活着的 monitor 重新注册;monitor 不存在时 supervisor 会自然
/// 在下一次循环里读到新的 prefs。
@@ -1492,29 +1421,6 @@ impl Coordinator {
report_insert_fallback_card_height(&self.inner, presentation_id, height)
}
pub(crate) fn update_hotkey_binding(&self) {
let target = hotkey_runtime_target(&self.inner);
let dictation_trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation);
let binding = crate::types::HotkeyBinding {
trigger: dictation_trigger.unwrap_or(crate::types::HotkeyTrigger::Custom),
mode: target.dictation_mode,
keys: None,
};
if dictation_trigger.is_some() {
take_combo_hotkey_on_main_thread(&self.inner);
} else {
self.update_combo_hotkey_binding();
}
self.ensure_modifier_hotkey_monitor(binding);
self.update_modifier_shortcut_bindings();
}
fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) {
if let Err(error) = self.try_ensure_modifier_hotkey_monitor(binding) {
log::warn!("[coord] modifier hotkey update failed: {error}");
}
}
fn try_ensure_modifier_hotkey_monitor(
&self,
binding: crate::types::HotkeyBinding,
@@ -1591,28 +1497,9 @@ impl Coordinator {
if previous.style_packs != next.style_packs {
self.try_update_style_pack_hotkey_bindings()?;
}
#[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.dictation != next.dictation || previous.dictation_mode != next.dictation_mode {
self.try_update_native_dictation_binding()?;
self.update_modifier_shortcut_bindings();
}
if previous.qa != next.qa {
self.update_qa_hotkey_binding();
@@ -1377,6 +1377,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
if crate::shortcut_binding::legacy_modifier_trigger(&target.dictation).is_some() {
take_combo_hotkey_on_main_thread(&inner);
inner.side_aware_combo.lock().take();
inner.mouse_dictation.lock().take();
return;
}
@@ -1384,97 +1385,19 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
if is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&inner);
inner.side_aware_combo.lock().take();
inner.mouse_dictation.lock().take();
return;
}
if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) {
take_combo_hotkey_on_main_thread(&inner);
if inner.side_aware_combo.lock().is_some() {
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined);
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_tx) {
Ok(monitor) => {
*inner.side_aware_combo.lock() = Some(monitor);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || hotkey_bridge_loop(inner_clone, rx))
.ok();
return;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试"
);
}
drop(registration);
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
}
}
inner.side_aware_combo.lock().take();
if inner.combo_hotkey.lock().is_some() {
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(0);
let binding_for_main = binding.clone();
if inner
.host
.run_on_main_thread(move || {
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
})
.is_err()
{
drop(registration);
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
drop(init_rx);
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试"
);
}
drop(registration);
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
let coordinator = Coordinator {
inner: Arc::clone(&inner),
};
match init_result {
Ok(monitor) => {
*inner.combo_hotkey.lock() = Some(monitor);
log::info!(
"[coord] combo hotkey listener installed on main thread (after {} attempt(s))",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
.ok();
return;
}
Err(e) => {
match coordinator.try_update_native_dictation_binding() {
Ok(()) => return,
Err(error) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试");
log::warn!("[coord] dictation shortcut registration attempt {attempts} failed: {error}; retrying in 3s");
}
drop(registration);
std::thread::sleep(std::time::Duration::from_secs(3));
@@ -2469,6 +2392,7 @@ pub(crate) mod less_computer_test_support {
less_computer_combo_pending_press: Mutex::new(None),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
mouse_dictation: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
@@ -2,6 +2,7 @@
use super::*;
impl Coordinator {
#[cfg(target_os = "macos")]
pub(crate) fn dictation_shortcut_is_busy(&self) -> bool {
!matches!(
self.backend().snapshot().dictation.phase,
@@ -14,8 +15,19 @@ impl Coordinator {
/// Keep the previous listener until replacement registration succeeds. The
/// caller is a worker thread; Carbon ownership changes run on the UI thread.
#[cfg(not(mobile))]
pub(crate) fn try_update_native_dictation_binding(&self) -> Result<(), String> {
let target = hotkey_runtime_target(&self.inner);
if !is_unconfigured_shortcut(&target.dictation) {
crate::shortcut_binding::validate_binding(&target.dictation)
.map_err(|error| error.to_string())?;
}
self.try_ensure_modifier_hotkey_monitor(crate::types::HotkeyBinding {
trigger: crate::shortcut_binding::legacy_modifier_trigger(&target.dictation)
.unwrap_or(crate::types::HotkeyTrigger::Custom),
mode: target.dictation_mode,
keys: None,
})?;
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
@@ -27,7 +39,7 @@ impl Coordinator {
let cancelled = callback_cancelled.lock();
let result = (|| {
if *cancelled || hotkey_runtime_target(&inner) != target {
return Err("macDictationKeyChanged".into());
return Err("Dictation shortcut target changed during registration".into());
}
let binding = target.dictation.clone();
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&binding);
@@ -37,6 +49,11 @@ impl Coordinator {
}
inner.combo_hotkey.lock().take();
inner.side_aware_combo.lock().take();
inner.mouse_dictation.lock().take();
} else if crate::shortcut_binding::binding_requires_mouse_hook(&binding) {
try_install_mouse_dictation(&inner, binding)?;
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() {
@@ -48,8 +65,7 @@ impl Coordinator {
.map_err(|error| error.to_string())?;
} else {
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
let combo_tx =
spawn_combo_abort_bridge(&inner, handle_trigger_combined);
let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined);
let monitor = crate::side_aware_combo::SideAwareComboMonitor::start(
binding, tx, combo_tx,
)
@@ -62,6 +78,7 @@ impl Coordinator {
*slot = Some(monitor);
}
inner.combo_hotkey.lock().take();
inner.mouse_dictation.lock().take();
} else {
let mut slot = inner.combo_hotkey.lock();
if let Some(monitor) = slot.as_ref() {
@@ -80,6 +97,7 @@ impl Coordinator {
*slot = Some(monitor);
}
inner.side_aware_combo.lock().take();
inner.mouse_dictation.lock().take();
}
if let Some(monitor) = inner.hotkey.lock().as_ref() {
monitor.update_binding(crate::types::HotkeyBinding {
@@ -102,10 +120,46 @@ impl Coordinator {
Ok(result) => result,
Err(_) => {
*cancelled = true;
Err("macDictationKeyUnavailable".into())
Err("Dictation shortcut registration did not acknowledge startup".into())
}
}
}
}
}
#[cfg(mobile)]
pub(crate) fn try_update_native_dictation_binding(&self) -> Result<(), String> {
Err("Global dictation shortcuts are unavailable on mobile".into())
}
}
/// Publish only after both the native hook and its event consumer are ready.
pub(super) fn try_install_mouse_dictation(
inner: &Arc<Inner>,
binding: crate::types::ShortcutBinding,
) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|error| error.to_string())?;
let mut slot = inner.mouse_dictation.lock();
if inner.shutdown.load(Ordering::SeqCst) {
return Err("Cannot register mouse shortcut during shutdown".into());
}
if slot.as_ref().is_some_and(|monitor| !monitor.is_running()) {
slot.take();
}
if let Some(monitor) = slot.as_ref() {
return monitor
.update_binding(binding)
.map_err(|error| error.to_string());
}
let bridge_inner = Arc::clone(inner);
let monitor =
crate::mouse_dictation::MouseDictationMonitor::start_with_bridge(binding, |rx| {
std::thread::Builder::new()
.name("openless-mouse-dictation-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.map(|_| ())
.map_err(|error| error.to_string())
})?;
*slot = Some(monitor);
Ok(())
}
@@ -26,6 +26,10 @@ impl openless_core::config::RestoreRuntimeEffects for RestoreHost {
.ok_or_else(|| failure("restore_host_unavailable"))?;
openless_core::reject_hotkey_collisions(&target)
.map_err(|_| failure("restore_hotkey_conflict"))?;
if crate::shortcut_binding::binding_requires_mouse_hook(&target.dictation_hotkey) {
crate::shortcut_binding::validate_binding(&target.dictation_hotkey)
.map_err(|_| failure("restore_mouse_hotkey_unsupported"))?;
}
let coord = Coordinator {
inner: Arc::clone(&inner),
};
@@ -197,7 +201,12 @@ fn reconcile_hotkeys_on_main(
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation);
if trigger.is_some() || is_unconfigured_shortcut(&target.dictation) {
inner.side_aware_combo.lock().take();
inner.mouse_dictation.lock().take();
} else if crate::shortcut_binding::binding_requires_mouse_hook(&target.dictation) {
inner.side_aware_combo.lock().take();
try_install_mouse_dictation(inner, target.dictation.clone())?;
} else if crate::shortcut_binding::binding_requires_side_aware_hook(&target.dictation) {
inner.mouse_dictation.lock().take();
let mut side_slot = inner.side_aware_combo.lock();
if let Some(monitor) = side_slot.as_ref() {
monitor
@@ -221,6 +230,7 @@ fn reconcile_hotkeys_on_main(
}
} else {
inner.side_aware_combo.lock().take();
inner.mouse_dictation.lock().take();
let (send, receive) = mpsc::channel();
let monitor = ComboHotkeyMonitor::start(target.dictation.clone(), send)
.map_err(|error| error.to_string())?;
+5
View File
@@ -96,6 +96,11 @@ mod side_aware_combo;
#[cfg(mobile)]
#[path = "mobile_stubs/side_aware_combo.rs"]
mod side_aware_combo;
#[cfg(not(mobile))]
mod mouse_dictation;
#[cfg(mobile)]
#[path = "mobile_stubs/mouse_dictation.rs"]
mod mouse_dictation;
mod tauri_events;
mod types;
#[cfg(not(mobile))]
@@ -0,0 +1,38 @@
//! Mobile stub — global mouse dictation triggers are unavailable on Android/iOS.
use std::sync::mpsc::Sender;
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent};
use crate::types::ShortcutBinding;
pub struct MouseDictationMonitor;
impl MouseDictationMonitor {
pub fn start_with_bridge(
_binding: ShortcutBinding,
_bridge: impl FnOnce(std::sync::mpsc::Receiver<ComboHotkeyEvent>) -> Result<(), String>,
) -> Result<Self, String> {
Err("Mouse shortcuts are only supported on Windows".into())
}
pub fn is_running(&self) -> bool {
false
}
pub fn start(
_binding: ShortcutBinding,
_tx: Sender<ComboHotkeyEvent>,
) -> Result<Self, ComboHotkeyError> {
Err(ComboHotkeyError::RegisterFailed(
"mouse dictation is not available on mobile".into(),
))
}
pub fn update_binding(&self, _binding: ShortcutBinding) -> Result<(), ComboHotkeyError> {
Err(ComboHotkeyError::RegisterFailed(
"mouse dictation is not available on mobile".into(),
))
}
}
pub fn handle_button(_primary: &str, _pressed: bool) {}
@@ -3,8 +3,9 @@
use crate::types::ShortcutBinding;
pub use openless_core::{
binding_requires_side_aware_hook, bindings_overlap, is_side_specific_modifier_tag,
legacy_modifier_trigger, normalize_side_modifier_tag, reject_side_specific_non_dictation,
binding_requires_mouse_hook, binding_requires_side_aware_hook, bindings_overlap,
is_mouse_button_primary, is_side_specific_modifier_tag, legacy_modifier_trigger,
normalize_side_modifier_tag, reject_side_specific_non_dictation, MOUSE_NON_DICTATION_MSG,
SIDE_SPECIFIC_NON_DICTATION_MSG,
};
@@ -0,0 +1,614 @@
//! Global Mouse4 / Mouse5 dictation triggers via a dedicated mouse hook.
//!
//! Keyboard combos use `global-hotkey` / `RegisterHotKey`, which cannot register
//! mouse buttons. This module mirrors the MacDictationKey / side-aware pattern:
//! install a native listener and emit [`ComboHotkeyEvent`] into the shared
//! combo bridge so Hold / Toggle edge semantics stay identical.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{OnceLock, RwLock};
use std::time::Instant;
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent};
use crate::shortcut_binding::binding_requires_mouse_hook;
use crate::types::ShortcutBinding;
static ACTIVE_MOUSE: OnceLock<RwLock<Option<MouseMonitorState>>> = OnceLock::new();
struct MouseMonitorState {
primary: String,
/// Normalized generic modifier tags: ctrl / alt / shift / super.
modifiers: Vec<String>,
tx: Sender<ComboHotkeyEvent>,
held: AtomicBool,
}
impl MouseMonitorState {
fn handle_edge(&self, pressed: bool, modifiers_match: bool) {
if pressed {
if modifiers_match && !self.held.swap(true, Ordering::SeqCst) {
send_edge(self, ComboHotkeyEvent::Pressed { at: Instant::now() });
}
} else {
// Release belongs to the accepted press even if modifiers changed.
release_held(self);
}
}
}
pub struct MouseDictationMonitor {
#[cfg(all(target_os = "windows", not(test)))]
_hook: platform::HookThread,
}
impl MouseDictationMonitor {
/// A bridge failure drops the installed hook before returning to the transaction.
pub fn start_with_bridge(
binding: ShortcutBinding,
bridge: impl FnOnce(std::sync::mpsc::Receiver<ComboHotkeyEvent>) -> Result<(), String>,
) -> Result<Self, String> {
let (tx, rx) = std::sync::mpsc::channel();
let monitor = Self::start(binding, tx).map_err(|error| error.to_string())?;
bridge(rx)?;
Ok(monitor)
}
pub fn is_running(&self) -> bool {
#[cfg(all(target_os = "windows", not(test)))]
{
self._hook.is_running()
}
#[cfg(any(not(target_os = "windows"), test))]
{
true
}
}
pub fn start(
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
) -> Result<Self, ComboHotkeyError> {
#[cfg(all(not(target_os = "windows"), not(test)))]
{
let _ = (binding, tx);
return Err(ComboHotkeyError::RegisterFailed(
"Mouse4/Mouse5 global hotkeys are currently supported on Windows only".into(),
));
}
#[cfg(any(target_os = "windows", test))]
{
let state = state_from_binding(binding, tx)?;
let slot = ACTIVE_MOUSE.get_or_init(|| RwLock::new(None));
let mut guard = slot
.write()
.map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?;
if guard.is_some() {
return Err(ComboHotkeyError::RegisterFailed(
"mouse monitor already active".into(),
));
}
#[cfg(all(target_os = "windows", not(test)))]
let hook = platform::HookThread::start().map_err(ComboHotkeyError::RegisterFailed)?;
*guard = Some(state);
Ok(Self {
#[cfg(all(target_os = "windows", not(test)))]
_hook: hook,
})
}
}
pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), ComboHotkeyError> {
let slot = ACTIVE_MOUSE
.get()
.ok_or_else(|| ComboHotkeyError::RegisterFailed("mouse monitor inactive".into()))?;
let mut guard = slot
.write()
.map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?;
let Some(existing) = guard.as_mut() else {
return Err(ComboHotkeyError::RegisterFailed(
"mouse monitor inactive".into(),
));
};
let next = state_from_binding(binding, existing.tx.clone())?;
release_held(existing);
existing.primary = next.primary;
existing.modifiers = next.modifiers;
Ok(())
}
}
impl Drop for MouseDictationMonitor {
fn drop(&mut self) {
if let Some(slot) = ACTIVE_MOUSE.get() {
if let Ok(mut guard) = slot.write() {
if let Some(state) = guard.as_mut() {
release_held(state);
}
*guard = None;
}
}
}
}
fn state_from_binding(
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
) -> Result<MouseMonitorState, ComboHotkeyError> {
if !binding_requires_mouse_hook(&binding) {
return Err(ComboHotkeyError::UnsupportedKey(binding.primary));
}
let primary = normalize_mouse_primary(&binding.primary)
.ok_or_else(|| ComboHotkeyError::UnsupportedKey(binding.primary.clone()))?;
let mut modifiers = Vec::new();
for raw in &binding.modifiers {
modifiers.push(
normalize_generic_modifier(raw)
.ok_or_else(|| ComboHotkeyError::UnsupportedModifier(raw.clone()))?,
);
}
modifiers.sort();
modifiers.dedup();
Ok(MouseMonitorState {
primary,
modifiers,
tx,
held: AtomicBool::new(false),
})
}
fn normalize_mouse_primary(raw: &str) -> Option<String> {
match raw.trim().to_ascii_uppercase().as_str() {
"MOUSE4" => Some("Mouse4".into()),
"MOUSE5" => Some("Mouse5".into()),
_ => None,
}
}
fn normalize_generic_modifier(raw: &str) -> Option<String> {
match raw.trim().to_ascii_lowercase().as_str() {
"ctrl" | "control" => Some("ctrl".into()),
"alt" | "option" | "opt" => Some("alt".into()),
"shift" => Some("shift".into()),
"cmd" | "command" if cfg!(target_os = "windows") => Some("ctrl".into()),
"cmd" | "command" | "super" | "meta" | "win" => Some("super".into()),
_ => None,
}
}
fn release_held(state: &MouseMonitorState) {
if state.held.swap(false, Ordering::SeqCst) {
send_edge(state, ComboHotkeyEvent::Released { at: Instant::now() });
}
}
fn with_active<F, R>(f: F) -> Option<R>
where
F: FnOnce(&MouseMonitorState) -> R,
{
let slot = ACTIVE_MOUSE.get()?;
let guard = slot.read().ok()?;
guard.as_ref().map(f)
}
fn send_edge(state: &MouseMonitorState, evt: ComboHotkeyEvent) {
if let Err(err) = state.tx.send(evt) {
log::warn!("[mouse-dictation] event send failed: {err}");
}
}
fn modifiers_match(required: &[String]) -> bool {
#[cfg(all(target_os = "windows", not(test)))]
{
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
// Match hotkey.rs: use raw VK codes rather than VIRTUAL_KEY helpers.
const VK_SHIFT: i32 = 0x10;
const VK_CONTROL: i32 = 0x11;
const VK_MENU: i32 = 0x12;
const VK_LWIN: i32 = 0x5B;
const VK_RWIN: i32 = 0x5C;
let ctrl = unsafe { GetAsyncKeyState(VK_CONTROL) } < 0;
let alt = unsafe { GetAsyncKeyState(VK_MENU) } < 0;
let shift = unsafe { GetAsyncKeyState(VK_SHIFT) } < 0;
let meta =
unsafe { GetAsyncKeyState(VK_LWIN) } < 0 || unsafe { GetAsyncKeyState(VK_RWIN) } < 0;
match_modifier_states(required, [ctrl, alt, shift, meta])
}
#[cfg(any(not(target_os = "windows"), test))]
{
// Tests synthesize edges without real modifier state; require empty modifiers.
match_modifier_states(required, [false; 4])
}
}
fn match_modifier_states(required: &[String], down: [bool; 4]) -> bool {
// Exact matching also rejects unexpected modifiers for bare mouse bindings.
["ctrl", "alt", "shift", "super"]
.into_iter()
.zip(down)
.all(|(tag, pressed)| pressed == required.iter().any(|required| required == tag))
}
/// Dispatch a Mouse4 / Mouse5 edge into the active monitor (if any).
pub fn handle_button(primary: &str, pressed: bool) {
let Some(normalized) = normalize_mouse_primary(primary) else {
return;
};
with_active(|state| {
if state.primary != normalized {
return;
}
state.handle_edge(pressed, modifiers_match(&state.modifiers));
});
}
#[cfg(target_os = "windows")]
pub mod platform {
use super::*;
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
use windows::Win32::System::Threading::GetCurrentThreadId;
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, PeekMessageW, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx,
HC_ACTION, HHOOK, MSLLHOOKSTRUCT, PM_NOREMOVE, WH_MOUSE_LL, WM_QUIT, WM_XBUTTONDOWN,
WM_XBUTTONUP, XBUTTON1, XBUTTON2,
};
pub struct HookThread {
thread_id: u32,
thread: Option<std::thread::JoinHandle<()>>,
}
impl HookThread {
pub fn is_running(&self) -> bool {
self.thread
.as_ref()
.is_some_and(|thread| !thread.is_finished())
}
pub fn start() -> Result<Self, String> {
Self::start_with_install(|| unsafe {
SetWindowsHookExW(WH_MOUSE_LL, Some(low_level_mouse_proc), None, 0)
.map_err(|error| format!("mouse hook install failed: {error}"))
})
}
fn start_with_install(
install: impl FnOnce() -> Result<HHOOK, String> + Send + 'static,
) -> Result<Self, String> {
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(0);
let thread = std::thread::Builder::new()
.name("openless-mouse-hook".into())
.spawn(move || unsafe {
let mut msg = windows::Win32::UI::WindowsAndMessaging::MSG::default();
// Create the queue before publishing its thread id to Drop.
let _ = PeekMessageW(&mut msg, None, 0, 0, PM_NOREMOVE);
let hook = match install() {
Ok(hook) => hook,
Err(error) => {
let _ = ready_tx.send(Err(error));
return;
}
};
if ready_tx.send(Ok(GetCurrentThreadId())).is_ok() {
while windows::Win32::UI::WindowsAndMessaging::GetMessageW(
&mut msg, None, 0, 0,
)
.0 > 0
{
let _ = windows::Win32::UI::WindowsAndMessaging::TranslateMessage(&msg);
let _ = windows::Win32::UI::WindowsAndMessaging::DispatchMessageW(&msg);
}
}
let _ = UnhookWindowsHookEx(hook);
})
.map_err(|e| format!("spawn mouse hook thread: {e}"))?;
// A timeout drops the rendezvous receiver; the worker then unhooks itself.
let thread_id = ready_rx
.recv_timeout(std::time::Duration::from_secs(3))
.map_err(|error| format!("mouse hook startup failed: {error}"))??;
Ok(Self {
thread_id,
thread: Some(thread),
})
}
}
impl Drop for HookThread {
fn drop(&mut self) {
let Some(thread) = self.thread.take() else {
return;
};
if !thread.is_finished() {
if let Err(error) =
unsafe { PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)) }
{
log::error!("[mouse-dictation] stop hook failed: {error}");
return;
}
}
// MouseDictationMonitor released the callback's state lock before this join.
if thread.join().is_err() {
log::error!("[mouse-dictation] hook thread panicked");
}
}
}
unsafe extern "system" fn low_level_mouse_proc(
code: i32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
if code == HC_ACTION as i32 && lparam.0 != 0 {
let msg = wparam.0 as u32;
let mouse = std::ptr::read(lparam.0 as *const MSLLHOOKSTRUCT);
if matches!(msg, WM_XBUTTONDOWN | WM_XBUTTONUP) {
let hi = ((mouse.mouseData >> 16) & 0xFFFF) as u16;
let primary = if hi == XBUTTON1 as u16 {
Some("Mouse4")
} else if hi == XBUTTON2 as u16 {
Some("Mouse5")
} else {
None
};
if let Some(primary) = primary {
handle_button(primary, msg == WM_XBUTTONDOWN);
}
}
}
CallNextHookEx(None, code, wparam, lparam)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installation_failure_is_reported_and_can_retry() {
for attempt in 0..2 {
let error =
HookThread::start_with_install(move || Err(format!("install {attempt}")))
.err()
.expect("installation must fail");
assert_eq!(error, format!("install {attempt}"));
}
}
#[test]
#[ignore = "installs real Windows hooks; run explicitly on an interactive desktop"]
fn native_hook_can_stop_and_restart() {
for _ in 0..3 {
drop(HookThread::start().expect("native hook startup"));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{mpsc, Mutex};
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn clear_active_monitor() {
if let Some(slot) = ACTIVE_MOUSE.get() {
if let Ok(mut guard) = slot.write() {
if let Some(state) = guard.as_mut() {
release_held(state);
}
*guard = None;
}
}
}
fn mouse4_binding() -> ShortcutBinding {
ShortcutBinding {
primary: "Mouse4".into(),
modifiers: vec![],
}
}
#[test]
fn failed_bridge_drops_monitor_and_allows_retry() {
let _lock = TEST_LOCK.lock().unwrap();
let error = MouseDictationMonitor::start_with_bridge(mouse4_binding(), |_| {
Err("spawn bridge failed".into())
})
.err()
.expect("bridge failure must propagate");
assert_eq!(error, "spawn bridge failed");
let mut receiver = None;
let monitor = MouseDictationMonitor::start_with_bridge(mouse4_binding(), |rx| {
receiver = Some(rx);
Ok(())
})
.unwrap();
handle_button("Mouse4", true);
let receiver = receiver.unwrap();
assert!(matches!(
receiver.try_recv().unwrap(),
ComboHotkeyEvent::Pressed { .. }
));
drop(monitor);
assert!(matches!(
receiver.try_recv().unwrap(),
ComboHotkeyEvent::Released { .. }
));
assert!(matches!(
receiver.try_recv(),
Err(mpsc::TryRecvError::Disconnected)
));
}
#[test]
fn modifiers_match_exactly_and_use_keyboard_aliases() {
let required = vec!["ctrl".into()];
assert!(match_modifier_states(
&required,
[true, false, false, false]
));
assert!(!match_modifier_states(&required, [false; 4]));
assert!(!match_modifier_states(
&required,
[true, false, true, false]
));
assert!(!match_modifier_states(&[], [true, false, false, false]));
assert_eq!(
normalize_generic_modifier("cmd").as_deref(),
Some(if cfg!(target_os = "windows") {
"ctrl"
} else {
"super"
})
);
}
#[test]
fn edges_deduplicate_and_release_after_modifiers_change() {
let (tx, rx) = mpsc::channel();
let state = state_from_binding(mouse4_binding(), tx).unwrap();
state.handle_edge(true, false);
assert!(rx.try_recv().is_err());
state.handle_edge(true, true);
state.handle_edge(true, true);
assert!(matches!(
rx.try_recv().unwrap(),
ComboHotkeyEvent::Pressed { .. }
));
assert!(rx.try_recv().is_err());
state.handle_edge(false, false);
state.handle_edge(false, false);
assert!(matches!(
rx.try_recv().unwrap(),
ComboHotkeyEvent::Released { .. }
));
assert!(rx.try_recv().is_err());
}
#[test]
fn invalid_update_preserves_held_binding_and_valid_update_releases_once() {
let _lock = TEST_LOCK.lock().unwrap();
let (tx, rx) = mpsc::channel();
let monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
handle_button("Mouse4", true);
let _ = rx.try_recv().unwrap();
assert!(monitor
.update_binding(ShortcutBinding {
primary: "Mouse5".into(),
modifiers: vec!["ctrl-left".into()],
})
.is_err());
assert!(rx.try_recv().is_err());
monitor
.update_binding(ShortcutBinding {
primary: "Mouse5".into(),
modifiers: vec![],
})
.unwrap();
assert!(matches!(
rx.try_recv().unwrap(),
ComboHotkeyEvent::Released { .. }
));
handle_button("Mouse4", false);
assert!(rx.try_recv().is_err());
drop(monitor);
assert!(rx.try_recv().is_err());
}
#[test]
fn invalid_start_does_not_reserve_singleton() {
let _lock = TEST_LOCK.lock().unwrap();
let (tx, _rx) = mpsc::channel();
assert!(MouseDictationMonitor::start(
ShortcutBinding {
primary: "Mouse3".into(),
modifiers: vec![],
},
tx.clone()
)
.is_err());
for _ in 0..3 {
drop(MouseDictationMonitor::start(mouse4_binding(), tx.clone()).unwrap());
}
}
#[test]
fn press_release_emits_combo_edges() {
let _lock = TEST_LOCK.lock().unwrap();
clear_active_monitor();
let (tx, rx) = mpsc::channel();
let _monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
handle_button("Mouse4", true);
assert!(matches!(
rx.recv().unwrap(),
ComboHotkeyEvent::Pressed { .. }
));
handle_button("Mouse4", false);
assert!(matches!(
rx.recv().unwrap(),
ComboHotkeyEvent::Released { .. }
));
clear_active_monitor();
}
#[test]
fn wrong_button_ignored() {
let _lock = TEST_LOCK.lock().unwrap();
clear_active_monitor();
let (tx, rx) = mpsc::channel();
let _monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
handle_button("Mouse5", true);
assert!(rx.try_recv().is_err());
clear_active_monitor();
}
#[test]
fn dropping_monitor_emits_release_when_held() {
let _lock = TEST_LOCK.lock().unwrap();
clear_active_monitor();
let (tx, rx) = mpsc::channel();
let monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
handle_button("Mouse4", true);
assert!(matches!(
rx.recv().unwrap(),
ComboHotkeyEvent::Pressed { .. }
));
drop(monitor);
assert!(matches!(
rx.recv().unwrap(),
ComboHotkeyEvent::Released { .. }
));
clear_active_monitor();
}
#[test]
fn update_binding_switches_primary() {
let _lock = TEST_LOCK.lock().unwrap();
clear_active_monitor();
let (tx, rx) = mpsc::channel();
let monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
monitor
.update_binding(ShortcutBinding {
primary: "Mouse5".into(),
modifiers: vec![],
})
.unwrap();
handle_button("Mouse4", true);
assert!(rx.try_recv().is_err());
handle_button("Mouse5", true);
assert!(matches!(
rx.recv().unwrap(),
ComboHotkeyEvent::Pressed { .. }
));
clear_active_monitor();
}
}
@@ -7,13 +7,22 @@ use crate::types::HotkeyTrigger;
use crate::types::ShortcutBinding;
pub use openless_core::{
binding_requires_side_aware_hook, bindings_overlap, is_side_specific_modifier_tag,
legacy_modifier_trigger, normalize_side_modifier_tag, reject_side_specific_non_dictation,
ShortcutBindingError, SIDE_SPECIFIC_NON_DICTATION_MSG,
binding_requires_mouse_hook, binding_requires_side_aware_hook, bindings_overlap,
is_side_specific_modifier_tag, legacy_modifier_trigger, normalize_side_modifier_tag,
reject_side_specific_non_dictation, ShortcutBindingError, SIDE_SPECIFIC_NON_DICTATION_MSG,
};
pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> {
openless_core::validate_shortcut_binding(binding)?;
if binding_requires_mouse_hook(binding) {
return if cfg!(target_os = "windows") {
Ok(())
} else {
Err(ShortcutBindingError::UnsupportedKey(
"Mouse4/Mouse5 global shortcuts require Windows".into(),
))
};
}
#[cfg(target_os = "macos")]
if binding.primary == crate::macos_dictation_key::PRIMARY {
return Ok(());
@@ -61,6 +70,7 @@ fn normalize_modifier_tag(raw: &str) -> String {
}
pub fn parse_primary(raw: &str) -> Result<Code, ShortcutBindingError> {
let raw = if raw == " " { "Space" } else { raw };
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ShortcutBindingError::UnsupportedKey("(空)".into()));
@@ -268,6 +278,44 @@ mod tests {
assert!(validate_binding(&binding).is_ok());
}
#[test]
fn mouse_button_skips_global_hotkey_parse() {
let binding = ShortcutBinding {
primary: "Mouse4".into(),
modifiers: vec![],
};
assert!(binding_requires_mouse_hook(&binding));
assert_eq!(
validate_binding(&binding).is_ok(),
cfg!(target_os = "windows")
);
assert!(parse_global_hotkey(&binding).is_err());
}
#[test]
fn mouse_with_ctrl_validates_without_registerhotkey() {
let binding = ShortcutBinding {
primary: "Mouse5".into(),
modifiers: vec!["ctrl".into()],
};
assert!(binding_requires_mouse_hook(&binding));
assert_eq!(
validate_binding(&binding).is_ok(),
cfg!(target_os = "windows")
);
}
#[test]
fn literal_space_primary_normalizes() {
let binding = ShortcutBinding {
primary: " ".into(),
modifiers: vec!["ctrl".into()],
};
assert!(validate_binding(&binding).is_ok());
let parsed = parse_global_hotkey(&binding).expect("space parses");
assert_eq!(parsed.key, Code::Space);
}
#[test]
fn generic_combo_uses_global_hotkey_path() {
let binding = ShortcutBinding {
@@ -8,7 +8,11 @@ import {
MODIFIER_CHORD_PRIMARY,
modifiersFromPressedCodes,
} from '../lib/hotkey';
import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder';
import {
primaryFromKeyboardEvent,
formatShortcutSaveError,
shortcutFromMouseEvent,
} from '../lib/hotkeyRecorder';
import { KbdGroup } from './Kbd';
import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc';
import type { ShortcutBinding } from '../lib/types';
@@ -35,6 +39,7 @@ export function ShortcutRecorder({
comboOnly = false,
sideSpecificModifiers = false,
allowMacDictationKey = false,
allowMouseButtons = false,
}: {
value: ShortcutBinding | null;
onSave: (binding: ShortcutBinding) => Promise<void>;
@@ -54,6 +59,8 @@ export function ShortcutRecorder({
sideSpecificModifiers?: boolean;
/** macOS dictation only: choose the dedicated key as the single trigger. */
allowMacDictationKey?: boolean;
/** Windows dictation only; other shortcut consumers cannot install mouse hooks. */
allowMouseButtons?: boolean;
}) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
@@ -131,10 +138,7 @@ export function ShortcutRecorder({
setRecording(false);
setError(null);
} catch (reason) {
const message = String(reason);
setError(
message.includes('macDictationKey') ? message : t('settings.recording.comboConflict'),
);
setError(formatShortcutSaveError(reason, t('settings.recording.shortcutSaveFailed')));
}
};
@@ -168,12 +172,23 @@ export function ShortcutRecorder({
console.warn('[shortcut] recording state sync failed', error);
}
})();
const onMouseDown = (e: MouseEvent) => {
if (cancelled || !allowMouseButtons) return;
const binding = shortcutFromMouseEvent(e);
if (!binding) return;
e.preventDefault();
e.stopPropagation();
clearPendingModifier();
void finishRef.current(binding);
};
window.addEventListener('mousedown', onMouseDown, true);
return () => {
cancelled = true;
unlisten?.();
window.removeEventListener('mousedown', onMouseDown, true);
void setShortcutRecordingActive(false);
};
}, [recording]);
}, [recording, allowMouseButtons]);
/** 开始录入:同时收起菜单——「录制快捷键」按下后,重置/停用两个按钮随之消失。 */
const startRecording = () => {
@@ -261,10 +276,7 @@ export function ShortcutRecorder({
try {
await onReset?.();
} catch (reason) {
const message = String(reason);
setError(
message.includes('macDictationKey') ? message : t('settings.recording.comboConflict'),
);
setError(formatShortcutSaveError(reason, t('settings.recording.shortcutSaveFailed')));
}
};
@@ -376,6 +388,7 @@ export function ShortcutRecorder({
{t('settings.recording.comboRecordHint')}
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
Esc · {t('common.cancel')}
{allowMouseButtons && <> · {t('settings.recording.mouseSideHint')}</>}
</div>
</motion.div>
) : (
@@ -509,48 +522,3 @@ function modifierPrimaryFromCode(code: string, key: string): string {
if (key === 'Shift') return 'Shift';
return '';
}
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;
const codeToName: Record<string, string> = {
Space: 'Space',
Enter: 'Enter',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Delete',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
};
if (/^F\d{1,2}$/.test(e.key)) return e.key;
return codeToName[e.code] || e.key;
}
function primaryFromPrintableCode(code: string): string {
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
const codeToPrimary: Record<string, string> = {
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/',
IntlBackslash: '\\',
};
return codeToPrimary[code] || '';
}
+2
View File
@@ -1471,6 +1471,8 @@ export const de: typeof zhCN = {
comboRecorded: 'Aufgezeichnet',
comboClear: 'Leeren',
comboConflict: 'Diese Tastenkombination ist nicht verfügbar',
shortcutSaveFailed: 'Tastenkombination konnte nicht gespeichert werden',
mouseSideHint: 'Mausseitentasten Mouse4 / Mouse5 werden als globale Diktier-Hotkeys unter Windows unterstützt',
allowNonTsfFallbackLabel: 'Alternative ohne TSF erlauben',
allowNonTsfFallbackDesc:
'Windows: Falls das Einfügen über TSF fehlschlägt, wird Unicode-Text dosiert über SendInput eingegeben. Schlägt auch das fehl, wird der Text in die Zwischenablage kopiert.',
+2
View File
@@ -1442,6 +1442,8 @@ export const en: typeof zhCN = {
comboRecorded: 'Recorded',
comboClear: 'Clear',
comboConflict: 'This shortcut combination is not available',
shortcutSaveFailed: 'Failed to save shortcut',
mouseSideHint: 'Mouse4 / Mouse5 side buttons are supported as global dictation hotkeys on Windows',
allowNonTsfFallbackLabel: 'Allow non-TSF fallback',
allowNonTsfFallbackDesc:
'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.',
+2
View File
@@ -1463,6 +1463,8 @@ export const es: typeof zhCN = {
comboRecorded: 'Atajo registrado',
comboClear: 'Borrar',
comboConflict: 'Esta combinación de teclas no está disponible',
shortcutSaveFailed: 'No se pudo guardar el atajo',
mouseSideHint: 'Los botones laterales Mouse4 / Mouse5 se admiten como atajos globales de dictado en Windows',
allowNonTsfFallbackLabel: 'Permitir alternativa sin TSF',
allowNonTsfFallbackDesc:
'Windows: si falla la inserción TSF, usa SendInput Unicode con pausas. Si también falla, copia el texto al portapapeles.',
+2
View File
@@ -1485,6 +1485,8 @@ export const fr: typeof zhCN = {
comboRecorded: 'Raccourci enregistré',
comboClear: 'Effacer',
comboConflict: 'Cette combinaison n’est pas disponible',
shortcutSaveFailed: 'Impossible d’enregistrer le raccourci',
mouseSideHint: 'Les boutons latéraux Mouse4 / Mouse5 sont pris en charge comme raccourcis de dictée globaux sous Windows',
allowNonTsfFallbackLabel: 'Autoriser une solution de repli sans TSF',
allowNonTsfFallbackDesc:
'Windows : si l’insertion TSF échoue, utilise SendInput Unicode avec des pauses. En cas de nouvel échec, copie le texte dans le presse-papiers.',
+2
View File
@@ -1432,6 +1432,8 @@ export const ja: typeof zhCN = {
comboRecorded: '記録済み',
comboClear: 'クリア',
comboConflict: 'このショートカットの組み合わせは使用できません',
shortcutSaveFailed: 'ショートカットの保存に失敗しました',
mouseSideHint: 'マウスサイドボタン Mouse4 / Mouse5 は Windows でグローバルなディクテーションホットキーとして使えます',
allowNonTsfFallbackLabel: '非 TSF フォールバックを許可',
allowNonTsfFallbackDesc:
'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。',
+2
View File
@@ -1420,6 +1420,8 @@ export const ko: typeof zhCN = {
comboRecorded: '녹화됨',
comboClear: '지우기',
comboConflict: '이 단축키 조합은 사용할 수 없습니다',
shortcutSaveFailed: '단축키를 저장하지 못했습니다',
mouseSideHint: '마우스 측면 버튼 Mouse4 / Mouse5는 Windows에서 전역 받아쓰기 단축키로 지원됩니다',
allowNonTsfFallbackLabel: '비 TSF 폴백 허용',
allowNonTsfFallbackDesc:
'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.',
+2
View File
@@ -1373,6 +1373,8 @@ export const zhCN = {
comboRecorded: '已录制',
comboClear: '清除',
comboConflict: '该快捷键组合不可用',
shortcutSaveFailed: '保存快捷键失败',
mouseSideHint: '支持鼠标侧键 Mouse4 / Mouse5(Windows 全局)',
allowNonTsfFallbackLabel: '允许非 TSF 兜底',
allowNonTsfFallbackDesc:
'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。',
+2
View File
@@ -1374,6 +1374,8 @@ export const zhTW: typeof zhCN = {
comboRecorded: '已錄製',
comboClear: '清除',
comboConflict: '此快捷鍵組合不可用',
shortcutSaveFailed: '儲存快捷鍵失敗',
mouseSideHint: '支援滑鼠側鍵 Mouse4 / Mouse5(Windows 全域)',
allowNonTsfFallbackLabel: '允許非 TSF 兜底',
allowNonTsfFallbackDesc:
'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。',
@@ -1,7 +1,10 @@
import {
createHotkeyRecorderState,
formatShortcutSaveError,
shortcutFromMouseEvent,
functionKeyPrimaryFromEvent,
orderHotkeyCodes,
primaryFromKeyboardEvent,
updateHotkeyRecorderState,
} from './hotkeyRecorder';
@@ -11,6 +14,28 @@ function assertEqual<T>(actual: T, expected: T, name: string) {
}
}
for (const message of [
'RegisterFailed',
'ManagerInitFailed',
'Windows only',
'Conflict',
'unexpected backend failure',
]) {
assertEqual(formatShortcutSaveError(message, 'Save failed'), message, 'preserves backend error');
assertEqual(
formatShortcutSaveError(new Error(message), 'Save failed'),
message,
'preserves Error message',
);
}
for (const reason of ['', ' ', null, undefined, {}]) {
assertEqual(
formatShortcutSaveError(reason, 'Save failed'),
'Save failed',
'empty error fallback',
);
}
function assertDeepEqual(actual: unknown, expected: unknown, name: string) {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
@@ -19,6 +44,45 @@ function assertDeepEqual(actual: unknown, expected: unknown, name: string) {
}
}
for (const [button, primary] of [
[3, 'Mouse4'],
[4, 'Mouse5'],
] as const) {
assertDeepEqual(
shortcutFromMouseEvent({
button,
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
}),
{ primary, modifiers: ['ctrl'] },
'mouse event preserves a modifier held before recording',
);
assertDeepEqual(
shortcutFromMouseEvent({
button,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
}),
{ primary, modifiers: [] },
'bare mouse side button',
);
}
assertEqual(
shortcutFromMouseEvent({
button: 0,
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
}),
null,
'ordinary click cannot become a shortcut',
);
function apply(state = createHotkeyRecorderState(), code: string, pressed: boolean) {
const next = updateHotkeyRecorderState(state, code, pressed);
return next;
@@ -132,3 +196,16 @@ assertEqual(
null,
'printable key preserved',
);
assertEqual(
primaryFromKeyboardEvent({ code: 'Space', key: ' ' }),
'Space',
'Space key normalizes to named Space (not literal space)',
);
assertEqual(
primaryFromKeyboardEvent({ code: 'Space', key: 'Space' }),
'Space',
'Space code with Space key name',
);
assertEqual(primaryFromKeyboardEvent({ code: 'KeyA', key: 'a' }), 'A', 'letter from code');
assertEqual(primaryFromKeyboardEvent({ code: 'Digit1', key: '!' }), '1', 'digit from code');
@@ -1,3 +1,6 @@
import type { ShortcutBinding } from './types';
import { windowMouseHotkeyCode } from './windowHotkeyFallback';
export interface HotkeyRecorderState {
pressedCodes: string[];
draftCodes: string[];
@@ -116,3 +119,72 @@ export function functionKeyPrimaryFromEvent(event: { code: string; key: string }
if (supported.test(event.key)) return event.key;
return null;
}
/**
* Normalize a keyboard event into the ShortcutBinding primary string.
* Space must use the named code — `e.key === ' '` is length 1 and would otherwise
* be trimmed to empty by backend validate_primary/parse_primary (#1109).
*/
export function primaryFromKeyboardEvent(event: { code: string; key: string }): string {
const functionKey = functionKeyPrimaryFromEvent(event);
if (functionKey) return functionKey;
const printable = primaryFromPrintableCode(event.code);
if (printable) return printable;
if (event.code === 'Space' || event.key === ' ') return 'Space';
if (event.key.length === 1) return event.key;
const codeToName: Record<string, string> = {
Space: 'Space',
Enter: 'Enter',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Delete',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
};
if (/^F\d{1,2}$/.test(event.key)) return event.key;
return codeToName[event.code] || event.key;
}
function primaryFromPrintableCode(code: string): string {
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
const codeToPrimary: Record<string, string> = {
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/',
IntlBackslash: '\\',
};
return codeToPrimary[code] || '';
}
/** Preserve backend failure details without classifying localized strings. */
export function formatShortcutSaveError(reason: unknown, fallback: string): string {
const message =
reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : '';
return message.trim() || fallback;
}
export function shortcutFromMouseEvent(
event: Pick<MouseEvent, 'button' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey'>,
): ShortcutBinding | null {
const primary = windowMouseHotkeyCode(event.button);
if (!primary) return null;
const modifiers: string[] = [];
if (event.ctrlKey) modifiers.push('ctrl');
if (event.altKey) modifiers.push('alt');
if (event.shiftKey) modifiers.push('shift');
if (event.metaKey) modifiers.push('super');
return { primary, modifiers };
}
@@ -258,11 +258,15 @@ export function RecordingInputSection() {
</div>
)}
{showDesktopHotkey && (
<SettingRow label={t('settings.recording.hotkeyLabel')}>
<SettingRow
label={t('settings.recording.hotkeyLabel')}
desc={os !== 'win' ? t('settings.recording.mouseSideHint') : undefined}
>
<ShortcutRecorder
value={prefs.dictationHotkey}
sideSpecificModifiers
allowMacDictationKey={os === 'mac'}
allowMouseButtons={os === 'win'}
// 录音快捷键是核心热键,Rust 端不接受 null,不可停用——置灰并提示。
disableDisabled
disableHint={t('settings.recording.comboDisableHint')}