mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
fix(capture): cooperative app activation for synthetic paste on macOS 14+
macOS 14 deprecated NSRunningApplication.activateWithOptions: in favour of a cooperative-activation pattern: the caller first yields activation rights to the target, then the target activate()s against the tightened Sonoma foreground rules. Without the yield, activate() on 14+ sometimes silently fails or only bounces the dock icon — the exact "paste lands in the wrong app" symptom we were previously one API break away from. activate_pid now discovers the 14+ selector via respondsToSelector: and branches: on 14+ it yieldActivationToApplication:'s from NSRunningApplication.current then calls -activate on the target; on 11–13 it stays on -activateWithOptions: (still the only option). Both branches propagate the BOOL return — if activation is refused we error out before clobbering the clipboard instead of silently proceeding. The respondsToSelector: result is cached in a OnceLock so the probe isn't repeated on every paste. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
53a7693101
commit
c65531bed1
@@ -10,7 +10,11 @@
|
|||||||
//! does and what users expect.
|
//! does and what users expect.
|
||||||
//!
|
//!
|
||||||
//! - **macOS** — `AXUIElementCopyAttributeValue(kAXFocusedUIElement)` +
|
//! - **macOS** — `AXUIElementCopyAttributeValue(kAXFocusedUIElement)` +
|
||||||
//! `AXUIElementGetPid` + `NSRunningApplication.activateWithOptions:`.
|
//! `AXUIElementGetPid` + NSRunningApplication activation. Activation
|
||||||
|
//! uses the cooperative-activation pattern on macOS 14+ (the caller
|
||||||
|
//! `yieldActivationToApplication:`s, then the target `activate`s) and
|
||||||
|
//! falls back to the pre-Sonoma `activateWithOptions:` on 11–13. See
|
||||||
|
//! `activate_pid` for the rationale.
|
||||||
//! - **Windows** — `GetForegroundWindow` + `GetWindowThreadProcessId` for
|
//! - **Windows** — `GetForegroundWindow` + `GetWindowThreadProcessId` for
|
||||||
//! the top-level HWND and PID; UIAutomation's `IUIAutomation::GetFocusedElement`
|
//! the top-level HWND and PID; UIAutomation's `IUIAutomation::GetFocusedElement`
|
||||||
//! for best-effort control-class (skipped silently if COM isn't usable).
|
//! for best-effort control-class (skipped silently if COM isn't usable).
|
||||||
@@ -247,27 +251,78 @@ pub fn capture_focus() -> Result<FocusSnapshot, String> {
|
|||||||
/// last-focused window. Paired with [`capture_focus`] at chord-start so a
|
/// last-focused window. Paired with [`capture_focus`] at chord-start so a
|
||||||
/// post-transcription synthetic ⌘V lands where the user started, not
|
/// post-transcription synthetic ⌘V lands where the user started, not
|
||||||
/// wherever focus drifted to during the transcribe / refine window.
|
/// wherever focus drifted to during the transcribe / refine window.
|
||||||
|
///
|
||||||
|
/// macOS 14 (Sonoma) deprecated `activateWithOptions:` in favour of a
|
||||||
|
/// cooperative-activation pattern: the caller first invokes
|
||||||
|
/// `yieldActivationToApplication:` on its own `NSRunningApplication` to
|
||||||
|
/// grant the target activation rights, then the target's `activate`
|
||||||
|
/// succeeds against the tightened Sonoma foreground rules. Without the
|
||||||
|
/// yield, `activate` on 14+ sometimes silently fails or only bounces the
|
||||||
|
/// dock icon — exactly the "paste lands in the wrong app" symptom we're
|
||||||
|
/// trying to prevent. The yield is discovered at runtime via
|
||||||
|
/// `respondsToSelector:` so we don't need an operatingSystemVersion probe
|
||||||
|
/// and the pre-Sonoma path stays identical.
|
||||||
|
///
|
||||||
|
/// The BOOL return of both `activate` and `activateWithOptions:` is now
|
||||||
|
/// propagated — if the system refuses activation (target quit mid-
|
||||||
|
/// transcription, trust revoked, cooperative-activation refused) the
|
||||||
|
/// caller aborts before clobbering the clipboard.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn activate_pid(pid: i32) -> Result<(), String> {
|
pub fn activate_pid(pid: i32) -> Result<(), String> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let _pool = AutoreleasePool::new();
|
let _pool = AutoreleasePool::new();
|
||||||
let app: Id = msg_send![
|
let target: Id = msg_send![
|
||||||
class!(NSRunningApplication),
|
class!(NSRunningApplication),
|
||||||
runningApplicationWithProcessIdentifier: pid
|
runningApplicationWithProcessIdentifier: pid
|
||||||
];
|
];
|
||||||
if app.is_null() {
|
if target.is_null() {
|
||||||
return Err(format!("No running application for PID {}", pid));
|
return Err(format!("No running application for PID {}", pid));
|
||||||
}
|
}
|
||||||
// NSApplicationActivateIgnoringOtherApps = 1 << 1 = 2.
|
|
||||||
//
|
let activated: bool = if can_yield_activation() {
|
||||||
// macOS 14 deprecated this in favour of `activate()` but kept it
|
let current: Id =
|
||||||
// functional when the caller has Accessibility permission — which
|
msg_send![class!(NSRunningApplication), currentApplication];
|
||||||
// we require for the paste event anyway.
|
if !current.is_null() {
|
||||||
let _: bool = msg_send![app, activateWithOptions: 2u64];
|
let _: () = msg_send![current, yieldActivationToApplication: target];
|
||||||
|
}
|
||||||
|
msg_send![target, activate]
|
||||||
|
} else {
|
||||||
|
// NSApplicationActivateIgnoringOtherApps = 1 << 1 = 2.
|
||||||
|
msg_send![target, activateWithOptions: 2u64]
|
||||||
|
};
|
||||||
|
|
||||||
|
if !activated {
|
||||||
|
return Err(format!(
|
||||||
|
"NSRunningApplication activate returned false for PID {} — the target may have quit mid-transcription, Accessibility is no longer trusted, or the system refused cooperative activation.",
|
||||||
|
pid
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `true` when `NSRunningApplication` responds to
|
||||||
|
/// `yieldActivationToApplication:` — the macOS 14+ discriminator for the
|
||||||
|
/// cooperative-activation APIs. Cached since the answer doesn't change
|
||||||
|
/// over a process's lifetime and the objc_msgSend probe is otherwise
|
||||||
|
/// repeated on every paste.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn can_yield_activation() -> bool {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static CACHED: OnceLock<bool> = OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| unsafe {
|
||||||
|
let current: Id = msg_send![class!(NSRunningApplication), currentApplication];
|
||||||
|
if current.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let responds: bool = msg_send![
|
||||||
|
current,
|
||||||
|
respondsToSelector: sel!(yieldActivationToApplication:)
|
||||||
|
];
|
||||||
|
responds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod win {
|
mod win {
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|||||||
Reference in New Issue
Block a user