Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs",
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs",
"check:hotkey-side-modifiers": "npx tsx src/lib/hotkeySideModifiers.test.ts",
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs",
"check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ mod asr {

#[path = "../../src/coordinator_state.rs"]
mod coordinator_state;
#[path = "../../src/global_hotkey_runtime.rs"]
mod global_hotkey_runtime;
#[path = "../../src/combo_hotkey.rs"]
mod combo_hotkey;
#[path = "../../src/side_aware_combo.rs"]
mod side_aware_combo;
#[path = "../../src/hotkey.rs"]
mod hotkey;
#[cfg(not(target_os = "macos"))]
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/src/combo_hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey};
use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError};
use crate::types::ShortcutBinding;

#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComboHotkeyEvent {
/// 用户按下了配置的组合键。
Pressed,
Expand Down
99 changes: 81 additions & 18 deletions openless-all/app/src-tauri/src/commands/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub fn set_translation_hotkey(
binding: ShortcutBinding,
) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(&binding)?;
let previous = coord.prefs().get();
reject_dictation_translation_hotkey_overlap(&previous.dictation_hotkey, &binding)?;
if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() {
Expand Down Expand Up @@ -76,6 +77,7 @@ pub fn set_switch_style_hotkey(
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
reject_modifier_only_action_shortcut(binding)?;
}
let mut prefs = coord.prefs().get();
Expand Down Expand Up @@ -106,6 +108,7 @@ pub fn set_open_app_hotkey(
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
reject_modifier_only_action_shortcut(binding)?;
}
let mut prefs = coord.prefs().get();
Expand Down Expand Up @@ -229,6 +232,7 @@ fn reject_hotkey_overlap(
}

pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> {
reject_non_dictation_side_specific_shortcuts(prefs)?;
// 停用(None)的 action 快捷键不参与任何冲突检测。
let switch_style = prefs.switch_style_hotkey.as_ref();
let open_app = prefs.open_app_hotkey.as_ref();
Expand Down Expand Up @@ -274,6 +278,25 @@ pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), St
Ok(())
}

pub(crate) fn reject_non_dictation_side_specific_shortcuts(
prefs: &UserPreferences,
) -> Result<(), String> {
crate::shortcut_binding::reject_side_specific_non_dictation(&prefs.translation_hotkey)?;
if let Some(binding) = prefs.qa_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
if let Some(binding) = prefs.switch_style_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
if let Some(binding) = prefs.open_app_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
if let Some(binding) = prefs.coding_agent_voice_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
Ok(())
}

pub(crate) fn reject_dictation_translation_hotkey_overlap(
dictation: &ShortcutBinding,
translation: &ShortcutBinding,
Expand Down Expand Up @@ -405,21 +428,7 @@ fn reject_less_computer_open_app_hotkey_overlap(
}

fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool {
let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left);
let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right);
match (left_legacy, right_legacy) {
(Some(left), Some(right)) => left == right,
(Some(_), None) | (None, Some(_)) => false,
(None, None) => {
let Ok(left) = crate::shortcut_binding::parse_global_hotkey(left) else {
return false;
};
let Ok(right) = crate::shortcut_binding::parse_global_hotkey(right) else {
return false;
};
left == right
}
}
crate::shortcut_binding::bindings_overlap(left, right)
}

#[cfg(test)]
Expand All @@ -433,9 +442,6 @@ mod tests {
}
}

/// 锁定碰撞矩阵:每个动作键与 Less Computer 键相同都必须被 reject_hotkey_collisions
/// 拒绝。5 个快捷 setter(set_dictation/translation/switch_style/open_app/qa_hotkey)
/// 此前漏校验 coding_agent_voice_hotkey,已接入对应的 less_computer 校验。
#[test]
fn each_action_hotkey_collides_with_less_computer() {
let lc = key("LeftControl");
Expand Down Expand Up @@ -474,4 +480,61 @@ mod tests {
// 复位后再次全不同 → 通过。
assert!(reject_hotkey_collisions(&prefs).is_ok());
}

#[test]
fn side_specific_dictation_overlaps_generic_qa_hotkey() {
let mut prefs = UserPreferences {
dictation_hotkey: ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
},
qa_hotkey: Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd".into()],
}),
..Default::default()
};
#[cfg(target_os = "windows")]
{
assert!(reject_hotkey_collisions(&prefs).is_ok());
prefs.qa_hotkey = Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["super".into()],
});
assert!(reject_hotkey_collisions(&prefs).is_err());
}
#[cfg(not(target_os = "windows"))]
{
assert!(reject_hotkey_collisions(&prefs).is_err());
}
prefs.qa_hotkey = Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd".into(), "shift".into()],
});
assert!(reject_hotkey_collisions(&prefs).is_ok());
}

#[test]
fn rejects_side_specific_qa_hotkey_on_save() {
let prefs = UserPreferences {
qa_hotkey: Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
}),
..Default::default()
};
assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_err());
}

#[test]
fn accepts_side_specific_dictation_hotkey_on_save() {
let prefs = UserPreferences {
dictation_hotkey: ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
},
..Default::default()
};
assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_ok());
}
}
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/commands/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub fn set_qa_hotkey(
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") {
return Err("Shift 单键目前只能用于翻译快捷键".into());
}
Expand Down
33 changes: 30 additions & 3 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ struct Inner {
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
side_aware_combo: Mutex<Option<crate::side_aware_combo::SideAwareComboMonitor>>,
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
Expand Down Expand Up @@ -403,6 +404,7 @@ impl Coordinator {
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
Expand Down Expand Up @@ -495,6 +497,7 @@ impl Coordinator {
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
Expand Down Expand Up @@ -810,18 +813,41 @@ impl Coordinator {
pub fn update_combo_hotkey_binding(&self) {
let prefs = self.inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。
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 = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
// Custom 但没录到有效主键:清掉旧 monitor,避免旧快捷键继续生效。
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::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, 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 || combo_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 app = self.inner.app.lock().clone();
let Some(app) = app else {
log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过");
Expand Down Expand Up @@ -2028,6 +2054,7 @@ fn resolve_ark_endpoint_with_policy(
#[cfg(test)]
mod tests {
use super::dictation::abort_recording_with_error;
use super::dictation::{handle_pressed_edge, handle_released_edge};
use super::*;
use crate::types::{HotkeyMode, HotkeyTrigger};
use once_cell::sync::Lazy;
Expand Down
41 changes: 37 additions & 4 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,21 +488,48 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
// 读当前 prefs
let prefs = inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 不是 Custom → 卸载后退出守护
take_combo_hotkey_on_main_thread(&inner);
// 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470
inner.side_aware_combo.lock().take();
return;
}

let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&inner);
// 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470
inner.side_aware_combo.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::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, 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 || combo_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 后重试");
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
}
}

inner.side_aware_combo.lock().take();

if inner.combo_hotkey.lock().is_some() {
// 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470
return;
}

Expand Down Expand Up @@ -550,7 +577,10 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
.ok();
#[cfg(target_os = "linux")]
sync_custom_dictation_to_plugin(&inner);
attempts = 0;
return;
}
Err(e) => {
attempts += 1;
Expand Down Expand Up @@ -1160,6 +1190,9 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k
}
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft",
HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight",
HotkeyTrigger::LeftCommand => key == "Meta" && code == "MetaLeft",
HotkeyTrigger::LeftShift => key == "Shift" && code == "ShiftLeft",
HotkeyTrigger::RightShift => key == "Shift" && code == "ShiftRight",
HotkeyTrigger::Fn => key == "Control" && code == "ControlRight",
// MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback
HotkeyTrigger::MediaPlayPause => false,
Expand Down
Loading
Loading