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..13a9d87d 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, }; @@ -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 } @@ -125,6 +129,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 +157,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 +193,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..cb7fcf17 100644 --- a/openless -all/app/src-tauri/src/hotkey.rs +++ b/openless -all/app/src-tauri/src/hotkey.rs @@ -4,18 +4,20 @@ //! `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::mpsc::Sender; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc::{self, Sender}; use std::sync::Arc; -use std::thread; +use std::time::Duration; use parking_lot::RwLock; -use crate::types::HotkeyBinding; +use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError}; #[derive(Clone, Copy, Debug)] pub enum HotkeyEvent { @@ -24,6 +26,12 @@ pub enum HotkeyEvent { Cancelled, } +pub trait HotkeyAdapter: Send + Sync { + fn kind(&self) -> HotkeyAdapterKind; + fn update_binding(&self, binding: HotkeyBinding); + fn shutdown(&self) {} +} + struct Shared { binding: RwLock, /// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。 @@ -31,41 +39,98 @@ 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), - }); - - 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))?; - - 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 启动超时")), - } + /// 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)?, + }) } pub fn update_binding(&self, binding: HotkeyBinding) { - *self.shared.binding.write() = binding; - self.shared.trigger_held.store(false, Ordering::SeqCst); + self.adapter.update_binding(binding); + } + + pub fn kind(&self) -> HotkeyAdapterKind { + self.adapter.kind() + } + + pub fn capability() -> HotkeyCapability { + HotkeyCapability::current() + } +} + +impl Drop for HotkeyMonitor { + fn drop(&mut self) { + self.adapter.shutdown(); } } +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}"); + } +} + +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")] @@ -75,8 +140,42 @@ 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, 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 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 { + shared: Arc, + } + + impl HotkeyAdapter for MacHotkeyAdapter { + fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::MacEventTap + } + + fn update_binding(&self, binding: HotkeyBinding) { + update_shared_binding(&self.shared, binding); + } + } // ── Raw CG/CF FFI ────────────────────────────────────────────────────── @@ -160,25 +259,16 @@ 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( - 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, @@ -200,7 +290,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 +304,7 @@ mod platform { CGEventTapEnable(tap, true); log::info!("[hotkey] CGEventTap 已启动"); - let _ = status_tx.send(true); + let _ = status_tx.send(Ok(())); CFRunLoopRun(); } } @@ -269,12 +362,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 +385,219 @@ mod platform { } } -// ─────────────────────────── non-macOS implementation ─────────────────────────── +// ─────────────────────────── Windows implementation ─────────────────────────── + +#[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::System::Threading::GetCurrentThreadId; + use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW, + TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, + WH_KEYBOARD_LL, WM_QUIT, + }; + + 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; + 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 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 { + fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::WindowsLowLevel + } + + fn update_binding(&self, binding: HotkeyBinding) { + 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}"); + } + } + } + } + + 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: StartupTx) { + let thread_id = unsafe { GetCurrentThreadId() }; + 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(thread_id)); + } + 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.0 & 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 { + // 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, + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU, + HotkeyTrigger::RightCommand => VK_RWIN, + HotkeyTrigger::LeftOption => VK_RMENU, + HotkeyTrigger::Fn => VK_RCONTROL, + } + } +} + +// ─────────────────────────── Linux / other implementation ─────────────────────────── -#[cfg(not(target_os = "macos"))] +#[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 +606,51 @@ mod platform { use rdev::{listen, Event, EventType, Key}; - use super::{HotkeyEvent, Shared}; - use crate::types::HotkeyTrigger; + use super::{ + install_error, start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, + Shared, StartupTx, + }; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; - pub fn run_listen_loop( - shared: Arc, + pub fn start_adapter( + binding: HotkeyBinding, tx: Sender, - status_tx: std::sync::mpsc::Sender, - ) { - // rdev 没有"安装即可知"的 API。给 listen 一个短窗口: - // 如果 hook 立即失败,向 supervisor 汇报失败;否则视为已进入监听循环。 + ) -> Result, HotkeyInstallError> { + 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 { + shared: Arc, + } + + impl HotkeyAdapter for RdevHotkeyAdapter { + fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::Rdev + } + + fn update_binding(&self, binding: HotkeyBinding) { + update_shared_binding(&self.shared, binding); + } + } + + 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(); 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 +659,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..d4e1007a 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, @@ -163,6 +164,10 @@ pub fn run() { } } } + RunEvent::Exit => { + let coordinator = app.state::>(); + coordinator.stop_hotkey_listener(); + } _ => {} }); } @@ -297,13 +302,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..6891ca6e 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,22 +273,30 @@ 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. - Self { - trigger: if cfg!(target_os = "windows") { - HotkeyTrigger::RightAlt - } else { - HotkeyTrigger::RightOption - }, - mode: HotkeyMode::Toggle, + #[cfg(target_os = "windows")] + { + Self { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Hold, + } + } + + #[cfg(not(target_os = "windows"))] + { + Self { + trigger: HotkeyTrigger::RightOption, + mode: HotkeyMode::Toggle, + } } } } 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 093a0f05..875e4afc 100644 --- a/openless -all/app/src/components/FloatingShell.tsx +++ b/openless -all/app/src/components/FloatingShell.tsx @@ -13,6 +13,7 @@ import { History } from '../pages/History'; import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { APP_VERSION_LABEL } from '../lib/appVersion'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; import { getCredentials, openExternal } from '../lib/ipc'; import { OL_DATA } from '../lib/mockData'; import { @@ -20,6 +21,7 @@ import { shouldShowProviderSetupPrompt, } from '../lib/providerSetup'; import type { SettingsSectionId } from '../pages/Settings'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; interface NavItem { @@ -55,8 +57,8 @@ 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 { hotkey } = useHotkeySettings(); const Page = (NAV.find((n) => n.id === currentTab) ?? NAV[0]).cmp; - const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option'; useEffect(() => { let cancelled = false; @@ -172,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 123984cd..9186e0c6 100644 --- a/openless -all/app/src/components/Onboarding.tsx +++ b/openless -all/app/src/components/Onboarding.tsx @@ -11,8 +11,9 @@ import { requestAccessibilityPermission, requestMicrophonePermission, } from '../lib/ipc'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; import type { PermissionStatus } from '../lib/types'; -import { detectOS } from './WindowChrome'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; interface OnboardingProps { onComplete: () => void; @@ -22,7 +23,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); const [busy, setBusy] = useState(false); - const os = detectOS(); + const { capability } = useHotkeySettings(); const refresh = async () => { const [a, m] = await Promise.all([ @@ -124,13 +125,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..69980049 100644 --- a/openless -all/app/src/pages/History.tsx +++ b/openless -all/app/src/pages/History.tsx @@ -4,8 +4,10 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; 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 }> = [ @@ -28,7 +30,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 } = useHotkeySettings(); const refresh = async () => { const data = await listHistory(); @@ -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..df47355f 100644 --- a/openless -all/app/src/pages/Overview.tsx +++ b/openless -all/app/src/pages/Overview.tsx @@ -2,9 +2,10 @@ import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon'; -import { detectOS } from '../components/WindowChrome'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; 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,8 +25,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); - const os = detectOS(); - const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option'; + const { hotkey } = useHotkeySettings(); useEffect(() => { listHistory().then(setHistory); @@ -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..437d912d 100644 --- a/openless -all/app/src/pages/Settings.tsx +++ b/openless -all/app/src/pages/Settings.tsx @@ -4,13 +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, getHotkeyStatus, - getSettings, openExternal, openSystemSettings, readCredential, @@ -18,9 +17,15 @@ import { requestMicrophonePermission, setActiveLlmProvider, setCredential, - setSettings, } from '../lib/ipc'; -import type { HotkeyMode, HotkeyStatus, HotkeyTrigger, PermissionStatus, UserPreferences } from '../lib/types'; +import type { + HotkeyCapability, + HotkeyMode, + HotkeyStatus, + HotkeyTrigger, + PermissionStatus, +} from '../lib/types'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; interface SettingsProps { @@ -95,45 +100,10 @@ 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 不需要辅助功能权限。' - : '按下即开始捕获语音,全局生效。需要授予辅助功能权限。'; - - useEffect(() => { - getSettings().then(setPrefs); - }, []); + const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); - if (!prefs) { + if (!prefs || !capability) { return (
加载中…
@@ -141,22 +111,20 @@ 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', '切换式'], ['hold', '按住说话'], ]; + const hotkeyDesc = capability.requiresAccessibilityPermission + ? '按下即开始捕获语音,全局生效。需要授予辅助功能权限。' + : '按下即开始捕获语音,全局生效。无需额外辅助功能授权。'; return ( @@ -172,8 +140,8 @@ function RecordingSection() { fontFamily: 'var(--ol-font-mono)', }} > - {triggerOptions.map(t => ( - + {capability.availableTriggers.map(t => ( + ))} @@ -200,6 +168,11 @@ function RecordingSection() { + {capability.statusHint && ( +
+ {capability.statusHint} +
+ )}
); } @@ -237,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) { @@ -397,16 +367,25 @@ const iconBtnStyle: CSSProperties = { }; function ShortcutsSection() { - const os = detectOS(); - const desc = os === 'win' - ? '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。' - : '所有快捷键全局生效,需要在权限设置中开启辅助功能。'; + const { hotkey, capability } = useHotkeySettings(); + + if (!hotkey || !capability) { + return ( + +
加载中…
+
+ ); + } + + const desc = capability.requiresAccessibilityPermission + ? '所有快捷键全局生效,需要在权限设置中开启辅助功能。' + : '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。'; const rows: Array<[string, string]> = [ - ['开始 / 停止录音', os === 'win' ? '右 Alt' : '右 Option'], + ['开始 / 停止录音', getHotkeyStartStopLabel(hotkey)], ['取消本次录音', 'Esc'], ['胶囊确认插入', '点击右侧 ✓'], - ['切换上一次风格', os === 'win' ? '暂未支持' : '⌘ ⇧ S'], - ['打开 OpenLess', os === 'win' ? '暂未支持' : '⌘ ⇧ O'], + ['切换上一次风格', capability.requiresAccessibilityPermission ? '⌘ ⇧ S' : '暂未支持'], + ['打开 OpenLess', capability.requiresAccessibilityPermission ? '⌘ ⇧ O' : '暂未支持'], ]; return ( @@ -432,10 +411,7 @@ 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 } = useHotkeySettings(); const refresh = async () => { const [a, m] = await Promise.all([ @@ -478,6 +454,10 @@ function PermissionsSection() { refresh(); }; + const desc = capability?.requiresAccessibilityPermission + ? 'OpenLess 需要以下系统权限才能正常工作。授权后通常需要完全退出 App 重启一次才生效。' + : 'OpenLess 需要麦克风可用,并依赖全局快捷键监听状态判断 native hook 是否正常工作。'; + return (
权限
@@ -494,7 +474,7 @@ function PermissionsSection() { )}
- {os !== 'win' && ( + {capability?.requiresAccessibilityPermission && (
@@ -506,7 +486,10 @@ function PermissionsSection() {
)} - +
{hotkey?.message && ( @@ -578,3 +561,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 监听器'; +} 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; +}