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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/platform-adapter-architecture.md
Original file line number Diff line number Diff line change
@@ -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`
- 不在这层引入新的全局快捷键依赖
9 changes: 7 additions & 2 deletions openless -all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Coordinator>>;
Expand All @@ -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();
Expand Down
31 changes: 23 additions & 8 deletions openless -all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -149,25 +157,28 @@ impl Coordinator {

fn hotkey_supervisor_loop(inner: Arc<Inner>) {
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::<HotkeyEvent>();
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))",
Expand All @@ -182,13 +193,17 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
}
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));
Expand Down
Loading
Loading