diff --git a/docs/architecture.md b/docs/architecture.md index 04306b686..73a0ac86d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-09-30。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-10-03。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 @@ -75,6 +75,8 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 +macOS 全局热键在后台 CGEventTap 线程读取按键与修饰键边沿。只有配置的 Fn 正处于按住状态时,监听器才解析系统功能键负载;`NSEvent eventWithCGEvent:` 的转换同步交给主线程,完成分类后再处理 Fn 松键。此转换会进入 HIToolbox 的输入源逻辑,不能在热键线程执行;普通 Caps Lock 中英文切换不需要该转换。Esc 与组合键取消仍由原有独立通道处理。 + Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 5217bc7b6..82cbb206f 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4212,6 +4212,7 @@ dependencies = [ "core-graphics 0.24.0", "coreaudio-sys", "cpal", + "dispatch2", "enigo", "env_logger", "foundry-local-sdk", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 22729c64c..49df24e2c 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -130,6 +130,7 @@ core-foundation = "0.10" tokenizers = "0.21" whisper-rs = { version = "0.14", features = ["metal"] } core-graphics = "0.24" +dispatch2 = "0.3" # issue #470:托盘麦克风设备变更改用 CoreAudio AudioObjectAddPropertyListener 原生通知 # (替代 10s 轮询,空闲零唤醒)。符号本就在依赖树(cpal → coreaudio-sys 0.2),提升为 # 直接依赖零额外编译成本。 diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 2beb41961..1ff32703d 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -564,6 +564,11 @@ mod platform { #[repr(C)] struct OpaqueCgEvent(c_void); + // SAFETY: NSEvent's public signature uses an opaque __CGEvent pointer. + unsafe impl objc2::encode::RefEncode for OpaqueCgEvent { + const ENCODING_REF: objc2::encode::Encoding = + objc2::encode::Encoding::Pointer(&objc2::encode::Encoding::Struct("__CGEvent", &[])); + } type CgEventRef = *mut OpaqueCgEvent; #[repr(C)] @@ -776,9 +781,7 @@ mod platform { // systemDefined. While the Fn trigger is held, treat these as function-layer combos — // dictation must not start at the same time. SYSTEM_DEFINED => { - if let Some((subtype, data1)) = system_defined_event_payload(event) { - note_fn_function_layer_event(ctx, subtype, data1); - } + handle_system_defined_event(ctx, || system_defined_event_payload(event)); } _ => {} } @@ -980,22 +983,58 @@ mod platform { note_companion_key_down(ctx); } - fn system_defined_event_payload(event: CgEventRef) -> Option<(i16, isize)> { - use objc2::msg_send; - use objc2::runtime::{AnyClass, AnyObject}; + fn handle_system_defined_event( + ctx: &CallbackContext, + payload: impl FnOnce() -> Option<(i16, isize)>, + ) { + // System controls only affect dictation while a configured Fn press is held. + if !ctx.shared.trigger_held.load(Ordering::SeqCst) + || ctx.shared.binding.read().trigger != HotkeyTrigger::Fn + { + return; + } + if let Some((subtype, data1)) = payload() { + note_fn_function_layer_event(ctx, subtype, data1); + } + } + fn system_defined_event_payload(event: CgEventRef) -> Option<(i16, isize)> { if event.is_null() { return None; } - let cls = AnyClass::get("NSEvent")?; - let ns_event: *mut AnyObject = - unsafe { msg_send![cls, eventWithCGEvent: event.cast::()] }; - if ns_event.is_null() { - return None; + if unsafe { libc::pthread_main_np() } != 0 { + return system_defined_event_payload_on_main(event); } - let subtype: i16 = unsafe { msg_send![ns_event, subtype] }; - let data1: isize = unsafe { msg_send![ns_event, data1] }; - Some((subtype, data1)) + // NSEvent conversion can enter HIToolbox's main-thread-only input-source + // machinery. Complete classification before the listener handles Fn release. + let address = event as usize; + let mut payload = None; + dispatch2::DispatchQueue::main().exec_sync(|| { + payload = system_defined_event_payload_on_main(address as CgEventRef); + }); + payload + } + + fn system_defined_event_payload_on_main(event: CgEventRef) -> Option<(i16, isize)> { + use objc2::msg_send; + use objc2::rc::autoreleasepool; + use objc2::runtime::{AnyClass, AnyObject}; + + debug_assert_ne!(unsafe { libc::pthread_main_np() }, 0); + autoreleasepool(|_| { + let cls = AnyClass::get("NSEvent")?; + let ns_event: *mut AnyObject = unsafe { msg_send![cls, eventWithCGEvent: event] }; + if ns_event.is_null() { + return None; + } + let event_type: usize = unsafe { msg_send![ns_event, type] }; + if event_type != SYSTEM_DEFINED as usize { + return None; + } + let subtype: i16 = unsafe { msg_send![ns_event, subtype] }; + let data1: isize = unsafe { msg_send![ns_event, data1] }; + Some((subtype, data1)) + }) } fn is_auxiliary_function_key_event(subtype: i16, data1: isize) -> bool { @@ -1301,6 +1340,29 @@ mod platform { assert_eq!(drain_combo(&combo_rx), 0); } + #[test] + fn mac_idle_input_source_switch_does_not_decode_appkit_events() { + for trigger in [HotkeyTrigger::Fn, HotkeyTrigger::RightOption] { + let shared = shared(trigger); + let (ctx, rx) = callback_context(shared); + handle_system_defined_event(&ctx, || { + panic!("idle input-source switching must not convert CGEvent to NSEvent"); + }); + assert!(drain(&rx).is_empty()); + } + } + + #[test] + fn mac_system_controls_do_not_decode_appkit_during_non_fn_holds() { + let shared = shared(HotkeyTrigger::RightOption); + shared.trigger_held.store(true, Ordering::SeqCst); + let (ctx, rx) = callback_context(shared); + handle_system_defined_event(&ctx, || { + panic!("non-Fn dictation must not decode unrelated system controls"); + }); + assert!(drain(&rx).is_empty()); + } + #[test] fn mac_fn_function_layer_event_suppresses_tap_without_pending_abort() { let shared = shared(HotkeyTrigger::Fn); @@ -1315,11 +1377,9 @@ mod platform { pressed_at, ); // subtype/data1 encoding for brightness-up (NX_KEYTYPE_BRIGHTNESS_UP=2). - note_fn_function_layer_event( - &ctx, - NX_SUBTYPE_AUX_CONTROL_BUTTONS, - (2_i64 << 16) as isize, - ); + handle_system_defined_event(&ctx, || { + Some((NX_SUBTYPE_AUX_CONTROL_BUTTONS, (2_i64 << 16) as isize)) + }); handle_dictation_trigger_flags_changed( &ctx, trigger_to_keycode(HotkeyTrigger::Fn),