Tauri(十九)——实现 macOS 划词监控的完整实践

背景

为了提高 Coco AI 的用户使用率,以及提供快捷操作等,我给我们 Coco AI 也增加了划词功能。

接下来就介绍一下如何在 Tauri v2 中实现"划词"功能(选中文本的实时检测与前端弹窗联动),覆盖 macOS 无障碍权限、坐标转换、多屏支持、前端事件桥接与性能/稳定性策略。

功能概述

  • 在系统前台 App 中选中文本后,后端读取选区文本与鼠标坐标,通过事件主动推给前端。
  • 前端根据事件展示/隐藏弹窗(或"快查"面板),并在主窗口中同步输入/状态。
  • 提供"全局开关",随时启停划词监控。

关键点与设计思路

  • 权限:macOS 读取选区依赖系统"无障碍(Accessibility)"权限;首次运行时请求用户授权。
  • 稳定性:对选区读取做轻量重试与去抖,避免弹窗闪烁。
  • 坐标 :Quartz 坐标系为"左下角为原点",前端常用"左上角为原点";需要对 y 做翻转。
  • 多屏:在多显示器场景下,根据鼠标所在显示器与全局边界计算统一坐标。
  • 交互保护:当 Coco 自己在前台时,暂不读取选区,避免把弹窗交互误判为空选区。
  • 事件协议 :统一向前端发两个事件:
    • selection-detected:选区文本与坐标(或空字符串表示隐藏)
    • selection-enabled:开关状态

后端实现(Tauri v2 / Rust)

  • 定义事件载荷与全局开关,导出命令给前端调用。
  • 在启动入口中开启监控线程,不断读取选区并发事件。
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() }
  • 启动监控线程:权限校验、选区读取、坐标转换与事件发送。
rust 复制代码
#[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
rust 复制代码
#[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 更新 searchValueisChatModeaskAiMessage 等。
ts 复制代码
// 伪示例:监听 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_enabledget_selection_enabled
    • 应用启动后调用一次 start_selection_monitor(app_handle.clone()) 开启监控线程
rust 复制代码
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.jsoncapabilities 配置允许相应操作。

稳定性与性能策略

  • 去抖与重试
    • 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/...

相关推荐
ganshenml2 小时前
【Web】证书(SSL/TLS)与域名之间的关系:完整、通俗、可落地的讲解
前端·网络协议·ssl
g***B7382 小时前
Rust在网络中的Tokio
开发语言·网络·rust
疏狂难除2 小时前
尝试rust与python的混合编程(二)
数据库·python·rust
这是个栗子2 小时前
npm报错 : 无法加载文件 npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
9***P3343 小时前
Rust在网络中的Rocket
开发语言·后端·rust
HIT_Weston3 小时前
44、【Ubuntu】【Gitlab】拉出内网 Web 服务:http.server 分析(一)
前端·ubuntu·gitlab
华仔啊3 小时前
Vue3 如何实现图片懒加载?其实一个 Intersection Observer 就搞定了
前端·vue.js
JamesGosling6664 小时前
深入理解内容安全策略(CSP):原理、作用与实践指南
前端·浏览器
不要想太多4 小时前
前端进阶系列之《浏览器渲染原理》
前端