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
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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. 分层与工作区

Expand Down Expand Up @@ -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 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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),提升为
# 直接依赖零额外编译成本。
Expand Down
98 changes: 79 additions & 19 deletions openless-all/app/src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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));
}
_ => {}
}
Expand Down Expand Up @@ -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::<c_void>()] };
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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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),
Expand Down
Loading