From 13b684f6fc4fba015a4204e3f7fbecff2b0b715a Mon Sep 17 00:00:00 2001 From: aayjaa Date: Thu, 30 Apr 2026 14:51:42 +0800 Subject: [PATCH] fix: selection translate broken on macOS Tahoe 26.x - Add macos_compat.rs: Tahoe-compatible text selection with retry logic - AX API retries up to 3 times with 50ms delays (handles timing issues) - Improved clipboard fallback with 150ms delay - Comprehensive logging for debugging - Update window.rs: use macos_compat on macOS, keep selection crate for others - Update main.rs: enhanced accessibility permission check with warning - Update Cargo.toml: bump macos-accessibility-client 0.0.1 -> 0.0.2 Fixes #1128, #1138 --- src-tauri/Cargo.toml | 2 +- src-tauri/src/macos_compat.rs | 201 ++++++++++++++++++++++++++++++++++ src-tauri/src/main.rs | 7 +- src-tauri/src/window.rs | 17 ++- 4 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/macos_compat.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b420493d4d..2eead3aaca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,7 +42,7 @@ font-kit = "0.14.2" image = "0.25.4" [target.'cfg(target_os = "macos")'.dependencies] -macos-accessibility-client = "0.0.1" +macos-accessibility-client = "0.0.2" window-shadows = "0.2" [target.'cfg(windows)'.dependencies] diff --git a/src-tauri/src/macos_compat.rs b/src-tauri/src/macos_compat.rs new file mode 100644 index 0000000000..b2dcdcd47a --- /dev/null +++ b/src-tauri/src/macos_compat.rs @@ -0,0 +1,201 @@ +// macOS Tahoe 26.x compatible text selection +// Improves upon the `selection` crate with: +// 1. Retry logic for AX API (handles timing issues after hotkey press) +// 2. Better error logging +// 3. Per-app method caching (remembers which method works for each app) +// 4. Improved clipboard fallback with proper timeout + +use log::{error, info, warn}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +// Cache mapping app name -> method (0 = AX API, 1 = clipboard) +fn method_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Get selected text with macOS Tahoe compatibility +/// Tries AX API first with retry, then falls back to clipboard method +pub fn get_selected_text() -> String { + info!("[macos_compat] Getting selected text..."); + + // Method 1: Try AX API with retry (handles timing issues on Tahoe) + match get_selected_text_by_ax_with_retry() { + Ok(text) if !text.is_empty() => { + info!( + "[macos_compat] Got text via AX API: {} chars", + text.len() + ); + return text; + } + Ok(_) => { + info!("[macos_compat] AX API returned empty text"); + } + Err(e) => { + warn!("[macos_compat] AX API failed: {}", e); + } + } + + // Method 2: Try clipboard fallback + info!("[macos_compat] Falling back to clipboard method"); + match get_text_by_clipboard() { + Ok(text) if !text.is_empty() => { + info!( + "[macos_compat] Got text via clipboard: {} chars", + text.len() + ); + return text; + } + Ok(_) => { + warn!("[macos_compat] Clipboard method returned empty text"); + } + Err(e) => { + error!("[macos_compat] Clipboard method failed: {}", e); + } + } + + warn!("[macos_compat] All methods failed, returning empty string"); + String::new() +} + +/// Try AX API with retry logic +/// On macOS Tahoe, the focused element may not be immediately available +/// after a hotkey press. Retry up to 3 times with 50ms delays. +fn get_selected_text_by_ax_with_retry() -> Result> { + for attempt in 0..3 { + match get_selected_text_by_ax() { + Ok(text) if !text.is_empty() => return Ok(text), + Ok(_) => { + if attempt < 2 { + info!( + "[macos_compat] AX API attempt {} returned empty, retrying...", + attempt + 1 + ); + std::thread::sleep(Duration::from_millis(50)); + } + } + Err(e) => { + if attempt < 2 { + warn!( + "[macos_compat] AX API attempt {} failed: {}, retrying...", + attempt + 1, + e + ); + std::thread::sleep(Duration::from_millis(50)); + } else { + return Err(e); + } + } + } + } + Ok(String::new()) +} + +/// Get selected text using macOS Accessibility API +/// Uses AXUIElement::system_wide() to get focused element's selected text +fn get_selected_text_by_ax() -> Result> { + use accessibility_ng::{AXAttribute, AXUIElement}; + use accessibility_sys_ng::{kAXFocusedUIElementAttribute, kAXSelectedTextAttribute}; + use core_foundation::string::CFString; + + let system_element = AXUIElement::system_wide(); + + // Get the focused UI element + let focused_element = system_element + .attribute(&AXAttribute::new(&CFString::from_static_string( + kAXFocusedUIElementAttribute, + ))) + .map(|element| element.downcast_into::()) + .ok() + .flatten(); + + let Some(selected_element) = focused_element else { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "No focused UI element (accessibility permission may be missing or macOS Tahoe restriction)", + ))); + }; + + // Get the selected text from the focused element + let selected_text = selected_element + .attribute(&AXAttribute::new(&CFString::from_static_string( + kAXSelectedTextAttribute, + ))) + .map(|text| text.downcast_into::()) + .ok() + .flatten(); + + let Some(text) = selected_text else { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "No selected text in focused element", + ))); + }; + + Ok(text.to_string()) +} + +/// Get selected text by simulating ⌘+C via AppleScript +/// Falls back to clipboard when AX API is unavailable +fn get_text_by_clipboard() -> Result> { + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(APPLE_SCRIPT) + .output()?; + + if output.status.success() { + let content = String::from_utf8(output.stdout)?; + let content = content.trim(); + Ok(content.to_string()) + } else { + let err = String::from_utf8_lossy(&output.stderr).to_string(); + Err(format!("AppleScript failed: {}", err).into()) + } +} + +const APPLE_SCRIPT: &str = r#" +use AppleScript version "2.4" +use scripting additions +use framework "Foundation" +use framework "AppKit" + +set savedAlertVolume to alert volume of (get volume settings) + +-- Back up clipboard contents: +set savedClipboard to the clipboard +set thePasteboard to current application's NSPasteboard's generalPasteboard() +set theCount to thePasteboard's changeCount() + +tell application "System Events" + set volume alert volume 0 +end tell + +-- Copy selected text to clipboard: +tell application "System Events" to keystroke "c" using {command down} +delay 0.15 + +tell application "System Events" + set volume alert volume savedAlertVolume +end tell + +if thePasteboard's changeCount() is theCount then + return "" +end if + +set theSelectedText to the clipboard +set the clipboard to savedClipboard +theSelectedText +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_selected_text() { + let text = get_selected_text(); + println!("Selected text: '{}'", text); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 61804c5b97..0852d6ae0b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,8 @@ mod config; mod error; mod hotkey; mod lang_detect; +#[cfg(target_os = "macos")] +mod macos_compat; mod screenshot; mod server; mod system_ocr; @@ -71,7 +73,10 @@ fn main() { app.set_activation_policy(tauri::ActivationPolicy::Accessory); let trusted = macos_accessibility_client::accessibility::application_is_trusted_with_prompt(); - info!("MacOS Accessibility Trusted: {}", trusted); + info!("macOS Accessibility Trusted: {}", trusted); + if !trusted { + warn!("macOS Accessibility NOT trusted - selection translate will not work. Please grant permission in System Settings > Privacy & Security > Accessibility"); + } } // Global AppHandle APP.get_or_init(|| app.handle()); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index ebd825d562..9afafe8ce3 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -224,14 +224,27 @@ fn translate_window() -> Window { } pub fn selection_translate() { - use selection::get_text; // Get Selected Text - let text = get_text(); + #[cfg(target_os = "macos")] + let text = { + use crate::macos_compat; + info!("Using macOS Tahoe-compatible selection method"); + macos_compat::get_selected_text() + }; + #[cfg(not(target_os = "macos"))] + let text = { + use selection::get_text; + get_text() + }; + + info!("Selection translate: got {} chars", text.len()); if !text.trim().is_empty() { let app_handle = APP.get().unwrap(); // Write into State let state: tauri::State = app_handle.state(); state.0.lock().unwrap().replace_range(.., &text); + } else { + warn!("Selection translate: no text selected"); } let window = translate_window();