diff --git a/Cargo.lock b/Cargo.lock index f60fade..a0bc99f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,7 @@ name = "cp-win" version = "3.0.0" dependencies = [ "cp-core", + "cp-store", "cp-win-sys", "image", "windows", diff --git a/Cargo.toml b/Cargo.toml index 3896f8f..325f087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ windows = { version = "0.62", features = [ "Storage_Streams", "Foundation_Collections", "Win32_UI_Shell", + "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Com", "Win32_System_Com_StructuredStorage", diff --git a/crates/cp-core/src/dib.rs b/crates/cp-core/src/dib.rs index bd57194..ed54e6d 100644 --- a/crates/cp-core/src/dib.rs +++ b/crates/cp-core/src/dib.rs @@ -167,10 +167,25 @@ pub fn to_png(dib: &[u8]) -> Option> { } pub fn from_png(png: &[u8]) -> Option> { - if too_large(png.len()) { + from_encoded(png, image::ImageFormat::Png) +} + +pub fn from_jpeg(jpeg: &[u8]) -> Option> { + from_encoded(jpeg, image::ImageFormat::Jpeg) +} + +fn from_encoded(encoded: &[u8], format: image::ImageFormat) -> Option> { + if too_large(encoded.len()) { + return None; + } + let decoded = image::load_from_memory_with_format(encoded, format).ok()?; + from_image(&decoded) +} + +pub fn from_image(decoded: &image::DynamicImage) -> Option> { + if decoded.width() == 0 || decoded.height() == 0 { return None; } - let decoded = image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?; let mut bmp = std::io::Cursor::new(Vec::new()); decoded.write_to(&mut bmp, image::ImageFormat::Bmp).ok()?; let bmp = bmp.into_inner(); @@ -590,6 +605,42 @@ mod tests { assert_eq!(from_png(&vec![0u8; LARGEST_BITMAP + 1]), None); } + #[test] + fn a_jpeg_becomes_a_bitmap_too_and_a_png_is_not_a_jpeg() { + let dib = Dib::rgb32(4, 4).build(); + let png = to_png(&dib).expect("png"); + let mut jpeg = Vec::new(); + image::load_from_memory(&png) + .expect("relee") + .to_rgb8() + .write_to( + &mut std::io::Cursor::new(&mut jpeg), + image::ImageFormat::Jpeg, + ) + .expect("jpeg"); + let back = from_jpeg(&jpeg).expect("dib"); + let head = header(&back).expect("cabecera"); + assert_eq!((head.width, head.height.abs()), (4, 4)); + assert_eq!(head.bit_count, 24, "un JPEG no trae alfa y el DIB tampoco"); + assert_eq!( + from_jpeg(&png), + None, + "un PNG no se cuela por la puerta del JPEG" + ); + assert_eq!(from_jpeg(&[]), None); + assert_eq!(from_jpeg(&vec![0u8; LARGEST_BITMAP + 1]), None); + } + + #[test] + fn a_decoded_image_with_no_pixels_is_no_bitmap() { + assert_eq!(from_image(&image::DynamicImage::new_rgb8(0, 0)), None); + assert_eq!(from_image(&image::DynamicImage::new_rgba8(3, 0)), None); + let opaque = from_image(&image::DynamicImage::new_rgb8(1, 1)).expect("dib"); + assert_eq!(header(&opaque).expect("cabecera").bit_count, 24); + let translucent = from_image(&image::DynamicImage::new_rgba8(1, 1)).expect("dib"); + assert_eq!(header(&translucent).expect("cabecera").bit_count, 32); + } + #[test] fn a_png_from_a_dib_is_a_png() { let dib = Dib::rgb32(4, 4).build(); diff --git a/crates/cp-win-sys/src/clipboard.rs b/crates/cp-win-sys/src/clipboard.rs index 85eeeea..3b19a54 100644 --- a/crates/cp-win-sys/src/clipboard.rs +++ b/crates/cp-win-sys/src/clipboard.rs @@ -45,22 +45,7 @@ impl Clipboard { if handle.is_invalid() { return None; } - let global = HGLOBAL(handle.0); - - let size = unsafe { GlobalSize(global) }; - if size == 0 { - return None; - } - - let address = unsafe { GlobalLock(global) }; - if address.is_null() { - return None; - } - - let bytes = unsafe { std::slice::from_raw_parts(address.cast::(), size) }.to_vec(); - - let _ = unsafe { GlobalUnlock(global) }; - Some(bytes) + global_bytes(HGLOBAL(handle.0), usize::MAX) } pub fn size_of(&self, id: u32) -> Option { @@ -80,6 +65,23 @@ impl Drop for Clipboard { } } +pub(crate) fn global_bytes(global: HGLOBAL, up_to: usize) -> Option> { + let size = unsafe { GlobalSize(global) }; + if size == 0 || size > up_to { + return None; + } + + let address = unsafe { GlobalLock(global) }; + if address.is_null() { + return None; + } + + let bytes = unsafe { std::slice::from_raw_parts(address.cast::(), size) }.to_vec(); + + let _ = unsafe { GlobalUnlock(global) }; + Some(bytes) +} + pub fn sequence() -> Option { let raw = unsafe { GetClipboardSequenceNumber() }; (raw != 0).then_some(i64::from(raw)) diff --git a/crates/cp-win-sys/src/files.rs b/crates/cp-win-sys/src/files.rs new file mode 100644 index 0000000..f7ecbe8 --- /dev/null +++ b/crates/cp-win-sys/src/files.rs @@ -0,0 +1,119 @@ +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use windows::Win32::UI::Shell::{ + ILCreateFromPathW, ILFree, SHOpenFolderAndSelectItems, ShellExecuteW, +}; +use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; +use windows::core::{PCWSTR, w}; + +use crate::com::{Apartment, shell_path}; + +const RUNS_WHEN_OPENED: [&str; 8] = ["bat", "cmd", "com", "exe", "lnk", "msi", "ps1", "scr"]; + +const LAUNCHED: usize = 32; + +pub fn runs_when_opened(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| RUNS_WHEN_OPENED.contains(&ext.to_ascii_lowercase().as_str())) +} + +pub fn open(path: &Path) -> bool { + if runs_when_opened(path) { + return reveal(path); + } + let Some(wide) = wide_of(path) else { + return false; + }; + let _apartment = Apartment::enter(); + + let instance = unsafe { + ShellExecuteW( + None, + w!("open"), + PCWSTR(wide.as_ptr()), + None, + None, + SW_SHOWNORMAL, + ) + }; + instance.0 as usize > LAUNCHED +} + +pub fn reveal(path: &Path) -> bool { + let Some(wide) = wide_of(path) else { + return false; + }; + let _apartment = Apartment::enter(); + + let list = unsafe { ILCreateFromPathW(PCWSTR(wide.as_ptr())) }; + if list.is_null() { + return false; + } + + let selected = unsafe { SHOpenFolderAndSelectItems(list, None, 0) }; + + unsafe { ILFree(Some(list)) }; + selected.is_ok() +} + +fn wide_of(path: &Path) -> Option> { + let absolute = shell_path(path)?; + Some( + absolute + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_would_run_is_revealed_instead_of_opened() { + for name in [ + "setup.exe", + "script.BAT", + "run.cmd", + "tool.ps1", + "install.msi", + "atajo.lnk", + "viejo.com", + "fondo.scr", + ] { + assert!(runs_when_opened(Path::new(name)), "{name} se ejecuta"); + } + for name in [ + "informe.pdf", + "foto.png", + "nota.txt", + "sin-extension", + "carpeta.d", + ] { + assert!(!runs_when_opened(Path::new(name)), "{name} se abre"); + } + } + + #[test] + fn a_path_that_is_not_there_is_neither_opened_nor_revealed() { + let missing = Path::new(r"C:\no-existe-nada-de-nada\ni-esto.txt"); + assert!(!open(missing)); + assert!(!reveal(missing)); + assert_eq!(wide_of(missing), None); + } + + #[test] + fn the_wide_path_is_absolute_and_ends_in_a_terminator() { + let wide = wide_of(Path::new(".")).expect("ruta"); + assert_eq!(wide.last(), Some(&0)); + let text = String::from_utf16_lossy(&wide[..wide.len() - 1]); + assert!(text.contains(':'), "{text} no es absoluta"); + assert!( + !text.starts_with(r"\\?\"), + "{text} lleva el prefijo que el shell no entiende" + ); + } +} diff --git a/crates/cp-win-sys/src/lib.rs b/crates/cp-win-sys/src/lib.rs index 2ae4b36..3d167f6 100644 --- a/crates/cp-win-sys/src/lib.rs +++ b/crates/cp-win-sys/src/lib.rs @@ -2,11 +2,13 @@ pub mod clipboard; pub mod com; +pub mod files; pub mod formats; pub mod frontmost; pub mod keystroke; pub mod media; pub mod ocr; +pub mod ole; pub mod paths; pub mod permissions; pub mod reading; diff --git a/crates/cp-win-sys/src/ole.rs b/crates/cp-win-sys/src/ole.rs new file mode 100644 index 0000000..b2b1e3e --- /dev/null +++ b/crates/cp-win-sys/src/ole.rs @@ -0,0 +1,125 @@ +use windows::Win32::Foundation::HGLOBAL; +use windows::Win32::System::Com::{ + DVASPECT_CONTENT, FORMATETC, IDataObject, IStream, STGMEDIUM, STREAM_SEEK_SET, TYMED, + TYMED_HGLOBAL, TYMED_ISTREAM, +}; +use windows::Win32::System::Ole::{ + OleGetClipboard, OleInitialize, OleUninitialize, ReleaseStgMedium, +}; + +use crate::clipboard::global_bytes; + +const CHUNK: usize = 64 * 1024; + +pub struct Ole { + ours: bool, +} + +impl Ole { + pub fn enter() -> Self { + let entered = unsafe { OleInitialize(None) }; + Self { + ours: entered.is_ok(), + } + } +} + +impl Drop for Ole { + fn drop(&mut self) { + if self.ours { + unsafe { OleUninitialize() }; + } + } +} + +pub fn indexed_contents(format: u32, count: usize, up_to: usize) -> Vec>> { + let _ole = Ole::enter(); + let Ok(data) = (unsafe { OleGetClipboard() }) else { + return vec![None; count]; + }; + (0..count) + .map(|index| { + let indexed = i32::try_from(index).ok(); + indexed + .and_then(|lindex| one_of(&data, format, lindex, up_to)) + .or_else(|| { + (count == 1) + .then(|| one_of(&data, format, -1, up_to)) + .flatten() + }) + }) + .collect() +} + +fn one_of(data: &IDataObject, format: u32, lindex: i32, up_to: usize) -> Option> { + let asked = FORMATETC { + cfFormat: u16::try_from(format).ok()?, + ptd: std::ptr::null_mut(), + dwAspect: DVASPECT_CONTENT.0, + lindex, + tymed: (TYMED_HGLOBAL.0 | TYMED_ISTREAM.0) as u32, + }; + + let mut medium: STGMEDIUM = unsafe { data.GetData(&asked) }.ok()?; + let bytes = match TYMED(medium.tymed as i32) { + TYMED_HGLOBAL => { + let global: HGLOBAL = unsafe { medium.u.hGlobal }; + global_bytes(global, up_to) + } + TYMED_ISTREAM => { + let stream: Option = unsafe { (*medium.u.pstm).clone() }; + stream.and_then(|stream| stream_bytes(&stream, up_to)) + } + _ => None, + }; + + unsafe { ReleaseStgMedium(&mut medium) }; + bytes +} + +fn stream_bytes(stream: &IStream, up_to: usize) -> Option> { + let _ = unsafe { stream.Seek(0, STREAM_SEEK_SET, None) }; + let mut out = Vec::new(); + let mut chunk = vec![0u8; CHUNK]; + loop { + let mut read = 0u32; + + let status = + unsafe { stream.Read(chunk.as_mut_ptr().cast(), CHUNK as u32, Some(&mut read)) }; + if status.is_err() && read == 0 { + return None; + } + if read == 0 { + break; + } + out.extend_from_slice(&chunk[..read as usize]); + if out.len() > up_to { + return None; + } + } + (!out.is_empty()).then_some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entering_twice_is_not_a_problem() { + let first = Ole::enter(); + let second = Ole::enter(); + drop(second); + drop(first); + } + + #[test] + fn asking_for_nothing_gets_nothing_and_does_not_touch_the_clipboard() { + assert!(indexed_contents(0xC000, 0, CHUNK).is_empty()); + } + + #[test] + fn a_format_nobody_offers_comes_back_absent_for_every_index() { + let seen = indexed_contents(0xFFFE, 3, CHUNK); + assert_eq!(seen, vec![None, None, None]); + } +} diff --git a/crates/cp-win-sys/src/paths.rs b/crates/cp-win-sys/src/paths.rs index 0a236ab..c4cfd65 100644 --- a/crates/cp-win-sys/src/paths.rs +++ b/crates/cp-win-sys/src/paths.rs @@ -17,6 +17,10 @@ pub fn blobs_dir() -> Option { Some(data_dir()?.join("blobs")) } +pub fn pastes_dir() -> PathBuf { + std::env::temp_dir().join("CopyPaste") +} + #[cfg(test)] mod tests { use super::*; @@ -57,6 +61,19 @@ mod tests { ); } + #[test] + fn what_is_pasted_out_of_the_history_lands_in_temp_not_in_the_data_folder() { + let pastes = pastes_dir(); + assert!(pastes.starts_with(std::env::temp_dir()), "{pastes:?}"); + if let Some(root) = data_dir() { + assert!(!pastes.starts_with(&root), "{pastes:?} no es historial"); + } + assert_eq!( + pastes.file_name().and_then(|n| n.to_str()), + Some("CopyPaste") + ); + } + #[test] fn the_folder_is_the_one_2x_already_uses() { let root = data_dir().expect("LOCALAPPDATA"); diff --git a/crates/cp-win-sys/src/reading.rs b/crates/cp-win-sys/src/reading.rs index 16df3a0..8b791e5 100644 --- a/crates/cp-win-sys/src/reading.rs +++ b/crates/cp-win-sys/src/reading.rs @@ -36,15 +36,29 @@ pub fn within( } } -pub fn anything_within( - patience: Duration, - work: impl FnOnce() -> T + Send + 'static, -) -> Option { +pub struct Pending { + hear: std::sync::mpsc::Receiver, +} + +impl Pending { + pub fn wait(&self, patience: Duration) -> Option { + self.hear.recv_timeout(patience).ok() + } +} + +pub fn begin(work: impl FnOnce() -> T + Send + 'static) -> Pending { let (tell, hear) = std::sync::mpsc::channel(); std::thread::spawn(move || { let _ = tell.send(work()); }); - hear.recv_timeout(patience).ok() + Pending { hear } +} + +pub fn anything_within( + patience: Duration, + work: impl FnOnce() -> T + Send + 'static, +) -> Option { + begin(work).wait(patience) } #[cfg(test)] @@ -90,6 +104,27 @@ mod tests { ); } + #[test] + fn a_late_answer_is_picked_up_by_a_later_wait_on_the_same_read() { + let (release, gate) = std::sync::mpsc::channel::<()>(); + let pending = begin(move || { + let _ = gate.recv(); + 42 + }); + assert_eq!(pending.wait(Duration::from_millis(20)), None, "aún no"); + release.send(()).expect("la fuente contesta"); + assert_eq!( + pending.wait(Duration::from_secs(5)), + Some(42), + "sin leer dos veces" + ); + assert_eq!( + pending.wait(Duration::from_millis(20)), + None, + "y una vez entregada, no hay más" + ); + } + #[test] fn the_patience_sits_between_the_two_measured_worlds() { let good = Duration::from_millis(2); diff --git a/crates/cp-win/Cargo.toml b/crates/cp-win/Cargo.toml index a7c80a9..bbe543d 100644 --- a/crates/cp-win/Cargo.toml +++ b/crates/cp-win/Cargo.toml @@ -13,6 +13,7 @@ cp-win-sys.workspace = true windows.workspace = true [target.'cfg(target_os = "windows")'.dev-dependencies] +cp-store.workspace = true image.workspace = true [lints] diff --git a/crates/cp-win/examples/probe/battery.rs b/crates/cp-win/examples/probe/battery.rs index a80dd8d..4bbaf0a 100644 --- a/crates/cp-win/examples/probe/battery.rs +++ b/crates/cp-win/examples/probe/battery.rs @@ -1,22 +1,41 @@ use cp_core::formats::{Family, Take}; +use cp_core::paste_as::{Form, forms_for, render}; use cp_core::watch::{Cadence, Seen, Watcher}; use cp_win::capture::{self, Captured, capture}; +use cp_win::content::content_of; use cp_win::formats::CATALOG; use cp_win::paste::{Outcome, paste_into}; -use cp_win::restore::{Restored, to_clipboard, to_clipboard_as_plain_text}; +use cp_win::restore::{Restored, to_clipboard}; use cp_win::transfer::{self, Transfer}; +use cp_win::virtual_files::{self, DESCRIPTOR}; use cp_win::watching::Watching; use cp_win_sys::clipboard::{self, Clipboard}; -use cp_win_sys::formats::{CF_UNICODETEXT, name_of}; +use cp_win_sys::formats::{CF_HDROP, CF_UNICODETEXT, id_of, name_of}; use cp_win_sys::frontmost::{self, Target}; use cp_win_sys::permissions::Readiness; use cp_win_sys::reading::{self, PATIENCE, Reading}; use cp_win_sys::window::EditWindow; use cp_win_sys::writing::{Written, text_of, utf16_of}; -use cp_win_sys::{media, ocr, thumbnail}; - -const CASES: u32 = 39; -const MAY_SKIP: &[&str] = &["B4", "E1", "L1"]; +use cp_win_sys::{files, media, ocr, source, thumbnail}; + +const CASES: u32 = 46; +const MAY_SKIP: &[&str] = &["B2", "B4", "E1", "L1", "P1", "P2"]; +const SKIPPED: &str = "omitido: "; + +const VIRTUAL_FILE_NAME: &str = "adjunto virtual.txt"; +const VIRTUAL_FILE_BYTES: &[u8] = b"cp-e2 contenido"; +const VIRTUAL_FILE_SCRIPT: &str = "Add-Type -AssemblyName System.Windows.Forms; \ +$bytes = [System.Text.Encoding]::UTF8.GetBytes('cp-e2 contenido'); \ +$fgd = New-Object byte[] 596; \ +[BitConverter]::GetBytes([uint32]1).CopyTo($fgd, 0); \ +[BitConverter]::GetBytes([uint32]0x40).CopyTo($fgd, 4); \ +[BitConverter]::GetBytes([uint32]$bytes.Length).CopyTo($fgd, 72); \ +[System.Text.Encoding]::Unicode.GetBytes('adjunto virtual.txt').CopyTo($fgd, 76); \ +$d = New-Object System.Windows.Forms.DataObject; \ +$d.SetData('FileGroupDescriptorW', (New-Object System.IO.MemoryStream(,$fgd))); \ +$d.SetData('FileContents', (New-Object System.IO.MemoryStream(,$bytes))); \ +[System.Windows.Forms.Clipboard]::SetDataObject($d, $true); \ +'placed'"; struct Battery { passed: u32, @@ -42,6 +61,20 @@ impl Battery { } } + fn case_or_skip( + &mut self, + id: &'static str, + what: &str, + run: impl FnOnce() -> Result<(), String>, + ) { + match run() { + Err(why) if why.starts_with(SKIPPED) => { + self.skip(id, what, why.trim_start_matches(SKIPPED)); + } + other => self.case(id, what, || other), + } + } + fn skip(&mut self, id: &'static str, what: &str, why: &str) { if MAY_SKIP.contains(&id) { self.skipped.push(id); @@ -53,6 +86,76 @@ impl Battery { } } +struct Scratch { + dir: std::path::PathBuf, +} + +impl Scratch { + fn new(prefix: &str) -> Result { + let dir = std::env::temp_dir().join(format!("{prefix}-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + Ok(Self { dir }) + } + + fn file(&self, name: &str, bytes: &[u8]) -> Result { + let path = self.dir.join(name); + std::fs::write(&path, bytes).map_err(|why| why.to_string())?; + Ok(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.dir).ok(); + } +} + +fn shell_is_running() -> Result<(), String> { + match powershell_within("(Get-Process -Name explorer -ErrorAction SilentlyContinue).Count") { + Some(count) if !count.is_empty() && count != "0" => Ok(()), + Some(_) => Err(format!( + "{SKIPPED}no hay Explorador en esta sesión: no hay escritorio al que abrir o revelar" + )), + None => Err(format!("{SKIPPED}PowerShell no contesta")), + } +} + +fn powershell_within(script: &str) -> Option { + let mut child = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .stdin(std::process::Stdio::null()) + .spawn() + .ok()?; + let started = std::time::Instant::now(); + while started.elapsed() < std::time::Duration::from_secs(10) { + if child.try_wait().ok()?.is_some() { + let out = child.wait_with_output().ok()?; + if !out.status.success() { + return None; + } + return Some(String::from_utf8_lossy(&out.stdout).trim().to_owned()); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + child.kill().ok(); + child.wait().ok(); + None +} + +fn wait_until(what: &str, mut observed: impl FnMut() -> Option) -> Result<(), String> { + for _ in 0..8 { + std::thread::sleep(std::time::Duration::from_millis(300)); + match observed() { + Some(true) => return Ok(()), + Some(false) => {} + None => return Err("PowerShell no contesta o falló al preguntar".into()), + } + } + Err(format!("{what}: no pasó a tiempo")) +} + fn offered_names() -> Result, String> { let clipboard = Clipboard::open().ok_or("no se pudo abrir el portapapeles")?; Ok(clipboard.offered().into_iter().map(name_of).collect()) @@ -96,8 +199,10 @@ fn main() -> std::process::ExitCode { if names.is_empty() { return Err("el portapapeles está vacío: copia algo y repite".into()); } + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let family = CATALOG.classify(&refs); for name in &names { - let take = CATALOG.decide(name); + let take = CATALOG.decide_in(family, name); let mark = match take { Take::Payload => "copia", Take::Presence => "anota", @@ -108,18 +213,23 @@ fn main() -> std::process::ExitCode { Ok(()) }); - b.case("B2", "lo que se copia se puede leer de verdad", || { + b.case_or_skip("B2", "lo que se copia se puede leer de verdad", || { let clipboard = Clipboard::open().ok_or("no abrió")?; let ids = clipboard.offered(); let names: Vec = ids.iter().map(|id| name_of(*id)).collect(); let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - if CATALOG.refusal(&refs).is_some() { - return Err("la fuente pidió no registrar esto".into()); + if let Some(refusal) = CATALOG.refusal(&refs) { + return Err(format!( + "{SKIPPED}la fuente pidió no registrar esto ({refusal:?}): no hay nada que leer" + )); } + let family = CATALOG.classify(&refs); let mut read = 0usize; let mut bytes = 0usize; for (id, name) in ids.iter().zip(&names) { - if CATALOG.decide(name) != Take::Payload || CATALOG.costlier_twin(name, &refs) { + if CATALOG.decide_in(family, name) != Take::Payload + || CATALOG.costlier_twin(name, &refs) + { continue; } match clipboard.size_of(*id) { @@ -318,6 +428,34 @@ fn main() -> std::process::ExitCode { Ok(()) }); + b.case( + "G4", + "insistir ante una fuente que responde no cuesta un segundo intento", + || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-g4"))]); + } + let direct = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + capture(&clipboard).kept().ok_or("no se capturó")? + }; + let started = std::time::Instant::now(); + let got = capture::capture_insisting(capture::PATIENCE, cp_core::watch::RETRY); + let took = started.elapsed(); + match got { + Captured::Kept(item) if item == direct => { + if took < capture::PATIENCE { + Ok(()) + } else { + Err(format!("tardó {took:?}: hubo pausa sin motivo")) + } + } + other => Err(format!("llegó {other:?}")), + } + }, + ); + b.case("H1", "un texto copiado se convierte en un ítem", || { { let clipboard = Clipboard::open().ok_or("no abrió")?; @@ -499,38 +637,79 @@ fn main() -> std::process::ExitCode { } }); - b.case("I3", "pegar en plano no muda lo guardado", || { + b.case("I3", "pegar en plano no mutila el ítem guardado", || { + let html = id_of("HTML Format").ok_or("no se registró HTML Format")?; { let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i3-con-estilos"))]); + clipboard.replace(&[ + (CF_UNICODETEXT, &utf16_of("con estilos")), + (html, b"con estilos"), + ]); } let item = { let clipboard = Clipboard::open().ok_or("no abrió")?; - match capture(&clipboard) { - Captured::Kept(item) => item, - other => return Err(format!("no se capturó: {other:?}")), - } + capture(&clipboard).kept().ok_or("no se capturó")? }; - let before = item.clone(); + let had = item.formats.len(); + + let plain = render(Form::PlainText, &content_of(&item, None)) + .ok_or("no se ofreció la forma plana")? + .into_item(); let written = { let clipboard = Clipboard::open().ok_or("no abrió")?; - to_clipboard_as_plain_text(&clipboard, &item) + to_clipboard(&clipboard, &plain) }; - if !matches!( - written, - Restored::Written { - incomplete: false, - .. + match written { + Restored::Written { formats: 1, .. } => {} + other => return Err(format!("devolvió {other:?}")), + } + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + if clipboard.offered().contains(&html) { + return Err("quedó el HTML: no se pegó en plano".into()); } - ) { - return Err(format!("la escritura plana dio {written:?}")); } - if item != before { - return Err("el ítem se mutiló al pegarlo en plano".into()); + + if item.formats.len() != had { + return Err("el ítem perdió formatos".into()); + } + let restored = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard(&clipboard, &item) + }; + match restored { + Restored::Written { .. } => {} + other => return Err(format!("no se pudo restaurar con estilos: {other:?}")), + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + if !clipboard.offered().contains(&html) { + return Err("el HTML no volvió: el ítem había quedado mutilado".into()); } Ok(()) }); + b.case( + "I4", + "un ítem sin texto plano no se puede pegar en plano", + || { + let only_image = cp_core::item::Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![cp_core::item::Format { + id: "PNG".into(), + payload: cp_core::item::Payload::Inline(vec![1, 2, 3]), + }], + }; + let content = content_of(&only_image, None); + if forms_for(&content).contains(&Form::PlainText) { + return Err("se ofreció pegar en plano sin texto".into()); + } + match render(Form::PlainText, &content) { + None => Ok(()), + Some(other) => Err(format!("devolvió {other:?}")), + } + }, + ); + b.group("J · El vigilante en marcha"); b.case("J1", "una copia despierta al vigilante", || { @@ -803,6 +982,130 @@ fn main() -> std::process::ExitCode { }, ); + b.case("N4", "la miniatura va al almacén y vuelve", || { + let dir = std::env::temp_dir().join(format!("cp-probe-thumbs-{}", std::process::id())); + let blobs = cp_store::Blobs::at(&dir).map_err(|why| why.to_string())?; + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("falta el fixture: {why}"))?; + let thumb = cp_core::thumbnail::of_image(&png, cp_core::thumbnail::MAX_SIDE) + .ok_or("no se generó")?; + let digest = blobs.put(&thumb).map_err(|why| why.to_string())?; + let back = blobs + .get(&digest) + .map_err(|why| why.to_string())? + .ok_or("no volvió")?; + std::fs::remove_dir_all(&dir).ok(); + if back != thumb { + return Err("la miniatura volvió distinta".into()); + } + Ok(()) + }); + + b.group("O · Origen"); + + b.case( + "O1", + "la aplicación de origen se guarda con su nombre visible", + || { + let pid = frontmost::foreground() + .and_then(source::process_of) + .unwrap_or_else(std::process::id); + let name = source::name_of(pid).ok_or("sin nombre visible")?; + if name.is_empty() { + return Err("el nombre llegó vacío".into()); + } + if name.contains('\\') || name.to_ascii_lowercase().ends_with(".exe") { + return Err(format!("«{name}» es la ruta, no el nombre")); + } + + let store = cp_store::Store::in_memory().map_err(|why| why.to_string())?; + let id = store + .insert_text("uuid-origen", "algo copiado", 1) + .map_err(|why| why.to_string())?; + store + .set_source(id, &name, 2) + .map_err(|why| why.to_string())?; + let found = store + .list( + &cp_store::Filter { + query: Some(name.clone()), + ..Default::default() + }, + 10, + None, + ) + .map_err(|why| why.to_string())? + .rows; + if found.len() != 1 { + return Err(format!("buscando «{name}» salieron {} ítems", found.len())); + } + println!(" origen «{name}», guardado y encontrado"); + Ok(()) + }, + ); + + b.group("P · Abrir y revelar"); + + b.case_or_skip( + "P1", + "revelar un archivo lo deja seleccionado en el Explorador", + || { + shell_is_running()?; + let scratch = Scratch::new("cp-p1")?; + let file = scratch.file("revelado.txt", b"cp-p1")?; + if !files::reveal(&file) { + return Err("reveal dijo que no".into()); + } + let mut window_seen = false; + let mut selection = String::new(); + let shown = wait_until("el Explorador deja el archivo seleccionado", || { + let listing = powershell_within( + "(New-Object -ComObject Shell.Application).Windows() | ForEach-Object { $_.LocationURL + ' | ' + (($_.Document.SelectedItems() | ForEach-Object { $_.Path }) -join ',') }", + )?; + let ours = listing.lines().find(|line| line.contains("cp-p1")); + window_seen |= ours.is_some(); + selection = ours + .and_then(|line| line.split_once(" | ")) + .map(|(_, selected)| selected.trim().to_owned()) + .unwrap_or_default(); + Some(selection.contains("revelado.txt")) + }); + powershell_within( + "(New-Object -ComObject Shell.Application).Windows() | Where-Object { $_.LocationURL -like '*cp-p1*' } | ForEach-Object { $_.Quit() }", + ); + if shown.is_err() && !window_seen { + return Err(format!( + "{SKIPPED}el Explorador no abrió ninguna ventana en esta sesión: sin escritorio interactivo" + )); + } + if shown.is_err() && selection.is_empty() { + return Err(format!( + "{SKIPPED}la carpeta se abrió, pero la vista no reporta selección en esta sesión" + )); + } + shown.map_err(|why| format!("{why}; seleccionado: «{selection}»")) + }, + ); + + b.case_or_skip("P2", "abrir un archivo lo entrega a su aplicación", || { + shell_is_running()?; + let scratch = Scratch::new("cp-p2")?; + let file = scratch.file("abierto.txt", b"cp-p2")?; + if !files::open(&file) { + return Err("open dijo que no".into()); + } + let opened = wait_until("la aplicación tiene el documento abierto", || { + powershell_within( + "Get-Process | Where-Object { $_.MainWindowTitle -like '*abierto.txt*' } | ForEach-Object { $_.ProcessName }", + ) + .map(|names| !names.is_empty()) + }); + powershell_within( + "Get-Process | Where-Object { $_.MainWindowTitle -like '*abierto.txt*' } | ForEach-Object { $_.CloseMainWindow() | Out-Null }", + ); + opened + }); + b.group("C · Los formatos que cuelgan no se piden"); b.case("C1", "nada marcado como presencia se llega a pedir", || { @@ -875,6 +1178,75 @@ fn main() -> std::process::ExitCode { ), } + b.case( + "E2", + "un archivo virtual se captura con sus bytes y se pega como archivo", + || { + match powershell_within(VIRTUAL_FILE_SCRIPT).as_deref() { + Some("placed") => {} + other => return Err(format!("PowerShell no montó el DataObject: {other:?}")), + } + let item = capture::capture_now().kept().ok_or("no se capturó")?; + if item.kind != Some(cp_core::kind::Kind::File) { + return Err(format!("la clase salió {:?}", item.kind)); + } + let described = item + .format(DESCRIPTOR) + .and_then(|one| match &one.payload { + cp_core::item::Payload::Inline(bytes) => Some(bytes.as_slice()), + _ => None, + }) + .map(virtual_files::described_in) + .ok_or("el descriptor no se guardó")?; + if described.len() != 1 || described[0].name != VIRTUAL_FILE_NAME { + return Err(format!("el descriptor dice {described:?}")); + } + match item + .format(&virtual_files::contents_id(0)) + .map(|one| &one.payload) + { + Some(cp_core::item::Payload::Inline(bytes)) if bytes == VIRTUAL_FILE_BYTES => {} + other => return Err(format!("el contenido llegó como {other:?}")), + } + println!( + " {} formatos, {} bytes, clase {:?}", + item.formats.len(), + item.stored_bytes(), + item.kind + ); + + let written = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard(&clipboard, &item) + }; + match written { + Restored::Written { formats: 2, .. } => {} + other => { + return Err(format!( + "al pegar dio {other:?}, y no CF_HDROP más el efecto" + )); + } + } + let paths = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + cp_win::drop::paths_in(&clipboard.bytes(CF_HDROP).ok_or("sin CF_HDROP")?) + }; + let pasted = match paths.as_slice() { + [one] if one.ends_with(VIRTUAL_FILE_NAME) => std::path::PathBuf::from(one), + other => return Err(format!("el drop trae {other:?}")), + }; + let on_disk = std::fs::read(&pasted).map_err(|why| why.to_string())?; + if let Some(folder) = pasted.parent() { + std::fs::remove_dir_all(folder).ok(); + } + if on_disk != VIRTUAL_FILE_BYTES { + return Err("lo que llegó al disco no es lo que se copió".into()); + } + println!(" pegado en {}", pasted.display()); + Ok(()) + }, + ); + let ran = b.passed + b.failed + u32::try_from(b.skipped.len()).unwrap_or(u32::MAX); if ran != CASES { b.failed += 1; diff --git a/crates/cp-win/examples/twice.rs b/crates/cp-win/examples/twice.rs new file mode 100644 index 0000000..9cb37cc --- /dev/null +++ b/crates/cp-win/examples/twice.rs @@ -0,0 +1,103 @@ +#[cfg(target_os = "windows")] +mod on_windows { + use cp_core::item::{Format, Item, Payload}; + use cp_win::capture::capture_now; + use cp_win_sys::clipboard; + use std::time::Duration; + + pub fn main() { + println!("copia algo… (esperando la primera copia)"); + let first = next_copy(); + println!("primera: huella {:016x}", first.fingerprint()); + println!("vuelve a copiar exactamente lo mismo…"); + let second = next_copy(); + println!("segunda: huella {:016x}", second.fingerprint()); + for format in &first.formats { + let Some(a) = bytes_of(format) else { continue }; + match second + .formats + .iter() + .find(|f| f.id == format.id) + .and_then(bytes_of) + { + None => println!(" {:40} solo en la primera", format.id), + Some(b) if a == b => println!(" {:40} {:>8} B iguales", format.id, a.len()), + Some(b) => { + let at = a + .iter() + .zip(b) + .position(|(x, y)| x != y) + .unwrap_or(a.len().min(b.len())); + println!( + " {:40} {:>8} B / {:>8} B DIFIEREN desde el byte {at}", + format.id, + a.len(), + b.len() + ); + println!(" 1: {}", window(&format.id, a, at)); + println!(" 2: {}", window(&format.id, b, at)); + } + } + } + for format in &second.formats { + if bytes_of(format).is_some() && !first.formats.iter().any(|f| f.id == format.id) { + println!(" {:40} solo en la segunda", format.id); + } + } + if first.fingerprint() == second.fingerprint() { + println!("misma identidad: se reactivaría, no se duplicaría"); + } else { + println!("identidades distintas: hoy serían dos ítems"); + } + } + + fn next_copy() -> Item { + let started = clipboard::sequence(); + loop { + std::thread::sleep(Duration::from_millis(100)); + if clipboard::sequence() != started { + std::thread::sleep(Duration::from_millis(300)); + if let Some(item) = capture_now().kept() { + return item; + } + } + } + } + + fn bytes_of(format: &Format) -> Option<&[u8]> { + match &format.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => Some(bytes), + _ => None, + } + } + + fn window(id: &str, bytes: &[u8], at: usize) -> String { + let text = if id == "CF_UNICODETEXT" { + let units: Vec = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + let at = at / 2; + let from = at.saturating_sub(40); + let to = (at + 60).min(units.len()); + String::from_utf16_lossy(&units[from..to]) + } else { + let from = at.saturating_sub(40); + let to = (at + 60).min(bytes.len()); + String::from_utf8_lossy(&bytes[from..to]).into_owned() + }; + text.chars() + .map(|c| if c.is_control() { '·' } else { c }) + .collect() + } +} + +#[cfg(target_os = "windows")] +fn main() { + on_windows::main(); +} + +#[cfg(not(target_os = "windows"))] +fn main() {} diff --git a/crates/cp-win/src/capture.rs b/crates/cp-win/src/capture.rs index 178b85c..f108c68 100644 --- a/crates/cp-win/src/capture.rs +++ b/crates/cp-win/src/capture.rs @@ -1,14 +1,17 @@ pub use cp_core::capture::Captured; +use cp_core::capture::insisting; use cp_core::dib; use cp_core::formats::{Family, Refusal, Take}; -use cp_core::item::{Format, Item, Payload, SYNTHETIC_IMAGE}; +use cp_core::item::{BLOB_UP_TO, Format, Item, Payload, SYNTHETIC_IMAGE}; use cp_core::kind::{self, Kind}; -use cp_win_sys::clipboard::Clipboard; +use cp_win_sys::clipboard::{self, Clipboard}; use cp_win_sys::formats::name_of; use cp_win_sys::reading; use cp_win_sys::writing::text_of; +pub use crate::drop::paths_in; use crate::formats::CATALOG; +use crate::virtual_files::{self, CONTENTS, DESCRIPTOR}; pub const PATIENCE: std::time::Duration = std::time::Duration::from_millis(400); @@ -16,11 +19,42 @@ const _: () = assert!(PATIENCE.as_millis() > cp_win_sys::reading::PATIENCE.as_mi const _: () = assert!(PATIENCE.as_millis() < 30_000); pub fn capture_within(patience: std::time::Duration) -> Captured { - reading::anything_within(patience, || match Clipboard::open() { + reading::anything_within(patience, capture_now).unwrap_or(Captured::TooSlow) +} + +pub fn capture_insisting(patience: std::time::Duration, retry: cp_core::watch::Retry) -> Captured { + let started = sequence_now(); + let pending = reading::begin(capture_now); + let got = insisting(retry, sequence_now, || { + pending.wait(patience).unwrap_or(Captured::TooSlow) + }); + match got { + Captured::Nothing if sequence_now() != started => Captured::Superseded, + other => other, + } +} + +pub fn capture_now() -> Captured { + let captured = match Clipboard::open() { Some(clipboard) => capture(&clipboard), None => Captured::Nothing, - }) - .unwrap_or(Captured::TooSlow) + }; + let Captured::Kept(item) = captured else { + return captured; + }; + if !awaits_virtual_contents(&item) { + return Captured::Kept(item); + } + let started = sequence_now(); + let item = with_virtual_contents(item); + if sequence_now() != started { + return Captured::Superseded; + } + Captured::Kept(item) +} + +fn sequence_now() -> i64 { + clipboard::sequence().unwrap_or(i64::MIN) } pub fn capture(clipboard: &Clipboard) -> Captured { @@ -38,13 +72,16 @@ pub fn capture(clipboard: &Clipboard) -> Captured { return Captured::Refused(refusal); } - let family = CATALOG.classify(&offered); + let virtual_only = virtual_files::offered_without_a_drop(&offered); + let family = CATALOG + .classify(&offered) + .or_else(|| virtual_only.then_some(Family::Files)); let mut formats: Vec = Vec::new(); for (id, name) in ids.iter().zip(&names) { if formats.iter().any(|kept| kept.id == *name) { continue; } - let payload = payload_for(clipboard, *id, name, &offered); + let payload = payload_for(clipboard, family, *id, name, &offered); formats.push(Format { id: name.clone(), payload, @@ -53,6 +90,9 @@ pub fn capture(clipboard: &Clipboard) -> Captured { if let Some(png) = transcoded_image(clipboard, &ids, &names, &offered) { formats.push(png); } + if virtual_only { + announce_virtual_files(clipboard, &ids, &names, &mut formats); + } Captured::Kept(Item { kind: refine(family, &formats), @@ -70,8 +110,14 @@ fn asked_not_to_be_kept(clipboard: &Clipboard, ids: &[u32], names: &[String]) -> }) } -fn payload_for(clipboard: &Clipboard, id: u32, name: &str, offered: &[&str]) -> Payload { - if CATALOG.decide(name) != Take::Payload { +fn payload_for( + clipboard: &Clipboard, + family: Option, + id: u32, + name: &str, + offered: &[&str], +) -> Payload { + if CATALOG.decide_in(family, name) != Take::Payload { return Payload::Announced { size: None }; } if CATALOG.costlier_twin(name, offered) { @@ -112,6 +158,73 @@ fn transcoded_image( }) } +fn announce_virtual_files( + clipboard: &Clipboard, + ids: &[u32], + names: &[String], + formats: &mut Vec, +) { + let descriptor_id = ids + .iter() + .zip(names) + .find(|(_, name)| name.as_str() == DESCRIPTOR) + .map(|(id, _)| *id); + let Some(descriptor) = descriptor_id.and_then(|id| read(clipboard, id)) else { + return; + }; + let described = virtual_files::described_in(&descriptor); + if described.is_empty() { + return; + } + if let Some(slot) = formats.iter_mut().find(|one| one.id == DESCRIPTOR) { + slot.payload = Payload::stored(descriptor); + } + for (index, one) in described.iter().enumerate() { + if one.is_dir { + continue; + } + formats.push(Format { + id: virtual_files::contents_id(index), + payload: match one.size { + Some(size) if size > BLOB_UP_TO as u64 => Payload::TooBig { + size: usize::try_from(size).unwrap_or(usize::MAX), + }, + _ => Payload::Absent, + }, + }); + } +} + +fn awaits_virtual_contents(item: &Item) -> bool { + item.formats.iter().any(|one| { + matches!(one.payload, Payload::Absent) && virtual_files::index_of(&one.id).is_some() + }) +} + +fn with_virtual_contents(mut item: Item) -> Item { + let Some(count) = bytes_of(&item.formats, DESCRIPTOR) + .map(|descriptor| virtual_files::described_in(descriptor).len()) + else { + return item; + }; + let Some(contents_id) = cp_win_sys::formats::id_of(CONTENTS) else { + return item; + }; + let delivered = cp_win_sys::ole::indexed_contents(contents_id, count, BLOB_UP_TO); + for format in &mut item.formats { + if !matches!(format.payload, Payload::Absent) { + continue; + } + if let Some(bytes) = virtual_files::index_of(&format.id) + .and_then(|index| delivered.get(index)) + .and_then(Clone::clone) + { + format.payload = Payload::stored(bytes); + } + } + item +} + fn refine(family: Option, formats: &[Format]) -> Option { match family? { Family::Image => Some(Kind::Image), @@ -124,11 +237,17 @@ fn refine(family: Option, formats: &[Format]) -> Option { let name = path.rsplit(['\\', '/']).next().unwrap_or(&path).to_owned(); kind::classify_file(&name, path.ends_with('\\')) } - None => Kind::File, + None => first_described(formats) + .map_or(Kind::File, |one| kind::classify_file(&one.name, one.is_dir)), }), } } +fn first_described(formats: &[Format]) -> Option { + let descriptor = bytes_of(formats, DESCRIPTOR)?; + virtual_files::described_in(descriptor).into_iter().next() +} + fn bytes_of<'a>(formats: &'a [Format], id: &str) -> Option<&'a [u8]> { formats .iter() @@ -144,66 +263,11 @@ fn first_path(formats: &[Format]) -> Option { paths_in(drop).into_iter().next() } -pub fn paths_in(drop: &[u8]) -> Vec { - let Some(offset) = drop - .get(..4) - .map(|four| u32::from_le_bytes([four[0], four[1], four[2], four[3]]) as usize) - else { - return Vec::new(); - }; - let wide = drop.get(16..20).is_some_and(|flag| flag[0] != 0); - let Some(names) = drop.get(offset..) else { - return Vec::new(); - }; - if !wide { - return names - .split(|byte| *byte == 0) - .take_while(|part| !part.is_empty()) - .map(|part| String::from_utf8_lossy(part).into_owned()) - .collect(); - } - let units: Vec = names - .as_chunks::<2>() - .0 - .iter() - .map(|pair| u16::from_le_bytes(*pair)) - .collect(); - units - .split(|unit| *unit == 0) - .take_while(|part| !part.is_empty()) - .map(String::from_utf16_lossy) - .collect() -} - #[cfg(test)] mod tests { use super::*; - - fn drop_files(paths: &[&str], wide: bool) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&20u32.to_le_bytes()); - out.extend_from_slice(&0u32.to_le_bytes()); - out.extend_from_slice(&0u32.to_le_bytes()); - out.extend_from_slice(&0u32.to_le_bytes()); - out.extend_from_slice(&u32::from(wide).to_le_bytes()); - for path in paths { - if wide { - for unit in path.encode_utf16() { - out.extend_from_slice(&unit.to_le_bytes()); - } - out.extend_from_slice(&0u16.to_le_bytes()); - } else { - out.extend_from_slice(path.as_bytes()); - out.push(0); - } - } - if wide { - out.extend_from_slice(&0u16.to_le_bytes()); - } else { - out.push(0); - } - out - } + use crate::drop::drop_of; + use crate::virtual_files::descriptor_of; #[test] fn the_whole_capture_has_a_ceiling_well_under_the_thirty_seconds() { @@ -220,46 +284,11 @@ mod tests { assert_eq!(seen, None, "el hilo se abandona y no se espera"); } - #[test] - fn every_copied_path_is_read_not_just_the_first() { - let paths = [r"C:\uno.txt", r"C:\dos.txt", r"C:\una carpeta"]; - let seen = paths_in(&drop_files(&paths, true)); - assert_eq!(seen, paths); - } - - #[test] - fn a_legacy_ansi_drop_is_read_too() { - let paths = [r"C:\uno.txt", r"C:\dos.txt"]; - let seen = paths_in(&drop_files(&paths, false)); - assert_eq!(seen, paths); - } - - #[test] - fn a_single_path_comes_back_alone() { - assert_eq!( - paths_in(&drop_files(&[r"C:\solo.png"], true)), - [r"C:\solo.png"] - ); - } - - #[test] - fn nonsense_is_not_a_drop() { - assert!(paths_in(&[]).is_empty()); - assert!(paths_in(&[0, 0, 0]).is_empty()); - assert!(paths_in(&u32::MAX.to_le_bytes()).is_empty()); - } - - #[test] - fn a_path_with_accents_and_spaces_survives() { - let paths = [r"C:\Mis Documentos\informe ñ.pdf"]; - assert_eq!(paths_in(&drop_files(&paths, true)), paths); - } - #[test] fn the_class_of_a_drop_comes_from_its_first_path() { let formats = vec![Format { id: "CF_HDROP".into(), - payload: Payload::Inline(drop_files(&[r"C:\video.mkv"], true)), + payload: Payload::Inline(drop_of(&[r"C:\video.mkv"])), }]; assert_eq!(refine(Some(Family::Files), &formats), Some(Kind::Video)); } @@ -268,7 +297,7 @@ mod tests { fn a_folder_is_told_apart_by_its_trailing_separator() { let formats = vec![Format { id: "CF_HDROP".into(), - payload: Payload::Inline(drop_files(&[r"C:\Documentos\\"], true)), + payload: Payload::Inline(drop_of(&[r"C:\Documentos\\"])), }]; assert_eq!(refine(Some(Family::Files), &formats), Some(Kind::Folder)); } @@ -301,6 +330,69 @@ mod tests { assert_eq!(refine(None, &[]), None); } + #[test] + fn a_virtual_file_is_classed_by_the_name_its_descriptor_gives() { + let formats = vec![Format { + id: DESCRIPTOR.into(), + payload: Payload::Inline(descriptor_of(&[("captura.png", Some(9), false)])), + }]; + assert_eq!(refine(Some(Family::Files), &formats), Some(Kind::Image)); + let folder = vec![Format { + id: DESCRIPTOR.into(), + payload: Payload::Inline(descriptor_of(&[("adjuntos", None, true)])), + }]; + assert_eq!(refine(Some(Family::Files), &folder), Some(Kind::Folder)); + let real_drop_wins = vec![ + Format { + id: "CF_HDROP".into(), + payload: Payload::Inline(drop_of(&[r"C:\video.mkv"])), + }, + folder[0].clone(), + ]; + assert_eq!( + refine(Some(Family::Files), &real_drop_wins), + Some(Kind::Video) + ); + } + + #[test] + fn only_a_virtual_item_with_something_absent_waits_for_the_ole_read() { + let waiting = Item { + kind: Some(Kind::File), + formats: vec![ + Format { + id: DESCRIPTOR.into(), + payload: Payload::Inline(descriptor_of(&[("a.txt", Some(1), false)])), + }, + Format { + id: virtual_files::contents_id(0), + payload: Payload::Absent, + }, + ], + }; + assert!(awaits_virtual_contents(&waiting)); + let too_big = Item { + kind: Some(Kind::File), + formats: vec![Format { + id: virtual_files::contents_id(0), + payload: Payload::TooBig { size: 1 << 40 }, + }], + }; + assert!( + !awaits_virtual_contents(&too_big), + "lo que no cabe no se pide" + ); + let plain_absent = Item { + kind: Some(Kind::Text), + formats: vec![Format { + id: "CF_UNICODETEXT".into(), + payload: Payload::Absent, + }], + }; + assert!(!awaits_virtual_contents(&plain_absent)); + assert!(!awaits_virtual_contents(&Item::plain("hola"))); + } + #[test] fn a_drop_with_no_paths_is_still_a_file() { let formats = vec![Format { diff --git a/crates/cp-win/src/content.rs b/crates/cp-win/src/content.rs new file mode 100644 index 0000000..e472f52 --- /dev/null +++ b/crates/cp-win/src/content.rs @@ -0,0 +1,265 @@ +use cp_core::item::{Item, Payload, SYNTHETIC_IMAGE, SYNTHETIC_TEXT}; +use cp_core::paste_as::Content; +use cp_win_sys::writing::text_of; +use std::borrow::Cow; + +use crate::drop::paths_in; + +pub fn content_of<'a>(item: &'a Item, ocr: Option<&'a str>) -> Content<'a> { + let bytes = |id: &str| { + item.format(id).and_then(|format| match &format.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => Some(bytes.as_slice()), + _ => None, + }) + }; + let utf8 = |id: &str| bytes(id).and_then(|bytes| std::str::from_utf8(bytes).ok()); + let html = utf8("HTML Format"); + Content { + kind: item.kind, + text: bytes("CF_UNICODETEXT") + .and_then(text_of) + .map(Cow::Owned) + .or_else(|| utf8(SYNTHETIC_TEXT).map(Cow::Borrowed)), + html: html.map(Cow::Borrowed), + rich: html.is_some() || bytes("Rich Text Format").is_some(), + image: bytes("PNG").or_else(|| bytes(SYNTHETIC_IMAGE)), + paths: bytes("CF_HDROP").map(paths_in).unwrap_or_default(), + title: None, + ocr, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::drop::drop_of; + use cp_core::item::Format; + use cp_core::kind::Kind; + use cp_core::paste_as::{Form, Rendered, forms_for, render}; + use cp_win_sys::writing::utf16_of; + + const WORD_HTML: &str = "Version:0.9\r\nStartHTML:0000000105\r\nEndHTML:0000000200\r\nStartFragment:0000000141\r\nEndFragment:0000000164\r\n

hola

"; + + fn inline(id: &str, bytes: &[u8]) -> Format { + Format { + id: id.into(), + payload: Payload::Inline(bytes.to_vec()), + } + } + + fn unicode(text: &str) -> Format { + inline("CF_UNICODETEXT", &utf16_of(text)) + } + + #[test] + fn a_word_paragraph_is_rich_with_html_and_plain_text() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + inline("HTML Format", WORD_HTML.as_bytes()), + unicode("hola"), + Format { + id: "CF_DIBV5".into(), + payload: Payload::Announced { size: None }, + }, + ], + }; + let content = content_of(&item, None); + assert_eq!(content.text.as_deref(), Some("hola")); + assert_eq!(content.html.as_deref(), Some(WORD_HTML)); + assert!(content.rich); + assert_eq!(content.image, None); + assert_eq!(forms_for(&content), vec![Form::PlainText, Form::Markdown]); + } + + #[test] + fn the_clipboard_header_of_the_html_never_reaches_the_markdown() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![inline("HTML Format", WORD_HTML.as_bytes()), unicode("hola")], + }; + let markdown = render(Form::Markdown, &content_of(&item, None)); + assert_eq!(markdown, Some(Rendered::Text("**hola**".into()))); + } + + #[test] + fn html_alone_is_enough_to_be_rich() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![inline("HTML Format", b"hola"), unicode("hola")], + }; + let content = content_of(&item, None); + assert!(content.rich); + assert_eq!(content.html.as_deref(), Some("hola")); + } + + #[test] + fn plain_rtf_alone_is_rich_and_a_blob_reads_like_an_inline_payload() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + Format { + id: "CF_UNICODETEXT".into(), + payload: Payload::Blob(utf16_of("hola")), + }, + ], + }; + let content = content_of(&item, None); + assert!(content.rich, "el RTF a solas ya es enriquecido"); + assert_eq!( + content.text.as_deref(), + Some("hola"), + "un blob se lee igual que un inline" + ); + } + + #[test] + fn a_rich_text_with_an_image_offers_both_families() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + unicode("hola"), + inline("PNG", &[137, 80, 78, 71]), + ], + }; + assert_eq!( + forms_for(&content_of(&item, None)), + vec![Form::PlainText, Form::ImageJpeg], + "un documento con una imagen incrustada se puede pegar como texto o como su imagen" + ); + } + + #[test] + fn an_rtf_only_copy_is_rich_but_has_no_markdown() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + unicode("hola"), + ], + }; + let content = content_of(&item, None); + assert!(content.rich); + assert_eq!(content.html, None); + assert_eq!(forms_for(&content), vec![Form::PlainText]); + } + + #[test] + fn a_drop_becomes_its_paths_and_a_link_carries_no_title() { + let files = Item { + kind: Some(Kind::File), + formats: vec![inline( + "CF_HDROP", + &drop_of(&[r"C:\Mis Documentos\cp a9.png", r"C:\dos.txt"]), + )], + }; + let content = content_of(&files, None); + assert_eq!( + content.paths, + vec![r"C:\Mis Documentos\cp a9.png", r"C:\dos.txt"] + ); + assert_eq!(content.text, None); + assert_eq!(forms_for(&content), vec![Form::Path]); + + let link = Item { + kind: Some(Kind::Link), + formats: vec![ + unicode("https://ejemplo.test"), + inline("Chromium internal source URL", b"https://ejemplo.test"), + ], + }; + let content = content_of(&link, None); + assert_eq!(content.title, None); + let forms = forms_for(&content); + assert!(forms.contains(&Form::LinkMarkdown)); + assert!( + !forms.contains(&Form::LinkTitled), + "sin título no hay forma titulada" + ); + } + + #[test] + fn a_stored_image_and_what_was_read_in_it() { + let item = Item { + kind: Some(Kind::Image), + formats: vec![inline("PNG", &[137, 80, 78, 71])], + }; + let content = content_of(&item, Some("Pedido 4417")); + assert_eq!(content.image, Some(&[137u8, 80, 78, 71][..])); + assert_eq!(content.ocr, Some("Pedido 4417")); + assert!(!content.rich); + assert_eq!(forms_for(&content), vec![Form::ImageJpeg, Form::ImageOcr]); + } + + #[test] + fn a_bitmap_only_copy_is_the_png_we_transcoded_from_it() { + let item = Item { + kind: Some(Kind::Image), + formats: vec![ + Format { + id: "CF_DIBV5".into(), + payload: Payload::Announced { size: Some(4) }, + }, + inline("CF_DIB", &[40, 0, 0, 0]), + inline(SYNTHETIC_IMAGE, &[137, 80, 78, 71]), + ], + }; + let content = content_of(&item, None); + assert_eq!( + content.image, + Some(&[137u8, 80, 78, 71][..]), + "el DIB crudo no se ofrece: la imagen es el PNG sintético" + ); + let both = Item { + kind: Some(Kind::Image), + formats: vec![inline(SYNTHETIC_IMAGE, &[1]), inline("PNG", &[2])], + }; + assert_eq!( + content_of(&both, None).image, + Some(&[2u8][..]), + "el PNG de la fuente manda si lo hay" + ); + } + + #[test] + fn what_the_store_wrote_itself_is_read_back_the_same_way() { + let edited = Item::plain("editado a mano"); + assert_eq!( + content_of(&edited, None).text.as_deref(), + Some("editado a mano") + ); + let synthetic = Item { + kind: Some(Kind::Image), + formats: vec![inline(SYNTHETIC_IMAGE, &[1, 2, 3])], + }; + assert_eq!(content_of(&synthetic, None).image, Some(&[1u8, 2, 3][..])); + } + + #[test] + fn the_system_text_wins_over_the_synthetic_one() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![inline(SYNTHETIC_TEXT, b"sintetico"), unicode("del sistema")], + }; + assert_eq!(content_of(&item, None).text.as_deref(), Some("del sistema")); + } + + #[test] + fn bytes_that_are_not_text_do_not_pretend_to_be() { + let lone_surrogate = [0x00, 0xD8, 0x00, 0x00]; + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("CF_UNICODETEXT", &lone_surrogate), + inline("HTML Format", &[0xff, 0xfe, 0x00]), + ], + }; + let content = content_of(&item, None); + assert_eq!(content.text, None); + assert_eq!(content.html, None); + assert!(!content.rich, "un HTML ilegible no hace rico al ítem"); + } +} diff --git a/crates/cp-win/src/drop.rs b/crates/cp-win/src/drop.rs new file mode 100644 index 0000000..16804c4 --- /dev/null +++ b/crates/cp-win/src/drop.rs @@ -0,0 +1,109 @@ +const HEADER: usize = 20; + +pub fn paths_in(drop: &[u8]) -> Vec { + let Some(offset) = drop + .get(..4) + .map(|four| u32::from_le_bytes([four[0], four[1], four[2], four[3]]) as usize) + else { + return Vec::new(); + }; + let wide = drop.get(16..20).is_some_and(|flag| flag[0] != 0); + let Some(names) = drop.get(offset..) else { + return Vec::new(); + }; + if !wide { + return names + .split(|byte| *byte == 0) + .take_while(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect(); + } + let units: Vec = names + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + units + .split(|unit| *unit == 0) + .take_while(|part| !part.is_empty()) + .map(String::from_utf16_lossy) + .collect() +} + +pub fn drop_of>(paths: &[S]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(HEADER as u32).to_le_bytes()); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(&1u32.to_le_bytes()); + for path in paths { + for unit in path.as_ref().encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + out.extend_from_slice(&0u16.to_le_bytes()); + } + out.extend_from_slice(&0u16.to_le_bytes()); + out +} + +#[cfg(test)] +pub(crate) fn ansi_drop_of(paths: &[&str]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(HEADER as u32).to_le_bytes()); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(&0u32.to_le_bytes()); + for path in paths { + out.extend_from_slice(path.as_bytes()); + out.push(0); + } + out.push(0); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_copied_path_is_read_not_just_the_first() { + let paths = [r"C:\uno.txt", r"C:\dos.txt", r"C:\una carpeta"]; + assert_eq!(paths_in(&drop_of(&paths)), paths); + } + + #[test] + fn a_legacy_ansi_drop_is_read_too() { + let paths = [r"C:\uno.txt", r"C:\dos.txt"]; + assert_eq!(paths_in(&ansi_drop_of(&paths)), paths); + } + + #[test] + fn a_single_path_comes_back_alone() { + assert_eq!(paths_in(&drop_of(&[r"C:\solo.png"])), [r"C:\solo.png"]); + } + + #[test] + fn nonsense_is_not_a_drop() { + assert!(paths_in(&[]).is_empty()); + assert!(paths_in(&[0, 0, 0]).is_empty()); + assert!(paths_in(&u32::MAX.to_le_bytes()).is_empty()); + } + + #[test] + fn a_path_with_accents_and_spaces_survives() { + let paths = [r"C:\Mis Documentos\informe ñ.pdf"]; + assert_eq!(paths_in(&drop_of(&paths)), paths); + } + + #[test] + fn the_drop_we_build_is_the_shape_the_shell_reads() { + let drop = drop_of(&[r"C:\a.txt"]); + assert_eq!( + &drop[..4], + &20u32.to_le_bytes(), + "los nombres empiezan tras la cabecera" + ); + assert_eq!(drop[16], 1, "y van en UTF-16"); + assert_eq!(&drop[drop.len() - 4..], &[0, 0, 0, 0], "doble terminador"); + assert!(paths_in(&drop_of::<&str>(&[])).is_empty()); + } +} diff --git a/crates/cp-win/src/formats.rs b/crates/cp-win/src/formats.rs index 69b7b6f..5173bc2 100644 --- a/crates/cp-win/src/formats.rs +++ b/crates/cp-win/src/formats.rs @@ -294,10 +294,15 @@ mod tests { } #[test] - fn virtual_files_are_noted_and_never_asked_for() { + fn virtual_files_are_noted_and_never_asked_for_through_the_catalog() { for virtualised in ["FileGroupDescriptorW", "FileContents"] { assert_eq!(CATALOG.decide(virtualised), Take::Presence); } + assert_eq!( + CATALOG.classify(&["FileGroupDescriptorW", "FileContents"]), + None, + "sin CF_HDROP el catálogo no ve archivos: la captura los pide por OLE cuando no hay drop" + ); } #[test] diff --git a/crates/cp-win/src/lib.rs b/crates/cp-win/src/lib.rs index 2214e06..5f28e4d 100644 --- a/crates/cp-win/src/lib.rs +++ b/crates/cp-win/src/lib.rs @@ -1,10 +1,13 @@ #![cfg(target_os = "windows")] pub mod capture; +pub mod content; +pub mod drop; pub mod formats; pub mod paste; pub mod restore; pub mod transfer; +pub mod virtual_files; pub mod watch; pub mod watching; diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index fefa62c..4cc5e79 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -1,9 +1,17 @@ use cp_core::dib; -use cp_core::item::{Item, Payload, SYNTHETIC_IMAGE, SYNTHETIC_TEXT}; +use cp_core::item::{Item, Payload, SYNTHETIC_IMAGE, SYNTHETIC_JPEG, SYNTHETIC_TEXT}; use cp_win_sys::clipboard::Clipboard; -use cp_win_sys::formats::{CF_DIBV5, CF_UNICODETEXT, id_of}; +use cp_win_sys::formats::{CF_DIBV5, CF_HDROP, CF_UNICODETEXT, id_of}; use cp_win_sys::writing::{Written, utf16_of}; +use crate::drop::drop_of; +use crate::transfer::COPY; +use crate::virtual_files::{self, Materialized}; + +const PNG: &str = "PNG"; +const JFIF: &str = "JFIF"; +const DROP_EFFECT: &str = "Preferred DropEffect"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Restored { Written { formats: usize, incomplete: bool }, @@ -12,9 +20,11 @@ pub enum Restored { } pub fn to_clipboard(clipboard: &Clipboard, item: &Item) -> Restored { - let mut owned: Vec<(u32, Vec)> = Vec::new(); - let mut returned = 0; + let (mut owned, mut returned) = pasted_files(item); for format in &item.formats { + if virtual_files::is_virtual(&format.id) { + continue; + } let Some(bytes) = payload_of(format) else { continue; }; @@ -44,17 +54,29 @@ pub fn to_clipboard(clipboard: &Clipboard, item: &Item) -> Restored { } } -pub fn to_clipboard_as_plain_text(clipboard: &Clipboard, item: &Item) -> Restored { - let Some(text) = plain_text_of(item) else { - return Restored::NothingToWrite; +fn pasted_files(item: &Item) -> (Vec<(u32, Vec)>, usize) { + let Some(Materialized { paths, complete }) = + virtual_files::materialize(item, &cp_win_sys::paths::pastes_dir()) + else { + return (Vec::new(), 0); }; - match clipboard.replace(&[(CF_UNICODETEXT, &text)]) { - Written::Placed { .. } => Restored::Written { - formats: 1, - incomplete: false, - }, - Written::Refused => Restored::Failed, + let names: Vec = paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + let mut owned = vec![(CF_HDROP, drop_of(&names))]; + if let Some(effect) = id_of(DROP_EFFECT) { + owned.push((effect, COPY.to_le_bytes().to_vec())); } + let delivered = if complete { + item.formats + .iter() + .filter(|one| virtual_files::is_virtual(&one.id)) + .count() + } else { + 0 + }; + (owned, delivered) } fn payload_of(format: &cp_core::item::Format) -> Option<&[u8]> { @@ -72,31 +94,23 @@ fn writable(id: &str, bytes: &[u8]) -> Vec<(u32, Vec)> { }; } if id == SYNTHETIC_IMAGE { - let mut both = Vec::new(); - if let Some(png) = id_of("PNG") { - both.push((png, bytes.to_vec())); - } - if let Some(raw) = dib::from_png(bytes) { - both.push((CF_DIBV5, raw)); - } - return both; + return image_and_bitmap(PNG, bytes, dib::from_png(bytes)); + } + if id == SYNTHETIC_JPEG { + return image_and_bitmap(JFIF, bytes, dib::from_jpeg(bytes)); } id_of(id).map_or_else(Vec::new, |id| vec![(id, bytes.to_vec())]) } -fn plain_text_of(item: &Item) -> Option> { - for format in &item.formats { - let Some(bytes) = payload_of(format) else { - continue; - }; - if format.id == "CF_UNICODETEXT" { - return Some(bytes.to_vec()); - } - if format.id == SYNTHETIC_TEXT { - return std::str::from_utf8(bytes).ok().map(utf16_of); - } +fn image_and_bitmap(name: &str, encoded: &[u8], raw: Option>) -> Vec<(u32, Vec)> { + let mut both = Vec::new(); + if let Some(id) = id_of(name) { + both.push((id, encoded.to_vec())); + } + if let Some(raw) = raw { + both.push((CF_DIBV5, raw)); } - None + both } #[cfg(test)] @@ -129,6 +143,26 @@ mod tests { ); } + #[test] + fn a_rendered_jpeg_goes_back_as_jfif_and_a_bitmap() { + let jpeg = jpeg_bytes(); + let written = writable(SYNTHETIC_JPEG, &jpeg); + assert_eq!(written.len(), 2, "el JPEG y el clasico"); + let (jfif, _) = written + .iter() + .find(|(_, bytes)| bytes == &jpeg) + .expect("el JPEG viaja intacto"); + assert_eq!(cp_win_sys::formats::name_of(*jfif), JFIF); + assert!(written.iter().any(|(id, _)| *id == CF_DIBV5)); + } + + #[test] + fn a_jpeg_that_does_not_decode_still_goes_back_as_itself() { + let written = writable(SYNTHETIC_JPEG, b"esto no es un jpeg"); + assert_eq!(written.len(), 1, "sin bitmap, pero el JPEG no se pierde"); + assert!(written.iter().all(|(id, _)| *id != CF_DIBV5)); + } + #[test] fn a_name_the_system_does_not_know_is_registered_not_dropped() { let written = writable("Rich Text Format", b"{\\rtf1}"); @@ -151,6 +185,41 @@ mod tests { assert_eq!(item.formats.len(), 1, "y sigue siendo un solo formato"); } + #[test] + fn virtual_files_never_go_back_as_themselves_but_as_files_on_disk() { + use crate::virtual_files::{DESCRIPTOR, contents_id, descriptor_of}; + let item = Item { + kind: Some(cp_core::kind::Kind::File), + formats: vec![ + inline(DESCRIPTOR, &descriptor_of(&[("nota.txt", Some(4), false)])), + Format { + id: "FileContents".into(), + payload: Payload::Announced { size: None }, + }, + inline(&contents_id(0), b"hola"), + ], + }; + let (owned, delivered) = pasted_files(&item); + assert_eq!( + delivered, 3, + "los tres formatos virtuales salen por el drop" + ); + assert_eq!(owned.len(), 2, "CF_HDROP y el efecto"); + assert_eq!(owned[0].0, CF_HDROP); + let paths = crate::drop::paths_in(&owned[0].1); + assert_eq!(paths.len(), 1); + assert!(paths[0].ends_with("nota.txt"), "{}", paths[0]); + assert_eq!(std::fs::read(&paths[0]).expect("en disco"), b"hola"); + assert_eq!(owned[1].1, COPY.to_le_bytes(), "pegar copia, no mueve"); + let folder = std::path::Path::new(&paths[0]).parent().expect("carpeta"); + std::fs::remove_dir_all(folder).ok(); + } + + #[test] + fn an_item_without_virtual_files_pastes_no_files() { + assert_eq!(pasted_files(&Item::plain("hola")), (Vec::new(), 0)); + } + #[test] fn nothing_readable_is_nothing_to_write() { let item = Item { @@ -160,39 +229,55 @@ mod tests { payload: Payload::Announced { size: Some(10) }, }], }; - assert_eq!(plain_text_of(&item), None); + assert!(item.formats.iter().all(|one| payload_of(one).is_none())); } #[test] - fn the_plain_text_is_the_one_the_system_uses() { + fn pasting_as_plain_text_is_a_rendered_form_written_like_any_item() { + use cp_core::paste_as::{Form, render}; let item = Item { - kind: None, + kind: Some(cp_core::kind::Kind::Text), formats: vec![ - inline("Rich Text Format", b"{\\rtf1 hola}"), + inline("HTML Format", b"hola"), inline("CF_UNICODETEXT", &utf16_of("hola")), ], }; - assert_eq!(plain_text_of(&item), Some(utf16_of("hola"))); - } - - #[test] - fn a_synthetic_item_can_also_be_pasted_flat() { - let item = Item::plain("hola"); - assert_eq!(plain_text_of(&item), Some(utf16_of("hola"))); + let plain = render(Form::PlainText, &crate::content::content_of(&item, None)) + .expect("hay texto") + .into_item(); + let written: Vec<(u32, Vec)> = plain + .formats + .iter() + .filter_map(|one| payload_of(one).map(|bytes| writable(&one.id, bytes))) + .flatten() + .collect(); + assert_eq!(written, vec![(CF_UNICODETEXT, utf16_of("hola"))]); + assert_eq!(item.formats.len(), 2, "el ítem guardado no cambia"); + let only_image = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![inline(PNG, &[1, 2, 3])], + }; + assert_eq!( + render( + Form::PlainText, + &crate::content::content_of(&only_image, None) + ), + None, + "sin texto plano no hay forma plana que ofrecer" + ); } - #[test] - fn asking_for_flat_text_does_not_touch_what_is_stored() { - let item = Item { - kind: None, - formats: vec![ - inline("Rich Text Format", b"{\\rtf1 con estilos}"), - inline("CF_UNICODETEXT", &utf16_of("con estilos")), - ], - }; - let before = item.clone(); - let _ = plain_text_of(&item); - assert_eq!(item, before, "el item conserva su RTF para la proxima vez"); + fn jpeg_bytes() -> Vec { + let mut out = Vec::new(); + image::load_from_memory(&image_bytes()) + .expect("png") + .to_rgb8() + .write_to( + &mut std::io::Cursor::new(&mut out), + image::ImageFormat::Jpeg, + ) + .expect("jpeg"); + out } fn image_bytes() -> Vec { diff --git a/crates/cp-win/src/transfer.rs b/crates/cp-win/src/transfer.rs index 5dc1696..09b57a6 100644 --- a/crates/cp-win/src/transfer.rs +++ b/crates/cp-win/src/transfer.rs @@ -1,4 +1,4 @@ -const COPY: u32 = 1; +pub(crate) const COPY: u32 = 1; const MOVE: u32 = 2; const LINK: u32 = 4; diff --git a/crates/cp-win/src/virtual_files.rs b/crates/cp-win/src/virtual_files.rs new file mode 100644 index 0000000..6a1af7e --- /dev/null +++ b/crates/cp-win/src/virtual_files.rs @@ -0,0 +1,432 @@ +use cp_core::item::{Format, Item, Payload}; +use std::path::{Path, PathBuf}; + +pub const DESCRIPTOR: &str = "FileGroupDescriptorW"; +pub const CONTENTS: &str = "FileContents"; +const CONTENTS_PREFIX: &str = "FileContents#"; + +const ENTRY: usize = 592; +const ATTRIBUTES_AT: usize = 36; +const SIZE_HIGH_AT: usize = 64; +const SIZE_LOW_AT: usize = 68; +const NAME_AT: usize = 72; +const NAME_UNITS: usize = 260; +const FD_ATTRIBUTES: u32 = 0x04; +const FD_FILESIZE: u32 = 0x40; +const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; +const FORBIDDEN: &str = "<>:\"|?*"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Described { + pub name: String, + pub size: Option, + pub is_dir: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Materialized { + pub paths: Vec, + pub complete: bool, +} + +pub fn offered_without_a_drop(offered: &[&str]) -> bool { + offered.contains(&DESCRIPTOR) && offered.contains(&CONTENTS) && !offered.contains(&"CF_HDROP") +} + +pub fn described_in(descriptor: &[u8]) -> Vec { + let Some(count) = u32_at(descriptor, 0) else { + return Vec::new(); + }; + (0..count as usize) + .map_while(|index| { + let at = 4 + index * ENTRY; + descriptor.get(at..at + ENTRY).and_then(entry) + }) + .collect() +} + +fn entry(bytes: &[u8]) -> Option { + let flags = u32_at(bytes, 0)?; + let attributes = u32_at(bytes, ATTRIBUTES_AT)?; + let units: Vec = bytes + .get(NAME_AT..NAME_AT + NAME_UNITS * 2)? + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .take_while(|unit| *unit != 0) + .collect(); + let size = if flags & FD_FILESIZE != 0 { + let high = u64::from(u32_at(bytes, SIZE_HIGH_AT)?); + let low = u64::from(u32_at(bytes, SIZE_LOW_AT)?); + Some((high << 32) + low) + } else { + None + }; + let described = flags & FD_ATTRIBUTES != 0; + Some(Described { + name: String::from_utf16_lossy(&units), + size, + is_dir: described && attributes & FILE_ATTRIBUTE_DIRECTORY != 0, + }) +} + +fn u32_at(bytes: &[u8], at: usize) -> Option { + let four: [u8; 4] = bytes.get(at..at + 4)?.try_into().ok()?; + Some(u32::from_le_bytes(four)) +} + +pub fn contents_id(index: usize) -> String { + format!("{CONTENTS_PREFIX}{index}") +} + +pub fn index_of(id: &str) -> Option { + id.strip_prefix(CONTENTS_PREFIX)?.parse().ok() +} + +pub fn is_virtual(id: &str) -> bool { + id == DESCRIPTOR || id == CONTENTS || index_of(id).is_some() +} + +pub fn relative_path_of(name: &str) -> Option { + let mut out = PathBuf::new(); + for part in name.split(['\\', '/']) { + let unsafe_part = part.is_empty() + || part.ends_with([' ', '.']) + || part + .chars() + .any(|c| c.is_control() || FORBIDDEN.contains(c)); + if unsafe_part { + return None; + } + out.push(part); + } + (out.components().next().is_some()).then_some(out) +} + +pub fn materialize(item: &Item, root: &Path) -> Option { + let described = described_in(bytes_of(item.format(DESCRIPTOR)?)?); + if described.is_empty() { + return None; + } + let dir = root.join(format!("{:016x}", item.fingerprint())); + let mut paths: Vec = Vec::new(); + let mut complete = true; + for (index, one) in described.iter().enumerate() { + let Some(relative) = relative_path_of(&one.name) else { + complete = false; + continue; + }; + let target = dir.join(&relative); + let written = if one.is_dir { + std::fs::create_dir_all(&target).is_ok() + } else { + item.format(&contents_id(index)) + .and_then(bytes_of) + .is_some_and(|bytes| write_under(&target, bytes)) + }; + if !written { + complete = false; + continue; + } + if let Some(first) = relative.components().next() { + let top = dir.join(first); + if !paths.contains(&top) { + paths.push(top); + } + } + } + (!paths.is_empty()).then_some(Materialized { paths, complete }) +} + +fn write_under(target: &Path, bytes: &[u8]) -> bool { + target + .parent() + .is_some_and(|parent| std::fs::create_dir_all(parent).is_ok()) + && std::fs::write(target, bytes).is_ok() +} + +fn bytes_of(format: &Format) -> Option<&[u8]> { + match &format.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => Some(bytes.as_slice()), + _ => None, + } +} + +#[cfg(test)] +pub(crate) fn descriptor_of(entries: &[(&str, Option, bool)]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + for (name, size, is_dir) in entries { + let mut one = vec![0u8; ENTRY]; + let mut flags = 0u32; + if let Some(size) = size { + flags |= FD_FILESIZE; + one[SIZE_HIGH_AT..SIZE_HIGH_AT + 4] + .copy_from_slice(&((size >> 32) as u32).to_le_bytes()); + one[SIZE_LOW_AT..SIZE_LOW_AT + 4].copy_from_slice(&(*size as u32).to_le_bytes()); + } + if *is_dir { + flags |= FD_ATTRIBUTES; + one[ATTRIBUTES_AT..ATTRIBUTES_AT + 4] + .copy_from_slice(&FILE_ATTRIBUTE_DIRECTORY.to_le_bytes()); + } + one[..4].copy_from_slice(&flags.to_le_bytes()); + for (at, unit) in name.encode_utf16().take(NAME_UNITS - 1).enumerate() { + one[NAME_AT + at * 2..NAME_AT + at * 2 + 2].copy_from_slice(&unit.to_le_bytes()); + } + out.extend_from_slice(&one); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use cp_core::kind::Kind; + + fn inline(id: &str, bytes: &[u8]) -> Format { + Format { + id: id.into(), + payload: Payload::Inline(bytes.to_vec()), + } + } + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cp-virtual-{}-{name}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + dir + } + + #[test] + fn a_descriptor_names_its_files_with_their_sizes_and_folders() { + let described = described_in(&descriptor_of(&[ + ("informe.pdf", Some(1234), false), + ("adjuntos", None, true), + ("adjuntos\\foto ñ.jpg", Some(5 << 32 | 7), false), + ])); + assert_eq!( + described, + vec![ + Described { + name: "informe.pdf".into(), + size: Some(1234), + is_dir: false + }, + Described { + name: "adjuntos".into(), + size: None, + is_dir: true + }, + Described { + name: "adjuntos\\foto ñ.jpg".into(), + size: Some(5 << 32 | 7), + is_dir: false + }, + ] + ); + } + + #[test] + fn the_name_runs_to_the_last_of_its_two_hundred_and_sixty_units() { + let longest = "n".repeat(NAME_UNITS - 1); + let described = described_in(&descriptor_of(&[(&longest, None, false)])); + assert_eq!(described[0].name, longest); + let accented = "ñ".repeat(200); + let described = described_in(&descriptor_of(&[(&accented, None, false)])); + assert_eq!(described[0].name, accented); + } + + #[test] + fn a_folder_needs_both_the_flag_and_the_attribute() { + let mut only_flag = descriptor_of(&[("x", None, true)]); + only_flag[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4].copy_from_slice(&0x20u32.to_le_bytes()); + assert!( + !described_in(&only_flag)[0].is_dir, + "FD_ATTRIBUTES con un archivo corriente" + ); + let mut only_attribute = descriptor_of(&[("x", Some(1), false)]); + only_attribute[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4] + .copy_from_slice(&FILE_ATTRIBUTE_DIRECTORY.to_le_bytes()); + assert!( + !described_in(&only_attribute)[0].is_dir, + "sin FD_ATTRIBUTES el atributo no vale" + ); + let mut both_bits = descriptor_of(&[("x", None, true)]); + both_bits[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4] + .copy_from_slice(&(FILE_ATTRIBUTE_DIRECTORY | 0x20).to_le_bytes()); + assert!(described_in(&both_bits)[0].is_dir); + let unsized_dir = described_in(&descriptor_of(&[("x", None, true)])); + assert_eq!(unsized_dir[0].size, None); + } + + #[test] + fn the_size_is_read_from_its_own_two_words() { + let mut swapped = descriptor_of(&[("x", Some(7), false)]); + swapped[4 + SIZE_HIGH_AT..4 + SIZE_HIGH_AT + 4].copy_from_slice(&3u32.to_le_bytes()); + swapped[4 + SIZE_LOW_AT..4 + SIZE_LOW_AT + 4].copy_from_slice(&0u32.to_le_bytes()); + assert_eq!(described_in(&swapped)[0].size, Some(3 << 32)); + assert_eq!( + described_in(&descriptor_of(&[("x", Some(u32::MAX as u64 + 2), false)]))[0].size, + Some(u32::MAX as u64 + 2) + ); + } + + #[test] + fn a_truncated_or_lying_descriptor_yields_what_it_really_holds() { + assert!(described_in(&[]).is_empty()); + assert!(described_in(&[0, 0, 0]).is_empty()); + let mut lying = descriptor_of(&[("a.txt", None, false)]); + lying[..4].copy_from_slice(&9u32.to_le_bytes()); + assert_eq!(described_in(&lying).len(), 1, "dice nueve, trae uno"); + let cut = &descriptor_of(&[("a.txt", None, false)])[..300]; + assert!(described_in(cut).is_empty()); + } + + #[test] + fn the_contents_ids_count_from_the_descriptor() { + assert_eq!(contents_id(0), "FileContents#0"); + assert_eq!(index_of("FileContents#12"), Some(12)); + assert_eq!(index_of("FileContents"), None); + assert_eq!(index_of("FileContents#x"), None); + assert!(is_virtual(DESCRIPTOR)); + assert!(is_virtual(CONTENTS)); + assert!(is_virtual("FileContents#3")); + assert!(!is_virtual("CF_HDROP")); + } + + #[test] + fn virtual_files_are_offered_only_when_there_is_no_real_drop() { + assert!(offered_without_a_drop(&[ + DESCRIPTOR, + CONTENTS, + "RenPrivateItem" + ])); + assert!( + !offered_without_a_drop(&[DESCRIPTOR, CONTENTS, "CF_HDROP"]), + "el Explorador ofrece ambos: los de disco mandan" + ); + assert!(!offered_without_a_drop(&[DESCRIPTOR])); + assert!(!offered_without_a_drop(&[CONTENTS])); + } + + #[test] + fn a_name_from_the_descriptor_never_escapes_its_folder() { + assert_eq!( + relative_path_of("informe.pdf"), + Some(PathBuf::from("informe.pdf")) + ); + assert_eq!( + relative_path_of("carpeta\\sub/nota.txt"), + Some(PathBuf::from("carpeta").join("sub").join("nota.txt")) + ); + for bad in [ + "", + "..\\x.txt", + "a\\..\\b", + ".", + "C:\\x.txt", + "\\\\srv\\share", + "con.txt", + "trailing. ", + "tab\tname", + ] { + assert_eq!(relative_path_of(bad), None, "{bad:?}"); + } + } + + #[test] + fn materializing_writes_each_file_under_the_item_and_hands_back_the_top_paths() { + let root = scratch("write"); + let item = Item { + kind: Some(Kind::File), + formats: vec![ + inline( + DESCRIPTOR, + &descriptor_of(&[ + ("informe.pdf", Some(4), false), + ("adjuntos", None, true), + ("adjuntos\\nota.txt", Some(3), false), + ]), + ), + inline(&contents_id(0), b"%PDF"), + inline(&contents_id(2), b"hola"), + ], + }; + let out = materialize(&item, &root).expect("se escribió"); + assert!(out.complete); + assert_eq!( + out.paths.len(), + 2, + "informe.pdf y adjuntos: {:?}", + out.paths + ); + assert_eq!(std::fs::read(&out.paths[0]).expect("pdf"), b"%PDF"); + assert!(out.paths[1].is_dir()); + assert_eq!( + std::fs::read(out.paths[1].join("nota.txt")).expect("nota"), + b"hola" + ); + assert!( + out.paths[0].starts_with(&root), + "{:?} fuera de {root:?}", + out.paths[0] + ); + let again = materialize(&item, &root).expect("pegar dos veces"); + assert_eq!(again.paths, out.paths, "la misma huella, la misma carpeta"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn what_never_arrived_or_cannot_be_named_leaves_the_paste_incomplete() { + let root = scratch("incomplete"); + let item = Item { + kind: Some(Kind::File), + formats: vec![ + inline( + DESCRIPTOR, + &descriptor_of(&[ + ("grande.iso", Some(1 << 40), false), + ("..\\fuera.txt", Some(1), false), + ("ok.txt", Some(2), false), + ]), + ), + Format { + id: contents_id(0), + payload: Payload::TooBig { size: 1 << 40 }, + }, + inline(&contents_id(1), b"x"), + inline(&contents_id(2), b"ok"), + ], + }; + let out = materialize(&item, &root).expect("algo se escribió"); + assert!(!out.complete); + assert_eq!(out.paths.len(), 1); + assert!(out.paths[0].ends_with("ok.txt")); + assert!( + !root.join("fuera.txt").exists(), + "nada se escapa de la carpeta" + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn an_item_without_virtual_files_has_nothing_to_materialize() { + let root = scratch("none"); + assert_eq!(materialize(&Item::plain("hola"), &root), None); + let empty = Item { + kind: None, + formats: vec![inline(DESCRIPTOR, &descriptor_of(&[]))], + }; + assert_eq!(materialize(&empty, &root), None); + let announced = Item { + kind: None, + formats: vec![Format { + id: DESCRIPTOR.into(), + payload: Payload::Announced { size: Some(596) }, + }], + }; + assert_eq!(materialize(&announced, &root), None); + assert!(!root.exists(), "sin archivos no se crea carpeta"); + } +}