From cd4dce06e9bacdfb2a72fc88eba51ce303638bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Thu, 13 Aug 2026 20:04:33 +0800 Subject: [PATCH 1/6] fix: sign the macOS bundle so Accessibility grants can hold Release bundles were shipped with no signature at all: only the inner Mach-O carried the linker's ad-hoc signature and Contents/_CodeSignature was absent. macOS still lists such an app under Accessibility and accepts the toggle, but it cannot bind the grant to an unsigned bundle, so the process stays untrusted no matter how often the user grants it. Ad-hoc sign the bundle through Tauri and fail the release build when the artifact comes out unsigned. Document that an ad-hoc identity changes per version, so an update invalidates the previous grant. --- .github/workflows/release.yml | 17 +++++++++++++++++ README.md | 7 +++++++ src-tauri/tauri.conf.json | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) 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/README.md b/README.md index 7ede815..4204f45 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,13 @@ 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. +> [!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. > The app reads only the text you selected, only after you finish selecting it. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8285ce1..e2bb471 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": "-" } } } From 4ba31374cdd37344f10e99406c7d16d70527eb99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Thu, 13 Aug 2026 20:15:39 +0800 Subject: [PATCH 2/6] fix: honour the operating system proxy when calling Google reqwest was pulled in with default-features disabled, which silently drops the system-proxy feature. The client then only read HTTP_PROXY-style variables, and a GUI app launched from Finder never inherits those. On any network that reaches Google only through a local proxy, every request went out direct, hung, and surfaced as a translation timeout. Restore system-proxy and socks, and add a manual fixture that contacts the real endpoint with the proxy variables stripped, so only the operating system's own configuration can satisfy it. --- src-tauri/Cargo.lock | 54 ++++++++++++++++++++++++--- src-tauri/Cargo.toml | 6 ++- src-tauri/src/services/translation.rs | 45 ++++++++++++++++++++-- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1dc7648..70839f4 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", ] @@ -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..804bcf4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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/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})"), + } + } } From ea706a4b776ce3815012d874fcb67dd3f65d5be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Thu, 13 Aug 2026 20:20:41 +0800 Subject: [PATCH 3/6] fix: wake web-backed surfaces before reading the selection Chromium and applications that host a web view build their accessibility tree only once a client asks for it. The first read returns an inert tree with an empty AXSelectedText, so a selection made in Cursor or ChatGPT was never seen while Office, TextEdit and browsers worked. Wake the surface under the pointer when the press begins, which gives it the whole drag to publish its tree, and retry the read once for surfaces that are still waking when the gesture ends. --- src-tauri/src/commands.rs | 37 +++++++-- src-tauri/src/integration_tests.rs | 98 +++++++++++++++++++++++ src-tauri/src/platform/macos/selection.rs | 54 +++++++++++++ src-tauri/src/platform/mod.rs | 8 ++ 4 files changed, 192 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ffdda73..cef5d2d 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::{ @@ -37,6 +37,10 @@ use crate::platform::macos::{ 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. @@ -125,9 +129,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 +167,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 +189,7 @@ impl ApplicationCoordinator { self.overlay.show_button(&selection).await?; } } - Err(_) => { + None => { self.reduce(CoordinatorEvent::SelectionRejected { request_id }); } } diff --git a/src-tauri/src/integration_tests.rs b/src-tauri/src/integration_tests.rs index 9af4bcd..44c85c7 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( diff --git a/src-tauri/src/platform/macos/selection.rs b/src-tauri/src/platform/macos/selection.rs index 4e4a90b..3df7a1d 100644 --- a/src-tauri/src/platform/macos/selection.rs +++ b/src-tauri/src/platform/macos/selection.rs @@ -103,6 +103,12 @@ 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; @@ -258,6 +264,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 +434,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], 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. From 262becb9a4fe7ac732f50876c57c337c468afa45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Thu, 13 Aug 2026 20:24:53 +0800 Subject: [PATCH 4/6] fix: follow the live display topology when resolving a selection The display transforms were captured once while the runtime was built, so a display attached afterwards was unknown to the adapter. Accessibility geometry on that display intersected no known bounds, normalisation dropped every fragment, and the selection resolved to nothing, leaving the built-in screen as the only place the button could ever appear. Read the topology from CoreGraphics at each resolution instead. CGDisplayBounds uses the same top-left logical space the accessibility API reports, and the window layer positions overlays at logical * scale, so both agree. --- src-tauri/src/commands.rs | 30 +--- src-tauri/src/platform/macos/selection.rs | 173 +++++++++++++++++++++- 2 files changed, 171 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cef5d2d..9ff5b80 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -30,9 +30,7 @@ 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}; @@ -370,7 +368,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()?; @@ -713,27 +711,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/platform/macos/selection.rs b/src-tauri/src/platform/macos/selection.rs index 3df7a1d..4bec32d 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}, @@ -114,12 +115,28 @@ unsafe extern "C" { 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" {} @@ -179,17 +196,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)), } } @@ -233,13 +277,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(), @@ -474,6 +519,73 @@ 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) +} + +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. @@ -1278,6 +1390,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 { From 12d16cbb57c97edeb378dfb8e69c0ec94ac84e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Fri, 14 Aug 2026 08:56:31 +0800 Subject: [PATCH 5/6] fix: re-check Accessibility after grant and tell the user to quit macOS does not apply a new Accessibility toggle to a process that is already running, and this app snapshotted permission only at launch, so the warning stayed up after the user allowed access. Re-read permission while Settings is open, start monitoring if the grant takes effect in place, and tell the user to quit from the menu bar when it does not. --- README.md | 4 +- src-tauri/src/commands.rs | 53 +++++++++++++++---- src-tauri/src/integration_tests.rs | 4 ++ src-tauri/src/lib.rs | 4 +- .../settings/SettingsPanel.test.tsx | 2 +- src/components/settings/SettingsPanel.tsx | 4 +- src/main.tsx | 18 ++++++- 7 files changed, 74 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4204f45..c7e8cbe 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ 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. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9ff5b80..2b8dabb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -65,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() @@ -600,21 +607,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. diff --git a/src-tauri/src/integration_tests.rs b/src-tauri/src/integration_tests.rs index 44c85c7..42602a7 100644 --- a/src-tauri/src/integration_tests.rs +++ b/src-tauri/src/integration_tests.rs @@ -507,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..d201a03 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| { 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.