diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e46a67..6bba8bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,18 @@ jobs: - run: pnpm install --frozen-lockfile + # An unsigned bundle is installable but can never hold an Accessibility + # grant: macOS lists it, accepts the toggle, and still reports the process + # as untrusted. Build and check the signature before anything is published; + # the release step below reuses this cached build. + - name: Build and verify the signed bundle + run: | + pnpm tauri build --target universal-apple-darwin --bundles app + app="src-tauri/target/universal-apple-darwin/release/bundle/macos/Desktop Translator.app" + codesign --verify --verbose=2 "$app" + codesign -dv --verbose=2 "$app" 2>&1 | grep -q '^Signature=' \ + || { echo "bundle is unsigned"; exit 1; } + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -73,6 +85,11 @@ jobs: On first run, grant Accessibility permission when prompted, then add your own Google Cloud Translation API key in Settings. + The app is ad-hoc signed, so its code identity changes with every + release. After installing an update, macOS drops the old grant: + remove the stale entry in Privacy & Security → Accessibility and + add the new one. + ## Platform support macOS only. The Windows adapters are written but have not been diff --git a/.gitignore b/.gitignore index 7c00de3..1e0cd32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .work-bundle/ +.codegraph/ AGENTS.md roles/ node_modules/ diff --git a/README.md b/README.md index 7ede815..c7e8cbe 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,16 @@ Two things are needed before the first translation. **1. Grant Accessibility permission.** The app reads the selected text and its on-screen position through the macOS Accessibility API. On first launch it will point you at *System Settings → Privacy & Security → Accessibility*; enable -Desktop Translator there. +Desktop Translator there, then **quit the app from the menu bar and open it +again**. macOS does not apply a new Accessibility grant to a process that is +already running, which is why the warning can remain after the switch is on. + +> [!IMPORTANT] +> Releases are ad-hoc signed, so the code identity changes with every version. +> macOS ties an Accessibility grant to that identity, which means an update +> silently invalidates the old grant even though the switch still looks enabled. +> After updating, remove the stale Desktop Translator entry from the +> Accessibility list and add the new app again. > [!NOTE] > No screen capture is involved and no Screen Recording permission is requested. diff --git a/package.json b/package.json index ffdd33d..afd5a83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "desktop-translator", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1dc7648..9a30b43 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -612,6 +612,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -635,7 +645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -648,7 +658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -851,7 +861,7 @@ dependencies = [ [[package]] name = "desktop-translator" -version = "0.1.0" +version = "0.1.1" dependencies = [ "apple-native-keyring-store", "async-trait", @@ -1765,9 +1775,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3349,7 +3361,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -3489,7 +3501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3923,6 +3935,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3944,7 +3977,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -5211,6 +5244,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 50a5889..b802fae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-translator" -version = "0.1.0" +version = "0.1.1" description = "Lightweight cross-platform selection translator" authors = ["Desktop Translator Contributors"] edition = "2021" @@ -17,7 +17,11 @@ async-trait = "0.1.92" crossbeam-channel = "0.5.16" html-escape = "0.2.15" keyring = { version = "4.1.6", default-features = false, features = ["apple-native-keyring-store", "v1", "windows-native-keyring-store"] } -reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } +# `system-proxy` and `socks` are part of reqwest's defaults and must be kept +# explicitly: without them the client only honours HTTP_PROXY-style variables, +# which a GUI app launched from Finder never inherits. Users who reach Google +# only through a local proxy would see every request time out. +reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "socks", "system-proxy"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" tauri = { version = "2.11.5", features = ["image-png", "macos-private-api", "tray-icon"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ffdda73..4594324 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,8 +13,8 @@ use zeroize::Zeroize; use crate::{ contracts::{ - AppError, AppErrorCode, TranslationRequest, TranslationResult, UserSettings, - ValidateContract, + AppError, AppErrorCode, SelectionSnapshot, TranslationRequest, TranslationResult, + UserSettings, ValidateContract, }, coordinator::{CoordinatorEvent, OverlayState}, platform::{ @@ -30,13 +30,15 @@ use crate::{ }; #[cfg(target_os = "macos")] -use crate::platform::macos::{ - AccessibilityPermission, DisplayTransform, MacSelectionAdapter, MacSpeechAdapter, -}; +use crate::platform::macos::{AccessibilityPermission, MacSelectionAdapter, MacSpeechAdapter}; #[cfg(target_os = "windows")] use crate::platform::windows::{WindowsSelectionAdapter, WindowsSpeechAdapter}; const SELECTION_SETTLE_DELAY: Duration = Duration::from_millis(25); +/// A surface woken at pointer-down may still be publishing its accessibility +/// tree when the gesture ends, so one late retry follows an empty first read. +const SELECTION_RETRY_DELAY: Duration = Duration::from_millis(150); +const SELECTION_ATTEMPTS: usize = 2; const APPLICATION_ID: &str = "com.desktoptranslator.desktop"; /// Separates persisted user intent from permission-gated runtime monitoring. @@ -63,6 +65,13 @@ impl MonitoringDecision { self.preferred_enabled && self.permission_granted } + /// Whether monitoring should start or stop after a permission re-check. + /// `None` means the running state already matches. + pub(crate) fn desired_monitor_change(self, currently_enabled: bool) -> Option { + let desired = self.effective_enabled(); + (desired != currently_enabled).then_some(desired) + } + #[cfg(test)] pub(crate) fn tray_toggle_request(self) -> bool { !self.effective_enabled() @@ -125,9 +134,32 @@ impl ApplicationCoordinator { return Ok(()); } self.reduce(CoordinatorEvent::PointerDown); + self.selection.prepare_source().await; self.overlay.hide().await } + /// Reads the selection, retrying once so a surface that was still waking at + /// pointer-up is not reported as having no selection. + async fn resolve_selection( + &self, + policy: &SelectionPolicy, + request_id: u64, + ) -> Option { + for attempt in 1..=SELECTION_ATTEMPTS { + if !self.request_is_current(request_id) { + return None; + } + match self.selection.resolve_selection(policy).await { + Ok(selection) => return Some(selection), + Err(_) if attempt < SELECTION_ATTEMPTS => { + tokio::time::sleep(SELECTION_RETRY_DELAY).await; + } + Err(_) => return None, + } + } + None + } + /// Resolves the focused selection after a short settle delay without polling. pub async fn pointer_up(&self) -> Result<(), AppError> { if !self.enabled.load(Ordering::Acquire) { @@ -140,8 +172,8 @@ impl ApplicationCoordinator { return Ok(()); } let policy = self.policy.lock().expect("selection policy").clone(); - match self.selection.resolve_selection(&policy).await { - Ok(selection) => { + match self.resolve_selection(&policy, request_id).await { + Some(selection) => { let should_show = { let mut state = self.state.lock().expect("coordinator state"); let next = state.clone().reduce(CoordinatorEvent::SelectionResolved { @@ -162,7 +194,7 @@ impl ApplicationCoordinator { self.overlay.show_button(&selection).await?; } } - Err(_) => { + None => { self.reduce(CoordinatorEvent::SelectionRejected { request_id }); } } @@ -343,7 +375,7 @@ impl RuntimeState { #[cfg(target_os = "macos")] let selection: Arc = - Arc::new(MacSelectionAdapter::new(display_transforms(app)?)); + Arc::new(MacSelectionAdapter::with_live_displays()); #[cfg(target_os = "windows")] let selection: Arc = Arc::new(WindowsSelectionAdapter::new()?); let loaded_settings = settings.load()?; @@ -379,6 +411,11 @@ impl RuntimeState { self.coordinator.clone() } + #[cfg(target_os = "macos")] + pub(crate) fn overlay_controller(&self) -> Arc { + self.overlay.clone() + } + /// Reports effective monitoring independently from persisted preference. pub fn monitoring_enabled(&self) -> bool { self.coordinator.is_enabled() @@ -575,21 +612,47 @@ pub fn open_accessibility_settings() -> Result<(), AppError> { .map_err(|_| internal_error("System accessibility settings could not be opened")) } +fn permission_status_label() -> &'static str { + if platform_permission_granted() { + "granted" + } else { + "denied" + } +} + /// Reports platform permission without triggering a prompt. #[tauri::command] pub fn get_permission_status() -> &'static str { - #[cfg(target_os = "macos")] - { - match MacSelectionAdapter::permission_status() { - crate::platform::macos::AccessibilityPermission::Granted => "granted", - crate::platform::macos::AccessibilityPermission::Denied => "denied", + permission_status_label() +} + +/// Re-reads Accessibility permission and starts or stops monitoring to match. +/// +/// macOS does not always apply a newly granted toggle to a process that is +/// already running. Callers should still quit and relaunch after a first-time +/// grant; this path covers the cases where the kernel does update in place, +/// and recovers monitoring after Settings is reopened. +#[tauri::command] +pub async fn sync_permission( + app: AppHandle, + state: State<'_, RuntimeState>, +) -> Result<&'static str, AppError> { + let granted = platform_permission_granted(); + let preferred = state.settings.load()?.enabled; + let decision = MonitoringDecision::new(preferred, granted); + match decision.desired_monitor_change(state.coordinator.is_enabled()) { + Some(true) => { + crate::start_global_monitor(&app)?; + state.coordinator.set_enabled(true).await?; } + Some(false) => { + let disabled = state.coordinator.set_enabled(false).await; + state.stop_monitor(); + disabled?; + } + None => {} } - #[cfg(target_os = "windows")] - { - let _ = std::any::TypeId::of::(); - "granted" - } + Ok(permission_status_label()) } /// Quits through Tauri so managed windows and state are dropped. @@ -686,27 +749,3 @@ fn platform_permission_granted() -> bool { true } } - -#[cfg(target_os = "macos")] -fn display_transforms(app: &AppHandle) -> Result, AppError> { - let monitors = app - .available_monitors() - .map_err(|_| internal_error("Monitor topology is unavailable"))?; - Ok(monitors - .into_iter() - .map(|monitor| { - let scale = monitor.scale_factor(); - DisplayTransform { - logical_bounds: crate::contracts::PhysicalRect { - x: monitor.position().x as f64 / scale, - y: monitor.position().y as f64 / scale, - width: monitor.size().width as f64 / scale, - height: monitor.size().height as f64 / scale, - }, - physical_origin_x: monitor.position().x as f64, - physical_origin_y: monitor.position().y as f64, - scale_factor: scale, - } - }) - .collect()) -} diff --git a/src-tauri/src/integration_tests.rs b/src-tauri/src/integration_tests.rs index 9af4bcd..42602a7 100644 --- a/src-tauri/src/integration_tests.rs +++ b/src-tauri/src/integration_tests.rs @@ -51,6 +51,45 @@ impl SelectionAdapter for FixedSelectionAdapter { } } +/// Stands in for a surface that publishes its accessibility tree lazily: it +/// reports no selection until it has been woken and asked enough times. +struct LazySelectionAdapter { + selection: SelectionSnapshot, + reads_before_selection_appears: usize, + reads: AtomicUsize, + wakes: AtomicUsize, +} + +impl LazySelectionAdapter { + fn new(reads_before_selection_appears: usize) -> Self { + Self { + selection: selection(), + reads_before_selection_appears, + reads: AtomicUsize::new(0), + wakes: AtomicUsize::new(0), + } + } +} + +#[async_trait] +impl SelectionAdapter for LazySelectionAdapter { + async fn resolve_selection(&self, _: &SelectionPolicy) -> Result { + let read = self.reads.fetch_add(1, Ordering::SeqCst); + if read < self.reads_before_selection_appears { + return Err(AppError::new( + AppErrorCode::NoSelection, + "no selection", + false, + )); + } + Ok(self.selection.clone()) + } + + async fn prepare_source(&self) { + self.wakes.fetch_add(1, Ordering::SeqCst); + } +} + #[derive(Default)] struct RecordingOverlay { actions: Mutex>, @@ -155,6 +194,65 @@ fn application_coordinator( ) } +fn coordinator_with_selection( + selection: Arc, + overlay: Arc, +) -> ApplicationCoordinator { + ApplicationCoordinator::new( + selection, + overlay, + Arc::new(CountingProvider { + calls: AtomicUsize::new(0), + }), + Arc::new(RecordingSpeech::default()), + SelectionPolicy { + max_code_points: 5_000, + excluded_application_id: Some("com.desktop-translator.app".into()), + }, + true, + ) +} + +#[tokio::test] +async fn a_press_wakes_the_surface_before_the_selection_is_read() { + let adapter = Arc::new(LazySelectionAdapter::new(0)); + let coordinator = + coordinator_with_selection(adapter.clone(), Arc::new(RecordingOverlay::default())); + + coordinator.pointer_down().await.expect("pointer down"); + + assert_eq!(adapter.wakes.load(Ordering::SeqCst), 1); + assert_eq!(adapter.reads.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn a_surface_that_wakes_late_still_produces_a_button() { + let adapter = Arc::new(LazySelectionAdapter::new(1)); + let overlay = Arc::new(RecordingOverlay::default()); + let coordinator = coordinator_with_selection(adapter.clone(), overlay.clone()); + + coordinator.pointer_down().await.expect("pointer down"); + coordinator.pointer_up().await.expect("pointer up"); + + assert_eq!(adapter.reads.load(Ordering::SeqCst), 2); + assert!( + overlay.actions.lock().expect("actions").contains(&"button"), + "a late-waking surface must still show the button" + ); +} + +#[tokio::test] +async fn a_surface_with_no_selection_is_not_retried_forever() { + let adapter = Arc::new(LazySelectionAdapter::new(usize::MAX)); + let overlay = Arc::new(RecordingOverlay::default()); + let coordinator = coordinator_with_selection(adapter.clone(), overlay.clone()); + + coordinator.pointer_up().await.expect("pointer up"); + + assert_eq!(adapter.reads.load(Ordering::SeqCst), 2); + assert!(!overlay.actions.lock().expect("actions").contains(&"button")); +} + #[tokio::test] async fn coordinator_reports_native_speech_availability() { let coordinator = application_coordinator( @@ -409,6 +507,10 @@ fn denied_startup_preserves_preference_but_tray_retries_enable() { let granted = MonitoringDecision::new(denied.preferred_enabled(), true); assert!(granted.effective_enabled()); + assert_eq!(denied.desired_monitor_change(false), None); + assert_eq!(denied.desired_monitor_change(true), Some(false)); + assert_eq!(granted.desired_monitor_change(false), Some(true)); + assert_eq!(granted.desired_monitor_change(true), None); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2ae4052..5310f7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,7 +28,8 @@ fn builder() -> tauri::Builder { dismiss_overlay, get_credential_status, get_permission_status, get_settings, get_speech_availability, open_accessibility_settings, overlay_ready, prompt_and_save_credential, quit_application, remove_credential, save_settings, speak_text, - stop_speech, test_credential, translate_input, translate_selection, RuntimeState, + stop_speech, sync_permission, test_credential, translate_input, translate_selection, + RuntimeState, }; use tauri_plugin_autostart::MacosLauncher; @@ -53,6 +54,7 @@ fn builder() -> tauri::Builder { overlay_ready, open_accessibility_settings, get_permission_status, + sync_permission, quit_application ]) .setup(|app| { @@ -82,6 +84,7 @@ fn builder() -> tauri::Builder { { let _ = window.hide(); } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] tauri::WindowEvent::ScaleFactorChanged { .. } if window.label() == "overlay" => { let coordinator = window.state::().coordinator(); tauri::async_runtime::spawn(async move { @@ -105,6 +108,7 @@ pub(crate) fn start_global_monitor(app: &tauri::AppHandle) -> Result<(), AppErro use crate::platform::macos::{PrimaryMouseEvent, PrimaryMouseObserver}; let app_handle = app.clone(); + let overlay = state.overlay_controller(); let observer = PrimaryMouseObserver::start().map_err(|_| { state.release_monitor_start(); AppError::new( @@ -120,18 +124,24 @@ pub(crate) fn start_global_monitor(app: &tauri::AppHandle) -> Result<(), AppErro let mut routing = PrimaryGestureRouting::default(); while let Ok(event) = observer.recv() { let should_forward = match event { - PrimaryMouseEvent::Pressed => routing.should_forward_press( - crate::overlay::cursor_is_over_overlay(&app_handle), + PrimaryMouseEvent::Pressed { position } => routing.should_forward_press( + crate::overlay::cursor_is_over_overlay(&app_handle, position), ), - PrimaryMouseEvent::Released => routing.should_forward_release(), + PrimaryMouseEvent::Released { position } => { + let should_forward = routing.should_forward_release(); + if should_forward { + overlay.record_selection_release(position); + } + should_forward + } }; if !should_forward { continue; } let result = tauri::async_runtime::block_on(async { match event { - PrimaryMouseEvent::Pressed => coordinator.pointer_down().await, - PrimaryMouseEvent::Released => coordinator.pointer_up().await, + PrimaryMouseEvent::Pressed { .. } => coordinator.pointer_down().await, + PrimaryMouseEvent::Released { .. } => coordinator.pointer_up().await, } }); if result.is_err() { diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index bcccf6c..42f16f0 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -6,15 +6,20 @@ use std::{ sync::{Arc, Mutex}, time::Duration, }; -use tauri::{ - AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, - WebviewWindowBuilder, -}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +#[cfg(target_os = "macos")] +use tauri::{LogicalPosition, LogicalSize}; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +use tauri::{PhysicalPosition, PhysicalSize}; use tokio::sync::oneshot; +#[cfg(not(target_os = "macos"))] +use crate::placement::place_overlay_on_monitors; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +use crate::placement::MonitorWorkArea; use crate::{ contracts::{AppError, AppErrorCode, PhysicalRect, SelectionSnapshot, TranslationResult}, - placement::{place_overlay_on_monitors, MonitorWorkArea, PhysicalSize as OverlaySize}, + placement::PhysicalSize as OverlaySize, platform::OverlayController, }; @@ -95,6 +100,8 @@ pub struct TauriOverlayController { session: Arc>, idle_cancel: Arc>>>, operation: Arc>, + #[cfg(target_os = "macos")] + macos_anchor_logical: Arc>>, } impl TauriOverlayController { @@ -105,6 +112,8 @@ impl TauriOverlayController { session: Arc::new(Mutex::new(OverlaySession::default())), idle_cancel: Arc::new(Mutex::new(None)), operation: Arc::new(Mutex::new(())), + #[cfg(target_os = "macos")] + macos_anchor_logical: Arc::new(Mutex::new(None)), } } @@ -112,6 +121,21 @@ impl TauriOverlayController { ensure_overlay(&self.app) } + #[cfg(target_os = "macos")] + pub(crate) fn record_selection_release(&self, position: crate::placement::PhysicalPoint) { + if position.x.is_finite() && position.y.is_finite() { + *self + .macos_anchor_logical + .lock() + .expect("macOS overlay anchor") = Some(PhysicalRect { + x: position.x, + y: position.y, + width: 1.0, + height: 1.0, + }); + } + } + /// Flushes the first buffered selection after the renderer listener exists. pub fn renderer_ready(&self) -> Result<(), AppError> { let _operation = self.operation.lock().expect("overlay operation"); @@ -128,14 +152,24 @@ impl TauriOverlayController { /// Grows the trigger-sized window to the card footprint before content is /// emitted. Missing windows are ignored so a dismissed overlay stays hidden. - fn expand_to_card(&self, selection: &SelectionSnapshot) -> Result<(), AppError> { + fn expand_to_card(&self, _selection: &SelectionSnapshot) -> Result<(), AppError> { let _operation = self.operation.lock().expect("overlay operation"); let Some(window) = self.app.get_webview_window(OVERLAY_LABEL) else { return Ok(()); }; + #[cfg(target_os = "macos")] + let anchor = self + .macos_anchor_logical + .lock() + .expect("macOS overlay anchor") + .as_ref() + .copied() + .ok_or_else(|| overlay_error("Overlay anchor is unavailable"))?; + #[cfg(not(target_os = "macos"))] + let anchor = _selection.anchor_physical_px; position_overlay( &window, - selection.anchor_physical_px, + anchor, OverlaySize { width: OVERLAY_WIDTH, height: OVERLAY_HEIGHT, @@ -187,9 +221,19 @@ impl OverlayController for TauriOverlayController { let _operation = self.operation.lock().expect("overlay operation"); self.cancel_idle_destruction(); let window = self.window()?; + #[cfg(target_os = "macos")] + let anchor = self + .macos_anchor_logical + .lock() + .expect("macOS overlay anchor") + .as_ref() + .copied() + .ok_or_else(|| overlay_error("Selection release position is unavailable"))?; + #[cfg(not(target_os = "macos"))] + let anchor = selection.anchor_physical_px; position_overlay( &window, - selection.anchor_physical_px, + anchor, OverlaySize { width: TRIGGER_WIDTH, height: TRIGGER_HEIGHT, @@ -270,6 +314,43 @@ pub fn hide_overlay(app: &AppHandle) -> Result<(), AppError> { } /// Reports whether the current pointer is inside the visible contextual surface. +#[cfg(target_os = "macos")] +pub(crate) fn cursor_is_over_overlay( + app: &AppHandle, + cursor: crate::placement::PhysicalPoint, +) -> bool { + let Some(window) = app.get_webview_window(OVERLAY_LABEL) else { + return false; + }; + if window.is_visible().ok() != Some(true) { + return false; + } + let Ok(origin) = window.outer_position() else { + return false; + }; + let Ok(size) = window.outer_size() else { + return false; + }; + let Ok(scale_factor) = window.scale_factor() else { + return false; + }; + let Some(bounds) = crate::platform::macos::window::logical_window_bounds( + crate::placement::PhysicalPoint { + x: origin.x as f64, + y: origin.y as f64, + }, + OverlaySize { + width: size.width as f64, + height: size.height as f64, + }, + scale_factor, + ) else { + return false; + }; + crate::platform::macos::window::point_is_inside_overlay(cursor, bounds) +} + +#[cfg(not(target_os = "macos"))] pub(crate) fn cursor_is_over_overlay(app: &AppHandle) -> bool { let Some(window) = app.get_webview_window(OVERLAY_LABEL) else { return false; @@ -286,7 +367,6 @@ pub(crate) fn cursor_is_over_overlay(app: &AppHandle) -> bool { let Ok(cursor) = app.cursor_position() else { return false; }; - cursor.x >= origin.x as f64 && cursor.x < origin.x as f64 + size.width as f64 && cursor.y >= origin.y as f64 @@ -321,6 +401,64 @@ fn ensure_overlay(app: &AppHandle) -> Result { Ok(window) } +#[cfg(target_os = "windows")] +fn position_overlay( + window: &WebviewWindow, + anchor: PhysicalRect, + logical_size: OverlaySize, +) -> Result<(), AppError> { + use crate::platform::windows::window::{ + monitor_work_area_for, position_non_activating_tool_window, + }; + + let work_area = monitor_work_area_for(anchor)?; + let placement = place_overlay_on_monitors(anchor, &[work_area], logical_size, OVERLAY_GAP) + .ok_or_else(|| overlay_error("Overlay position could not be resolved"))?; + let hwnd = window + .hwnd() + .map_err(|_| overlay_error("Native overlay handle is unavailable"))?; + position_non_activating_tool_window(hwnd, &placement) +} + +#[cfg(target_os = "macos")] +fn position_overlay( + window: &WebviewWindow, + anchor_logical: PhysicalRect, + logical_size: OverlaySize, +) -> Result<(), AppError> { + use crate::platform::macos::{active_display_transforms, MacScreenGeometry}; + + let screens: Vec<_> = active_display_transforms() + .into_iter() + .enumerate() + .map(|(index, display)| MacScreenGeometry { + id: format!("display-{index}"), + logical_bounds: display.logical_bounds, + }) + .collect(); + let placement = crate::platform::macos::window::place_overlay_in_screen_points( + anchor_logical, + &screens, + logical_size, + OVERLAY_GAP, + ) + .ok_or_else(|| overlay_error("Overlay position could not be resolved"))?; + + window + .set_size(LogicalSize::new( + placement.size_logical_points.width, + placement.size_logical_points.height, + )) + .and_then(|_| { + window.set_position(LogicalPosition::new( + placement.position_logical_points.x, + placement.position_logical_points.y, + )) + }) + .map_err(|_| overlay_error("Overlay position could not be applied")) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] fn position_overlay( window: &WebviewWindow, anchor: PhysicalRect, diff --git a/src-tauri/src/platform/macos/input.rs b/src-tauri/src/platform/macos/input.rs index d7ac1c3..59e950a 100644 --- a/src-tauri/src/platform/macos/input.rs +++ b/src-tauri/src/platform/macos/input.rs @@ -11,6 +11,8 @@ use std::{ thread::{self, JoinHandle}, }; +use crate::placement::PhysicalPoint; + type CGEventTapProxy = *mut c_void; type CGEventRef = *mut c_void; type CFMachPortRef = *mut c_void; @@ -61,10 +63,10 @@ unsafe extern "C" { fn CFRunLoopStop(run_loop: CFRunLoopRef); } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum PrimaryMouseEvent { - Pressed, - Released, + Pressed { position: PhysicalPoint }, + Released { position: PhysicalPoint }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -76,11 +78,11 @@ impl PrimaryGestureState { /// Returns true exactly when a primary press is completed by its release. pub fn observe(&mut self, event: PrimaryMouseEvent) -> bool { match event { - PrimaryMouseEvent::Pressed => { + PrimaryMouseEvent::Pressed { .. } => { self.pressed = true; false } - PrimaryMouseEvent::Released => std::mem::take(&mut self.pressed), + PrimaryMouseEvent::Released { .. } => std::mem::take(&mut self.pressed), } } } @@ -287,9 +289,14 @@ unsafe extern "C" fn event_tap_callback( } return event; } + // SAFETY: Quartz supplies a live event for mouse callbacks. + let Some(position) = (unsafe { super::selection::event_location_logical(event.cast_const()) }) + else { + return event; + }; let observed = match event_type { - LEFT_MOUSE_DOWN => Some(PrimaryMouseEvent::Pressed), - LEFT_MOUSE_UP => Some(PrimaryMouseEvent::Released), + LEFT_MOUSE_DOWN => Some(PrimaryMouseEvent::Pressed { position }), + LEFT_MOUSE_UP => Some(PrimaryMouseEvent::Released { position }), _ => None, }; if let Some(observed) = observed { @@ -329,21 +336,37 @@ mod tests { deliver_event, EventTapContext, ObserverStopState, PrimaryGestureState, PrimaryMouseEvent, }; + fn point(x: f64, y: f64) -> crate::placement::PhysicalPoint { + crate::placement::PhysicalPoint { x, y } + } + #[test] fn completes_only_a_matching_primary_press_and_release() { let mut state = PrimaryGestureState::default(); - assert!(!state.observe(PrimaryMouseEvent::Released)); - assert!(!state.observe(PrimaryMouseEvent::Pressed)); - assert!(state.observe(PrimaryMouseEvent::Released)); - assert!(!state.observe(PrimaryMouseEvent::Released)); + assert!(!state.observe(PrimaryMouseEvent::Released { + position: point(10.0, 10.0), + })); + assert!(!state.observe(PrimaryMouseEvent::Pressed { + position: point(10.0, 10.0), + })); + assert!(state.observe(PrimaryMouseEvent::Released { + position: point(20.0, 20.0), + })); + assert!(!state.observe(PrimaryMouseEvent::Released { + position: point(20.0, 20.0), + })); } #[test] fn independently_completes_double_and_triple_click_cycles() { let mut state = PrimaryGestureState::default(); for _ in 0..3 { - assert!(!state.observe(PrimaryMouseEvent::Pressed)); - assert!(state.observe(PrimaryMouseEvent::Released)); + assert!(!state.observe(PrimaryMouseEvent::Pressed { + position: point(1.0, 1.0), + })); + assert!(state.observe(PrimaryMouseEvent::Released { + position: point(1.0, 1.0), + })); } } @@ -358,10 +381,18 @@ mod tests { stop: Arc::new(ObserverStopState::default()), }; - deliver_event(&context, PrimaryMouseEvent::Pressed); - deliver_event(&context, PrimaryMouseEvent::Released); - - assert_eq!(receiver.try_recv(), Ok(PrimaryMouseEvent::Pressed)); + let pressed = PrimaryMouseEvent::Pressed { + position: point(2200.0, 500.0), + }; + deliver_event(&context, pressed); + deliver_event( + &context, + PrimaryMouseEvent::Released { + position: point(2200.0, 500.0), + }, + ); + + assert_eq!(receiver.try_recv(), Ok(pressed)); assert_eq!(dropped_events.load(std::sync::atomic::Ordering::Relaxed), 1); } @@ -376,12 +407,20 @@ mod tests { stop: stop.clone(), }; - deliver_event(&context, PrimaryMouseEvent::Pressed); + let pressed = PrimaryMouseEvent::Pressed { + position: point(2200.0, 500.0), + }; + deliver_event(&context, pressed); assert!(stop.stop()); assert!(!stop.stop()); - deliver_event(&context, PrimaryMouseEvent::Released); - - assert_eq!(receiver.try_recv(), Ok(PrimaryMouseEvent::Pressed)); + deliver_event( + &context, + PrimaryMouseEvent::Released { + position: point(2200.0, 500.0), + }, + ); + + assert_eq!(receiver.try_recv(), Ok(pressed)); assert!(receiver.try_recv().is_err()); } } diff --git a/src-tauri/src/platform/macos/mod.rs b/src-tauri/src/platform/macos/mod.rs index 7e3e8e2..66a4c24 100644 --- a/src-tauri/src/platform/macos/mod.rs +++ b/src-tauri/src/platform/macos/mod.rs @@ -9,10 +9,11 @@ pub mod window; pub use input::{PrimaryGestureState, PrimaryMouseEvent, PrimaryMouseObserver}; pub use selection::{ - normalize_rect, normalize_rects, AccessibilityPermission, DisplayTransform, MacSelectionAdapter, + active_display_transforms, normalize_rect, normalize_rects, AccessibilityPermission, + DisplayTransform, MacSelectionAdapter, }; pub use speech::MacSpeechAdapter; pub use window::{ configure_nonactivating_panel, hide_panel, order_front_without_activation, MacOverlayWindow, - NonActivatingPanelPolicy, OverlayCommand, + MacScreenGeometry, NonActivatingPanelPolicy, OverlayCommand, }; diff --git a/src-tauri/src/platform/macos/selection.rs b/src-tauri/src/platform/macos/selection.rs index 4e4a90b..d71ac0f 100644 --- a/src-tauri/src/platform/macos/selection.rs +++ b/src-tauri/src/platform/macos/selection.rs @@ -4,6 +4,7 @@ //! is wrapped immediately so ownership cannot leak into the safe adapter. use std::{ + borrow::Cow, ffi::{c_char, c_double, c_float, c_int, c_long, c_void, CStr, CString}, mem, ptr::{self, NonNull}, @@ -18,6 +19,7 @@ use async_trait::async_trait; use crate::{ contracts::{AppError, AppErrorCode, PhysicalRect, SelectionSnapshot}, + placement::PhysicalPoint, platform::{SelectionAdapter, SelectionPolicy}, }; @@ -103,17 +105,39 @@ unsafe extern "C" { element: *mut AXUIElementRef, ) -> AXError; fn AXUIElementGetPid(element: AXUIElementRef, pid: *mut c_int) -> AXError; + fn AXUIElementCreateApplication(pid: c_int) -> AXUIElementRef; + fn AXUIElementSetAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: CFTypeRef, + ) -> AXError; fn AXValueGetType(value: AXValueRef) -> u32; fn AXValueGetValue(value: AXValueRef, value_type: u32, output: *mut c_void) -> Boolean; fn AXValueCreate(value_type: u32, value: *const c_void) -> AXValueRef; } +type CGDirectDisplayId = u32; +type CGDisplayModeRef = *const c_void; + #[link(name = "CoreGraphics", kind = "framework")] unsafe extern "C" { fn CGEventCreate(source: CFTypeRef) -> CFTypeRef; fn CGEventGetLocation(event: CFTypeRef) -> CGPoint; + fn CGGetActiveDisplayList( + max_displays: u32, + displays: *mut CGDirectDisplayId, + count: *mut u32, + ) -> i32; + fn CGDisplayBounds(display: CGDirectDisplayId) -> CGRect; + fn CGDisplayCopyDisplayMode(display: CGDirectDisplayId) -> CGDisplayModeRef; + fn CGDisplayModeGetPixelWidth(mode: CGDisplayModeRef) -> usize; + fn CGDisplayModeGetWidth(mode: CGDisplayModeRef) -> usize; + fn CGDisplayModeRelease(mode: CGDisplayModeRef); } +/// Upper bound on displays queried in one call; far above any real desktop. +const MAX_ACTIVE_DISPLAYS: u32 = 16; + #[link(name = "AppKit", kind = "framework")] unsafe extern "C" {} @@ -173,17 +197,44 @@ pub struct DisplayTransform { /// /// Calls are synchronous native AX queries. Invoke the async trait method from /// a blocking worker, never the UI thread. +/// Where the adapter gets its display topology from. +#[derive(Clone)] +enum DisplaySource { + /// Read at every resolution, so an attached, detached or rescaled display + /// takes effect immediately. + Live, + /// A fixed topology, used by tests and manual fixtures. + Fixed(Arc>), +} + +impl DisplaySource { + fn transforms(&self) -> Cow<'_, [DisplayTransform]> { + match self { + Self::Live => Cow::Owned(active_display_transforms()), + Self::Fixed(fixed) => Cow::Borrowed(fixed.as_slice()), + } + } +} + #[derive(Clone)] pub struct MacSelectionAdapter { next_id: Arc, - displays: Arc>, + displays: DisplaySource, } impl MacSelectionAdapter { + /// Production adapter, which follows the live display topology. + pub fn with_live_displays() -> Self { + Self { + next_id: Arc::new(AtomicU64::new(1)), + displays: DisplaySource::Live, + } + } + pub fn new(displays: Vec) -> Self { Self { next_id: Arc::new(AtomicU64::new(1)), - displays: Arc::new(displays), + displays: DisplaySource::Fixed(Arc::new(displays)), } } @@ -227,13 +278,14 @@ impl MacSelectionAdapter { // SAFETY: create rule returns an owned AXUIElementRef. let system = unsafe { OwnedCf::from_create(AXUIElementCreateSystemWide()) } .ok_or_else(internal_error)?; + let displays = self.displays.transforms(); let focused_selection = focused_element(system.as_raw()) .map_err(|_| CandidateFailure::NoSelection) - .and_then(|element| selection_from_lineage(element, policy, &self.displays)); + .and_then(|element| selection_from_lineage(element, policy, &displays)); let resolved = prefer_selection_candidate(focused_selection, || { let element = element_at_pointer(system.as_raw()).map_err(|_| CandidateFailure::NoSelection)?; - selection_from_lineage(element, policy, &self.displays) + selection_from_lineage(element, policy, &displays) }) .map_err(|failure| match failure { CandidateFailure::NoSelection => no_selection_error(), @@ -258,6 +310,44 @@ impl MacSelectionAdapter { .map_err(|_| internal_error())?, }) } + + /// Best-effort wake of the surface under the pointer. A surface that does + /// not implement these attributes is simply left as it was. + fn wake_source(&self) { + if Self::permission_status() != AccessibilityPermission::Granted { + return; + } + // SAFETY: create rule returns an owned AXUIElementRef. + let Some(system) = (unsafe { OwnedCf::from_create(AXUIElementCreateSystemWide()) }) else { + return; + }; + let Ok(element) = element_at_pointer(system.as_raw()) else { + return; + }; + let mut pid: c_int = 0; + // SAFETY: the element is live and the pid is written only on success. + if unsafe { AXUIElementGetPid(element.as_raw(), &mut pid) } == 0 { + enable_chromium_accessibility(pid); + } + // Reading a selection attribute is what makes a surface that builds its + // tree lazily start building it. + let _ = copy_attribute(element.as_raw(), "AXSelectedText"); + } +} + +fn enable_chromium_accessibility(pid: c_int) { + // SAFETY: create rule returns an owned AXUIElementRef for the process. + let Some(application) = (unsafe { OwnedCf::from_create(AXUIElementCreateApplication(pid)) }) + else { + return; + }; + let Some(attribute) = CfString::new(CHROMIUM_ACCESSIBILITY_ATTRIBUTE) else { + return; + }; + // SAFETY: both references are live and the value is a constant CFBoolean. + let _ = unsafe { + AXUIElementSetAttributeValue(application.as_raw(), attribute.as_raw(), kCFBooleanTrue) + }; } fn selection_from_lineage( @@ -390,8 +480,18 @@ impl SelectionAdapter for MacSelectionAdapter { .await .map_err(|_| internal_error())? } + + async fn prepare_source(&self) { + let adapter = self.clone(); + let _ = tokio::task::spawn_blocking(move || adapter.wake_source()).await; + } } +/// Chromium exposes web content to the accessibility tree only after a client +/// sets this attribute on the application element. Other applications reject it +/// harmlessly. +const CHROMIUM_ACCESSIBILITY_ATTRIBUTE: &str = "AXManualAccessibility"; + pub fn normalize_rect( logical: PhysicalRect, displays: &[DisplayTransform], @@ -420,6 +520,89 @@ pub fn normalize_rect( }) } +/// Reads the current display topology. +/// +/// `CGDisplayBounds` is expressed in the same top-left logical space the +/// accessibility API reports geometry in, and the window layer positions +/// overlays at `logical * scale`, so both agree without conversion. +/// +/// This is read per resolution rather than cached once: displays are attached, +/// detached and rescaled while the application runs, and a topology that has +/// gone stale silently discards every selection made on a display it does not +/// know about. +pub fn active_display_transforms() -> Vec { + let mut ids = [0 as CGDirectDisplayId; MAX_ACTIVE_DISPLAYS as usize]; + let mut count: u32 = 0; + // SAFETY: the buffer holds MAX_ACTIVE_DISPLAYS entries and CoreGraphics + // writes the number it filled into count. + let result = + unsafe { CGGetActiveDisplayList(MAX_ACTIVE_DISPLAYS, ids.as_mut_ptr(), &mut count) }; + if result != 0 { + return Vec::new(); + } + ids.iter() + .take(count as usize) + .filter_map(|id| display_transform(*id)) + .collect() +} + +fn display_transform(id: CGDirectDisplayId) -> Option { + let scale_factor = display_scale_factor(id)?; + // SAFETY: CGDisplayBounds accepts any identifier and returns an empty + // rectangle for one that is no longer active. + let bounds = unsafe { CGDisplayBounds(id) }; + let transform = DisplayTransform { + logical_bounds: PhysicalRect { + x: bounds.origin.x, + y: bounds.origin.y, + width: bounds.size.width, + height: bounds.size.height, + }, + physical_origin_x: bounds.origin.x * scale_factor, + physical_origin_y: bounds.origin.y * scale_factor, + scale_factor, + }; + valid_display(&transform).then_some(transform) +} + +/// Reads one live CGEvent location in Quartz's global logical screen points. +/// +/// # Safety +/// `event` must be a live CGEvent for the duration of this call. +pub(crate) unsafe fn event_location_logical(event: *const c_void) -> Option { + if event.is_null() { + return None; + } + // SAFETY: guaranteed by the caller and CoreGraphics does not retain it. + let location = unsafe { CGEventGetLocation(event) }; + (location.x.is_finite() && location.y.is_finite()).then_some(PhysicalPoint { + x: location.x, + y: location.y, + }) +} + +fn display_scale_factor(id: CGDirectDisplayId) -> Option { + // SAFETY: the copy rule returns an owned mode, released below. + let mode = unsafe { CGDisplayCopyDisplayMode(id) }; + if mode.is_null() { + return None; + } + // SAFETY: the mode stays live until it is released. + let (pixel_width, width) = unsafe { + ( + CGDisplayModeGetPixelWidth(mode), + CGDisplayModeGetWidth(mode), + ) + }; + // SAFETY: ownership returns to CoreGraphics here and the mode is not used again. + unsafe { CGDisplayModeRelease(mode) }; + if width == 0 { + return None; + } + let scale = pixel_width as f64 / width as f64; + (scale.is_finite() && scale > 0.0).then_some(scale) +} + /// Splits a logical AX rectangle at display boundaries before scaling each /// piece into the physical topology. This avoids applying one monitor's scale /// to geometry that crosses onto another monitor. @@ -1224,6 +1407,59 @@ mod tests { })); } + /// The live topology must describe every attached display, place the main + /// one at the origin, and agree with the window layer's `logical * scale` + /// convention. A selection made on a display missing from this list resolves + /// to no geometry at all, which is how an external screen stops working. + #[test] + fn live_topology_describes_every_attached_display() { + let displays = super::active_display_transforms(); + if displays.is_empty() { + // A headless build machine has no display to describe. + return; + } + + assert!(displays + .iter() + .any(|display| { display.logical_bounds.x == 0.0 && display.logical_bounds.y == 0.0 })); + for display in &displays { + assert!(display.scale_factor > 0.0); + assert_eq!( + display.physical_origin_x, + display.logical_bounds.x * display.scale_factor + ); + assert_eq!( + display.physical_origin_y, + display.logical_bounds.y * display.scale_factor + ); + } + } + + /// Guards the defect directly: geometry on a display the adapter does not + /// know about produces nothing, so the topology may never be a stale cache. + #[test] + fn geometry_on_an_unknown_display_resolves_to_nothing() { + let only_builtin = [DisplayTransform { + logical_bounds: PhysicalRect { + x: 0.0, + y: 0.0, + width: 1800.0, + height: 1169.0, + }, + physical_origin_x: 0.0, + physical_origin_y: 0.0, + scale_factor: 2.0, + }]; + let on_a_second_display = PhysicalRect { + x: -800.0, + y: 400.0, + width: 120.0, + height: 20.0, + }; + + assert!(normalize_rects(on_a_second_display, &only_builtin).is_empty()); + } + fn manual_adapter() -> MacSelectionAdapter { MacSelectionAdapter::new(vec![DisplayTransform { logical_bounds: PhysicalRect { diff --git a/src-tauri/src/platform/macos/window.rs b/src-tauri/src/platform/macos/window.rs index f653286..11632e3 100644 --- a/src-tauri/src/platform/macos/window.rs +++ b/src-tauri/src/platform/macos/window.rs @@ -9,7 +9,8 @@ use std::{ use async_trait::async_trait; use crate::{ - contracts::{AppError, SelectionSnapshot, TranslationResult}, + contracts::{AppError, PhysicalRect, SelectionSnapshot, TranslationResult}, + placement::{PhysicalPoint, PhysicalSize}, platform::OverlayController, }; @@ -27,6 +28,129 @@ const IGNORES_CYCLE: NSUInteger = 1 << 6; const FULL_SCREEN_AUXILIARY: NSUInteger = 1 << 8; const STATUS_WINDOW_LEVEL: NSInteger = 25; +/// One macOS display in Quartz's global top-left logical-point space. +#[derive(Debug, Clone, PartialEq)] +pub struct MacScreenGeometry { + pub id: String, + pub logical_bounds: PhysicalRect, +} + +/// Resolved macOS overlay geometry, expressed entirely in logical screen points. +#[derive(Debug, Clone, PartialEq)] +pub struct MacOverlayPlacement { + pub screen_id: String, + pub position_logical_points: PhysicalPoint, + pub size_logical_points: PhysicalSize, +} + +impl MacOverlayPlacement { + pub fn bounds(&self) -> PhysicalRect { + PhysicalRect { + x: self.position_logical_points.x, + y: self.position_logical_points.y, + width: self.size_logical_points.width, + height: self.size_logical_points.height, + } + } +} + +/// Places an overlay without mixing Quartz logical points and backing pixels. +pub fn place_overlay_in_screen_points( + anchor: PhysicalRect, + screens: &[MacScreenGeometry], + logical_size: PhysicalSize, + gap: f64, +) -> Option { + if !valid_rect(anchor) || !valid_size(logical_size) || !gap.is_finite() || gap < 0.0 { + return None; + } + let center_x = anchor.x + anchor.width / 2.0; + let center_y = anchor.y + anchor.height / 2.0; + let screen = + screens + .iter() + .filter(|screen| valid_rect(screen.logical_bounds)) + .min_by(|left, right| { + distance_to_rect(center_x, center_y, left.logical_bounds) + .total_cmp(&distance_to_rect(center_x, center_y, right.logical_bounds)) + })?; + let area = screen.logical_bounds; + let right = area.x + area.width; + let bottom = area.y + area.height; + let mut x = anchor.x + anchor.width + gap; + let mut y = anchor.y + anchor.height + gap; + if x + logical_size.width > right { + x = anchor.x - logical_size.width - gap; + } + if y + logical_size.height > bottom { + y = anchor.y - logical_size.height - gap; + } + x = x.clamp(area.x, (right - logical_size.width).max(area.x)); + y = y.clamp(area.y, (bottom - logical_size.height).max(area.y)); + + Some(MacOverlayPlacement { + screen_id: screen.id.clone(), + position_logical_points: PhysicalPoint { x, y }, + size_logical_points: logical_size, + }) +} + +pub fn point_is_inside_overlay(point: PhysicalPoint, bounds: PhysicalRect) -> bool { + point.x >= bounds.x + && point.x < bounds.x + bounds.width + && point.y >= bounds.y + && point.y < bounds.y + bounds.height +} + +/// Converts Tauri's backing-pixel window bounds back to Quartz logical points +/// using the overlay's current display scale. +pub fn logical_window_bounds( + origin_backing_px: PhysicalPoint, + size_backing_px: PhysicalSize, + scale_factor: f64, +) -> Option { + if !valid_size(size_backing_px) || !scale_factor.is_finite() || scale_factor <= 0.0 { + return None; + } + Some(PhysicalRect { + x: origin_backing_px.x / scale_factor, + y: origin_backing_px.y / scale_factor, + width: size_backing_px.width / scale_factor, + height: size_backing_px.height / scale_factor, + }) +} + +fn valid_rect(rect: PhysicalRect) -> bool { + rect.x.is_finite() + && rect.y.is_finite() + && rect.width.is_finite() + && rect.height.is_finite() + && rect.width > 0.0 + && rect.height > 0.0 +} + +fn valid_size(size: PhysicalSize) -> bool { + size.width.is_finite() && size.height.is_finite() && size.width > 0.0 && size.height > 0.0 +} + +fn distance_to_rect(x: f64, y: f64, rect: PhysicalRect) -> f64 { + let dx = if x < rect.x { + rect.x - x + } else if x > rect.x + rect.width { + x - (rect.x + rect.width) + } else { + 0.0 + }; + let dy = if y < rect.y { + rect.y - y + } else if y > rect.y + rect.height { + y - (rect.y + rect.height) + } else { + 0.0 + }; + dx * dx + dy * dy +} + #[link(name = "AppKit", kind = "framework")] unsafe extern "C" {} @@ -297,10 +421,87 @@ mod tests { }; use super::{ - MacOverlayWindow, NonActivatingPanelPolicy, OverlayCommand, CAN_JOIN_ALL_SPACES, - FULL_SCREEN_AUXILIARY, IGNORES_CYCLE, NONACTIVATING_PANEL_MASK, + logical_window_bounds, place_overlay_in_screen_points, point_is_inside_overlay, + MacOverlayWindow, MacScreenGeometry, NonActivatingPanelPolicy, OverlayCommand, + CAN_JOIN_ALL_SPACES, FULL_SCREEN_AUXILIARY, IGNORES_CYCLE, NONACTIVATING_PANEL_MASK, }; + #[test] + fn mixed_scale_secondary_display_uses_captured_quartz_screen_points() { + // Captured from the user's Mac: the main display is Retina (2x), while + // the secondary display is 1x and begins at Quartz x=1800. + let screens = [ + MacScreenGeometry { + id: "main".into(), + logical_bounds: PhysicalRect { + x: 0.0, + y: 0.0, + width: 1800.0, + height: 1169.0, + }, + }, + MacScreenGeometry { + id: "secondary".into(), + logical_bounds: PhysicalRect { + x: 1800.0, + y: 0.0, + width: 2560.0, + height: 1440.0, + }, + }, + ]; + let release_event = crate::platform::macos::PrimaryMouseEvent::Released { + position: crate::placement::PhysicalPoint { + x: 2200.0, + y: 500.0, + }, + }; + // The pointer may move during Accessibility's settle/retry delay. The + // overlay must use the event's release coordinate, not sample this later. + let pointer_after_resolution_delay = crate::placement::PhysicalPoint { x: 300.0, y: 300.0 }; + let crate::platform::macos::PrimaryMouseEvent::Released { position } = release_event else { + unreachable!() + }; + assert_ne!(position, pointer_after_resolution_delay); + let release_point = PhysicalRect { + x: position.x, + y: position.y, + width: 1.0, + height: 1.0, + }; + + let placement = place_overlay_in_screen_points( + release_point, + &screens, + crate::placement::PhysicalSize { + width: 44.0, + height: 44.0, + }, + 8.0, + ) + .expect("secondary placement"); + + assert_eq!(placement.screen_id, "secondary"); + assert_eq!(placement.size_logical_points.width, 44.0); + assert!(placement.bounds().x >= 1800.0); + let logical_bounds = logical_window_bounds( + placement.position_logical_points, + placement.size_logical_points, + 1.0, + ) + .expect("secondary window bounds"); + let press_event = crate::platform::macos::PrimaryMouseEvent::Pressed { + position: crate::placement::PhysicalPoint { + x: logical_bounds.x + 22.0, + y: logical_bounds.y + 22.0, + }, + }; + let crate::platform::macos::PrimaryMouseEvent::Pressed { position } = press_event else { + unreachable!() + }; + assert!(point_is_inside_overlay(position, logical_bounds)); + } + #[test] fn default_policy_preserves_foreground_application() { let policy = NonActivatingPanelPolicy::default(); diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs index 7935ee7..705d941 100644 --- a/src-tauri/src/platform/mod.rs +++ b/src-tauri/src/platform/mod.rs @@ -151,6 +151,14 @@ pub trait SelectionAdapter: Send + Sync { &self, policy: &SelectionPolicy, ) -> Result; + + /// Asks the surface under the pointer to expose selection data before it is + /// read. Web-backed surfaces build their accessibility tree only once a + /// client asks for it, so the first read after a selection would otherwise + /// come back empty. Called at pointer-down, this gives the surface the whole + /// duration of the drag to answer. Implementations are best-effort and must + /// never fail a gesture. + async fn prepare_source(&self) {} } /// Controls the reusable non-activating contextual surface. diff --git a/src-tauri/src/platform/windows/window.rs b/src-tauri/src/platform/windows/window.rs index 9480476..e7f364e 100644 --- a/src-tauri/src/platform/windows/window.rs +++ b/src-tauri/src/platform/windows/window.rs @@ -21,7 +21,7 @@ use windows::Win32::{ use crate::{ contracts::{AppError, AppErrorCode, PhysicalRect, SelectionSnapshot, TranslationResult}, - placement::{place_overlay_on_monitors, MonitorWorkArea, PhysicalSize}, + placement::{place_overlay_on_monitors, MonitorWorkArea, OverlayPlacement, PhysicalSize}, platform::OverlayController, }; @@ -65,6 +65,39 @@ pub fn apply_non_activating_tool_window(hwnd: HWND) -> Result<(), AppError> { Ok(()) } +/// Moves and resizes the real overlay HWND in global physical screen pixels. +/// +/// Tauri's generic window move path can retain the monitor association used when +/// the WebView was created. Applying the resolved placement to the HWND keeps the +/// visible surface and its native hit-test bounds together across Windows +/// monitors with different origins or scale factors. +pub(crate) fn position_non_activating_tool_window( + hwnd: HWND, + placement: &OverlayPlacement, +) -> Result<(), AppError> { + let x = finite_i32(placement.position_physical_px.x)?; + let y = finite_i32(placement.position_physical_px.y)?; + let width = positive_i32(placement.size_physical_px.width)?; + let height = positive_i32(placement.size_physical_px.height)?; + + // SAFETY: the caller owns a live HWND and the placement contains only + // validated scalar bounds. HWND_TOPMOST keeps the contextual surface above + // ordinary windows while SWP_NOACTIVATE preserves the source selection. + unsafe { + SetWindowPos( + hwnd, + Some(hwnd_topmost()), + x, + y, + width, + height, + SET_WINDOW_POS_FLAGS(SWP_NOACTIVATE.0 | SWP_NOOWNERZORDER.0), + ) + .map_err(|_| internal("could not position Windows overlay"))?; + } + Ok(()) +} + /// Shows a configured contextual surface without changing foreground focus. pub fn show_without_activation(hwnd: HWND) { // SAFETY: ShowWindow uses only the caller-owned HWND and does not retain diff --git a/src-tauri/src/services/translation.rs b/src-tauri/src/services/translation.rs index aab8296..f212554 100644 --- a/src-tauri/src/services/translation.rs +++ b/src-tauri/src/services/translation.rs @@ -33,13 +33,20 @@ pub struct GoogleTranslationProvider { } impl GoogleTranslationProvider { - /// Creates a provider with a bounded request timeout and one retry. - pub fn new(credentials: Arc) -> Result { - let client = Client::builder() + /// Builds the outbound client. Proxy discovery is left at reqwest's default + /// so the operating system's proxy configuration is honoured; many networks + /// reach Google only through one. + fn build_client() -> Result { + Client::builder() .timeout(DEFAULT_TIMEOUT) .https_only(true) .build() - .map_err(|_| internal_error("translation client could not be initialized"))?; + .map_err(|_| internal_error("translation client could not be initialized")) + } + + /// Creates a provider with a bounded request timeout and one retry. + pub fn new(credentials: Arc) -> Result { + let client = Self::build_client()?; Ok(Self { client, @@ -845,4 +852,34 @@ mod tests { ); assert_eq!(provider.max_attempts, 1); } + + /// Networks that reach Google only through a local proxy expose whether the + /// production client performs proxy discovery at all. Run it with the proxy + /// environment variables removed, so only the operating system's own + /// configuration can satisfy the request: + /// + /// ```sh + /// env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ + /// cargo test --manifest-path src-tauri/Cargo.toml -- --ignored --nocapture reaches_google + /// ``` + #[tokio::test] + #[ignore = "manual network fixture: contacts the real Google endpoint"] + async fn production_client_reaches_google_without_proxy_environment_variables() { + let client = GoogleTranslationProvider::build_client().expect("production client"); + let response = client + .get(format!("{LANGUAGES_ENDPOINT}?key=deliberately-invalid")) + .send() + .await; + + match response { + // An invalid key is rejected by Google, which proves the endpoint + // was reached rather than blocked. + Ok(response) => assert!( + response.status().is_client_error(), + "unexpected status {}", + response.status() + ), + Err(error) => panic!("the endpoint was unreachable ({error})"), + } + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8285ce1..6486daa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Desktop Translator", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.desktoptranslator.desktop", "build": { "beforeDevCommand": "pnpm dev", @@ -30,7 +30,8 @@ "longDescription": "Desktop Translator watches for a completed text selection in any application, offers a small translate button beside it, and returns a translation from the Google Cloud Translation API without taking focus away from what you are reading.", "copyright": "Copyright © 2026 刘升龙 (Ldsystem)", "macOS": { - "minimumSystemVersion": "11.0" + "minimumSystemVersion": "11.0", + "signingIdentity": "-" } } } diff --git a/src/components/settings/SettingsPanel.test.tsx b/src/components/settings/SettingsPanel.test.tsx index 0540c6b..8085998 100644 --- a/src/components/settings/SettingsPanel.test.tsx +++ b/src/components/settings/SettingsPanel.test.tsx @@ -90,7 +90,7 @@ describe("SettingsPanel", () => { ); expect(container.querySelector('[role="alert"]')?.textContent).toContain( - "Allow access in System Settings", + "quit Desktop Translator from the menu bar", ); expect(container.textContent).toContain("Monitoring Off"); const enable = container.querySelector('input[name="enabled"]'); diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index 9a67202..64164f9 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -72,7 +72,9 @@ export function SettingsPanel({

Accessibility Permission Required

- Monitoring is off. Allow access in System Settings, then return here to enable it. + Monitoring is off. Allow access in System Settings, then quit + Desktop Translator from the menu bar and open it again. macOS does + not apply this permission to an app that is already running.