mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 06:10:43 -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.
|
||||
//!
|
||||
//! - **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
|
||||
//! the top-level HWND and PID; UIAutomation's `IUIAutomation::GetFocusedElement`
|
||||
//! 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
|
||||
/// post-transcription synthetic ⌘V lands where the user started, not
|
||||
/// 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")]
|
||||
pub fn activate_pid(pid: i32) -> Result<(), String> {
|
||||
unsafe {
|
||||
let _pool = AutoreleasePool::new();
|
||||
let app: Id = msg_send![
|
||||
let target: Id = msg_send![
|
||||
class!(NSRunningApplication),
|
||||
runningApplicationWithProcessIdentifier: pid
|
||||
];
|
||||
if app.is_null() {
|
||||
if target.is_null() {
|
||||
return Err(format!("No running application for PID {}", pid));
|
||||
}
|
||||
// NSApplicationActivateIgnoringOtherApps = 1 << 1 = 2.
|
||||
//
|
||||
// macOS 14 deprecated this in favour of `activate()` but kept it
|
||||
// functional when the caller has Accessibility permission — which
|
||||
// we require for the paste event anyway.
|
||||
let _: bool = msg_send![app, activateWithOptions: 2u64];
|
||||
|
||||
let activated: bool = if can_yield_activation() {
|
||||
let current: Id =
|
||||
msg_send![class!(NSRunningApplication), currentApplication];
|
||||
if !current.is_null() {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `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")]
|
||||
mod win {
|
||||
use std::path::Path;
|
||||
|
||||
Reference in New Issue
Block a user