Tauri(十九)——实现 macOS 划词监控的完整实践
背景
为了提高 Coco AI 的用户使用率,以及提供快捷操作等,我给我们 Coco AI 也增加了划词功能。
接下来就介绍一下如何在 Tauri v2 中实现“划词”功能(选中文本的实时检测与前端弹窗联动),覆盖 macOS 无障碍权限、坐标转换、多屏支持、前端事件桥接与性能/稳定性策略。
功能概述
- 在系统前台 App 中选中文本后,后端读取选区文本与鼠标坐标,通过事件主动推给前端。
- 前端根据事件展示/隐藏弹窗(或“快查”面板),并在主窗口中同步输入/状态。
- 提供“全局开关”,随时启停划词监控。
关键点与设计思路
- 权限:macOS 读取选区依赖系统“无障碍(Accessibility)”权限;首次运行时请求用户授权。
- 稳定性:对选区读取做轻量重试与去抖,避免弹窗闪烁。
-
坐标:Quartz 坐标系为“左下角为原点”,前端常用“左上角为原点”;需要对
y做翻转。 - 多屏:在多显示器场景下,根据鼠标所在显示器与全局边界计算统一坐标。
- 交互保护:当 Coco 自己在前台时,暂不读取选区,避免把弹窗交互误判为空选区。
-
事件协议:统一向前端发两个事件:
-
selection-detected:选区文本与坐标(或空字符串表示隐藏) -
selection-enabled:开关状态
-
后端实现(Tauri v2 / Rust)
- 定义事件载荷与全局开关,导出命令给前端调用。
- 在启动入口中开启监控线程,不断读取选区并发事件。
/// 事件载荷:选中文本与坐标(逻辑点、左上为原点)
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
text: String,
x: i32,
y: i32,
}
use std::sync::atomic::{AtomicBool, Ordering};
/// 全局开关:默认开启
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
enabled: bool,
}
/// 读写开关并广播
pub fn is_selection_enabled() -> bool { SELECTION_ENABLED.load(Ordering::Relaxed) }
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
}
/// Tauri 命令:供前端调用开关
#[tauri::command]
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
set_selection_enabled_internal(&app_handle, enabled);
}
#[tauri::command]
pub fn get_selection_enabled() -> bool { is_selection_enabled() }
- 启动监控线程:权限校验、选区读取、坐标转换与事件发送。
#[cfg(target_os = "macos")]
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
use std::time::Duration;
use tauri::Emitter;
// 同步初始开关状态到前端
set_selection_enabled_internal(&app_handle, is_selection_enabled());
// 申请/校验无障碍权限(macOS)
{
let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
if !trusted_before {
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
}
let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
if !trusted_after {
return; // 未授权则不启动监控
}
}
// 监控线程
std::thread::spawn(move || {
use objc2_core_graphics::CGEvent;
use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
#[cfg(target_os = "macos")]
use objc2_app_kit::NSWorkspace;
// 计算鼠标全局坐标(左上原点),并做 y 翻转
let current_mouse_point_global = || -> (i32, i32) {
unsafe {
let event = CGEvent::new(None);
let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
// 多屏取全局边界并翻转 y
// ...(详见源码的显示器遍历与边界计算)
// 返回 (x_top_left, y_flipped)
// ... existing code ...
(/*x*/0, /*y*/0)
}
};
// Coco 在前台时不读选区,避免交互中误判空
let is_frontmost_app_me = || -> bool {
#[cfg(target_os = "macos")]
unsafe {
let workspace = NSWorkspace::sharedWorkspace();
if let Some(frontmost) = workspace.frontmostApplication() {
let pid = frontmost.processIdentifier();
let my_pid = std::process::id() as i32;
return pid == my_pid;
}
}
false
};
// 状态机与去抖
let mut popup_visible = false;
let mut last_text = String::new();
let stable_threshold = 2; // 连续一致≥2次视为稳定
let empty_threshold = 2; // 连续空≥2次才隐藏
let mut stable_text = String::new();
let mut stable_count = 0;
let mut empty_count = 0;
loop {
std::thread::sleep(Duration::from_millis(30));
if !is_selection_enabled() {
if popup_visible {
let _ = app_handle.emit("selection-detected", "");
popup_visible = false;
last_text.clear();
stable_text.clear();
}
continue;
}
let front_is_me = is_frontmost_app_me();
let selected_text = if front_is_me {
None // 交互期间不读选区
} else {
read_selected_text_with_retries(2, 35) // 轻量重试
};
match selected_text {
Some(text) if !text.is_empty() => {
// 稳定性检测
// ... existing code ...
if stable_count >= stable_threshold {
if !popup_visible || text != last_text {
let (x, y) = current_mouse_point_global();
let payload = SelectionEventPayload { text: text.clone(), x, y };
let _ = app_handle.emit("selection-detected", payload);
last_text = text;
popup_visible = true;
}
}
}
_ => {
// 非前台且空选区:累计空次数后隐藏
// ... existing code ...
}
}
}
});
}
- 读取选区(AXUIElement):优先系统级焦点,其次前台 App 的焦点/窗口;仅读取
AXSelectedText。
#[cfg(target_os = "macos")]
fn read_selected_text() -> Option<String> {
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFRetained, CFString, CFType};
// 优先系统级焦点 AXFocusedUIElement,失败则回退到前台 App/窗口焦点
// 跳过当前进程(Coco)避免误判
// 成功后读取 AXSelectedText,转为 String 返回
// ... existing code ...
Some(/*selected text*/ String::new())
}
#[cfg(target_os = "macos")]
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
// 最多重试 N 次:缓解 AX 焦点短暂不稳定
// ... existing code ...
None
}
前端事件桥接
-
事件名称
-
selection-enabled:载荷{ enabled: boolean },用于同步开关状态 -
selection-detected:载荷{ text: string, x: number, y: number }或""(隐藏)
-
-
监听与联动建议
- 通过
platformAdapter.listenEvent("selection-detected", ...)已完成桥接。 - 收到带文本的事件后,渲染弹窗;收到
""时隐藏。 - 在主窗口中同步搜索/聊天输入与模式。例如配合
useSearchStore/useAppStore更新searchValue、isChatMode、askAiMessage等。
- 通过
// 伪示例:监听 selection-detected 并联动 UI
function useListenSelection() {
// ... existing code ...
platformAdapter.listenEvent("selection-detected", (payload) => {
if (payload === "") {
// 隐藏弹窗
// ... existing code ...
return;
}
const { text, x, y } = payload as { text: string; x: number; y: number };
// 展示弹窗(使用 x, y 定位)
// 同步到主窗口输入或 AI 询问
// ... existing code ...
});
}
Tauri v2 集成与命令注册
- 在后端入口(如
main.rs):- 注册命令:
set_selection_enabled、get_selection_enabled - 应用启动后调用一次
start_selection_monitor(app_handle.clone())开启监控线程
- 注册命令:
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
set_selection_enabled,
get_selection_enabled
])
.setup(|app| {
let handle = app.handle().clone();
#[cfg(target_os = "macos")]
{
start_selection_monitor(handle);
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running coco app");
}
权限与配置
- macOS 无障碍(Accessibility)权限
- 首次启动会触发系统授权提示;用户需在“系统设置 → 隐私与安全 → 辅助功能”中允许 Coco。
- 代码中使用
macos_accessibility_client检查与提示,不需额外Info.plist键。
- Tauri v2 Capabilities
- Tauri 对前端 API 能力有更细粒度的限制;如需事件、命令调用等,确保
tauri.conf.json的capabilities配置允许相应操作。
- Tauri 对前端 API 能力有更细粒度的限制;如需事件、命令调用等,确保
稳定性与性能策略
- 去抖与重试
-
stable_threshold = 2:相同文本稳定两次再触发事件,减少闪烁与误报 -
empty_threshold = 2:空选区累计两次再隐藏,避免短暂抖动导致过度隐藏
-
- 轮询间隔
-
30ms足够流畅,实际可根据功耗与体验权衡调整
-
- 交互保护
- 前台为 Coco 时不读选区,避免把弹窗交互过程误读为空选区,从而误触隐藏
坐标与多屏支持
- Quartz 坐标系为“左下为原点”,很多前端布局为“左上为原点”
- 通过计算全局高度并翻转
y,确保前端定位直观
- 通过计算全局高度并翻转
- 多屏场景
- 遍历所有活动显示器,计算全局最左、最上、最下边界,统一映射全局坐标
- 根据鼠标实际所在显示器确定相对坐标,兼顾跨屏切换的平滑性
常见问题与排查
- 未授权导致“没有任何事件”
- 检查“系统设置 → 隐私与安全 → 辅助功能”是否勾选 Coco
- 前端没有响应
selection-detected- 确认事件监听正确(命名与载荷形态)、确保主窗口同步更新输入与模式
- 坐标不正确或弹窗偏移
- 排查坐标系转换(y 翻转)、多屏边界计算是否符合实际布局
- 弹窗闪烁或频繁隐藏
- 调整
stable_threshold/empty_threshold与轮询间隔;也可对文本变化设更严格的稳定条件
- 调整
测试清单
- 授权流程:首次运行提示、授权后是否正常读取
- 多屏场景:跨屏移动鼠标后坐标是否正确、弹窗位置是否稳定
- 交互过程:点击弹窗与主窗口时是否停止读取选区、不会误判空而隐藏
- 文本变化:快速划词切换时是否平滑、不会频繁闪烁
小结
- 划词功能的核心在于 “权限 → 获取选区 → 稳定性处理 → 事件联动 → 前端渲染” 这条链路。
- Tauri v2 在能力管理与事件桥接上更清晰,结合 macOS 的 AX 接口与坐标转换,可以构建稳定、体验良好的系统级“快查”能力。
开源共建,欢迎 Star ✨:github.com/infinilabs/…