From 01b54ed918792a5faf0a9be0506dc48329ec4c4c Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 30 Apr 2026 07:31:09 +0800 Subject: [PATCH 1/4] Keep desktop hotkey behavior diagnosable across platforms Issue #36 exposed that the desktop hotkey path was still mixing platform hooks, UI copy, and coordinator state. This change keeps the existing macOS EventTap path, introduces an explicit Windows low-level keyboard adapter for modifier-only triggers, and makes the frontend render hotkey choices and status from capability/status data instead of OS string branching. Constraint: Must preserve the existing macOS CGEventTap implementation Constraint: Windows modifier-only triggers must not be silently downgraded to registered shortcuts Rejected: Keep rdev as the only non-macOS path | cannot reliably model right-side modifier-only behavior on Windows Rejected: Patch UI strings only | would leave root-cause visibility and adapter boundaries coupled Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep platform hook specifics inside the hotkey adapter layer and feed UI from capability/status APIs rather than OS hardcoding Tested: npm run build; cargo check Not-tested: Windows native hook runtime on real hardware; cross-target cargo check blocked by missing x86_64-w64-mingw32-gcc in this environment Related: #36 --- docs/platform-adapter-architecture.md | 53 +++ openless -all/app/src-tauri/src/commands.rs | 9 +- .../app/src-tauri/src/coordinator.rs | 27 +- openless -all/app/src-tauri/src/hotkey.rs | 396 +++++++++++++++--- openless -all/app/src-tauri/src/lib.rs | 17 +- openless -all/app/src-tauri/src/types.rs | 140 ++++++- .../app/src/components/FloatingShell.tsx | 9 +- .../app/src/components/Onboarding.tsx | 25 +- openless -all/app/src/lib/hotkey.ts | 25 ++ openless -all/app/src/lib/ipc.ts | 21 +- openless -all/app/src/lib/types.ts | 19 + openless -all/app/src/pages/History.tsx | 10 +- openless -all/app/src/pages/Overview.tsx | 14 +- openless -all/app/src/pages/Settings.tsx | 114 ++--- 14 files changed, 727 insertions(+), 152 deletions(-) create mode 100644 docs/platform-adapter-architecture.md create mode 100644 openless -all/app/src/lib/hotkey.ts diff --git a/docs/platform-adapter-architecture.md b/docs/platform-adapter-architecture.md new file mode 100644 index 00000000..a2af93d3 --- /dev/null +++ b/docs/platform-adapter-architecture.md @@ -0,0 +1,53 @@ +# Platform adapter architecture + +## Goal + +把 `Coordinator` 需要的热键边沿事件(`pressed` / `released` / `cancelled`)与各平台的 OS hook 细节隔离开,避免把 UI 文案、权限判断、按键映射和 session state machine 混在一起。 + +## Backend boundary + +Rust 层统一暴露三类对象: + +- `HotkeyAdapter` trait:平台监听器只负责安装、更新 binding、发送边沿事件。 +- `HotkeyCapability`:描述当前平台能提供什么(可选 trigger、是否需要辅助功能权限、是否支持 modifier-only trigger、是否有 fallback)。 +- `HotkeyStatus` / `HotkeyInstallError`:描述当前 hook 是否已安装、失败原因、当前实际 adapter。 + +`Coordinator` 不再关心 CGEventTap / Windows hook / `rdev` 的实现差异,只消费统一事件和状态。 + +## Platform adapters + +### macOS + +- Adapter: `MacHotkeyAdapter` +- Hook: `CGEventTap` +- 目的:保留现有已验证实现,不回退到 `rdev` +- 限制:依赖辅助功能权限;授权后通常需要完全退出再重开 + +### Windows + +- Adapter: `WindowsHotkeyAdapter` +- Hook: `SetWindowsHookExW(WH_KEYBOARD_LL)` +- 目的:支持右 Control / 右 Alt 这类 modifier-only trigger,并且保留左右侧语义 +- 备注:默认推荐 `右 Control + 按住说话` + +### Linux / other + +- Adapter: `RdevHotkeyAdapter` +- Hook: `rdev::listen` +- 目的:best-effort 兜底,不承诺与 macOS / Windows 同等行为 + +## UI contract + +前端通过 IPC 读取: + +- `get_hotkey_capability` +- `get_hotkey_status` +- `get_settings` + +设置页、权限页和快捷键提示必须基于 capability / status / actual binding 渲染,而不是再写 `if (os === 'win') ... else ...` 的平台硬编码文案。 + +## Explicit non-goals + +- 不静默把 modifier-only trigger 替换成普通 registered shortcut +- 不把平台差异泄漏到 `Coordinator` +- 不在这层引入新的全局快捷键依赖 diff --git a/openless -all/app/src-tauri/src/commands.rs b/openless -all/app/src-tauri/src/commands.rs index 0d4c4a0c..72af59c8 100644 --- a/openless -all/app/src-tauri/src/commands.rs +++ b/openless -all/app/src-tauri/src/commands.rs @@ -8,8 +8,8 @@ use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; use crate::types::{ - CredentialsStatus, DictationSession, DictionaryEntry, HotkeyStatus, PolishMode, - UserPreferences, + CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, + PolishMode, UserPreferences, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -33,6 +33,11 @@ pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { coord.hotkey_status() } +#[tauri::command] +pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { + coord.hotkey_capability() +} + #[tauri::command] pub fn get_credentials() -> CredentialsStatus { let snap = CredentialsVault::snapshot(); diff --git a/openless -all/app/src-tauri/src/coordinator.rs b/openless -all/app/src-tauri/src/coordinator.rs index 6fec51fc..2b0c9039 100644 --- a/openless -all/app/src-tauri/src/coordinator.rs +++ b/openless -all/app/src-tauri/src/coordinator.rs @@ -23,7 +23,7 @@ use crate::persistence::{ use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::recorder::Recorder; use crate::types::{ - CapsulePayload, CapsuleState, DictationSession, HotkeyMode, HotkeyStatus, + CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode, }; @@ -125,6 +125,10 @@ impl Coordinator { self.inner.hotkey_status.lock().clone() } + pub fn hotkey_capability(&self) -> HotkeyCapability { + HotkeyMonitor::capability() + } + pub async fn start_dictation(&self) -> Result<(), String> { begin_session(&self.inner).await } @@ -149,25 +153,28 @@ impl Coordinator { fn hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; + let capability = HotkeyMonitor::capability(); loop { if inner.hotkey.lock().is_some() { return; } *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, state: HotkeyStatusState::Starting, - message: Some(format!( - "正在安装全局快捷键监听(第 {} 次)", - attempts + 1 - )), + message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)), + last_error: None, }; let (tx, rx) = mpsc::channel::(); let binding = inner.prefs.get().hotkey; match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { + let adapter = monitor.kind(); *inner.hotkey.lock() = Some(monitor); *inner.hotkey_status.lock() = HotkeyStatus { + adapter, state: HotkeyStatusState::Installed, - message: None, + message: Some(format!("{} 已安装", adapter.display_name())), + last_error: None, }; log::info!( "[coord] hotkey listener installed (after {} attempt(s))", @@ -182,13 +189,17 @@ fn hotkey_supervisor_loop(inner: Arc) { } Err(e) => { attempts += 1; + let error_message = e.message.clone(); *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, state: HotkeyStatusState::Failed, - message: Some(e.to_string()), + message: Some(error_message.clone()), + last_error: Some(e), }; if attempts <= 3 || attempts % 10 == 0 { log::warn!( - "[coord] hotkey listener attempt #{attempts} failed: {e}; retrying in 3s" + "[coord] hotkey listener attempt #{attempts} failed: {}; retrying in 3s", + error_message ); } std::thread::sleep(std::time::Duration::from_secs(3)); diff --git a/openless -all/app/src-tauri/src/hotkey.rs b/openless -all/app/src-tauri/src/hotkey.rs index c9303001..b2880dc2 100644 --- a/openless -all/app/src-tauri/src/hotkey.rs +++ b/openless -all/app/src-tauri/src/hotkey.rs @@ -4,18 +4,18 @@ //! `OpenLessHotkey/HotkeyMonitor.swift` 同源。**不能用 `rdev`**:rdev 在每个 //! 事件回调里同步调 `TSMGetInputSourceProperty`,macOS 14+ 强制断言主线程, //! 非主线程触发 `dispatch_assert_queue_fail` → SIGTRAP abort(已踩坑)。 -//! - 其他平台:继续用 `rdev::listen`(Linux/Windows 的 listen 路径不依赖 TSM)。 +//! - Windows:原生 `WH_KEYBOARD_LL` low-level keyboard hook,保留 modifier-only +//! trigger(如右 Control / 右 Alt)的真实语义,不再把平台能力藏在 `rdev` 抽象里。 +//! - Linux / 其他:继续 best-effort 走 `rdev::listen`。 //! //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::AtomicBool; use std::sync::mpsc::Sender; -use std::sync::Arc; -use std::thread; use parking_lot::RwLock; -use crate::types::HotkeyBinding; +use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError}; #[derive(Clone, Copy, Debug)] pub enum HotkeyEvent { @@ -24,6 +24,11 @@ pub enum HotkeyEvent { Cancelled, } +pub trait HotkeyAdapter: Send + Sync { + fn kind(&self) -> HotkeyAdapterKind; + fn update_binding(&self, binding: HotkeyBinding); +} + struct Shared { binding: RwLock, /// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。 @@ -31,38 +36,45 @@ struct Shared { } pub struct HotkeyMonitor { - shared: Arc, + adapter: Box, } impl HotkeyMonitor { /// Spawn the listener thread and **wait synchronously** for it to confirm - /// the OS-level hook installed (CGEventTap on macOS / rdev::listen otherwise). - /// Returns Err if installation failed (typically Accessibility not granted on macOS), - /// so the caller can schedule a retry instead of silently dropping events. - pub fn start(binding: HotkeyBinding, tx: Sender) -> anyhow::Result { - let shared = Arc::new(Shared { - binding: RwLock::new(binding), - trigger_held: AtomicBool::new(false), - }); + /// the OS-level hook installed so the caller can surface an actual adapter + /// status instead of silently dropping events. + pub fn start( + binding: HotkeyBinding, + tx: Sender, + ) -> Result { + Ok(Self { + adapter: platform::start_adapter(binding, tx)?, + }) + } - let thread_shared = Arc::clone(&shared); - let (status_tx, status_rx) = std::sync::mpsc::channel::(); - thread::Builder::new() - .name("openless-hotkey".into()) - .spawn(move || platform::run_listen_loop(thread_shared, tx, status_tx))?; + pub fn update_binding(&self, binding: HotkeyBinding) { + self.adapter.update_binding(binding); + } - match status_rx.recv_timeout(std::time::Duration::from_secs(3)) { - Ok(true) => Ok(Self { shared }), - Ok(false) => Err(anyhow::anyhow!( - "hotkey hook 安装失败(macOS 多半是辅助功能权限未授予)" - )), - Err(_) => Err(anyhow::anyhow!("hotkey hook 启动超时")), - } + pub fn kind(&self) -> HotkeyAdapterKind { + self.adapter.kind() } - pub fn update_binding(&self, binding: HotkeyBinding) { - *self.shared.binding.write() = binding; - self.shared.trigger_held.store(false, Ordering::SeqCst); + pub fn capability() -> HotkeyCapability { + HotkeyCapability::current() + } +} + +fn install_error(code: &str, message: impl Into) -> HotkeyInstallError { + HotkeyInstallError { + code: code.into(), + message: message.into(), + } +} + +fn send_or_log(tx: &Sender, evt: HotkeyEvent) { + if let Err(e) = tx.send(evt) { + log::warn!("[hotkey] 事件发送失败: {e}"); } } @@ -75,8 +87,46 @@ mod platform { use std::sync::mpsc::Sender; use std::sync::Arc; - use super::{HotkeyEvent, Shared}; - use crate::types::HotkeyTrigger; + use super::{install_error, send_or_log, HotkeyAdapter, HotkeyEvent, Shared}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + + pub fn start_adapter( + binding: HotkeyBinding, + tx: Sender, + ) -> Result, HotkeyInstallError> { + let shared = Arc::new(Shared { + binding: parking_lot::RwLock::new(binding), + trigger_held: std::sync::atomic::AtomicBool::new(false), + }); + + let thread_shared = Arc::clone(&shared); + let (status_tx, status_rx) = std::sync::mpsc::channel::>(); + std::thread::Builder::new() + .name("openless-hotkey-mac-event-tap".into()) + .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) + .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; + + match status_rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(Ok(())) => Ok(Box::new(MacHotkeyAdapter { shared })), + Ok(Err(err)) => Err(err), + Err(_) => Err(install_error("startup_timeout", "hotkey hook 启动超时")), + } + } + + struct MacHotkeyAdapter { + shared: Arc, + } + + impl HotkeyAdapter for MacHotkeyAdapter { + fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::MacEventTap + } + + fn update_binding(&self, binding: HotkeyBinding) { + *self.shared.binding.write() = binding; + self.shared.trigger_held.store(false, Ordering::SeqCst); + } + } // ── Raw CG/CF FFI ────────────────────────────────────────────────────── @@ -160,24 +210,19 @@ mod platform { static kCFRunLoopCommonModes: CfStringRef; } - // ── Callback context ─────────────────────────────────────────────────── - struct CallbackContext { shared: Arc, tx: Sender, tap: std::sync::Mutex>, } - // CallbackContext crosses an FFI boundary as a raw pointer; the only field - // not auto-Send/Sync is the CfMachPortRef raw pointer, which is fine to - // share since CGEventTapEnable is thread-safe for our usage. unsafe impl Send for CallbackContext {} unsafe impl Sync for CallbackContext {} - pub fn run_listen_loop( + fn run_listen_loop( shared: Arc, tx: Sender, - status_tx: std::sync::mpsc::Sender, + status_tx: std::sync::mpsc::Sender>, ) { let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN); let context = Box::into_raw(Box::new(CallbackContext { @@ -200,7 +245,10 @@ mod platform { "[hotkey] CGEventTapCreate 失败 — Accessibility 权限未授予。Coordinator 会重试。" ); let _ = Box::from_raw(context); - let _ = status_tx.send(false); + let _ = status_tx.send(Err(install_error( + "accessibility_denied", + "hotkey hook 安装失败(辅助功能权限未授予)", + ))); return; } *(*context).tap.lock().unwrap() = Some(tap); @@ -211,7 +259,7 @@ mod platform { CGEventTapEnable(tap, true); log::info!("[hotkey] CGEventTap 已启动"); - let _ = status_tx.send(true); + let _ = status_tx.send(Ok(())); CFRunLoopRun(); } } @@ -269,12 +317,6 @@ mod platform { } } - fn send_or_log(tx: &Sender, evt: HotkeyEvent) { - if let Err(e) = tx.send(evt) { - log::warn!("[hotkey] 事件发送失败: {e}"); - } - } - fn trigger_to_keycode(trigger: HotkeyTrigger) -> i64 { match trigger { HotkeyTrigger::LeftControl => 59, @@ -298,9 +340,212 @@ mod platform { } } -// ─────────────────────────── non-macOS implementation ─────────────────────────── +// ─────────────────────────── Windows implementation ─────────────────────────── -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +mod platform { + use std::sync::atomic::Ordering; + use std::sync::atomic::{AtomicPtr, Ordering as AtomicOrdering}; + use std::sync::mpsc::Sender; + use std::sync::Arc; + + use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; + use windows::Win32::UI::Input::KeyboardAndMouse::KBDLLHOOKSTRUCT; + use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, DispatchMessageW, GetMessageW, SetWindowsHookExW, TranslateMessage, + UnhookWindowsHookEx, HC_ACTION, HHOOK, MSG, WH_KEYBOARD_LL, + }; + + use super::{install_error, send_or_log, HotkeyAdapter, HotkeyEvent, Shared}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + + const WM_KEYDOWN: usize = 0x0100; + const WM_KEYUP: usize = 0x0101; + const WM_SYSKEYDOWN: usize = 0x0104; + const WM_SYSKEYUP: usize = 0x0105; + + const VK_ESCAPE: u32 = 0x1B; + const VK_LCONTROL: u32 = 0xA2; + const VK_RCONTROL: u32 = 0xA3; + const VK_RMENU: u32 = 0xA5; + const VK_RWIN: u32 = 0x5C; + const LLKHF_INJECTED: u32 = 0x0000_0010; + + static HOOK_CONTEXT: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + + pub fn start_adapter( + binding: HotkeyBinding, + tx: Sender, + ) -> Result, HotkeyInstallError> { + let shared = Arc::new(Shared { + binding: parking_lot::RwLock::new(binding), + trigger_held: std::sync::atomic::AtomicBool::new(false), + }); + + let thread_shared = Arc::clone(&shared); + let (status_tx, status_rx) = std::sync::mpsc::channel::>(); + std::thread::Builder::new() + .name("openless-hotkey-win-ll-hook".into()) + .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) + .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; + + match status_rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(Ok(())) => Ok(Box::new(WindowsHotkeyAdapter { shared })), + Ok(Err(err)) => Err(err), + Err(_) => Err(install_error( + "startup_timeout", + "Windows hotkey hook 启动超时", + )), + } + } + + struct WindowsHotkeyAdapter { + shared: Arc, + } + + impl HotkeyAdapter for WindowsHotkeyAdapter { + fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::WindowsLowLevel + } + + fn update_binding(&self, binding: HotkeyBinding) { + *self.shared.binding.write() = binding; + self.shared.trigger_held.store(false, Ordering::SeqCst); + } + } + + struct CallbackContext { + shared: Arc, + tx: Sender, + hook: std::sync::Mutex>, + } + + unsafe impl Send for CallbackContext {} + unsafe impl Sync for CallbackContext {} + + fn run_listen_loop( + shared: Arc, + tx: Sender, + status_tx: std::sync::mpsc::Sender>, + ) { + let context = Box::into_raw(Box::new(CallbackContext { + shared, + tx, + hook: std::sync::Mutex::new(None), + })); + HOOK_CONTEXT.store(context, AtomicOrdering::SeqCst); + + unsafe { + let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(low_level_keyboard_proc), None, 0); + match hook { + Ok(hook) => { + *(*context).hook.lock().unwrap() = Some(hook); + log::info!("[hotkey] Windows low-level keyboard hook 已启动"); + let _ = status_tx.send(Ok(())); + } + Err(err) => { + HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst); + let _ = Box::from_raw(context); + let _ = status_tx.send(Err(install_error( + "hook_install_failed", + format!("Windows low-level keyboard hook 安装失败: {err}"), + ))); + return; + } + } + + let mut message = MSG::default(); + loop { + let result = GetMessageW(&mut message, None, 0, 0).0; + if result == -1 { + log::error!("[hotkey] Windows GetMessageW 返回错误,hook 线程退出"); + break; + } + if result == 0 { + log::warn!("[hotkey] Windows hook 消息循环收到退出消息"); + break; + } + let _ = TranslateMessage(&message); + let _ = DispatchMessageW(&message); + } + + if let Some(hook) = (*context).hook.lock().unwrap().take() { + let _ = UnhookWindowsHookEx(hook); + } + HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst); + let _ = Box::from_raw(context); + } + } + + unsafe extern "system" fn low_level_keyboard_proc( + code: i32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if code == HC_ACTION as i32 && lparam.0 != 0 { + if let Some(ctx) = callback_context() { + let keyboard = *(lparam.0 as *const KBDLLHOOKSTRUCT); + if keyboard.flags & LLKHF_INJECTED == 0 { + dispatch_keyboard_event(ctx, keyboard.vkCode, wparam.0); + } + } + } + + CallNextHookEx(None, code, wparam, lparam) + } + + unsafe fn callback_context<'a>() -> Option<&'a CallbackContext> { + let ptr = HOOK_CONTEXT.load(AtomicOrdering::SeqCst); + if ptr.is_null() { + None + } else { + Some(&*ptr) + } + } + + fn dispatch_keyboard_event(ctx: &CallbackContext, vk_code: u32, message: usize) { + if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) { + send_or_log(&ctx.tx, HotkeyEvent::Cancelled); + return; + } + + let trigger = ctx.shared.binding.read().trigger; + if vk_code != trigger_to_vk_code(trigger) { + return; + } + + match message { + WM_KEYDOWN | WM_SYSKEYDOWN => { + let was_held = ctx.shared.trigger_held.swap(true, Ordering::SeqCst); + if !was_held { + send_or_log(&ctx.tx, HotkeyEvent::Pressed); + } + } + WM_KEYUP | WM_SYSKEYUP => { + let was_held = ctx.shared.trigger_held.swap(false, Ordering::SeqCst); + if was_held { + send_or_log(&ctx.tx, HotkeyEvent::Released); + } + } + _ => {} + } + } + + fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 { + match trigger { + HotkeyTrigger::RightControl => VK_RCONTROL, + HotkeyTrigger::LeftControl => VK_LCONTROL, + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU, + HotkeyTrigger::RightCommand => VK_RWIN, + HotkeyTrigger::LeftOption => VK_RMENU, + HotkeyTrigger::Fn => VK_RCONTROL, + } + } +} + +// ─────────────────────────── Linux / other implementation ─────────────────────────── + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] mod platform { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; @@ -309,23 +554,59 @@ mod platform { use rdev::{listen, Event, EventType, Key}; - use super::{HotkeyEvent, Shared}; - use crate::types::HotkeyTrigger; + use super::{install_error, HotkeyAdapter, HotkeyEvent, Shared}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + + pub fn start_adapter( + binding: HotkeyBinding, + tx: Sender, + ) -> Result, HotkeyInstallError> { + let shared = Arc::new(Shared { + binding: parking_lot::RwLock::new(binding), + trigger_held: std::sync::atomic::AtomicBool::new(false), + }); + + let thread_shared = Arc::clone(&shared); + let (status_tx, status_rx) = std::sync::mpsc::channel::>(); + std::thread::Builder::new() + .name("openless-hotkey-rdev".into()) + .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) + .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; + + match status_rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(Ok(())) => Ok(Box::new(RdevHotkeyAdapter { shared })), + Ok(Err(err)) => Err(err), + Err(_) => Err(install_error("startup_timeout", "hotkey hook 启动超时")), + } + } + + struct RdevHotkeyAdapter { + shared: Arc, + } + + impl HotkeyAdapter for RdevHotkeyAdapter { + fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::Rdev + } + + fn update_binding(&self, binding: HotkeyBinding) { + *self.shared.binding.write() = binding; + self.shared.trigger_held.store(false, Ordering::SeqCst); + } + } - pub fn run_listen_loop( + fn run_listen_loop( shared: Arc, tx: Sender, - status_tx: std::sync::mpsc::Sender, + status_tx: std::sync::mpsc::Sender>, ) { - // rdev 没有"安装即可知"的 API。给 listen 一个短窗口: - // 如果 hook 立即失败,向 supervisor 汇报失败;否则视为已进入监听循环。 let status_sent = Arc::new(AtomicBool::new(false)); let ready_status_sent = Arc::clone(&status_sent); let ready_status_tx = status_tx.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(350)); if !ready_status_sent.swap(true, Ordering::SeqCst) { - let _ = ready_status_tx.send(true); + let _ = ready_status_tx.send(Ok(())); } }); let cb_shared = Arc::clone(&shared); @@ -334,7 +615,10 @@ mod platform { }); if let Err(err) = result { if !status_sent.swap(true, Ordering::SeqCst) { - let _ = status_tx.send(false); + let _ = status_tx.send(Err(install_error( + "listen_failed", + format!("rdev::listen 启动失败: {err:?}"), + ))); } log::error!("[hotkey] rdev::listen 启动失败: {:?}", err); } diff --git a/openless -all/app/src-tauri/src/lib.rs b/openless -all/app/src-tauri/src/lib.rs index 889c998b..5bef1da9 100644 --- a/openless -all/app/src-tauri/src/lib.rs +++ b/openless -all/app/src-tauri/src/lib.rs @@ -126,6 +126,7 @@ pub fn run() { commands::get_settings, commands::set_settings, commands::get_hotkey_status, + commands::get_hotkey_capability, commands::get_credentials, commands::set_credential, commands::list_history, @@ -297,13 +298,21 @@ pub(crate) fn restore_main_window_key_if_active(app: &AppHandle) use objc2::msg_send; use objc2::runtime::{AnyClass, AnyObject, Bool}; unsafe { - let Some(cls) = AnyClass::get("NSApplication") else { return }; + let Some(cls) = AnyClass::get("NSApplication") else { + return; + }; let ns_app: *mut AnyObject = msg_send![cls, sharedApplication]; - if ns_app.is_null() { return; } + if ns_app.is_null() { + return; + } let is_active: Bool = msg_send![ns_app, isActive]; - if !is_active.as_bool() { return; } + if !is_active.as_bool() { + return; + } let main_win: *mut AnyObject = msg_send![ns_app, mainWindow]; - if main_win.is_null() { return; } + if main_win.is_null() { + return; + } let _: () = msg_send![main_win, makeKeyWindow]; } }); diff --git a/openless -all/app/src-tauri/src/types.rs b/openless -all/app/src-tauri/src/types.rs index a61bed0f..912453b7 100644 --- a/openless -all/app/src-tauri/src/types.rs +++ b/openless -all/app/src-tauri/src/types.rs @@ -118,6 +118,20 @@ pub enum HotkeyTrigger { RightAlt, // Windows synonym for RightOption } +impl HotkeyTrigger { + pub fn display_name(&self) -> &'static str { + match self { + HotkeyTrigger::RightOption => "右 Option", + HotkeyTrigger::LeftOption => "左 Option", + HotkeyTrigger::RightControl => "右 Control", + HotkeyTrigger::LeftControl => "左 Control", + HotkeyTrigger::RightCommand => "右 Command", + HotkeyTrigger::Fn => "Fn (地球键)", + HotkeyTrigger::RightAlt => "右 Alt", + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum HotkeyMode { @@ -125,6 +139,24 @@ pub enum HotkeyMode { Hold, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum HotkeyAdapterKind { + MacEventTap, + WindowsLowLevel, + Rdev, +} + +impl HotkeyAdapterKind { + pub fn display_name(&self) -> &'static str { + match self { + HotkeyAdapterKind::MacEventTap => "macOS Event Tap", + HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook", + HotkeyAdapterKind::Rdev => "rdev 监听器", + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct HotkeyBinding { @@ -132,11 +164,102 @@ pub struct HotkeyBinding { pub mode: HotkeyMode, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HotkeyCapability { + pub adapter: HotkeyAdapterKind, + pub available_triggers: Vec, + pub requires_accessibility_permission: bool, + pub supports_modifier_only_trigger: bool, + pub supports_side_specific_modifiers: bool, + pub explicit_fallback_available: bool, + pub status_hint: Option, +} + +impl HotkeyCapability { + pub fn current() -> Self { + #[cfg(target_os = "macos")] + { + return Self { + adapter: HotkeyAdapterKind::MacEventTap, + available_triggers: vec![ + HotkeyTrigger::RightOption, + HotkeyTrigger::LeftOption, + HotkeyTrigger::RightControl, + HotkeyTrigger::LeftControl, + HotkeyTrigger::RightCommand, + HotkeyTrigger::Fn, + ], + requires_accessibility_permission: true, + supports_modifier_only_trigger: true, + supports_side_specific_modifiers: true, + explicit_fallback_available: false, + status_hint: Some("授权辅助功能后,通常需要完全退出并重新打开 OpenLess。".into()), + }; + } + + #[cfg(target_os = "windows")] + { + return Self { + adapter: HotkeyAdapterKind::WindowsLowLevel, + available_triggers: vec![ + HotkeyTrigger::RightControl, + HotkeyTrigger::RightAlt, + HotkeyTrigger::LeftControl, + HotkeyTrigger::RightCommand, + ], + requires_accessibility_permission: false, + supports_modifier_only_trigger: true, + supports_side_specific_modifiers: true, + explicit_fallback_available: false, + status_hint: Some( + "默认建议使用“右 Control + 按住说话”;若无响应,可在权限页查看 hook 安装状态。" + .into(), + ), + }; + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + Self { + adapter: HotkeyAdapterKind::Rdev, + available_triggers: vec![ + HotkeyTrigger::RightAlt, + HotkeyTrigger::RightControl, + HotkeyTrigger::LeftControl, + ], + requires_accessibility_permission: false, + supports_modifier_only_trigger: true, + supports_side_specific_modifiers: true, + explicit_fallback_available: false, + status_hint: Some( + "Linux 仅 best-effort:不同桌面环境 / Wayland 组合可能限制全局热键。".into(), + ), + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HotkeyInstallError { + pub code: String, + pub message: String, +} + +impl std::fmt::Display for HotkeyInstallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.message, self.code) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct HotkeyStatus { + pub adapter: HotkeyAdapterKind, pub state: HotkeyStatusState, pub message: Option, + pub last_error: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -150,21 +273,26 @@ pub enum HotkeyStatusState { impl Default for HotkeyStatus { fn default() -> Self { Self { + adapter: HotkeyCapability::current().adapter, state: HotkeyStatusState::Starting, message: Some("正在安装全局快捷键监听".into()), + last_error: None, } } } impl Default for HotkeyBinding { fn default() -> Self { - // Right Option (mac) / Right Alt (win) — toggle by default per design. + #[cfg(target_os = "windows")] + { + return Self { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Hold, + }; + } + Self { - trigger: if cfg!(target_os = "windows") { - HotkeyTrigger::RightAlt - } else { - HotkeyTrigger::RightOption - }, + trigger: HotkeyTrigger::RightOption, mode: HotkeyMode::Toggle, } } diff --git a/openless -all/app/src/components/FloatingShell.tsx b/openless -all/app/src/components/FloatingShell.tsx index 093a0f05..2826f879 100644 --- a/openless -all/app/src/components/FloatingShell.tsx +++ b/openless -all/app/src/components/FloatingShell.tsx @@ -13,7 +13,8 @@ import { History } from '../pages/History'; import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { APP_VERSION_LABEL } from '../lib/appVersion'; -import { getCredentials, openExternal } from '../lib/ipc'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getCredentials, getSettings, openExternal } from '../lib/ipc'; import { OL_DATA } from '../lib/mockData'; import { PROVIDER_SETUP_PROMPT_SEEN_KEY, @@ -55,13 +56,17 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings); const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); + const [hotkeyLabel, setHotkeyLabel] = useState(() => getHotkeyTriggerLabel(null)); const Page = (NAV.find((n) => n.id === currentTab) ?? NAV[0]).cmp; - const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option'; useEffect(() => { let cancelled = false; (async () => { const credentials = await getCredentials(); + const prefs = await getSettings(); + if (!cancelled) { + setHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey.trigger)); + } const promptSeenValue = window.localStorage.getItem(PROVIDER_SETUP_PROMPT_SEEN_KEY); if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptSeenValue)) { setProviderPromptOpen(true); diff --git a/openless -all/app/src/components/Onboarding.tsx b/openless -all/app/src/components/Onboarding.tsx index 123984cd..4e547944 100644 --- a/openless -all/app/src/components/Onboarding.tsx +++ b/openless -all/app/src/components/Onboarding.tsx @@ -7,12 +7,13 @@ import { useEffect, useState } from 'react'; import { checkAccessibilityPermission, checkMicrophonePermission, + getHotkeyCapability, openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, } from '../lib/ipc'; -import type { PermissionStatus } from '../lib/types'; -import { detectOS } from './WindowChrome'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import type { HotkeyCapability, PermissionStatus } from '../lib/types'; interface OnboardingProps { onComplete: () => void; @@ -21,16 +22,18 @@ interface OnboardingProps { export function Onboarding({ onComplete }: OnboardingProps) { const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); + const [capability, setCapability] = useState(null); const [busy, setBusy] = useState(false); - const os = detectOS(); const refresh = async () => { - const [a, m] = await Promise.all([ + const [a, m, c] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), + getHotkeyCapability(), ]); setAccessibility(a); setMicrophone(m); + setCapability(c); if ((a === 'granted' || a === 'notApplicable') && (m === 'granted' || m === 'notApplicable')) { onComplete(); } @@ -124,13 +127,13 @@ export function Onboarding({ onComplete }: OnboardingProps) { = { + rightOption: '右 Option', + leftOption: '左 Option', + rightControl: '右 Control', + leftControl: '左 Control', + rightCommand: '右 Command', + fn: 'Fn (地球键)', + rightAlt: '右 Alt', +}; + +export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { + return trigger ? HOTKEY_TRIGGER_LABEL[trigger] : '全局快捷键'; +} + +export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefined): string { + const trigger = getHotkeyTriggerLabel(binding?.trigger); + return binding?.mode === 'hold' ? `${trigger}(按住说话)` : `${trigger}(开始 / 停止)`; +} + +export function getHotkeyUsageHint(binding: HotkeyBinding | null | undefined): string { + const trigger = getHotkeyTriggerLabel(binding?.trigger); + return binding?.mode === 'hold' ? `按住 ${trigger} 说话,松开结束。` : `按 ${trigger} 开始录音,再按一次结束。`; +} diff --git a/openless -all/app/src/lib/ipc.ts b/openless -all/app/src/lib/ipc.ts index a3e852b3..2d496912 100644 --- a/openless -all/app/src/lib/ipc.ts +++ b/openless -all/app/src/lib/ipc.ts @@ -6,6 +6,7 @@ import type { CredentialsStatus, DictationSession, DictionaryEntry, + HotkeyCapability, HotkeyStatus, PermissionStatus, PolishMode, @@ -35,7 +36,7 @@ export async function invokeOrMock( // ── Mock fixtures ────────────────────────────────────────────────────── const mockSettings: UserPreferences = { - hotkey: { trigger: 'rightOption', mode: 'toggle' }, + hotkey: { trigger: 'rightControl', mode: 'hold' }, defaultMode: 'structured', enabledModes: ['raw', 'light', 'structured', 'formal'], launchAtLogin: false, @@ -44,14 +45,26 @@ const mockSettings: UserPreferences = { activeLlmProvider: 'ark', }; +const mockHotkeyCapability: HotkeyCapability = { + adapter: 'windowsLowLevel', + availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand'], + requiresAccessibilityPermission: false, + supportsModifierOnlyTrigger: true, + supportsSideSpecificModifiers: true, + explicitFallbackAvailable: false, + statusHint: '默认建议使用“右 Control + 按住说话”;若无响应,可在权限页查看 hook 安装状态。', +}; + const mockCredentialsStatus: CredentialsStatus = { volcengineConfigured: true, arkConfigured: true, }; const mockHotkeyStatus: HotkeyStatus = { + adapter: 'windowsLowLevel', state: 'installed', - message: null, + message: 'Windows 低层键盘 hook 已安装', + lastError: null, }; const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ @@ -90,6 +103,10 @@ export function getHotkeyStatus(): Promise { return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus); } +export function getHotkeyCapability(): Promise { + return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability); +} + // ── Credentials ──────────────────────────────────────────────────────── export function getCredentials(): Promise { return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus); diff --git a/openless -all/app/src/lib/types.ts b/openless -all/app/src/lib/types.ts index 7bf7c8f7..501c6c50 100644 --- a/openless -all/app/src/lib/types.ts +++ b/openless -all/app/src/lib/types.ts @@ -45,11 +45,30 @@ export interface HotkeyBinding { mode: HotkeyMode; } +export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'rdev'; + +export interface HotkeyCapability { + adapter: HotkeyAdapterKind; + availableTriggers: HotkeyTrigger[]; + requiresAccessibilityPermission: boolean; + supportsModifierOnlyTrigger: boolean; + supportsSideSpecificModifiers: boolean; + explicitFallbackAvailable: boolean; + statusHint: string | null; +} + +export interface HotkeyInstallError { + code: string; + message: string; +} + export type HotkeyStatusState = 'starting' | 'installed' | 'failed'; export interface HotkeyStatus { + adapter: HotkeyAdapterKind; state: HotkeyStatusState; message: string | null; + lastError: HotkeyInstallError | null; } export interface UserPreferences { diff --git a/openless -all/app/src/pages/History.tsx b/openless -all/app/src/pages/History.tsx index 11b9c544..fe05c5a0 100644 --- a/openless -all/app/src/pages/History.tsx +++ b/openless -all/app/src/pages/History.tsx @@ -4,8 +4,9 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; -import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; -import type { DictationSession, PolishMode } from '../lib/types'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { clearHistory, deleteHistoryEntry, getSettings, listHistory } from '../lib/ipc'; +import type { DictationSession, HotkeyBinding, PolishMode } from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; const FILTERS: Array<{ id: 'all' | PolishMode; label: string }> = [ @@ -28,7 +29,7 @@ export function History() { const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); - const hotkeyLabel = detectOS() === 'win' ? '右 Alt' : '右 Option'; + const [hotkey, setHotkey] = useState(null); const refresh = async () => { const data = await listHistory(); @@ -41,6 +42,7 @@ export function History() { useEffect(() => { refresh(); + getSettings().then(p => setHotkey(p.hotkey)); }, []); const filtered = useMemo( @@ -116,7 +118,7 @@ export function History() { {loading &&
加载中…
} {!loading && filtered.length === 0 && (
- 还没有历史记录。按 {hotkeyLabel} 录一段试试。 + 还没有历史记录。按 {getHotkeyTriggerLabel(hotkey?.trigger)} 录一段试试。
)} {filtered.map(s => ( diff --git a/openless -all/app/src/pages/Overview.tsx b/openless -all/app/src/pages/Overview.tsx index 5455f7d3..f393da7b 100644 --- a/openless -all/app/src/pages/Overview.tsx +++ b/openless -all/app/src/pages/Overview.tsx @@ -2,9 +2,9 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; -import { detectOS } from '../components/WindowChrome'; -import { getCredentials, listHistory } from '../lib/ipc'; -import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getCredentials, getSettings, listHistory } from '../lib/ipc'; +import type { CredentialsStatus, DictationSession, HotkeyBinding, PolishMode } from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; const MODE_LABEL: Record = { @@ -24,12 +24,12 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); - const os = detectOS(); - const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option'; + const [hotkey, setHotkey] = useState(null); useEffect(() => { listHistory().then(setHistory); getCredentials().then(setCreds); + getSettings().then(p => setHotkey(p.hotkey)); }, []); const metrics = useMemo(() => { @@ -83,7 +83,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { background: '#fff', borderRadius: 5, border: '0.5px solid var(--ol-line-strong)', color: 'var(--ol-ink)', - }}>{hotkeyLabel} + }}>{getHotkeyTriggerLabel(hotkey?.trigger)} 开始录音 } @@ -131,7 +131,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{history.length === 0 && (
- 还没有记录。按 {hotkeyLabel} 开始第一次录音。 + 还没有记录。按 {getHotkeyTriggerLabel(hotkey?.trigger)} 开始第一次录音。
)} {history.slice(0, 5).map(s => ( diff --git a/openless -all/app/src/pages/Settings.tsx b/openless -all/app/src/pages/Settings.tsx index 5d275623..96ef2e3b 100644 --- a/openless -all/app/src/pages/Settings.tsx +++ b/openless -all/app/src/pages/Settings.tsx @@ -4,11 +4,12 @@ import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import { Icon } from '../components/Icon'; -import { detectOS } from '../components/WindowChrome'; import { APP_VERSION_LABEL } from '../lib/appVersion'; +import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; import { checkAccessibilityPermission, checkMicrophonePermission, + getHotkeyCapability, getHotkeyStatus, getSettings, openExternal, @@ -20,7 +21,14 @@ import { setCredential, setSettings, } from '../lib/ipc'; -import type { HotkeyMode, HotkeyStatus, HotkeyTrigger, PermissionStatus, UserPreferences } from '../lib/types'; +import type { + HotkeyCapability, + HotkeyMode, + HotkeyStatus, + HotkeyTrigger, + PermissionStatus, + UserPreferences, +} from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; interface SettingsProps { @@ -95,45 +103,16 @@ function SettingRow({ label, desc, children }: SettingRowProps) { ); } -const TRIGGER_LABEL: Record = { - rightOption: '右 Option', - leftOption: '左 Option', - rightControl: '右 Control', - leftControl: '左 Control', - rightCommand: '右 Command', - fn: 'Fn (地球键)', - rightAlt: '右 Alt', -}; - -const MAC_TRIGGER_OPTIONS: HotkeyTrigger[] = [ - 'rightOption', - 'leftOption', - 'rightControl', - 'leftControl', - 'rightCommand', - 'fn', -]; - -const WIN_TRIGGER_OPTIONS: HotkeyTrigger[] = [ - 'rightAlt', - 'rightControl', - 'leftControl', - 'rightCommand', -]; - function RecordingSection() { const [prefs, setPrefs] = useState(null); - const os = detectOS(); - const triggerOptions = os === 'win' ? WIN_TRIGGER_OPTIONS : MAC_TRIGGER_OPTIONS; - const hotkeyDesc = os === 'win' - ? '按下即开始捕获语音,全局生效。Windows 不需要辅助功能权限。' - : '按下即开始捕获语音,全局生效。需要授予辅助功能权限。'; + const [capability, setCapability] = useState(null); useEffect(() => { getSettings().then(setPrefs); + getHotkeyCapability().then(setCapability); }, []); - if (!prefs) { + if (!prefs || !capability) { return (
加载中…
@@ -157,6 +136,9 @@ function RecordingSection() { ['toggle', '切换式'], ['hold', '按住说话'], ]; + const hotkeyDesc = capability.requiresAccessibilityPermission + ? '按下即开始捕获语音,全局生效。需要授予辅助功能权限。' + : '按下即开始捕获语音,全局生效。无需额外辅助功能授权。'; return ( @@ -172,8 +154,8 @@ function RecordingSection() { fontFamily: 'var(--ol-font-mono)', }} > - {triggerOptions.map(t => ( - + {capability.availableTriggers.map(t => ( + ))} @@ -200,6 +182,11 @@ function RecordingSection() { + {capability.statusHint && ( +
+ {capability.statusHint} +
+ )}
); } @@ -397,16 +384,31 @@ const iconBtnStyle: CSSProperties = { }; function ShortcutsSection() { - const os = detectOS(); - const desc = os === 'win' - ? '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。' - : '所有快捷键全局生效,需要在权限设置中开启辅助功能。'; + const [prefs, setPrefs] = useState(null); + const [capability, setCapability] = useState(null); + + useEffect(() => { + getSettings().then(setPrefs); + getHotkeyCapability().then(setCapability); + }, []); + + if (!prefs || !capability) { + return ( + +
加载中…
+
+ ); + } + + const desc = capability.requiresAccessibilityPermission + ? '所有快捷键全局生效,需要在权限设置中开启辅助功能。' + : '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。'; const rows: Array<[string, string]> = [ - ['开始 / 停止录音', os === 'win' ? '右 Alt' : '右 Option'], + ['开始 / 停止录音', getHotkeyStartStopLabel(prefs.hotkey)], ['取消本次录音', 'Esc'], ['胶囊确认插入', '点击右侧 ✓'], - ['切换上一次风格', os === 'win' ? '暂未支持' : '⌘ ⇧ S'], - ['打开 OpenLess', os === 'win' ? '暂未支持' : '⌘ ⇧ O'], + ['切换上一次风格', capability.requiresAccessibilityPermission ? '⌘ ⇧ S' : '暂未支持'], + ['打开 OpenLess', capability.requiresAccessibilityPermission ? '⌘ ⇧ O' : '暂未支持'], ]; return ( @@ -432,18 +434,17 @@ function PermissionsSection() { const [accessibility, setAccessibility] = useState('loading'); const [microphone, setMicrophone] = useState('loading'); const [hotkey, setHotkey] = useState(null); - const os = detectOS(); - const desc = os === 'win' - ? 'OpenLess 需要麦克风可用,并依赖全局快捷键监听状态判断 Windows 侧是否正常工作。' - : 'OpenLess 需要以下系统权限才能正常工作。授权后通常需要完全退出 App 重启一次才生效。'; + const [capability, setCapability] = useState(null); const refresh = async () => { - const [a, m] = await Promise.all([ + const [a, m, c] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), + getHotkeyCapability(), ]); setAccessibility(a); setMicrophone(m); + setCapability(c); setHotkey(await getHotkeyStatus()); }; @@ -478,6 +479,10 @@ function PermissionsSection() { refresh(); }; + const desc = capability?.requiresAccessibilityPermission + ? 'OpenLess 需要以下系统权限才能正常工作。授权后通常需要完全退出 App 重启一次才生效。' + : 'OpenLess 需要麦克风可用,并依赖全局快捷键监听状态判断 native hook 是否正常工作。'; + return (
权限
@@ -494,7 +499,7 @@ function PermissionsSection() { )}
- {os !== 'win' && ( + {capability?.requiresAccessibilityPermission && (
@@ -506,7 +511,10 @@ function PermissionsSection() {
)} - +
{hotkey?.message && ( @@ -578,3 +586,9 @@ function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) { } return 监听失败; } + +function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { + if (adapter === 'macEventTap') return 'macOS Event Tap'; + if (adapter === 'windowsLowLevel') return 'Windows 低层键盘 hook'; + return 'rdev 监听器'; +} From ca239124109961eac497db796f018f3fc40ebc59 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 30 Apr 2026 07:52:48 +0800 Subject: [PATCH 2/4] Keep Windows-target hotkey checks compiling cleanly Windows cross-target cargo check exposed two target-specific mismatches in the new low-level keyboard hook path: the hook struct import needed to match the windows crate module layout used here, and the injected-event flag access needed the wrapper-field form expected by that type. I also split the default hotkey binding cfg blocks so the Windows default stays explicit without leaving a cross-target unreachable branch behind. Constraint: Must preserve the new native Windows low-level hook path added for issue 36 Rejected: Skip Windows-target verification | would leave the affected platform path unproven Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Windows hook imports and flag access aligned with this repo's windows crate APIs Tested: cargo check; cargo check --target x86_64-pc-windows-gnu Not-tested: Windows real hardware runtime behavior --- openless -all/app/src-tauri/src/hotkey.rs | 5 ++--- openless -all/app/src-tauri/src/types.rs | 13 ++++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/openless -all/app/src-tauri/src/hotkey.rs b/openless -all/app/src-tauri/src/hotkey.rs index b2880dc2..9b6c89a4 100644 --- a/openless -all/app/src-tauri/src/hotkey.rs +++ b/openless -all/app/src-tauri/src/hotkey.rs @@ -350,10 +350,9 @@ mod platform { use std::sync::Arc; use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; - use windows::Win32::UI::Input::KeyboardAndMouse::KBDLLHOOKSTRUCT; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetMessageW, SetWindowsHookExW, TranslateMessage, - UnhookWindowsHookEx, HC_ACTION, HHOOK, MSG, WH_KEYBOARD_LL, + UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, }; use super::{install_error, send_or_log, HotkeyAdapter, HotkeyEvent, Shared}; @@ -485,7 +484,7 @@ mod platform { if code == HC_ACTION as i32 && lparam.0 != 0 { if let Some(ctx) = callback_context() { let keyboard = *(lparam.0 as *const KBDLLHOOKSTRUCT); - if keyboard.flags & LLKHF_INJECTED == 0 { + if keyboard.flags.0 & LLKHF_INJECTED == 0 { dispatch_keyboard_event(ctx, keyboard.vkCode, wparam.0); } } diff --git a/openless -all/app/src-tauri/src/types.rs b/openless -all/app/src-tauri/src/types.rs index 912453b7..6891ca6e 100644 --- a/openless -all/app/src-tauri/src/types.rs +++ b/openless -all/app/src-tauri/src/types.rs @@ -285,15 +285,18 @@ impl Default for HotkeyBinding { fn default() -> Self { #[cfg(target_os = "windows")] { - return Self { + Self { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Hold, - }; + } } - Self { - trigger: HotkeyTrigger::RightOption, - mode: HotkeyMode::Toggle, + #[cfg(not(target_os = "windows"))] + { + Self { + trigger: HotkeyTrigger::RightOption, + mode: HotkeyMode::Toggle, + } } } } From 688d8667c18f68c1504b6156dedc5aeca7995963 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 30 Apr 2026 08:01:01 +0800 Subject: [PATCH 3/4] Make hotkey review follow-ups explicit and shared The Windows adapter now documents why a few cross-platform trigger aliases map to the same modifier virtual key, so follow-up edits do not accidentally undo issue-36 semantics. On the frontend, hotkey settings and capability now load through one shared provider instead of repeated per-view IPC fetches, which cuts redundant loading branches across onboarding, shell, overview, history, and the hotkey-related settings sections. Constraint: Keep the issue-36 behavior intact while making the review follow-ups minimal Rejected: Rework the full settings data flow | broader than the review comments required Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep shared hotkey state limited to binding and capability unless another cross-view settings dependency is proven Tested: npm run build; cargo check; cargo check --target x86_64-pc-windows-gnu Not-tested: Real Windows runtime hotkey behavior and interactive UI smoke test --- openless -all/app/src-tauri/src/hotkey.rs | 6 ++ openless -all/app/src/App.tsx | 10 +-- .../app/src/components/FloatingShell.tsx | 13 ++-- .../app/src/components/Onboarding.tsx | 10 ++- openless -all/app/src/pages/History.tsx | 8 +-- openless -all/app/src/pages/Overview.tsx | 8 +-- openless -all/app/src/pages/Settings.tsx | 57 +++++----------- .../app/src/state/HotkeySettingsContext.tsx | 66 +++++++++++++++++++ 8 files changed, 111 insertions(+), 67 deletions(-) create mode 100644 openless -all/app/src/state/HotkeySettingsContext.tsx diff --git a/openless -all/app/src-tauri/src/hotkey.rs b/openless -all/app/src-tauri/src/hotkey.rs index 9b6c89a4..9508143d 100644 --- a/openless -all/app/src-tauri/src/hotkey.rs +++ b/openless -all/app/src-tauri/src/hotkey.rs @@ -531,6 +531,12 @@ mod platform { } fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 { + // Windows only gives us a small set of modifier virtual keys that can be + // used as reliable modifier-only global triggers, so the cross-platform + // trigger list intentionally collapses a few aliases onto the same + // physical Windows key: + // - LeftOption reuses RightAlt / VK_RMENU + // - Fn reuses RightControl / VK_RCONTROL match trigger { HotkeyTrigger::RightControl => VK_RCONTROL, HotkeyTrigger::LeftControl => VK_LCONTROL, diff --git a/openless -all/app/src/App.tsx b/openless -all/app/src/App.tsx index a4466212..e4550a7d 100644 --- a/openless -all/app/src/App.tsx +++ b/openless -all/app/src/App.tsx @@ -3,6 +3,7 @@ import { Capsule } from './components/Capsule'; import { FloatingShell } from './components/FloatingShell'; import { Onboarding } from './components/Onboarding'; import { checkAccessibilityPermission, checkMicrophonePermission, isTauri } from './lib/ipc'; +import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; interface AppProps { isCapsule: boolean; @@ -39,8 +40,9 @@ export function App({ isCapsule }: AppProps) { if (gate === 'checking') { return null; } - if (gate === 'onboarding') { - return setGate('ready')} />; - } - return ; + return ( + + {gate === 'onboarding' ? setGate('ready')} /> : } + + ); } diff --git a/openless -all/app/src/components/FloatingShell.tsx b/openless -all/app/src/components/FloatingShell.tsx index 2826f879..875e4afc 100644 --- a/openless -all/app/src/components/FloatingShell.tsx +++ b/openless -all/app/src/components/FloatingShell.tsx @@ -14,13 +14,14 @@ import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; -import { getCredentials, getSettings, openExternal } from '../lib/ipc'; +import { getCredentials, openExternal } from '../lib/ipc'; import { OL_DATA } from '../lib/mockData'; import { PROVIDER_SETUP_PROMPT_SEEN_KEY, shouldShowProviderSetupPrompt, } from '../lib/providerSetup'; import type { SettingsSectionId } from '../pages/Settings'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; interface NavItem { @@ -56,17 +57,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings); const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); - const [hotkeyLabel, setHotkeyLabel] = useState(() => getHotkeyTriggerLabel(null)); + const { hotkey } = useHotkeySettings(); const Page = (NAV.find((n) => n.id === currentTab) ?? NAV[0]).cmp; useEffect(() => { let cancelled = false; (async () => { const credentials = await getCredentials(); - const prefs = await getSettings(); - if (!cancelled) { - setHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey.trigger)); - } const promptSeenValue = window.localStorage.getItem(PROVIDER_SETUP_PROMPT_SEEN_KEY); if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptSeenValue)) { setProviderPromptOpen(true); @@ -177,12 +174,12 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
录音快捷键
{hotkeyLabel} + }}>{getHotkeyTriggerLabel(hotkey?.trigger)} 开始 / 停止
diff --git a/openless -all/app/src/components/Onboarding.tsx b/openless -all/app/src/components/Onboarding.tsx index 4e547944..9186e0c6 100644 --- a/openless -all/app/src/components/Onboarding.tsx +++ b/openless -all/app/src/components/Onboarding.tsx @@ -7,13 +7,13 @@ import { useEffect, useState } from 'react'; import { checkAccessibilityPermission, checkMicrophonePermission, - getHotkeyCapability, openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, } from '../lib/ipc'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; -import type { HotkeyCapability, PermissionStatus } from '../lib/types'; +import type { PermissionStatus } from '../lib/types'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; interface OnboardingProps { onComplete: () => void; @@ -22,18 +22,16 @@ interface OnboardingProps { export function Onboarding({ onComplete }: OnboardingProps) { const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); - const [capability, setCapability] = useState(null); const [busy, setBusy] = useState(false); + const { capability } = useHotkeySettings(); const refresh = async () => { - const [a, m, c] = await Promise.all([ + const [a, m] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), - getHotkeyCapability(), ]); setAccessibility(a); setMicrophone(m); - setCapability(c); if ((a === 'granted' || a === 'notApplicable') && (m === 'granted' || m === 'notApplicable')) { onComplete(); } diff --git a/openless -all/app/src/pages/History.tsx b/openless -all/app/src/pages/History.tsx index fe05c5a0..69980049 100644 --- a/openless -all/app/src/pages/History.tsx +++ b/openless -all/app/src/pages/History.tsx @@ -5,8 +5,9 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; -import { clearHistory, deleteHistoryEntry, getSettings, listHistory } from '../lib/ipc'; -import type { DictationSession, HotkeyBinding, PolishMode } from '../lib/types'; +import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; +import type { DictationSession, PolishMode } from '../lib/types'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; const FILTERS: Array<{ id: 'all' | PolishMode; label: string }> = [ @@ -29,7 +30,7 @@ export function History() { const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); - const [hotkey, setHotkey] = useState(null); + const { hotkey } = useHotkeySettings(); const refresh = async () => { const data = await listHistory(); @@ -42,7 +43,6 @@ export function History() { useEffect(() => { refresh(); - getSettings().then(p => setHotkey(p.hotkey)); }, []); const filtered = useMemo( diff --git a/openless -all/app/src/pages/Overview.tsx b/openless -all/app/src/pages/Overview.tsx index f393da7b..df47355f 100644 --- a/openless -all/app/src/pages/Overview.tsx +++ b/openless -all/app/src/pages/Overview.tsx @@ -3,8 +3,9 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; -import { getCredentials, getSettings, listHistory } from '../lib/ipc'; -import type { CredentialsStatus, DictationSession, HotkeyBinding, PolishMode } from '../lib/types'; +import { getCredentials, listHistory } from '../lib/ipc'; +import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; const MODE_LABEL: Record = { @@ -24,12 +25,11 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); - const [hotkey, setHotkey] = useState(null); + const { hotkey } = useHotkeySettings(); useEffect(() => { listHistory().then(setHistory); getCredentials().then(setCreds); - getSettings().then(p => setHotkey(p.hotkey)); }, []); const metrics = useMemo(() => { diff --git a/openless -all/app/src/pages/Settings.tsx b/openless -all/app/src/pages/Settings.tsx index 96ef2e3b..437d912d 100644 --- a/openless -all/app/src/pages/Settings.tsx +++ b/openless -all/app/src/pages/Settings.tsx @@ -9,9 +9,7 @@ import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; import { checkAccessibilityPermission, checkMicrophonePermission, - getHotkeyCapability, getHotkeyStatus, - getSettings, openExternal, openSystemSettings, readCredential, @@ -19,7 +17,6 @@ import { requestMicrophonePermission, setActiveLlmProvider, setCredential, - setSettings, } from '../lib/ipc'; import type { HotkeyCapability, @@ -27,8 +24,8 @@ import type { HotkeyStatus, HotkeyTrigger, PermissionStatus, - UserPreferences, } from '../lib/types'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; interface SettingsProps { @@ -104,13 +101,7 @@ function SettingRow({ label, desc, children }: SettingRowProps) { } function RecordingSection() { - const [prefs, setPrefs] = useState(null); - const [capability, setCapability] = useState(null); - - useEffect(() => { - getSettings().then(setPrefs); - getHotkeyCapability().then(setCapability); - }, []); + const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); if (!prefs || !capability) { return ( @@ -120,17 +111,12 @@ function RecordingSection() { ); } - const updatePrefs = async (next: UserPreferences) => { - setPrefs(next); - await setSettings(next); - }; - const onTriggerChange = (trigger: HotkeyTrigger) => - updatePrefs({ ...prefs, hotkey: { ...prefs.hotkey, trigger } }); + savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, trigger } }); const onModeChange = (mode: HotkeyMode) => - updatePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); + savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); const onShowCapsuleChange = (showCapsule: boolean) => - updatePrefs({ ...prefs, showCapsule }); + savePrefs({ ...prefs, showCapsule }); const choices: Array<[HotkeyMode, string]> = [ ['toggle', '切换式'], @@ -224,24 +210,21 @@ type LlmPresetId = typeof LLM_PRESETS[number]['id']; const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration'; function ProvidersSection() { - const [prefs, setPrefsState] = useState(null); + const { prefs, updatePrefs } = useHotkeySettings(); const [llmProvider, setLlmProvider] = useState('ark'); useEffect(() => { - getSettings().then(p => { - setPrefsState(p); - const known = LLM_PRESETS.find(x => x.id === p.activeLlmProvider); - setLlmProvider(known ? known.id : 'custom'); - }); - }, []); + if (!prefs) return; + const known = LLM_PRESETS.find(x => x.id === prefs.activeLlmProvider); + setLlmProvider(known ? known.id : 'custom'); + }, [prefs]); const onLlmProviderChange = async (id: LlmPresetId) => { setLlmProvider(id); await setActiveLlmProvider(id); if (prefs) { const next = { ...prefs, activeLlmProvider: id }; - setPrefsState(next); - await setSettings(next); + await updatePrefs(next); } const preset = LLM_PRESETS.find(p => p.id === id); if (preset?.baseUrl) { @@ -384,15 +367,9 @@ const iconBtnStyle: CSSProperties = { }; function ShortcutsSection() { - const [prefs, setPrefs] = useState(null); - const [capability, setCapability] = useState(null); + const { hotkey, capability } = useHotkeySettings(); - useEffect(() => { - getSettings().then(setPrefs); - getHotkeyCapability().then(setCapability); - }, []); - - if (!prefs || !capability) { + if (!hotkey || !capability) { return (
加载中…
@@ -404,7 +381,7 @@ function ShortcutsSection() { ? '所有快捷键全局生效,需要在权限设置中开启辅助功能。' : '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。'; const rows: Array<[string, string]> = [ - ['开始 / 停止录音', getHotkeyStartStopLabel(prefs.hotkey)], + ['开始 / 停止录音', getHotkeyStartStopLabel(hotkey)], ['取消本次录音', 'Esc'], ['胶囊确认插入', '点击右侧 ✓'], ['切换上一次风格', capability.requiresAccessibilityPermission ? '⌘ ⇧ S' : '暂未支持'], @@ -434,17 +411,15 @@ function PermissionsSection() { const [accessibility, setAccessibility] = useState('loading'); const [microphone, setMicrophone] = useState('loading'); const [hotkey, setHotkey] = useState(null); - const [capability, setCapability] = useState(null); + const { capability } = useHotkeySettings(); const refresh = async () => { - const [a, m, c] = await Promise.all([ + const [a, m] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), - getHotkeyCapability(), ]); setAccessibility(a); setMicrophone(m); - setCapability(c); setHotkey(await getHotkeyStatus()); }; diff --git a/openless -all/app/src/state/HotkeySettingsContext.tsx b/openless -all/app/src/state/HotkeySettingsContext.tsx new file mode 100644 index 00000000..8ee08fd2 --- /dev/null +++ b/openless -all/app/src/state/HotkeySettingsContext.tsx @@ -0,0 +1,66 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { getHotkeyCapability, getSettings, setSettings } from '../lib/ipc'; +import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types'; + +interface HotkeySettingsContextValue { + prefs: UserPreferences | null; + hotkey: HotkeyBinding | null; + capability: HotkeyCapability | null; + loading: boolean; + refresh: () => Promise; + updatePrefs: (next: UserPreferences) => Promise; +} + +const HotkeySettingsContext = createContext(null); + +export function HotkeySettingsProvider({ children }: { children: ReactNode }) { + const [prefs, setPrefs] = useState(null); + const [capability, setCapability] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + const [nextPrefs, nextCapability] = await Promise.all([getSettings(), getHotkeyCapability()]); + setPrefs(nextPrefs); + setCapability(nextCapability); + setLoading(false); + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const updatePrefs = useCallback(async (next: UserPreferences) => { + setPrefs(next); + await setSettings(next); + }, []); + + const value = useMemo( + () => ({ + prefs, + hotkey: prefs?.hotkey ?? null, + capability, + loading, + refresh, + updatePrefs, + }), + [capability, loading, prefs, refresh, updatePrefs], + ); + + return {children}; +} + +export function useHotkeySettings() { + const value = useContext(HotkeySettingsContext); + if (!value) { + throw new Error('useHotkeySettings must be used within HotkeySettingsProvider'); + } + return value; +} From 62debe4b1d4b6360d0fb96f8dfbe1bfe24ef569f Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 30 Apr 2026 08:25:47 +0800 Subject: [PATCH 4/4] Make native hotkey adapters easier to stop and maintain The three platform adapters were all repeating the same startup scaffolding for shared state, thread launch, and readiness reporting, so that boilerplate now lives in one helper and each adapter keeps only its platform-specific listen loop. The Windows low-level hook also now carries an explicit shutdown path by posting WM_QUIT to the hook thread when the monitor is dropped during app exit, which keeps hook teardown aligned with coordinator lifecycle changes. Constraint: Keep issue-36 hotkey behavior unchanged while tightening adapter lifecycle handling Rejected: Leave Windows hook teardown implicit | would keep GetMessageW loop lifetime coupled to process exit only Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep adapter startup centralized only for shared scaffolding; platform event semantics should stay in their own modules Tested: cargo fmt --all; cargo check; cargo check --target x86_64-pc-windows-gnu Not-tested: Real Windows runtime shutdown and hook reinitialization behavior --- .../app/src-tauri/src/coordinator.rs | 4 + openless -all/app/src-tauri/src/hotkey.rs | 203 +++++++++++------- openless -all/app/src-tauri/src/lib.rs | 4 + 3 files changed, 129 insertions(+), 82 deletions(-) diff --git a/openless -all/app/src-tauri/src/coordinator.rs b/openless -all/app/src-tauri/src/coordinator.rs index 2b0c9039..13a9d87d 100644 --- a/openless -all/app/src-tauri/src/coordinator.rs +++ b/openless -all/app/src-tauri/src/coordinator.rs @@ -105,6 +105,10 @@ impl Coordinator { .ok(); } + pub fn stop_hotkey_listener(&self) { + self.inner.hotkey.lock().take(); + } + pub fn history(&self) -> &HistoryStore { &self.inner.history } diff --git a/openless -all/app/src-tauri/src/hotkey.rs b/openless -all/app/src-tauri/src/hotkey.rs index 9508143d..cb7fcf17 100644 --- a/openless -all/app/src-tauri/src/hotkey.rs +++ b/openless -all/app/src-tauri/src/hotkey.rs @@ -11,7 +11,9 @@ //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 use std::sync::atomic::AtomicBool; -use std::sync::mpsc::Sender; +use std::sync::mpsc::{self, Sender}; +use std::sync::Arc; +use std::time::Duration; use parking_lot::RwLock; @@ -27,6 +29,7 @@ pub enum HotkeyEvent { pub trait HotkeyAdapter: Send + Sync { fn kind(&self) -> HotkeyAdapterKind; fn update_binding(&self, binding: HotkeyBinding); + fn shutdown(&self) {} } struct Shared { @@ -65,6 +68,12 @@ impl HotkeyMonitor { } } +impl Drop for HotkeyMonitor { + fn drop(&mut self) { + self.adapter.shutdown(); + } +} + fn install_error(code: &str, message: impl Into) -> HotkeyInstallError { HotkeyInstallError { code: code.into(), @@ -78,6 +87,50 @@ fn send_or_log(tx: &Sender, evt: HotkeyEvent) { } } +type StartupTx = mpsc::Sender>; + +struct ListenerThread { + shared: Arc, + startup: T, +} + +fn start_listener_thread( + binding: HotkeyBinding, + tx: Sender, + thread_name: &str, + startup_timeout_message: &'static str, + run_listen_loop: F, +) -> Result, HotkeyInstallError> +where + T: Send + 'static, + F: FnOnce(Arc, Sender, StartupTx) + Send + 'static, +{ + let shared = Arc::new(Shared { + binding: RwLock::new(binding), + trigger_held: AtomicBool::new(false), + }); + + let thread_shared = Arc::clone(&shared); + let (status_tx, status_rx) = mpsc::channel::>(); + std::thread::Builder::new() + .name(thread_name.into()) + .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) + .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; + + match status_rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(startup)) => Ok(ListenerThread { shared, startup }), + Ok(Err(err)) => Err(err), + Err(_) => Err(install_error("startup_timeout", startup_timeout_message)), + } +} + +fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) { + *shared.binding.write() = binding; + shared + .trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); +} + // ─────────────────────────── macOS implementation ─────────────────────────── #[cfg(target_os = "macos")] @@ -87,30 +140,27 @@ mod platform { use std::sync::mpsc::Sender; use std::sync::Arc; - use super::{install_error, send_or_log, HotkeyAdapter, HotkeyEvent, Shared}; + use super::{ + install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, + HotkeyEvent, Shared, StartupTx, + }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; pub fn start_adapter( binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { - let shared = Arc::new(Shared { - binding: parking_lot::RwLock::new(binding), - trigger_held: std::sync::atomic::AtomicBool::new(false), - }); - - let thread_shared = Arc::clone(&shared); - let (status_tx, status_rx) = std::sync::mpsc::channel::>(); - std::thread::Builder::new() - .name("openless-hotkey-mac-event-tap".into()) - .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) - .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; - - match status_rx.recv_timeout(std::time::Duration::from_secs(3)) { - Ok(Ok(())) => Ok(Box::new(MacHotkeyAdapter { shared })), - Ok(Err(err)) => Err(err), - Err(_) => Err(install_error("startup_timeout", "hotkey hook 启动超时")), - } + let listener = start_listener_thread( + binding, + tx, + "openless-hotkey-mac-event-tap", + "hotkey hook 启动超时", + run_listen_loop, + )?; + let _ = listener.startup; + Ok(Box::new(MacHotkeyAdapter { + shared: listener.shared, + })) } struct MacHotkeyAdapter { @@ -123,8 +173,7 @@ mod platform { } fn update_binding(&self, binding: HotkeyBinding) { - *self.shared.binding.write() = binding; - self.shared.trigger_held.store(false, Ordering::SeqCst); + update_shared_binding(&self.shared, binding); } } @@ -219,11 +268,7 @@ mod platform { unsafe impl Send for CallbackContext {} unsafe impl Sync for CallbackContext {} - fn run_listen_loop( - shared: Arc, - tx: Sender, - status_tx: std::sync::mpsc::Sender>, - ) { + fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN); let context = Box::into_raw(Box::new(CallbackContext { shared, @@ -350,12 +395,17 @@ mod platform { use std::sync::Arc; use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; + use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::WindowsAndMessaging::{ - CallNextHookEx, DispatchMessageW, GetMessageW, SetWindowsHookExW, TranslateMessage, - UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, + CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW, + TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, + WH_KEYBOARD_LL, WM_QUIT, }; - use super::{install_error, send_or_log, HotkeyAdapter, HotkeyEvent, Shared}; + use super::{ + install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, + HotkeyEvent, Shared, StartupTx, + }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; const WM_KEYDOWN: usize = 0x0100; @@ -376,30 +426,22 @@ mod platform { binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { - let shared = Arc::new(Shared { - binding: parking_lot::RwLock::new(binding), - trigger_held: std::sync::atomic::AtomicBool::new(false), - }); - - let thread_shared = Arc::clone(&shared); - let (status_tx, status_rx) = std::sync::mpsc::channel::>(); - std::thread::Builder::new() - .name("openless-hotkey-win-ll-hook".into()) - .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) - .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; - - match status_rx.recv_timeout(std::time::Duration::from_secs(3)) { - Ok(Ok(())) => Ok(Box::new(WindowsHotkeyAdapter { shared })), - Ok(Err(err)) => Err(err), - Err(_) => Err(install_error( - "startup_timeout", - "Windows hotkey hook 启动超时", - )), - } + let listener = start_listener_thread( + binding, + tx, + "openless-hotkey-win-ll-hook", + "Windows hotkey hook 启动超时", + run_listen_loop, + )?; + Ok(Box::new(WindowsHotkeyAdapter { + shared: listener.shared, + thread_id: listener.startup, + })) } struct WindowsHotkeyAdapter { shared: Arc, + thread_id: u32, } impl HotkeyAdapter for WindowsHotkeyAdapter { @@ -408,8 +450,16 @@ mod platform { } fn update_binding(&self, binding: HotkeyBinding) { - *self.shared.binding.write() = binding; - self.shared.trigger_held.store(false, Ordering::SeqCst); + update_shared_binding(&self.shared, binding); + } + + fn shutdown(&self) { + unsafe { + if let Err(err) = PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)) + { + log::warn!("[hotkey] Windows hook 退出消息发送失败: {err}"); + } + } } } @@ -422,11 +472,8 @@ mod platform { unsafe impl Send for CallbackContext {} unsafe impl Sync for CallbackContext {} - fn run_listen_loop( - shared: Arc, - tx: Sender, - status_tx: std::sync::mpsc::Sender>, - ) { + fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx) { + let thread_id = unsafe { GetCurrentThreadId() }; let context = Box::into_raw(Box::new(CallbackContext { shared, tx, @@ -440,7 +487,7 @@ mod platform { Ok(hook) => { *(*context).hook.lock().unwrap() = Some(hook); log::info!("[hotkey] Windows low-level keyboard hook 已启动"); - let _ = status_tx.send(Ok(())); + let _ = status_tx.send(Ok(thread_id)); } Err(err) => { HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst); @@ -559,30 +606,27 @@ mod platform { use rdev::{listen, Event, EventType, Key}; - use super::{install_error, HotkeyAdapter, HotkeyEvent, Shared}; + use super::{ + install_error, start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, + Shared, StartupTx, + }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; pub fn start_adapter( binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { - let shared = Arc::new(Shared { - binding: parking_lot::RwLock::new(binding), - trigger_held: std::sync::atomic::AtomicBool::new(false), - }); - - let thread_shared = Arc::clone(&shared); - let (status_tx, status_rx) = std::sync::mpsc::channel::>(); - std::thread::Builder::new() - .name("openless-hotkey-rdev".into()) - .spawn(move || run_listen_loop(thread_shared, tx, status_tx)) - .map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?; - - match status_rx.recv_timeout(std::time::Duration::from_secs(3)) { - Ok(Ok(())) => Ok(Box::new(RdevHotkeyAdapter { shared })), - Ok(Err(err)) => Err(err), - Err(_) => Err(install_error("startup_timeout", "hotkey hook 启动超时")), - } + let listener = start_listener_thread( + binding, + tx, + "openless-hotkey-rdev", + "hotkey hook 启动超时", + run_listen_loop, + )?; + let _ = listener.startup; + Ok(Box::new(RdevHotkeyAdapter { + shared: listener.shared, + })) } struct RdevHotkeyAdapter { @@ -595,16 +639,11 @@ mod platform { } fn update_binding(&self, binding: HotkeyBinding) { - *self.shared.binding.write() = binding; - self.shared.trigger_held.store(false, Ordering::SeqCst); + update_shared_binding(&self.shared, binding); } } - fn run_listen_loop( - shared: Arc, - tx: Sender, - status_tx: std::sync::mpsc::Sender>, - ) { + fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { let status_sent = Arc::new(AtomicBool::new(false)); let ready_status_sent = Arc::clone(&status_sent); let ready_status_tx = status_tx.clone(); diff --git a/openless -all/app/src-tauri/src/lib.rs b/openless -all/app/src-tauri/src/lib.rs index 5bef1da9..d4e1007a 100644 --- a/openless -all/app/src-tauri/src/lib.rs +++ b/openless -all/app/src-tauri/src/lib.rs @@ -164,6 +164,10 @@ pub fn run() { } } } + RunEvent::Exit => { + let coordinator = app.state::>(); + coordinator.stop_hotkey_listener(); + } _ => {} }); }