diff --git a/Cargo.lock b/Cargo.lock index 0a24c49..a38cc36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,7 +131,9 @@ version = "3.0.0" dependencies = [ "objc2", "objc2-app-kit", + "objc2-application-services", "objc2-av-foundation", + "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation", "objc2-vision", @@ -458,6 +460,21 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-application-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69282c2b5bc58fba07cb9de2113619532eb551e98efe3d8d695509ef45fbd53b" +dependencies = [ + "bitflags", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-services", + "objc2-foundation", +] + [[package]] name = "objc2-av-foundation" version = "0.3.2" @@ -542,7 +559,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags", + "block2", "dispatch2", + "libc", "objc2", ] @@ -597,6 +616,18 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -697,6 +728,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-vision" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 376ef38..cecba3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ objc2 = "0.6" objc2-foundation = "0.3" objc2-app-kit = "0.3" objc2-core-graphics = "0.3" +objc2-core-foundation = "0.3" objc2-application-services = "0.3" objc2-vision = "0.3" objc2-av-foundation = { version = "0.3", features = ["objc2-core-media", "AVAssetTrack"] } diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index 407022c..fe736b4 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -63,6 +63,14 @@ impl Catalog { Take::Presence } + pub fn decide_in(&self, family: Option, id: &str) -> Take { + let id = self.canonical(id); + if family == Some(Family::Files) && self.images_by_preference.contains(&id) { + return Take::Presence; + } + self.decide(id) + } + pub fn refusal(&self, offered: &[&str]) -> Option { offered.iter().find_map(|id| { self.concealed @@ -269,6 +277,52 @@ mod tests { Some(Family::Image) ); } + + #[test] + fn a_copied_file_keeps_its_path_and_notes_its_icon() { + let files = Some(Family::Files); + assert_eq!(PROBE.decide_in(files, "cheap/image"), Take::Presence); + assert_eq!(PROBE.decide_in(files, "costly/image"), Take::Presence); + assert_eq!(PROBE.decide_in(files, "one/file"), Take::Payload); + assert_eq!(PROBE.decide_in(files, "old/text"), Take::Payload); + assert_eq!(PROBE.decide_in(files, "hangs/forever"), Take::Never); + } + + #[test] + fn outside_a_file_copy_the_family_changes_nothing() { + for family in [None, Some(Family::Text), Some(Family::Image)] { + for id in [ + "cheap/image", + "costly/image", + "old/text", + "hangs/forever", + "huge/icon", + "who/knows", + ] { + assert_eq!( + PROBE.decide_in(family, id), + PROBE.decide(id), + "{family:?} {id}" + ); + } + } + } + + #[test] + fn a_file_copy_notes_the_icon_under_its_legacy_name_too() { + const ICONIC: Catalog = Catalog { + aliases: &[("legacy/picture", "cheap/image")], + ..PROBE + }; + assert_eq!( + ICONIC.decide_in(Some(Family::Files), "legacy/picture"), + Take::Presence + ); + assert_eq!( + ICONIC.decide_in(Some(Family::Text), "legacy/picture"), + Take::Payload + ); + } } #[cfg(test)] diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs index 6f62deb..c4eea1a 100644 --- a/crates/cp-core/src/paste.rs +++ b/crates/cp-core/src/paste.rs @@ -40,6 +40,19 @@ pub enum Next { Degrade, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Route { + Keystroke, + Menu, +} + +pub fn route_for(can_post_events: bool, can_drive_menus: bool) -> Option { + if can_post_events { + return Some(Route::Keystroke); + } + can_drive_menus.then_some(Route::Menu) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Warning { SecureInputActive, @@ -99,6 +112,18 @@ impl Failure { mod tests { use super::*; + #[test] + fn the_keystroke_is_the_route_whenever_it_is_allowed() { + assert_eq!(route_for(true, false), Some(Route::Keystroke)); + assert_eq!(route_for(true, true), Some(Route::Keystroke)); + } + + #[test] + fn the_menu_is_the_route_only_when_the_keystroke_is_denied() { + assert_eq!(route_for(false, true), Some(Route::Menu)); + assert_eq!(route_for(false, false), None); + } + #[test] fn a_slow_app_gets_the_dozen_tries_the_2x_needed() { let mut attempt = Attempt::default(); diff --git a/crates/cp-mac-sys/Cargo.toml b/crates/cp-mac-sys/Cargo.toml index c22e9bb..6969c2c 100644 --- a/crates/cp-mac-sys/Cargo.toml +++ b/crates/cp-mac-sys/Cargo.toml @@ -12,6 +12,8 @@ objc2.workspace = true objc2-foundation.workspace = true objc2-app-kit.workspace = true objc2-core-graphics.workspace = true +objc2-core-foundation.workspace = true +objc2-application-services.workspace = true objc2-vision.workspace = true objc2-av-foundation.workspace = true diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index 9423bcc..11d895e 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -4,8 +4,10 @@ pub mod frontmost; pub mod keyboard; pub mod keystroke; pub mod media; +pub mod menu; pub mod ocr; pub mod pasteboard; pub mod paths; pub mod permissions; +pub mod reading; pub mod runloop; diff --git a/crates/cp-mac-sys/src/menu.rs b/crates/cp-mac-sys/src/menu.rs new file mode 100644 index 0000000..dad7ff4 --- /dev/null +++ b/crates/cp-mac-sys/src/menu.rs @@ -0,0 +1,107 @@ +use objc2_application_services::{AXError, AXUIElement}; +use objc2_core_foundation::{CFArray, CFBoolean, CFNumber, CFRetained, CFString, CFType}; +use std::ptr::NonNull; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuFailure { + NoMenuBar, + NoPasteItem, + PasteDisabled, + PressRefused(i32), +} + +pub fn press_paste(pid: i32) -> Result<(), MenuFailure> { + let app = unsafe { AXUIElement::new_application(pid) }; + let bar = element(&app, "AXMenuBar").ok_or(MenuFailure::NoMenuBar)?; + let item = children(&bar) + .into_iter() + .flat_map(|menu| children(&menu)) + .flat_map(|submenu| children(&submenu)) + .find(|entry| is_paste_item(entry)) + .ok_or(MenuFailure::NoPasteItem)?; + if !enabled(&item) { + return Err(MenuFailure::PasteDisabled); + } + let pressed = unsafe { item.perform_action(&CFString::from_str("AXPress")) }; + if pressed == AXError::Success { + Ok(()) + } else { + Err(MenuFailure::PressRefused(pressed.0)) + } +} + +pub fn is_paste_shortcut(command_char: &str, modifiers: u32) -> bool { + command_char.eq_ignore_ascii_case("v") && modifiers == 0 +} + +fn is_paste_item(entry: &AXUIElement) -> bool { + let Some(character) = attribute(entry, "AXMenuItemCmdChar") else { + return false; + }; + let Some(character) = character.downcast_ref::() else { + return false; + }; + let modifiers = attribute(entry, "AXMenuItemCmdModifiers") + .and_then(|value| value.downcast_ref::()?.as_i32()) + .unwrap_or(0); + is_paste_shortcut(&character.to_string(), modifiers.unsigned_abs()) +} + +fn enabled(entry: &AXUIElement) -> bool { + attribute(entry, "AXEnabled") + .and_then(|value| Some(value.downcast_ref::()?.as_bool())) + .unwrap_or(false) +} + +fn element(parent: &AXUIElement, name: &str) -> Option> { + attribute(parent, name)?.downcast::().ok() +} + +fn children(parent: &AXUIElement) -> Vec> { + let Some(array) = + attribute(parent, "AXChildren").and_then(|value| value.downcast::().ok()) + else { + return Vec::new(); + }; + let objects = unsafe { array.cast_unchecked::() }; + objects + .to_vec() + .into_iter() + .filter_map(|child| child.downcast::().ok()) + .collect() +} + +fn attribute(element: &AXUIElement, name: &str) -> Option> { + let mut value: *const CFType = std::ptr::null(); + let asked = unsafe { + element.copy_attribute_value(&CFString::from_str(name), NonNull::from(&mut value)) + }; + if asked != AXError::Success { + return None; + } + let raw = NonNull::new(value.cast_mut())?; + Some(unsafe { CFRetained::from_raw(raw) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paste_is_command_v_and_nothing_else() { + assert!(is_paste_shortcut("v", 0)); + assert!(is_paste_shortcut("V", 0)); + assert!( + !is_paste_shortcut("v", 1), + "⇧⌘V es «pegar con el mismo estilo»" + ); + assert!(!is_paste_shortcut("v", 2), "⌥⌘V es otra cosa"); + assert!(!is_paste_shortcut("c", 0)); + assert!(!is_paste_shortcut("", 0)); + } + + #[test] + fn a_process_that_does_not_exist_has_no_menu_bar() { + assert_eq!(press_paste(i32::MAX), Err(MenuFailure::NoMenuBar)); + } +} diff --git a/crates/cp-mac-sys/src/pasteboard.rs b/crates/cp-mac-sys/src/pasteboard.rs index 02b1d9c..1368a72 100644 --- a/crates/cp-mac-sys/src/pasteboard.rs +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -1,5 +1,5 @@ use objc2_app_kit::{NSPasteboard, NSPasteboardItem, NSPasteboardWriting}; -use objc2_foundation::{MainThreadMarker, NSString}; +use objc2_foundation::{MainThreadMarker, NSString, NSURL}; pub struct Pasteboard { inner: objc2::rc::Retained, @@ -12,6 +12,12 @@ impl Pasteboard { } } + pub fn general_from_any_thread() -> Self { + Self { + inner: NSPasteboard::generalPasteboard(), + } + } + pub fn change_count(&self) -> i64 { self.inner.changeCount() as i64 } @@ -102,3 +108,68 @@ impl Pasteboard { pub fn change_count_from_any_thread() -> i64 { objc2_app_kit::NSPasteboard::generalPasteboard().changeCount() as i64 } + +pub fn file_path_of(url: &str) -> Option { + let parsed = NSURL::URLWithString(&NSString::from_str(url))?; + if !parsed.isFileURL() { + return None; + } + let path = parsed.filePathURL()?.absoluteString()?; + Some(path.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference_of(path: &std::path::Path) -> String { + NSURL::fileURLWithPath(&NSString::from_str(&path.display().to_string())) + .fileReferenceURL() + .and_then(|url| url.absoluteString()) + .map(|url| url.to_string()) + .expect("un archivo que existe tiene referencia") + } + + #[test] + fn a_finder_reference_becomes_the_path_it_points_at() { + let dir = std::env::temp_dir().join(format!("cp-ref-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("foto de perfil.png"); + std::fs::write(&file, b"png").unwrap(); + let reference = reference_of(&file); + assert!(reference.starts_with("file:///.file/id="), "{reference}"); + let resolved = file_path_of(&reference).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + assert!(resolved.starts_with("file:///"), "{resolved}"); + assert!(resolved.ends_with("/foto%20de%20perfil.png"), "{resolved}"); + assert!(!resolved.contains(".file/id=")); + } + + #[test] + fn a_path_url_comes_back_as_it_was() { + assert_eq!( + file_path_of("file:///tmp/uno.txt").as_deref(), + Some("file:///tmp/uno.txt") + ); + assert_eq!( + file_path_of("file:///tmp/con%20espacio.txt").as_deref(), + Some("file:///tmp/con%20espacio.txt") + ); + } + + #[test] + fn what_is_not_a_file_has_no_path() { + assert_eq!(file_path_of("https://example.com/x.png"), None); + assert_eq!(file_path_of("no es una url"), None); + assert_eq!(file_path_of(""), None); + } + + #[test] + fn a_dead_reference_has_no_path_either() { + let file = std::env::temp_dir().join(format!("cp-dead-{}.txt", std::process::id())); + std::fs::write(&file, b"efimero").unwrap(); + let reference = reference_of(&file); + std::fs::remove_file(&file).unwrap(); + assert_eq!(file_path_of(&reference), None); + } +} diff --git a/crates/cp-mac-sys/src/permissions.rs b/crates/cp-mac-sys/src/permissions.rs index 2a94930..f7e3092 100644 --- a/crates/cp-mac-sys/src/permissions.rs +++ b/crates/cp-mac-sys/src/permissions.rs @@ -1,3 +1,6 @@ +use objc2_application_services::{AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt}; +use objc2_core_foundation::{CFBoolean, CFDictionary}; + unsafe extern "C" { fn CGPreflightPostEventAccess() -> bool; fn CGRequestPostEventAccess() -> bool; @@ -17,6 +20,12 @@ pub fn is_accessibility_trusted() -> bool { unsafe { AXIsProcessTrusted() } } +pub fn request_accessibility() -> bool { + let prompt = unsafe { kAXTrustedCheckOptionPrompt }; + let options = CFDictionary::from_slices(&[prompt], &[CFBoolean::new(true)]); + unsafe { AXIsProcessTrustedWithOptions(Some(options.as_opaque())) } +} + pub fn is_secure_input_enabled() -> bool { unsafe { IsSecureEventInputEnabled() } } @@ -38,7 +47,7 @@ impl Readiness { } pub fn can_paste(&self) -> bool { - self.can_post + self.can_post || self.accessibility } pub fn can_use_menu_fallback(&self) -> bool { @@ -59,27 +68,20 @@ mod tests { } #[test] - fn pasting_depends_on_posting_events_and_nothing_else() { + fn pasting_needs_one_of_the_two_permissions() { assert!(with(true, false, false).can_paste()); assert!( with(true, false, true).can_paste(), "el input seguro no manda" ); - assert!(with(true, true, true).can_paste()); - assert!(!with(false, true, false).can_paste()); + assert!(with(false, true, false).can_paste()); + assert!(!with(false, false, false).can_paste()); } #[test] fn the_menu_fallback_needs_its_own_permission() { assert!(!with(true, false, false).can_use_menu_fallback()); assert!(with(true, true, false).can_use_menu_fallback()); - } - - #[test] - fn the_two_permissions_are_independent() { - assert!(with(true, false, false).can_paste()); - assert!(!with(true, false, false).can_use_menu_fallback()); - assert!(!with(false, true, false).can_paste()); assert!(with(false, true, false).can_use_menu_fallback()); } } diff --git a/crates/cp-mac-sys/src/reading.rs b/crates/cp-mac-sys/src/reading.rs new file mode 100644 index 0000000..0b27593 --- /dev/null +++ b/crates/cp-mac-sys/src/reading.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +pub fn anything_within( + patience: Duration, + work: impl FnOnce() -> T + Send + 'static, +) -> Option { + let (tell, hear) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tell.send(work()); + }); + hear.recv_timeout(patience).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_answers_in_time_is_delivered() { + assert_eq!( + anything_within(Duration::from_millis(400), || vec![1, 2, 3]), + Some(vec![1, 2, 3]) + ); + } + + #[test] + fn what_does_not_answer_in_time_is_abandoned() { + let seen = anything_within(Duration::from_millis(20), || { + std::thread::sleep(Duration::from_millis(400)); + vec![9] + }); + assert_eq!(seen, None); + } + + #[test] + fn abandoning_one_read_does_not_poison_the_next() { + let abandoned = anything_within(Duration::from_millis(10), || { + std::thread::sleep(Duration::from_millis(300)); + 0 + }); + assert_eq!(abandoned, None); + assert_eq!(anything_within(Duration::from_millis(400), || 7), Some(7)); + } + + #[test] + fn a_read_that_panics_is_abandoned_like_any_other() { + let seen = anything_within(Duration::from_millis(50), || -> u8 { + panic!("el proveedor murió") + }); + assert_eq!(seen, None); + } +} diff --git a/crates/cp-mac/examples/probe/battery.rs b/crates/cp-mac/examples/probe/battery.rs index 053a994..84c128d 100644 --- a/crates/cp-mac/examples/probe/battery.rs +++ b/crates/cp-mac/examples/probe/battery.rs @@ -1,14 +1,15 @@ use cp_core::destination::Tracker; use cp_core::item::Payload; use cp_core::kind::Kind; +use cp_core::paste::Route; use cp_core::watch::{Cadence, Seen, Watcher}; -use cp_mac::capture::capture; +use cp_mac::capture::{Captured, PATIENCE, capture, capture_within}; use cp_mac::paste::Paster; use cp_mac_sys::keyboard::{self, QWERTY_V}; use cp_mac_sys::pasteboard::{self, Pasteboard}; use cp_mac_sys::permissions::Readiness; use cp_mac_sys::{frontmost, keystroke}; -use objc2_foundation::MainThreadMarker; +use objc2_foundation::{MainThreadMarker, NSString, NSURL}; use std::time::{Duration, Instant}; struct Battery { @@ -170,6 +171,109 @@ fn main() -> std::process::ExitCode { } }); + b.case("A9", "una referencia de Finder se guarda como ruta", || { + let dir = std::env::temp_dir().join(format!("cp-a9-{}", pb.change_count())); + std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + let file = dir.join("cp a9.png"); + std::fs::write(&file, b"png").map_err(|why| why.to_string())?; + let reference = NSURL::fileURLWithPath(&NSString::from_str(&file.display().to_string())) + .fileReferenceURL() + .and_then(|url| url.absoluteString()) + .map(|url| url.to_string()) + .ok_or("el archivo no tiene referencia")?; + if !reference.starts_with("file:///.file/id=") { + return Err(format!( + "la referencia no tiene la forma de Finder: {reference}" + )); + } + pb.write_items(&[vec![("public.file-url", reference.as_str())]]); + let item = capture(&pb).ok_or("no se capturó")?; + let stored = match &item + .format("public.file-url") + .ok_or("falta la ruta")? + .payload + { + Payload::Inline(bytes) => String::from_utf8_lossy(bytes).to_string(), + other => return Err(format!("llegó {other:?}")), + }; + let missing = frontmost::missing_paths(&stored); + std::fs::remove_dir_all(&dir).ok(); + if stored.contains(".file/id=") || !stored.ends_with("/cp%20a9.png") { + return Err(format!("se guardó «{stored}»")); + } + if item.kind != Some(Kind::Image) { + return Err(format!("se clasificó como {:?}", item.kind)); + } + if !missing.is_empty() { + return Err(format!("se dio por borrado: {missing:?}")); + } + Ok(()) + }); + + b.case("A10", "un archivo copiado no cuesta megabytes", || { + let icon = vec![0u8; 4 * 1024 * 1024]; + pb.write_all(&[ + ("public.file-url", b"file:///tmp/cp-a10.txt"), + ("public.tiff", &icon), + ]); + let item = capture(&pb).ok_or("no se capturó")?; + match &item + .format("public.tiff") + .ok_or("el icono ni se anotó")? + .payload + { + Payload::Announced { size: None } => {} + other => return Err(format!("el icono se guardó: {other:?}")), + } + if item.oversized_format().is_some() || item.stored_bytes() > 1024 { + return Err(format!("se guardaron {} bytes", item.stored_bytes())); + } + Ok(()) + }); + + b.case("A11", "una hoja de cálculo no se guarda como foto", || { + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("falta el fixture: {why}"))?; + pb.write_all(&[ + ("com.microsoft.Embed-Source", b"\x01"), + ("public.utf8-plain-text", b"A\tB\nC\tD"), + ("public.png", &png), + ]); + let item = capture(&pb).ok_or("no se capturó")?; + if item.kind == Some(Kind::Image) { + return Err("el rango se clasificó como imagen".into()); + } + match &item + .format("public.utf8-plain-text") + .ok_or("falta el texto")? + .payload + { + Payload::Inline(bytes) if bytes == b"A\tB\nC\tD" => Ok(()), + other => Err(format!("llegó {other:?}")), + } + }); + + b.case( + "A12", + "una imagen anunciada sin bytes no hace al ítem imagen", + || { + pb.write_all(&[("public.utf8-plain-text", b"celda"), ("public.png", b"")]); + let item = capture(&pb).ok_or("no se capturó")?; + match &item + .format("public.png") + .ok_or("la imagen ni se anotó")? + .payload + { + Payload::Absent => {} + other => return Err(format!("una imagen vacía se guardó: {other:?}")), + } + if item.kind != Some(Kind::Text) { + return Err(format!("se clasificó como {:?}", item.kind)); + } + Ok(()) + }, + ); + b.group("I · Volver al portapapeles"); b.case("I1", "un ítem vuelve con todos sus formatos", || { @@ -685,6 +789,19 @@ fn main() -> std::process::ExitCode { }, ); + b.case( + "C5", + "la captura acotada devuelve lo mismo que la directa", + || { + pb.write_text("cp-c5"); + let direct = capture(&pb).ok_or("no se capturó")?; + match capture_within(PATIENCE) { + Captured::Kept(item) if item == direct => Ok(()), + other => Err(format!("llegó {other:?}")), + } + }, + ); + b.group("D · Teclado"); b.case("D1", "el layout activo resuelve la «v»", || { @@ -800,27 +917,39 @@ fn main() -> std::process::ExitCode { } Ok(()) }); - if ready.can_post { - match paste_round_trip(&pb, &paster) { + for (id, route, what, allowed, permission) in [ + ( + "F4", + Route::Keystroke, + "pegado real en TextEdit", + ready.can_post, + "sin permiso para postear eventos", + ), + ( + "F5", + Route::Menu, + "pegado por el menú Edición cuando ⌘V no entra", + ready.accessibility, + "sin permiso de Accesibilidad", + ), + ] { + if !allowed { + b.skip(id, what, permission); + continue; + } + match paste_round_trip(&pb, &paster, route) { Ok(()) => { b.passed += 1; - println!(" ok F4 pegado real en TextEdit, ida y vuelta"); + println!(" ok {id:<5} {what}, ida y vuelta"); } Err(why) if why.starts_with("TextEdit no llegó") => { - b.skip("F4", "pegado real en TextEdit", &why); + b.skip(id, what, &why); } Err(why) => { b.failed += 1; - println!(" FALLA F4 pegado real en TextEdit"); - println!(" {why}"); + println!(" FALLA {id:<5} {what}\n {why}"); } } - } else { - b.skip( - "F4", - "pegado real en TextEdit", - "sin permiso para postear eventos", - ); } } None => b.skip("F3", "el pegador se construye", "no hay fuente de eventos"), @@ -838,7 +967,7 @@ fn main() -> std::process::ExitCode { } } -fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { +fn paste_round_trip(pb: &Pasteboard, paster: &Paster, route: Route) -> Result<(), String> { let path = "/tmp/cp-probe-target.txt"; std::fs::write(path, "").map_err(|why| why.to_string())?; run_open(&["-a", "TextEdit", path]); @@ -854,6 +983,7 @@ fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { if let Some(pid) = textedit { frontmost::bring_to_front(pid); std::thread::sleep(Duration::from_millis(250)); + cp_mac_sys::runloop::pump(0.0); if let Some((front_pid, bundle)) = frontmost::frontmost() && front_pid == pid { @@ -878,8 +1008,11 @@ fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { std::thread::sleep(Duration::from_millis(120)); let started = Instant::now(); - match paster.paste_into(&target, || {}) { + match paster.paste_via(Some(route), &target, || {}) { cp_mac::paste::Outcome::Degraded(why) => return Err(format!("degradó: {why:?}")), + cp_mac::paste::Outcome::Sent { via, .. } if via != route => { + return Err(format!("fue por {via:?}")); + } cp_mac::paste::Outcome::Sent { .. } => {} } std::thread::sleep(Duration::from_millis(400)); diff --git a/crates/cp-mac/src/capture.rs b/crates/cp-mac/src/capture.rs index ff42157..51a4a93 100644 --- a/crates/cp-mac/src/capture.rs +++ b/crates/cp-mac/src/capture.rs @@ -2,7 +2,26 @@ use crate::formats::CATALOG; use cp_core::formats::{Family, Take}; use cp_core::item::{Format, Item, Payload}; use cp_core::kind::{self, Kind}; -use cp_mac_sys::pasteboard::Pasteboard; +use cp_mac_sys::pasteboard::{self, Pasteboard}; +use cp_mac_sys::reading; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Captured { + Kept(Item), + Nothing, + TooSlow, +} + +pub const PATIENCE: std::time::Duration = std::time::Duration::from_millis(400); + +const _: () = assert!(PATIENCE.as_millis() < 60_000); + +pub fn capture_within(patience: std::time::Duration) -> Captured { + reading::anything_within(patience, || { + capture(&Pasteboard::general_from_any_thread()).map_or(Captured::Nothing, Captured::Kept) + }) + .unwrap_or(Captured::TooSlow) +} pub fn capture(pb: &Pasteboard) -> Option { let offered = pb.types(); @@ -19,7 +38,7 @@ pub fn capture(pb: &Pasteboard) -> Option { if formats.iter().any(|kept| kept.id == canonical) { continue; } - let payload = match CATALOG.decide(canonical) { + let payload = match CATALOG.decide_in(family, canonical) { Take::Never => Payload::Announced { size: None }, Take::Presence => Payload::Announced { size: None }, Take::Payload => { @@ -32,6 +51,7 @@ pub fn capture(pb: &Pasteboard) -> Option { } } else { match pb.data(canonical) { + Some(bytes) if bytes.is_empty() && is_image(canonical) => Payload::Absent, Some(bytes) => Payload::stored(bytes), None => Payload::Absent, } @@ -49,19 +69,38 @@ pub fn capture(pb: &Pasteboard) -> Option { } fn gather_file_urls(pb: &Pasteboard) -> Option> { - let each = pb.data_per_item("public.file-url"); + let mut each = pb.data_per_item("public.file-url"); if each.is_empty() { - return pb.data("public.file-url"); + each.push(pb.data("public.file-url")?); } - let joined: Vec = each - .iter() - .filter_map(|bytes| String::from_utf8(bytes.clone()).ok()) + let resolved: Vec = each + .into_iter() + .filter_map(|bytes| String::from_utf8(bytes).ok()) + .map(|url| pasteboard::file_path_of(&url).unwrap_or(url)) .collect(); - (!joined.is_empty()).then(|| joined.join("\n").into_bytes()) + (!resolved.is_empty()).then(|| resolved.join("\n").into_bytes()) +} + +fn is_image(id: &str) -> bool { + CATALOG.images_by_preference.contains(&id) +} + +fn has_bytes(one: &Format) -> bool { + one.payload.size().is_some_and(|bytes| bytes > 0) } fn refine(family: Option, formats: &[Format]) -> Option { - match family? { + let image_delivered = formats + .iter() + .any(|one| is_image(&one.id) && has_bytes(one)); + let text_delivered = formats + .iter() + .any(|one| one.id == "public.utf8-plain-text" && has_bytes(one)); + let family = match family? { + Family::Image if !image_delivered && text_delivered => Family::Text, + other => other, + }; + match family { Family::Image => Some(Kind::Image), Family::Text => { let text = formats @@ -96,3 +135,26 @@ fn refine(family: Option, formats: &[Format]) -> Option { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_patience_sits_between_the_two_measured_worlds() { + let delivered = std::time::Duration::from_millis(9); + let promised = std::time::Duration::from_secs(60); + assert!(delivered < PATIENCE, "Finder entrega 16 tipos en 9 ms"); + assert!(PATIENCE < std::time::Duration::from_secs(1)); + assert!(PATIENCE < promised, "una promesa tarda 60 s en rendirse"); + } + + #[test] + fn a_capture_that_does_not_finish_in_time_is_abandoned() { + let seen = reading::anything_within(std::time::Duration::from_millis(20), || { + std::thread::sleep(std::time::Duration::from_secs(60)); + Captured::Nothing + }); + assert_eq!(seen, None, "el hilo se abandona y no se espera"); + } +} diff --git a/crates/cp-mac/src/formats.rs b/crates/cp-mac/src/formats.rs index 382162b..9d258e7 100644 --- a/crates/cp-mac/src/formats.rs +++ b/crates/cp-mac/src/formats.rs @@ -27,6 +27,7 @@ pub const CATALOG: Catalog = Catalog { ("NeXT Rich Text Format v1.0 pasteboard type", "public.rtf"), ("NeXT RTFD pasteboard type", "com.apple.flat-rtfd"), ("Apple HTML pasteboard type", "public.html"), + ("Apple Web Archive pasteboard type", "com.apple.webarchive"), ], concealed: &[ "org.nspasteboard.ConcealedType", @@ -51,7 +52,15 @@ pub const CATALOG: Catalog = Catalog { files: &["public.file-url"], images_by_preference: &["public.png", "public.tiff"], equivalents: &[], - embeddable: &[], + embeddable: &[ + "com.apple.iWork.TSPNativeData", + "com.apple.iWork.TSPNativeMetadata", + "com.apple.iWork.TSPDescription", + "com.microsoft.Embed-Source", + "com.microsoft.Object-Descriptor", + "com.microsoft.Link-Source-Descriptor", + "com.microsoft.ObjectLink", + ], }; #[cfg(test)] @@ -95,6 +104,107 @@ mod tests { "NeXT TIFF v4.0 pasteboard type", ]; + const NUMBERS: &[&str] = &[ + "com.apple.iWork.TSPNativeData", + "com.apple.iWork.TSPNativeMetadata", + "com.apple.iWork.TSPDescription", + "com.apple.iWork.pasteboardState.numberOfTables-1", + "com.apple.iWork.pasteboardState.applicationName-Numbers", + "com.apple.iWork.pasteboardState.hasTables", + "com.apple.flat-rtfd", + "NeXT RTFD pasteboard type", + "com.apple.webarchive", + "Apple Web Archive pasteboard type", + "public.rtf", + "NeXT Rich Text Format v1.0 pasteboard type", + "public.utf16-external-plain-text", + "CorePasteboardFlavorType 0x75743136", + "public.utf8-plain-text", + "NSStringPboardType", + "public.html", + "Apple HTML pasteboard type", + "public.png", + "Apple PNG pasteboard type", + "com.adobe.pdf", + "Apple PDF pasteboard type", + "public.tiff", + "NeXT TIFF v4.0 pasteboard type", + ]; + + const EXCEL: &[&str] = &[ + "public.html", + "Apple HTML pasteboard type", + "public.utf16-plain-text", + "CorePasteboardFlavorType 0x75747874", + "public.utf8-plain-text", + "NSStringPboardType", + "com.adobe.pdf", + "Apple PDF pasteboard type", + "public.png", + "Apple PNG pasteboard type", + "public.tiff", + "NeXT TIFF v4.0 pasteboard type", + "public.rtf", + "NeXT Rich Text Format v1.0 pasteboard type", + "public.utf16-external-plain-text", + "CorePasteboardFlavorType 0x75743136", + "com.microsoft.Embed-Source", + "com.microsoft.Object-Descriptor", + "com.microsoft.Link-Source", + "com.microsoft.Link-Source-Descriptor", + "com.microsoft.Link", + "com.microsoft.DSP-Text", + "com.apple.webarchive", + "Apple Web Archive pasteboard type", + "com.microsoft.ole.source.46867.0x76580810e0", + "com.microsoft.DataObject", + "com.microsoft.appbundleid", + ]; + + const WORD: &[&str] = &[ + "com.microsoft.Object-Descriptor", + "public.rtf", + "NeXT Rich Text Format v1.0 pasteboard type", + "public.utf16-external-plain-text", + "CorePasteboardFlavorType 0x75743136", + "public.utf8-plain-text", + "NSStringPboardType", + "public.html", + "Apple HTML pasteboard type", + "public.utf16-plain-text", + "CorePasteboardFlavorType 0x75747874", + "com.adobe.pdf", + "Apple PDF pasteboard type", + "com.microsoft.Embed-Source", + "com.microsoft.Link-Source", + "com.microsoft.Link-Source-Descriptor", + "com.microsoft.ObjectLink", + "com.apple.webarchive", + "Apple Web Archive pasteboard type", + "com.apple.flat-rtfd", + "NeXT RTFD pasteboard type", + "com.microsoft.ole.source.68486.0x1053dc3c8", + "com.microsoft.DataObject", + "com.microsoft.appbundleid", + ]; + + const PAGES: &[&str] = &[ + "com.apple.iWork.TSPNativeData", + "com.apple.iWork.TSPNativeMetadata", + "com.apple.iWork.TSPDescription", + "com.apple.iWork.pasteboardState.applicationName-Pages", + "com.apple.flat-rtfd", + "NeXT RTFD pasteboard type", + "public.rtf", + "NeXT Rich Text Format v1.0 pasteboard type", + "public.utf16-external-plain-text", + "CorePasteboardFlavorType 0x75743136", + "public.utf8-plain-text", + "NSStringPboardType", + "dyn.ah62d4rv4gu8y63n2nuuhg5pbsm4ca6dbsr4gnkduqf31k3pcr7u1e3basv61a3k", + "NeXT smart paste pasteboard type", + ]; + #[test] fn safari_keeps_the_context_that_2x_threw_away() { let kept: Vec<&&str> = SAFARI @@ -118,6 +228,15 @@ mod tests { assert_eq!(CATALOG.decide(opaque), Take::Presence); } assert_eq!(CATALOG.decide("public.file-url"), Take::Payload); + let files = CATALOG.classify(FINDER); + for icon in ["public.tiff", "NeXT TIFF v4.0 pasteboard type"] { + assert_eq!(CATALOG.decide_in(files, icon), Take::Presence, "{icon}"); + } + assert_eq!(CATALOG.decide_in(files, "public.file-url"), Take::Payload); + assert_eq!( + CATALOG.decide_in(CATALOG.classify(PREVIEW), "public.tiff"), + Take::Payload + ); } #[test] @@ -134,11 +253,44 @@ mod tests { ("NeXT TIFF v4.0 pasteboard type", "public.tiff"), ("Apple PNG pasteboard type", "public.png"), ("NeXT RTFD pasteboard type", "com.apple.flat-rtfd"), + ("Apple Web Archive pasteboard type", "com.apple.webarchive"), ] { assert_eq!(CATALOG.canonical(legacy), modern); } } + #[test] + fn a_spreadsheet_is_text_even_when_it_offers_a_picture_of_itself() { + assert_eq!(CATALOG.classify(NUMBERS), Some(Family::Text)); + assert_eq!(CATALOG.classify(EXCEL), Some(Family::Text)); + assert_eq!(CATALOG.preferred_image(NUMBERS), Some("public.png")); + } + + #[test] + fn a_document_from_word_or_pages_is_text() { + assert_eq!(CATALOG.classify(WORD), Some(Family::Text)); + assert_eq!(CATALOG.classify(PAGES), Some(Family::Text)); + } + + #[test] + fn the_document_markers_are_noted_never_read() { + for marker in CATALOG.embeddable { + assert_eq!(CATALOG.decide(marker), Take::Presence, "{marker}"); + } + assert!( + !CATALOG + .embeddable + .iter() + .any(|marker| CATALOG.images_by_preference.contains(marker)) + ); + } + + #[test] + fn a_marker_without_text_does_not_hide_an_image() { + let picture_only = ["com.microsoft.Embed-Source", "public.png"]; + assert_eq!(CATALOG.classify(&picture_only), Some(Family::Image)); + } + #[test] fn the_type_that_hangs_the_app_is_never_asked_for() { assert_eq!( diff --git a/crates/cp-mac/src/paste.rs b/crates/cp-mac/src/paste.rs index 1223d18..aeee09c 100644 --- a/crates/cp-mac/src/paste.rs +++ b/crates/cp-mac/src/paste.rs @@ -1,17 +1,24 @@ use cp_core::destination::Destination; -use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase, SETTLE}; +use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase, Route, SETTLE, route_for}; use cp_mac_sys::frontmost; use cp_mac_sys::keyboard::{self, QWERTY_V}; use cp_mac_sys::keystroke::{self, Keystroke}; -use cp_mac_sys::permissions; -use cp_mac_sys::runloop; +use cp_mac_sys::permissions::Readiness; +use cp_mac_sys::{menu, runloop}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { - Sent { took: std::time::Duration }, + Sent { + took: std::time::Duration, + via: Route, + }, Degraded(Failure), } +pub fn route_of(ready: &Readiness) -> Option { + route_for(ready.can_post, ready.accessibility) +} + pub struct Paster { keys: Keystroke, } @@ -28,12 +35,21 @@ impl Paster { } pub fn paste_into(&self, target: &Destination, hide_panel: impl FnOnce()) -> Outcome { + self.paste_via(route_of(&Readiness::probe()), target, hide_panel) + } + + pub fn paste_via( + &self, + route: Option, + target: &Destination, + hide_panel: impl FnOnce(), + ) -> Outcome { let started = std::time::Instant::now(); let mut attempt = Attempt::default(); - if !permissions::can_post_events() { + let Some(route) = route else { return Outcome::Degraded(Failure::SendDenied); - } + }; hide_panel(); @@ -62,9 +78,14 @@ impl Paster { } attempt.sending(); - if self.keys.command(self.keycode()) { + let sent = match route { + Route::Keystroke => self.keys.command(self.keycode()), + Route::Menu => menu::press_paste(target.pid).is_ok(), + }; + if sent { Outcome::Sent { took: started.elapsed(), + via: route, } } else { Outcome::Degraded(Failure::SendDenied)