diff --git a/src-tauri/src/prediction/macos.rs b/src-tauri/src/prediction/macos.rs index 64642983..86cf318c 100644 --- a/src-tauri/src/prediction/macos.rs +++ b/src-tauri/src/prediction/macos.rs @@ -1,12 +1,14 @@ use super::context::{Adapter, RawContext, Status}; use core_foundation::{ base::{CFEqual, CFRange, CFType, CFTypeRef, TCFType}, + runloop::{kCFRunLoopDefaultMode, CFRunLoop}, string::{CFString, CFStringRef}, }; #[link(name = "ApplicationServices", kind = "framework")] unsafe extern "C" { fn AXUIElementCreateSystemWide() -> CFTypeRef; + fn AXUIElementCreateApplication(pid: i32) -> CFTypeRef; fn AXUIElementCopyAttributeValue(e: CFTypeRef, a: CFStringRef, out: *mut CFTypeRef) -> i32; fn AXUIElementCopyParameterizedAttributeValue( e: CFTypeRef, @@ -41,9 +43,7 @@ fn string(value: CFType) -> Result { } Ok(unsafe { CFString::wrap_under_get_rule(value.as_CFTypeRef().cast()) }.to_string()) } -pub struct MacAdapter { - system: CFType, -} +pub struct MacAdapter; impl MacAdapter { pub fn new() -> Result { let system = unsafe { AXUIElementCreateSystemWide() }; @@ -53,9 +53,8 @@ impl MacAdapter { unsafe { AXUIElementSetMessagingTimeout(system, 0.2); } - Ok(Self { - system: unsafe { CFType::wrap_under_create_rule(system) }, - }) + let _system = unsafe { CFType::wrap_under_create_rule(system) }; + Ok(Self) } fn text(&self, target: &CFType, range: CFRange) -> Result { let parameter = unsafe { AXValueCreate(4, (&range as *const CFRange).cast()) }; @@ -83,7 +82,32 @@ impl MacAdapter { impl Adapter for MacAdapter { type Target = CFType; fn focused(&mut self) -> Result { - attribute(&self.system, "AXFocusedUIElement") + objc2::rc::autoreleasepool(|_| { + focused_application( + || { + // The pipe-driven worker has no AppKit event loop. Refresh + // NSWorkspace's foreground cache without waiting for events. + unsafe { + CFRunLoop::run_in_mode( + kCFRunLoopDefaultMode, + std::time::Duration::ZERO, + false, + ); + } + objc2_app_kit::NSWorkspace::sharedWorkspace() + .frontmostApplication() + .map(|app| app.processIdentifier()) + }, + |pid| { + let app = unsafe { AXUIElementCreateApplication(pid) }; + if app.is_null() { + return Err(Status::NoFocus); + } + let app = unsafe { CFType::wrap_under_create_rule(app) }; + attribute(&app, "AXFocusedUIElement") + }, + ) + }) } fn protected(&mut self, target: &CFType) -> Result { if unsafe { IsSecureEventInputEnabled() } { @@ -184,3 +208,55 @@ impl Adapter for MacAdapter { }) } } + +fn focused_application( + mut foreground: impl FnMut() -> Option, + focused: impl FnOnce(i32) -> Result, +) -> Result { + let pid = foreground().filter(|pid| *pid > 0).ok_or(Status::NoFocus)?; + let target = focused(pid)?; + if foreground() != Some(pid) { + return Err(Status::NoFocus); + } + Ok(target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_focus_from_current_application() { + assert_eq!(focused_application(|| Some(42), |pid| Ok(pid + 1)), Ok(43)); + } + + #[test] + fn rejects_missing_foreground_without_querying_accessibility() { + for pid in [None, Some(0), Some(-1)] { + assert_eq!( + focused_application( + || pid, + |_| -> Result<(), Status> { panic!("must not query an invalid application") } + ), + Err(Status::NoFocus) + ); + } + } + + #[test] + fn rejects_foreground_changes_during_discovery() { + let mut applications = [Some(42), Some(43)].into_iter(); + assert_eq!( + focused_application(|| applications.next().unwrap(), |_| Ok(())), + Err(Status::NoFocus) + ); + } + + #[test] + fn preserves_accessibility_failures() { + assert_eq!( + focused_application(|| Some(42), |_| Err::<(), _>(Status::Unsupported)), + Err(Status::Unsupported) + ); + } +} diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 6792afb1..d9b3ee16 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -215,16 +215,29 @@ pub fn select(token: u64, index: usize) -> Result<(), String> { }) } fn resource(app: &AppHandle) -> Result { - if cfg!(debug_assertions) { - return Ok(Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/WordData2017051601.db")); - } - app.path() + let bundled = app + .path() .resolve( "resources/WordData2017051601.db", tauri::path::BaseDirectory::Resource, ) - .map_err(|_| ()) + .map_err(|_| ()); + let development = cfg!(debug_assertions) + .then(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/WordData2017051601.db")); + database_resource(bundled, development) +} + +fn database_resource( + bundled: Result, + development: Option, +) -> Result { + bundled + .ok() + .filter(|path| path.is_file()) + .or_else(|| development.filter(|path| path.is_file())) + .ok_or(()) } + pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ignored: &[String]) { let Some(keyboard) = keyboard else { stop(); @@ -362,6 +375,43 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign #[cfg(test)] mod tests { use super::*; + #[test] + fn database_resolution_supports_transferred_bundles_and_unbundled_development() { + let root = std::env::temp_dir().join(format!("switchify-resource-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let bundled = root.join("bundled.db"); + let source = root.join("source.db"); + std::fs::write(&bundled, []).unwrap(); + std::fs::write(&source, []).unwrap(); + assert_eq!( + database_resource(Ok(bundled.clone()), Some(source.clone())), + Ok(bundled.clone()) + ); + assert_eq!( + database_resource(Ok(bundled.clone()), None), + Ok(bundled.clone()) + ); + std::fs::remove_file(&source).unwrap(); + assert_eq!( + database_resource(Ok(bundled.clone()), Some(source.clone())), + Ok(bundled.clone()) + ); + std::fs::write(&source, []).unwrap(); + std::fs::remove_file(&bundled).unwrap(); + assert_eq!( + database_resource(Ok(bundled.clone()), Some(source.clone())), + Ok(source.clone()) + ); + assert_eq!( + database_resource(Err(()), Some(source.clone())), + Ok(source.clone()) + ); + assert_eq!(database_resource(Ok(bundled), None), Err(())); + assert_eq!(database_resource(Ok(root.clone()), None), Err(())); + std::fs::remove_file(source).unwrap(); + std::fs::remove_dir(root).unwrap(); + } + #[test] fn cancellation_kills_and_reaps_a_blocked_worker() { #[cfg(target_os = "windows")]