Files
coco-app/src-tauri/src/selection_monitor.rs
BiggerRain 97d2450fa7 feat: selection settings add & delete (#992)
* feat: add selection window page

* fix: chat input

* feat: add selection page

* chore: add

* chore: test

* feat: add

* feat: add store

* feat: add selection settings

* chore: remove unused code

* docs: add release note

* docs: add release note

* chore: format code

* chore: format code

* fix: copy error

* disable hashbrown default feature

* Enable unstable feature allocator_api

To make coco-app compile in CI:

```
--> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.15.5/src/raw/mod.rs:3856:12
     |
3856 | impl<T, A: Allocator> RawIntoIter<T, A> {
     |            ^^^^^^^^^
     |
     = note: see issue #32838 <https://github.com/rust-lang/rust/issues/32838> for more information
     = help: add `#![feature(allocator_api)]` to the crate attributes to enable
     = note: this compiler was built on 2025-06-25; consider upgrading it if it is out of date
```

I don't know why it does not compile, feature `allocator-api2` is
enabled for `hashbrown 0.15.5`, so technically [1] it should not use the
allocator APIs from the std. According to [2], enabling the `nightly`
feature of `allocator-api2` may cause this issue as well, but it is not
enabled in our case either.

Anyway, enabling `#![feature(allocator_api)]` should make it work.

[1]: b751eef8e9/src/raw/alloc.rs (L26-L47)
[2]: https://github.com/rust-lang/hashbrown/issues/564

* put it in main.rs

* format main.rs

* Enable default-features for hashbrown 0.15.5

* format main.rs

* enable feature allocator-api2

* feat: add selection set config

* fix: selection setting

* fix: ci error

* fix: ci error

* fix: ci error

* fix: ci error

* merge: merge main

* fix: rust code warn

* fix: rust code error

* fix: rust code error

* fix: selection settings

* style: selection styles

* style: selection styles

* feat: selection settings add & delete

* feat: selection settings add & delete

* feat: selection settings add & delete

* style: selection styles

* chore: add @tauri-store/zustand plugin

* refactor: the selection store using @tauri-store/zustand

* fix: data error

* fix: data error

* chore: remove config

* chore: selection

* chore: selection

* chore: width

* chore: ignore selection in the app itself

* style: selection styles

* style: remove

* docs: add notes

* chore: add permission check

* chore: selection

* chore: style & store

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-12-05 15:32:57 +08:00

672 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Event payload sent to the frontend when selection is detected.
/// Coordinates use logical (Quartz) points with a top-left origin.
/// Note: `y` is flipped on the backend to match the frontends usage.
use tauri::Emitter;
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
text: String,
x: i32,
y: i32,
}
use once_cell::sync::Lazy;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
/// Global toggle: selection monitoring enabled by default.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
/// Ensure we only start the monitor thread once. Allows delayed start after
/// Accessibility permission is granted post-launch.
static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false);
/// Guard to avoid spawning multiple permission watcher threads.
#[cfg(target_os = "macos")]
static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
/// Session flags for controlling macOS Accessibility prompts.
#[cfg(target_os = "macos")]
static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
static LAST_ACCESSIBILITY_PROMPT: Lazy<Mutex<Option<std::time::Instant>>> =
Lazy::new(|| Mutex::new(None));
#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
enabled: bool,
}
#[derive(serde::Serialize, Clone)]
struct SelectionPermissionInfo {
bundle_id: String,
exe_path: String,
in_applications: bool,
is_dmg: bool,
is_dev_guess: bool,
}
/// Read the current selection monitoring state.
pub fn is_selection_enabled() -> bool {
SELECTION_ENABLED.load(Ordering::Relaxed)
}
/// Update the monitoring state and broadcast to the frontend.
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "selection monitoring toggled: enabled={}", enabled);
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
}
/// Tauri command: set selection monitoring state.
#[tauri::command]
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
set_selection_enabled_internal(&app_handle, enabled);
// When enabling selection monitoring on macOS, ensure Accessibility permission.
// If not granted, trigger system prompt and deep-link to the right settings pane,
// and notify frontend to guide the user.
#[cfg(target_os = "macos")]
{
if enabled {
let trusted = ensure_accessibility_permission(&app_handle);
// If permission is now trusted and the monitor hasn't started yet,
// start it immediately to avoid requiring an app restart.
if trusted && !MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
log::info!(target: "coco_lib::selection_monitor", "set_selection_enabled: permission trusted; starting monitor thread");
start_selection_monitor(app_handle.clone());
return;
}
}
}
}
/// Tauri command: get selection monitoring state.
#[tauri::command]
pub fn get_selection_enabled() -> bool {
is_selection_enabled()
}
#[cfg(target_os = "macos")]
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
// Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread.
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: entrypoint");
use std::time::Duration;
use tauri::Emitter;
// Sync initial enabled state to the frontend on startup.
set_selection_enabled_internal(&app_handle, is_selection_enabled());
// Accessibility permission is required to read selected text in the foreground app.
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
#[cfg(target_os = "macos")]
{
// If already started, don't start twice.
if MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: already started; skipping");
return;
}
if !ensure_accessibility_permission(&app_handle) {
log::warn!(target: "coco_lib::selection_monitor", "start_selection_monitor: accessibility not granted; deferring watcher start");
// Spawn a short-lived permission watcher to auto-start once the user grants.
if !PERMISSION_WATCHER_STARTED.swap(true, Ordering::Relaxed) {
let app_handle_clone = app_handle.clone();
std::thread::Builder::new()
.name("selection-permission-watcher".into())
.spawn(move || {
use std::time::Duration;
// Persistent polling with gentle backoff: checks every 2s for ~1 minute,
// then every 10s thereafter, until either trusted or selection disabled.
let mut checks: u32 = 0;
loop {
// If user disabled selection in between, stop early.
if !is_selection_enabled() {
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: selection disabled; stop polling");
break;
}
// Fast trust check without prompt.
if macos_accessibility_client::accessibility::application_is_trusted() {
log::info!(target: "coco_lib::selection_monitor", "permission watcher: accessibility granted; starting monitor");
// Reset watcher flag before starting monitor to allow future retries if needed.
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
start_selection_monitor(app_handle_clone.clone());
return;
}
// Backoff strategy.
checks += 1;
let sleep_secs = if checks <= 30 { 2 } else { 10 }; // ~1 min fast, then slower
if checks % 30 == 0 {
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: still not granted; continuing to poll (checks={})", checks);
}
std::thread::sleep(Duration::from_secs(sleep_secs));
}
// Done polling without success; allow future attempts.
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: stopped (no grant)");
})
.unwrap_or_else(|e| {
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
// Fail fast here: spawning a watcher thread is critical for deferred start.
panic!(
"permission watcher: failed to spawn: {}",
e
);
});
} else {
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: permission watcher already running; skip spawning");
}
return;
}
}
#[cfg(not(target_os = "macos"))]
{
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: non-macos platform, no selection monitor");
}
// Background thread: drives popup show/hide based on mouse and AX selection state.
MONITOR_THREAD_STARTED.store(true, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: starting watcher thread");
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
use objc2_app_kit::NSWorkspace;
use objc2_core_graphics::CGEvent;
use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
// Get current mouse position (logical top-left origin), flipping `y` to match frontend usage.
let current_mouse_point_global = || -> (i32, i32) {
unsafe {
let event = CGEvent::new(None);
let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
// Enumerate active displays to compute global bounds and pick the display containing the cursor.
let mut displays: [u32; 16] = [0; 16];
let mut display_count: u32 = 0;
let _ = CGGetActiveDisplayList(
displays.len() as u32,
displays.as_mut_ptr(),
&mut display_count,
);
if display_count == 0 {
// Fallback to main display.
let did = CGMainDisplayID();
let b = CGDisplayBounds(did);
let min_x_pt = b.origin.x as f64;
let max_top_pt = (b.origin.y + b.size.height) as f64;
let min_bottom_pt = b.origin.y as f64;
let total_h_pt = max_top_pt - min_bottom_pt;
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
return (x_top_left, y_flipped);
}
let mut _chosen = CGMainDisplayID(); // default fallback
// log::info!(
// "current_mouse: pt=({:.1},{:.1}) → display={}",
// pt.x as f64,
// pt.y as f64,
// chosen
// );
let mut min_x_pt = f64::INFINITY;
let mut max_top_pt = f64::NEG_INFINITY;
let mut min_bottom_pt = f64::INFINITY;
for i in 0..display_count as usize {
let did = displays[i];
let b = CGDisplayBounds(did);
if (b.origin.x as f64) < min_x_pt {
min_x_pt = b.origin.x as f64;
}
let top = (b.origin.y + b.size.height) as f64;
if top > max_top_pt {
max_top_pt = top;
}
if (b.origin.y as f64) < min_bottom_pt {
min_bottom_pt = b.origin.y as f64;
}
let in_x = pt.x >= b.origin.x && pt.x <= b.origin.x + b.size.width;
let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height;
if in_x && in_y {
_chosen = did;
// log::info!(
// "current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
// pt.x as f64,
// pt.y as f64,
// chosen,
// b.origin.x,
// b.origin.y
// );
}
}
let total_h_pt = max_top_pt - min_bottom_pt;
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
(x_top_left, y_flipped)
}
};
// Determine whether the frontmost app is this process (Coco).
// Avoid misinterpreting empty selection when interacting with the popup itself.
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
};
// Selection-driven state machine.
let mut popup_visible = false;
let mut last_text = String::new();
// Stability and hide thresholds (tunable).
let stable_threshold = 2; // same content ≥2 times → stable selection
let empty_threshold = 2; // empty value ≥2 times → stable empty
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 disabled: do not read AX / do not show popup; hide if currently visible.
if !is_selection_enabled() {
if popup_visible {
let _ = app_handle.emit("selection-detected", "");
popup_visible = false;
last_text.clear();
stable_text.clear();
}
continue;
}
// Skip empty-selection hide checks while interacting with the Coco popup.
// Robust check: treat as "self" if either the frontmost app or the
// system-wide focused element belongs to this process.
let front_is_me = is_frontmost_app_me() || is_focused_element_me();
// When Coco is frontmost, disable detection but do NOT hide the popup.
// Users may be clicking the popup; we must keep it visible.
if front_is_me {
// Reset counters to avoid stale state on re-entry.
stable_count = 0;
empty_count = 0;
continue;
}
// Lightweight retries to smooth out transient AX focus instability.
let selected_text = {
// Up to 2 retries, 35ms apart.
read_selected_text_with_retries(2, 35)
};
match selected_text {
Some(text) if !text.is_empty() => {
empty_count = 0;
if text == stable_text {
stable_count += 1;
} else {
stable_text = text.clone();
stable_count = 1;
}
// Update/show only when selection is stable to avoid flicker.
if stable_count >= stable_threshold {
if !popup_visible || text != last_text {
// Second guard: do not emit when Coco is frontmost
// or the system-wide focused element belongs to Coco.
// Keep popup as-is to allow user interaction.
if is_frontmost_app_me() || is_focused_element_me() {
stable_count = 0;
empty_count = 0;
continue;
}
let (x, y) = current_mouse_point_global();
let payload = SelectionEventPayload {
text: text.clone(),
x,
y,
};
let _ = app_handle.emit("selection-detected", payload);
// Log selection state change once per stable update to avoid flooding.
let snippet: String = stable_text.chars().take(120).collect();
log::info!(target: "coco_lib::selection_monitor", "selection stable; showing popup (len={}, snippet=\"{}\")", stable_text.len(), snippet.replace('\n', "\\n"));
last_text = text;
popup_visible = true;
}
}
}
_ => {
// If not Coco in front and selection is empty: accumulate empties, then hide.
if !front_is_me {
stable_count = 0;
empty_count += 1;
if popup_visible && empty_count >= empty_threshold {
let _ = app_handle.emit("selection-detected", "");
log::info!(target: "coco_lib::selection_monitor", "selection empty; hiding popup");
popup_visible = false;
last_text.clear();
stable_text.clear();
}
} else {
// When Coco is frontmost: do not hide or clear state during interaction.
}
}
}
}
});
}
/// Ensure macOS Accessibility permission with double-checking and session throttling.
/// Returns true when trusted; otherwise triggers prompt/settings link and emits
/// `selection-permission-required` to the frontend, then returns false.
#[cfg(target_os = "macos")]
fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool {
use std::time::{Duration, Instant};
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: begin");
// First check — fast path.
let trusted = macos_accessibility_client::accessibility::application_is_trusted();
if trusted {
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (fast path)");
return true;
}
// If we've seen trust earlier in this session, transient false may occur.
// Re-check after a short delay to avoid spurious prompts.
if SEEN_ACCESSIBILITY_TRUSTED_ONCE.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(150));
if macos_accessibility_client::accessibility::application_is_trusted() {
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (after transient recheck)");
return true;
}
}
// Throttle system prompt to at most once per 60s in this session.
let mut last = LAST_ACCESSIBILITY_PROMPT.lock().unwrap();
let now = Instant::now();
let allow_prompt = match *last {
Some(ts) => now.duration_since(ts) > Duration::from_secs(60),
None => true,
};
if allow_prompt {
// Try to trigger the system authorization prompt.
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: triggering system prompt");
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
*last = Some(now);
// Small grace period then re-check.
std::thread::sleep(Duration::from_millis(150));
if macos_accessibility_client::accessibility::application_is_trusted() {
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: user granted accessibility during prompt");
return true;
}
log::warn!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: still not trusted after prompt");
}
// Still not trusted — notify frontend and deep-link to settings.
let _ = app_handle.emit("selection-permission-required", true);
log::debug!(target: "coco_lib::selection_monitor", "selection-permission-required emitted");
// Provide richer context so frontend can give more explicit guidance.
let info = collect_selection_permission_info();
log::info!(target: "coco_lib::selection_monitor", "selection-permission-info: bundle_id={}, exe_path={}, in_applications={}, is_dmg={}, is_dev_guess={}",
info.bundle_id, info.exe_path, info.in_applications, info.is_dmg, info.is_dev_guess);
let _ = app_handle.emit("selection-permission-info", info);
#[allow(unused_must_use)]
{
use std::process::Command;
Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.status();
}
false
}
#[cfg(target_os = "macos")]
fn collect_selection_permission_info() -> SelectionPermissionInfo {
let exe_path = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| String::from("<unknown>"));
let in_applications = exe_path.starts_with("/Applications/");
let is_dmg = exe_path.starts_with("/Volumes/");
let is_dev_guess = exe_path.contains("/target/debug/")
|| exe_path.contains(".cargo")
|| exe_path.contains("/node_modules/");
// Find the nearest *.app directory from the current executable path.
let bundle_id = get_bundle_id_dynamic();
SelectionPermissionInfo {
bundle_id,
exe_path,
in_applications,
is_dmg,
is_dev_guess,
}
}
#[cfg(target_os = "macos")]
fn get_bundle_id_dynamic() -> String {
use std::path::PathBuf;
use std::process::Command;
// Find the nearest *.app directory from the current executable path.
let mut app_dir: Option<PathBuf> = None;
if let Ok(mut p) = std::env::current_exe() {
for _ in 0..8 {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".app") {
app_dir = Some(p.clone());
break;
}
}
if !p.pop() {
break;
}
}
}
if let Some(app) = app_dir {
let info = app.join("Contents").join("Info.plist");
if info.exists() {
// use `defaults read <Info.plist> CFBundleIdentifier` to read Bundle ID
if let Ok(out) = Command::new("defaults")
.arg("read")
.arg(info.to_string_lossy().into_owned())
.arg("CFBundleIdentifier")
.output()
{
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return s;
}
}
}
}
}
// Fallback: use the default bundle ID when in dev mode or Info.plist is not found.
"rs.coco.app".to_string()
}
// macOS-wide accessibility entry point: allows reading system-level focused elements.
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement;
}
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn AXUIElementGetPid(
element: *mut objc2_application_services::AXUIElement,
pid: *mut i32,
) -> objc2_application_services::AXError;
}
#[cfg(target_os = "macos")]
fn is_focused_element_me() -> bool {
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFRetained, CFString, CFType};
use std::ptr::NonNull;
let mut focused_ui_ptr: *const CFType = std::ptr::null();
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
let system_elem = unsafe { AXUIElementCreateSystemWide() };
if system_elem.is_null() {
return false;
}
let system_elem_retained: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) };
let err = unsafe {
system_elem_retained
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success || focused_ui_ptr.is_null() {
return false;
}
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
let mut pid: i32 = -1;
let get_err = unsafe { AXUIElementGetPid(focused_ui_elem, &mut pid as *mut i32) };
if get_err != AXError::Success {
return false;
}
let my_pid = std::process::id() as i32;
pid == my_pid
}
/// Read the selected text of the frontmost application (without using the clipboard).
/// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties.
#[cfg(target_os = "macos")]
fn read_selected_text() -> Option<String> {
use objc2_app_kit::NSWorkspace;
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFRetained, CFString, CFType};
use std::ptr::NonNull;
// Prefer system-wide focused element; if unavailable, fall back to app/window focused element.
let mut focused_ui_ptr: *const CFType = std::ptr::null();
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
// System-wide focused UI element.
let system_elem = unsafe { AXUIElementCreateSystemWide() };
if !system_elem.is_null() {
let system_elem_retained: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) };
let err = unsafe {
system_elem_retained
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success {
focused_ui_ptr = std::ptr::null();
}
}
// Fallback to the frontmost app's focused/window element.
if focused_ui_ptr.is_null() {
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
let frontmost_app = unsafe { workspace.frontmostApplication() }?;
let pid = unsafe { frontmost_app.processIdentifier() };
// Skip if frontmost is Coco (this process).
let my_pid = std::process::id() as i32;
if pid == my_pid {
return None;
}
let app_element = unsafe { AXUIElement::new_application(pid) };
let err = unsafe {
app_element
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success || focused_ui_ptr.is_null() {
// Try `AXFocusedWindow` as a lightweight fallback.
let mut focused_window_ptr: *const CFType = std::ptr::null();
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
let w_err = unsafe {
app_element.copy_attribute_value(
&focused_window_attr,
NonNull::new(&mut focused_window_ptr).unwrap(),
)
};
if w_err != AXError::Success || focused_window_ptr.is_null() {
return None;
}
focused_ui_ptr = focused_window_ptr;
}
}
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
let focused_ui: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(focused_ui_elem).unwrap()) };
// Prefer `AXSelectedText`; otherwise return None (can be extended to read ranges).
let mut selected_text_ptr: *const CFType = std::ptr::null();
let selected_text_attr = CFString::from_static_str("AXSelectedText");
let err = unsafe {
focused_ui.copy_attribute_value(
&selected_text_attr,
NonNull::new(&mut selected_text_ptr).unwrap(),
)
};
if err != AXError::Success || selected_text_ptr.is_null() {
return None;
}
// CFString → Rust String
let selected_cfstr: CFRetained<CFString> = unsafe {
CFRetained::from_raw(NonNull::new(selected_text_ptr.cast::<CFString>().cast_mut()).unwrap())
};
Some(selected_cfstr.to_string())
}
/// Read selected text with lightweight retries to handle transient AX focus instability.
#[cfg(target_os = "macos")]
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
use std::thread;
use std::time::Duration;
for attempt in 0..=retries {
if let Some(text) = read_selected_text() {
if !text.is_empty() {
if attempt > 0 {
// log::info!(
// "read_selected_text: 第{}次重试成功,获取到选中文本",
// attempt
// );
}
return Some(text);
}
}
if attempt < retries {
thread::sleep(Duration::from_millis(delay_ms));
}
}
None
}