From 953311b47e7124f6ccba900f33a8a6194798565d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 14 Sep 2026 13:56:39 -0300 Subject: [PATCH 1/4] feat(win): seal the core on Windows, measured against real sources The workspace did not build on Windows at all. Now 290 tests, 97.77% coverage and zero surviving mutants, with the format catalogue derived from seventeen real copies rather than from assumption. The sequence counter does not count copies here: it climbs 5 to 15 per copy depending on how it was made, so the watcher declares its cadence instead of defaulting to the macOS one and inventing lost copies. --- .github/workflows/ci.yml | 22 +- .github/workflows/rules.yml | 43 +- Cargo.lock | 7 + Cargo.toml | 1 + crates/cp-core/src/dib.rs | 694 ++++++++++++++++++ crates/cp-core/src/formats.rs | 303 +++++++- crates/cp-core/src/kind.rs | 93 ++- crates/cp-core/src/lib.rs | 1 + crates/cp-core/src/watch.rs | 277 +++++-- crates/cp-mac-sys/build.rs | 5 + crates/cp-mac/Cargo.toml | 2 +- crates/cp-mac/examples/probe.rs | 936 +----------------------- crates/cp-mac/examples/probe/battery.rs | 929 +++++++++++++++++++++++ crates/cp-mac/src/capture.rs | 12 +- crates/cp-mac/src/formats.rs | 14 +- crates/cp-store/src/lib.rs | 2 +- crates/cp-store/src/store.rs | 137 +++- crates/cp-win/Cargo.toml | 14 + crates/cp-win/src/formats.rs | 417 +++++++++++ crates/cp-win/src/lib.rs | 8 + crates/cp-win/src/transfer.rs | 108 +++ 21 files changed, 3001 insertions(+), 1024 deletions(-) create mode 100644 crates/cp-core/src/dib.rs create mode 100644 crates/cp-mac/examples/probe/battery.rs create mode 100644 crates/cp-win/Cargo.toml create mode 100644 crates/cp-win/src/formats.rs create mode 100644 crates/cp-win/src/lib.rs create mode 100644 crates/cp-win/src/transfer.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc189928..5b6bce2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,12 @@ concurrency: jobs: check: - name: Format, lints and tests - runs-on: macos-15 + name: Format, lints and tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-15, windows-2025] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -26,6 +30,20 @@ jobs: # test porque NSPasteboard exige el hilo principal y el runner no lo # garantiza. - run: cargo run -p cp-mac --example probe + if: runner.os == 'macOS' + + cross: + name: The macOS crates still build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + - uses: Swatinem/rust-cache@v2 + # Da la señal de que un cambio en el núcleo rompió macOS sin esperar al + # runner de macOS, que es el más lento y el más escaso. + - run: cargo check --target aarch64-apple-darwin -p cp-mac -p cp-mac-sys deny: name: Dependency audit diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 863b6ada..5b2ae1ac 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -39,8 +39,10 @@ jobs: - name: No Spanish identifiers run: | + # Un identificador no vive ni dentro de una cadena ni dentro de un + # comentario, y aqui los comentarios van en espanol a proposito. if grep -rnE '\b(fecha|limite|prioridad|filtro|tarea|titulo|etiqueta|imagen|archivo) *:' \ - crates/*/src --include='*.rs' | grep -v '"'; then + crates/*/src --include='*.rs' | grep -v '"' | grep -vE ':[0-9]+: *///?!?'; then echo "los identificadores van en ingles"; exit 1 fi @@ -65,8 +67,15 @@ jobs: END { exit bad }' $(find crates -name '*.rs') deterministic: - name: Tests are deterministic - runs-on: macos-15 + name: Tests are deterministic (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-15, windows-2025] + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -75,8 +84,16 @@ jobs: run: for i in $(seq 1 20); do cargo test -p cp-core --quiet || exit 1; done mutants: - name: The suite kills every mutant - runs-on: macos-15 + name: The suite kills every mutant (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + crates: -p cp-core -p cp-store + - os: windows-2025 + crates: -p cp-core -p cp-store -p cp-win steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -85,7 +102,7 @@ jobs: # Una prueba que pasa contra un núcleo mutado no está probando nada. El # criterio de aceptación de la Etapa 0 es exactamente este: la batería # tiene que fallar contra una implementación que no sepa lo que sabemos. - - run: cargo mutants -p cp-core -p cp-store --no-times + - run: cargo mutants ${{ matrix.crates }} --no-times coverage: name: Coverage does not slip @@ -103,6 +120,20 @@ jobs: - run: cargo llvm-cov --no-report run -p cp-mac --example probe - run: cargo llvm-cov report --fail-under-lines 90 --summary-only + coverage-windows: + name: Coverage does not slip on Windows + runs-on: windows-2025 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@cargo-llvm-cov + # Sin el arnés de macOS el umbral sería otro, así que se mide solo lo que + # Windows ejecuta de verdad: el núcleo y el almacén. + - run: cargo llvm-cov -p cp-core -p cp-store -p cp-win --fail-under-lines 95 --summary-only + budget: name: Latency stays within budget runs-on: macos-15 diff --git a/Cargo.lock b/Cargo.lock index 25f47aa2..e635eaca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,13 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "cp-win" +version = "3.0.0" +dependencies = [ + "cp-core", +] + [[package]] name = "cpufeatures" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index d26c4d61..0d2eecc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ cp-core = { path = "crates/cp-core" } cp-store = { path = "crates/cp-store" } cp-mac-sys = { path = "crates/cp-mac-sys" } cp-mac = { path = "crates/cp-mac" } +cp-win = { path = "crates/cp-win" } thiserror = "2" tracing = "0.1" diff --git a/crates/cp-core/src/dib.rs b/crates/cp-core/src/dib.rs new file mode 100644 index 00000000..2f537423 --- /dev/null +++ b/crates/cp-core/src/dib.rs @@ -0,0 +1,694 @@ +//! El mapa de bits del portapapeles de Windows, convertido a algo que se pueda +//! guardar. +//! +//! `CF_DIB` y `CF_DIBV5` llegan sin comprimir y sin la cabecera de archivo que +//! los haría un `.bmp`. Medido el 14/09/2026 en Windows 11 26200, la misma +//! imagen ocupa **480.052 bytes en DIB y 1.964 en PNG**: guardar el DIB tal +//! cual es pagar 244 veces el precio por cada captura. +//! +//! Todo lo de aquí es de bytes a bytes, así que se prueba sin Windows delante. + +const FILE_HEADER: usize = 14; +const INFO_HEADER: usize = 40; +const BI_BITFIELDS: u32 = 3; +const RGBQUAD: usize = 4; + +/// Lo que dice la cabecera de un DIB. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Header { + pub size: u32, + pub width: i32, + pub height: i32, + pub bit_count: u16, + pub compression: u32, + pub clr_used: u32, +} + +fn u32_at(bytes: &[u8], at: usize) -> Option { + bytes + .get(at..at + 4) + .map(|four| u32::from_le_bytes([four[0], four[1], four[2], four[3]])) +} + +pub fn header(dib: &[u8]) -> Option
{ + let size = u32_at(dib, 0)?; + // Una cabecera que dice medir menos que la mínima, o más que el buffer + // entero, no es una cabecera: es basura con forma de imagen. + if (size as usize) < INFO_HEADER || size as usize > dib.len() { + return None; + } + Some(Header { + size, + width: u32_at(dib, 4)? as i32, + height: u32_at(dib, 8)? as i32, + bit_count: u16::from_le_bytes([*dib.get(14)?, *dib.get(15)?]), + compression: u32_at(dib, 16)?, + clr_used: u32_at(dib, 32)?, + }) +} + +/// Dónde empiezan los píxeles, contando desde el principio del DIB. +/// +/// Es el número que decide si la imagen sale bien o sale desplazada, y tiene +/// dos trampas que la 2.x documentó tras encontrarlas en campo. +pub fn pixel_offset(dib: &[u8]) -> Option { + let head = header(dib)?; + let mut table = 0usize; + + // Las máscaras de `BI_BITFIELDS` solo siguen a la cabecera clásica de 40 + // bytes; en `BITMAPV4HEADER` y `BITMAPV5HEADER` van dentro, y sumarlas otra + // vez desplaza la imagen 12 bytes. + if head.compression == BI_BITFIELDS && head.size as usize == INFO_HEADER { + table += 3 * 4; + } + + if head.bit_count <= 8 { + let colors = if head.clr_used > 0 { + head.clr_used + } else { + 1u32 << head.bit_count + }; + table += colors as usize * RGBQUAD; + } else if head.clr_used != 0 { + // Por encima de 8 bits la paleta es opcional, y los productores dejan + // `biClrUsed` sucio a menudo: honrarlo a ciegas manda los píxeles fuera + // del buffer. Solo se aplica si lo que dice cabe de verdad. + let claimed = head.clr_used as usize * RGBQUAD; + if head.size as usize + table + claimed <= dib.len() { + table += claimed; + } + } + + let offset = head.size as usize + table; + (offset <= dib.len()).then_some(offset) +} + +/// Un `.bmp` completo: el DIB con su cabecera de archivo delante. +pub fn as_bmp(dib: &[u8]) -> Option> { + let pixels = pixel_offset(dib)?; + let mut bmp = Vec::with_capacity(FILE_HEADER + dib.len()); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&((FILE_HEADER + dib.len()) as u32).to_le_bytes()); + bmp.extend_from_slice(&0u16.to_le_bytes()); + bmp.extend_from_slice(&0u16.to_le_bytes()); + bmp.extend_from_slice(&((FILE_HEADER + pixels) as u32).to_le_bytes()); + bmp.extend_from_slice(dib); + Some(bmp) +} + +/// Qué hay de verdad en el cuarto byte de cada píxel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alpha { + /// No hay cuarto byte: la imagen no es de 32 bits. + Absent, + /// Lo hay y no dice nada: o está todo a cero porque nadie lo escribió, o + /// está todo opaco. En los dos casos la imagen es opaca. + Opaque, + /// Hay transparencia de verdad y hay que respetarla. + Real, +} + +/// La máscara de alfa que la cabecera declara, cuando la lleva dentro. +/// +/// Solo `BITMAPV4HEADER` y `BITMAPV5HEADER` la tienen, en el mismo sitio. Con +/// la cabecera clásica de 40 bytes y `BI_BITFIELDS` las máscaras van detrás, +/// pero son tres y ninguna es la del alfa. +fn declared_alpha_mask(dib: &[u8], head: Header) -> Option { + (head.size as usize >= 56) + .then(|| u32_at(dib, 52)) + .flatten() +} + +/// Por especificación, el cuarto byte de un `BI_RGB` de 32 bits es +/// **indefinido**; en la práctica los productores modernos escriben el alfa +/// ahí. Honrar un canal entero a cero daría una imagen invisible, así que se +/// mira antes de creérselo. +/// +/// Pero la cabecera manda sobre la heurística. Medido el 14/09/2026: al poner +/// solo un `CF_DIB`, Windows sintetiza un `CF_DIBV5` con `biSize` 124, +/// `BI_RGB` y **`bV5AlphaMask` a cero** —dice que no hay canal alfa— sobre los +/// mismos píxeles. Mirar únicamente el cuarto byte de un sintetizado así es +/// inventarse una transparencia y grabarla en el PNG para siempre. +pub fn alpha(dib: &[u8]) -> Alpha { + let Some(head) = header(dib) else { + return Alpha::Absent; + }; + if head.bit_count != 32 { + return Alpha::Absent; + } + if declared_alpha_mask(dib, head) == Some(0) { + return Alpha::Absent; + } + let Some(start) = pixel_offset(dib) else { + return Alpha::Absent; + }; + // `pixel_offset` ya garantiza que el corte cae dentro; el `unwrap` evita + // una rama que ninguna entrada puede alcanzar. + let pixels = dib.get(start..).unwrap_or_default(); + let mut seen_zero = false; + let mut seen_full = false; + let mut seen_between = false; + for chunk in pixels.as_chunks::<4>().0 { + match chunk[3] { + 0x00 => seen_zero = true, + 0xFF => seen_full = true, + _ => seen_between = true, + } + } + if seen_between || (seen_zero && seen_full) { + Alpha::Real + } else { + Alpha::Opaque + } +} + +/// El DIB como PNG, que es como se guarda. +/// +/// Devuelve `None` si los bytes no son un mapa de bits que se pueda leer. +pub fn to_png(dib: &[u8]) -> Option> { + let mut owned; + // Todo lo que no sea transparencia declarada se escribe opaco: un cuarto + // byte que nadie llenó, leído como alfa, da una imagen invisible. + let source = if alpha(dib) != Alpha::Real && header(dib)?.bit_count == 32 { + owned = dib.to_vec(); + let start = pixel_offset(&owned)?; + for chunk in owned.get_mut(start..)?.as_chunks_mut::<4>().0 { + chunk[3] = 0xFF; + } + &owned + } else { + dib + }; + let bmp = as_bmp(source)?; + let decoded = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?; + let mut out = std::io::Cursor::new(Vec::new()); + decoded + .write_to(&mut out, image::ImageFormat::Png) + .ok() + .map(|()| out.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Un DIB armado a mano, para poder mover una pieza cada vez. + struct Dib { + header_size: u32, + width: i32, + height: i32, + bit_count: u16, + compression: u32, + clr_used: u32, + table: Vec, + pixels: Vec, + } + + impl Dib { + fn rgb32(width: i32, height: i32) -> Self { + Self { + header_size: INFO_HEADER as u32, + width, + height, + bit_count: 32, + compression: 0, + clr_used: 0, + table: Vec::new(), + pixels: [0x40, 0x80, 0xC0, 0xFF].repeat((width * height.abs()) as usize), + } + } + + fn build(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&self.header_size.to_le_bytes()); + out.extend_from_slice(&self.width.to_le_bytes()); + out.extend_from_slice(&self.height.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&self.bit_count.to_le_bytes()); + out.extend_from_slice(&self.compression.to_le_bytes()); + out.extend_from_slice(&(self.pixels.len() as u32).to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&self.clr_used.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.resize(self.header_size as usize, 0); + out.extend_from_slice(&self.table); + out.extend_from_slice(&self.pixels); + out + } + } + + #[test] + fn a_plain_dib_puts_the_pixels_right_after_the_header() { + let dib = Dib::rgb32(2, 2).build(); + assert_eq!(pixel_offset(&dib), Some(INFO_HEADER)); + } + + /// La primera trampa: con la cabecera clásica, las máscaras van detrás. + #[test] + fn bitfield_masks_follow_the_classic_header() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.table = vec![0; 12]; + assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 12)); + } + + /// Y la otra mitad de la trampa: con `BITMAPV4HEADER` y `BITMAPV5HEADER` + /// las máscaras están dentro, y sumarlas otra vez corre la imagen 12 bytes. + #[test] + fn bitfield_masks_live_inside_the_newer_headers() { + for size in [108u32, 124] { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = size; + dib.compression = BI_BITFIELDS; + assert_eq!( + pixel_offset(&dib.build()), + Some(size as usize), + "cabecera de {size} bytes" + ); + } + } + + #[test] + fn a_palette_below_nine_bits_shifts_the_pixels() { + let mut dib = Dib::rgb32(4, 1); + dib.bit_count = 8; + dib.pixels = vec![0, 1, 2, 3]; + dib.table = vec![0; 256 * RGBQUAD]; + assert_eq!( + pixel_offset(&dib.build()), + Some(INFO_HEADER + 256 * RGBQUAD), + "sin biClrUsed se asume la paleta entera" + ); + } + + #[test] + fn a_declared_palette_size_is_honoured() { + let mut dib = Dib::rgb32(4, 1); + dib.bit_count = 8; + dib.clr_used = 16; + dib.pixels = vec![0, 1, 2, 3]; + dib.table = vec![0; 16 * RGBQUAD]; + assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 16 * RGBQUAD)); + } + + /// La segunda trampa: por encima de 8 bits los productores dejan + /// `biClrUsed` sucio, y creérselo manda los píxeles fuera del buffer. + #[test] + fn a_dirty_palette_count_above_eight_bits_is_ignored() { + let mut dib = Dib::rgb32(2, 2); + dib.clr_used = 1_000_000; + assert_eq!( + pixel_offset(&dib.build()), + Some(INFO_HEADER), + "lo que no cabe no se resta" + ); + } + + #[test] + fn a_palette_that_does_fit_above_eight_bits_is_applied() { + let mut dib = Dib::rgb32(2, 2); + dib.clr_used = 2; + dib.table = vec![0; 2 * RGBQUAD]; + assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 2 * RGBQUAD)); + } + + /// Una cabecera que ocupa el buffer entero sigue siendo legible: dice lo + /// que dice, y que detrás no venga un solo píxel es otro asunto. + #[test] + fn a_header_that_fills_the_whole_buffer_is_still_a_header() { + let mut dib = Dib::rgb32(1, 1); + dib.pixels = Vec::new(); + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER); + assert_eq!( + header(&raw).map(|head| head.size), + Some(INFO_HEADER as u32), + "medir justo lo que hay no es medir de más" + ); + assert_eq!(pixel_offset(&raw), Some(INFO_HEADER)); + } + + /// La comprobación de que la paleta cabe suma los tres tramos: cabecera, + /// máscaras y paleta. Con la paleta justo fuera del buffer, la cuenta tiene + /// que dar que no cabe y quedarse con lo anterior. + #[test] + fn a_palette_one_byte_too_long_is_left_out() { + let mut dib = Dib::rgb32(2, 2); + dib.clr_used = 2; + dib.pixels = vec![0; 5]; + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER + 5); + assert_eq!( + pixel_offset(&raw), + Some(INFO_HEADER), + "ocho bytes de paleta no caben en cinco" + ); + } + + /// Y lo mismo con las máscaras por delante: los tres tramos se suman, no + /// se restan ni se multiplican entre sí. + #[test] + fn the_masks_count_towards_whether_the_palette_fits() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.clr_used = 3; + dib.table = vec![0; 12]; + dib.pixels = vec![0; 8]; + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER + 12 + 8); + assert_eq!( + pixel_offset(&raw), + Some(INFO_HEADER + 12), + "doce de máscaras más doce de paleta no caben en veinte" + ); + } + + #[test] + fn nonsense_is_not_a_bitmap() { + assert_eq!(header(&[]), None); + assert_eq!(header(&[0; 8]), None, "ni siquiera llega a la cabecera"); + assert_eq!(header(&[0; 40]), None, "una cabecera que dice medir cero"); + let mut lying = Dib::rgb32(2, 2).build(); + lying[0..4].copy_from_slice(&9999u32.to_le_bytes()); + assert_eq!(header(&lying), None, "dice medir más que todo el buffer"); + assert_eq!(as_bmp(&[]), None); + assert_eq!(to_png(&[]), None); + } + + #[test] + fn the_file_header_points_at_the_pixels() { + let dib = Dib::rgb32(2, 2).build(); + let bmp = as_bmp(&dib).expect("bmp"); + assert_eq!(&bmp[0..2], b"BM"); + assert_eq!( + u32::from_le_bytes([bmp[2], bmp[3], bmp[4], bmp[5]]) as usize, + FILE_HEADER + dib.len(), + "el tamaño declarado es el del archivo entero" + ); + assert_eq!( + u32::from_le_bytes([bmp[10], bmp[11], bmp[12], bmp[13]]) as usize, + FILE_HEADER + INFO_HEADER + ); + assert_eq!(&bmp[FILE_HEADER..], &dib[..], "el DIB viaja intacto"); + } + + #[test] + fn an_alpha_channel_nobody_wrote_is_not_transparency() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = [0x40, 0x80, 0xC0, 0x00].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Opaque); + } + + #[test] + fn an_alpha_channel_fully_opaque_is_opaque() { + let dib = Dib::rgb32(2, 2).build(); + assert_eq!(alpha(&dib), Alpha::Opaque); + } + + #[test] + fn a_mixed_alpha_channel_is_real_transparency() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = vec![ + 0x40, 0x80, 0xC0, 0xFF, // + 0x40, 0x80, 0xC0, 0x00, // + 0x40, 0x80, 0xC0, 0xFF, // + 0x40, 0x80, 0xC0, 0xFF, + ]; + assert_eq!( + alpha(&dib.build()), + Alpha::Real, + "unos a cero y otros opacos es un recorte con bordes" + ); + } + + #[test] + fn a_partial_alpha_value_is_real_transparency() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = [0x40, 0x80, 0xC0, 0x7F].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Real); + } + + /// Una cabecera que promete máscaras que el buffer no tiene: no hay + /// píxeles donde mirar, así que no hay alfa que leer. + #[test] + fn a_header_promising_more_than_the_buffer_holds_has_no_alpha() { + let mut dib = Dib::rgb32(1, 1); + dib.compression = BI_BITFIELDS; + dib.pixels = Vec::new(); + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER); + assert_eq!(pixel_offset(&raw), None, "las máscaras no caben"); + assert_eq!(alpha(&raw), Alpha::Absent); + assert_eq!(to_png(&raw), None); + } + + /// El caso medido el 14/09/2026: al poner solo un `CF_DIB`, Windows + /// sintetiza un `CF_DIBV5` de 124 bytes de cabecera, `BI_RGB`, con la + /// máscara de alfa a cero. La cabecera manda: no hay canal que leer. + #[test] + fn a_synthesised_v5_declaring_no_alpha_mask_has_no_alpha() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 124; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + let raw = dib.build(); + assert_eq!(u32::from_le_bytes([raw[52], raw[53], raw[54], raw[55]]), 0); + assert_eq!( + alpha(&raw), + Alpha::Absent, + "el cuarto byte parece alfa, pero la cabecera dice que no lo es" + ); + } + + /// Y sin esa corrección la imagen saldría medio transparente: el PNG tiene + /// que quedar opaco. + #[test] + fn a_synthesised_v5_does_not_become_half_transparent() { + let mut dib = Dib::rgb32(4, 4); + dib.header_size = 124; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(16); + let png = to_png(&dib.build()).expect("png"); + let back = image::load_from_memory(&png).expect("se relee").to_rgba8(); + assert!( + back.pixels().all(|pixel| pixel[3] == 0xFF), + "una transparencia que nadie declaró no se graba" + ); + } + + /// La cabecera más corta que llega a declarar el alfa mide 56 bytes: es la + /// `BITMAPV3INFOHEADER` que escriben Photoshop y GIMP, cuatro más que la de + /// tres máscaras. Pedir 57 la dejaría fuera y su transparencia se perdería. + #[test] + fn the_shortest_header_that_declares_alpha_is_fifty_six_bytes() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 56; + dib.compression = BI_BITFIELDS; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + let mut raw = dib.build(); + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(pixel_offset(&raw), Some(56), "las máscaras van dentro"); + assert_eq!( + alpha(&raw), + Alpha::Real, + "declara la máscara de alfa y hay que leerla" + ); + } + + /// Y la misma cabecera de 56 con la máscara a cero no tiene alfa, igual + /// que la de 124: lo que decide es que el campo esté, no cuánto mide. + #[test] + fn a_fifty_six_byte_header_with_no_mask_has_no_alpha() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 56; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Absent); + } + + /// La máscara declarada distingue el sintetizado del real: con una máscara + /// de verdad, los mismos píxeles sí llevan alfa. + #[test] + fn a_declared_mask_turns_the_same_pixels_into_real_alpha() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 124; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + let mut raw = dib.build(); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(alpha(&raw), Alpha::Real); + } + + /// La cabecera clásica no lleva máscara de alfa ni cuando usa + /// `BI_BITFIELDS`: allí solo hay tres, y la heurística sigue mandando. + #[test] + fn the_classic_header_has_no_alpha_mask_to_declare() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.table = vec![0; 12]; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Real); + } + + #[test] + fn below_thirty_two_bits_there_is_no_alpha_to_read() { + let mut dib = Dib::rgb32(2, 2); + dib.bit_count = 24; + dib.pixels = [0x40, 0x80, 0xC0].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Absent); + } + + /// El número que justifica todo este módulo. + #[test] + fn a_bitmap_becomes_a_fraction_of_its_size_as_png() { + let mut dib = Dib::rgb32(200, 200); + dib.pixels = [0x20, 0x60, 0xA0, 0xFF].repeat(200 * 200); + let raw = dib.build(); + let png = to_png(&raw).expect("png"); + assert!( + png.len() * 20 < raw.len(), + "{} bytes de DIB contra {} de PNG", + raw.len(), + png.len() + ); + } + + #[test] + fn a_png_from_a_dib_is_a_png() { + let dib = Dib::rgb32(4, 4).build(); + let png = to_png(&dib).expect("png"); + assert_eq!(&png[1..4], b"PNG"); + let back = image::load_from_memory(&png).expect("se relee"); + assert_eq!((back.width(), back.height()), (4, 4)); + } + + /// Un canal alfa a cero se corrige **antes** de decodificar: si no, el PNG + /// sale entero transparente y la captura se pierde sin que nadie lo vea. + #[test] + fn a_capture_with_an_unwritten_alpha_does_not_become_invisible() { + let mut dib = Dib::rgb32(4, 4); + dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); + let png = to_png(&dib.build()).expect("png"); + let back = image::load_from_memory(&png).expect("se relee").to_rgba8(); + assert!( + back.pixels().all(|pixel| pixel[3] == 0xFF), + "la imagen tiene que verse" + ); + } + + /// El caso que de verdad salva la imagen: la cabecera **declara** la + /// máscara de alfa, así que el decodificador va a leer ese canal, y la + /// fuente lo dejó entero a cero. Sin corregirlo, la captura se guarda + /// completamente transparente y el usuario ve una tarjeta vacía. + #[test] + fn a_declared_alpha_channel_left_at_zero_is_not_an_invisible_capture() { + let mut dib = Dib::rgb32(4, 4); + dib.header_size = 124; + dib.compression = BI_BITFIELDS; + dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); + let mut raw = dib.build(); + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!( + alpha(&raw), + Alpha::Opaque, + "todo a cero no es transparencia" + ); + let png = to_png(&raw).expect("png"); + let back = image::load_from_memory(&png).expect("se relee").to_rgba8(); + assert!( + back.pixels().all(|pixel| pixel[3] == 0xFF), + "la captura tiene que verse" + ); + } + + /// Y la transparencia de verdad sobrevive al viaje, que es la otra mitad: + /// forzar el alfa siempre es el fallo que la 2.x tiene al escribir. + #[test] + fn real_transparency_survives_the_trip() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.header_size = 124; + dib.pixels = vec![ + 0x20, 0x60, 0xA0, 0x00, // + 0x20, 0x60, 0xA0, 0x80, // + 0x20, 0x60, 0xA0, 0xC0, // + 0x20, 0x60, 0xA0, 0xFF, + ]; + let mut raw = dib.build(); + // Las máscaras de un BITMAPV5HEADER, en su sitio dentro de la cabecera. + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(alpha(&raw), Alpha::Real); + let png = to_png(&raw).expect("png"); + let back = image::load_from_memory(&png).expect("se relee").to_rgba8(); + let seen: Vec = back.pixels().map(|pixel| pixel[3]).collect(); + assert!( + seen.contains(&0x00) && seen.contains(&0xFF), + "los cuatro niveles de alfa llegaron como {seen:?}" + ); + } + + /// Un DIB cuyos píxeles no llegan hasta donde la cabecera promete no puede + /// tumbar el proceso: se dice que no en vez de indexar fuera. + #[test] + fn a_truncated_bitmap_is_refused_not_panicked_on() { + let dib = Dib::rgb32(100, 100).build(); + for cut in [0, 1, 20, 39, 40, 41, 100, 500] { + let short = &dib[..cut.min(dib.len())]; + let _ = header(short); + let _ = pixel_offset(short); + let _ = alpha(short); + let _ = as_bmp(short); + let _ = to_png(short); + } + } +} + +#[cfg(test)] +mod properties { + use super::*; + use proptest::prelude::*; + + proptest! { + /// Ningún montón de bytes puede tumbar el proceso. El portapapeles lo + /// llena cualquiera, así que esto no es una precaución teórica. + #[test] + fn no_pile_of_bytes_can_bring_the_process_down( + bytes in prop::collection::vec(any::(), 0..600), + ) { + let _ = header(&bytes); + let _ = pixel_offset(&bytes); + let _ = alpha(&bytes); + let _ = as_bmp(&bytes); + let _ = to_png(&bytes); + } + + /// Donde empiezan los píxeles cae siempre dentro del buffer: es el + /// número con el que después se indexa. + #[test] + fn the_pixels_always_start_inside_the_buffer( + bytes in prop::collection::vec(any::(), 0..600), + ) { + if let Some(at) = pixel_offset(&bytes) { + prop_assert!(at <= bytes.len()); + prop_assert!(at >= super::INFO_HEADER); + } + } + + /// El `.bmp` es el DIB con catorce bytes delante, ni uno más. + #[test] + fn the_bmp_is_the_dib_with_a_header_in_front( + bytes in prop::collection::vec(any::(), 0..600), + ) { + if let Some(bmp) = as_bmp(&bytes) { + prop_assert_eq!(bmp.len(), bytes.len() + super::FILE_HEADER); + prop_assert_eq!(&bmp[super::FILE_HEADER..], &bytes[..]); + } + } + } +} diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index eba39410..46730815 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -19,6 +19,25 @@ pub enum Family { Files, } +/// Por qué una copia no se registra. Se devuelve en vez de un booleano para +/// que la interfaz pueda decirlo: un descarte mudo es indistinguible de un +/// fallo, y quien copió desde Excel merece saber que Excel pidió esto. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// Un tipo dedicado a marcar secretos. Basta con que esté. + Marked(&'static str), + /// Un marcador cuyo **valor** pide no registrar. + Declined(&'static str), +} + +impl Refusal { + pub fn marker(self) -> &'static str { + match self { + Refusal::Marked(id) | Refusal::Declined(id) => id, + } + } +} + /// Los nombres de los formatos son de cada plataforma —UTIs en macOS, /// `CF_*` y cadenas registradas en Windows— pero las reglas que se les /// aplican son las mismas. El catálogo son los datos; la lógica vive aquí y @@ -34,12 +53,36 @@ pub struct Catalog { pub aliases: &'static [(&'static str, &'static str)], /// Su sola presencia significa «no registres esto». pub concealed: &'static [&'static str], + /// Marcadores cuyo valor decide: un entero de 32 bits en little-endian, + /// donde cero significa «no registres esto». + /// + /// Leerlos no rompe la regla de decidir por el tipo y nunca por el + /// contenido: cuatro bytes de un flag no son el secreto que el payload + /// guarda. Medido el 14/09/2026 en Windows 11 26200, + /// `CanIncludeInClipboardHistory` es exactamente eso. + pub denied_when_zero: &'static [&'static str], /// Prefijos de tipos generados, que nunca llevan payload útil. pub opaque_prefixes: &'static [&'static str], pub text: &'static [&'static str], pub files: &'static [&'static str], /// Representaciones de imagen, de la más barata a la más cara. pub images_by_preference: &'static [&'static str], + /// Otros grupos de representaciones del mismo contenido, cada uno de la + /// más barata a la más cara. Las imágenes tienen el suyo aparte porque + /// además deciden la familia. + /// + /// Medido el 14/09/2026: Firefox ofrece la misma selección como + /// `text/html` en 568.458 bytes y como `HTML Format` en 284.561. + pub equivalents: &'static [&'static [&'static str]], + /// Formatos de documento incrustable. Que estén significa que la fuente es + /// un documento, y entonces la imagen que ofrece es el render y no el + /// contenido. + /// + /// Medido el 14/09/2026: un rango de Excel de 20×2 ofrece 258.380 bytes de + /// `CF_DIBV5` junto a 500 de texto, y clasificarlo como imagen sería + /// guardar una captura de pantalla de unas celdas. macOS lo deja vacío: allí + /// ninguna aplicación de documentos adjunta una imagen de cortesía. + pub embeddable: &'static [&'static str], } impl Catalog { @@ -67,11 +110,31 @@ impl Catalog { Take::Presence } + /// Si la fuente marcó el contenido como secreto con un tipo dedicado. + /// /// Se decide por el tipo, nunca por el contenido: hay gestores que ponen /// el secreto en claro dentro del payload, así que leerlo para decidir ya /// sería haberlo leído. - pub fn is_concealed(&self, offered: &[&str]) -> bool { - offered.iter().any(|id| self.concealed.contains(id)) + pub fn refusal(&self, offered: &[&str]) -> Option { + offered.iter().find_map(|id| { + self.concealed + .iter() + .find(|marker| *marker == id) + .map(|marker| Refusal::Marked(marker)) + }) + } + + /// Si un marcador de los que se leen pide no registrar. + /// + /// Un marcador presente pero vacío, o más corto de cuatro bytes, no dice + /// nada: se ignora en vez de tomarlo por cero, que sería descartar la copia + /// por no haber sabido leerla. + pub fn declines(&self, id: &str, value: &[u8]) -> Option { + let marker = self.denied_when_zero.iter().find(|marker| **marker == id)?; + let [a, b, c, d, ..] = value else { + return None; + }; + (u32::from_le_bytes([*a, *b, *c, *d]) == 0).then_some(Refusal::Declined(marker)) } /// El tipo mostrado es una clasificación sobre el conjunto, nunca una @@ -81,24 +144,40 @@ impl Catalog { if known.iter().any(|id| self.files.contains(id)) { return Some(Family::Files); } - if known + let has_image = known .iter() - .any(|id| self.images_by_preference.contains(id)) - { + .any(|id| self.images_by_preference.contains(id)); + let has_text = known.iter().any(|id| self.text.contains(id)); + // Una imagen junto a texto en un documento incrustable es el render de + // ese documento. Sin esa compañía manda la imagen, que es como llega + // del navegador: acompañada de su dirección y sin dejar de ser imagen. + let courtesy = has_text && known.iter().any(|id| self.embeddable.contains(id)); + if has_image && !courtesy { return Some(Family::Image); } - if known.iter().any(|id| self.text.contains(id)) { + if has_text { return Some(Family::Text); } - None + has_image.then_some(Family::Image) } pub fn preferred_image(&self, offered: &[&str]) -> Option<&'static str> { + self.cheapest(self.images_by_preference, offered) + } + + /// Si `id` lleva el mismo contenido que otro que la fuente ofreció y sale + /// más caro. El barato se guarda; este se anota. + pub fn costlier_twin(&self, id: &str, offered: &[&str]) -> bool { + let id = self.canonical(id); + std::iter::once(self.images_by_preference) + .chain(self.equivalents.iter().copied()) + .filter(|group| group.contains(&id)) + .any(|group| self.cheapest(group, offered).is_some_and(|best| best != id)) + } + + fn cheapest(&self, group: &'static [&'static str], offered: &[&str]) -> Option<&'static str> { let known: Vec<&str> = offered.iter().map(|id| self.canonical(id)).collect(); - self.images_by_preference - .iter() - .find(|wanted| known.contains(wanted)) - .copied() + group.iter().find(|wanted| known.contains(wanted)).copied() } } @@ -118,10 +197,13 @@ mod tests { ], aliases: &[("old/text", "plain/text")], concealed: &["secret/marker"], + denied_when_zero: &["may/record"], opaque_prefixes: &["dyn."], text: &["plain/text", "rich/text"], files: &["one/file"], images_by_preference: &["cheap/image", "costly/image"], + equivalents: &[&["cheap/markup", "costly/markup"]], + embeddable: &["embedded/document"], }; const NOTHING: Catalog = Catalog { @@ -130,10 +212,13 @@ mod tests { wanted: &[], aliases: &[], concealed: &[], + denied_when_zero: &[], opaque_prefixes: &[], text: &[], files: &[], images_by_preference: &[], + equivalents: &[], + embeddable: &[], }; #[test] @@ -142,7 +227,9 @@ mod tests { assert_eq!(NOTHING.decide(""), Take::Presence); assert_eq!(NOTHING.classify(&["cualquier/cosa"]), None); assert_eq!(NOTHING.preferred_image(&["cualquier/cosa"]), None); - assert!(!NOTHING.is_concealed(&["cualquier/cosa"])); + assert_eq!(NOTHING.refusal(&["cualquier/cosa"]), None); + assert_eq!(NOTHING.declines("cualquier/cosa", &[0, 0, 0, 0]), None); + assert!(!NOTHING.costlier_twin("cualquier/cosa", &["otra/cosa"])); assert_eq!(NOTHING.canonical("x"), "x"); } @@ -150,7 +237,7 @@ mod tests { fn nothing_offered_at_all_is_not_an_item() { assert_eq!(PROBE.classify(&[]), None); assert_eq!(PROBE.preferred_image(&[]), None); - assert!(!PROBE.is_concealed(&[])); + assert_eq!(PROBE.refusal(&[]), None); } #[test] @@ -179,10 +266,13 @@ mod tests { wanted: &["costly/image", "plain/text"], aliases: &[], concealed: &[], + denied_when_zero: &[], opaque_prefixes: &[], text: &["plain/text"], files: &[], images_by_preference: &["costly/image"], + equivalents: &[], + embeddable: &[], }; assert_eq!( GREEDY.decide("costly/image"), @@ -213,8 +303,11 @@ mod tests { #[test] fn a_secret_is_recognised_by_the_type_alone() { - assert!(PROBE.is_concealed(&["plain/text", "secret/marker"])); - assert!(!PROBE.is_concealed(&["plain/text"])); + assert_eq!( + PROBE.refusal(&["plain/text", "secret/marker"]), + Some(Refusal::Marked("secret/marker")) + ); + assert_eq!(PROBE.refusal(&["plain/text"]), None); } #[test] @@ -242,6 +335,134 @@ mod tests { } } +/// Lo que Windows necesita y macOS no tuvo que resolver. Cada caso sale de una +/// medición del 14/09/2026 sobre Windows 11 26200. +#[cfg(test)] +mod windows_needs { + use super::tests::PROBE; + use super::*; + + /// Excel ofrece una imagen del rango junto al texto de las celdas. Sin la + /// señal del documento incrustable, copiar dos celdas se guardaría como una + /// captura de pantalla de 258 KB. + #[test] + fn a_spreadsheet_is_text_even_when_it_offers_a_picture_of_itself() { + let excel = ["plain/text", "cheap/image", "embedded/document"]; + assert_eq!(PROBE.classify(&excel), Some(Family::Text)); + } + + /// Y la regla no puede pasarse de lista: una imagen copiada del navegador + /// llega con su dirección al lado y sigue siendo una imagen. Es el hallazgo + /// 13 de la 2.x, que macOS ya tenía resuelto y no se puede perder. + #[test] + fn an_image_with_its_address_alongside_is_still_an_image() { + let browser = ["plain/text", "cheap/image"]; + assert_eq!(PROBE.classify(&browser), Some(Family::Image)); + } + + /// Un documento incrustable sin texto ninguno no convierte una imagen en + /// otra cosa: no hay texto que mostrar en su lugar. + #[test] + fn an_embeddable_marker_without_text_does_not_hide_the_image() { + assert_eq!( + PROBE.classify(&["cheap/image", "embedded/document"]), + Some(Family::Image) + ); + } + + /// Los archivos siguen ganando a todo, que es lo que hace que copiar en el + /// explorador se vea como archivos y no como el texto de sus rutas. + #[test] + fn files_still_win_over_everything() { + assert_eq!( + PROBE.classify(&["one/file", "plain/text", "cheap/image", "embedded/document"]), + Some(Family::Files) + ); + } + + /// El mismo contenido en dos envoltorios: se guarda el barato y el caro se + /// anota. Hasta ahora esto solo existía para imágenes. + #[test] + fn the_costlier_wrapping_of_the_same_text_is_only_noted() { + let firefox = ["costly/markup", "cheap/markup", "plain/text"]; + assert!(PROBE.costlier_twin("costly/markup", &firefox)); + assert!(!PROBE.costlier_twin("cheap/markup", &firefox)); + } + + #[test] + fn the_only_wrapping_on_offer_is_never_the_costlier_one() { + assert!(!PROBE.costlier_twin("costly/markup", &["costly/markup"])); + assert!(!PROBE.costlier_twin("costly/image", &["costly/image"])); + } + + #[test] + fn a_type_in_no_group_has_no_twin() { + assert!(!PROBE.costlier_twin("plain/text", &["plain/text", "cheap/markup"])); + } + + /// El marcador que se lee: cero pide no registrar, uno lo permite. + #[test] + fn a_marker_that_says_zero_refuses_the_copy() { + assert_eq!( + PROBE.declines("may/record", &[0, 0, 0, 0]), + Some(Refusal::Declined("may/record")) + ); + assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0]), None); + } + + /// No haber sabido leer el marcador no es que el marcador dijera que no. + #[test] + fn a_marker_too_short_to_read_decides_nothing() { + assert_eq!(PROBE.declines("may/record", &[]), None); + assert_eq!(PROBE.declines("may/record", &[0]), None); + assert_eq!(PROBE.declines("may/record", &[0, 0, 0]), None); + } + + /// Solo los marcadores declarados se leen. Que un formato cualquiera + /// empiece por cuatro ceros no lo convierte en una negativa. + #[test] + fn only_a_declared_marker_is_read() { + assert_eq!(PROBE.declines("plain/text", &[0, 0, 0, 0]), None); + assert_eq!(PROBE.declines("secret/marker", &[0, 0, 0, 0]), None); + } + + /// Un marcador más largo de cuatro bytes se lee por sus cuatro primeros, + /// que es lo que la 2.x hacía y lo que el sistema documenta. + #[test] + fn a_longer_marker_is_read_by_its_first_four_bytes() { + assert_eq!( + PROBE.declines("may/record", &[0, 0, 0, 0, 9, 9]), + Some(Refusal::Declined("may/record")) + ); + assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0, 0, 0]), None); + } + + /// Los cuatro bytes son un entero, no cuatro banderas sueltas. + #[test] + fn the_four_bytes_are_one_little_endian_number() { + assert_eq!(PROBE.declines("may/record", &[0, 0, 0, 1]), None); + assert_eq!(PROBE.declines("may/record", &[0, 1, 0, 0]), None); + } + + /// Cualquiera de los dos caminos basta para no registrar, y cada uno dice + /// cuál fue: el panel tiene que poder nombrar al que lo pidió. + #[test] + fn either_road_to_a_refusal_names_the_marker() { + assert_eq!( + PROBE + .refusal(&["plain/text", "secret/marker"]) + .map(Refusal::marker), + Some("secret/marker") + ); + assert_eq!( + PROBE + .declines("may/record", &[0, 0, 0, 0]) + .map(Refusal::marker), + Some("may/record") + ); + } +} + #[cfg(test)] mod properties { use super::tests::PROBE; @@ -256,6 +477,9 @@ mod properties { Just("huge/icon".to_string()), Just("cheap/image".to_string()), Just("costly/image".to_string()), + Just("cheap/markup".to_string()), + Just("costly/markup".to_string()), + Just("embedded/document".to_string()), Just("one/file".to_string()), "[a-z]{0,12}/[a-z]{0,12}", "dyn\\.[a-z0-9]{0,20}", @@ -317,5 +541,54 @@ mod properties { None => {} } } + + /// De cada grupo de representaciones se guarda exactamente una: nunca + /// las dos, y nunca ninguna cuando el grupo estaba en la oferta. + #[test] + fn exactly_one_of_each_group_is_kept(ids in prop::collection::vec(any_id(), 0..8)) { + let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + for group in std::iter::once(PROBE.images_by_preference) + .chain(PROBE.equivalents.iter().copied()) + { + let kept: Vec<&&str> = group + .iter() + .filter(|id| refs.iter().any(|one| PROBE.canonical(one) == **id)) + .filter(|id| !PROBE.costlier_twin(id, &refs)) + .collect(); + let offered = group + .iter() + .any(|id| refs.iter().any(|one| PROBE.canonical(one) == *id)); + prop_assert_eq!(kept.len(), usize::from(offered)); + } + } + + /// Un marcador que no dice cero nunca rechaza, y uno que no está + /// declarado no decide nada por mucho que valga cero. + #[test] + fn only_a_zero_in_a_declared_marker_refuses( + id in any_id(), + value in prop::collection::vec(any::(), 0..8), + ) { + match PROBE.declines(&id, &value) { + Some(Refusal::Declined(marker)) => { + prop_assert!(PROBE.denied_when_zero.contains(&marker)); + prop_assert!(value.len() >= 4); + prop_assert_eq!(u32::from_le_bytes([value[0], value[1], value[2], value[3]]), 0); + } + Some(other) => prop_assert!(false, "no es una negativa por valor: {:?}", other), + None => {} + } + } + + /// La clase nunca depende del orden en que la fuente enumeró sus tipos. + #[test] + fn the_class_does_not_depend_on_the_order_offered( + ids in prop::collection::vec(any_id(), 0..8), + ) { + let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + let mut backwards = refs.clone(); + backwards.reverse(); + prop_assert_eq!(PROBE.classify(&refs), PROBE.classify(&backwards)); + } } } diff --git a/crates/cp-core/src/kind.rs b/crates/cp-core/src/kind.rs index a27b1950..d5ae4217 100644 --- a/crates/cp-core/src/kind.rs +++ b/crates/cp-core/src/kind.rs @@ -94,10 +94,11 @@ pub fn classify_file(name: &str, is_directory: bool) -> Kind { "mp3", "m4a", "aac", "wav", "aiff", "aif", "flac", "ogg", "opus", "wma", ]; const VIDEO: &[&str] = &[ - "mp4", "mov", "m4v", "avi", "mkv", "webm", "wmv", "mpg", "mpeg", + "mp4", "mov", "m4v", "avi", "mkv", "webm", "wmv", "mpg", "mpeg", "flv", ]; const IMAGE: &[&str] = &[ "png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "tiff", "tif", "bmp", "svg", "avif", + "ico", ]; if AUDIO.contains(&extension.as_str()) { return Kind::Audio; @@ -137,7 +138,7 @@ fn is_email(text: &str) -> bool { fn is_url(text: &str) -> bool { const SCHEMES: &[&str] = &[ - "http://", "https://", "ftp://", "ftps://", "file://", "ssh://", + "http://", "https://", "ftp://", "ftps://", "file://", "ssh://", "mailto:", ]; if text.contains(char::is_whitespace) { return false; @@ -494,6 +495,12 @@ mod borders { assert!(!is_phone("(123) 456-7890 ñ"), "una letra rompe la forma"); } + #[test] + fn nothing_at_all_is_not_an_object() { + assert!(!is_json(""), "sin un primer carácter no hay delimitador"); + assert!(is_json("{}")); + } + #[test] fn a_quote_inside_a_string_does_not_close_it() { assert!(balanced(r#"{"\""}"#), "la comilla escapada sigue dentro"); @@ -555,3 +562,85 @@ mod redundancy { } } } + +/// Lo que la 2.x reconocía en Windows y la 3.0 había dejado fuera, más los +/// bordes que solo aparecen con rutas de Windows delante. +#[cfg(test)] +mod inherited_from_2x { + use super::*; + + /// Las tres clases que la tabla de la 2.x tenía y esta no: dos extensiones + /// y un esquema. Perderlas era degradar a un usuario que actualiza. + #[test] + fn the_three_the_rewrite_had_dropped() { + assert_eq!(classify_file("video.flv", false), Kind::Video); + assert_eq!(classify_file("favicon.ico", false), Kind::Image); + assert_eq!(classify_text("mailto:alguien@ejemplo.test"), Kind::Link); + } + + /// Y las que la 3.0 añadió sobre la tabla de la 2.x no se pierden al + /// traerlas de vuelta. + #[test] + fn what_the_rewrite_added_survives() { + for (name, kind) in [ + ("captura.heic", Kind::Image), + ("captura.avif", Kind::Image), + ("pelicula.m4v", Kind::Video), + ("pelicula.mpeg", Kind::Video), + ("audio.opus", Kind::Audio), + ("audio.aiff", Kind::Audio), + ] { + assert_eq!(classify_file(name, false), kind, "«{name}»"); + } + } + + /// Un esquema sin nada detras no es una direccion, tampoco el de correo. + #[test] + fn a_bare_mail_scheme_is_not_a_link() { + assert_eq!(classify_text("mailto:"), Kind::Text); + assert_eq!(classify_text("MAILTO:alguien@ejemplo.test"), Kind::Link); + } + + /// Una direccion de correo suelta sigue siendo un correo y no un enlace: + /// son dos clases distintas y el filtro por pestañas las separa. + #[test] + fn an_address_without_the_scheme_is_still_an_address() { + assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); + } + + /// Las rutas de Windows llevan otro separador y otra forma. La extension + /// es siempre la del ultimo tramo, aunque la carpeta tenga un punto. + #[test] + fn a_windows_path_is_classified_by_its_last_segment() { + for (path, kind) in [ + (r"C:\Users\ana\Imagenes\foto.PNG", Kind::Image), + (r"C:\fotos.2024\captura.jpg", Kind::Image), + (r"C:\version.1.2\programa", Kind::File), + (r"\servidor\compartido\clip.MKV", Kind::Video), + (r"C:\sin_extension", Kind::File), + ] { + assert_eq!(classify_file(path, false), kind, "«{path}»"); + } + } + + /// Una carpeta lo es aunque su nombre termine en algo que parece extension. + #[test] + fn a_folder_named_like_a_file_is_still_a_folder() { + assert_eq!(classify_file(r"C:\copias\respaldo.zip", true), Kind::Folder); + assert_eq!(classify_file("fotos.png", true), Kind::Folder); + } + + /// Un archivo que es solo extension no tiene extension. + #[test] + fn a_name_that_is_only_a_dot_has_no_extension() { + assert_eq!(classify_file(".png", false), Kind::Image); + assert_eq!(classify_file(".", false), Kind::File); + assert_eq!(classify_file("", false), Kind::File); + } + + /// La ruta local que Windows entrega en CF_HDROP no es una direccion web. + #[test] + fn a_local_windows_path_is_not_a_link() { + assert_eq!(classify_text(r"C:\Users\ana\documento.txt"), Kind::Text); + } +} diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index 06c06150..d65efb4e 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod destination; +pub mod dib; pub mod formats; pub mod hash; pub mod item; diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index 70f445f4..4935991f 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -5,26 +5,53 @@ pub enum Seen { Nothing, /// Se movió por nuestra propia escritura. Ours, - /// Hay contenido nuevo. `skipped` son las copias que ocurrieron entre - /// este sondeo y el anterior y que ya no se pueden recuperar: el sistema - /// solo guarda la última. No se ignoran, se cuentan. - Fresh { skipped: u64 }, + /// Hay contenido nuevo. `skipped` son las copias que ocurrieron entre este + /// sondeo y el anterior y que ya no se pueden recuperar: el sistema solo + /// guarda la última. No se ignoran, se cuentan —y donde el contador no + /// permite contarlas, se dice que no se sabe en vez de decir cero. + Fresh { skipped: Option }, +} + +/// Qué significa un salto del contador, que no es lo mismo en cada sistema. +/// +/// Elegir mal es contar copias perdidas que nunca ocurrieron, así que el +/// vigilante no tiene valor por defecto: quien lo construye lo declara. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cadence { + /// Una unidad por copia. Medido el 12/09/2026 en macOS 26.6.2: el + /// `changeCount` sube exactamente de uno en uno por escritura, incluso con + /// cinco escrituras separadas por 5 ms, así que un salto mayor que uno son + /// copias que no se vieron. + OnePerCopy, + /// Una cantidad que depende de cuántos formatos sintetiza el sistema y de + /// si quien copió pasó por OLE. Medido el 14/09/2026 en Windows 11 26200: + /// `GetClipboardSequenceNumber` sube **+5** al escribir `CF_UNICODETEXT` + /// directamente, **+12** con el mismo texto a través de OLE, **+11** con + /// `CF_HDROP` y **+9** con una imagen. El salto dice que hubo cambio; + /// cuántas copias hubo no lo dice. + Opaque, } /// Sondea el contador de secuencia del portapapeles —`changeCount` en macOS, /// `GetClipboardSequenceNumber` en Windows— y decide qué ha pasado. -/// -/// Medido el 12/09/2026: en macOS el contador sube exactamente de uno en uno -/// por escritura, incluso con cinco escrituras separadas por 5 ms, así que un -/// salto mayor que uno significa copias que no se vieron. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Watcher { last: Option, ours: Option, missed: u64, + cadence: Cadence, } impl Watcher { + pub fn new(cadence: Cadence) -> Self { + Self { + last: None, + ours: None, + missed: 0, + cadence, + } + } + /// Registra el contador que dejó nuestra propia escritura, para /// descartar exactamente esa y no una ventana de tiempo alrededor. pub fn wrote(&mut self, count: i64) { @@ -46,16 +73,30 @@ impl Watcher { self.ours = None; return Seen::Ours; } - // El contador retrocede al reiniciarse la sesión de ventanas, y eso - // no es una ráfaga de copias perdidas: el `max` deja ese caso en cero. - let skipped = count.saturating_sub(last).saturating_sub(1).max(0) as u64; - self.missed = self.missed.saturating_add(skipped); + let skipped = match self.cadence { + // El contador retrocede al reiniciarse la sesión de ventanas, y eso + // no es una ráfaga de copias perdidas: el `max` deja ese caso en cero. + Cadence::OnePerCopy => { + let skipped = count.saturating_sub(last).saturating_sub(1).max(0) as u64; + self.missed = self.missed.saturating_add(skipped); + Some(skipped) + } + Cadence::Opaque => None, + }; Seen::Fresh { skipped } } /// Cuántas copias se sabe que ocurrieron y no se pudieron capturar. - pub fn missed(&self) -> u64 { - self.missed + /// `None` donde el contador de la plataforma no permite saberlo. + pub fn missed(&self) -> Option { + match self.cadence { + Cadence::OnePerCopy => Some(self.missed), + Cadence::Opaque => None, + } + } + + pub fn cadence(&self) -> Cadence { + self.cadence } } @@ -63,96 +104,192 @@ impl Watcher { mod tests { use super::*; + fn mac() -> Watcher { + Watcher::new(Cadence::OnePerCopy) + } + #[test] fn the_counter_at_its_limits_does_not_overflow() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(i64::MIN); // La resta de los dos extremos no cabe en i64. let seen = watcher.tick(i64::MAX); assert!(matches!(seen, Seen::Fresh { .. })); - let mut other = Watcher::default(); + let mut other = mac(); other.tick(i64::MAX); assert_eq!(other.tick(i64::MAX), Seen::Nothing); - assert!(matches!(other.tick(i64::MIN), Seen::Fresh { skipped: 0 })); + assert!(matches!( + other.tick(i64::MIN), + Seen::Fresh { skipped: Some(0) } + )); } #[test] fn repeated_giant_jumps_do_not_wrap_the_counter_of_losses() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(0); for step in 1..=4 { watcher.tick(i64::MAX / 4 * step); } - assert!(watcher.missed() > 0, "algo se perdió, y se sabe"); + assert!(watcher.missed() > Some(0), "algo se perdió, y se sabe"); } #[test] fn a_negative_counter_is_handled_like_any_other() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(-100); - assert_eq!(watcher.tick(-99), Seen::Fresh { skipped: 0 }); - assert_eq!(watcher.tick(-95), Seen::Fresh { skipped: 3 }); + assert_eq!(watcher.tick(-99), Seen::Fresh { skipped: Some(0) }); + assert_eq!(watcher.tick(-95), Seen::Fresh { skipped: Some(3) }); } #[test] fn the_first_look_only_sets_the_mark() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); assert_eq!(watcher.tick(42), Seen::Nothing); - assert_eq!(watcher.missed(), 0); + assert_eq!(watcher.missed(), Some(0)); } #[test] fn a_still_counter_is_not_an_event() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(7); assert_eq!(watcher.tick(7), Seen::Nothing); } #[test] fn one_step_is_one_copy_with_nothing_lost() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(7); - assert_eq!(watcher.tick(8), Seen::Fresh { skipped: 0 }); - assert_eq!(watcher.missed(), 0); + assert_eq!(watcher.tick(8), Seen::Fresh { skipped: Some(0) }); + assert_eq!(watcher.missed(), Some(0)); } #[test] fn a_burst_is_counted_not_ignored() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(10); - assert_eq!(watcher.tick(14), Seen::Fresh { skipped: 3 }); - assert_eq!(watcher.missed(), 3, "tres copias ocurrieron y se perdieron"); + assert_eq!(watcher.tick(14), Seen::Fresh { skipped: Some(3) }); + assert_eq!( + watcher.missed(), + Some(3), + "tres copias ocurrieron y se perdieron" + ); } #[test] fn our_own_write_is_discarded_exactly() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(20); watcher.wrote(21); assert_eq!(watcher.tick(21), Seen::Ours); - assert_eq!(watcher.missed(), 0); + assert_eq!(watcher.missed(), Some(0)); } #[test] fn a_real_copy_right_after_ours_is_not_swallowed() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(20); watcher.wrote(21); assert_eq!(watcher.tick(21), Seen::Ours); assert_eq!( watcher.tick(22), - Seen::Fresh { skipped: 0 }, + Seen::Fresh { skipped: Some(0) }, "la ventana ciega de la 2.x se comía esta" ); } #[test] fn a_counter_that_goes_backwards_is_a_new_session_not_a_burst() { - let mut watcher = Watcher::default(); + let mut watcher = mac(); watcher.tick(5000); - assert_eq!(watcher.tick(3), Seen::Fresh { skipped: 0 }); - assert_eq!(watcher.missed(), 0); + assert_eq!(watcher.tick(3), Seen::Fresh { skipped: Some(0) }); + assert_eq!(watcher.missed(), Some(0)); + } +} + +/// El contador de Windows sube una cantidad que no es el número de copias. +/// Medido el 14/09/2026 en Windows 11 26200 con `GetClipboardSequenceNumber`. +#[cfg(test)] +mod windows_counter { + use super::*; + + fn windows() -> Watcher { + Watcher::new(Cadence::Opaque) + } + + /// El salto medido de una copia de texto corriente. Con la cadencia de + /// macOS, esa copia se anotaría como cuatro pérdidas que nunca existieron. + #[test] + fn one_ordinary_copy_is_not_a_burst_of_four() { + let mut watcher = windows(); + watcher.tick(51); + assert_eq!(watcher.tick(56), Seen::Fresh { skipped: None }); + assert_eq!(watcher.missed(), None, "no se sabe, y se dice"); + + let mut as_if_mac = Watcher::new(Cadence::OnePerCopy); + as_if_mac.tick(51); + assert_eq!( + as_if_mac.tick(56), + Seen::Fresh { skipped: Some(4) }, + "la cadencia equivocada inventa cuatro copias perdidas" + ); + } + + /// Los cuatro saltos medidos, uno por forma de copiar. Ninguno vale uno, y + /// entre ellos no hay proporción: por eso el número no se interpreta. + #[test] + fn every_measured_jump_is_a_single_copy() { + for jump in [5, 9, 11, 12] { + let mut watcher = windows(); + watcher.tick(1000); + assert_eq!( + watcher.tick(1000 + jump), + Seen::Fresh { skipped: None }, + "un salto de {jump} sigue siendo una copia" + ); + } + } + + /// El contador vale cero cuando el proceso no alcanza la estación de + /// ventanas —escritorio seguro, pantalla de bloqueo—. Volver de ahí es un + /// salto enorme desde cero, y no una ráfaga de mil copias. + #[test] + fn coming_back_from_a_locked_desktop_is_one_copy() { + let mut watcher = windows(); + watcher.tick(1000); + assert_eq!(watcher.tick(0), Seen::Fresh { skipped: None }); + assert_eq!(watcher.tick(1005), Seen::Fresh { skipped: None }); + assert_eq!(watcher.missed(), None); + } + + /// Lo que no depende de la cadencia: nuestra propia escritura se descarta + /// por el valor exacto, y la copia que llega justo después no se traga. + #[test] + fn our_own_write_is_still_discarded_exactly() { + let mut watcher = windows(); + watcher.tick(100); + watcher.wrote(112); + assert_eq!(watcher.tick(112), Seen::Ours); + assert_eq!(watcher.tick(124), Seen::Fresh { skipped: None }); + } + + #[test] + fn a_still_counter_is_still_not_an_event() { + let mut watcher = windows(); + watcher.tick(7); + assert_eq!(watcher.tick(7), Seen::Nothing); + } + + /// El vigilante dice con qué cadencia se construyó: el diagnóstico tiene + /// que poder distinguir «no hubo pérdidas» de «no se pueden contar». + #[test] + fn the_watcher_says_which_counter_it_is_reading() { + assert_eq!(windows().cadence(), Cadence::Opaque); + assert_eq!( + Watcher::new(Cadence::OnePerCopy).cadence(), + Cadence::OnePerCopy + ); } } @@ -172,7 +309,7 @@ mod properties { /// una ráfaga—, y lo que avanzó sin emitirse está contado como perdido. #[test] fn every_change_is_either_seen_or_counted(seq in ticks()) { - let mut watcher = Watcher::default(); + let mut watcher = Watcher::new(Cadence::OnePerCopy); let mut emitted: u64 = 0; for count in &seq { if let Seen::Fresh { .. } = watcher.tick(*count) { @@ -192,25 +329,31 @@ mod properties { } } - prop_assert_eq!(climbed as u64 + restarts, emitted + watcher.missed()); + prop_assert_eq!(climbed as u64 + restarts, emitted + watcher.missed().unwrap()); } - /// Un contador que no avanza nunca produce un evento. + /// Un contador que no avanza nunca produce un evento, se cuente como + /// se cuente. #[test] - fn a_still_counter_never_fires(start in 0i64..1000, repeats in 1usize..20) { - let mut watcher = Watcher::default(); + fn a_still_counter_never_fires( + start in 0i64..1000, + repeats in 1usize..20, + opaque in any::(), + ) { + let cadence = if opaque { Cadence::Opaque } else { Cadence::OnePerCopy }; + let mut watcher = Watcher::new(cadence); watcher.tick(start); for _ in 0..repeats { prop_assert_eq!(watcher.tick(start), Seen::Nothing); } - prop_assert_eq!(watcher.missed(), 0); + prop_assert_eq!(watcher.missed().unwrap_or(0), 0); } /// Lo perdido solo puede crecer. #[test] fn what_was_missed_never_shrinks(seq in ticks()) { - let mut watcher = Watcher::default(); - let mut floor = 0; + let mut watcher = Watcher::new(Cadence::OnePerCopy); + let mut floor = Some(0); for count in seq { watcher.tick(count); prop_assert!(watcher.missed() >= floor); @@ -218,28 +361,60 @@ mod properties { } } + /// Un contador opaco no inventa una cifra jamás, salte lo que salte. + #[test] + fn an_opaque_counter_never_invents_a_number( + seq in prop::collection::vec(0i64..100_000, 1..40), + ) { + let mut watcher = Watcher::new(Cadence::Opaque); + for count in seq { + if let Seen::Fresh { skipped } = watcher.tick(count) { + prop_assert_eq!(skipped, None); + } + prop_assert_eq!(watcher.missed(), None); + } + } + + /// Las dos cadencias ven **los mismos eventos**: lo único que cambia es + /// si se puede decir cuántas copias se perdieron, nunca si hubo copia. + #[test] + fn the_cadence_changes_the_count_never_the_event(seq in ticks()) { + let mut counted = Watcher::new(Cadence::OnePerCopy); + let mut opaque = Watcher::new(Cadence::Opaque); + for count in seq { + let one = counted.tick(count); + let other = opaque.tick(count); + prop_assert_eq!( + matches!(one, Seen::Fresh { .. }), + matches!(other, Seen::Fresh { .. }) + ); + prop_assert_eq!(one == Seen::Ours, other == Seen::Ours); + prop_assert_eq!(one == Seen::Nothing, other == Seen::Nothing); + } + } + /// Solo el contador exacto que registramos se descarta como nuestro, y /// solo una vez. #[test] fn only_the_exact_registered_count_is_ours(start in 0i64..1000, gap in 1i64..10) { - let mut watcher = Watcher::default(); + let mut watcher = Watcher::new(Cadence::OnePerCopy); watcher.tick(start); watcher.wrote(start + gap); let landed = watcher.tick(start + gap); prop_assert_eq!(landed, Seen::Ours); // El mismo valor otra vez ya no es nuestro: es que alguien copió. prop_assert_eq!(watcher.tick(start + gap), Seen::Nothing); - prop_assert_eq!(watcher.tick(start + gap + 1), Seen::Fresh { skipped: 0 }); + prop_assert_eq!(watcher.tick(start + gap + 1), Seen::Fresh { skipped: Some(0) }); } /// Registrar una escritura que nunca llega no puede tragarse una copia /// ajena posterior. #[test] fn a_write_that_never_lands_swallows_nothing(start in 0i64..1000) { - let mut watcher = Watcher::default(); + let mut watcher = Watcher::new(Cadence::OnePerCopy); watcher.tick(start); watcher.wrote(start + 99); - prop_assert_eq!(watcher.tick(start + 1), Seen::Fresh { skipped: 0 }); + prop_assert_eq!(watcher.tick(start + 1), Seen::Fresh { skipped: Some(0) }); } } } diff --git a/crates/cp-mac-sys/build.rs b/crates/cp-mac-sys/build.rs index f6da49f4..1ba2c03e 100644 --- a/crates/cp-mac-sys/build.rs +++ b/crates/cp-mac-sys/build.rs @@ -1,4 +1,9 @@ fn main() { + // Sin la guarda, `cargo test --workspace` en Windows falla antes de + // compilar una sola linea del nucleo: los frameworks solo existen en Apple. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { + return; + } println!("cargo:rustc-link-lib=framework=CoreGraphics"); println!("cargo:rustc-link-lib=framework=ApplicationServices"); println!("cargo:rustc-link-lib=framework=Carbon"); diff --git a/crates/cp-mac/Cargo.toml b/crates/cp-mac/Cargo.toml index 9006a08d..2871f505 100644 --- a/crates/cp-mac/Cargo.toml +++ b/crates/cp-mac/Cargo.toml @@ -13,7 +13,7 @@ cp-mac-sys.workspace = true thiserror.workspace = true objc2-foundation.workspace = true -[dev-dependencies] +[target.'cfg(target_os = "macos")'.dev-dependencies] cp-store.workspace = true cp-core.workspace = true cp-mac-sys.workspace = true diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index fed168c3..2a7c1daf 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -4,931 +4,11 @@ //! principal y el runner no lo garantiza, y varias tocan aplicaciones reales. //! Se ejecuta con `cargo run -p cp-mac --example probe`. -use cp_core::destination::Tracker; -use cp_core::item::Payload; -use cp_core::kind::Kind; -use cp_core::watch::{Seen, Watcher}; -use cp_mac::capture::capture; -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 std::time::{Duration, Instant}; - -struct Battery { - passed: u32, - failed: u32, - skipped: u32, -} - -impl Battery { - fn group(&self, name: &str) { - println!("\n {name}"); - } - - fn case(&mut self, id: &str, what: &str, run: impl FnOnce() -> Result<(), String>) { - match run() { - Ok(()) => { - self.passed += 1; - println!(" ok {id:<5} {what}"); - } - Err(why) => { - self.failed += 1; - println!(" FALLA {id:<5} {what}\n {why}"); - } - } - } - - fn skip(&mut self, id: &str, what: &str, why: &str) { - self.skipped += 1; - println!(" — {id:<5} {what} ({why})"); - } -} - -fn main() -> std::process::ExitCode { - let Some(mtm) = MainThreadMarker::new() else { - eprintln!("esto tiene que correr en el hilo principal"); - return std::process::ExitCode::FAILURE; - }; - let pb = Pasteboard::general(mtm); - let ready = Readiness::probe(); - let mut b = Battery { - passed: 0, - failed: 0, - skipped: 0, - }; - - b.group("A · Captura y formatos"); - - b.case("A1", "el texto plano va y vuelve", || { - pb.write_text("cp-a1"); - let item = capture(&pb).ok_or("no se capturó")?; - if item.kind != Some(Kind::Text) { - return Err(format!("se clasificó como {:?}", item.kind)); - } - match &item - .format("public.utf8-plain-text") - .ok_or("falta el texto")? - .payload - { - Payload::Inline(bytes) if bytes == b"cp-a1" => Ok(()), - other => Err(format!("llegó {other:?}")), - } - }); - - b.case("A2", "los gemelos legados no se guardan dos veces", || { - pb.write_text("cp-a2"); - let item = capture(&pb).ok_or("no se capturó")?; - let ids: Vec<&str> = item.formats.iter().map(|f| f.id.as_str()).collect(); - let mut unique = ids.clone(); - unique.sort_unstable(); - unique.dedup(); - if ids.len() != unique.len() { - return Err(format!("repetidos: {ids:?}")); - } - if ids.contains(&"NSStringPboardType") { - return Err("el gemelo legado no colapsó".into()); - } - Ok(()) - }); - - b.case("A3", "un texto vacío sigue siendo un ítem", || { - pb.write_text(""); - let item = capture(&pb).ok_or("no se capturó")?; - if item.formats.is_empty() { - return Err("sin formatos".into()); - } - Ok(()) - }); - - b.case("A4", "diez megabytes van y vuelven enteros", || { - let big = "a".repeat(10 * 1024 * 1024); - pb.write_text(&big); - let item = capture(&pb).ok_or("no se capturó")?; - match &item - .format("public.utf8-plain-text") - .ok_or("falta el texto")? - .payload - { - Payload::Blob(bytes) if bytes.len() == big.len() => Ok(()), - other => Err(format!("llegó {other:?}")), - } - }); - - b.case("A5", "un solo carácter multibyte", || { - pb.write_text("🎯"); - let item = capture(&pb).ok_or("no se capturó")?; - match &item - .format("public.utf8-plain-text") - .ok_or("falta el texto")? - .payload - { - Payload::Inline(bytes) if bytes == "🎯".as_bytes() => Ok(()), - other => Err(format!("llegó {other:?}")), - } - }); - - b.case("A6", "saltos de línea de los tres tipos", || { - let mixed = "uno\r\ndos\rtres\ncuatro"; - pb.write_text(mixed); - let item = capture(&pb).ok_or("no se capturó")?; - match &item - .format("public.utf8-plain-text") - .ok_or("falta el texto")? - .payload - { - Payload::Inline(bytes) if bytes == mixed.as_bytes() => Ok(()), - other => Err(format!("llegó {other:?}")), - } - }); - - b.case("A7", "tres archivos copiados son tres rutas", || { - pb.write_items(&[ - vec![("public.file-url", "file:///tmp/uno.txt")], - vec![("public.file-url", "file:///tmp/dos.txt")], - vec![("public.file-url", "file:///tmp/tres.txt")], - ]); - if pb.item_count() != 3 { - return Err(format!("el portapapeles tiene {} ítems", pb.item_count())); - } - let item = capture(&pb).ok_or("no se capturó")?; - let urls = item.format("public.file-url").ok_or("falta la ruta")?; - let text = match &urls.payload { - Payload::Inline(bytes) => String::from_utf8_lossy(bytes).to_string(), - other => return Err(format!("llegó {other:?}")), - }; - let lines: Vec<&str> = text.lines().collect(); - if lines.len() != 3 { - return Err(format!("se guardaron {} rutas: {lines:?}", lines.len())); - } - Ok(()) - }); - - b.case("A8", "un solo archivo sigue siendo una ruta", || { - pb.write_items(&[vec![("public.file-url", "file:///tmp/solo.txt")]]); - let item = capture(&pb).ok_or("no se capturó")?; - let urls = item.format("public.file-url").ok_or("falta la ruta")?; - match &urls.payload { - Payload::Inline(bytes) if !String::from_utf8_lossy(bytes).contains('\n') => Ok(()), - other => Err(format!("llegó {other:?}")), - } - }); - - b.group("I · Volver al portapapeles"); - - b.case("I1", "un ítem vuelve con todos sus formatos", || { - pb.write_types(&[ - ("public.utf8-plain-text", "texto plano"), - ("public.html", "texto plano"), - ]); - let captured = capture(&pb).ok_or("no se capturó")?; - let had = captured.formats.len(); - - // Se ensucia el portapapeles con otra cosa, como haría el usuario. - pb.write_text("algo distinto"); - - match cp_mac::restore::to_pasteboard(&pb, &captured) { - cp_mac::restore::Restored::Written { formats, .. } if formats >= 2 => {} - other => return Err(format!("restauró {other:?} de {had} formatos")), - } - - let back = capture(&pb).ok_or("no se capturó lo restaurado")?; - let text = back - .format("public.utf8-plain-text") - .ok_or("falta el texto")?; - if text.payload != Payload::Inline(b"texto plano".to_vec()) { - return Err(format!("el texto volvió como {:?}", text.payload)); - } - if back.format("public.html").is_none() { - return Err("el HTML no volvió: pegarlo perdería los estilos".into()); - } - Ok(()) - }); - - b.case("I2", "una imagen vuelve entera", || { - let png = std::fs::read("fixtures/texto-en-imagen.png") - .map_err(|why| format!("falta el fixture: {why}"))?; - let item = cp_core::item::Item { - kind: Some(Kind::Image), - formats: vec![cp_core::item::Format { - id: "public.png".into(), - payload: cp_core::item::Payload::Blob(png.clone()), - }], - }; - pb.write_text("otra cosa"); - match cp_mac::restore::to_pasteboard(&pb, &item) { - cp_mac::restore::Restored::Written { .. } => {} - other => return Err(format!("no se restauró: {other:?}")), - } - let back = pb.data("public.png").ok_or("no volvió el png")?; - if back.len() != png.len() { - return Err(format!("volvieron {} bytes de {}", back.len(), png.len())); - } - Ok(()) - }); - - b.case( - "I3", - "un ítem sin bytes lo dice en vez de vaciar el portapapeles", - || { - let hollow = cp_core::item::Item { - kind: None, - formats: vec![cp_core::item::Format { - id: "com.apple.icns".into(), - payload: cp_core::item::Payload::Announced { size: Some(10) }, - }], - }; - pb.write_text("lo que había antes"); - match cp_mac::restore::to_pasteboard(&pb, &hollow) { - cp_mac::restore::Restored::NothingToWrite => {} - other => return Err(format!("devolvió {other:?}")), - } - let kept = pb - .data("public.utf8-plain-text") - .and_then(|bytes| String::from_utf8(bytes).ok()) - .unwrap_or_default(); - if kept != "lo que había antes" { - return Err("se perdió lo que el usuario tenía copiado".into()); - } - Ok(()) - }, - ); - - b.case( - "I4", - "restaurar avisa si el ítem estaba incompleto", - || { - let partial = cp_core::item::Item { - kind: Some(Kind::Text), - formats: vec![ - cp_core::item::Format { - id: "public.utf8-plain-text".into(), - payload: cp_core::item::Payload::Inline(b"algo".to_vec()), - }, - cp_core::item::Format { - id: "public.tiff".into(), - payload: cp_core::item::Payload::Announced { size: Some(999) }, - }, - ], - }; - match cp_mac::restore::to_pasteboard(&pb, &partial) { - cp_mac::restore::Restored::Written { - formats: 1, - incomplete: true, - } => Ok(()), - other => Err(format!("devolvió {other:?}")), - } - }, - ); - - b.group("J · Archivos que ya no están"); - - b.case("J1", "una ruta que existe no se marca como rota", || { - let path = std::env::temp_dir().join("cp-probe-existe.txt"); - std::fs::write(&path, b"aqui estoy").map_err(|why| why.to_string())?; - let url = format!("file://{}", path.display()); - let missing = frontmost::missing_paths(&url); - std::fs::remove_file(&path).ok(); - if !missing.is_empty() { - return Err(format!("dijo que faltaba: {missing:?}")); - } - Ok(()) - }); - - b.case("J2", "una ruta borrada se detecta", || { - let path = std::env::temp_dir().join("cp-probe-borrado.txt"); - std::fs::write(&path, "efimero".as_bytes()).map_err(|why| why.to_string())?; - let url = format!("file://{}", path.display()); - std::fs::remove_file(&path).map_err(|why| why.to_string())?; - let missing = frontmost::missing_paths(&url); - if missing.len() != 1 { - return Err("no se enteró de que el archivo ya no está".into()); - } - Ok(()) - }); - - b.case("J3", "de tres archivos se dice cuál falta", || { - let dir = std::env::temp_dir(); - let uno = dir.join("cp-probe-uno.txt"); - let dos = dir.join("cp-probe-dos.txt"); - std::fs::write(&uno, b"a").map_err(|why| why.to_string())?; - std::fs::write(&dos, b"b").map_err(|why| why.to_string())?; - let urls = format!( - "file://{}\nfile://{}\nfile://{}", - uno.display(), - dos.display(), - dir.join("cp-probe-fantasma.txt").display() - ); - let missing = frontmost::missing_paths(&urls); - std::fs::remove_file(&uno).ok(); - std::fs::remove_file(&dos).ok(); - if missing.len() != 1 || !missing[0].contains("fantasma") { - return Err(format!("dijo que faltaban: {missing:?}")); - } - Ok(()) - }); - - b.case("J4", "una ruta con espacios y acentos se entiende", || { - let path = std::env::temp_dir().join("cp probe ñandú.txt"); - std::fs::write(&path, b"con acentos").map_err(|why| why.to_string())?; - let encoded = format!( - "file://{}", - path.display() - .to_string() - .replace(' ', "%20") - .replace('ñ', "%C3%B1") - .replace('ú', "%C3%BA") - ); - let missing = frontmost::missing_paths(&encoded); - std::fs::remove_file(&path).ok(); - if !missing.is_empty() { - return Err("una ruta escapada se tomó por inexistente".into()); - } - Ok(()) - }); - - b.group("K · Origen"); - - b.case( - "K1", - "la aplicación de origen se guarda con su nombre visible", - || { - let (pid, bundle) = frontmost::frontmost().ok_or("nadie al frente")?; - let name = frontmost::app_name(pid).ok_or("sin nombre visible")?; - if name.is_empty() { - return Err("el nombre llegó vacío".into()); - } - if Some(name.as_str()) == bundle.as_deref() { - return Err(format!("«{name}» es el identificador, 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.search(&name).map_err(|why| why.to_string())?; - if found.len() != 1 { - return Err(format!("buscando «{name}» salieron {} ítems", found.len())); - } - Ok(()) - }, - ); - - b.group("L · Miniaturas y medios"); - - b.case( - "L1", - "una captura da sus dimensiones sin decodificarse", - || { - let png = std::fs::read("fixtures/texto-en-imagen.png") - .map_err(|why| format!("falta el fixture: {why}"))?; - let size = cp_core::thumbnail::size_of(&png).ok_or("no se leyó el tamaño")?; - // El fixture se dibujó a 720×160 puntos en una pantalla Retina, - // así que el archivo tiene el doble de píxeles. Lo que se guarda - // y lo que se enseña son cosas distintas; esto es lo que se guarda. - if size.width != 1440 || size.height != 320 { - return Err(format!("dijo {}×{}", size.width, size.height)); - } - Ok(()) - }, - ); - - b.case( - "L2", - "la miniatura pesa mucho menos y guarda la proporción", - || { - 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 size = cp_core::thumbnail::size_of(&thumb).ok_or("sin tamaño")?; - if size.width != cp_core::thumbnail::MAX_SIDE { - return Err(format!("el lado mayor quedó en {}", size.width)); - } - if thumb.len() >= png.len() { - return Err(format!("pesa {} frente a {}", thumb.len(), png.len())); - } - Ok(()) - }, - ); - - b.case("L3", "la miniatura va al almacén y vuelve", || { - let dir = std::env::temp_dir().join(format!("cp-probe-thumbs-{}", pb.change_count())); - 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.case( - "L4", - "un archivo que no es medio no inventa metadatos", - || { - let path = std::env::temp_dir().join("cp-probe-no-media.txt"); - std::fs::write(&path, b"solo texto").map_err(|why| why.to_string())?; - let info = cp_mac_sys::media::info_for(&path); - std::fs::remove_file(&path).ok(); - match info { - None => Ok(()), - Some(found) if found.duration.is_none() => Ok(()), - Some(found) => Err(format!("se inventó {found:?}")), - } - }, - ); - - b.case("L5", "una ruta inexistente no es un medio", || { - let ghost = std::env::temp_dir().join("cp-probe-no-existe.mp4"); - if cp_mac_sys::media::info_for(&ghost).is_some() { - return Err("devolvió datos de algo que no está".into()); - } - Ok(()) - }); - - b.case("I5", "pegar en plano no mutila el ítem guardado", || { - pb.write_types(&[ - ("public.utf8-plain-text", "con estilos"), - ("public.html", "con estilos"), - ("public.rtf", "{\\rtf1 con estilos}"), - ]); - let item = capture(&pb).ok_or("no se capturó")?; - let had = item.formats.len(); - - // Se pega en plano: el portapapeles queda solo con el texto. - match cp_mac::restore::to_pasteboard_as_plain_text(&pb, &item) { - cp_mac::restore::Restored::Written { formats: 1, .. } => {} - other => return Err(format!("devolvió {other:?}")), - } - if pb.data("public.html").is_some() { - return Err("quedó el HTML: no se pegó en plano".into()); - } - - // Y el ítem guardado sigue teniendo todo, así que la próxima vez se - // puede pegar con estilos. - if item.formats.len() != had { - return Err("el ítem perdió formatos".into()); - } - match cp_mac::restore::to_pasteboard(&pb, &item) { - cp_mac::restore::Restored::Written { .. } => {} - other => return Err(format!("no se pudo restaurar con estilos: {other:?}")), - } - if pb.data("public.html").is_none() { - return Err("el HTML no volvió: el ítem había quedado mutilado".into()); - } - Ok(()) - }); - - b.case( - "I6", - "un ítem sin texto plano no se puede pegar en plano", - || { - let only_image = cp_core::item::Item { - kind: Some(Kind::Image), - formats: vec![cp_core::item::Format { - id: "public.png".into(), - payload: cp_core::item::Payload::Inline(vec![1, 2, 3]), - }], - }; - pb.write_text("lo que había"); - match cp_mac::restore::to_pasteboard_as_plain_text(&pb, &only_image) { - cp_mac::restore::Restored::NothingToWrite => Ok(()), - other => Err(format!("devolvió {other:?}")), - } - }, - ); - - b.group("G · Clasificación"); - - for (id, text, expected) in [ - ("G1", "alguien@ejemplo.test", Kind::Email), - ("G2", "https://ejemplo.test/ruta", Kind::Link), - ("G3", "#FF8800", Kind::Color), - ("G4", "192.168.1.1", Kind::Ip), - ("G5", "7ab3f6de-1c4b-4f5e-8a2d-9f0e1b2c3d4e", Kind::Uuid), - ("G6", "+34 600 123 456", Kind::Phone), - ("G7", "{\"clave\": [1, 2]}", Kind::Json), - ("G8", "fn main() {\n println!(\"hola\");\n}", Kind::Code), - ("G9", "una frase corriente y nada más", Kind::Text), - ] { - b.case( - id, - &format!("se clasifica como {}", expected.as_str()), - || { - pb.write_text(text); - let item = capture(&pb).ok_or("no se capturó")?; - if item.kind != Some(expected) { - return Err(format!("salió {:?}", item.kind)); - } - Ok(()) - }, - ); - } - - b.group("H · Búsqueda inteligente"); - - b.case("H1", "el texto dentro de una imagen se reconoce", || { - let png = std::fs::read("fixtures/texto-en-imagen.png") - .map_err(|why| format!("falta el fixture: {why}"))?; - let text = cp_mac_sys::ocr::searchable_text(&png).ok_or("Vision no pudo con la imagen")?; - if !text.contains("AB-4417") { - return Err(format!("leyó «{text}»")); - } - Ok(()) - }); - - b.case( - "H2", - "una captura copiada se encuentra por lo que pone dentro", - || { - let png = std::fs::read("fixtures/texto-en-imagen.png") - .map_err(|why| format!("falta el fixture: {why}"))?; - pb.write_data("public.png", &png); - - let item = capture(&pb).ok_or("no se capturó")?; - if item.kind != Some(Kind::Image) { - return Err(format!("se clasificó como {:?}", item.kind)); - } - let bytes = match &item.format("public.png").ok_or("falta el png")?.payload { - Payload::Inline(bytes) | Payload::Blob(bytes) => bytes.clone(), - other => return Err(format!("llegó {other:?}")), - }; - - let store = cp_store::Store::in_memory().map_err(|why| why.to_string())?; - // El ítem entero no cabe en la fila, así que se guarda la referencia y - // el texto reconocido, que es lo que hace buscable la captura. - let light = cp_core::item::Item { - kind: item.kind, - formats: vec![cp_core::item::Format { - id: "public.png".into(), - payload: cp_core::item::Payload::Announced { - size: Some(bytes.len()), - }, - }], - }; - let id = store - .insert_item("uuid-captura", &light, "", 1) - .map_err(|why| why.to_string())?; - let recognised = cp_mac_sys::ocr::searchable_text(&bytes) - .ok_or("Vision no pudo con lo capturado")?; - store - .set_ocr_text(id, &recognised, 2) - .map_err(|why| why.to_string())?; - - let hits = store.search("pedido").map_err(|why| why.to_string())?; - if hits.len() != 1 { - return Err(format!("buscando «pedido» salieron {} ítems", hits.len())); - } - Ok(()) - }, - ); - - b.group("B · Privacidad"); - - for (id, marker) in [ - ("B1", "org.nspasteboard.ConcealedType"), - ("B2", "org.nspasteboard.TransientType"), - ("B3", "com.agilebits.onepassword"), - ("B4", "net.antelle.keeweb"), - ("B5", "PasswordPboardType"), - ] { - b.case(id, &format!("«{marker}» excluye el ítem"), || { - pb.write_types(&[("public.utf8-plain-text", "secreto"), (marker, "1")]); - if capture(&pb).is_some() { - return Err("se capturó contenido marcado".into()); - } - Ok(()) - }); - } - - b.case("B6", "sin marcador se vuelve a capturar", || { - pb.write_text("esto sí"); - capture(&pb).ok_or("un texto normal debe capturarse")?; - Ok(()) - }); - - b.group("C · Vigilante"); - - b.case("C1", "el contador sube de uno en uno", || { - let before = pb.change_count(); - pb.write_text("cp-c1"); - let after = pb.change_count(); - if after - before != 1 { - return Err(format!("saltó de {before} a {after}")); - } - Ok(()) - }); - - b.case("C2", "cien copias, ninguna perdida en silencio", || { - let mut watcher = Watcher::default(); - watcher.tick(pb.change_count()); - let mut seen = 0u64; - for round in 0..100 { - pb.write_text(&format!("cp-c2-{round}")); - if let Seen::Fresh { .. } = watcher.tick(pb.change_count()) { - seen += 1; - } - } - if seen + watcher.missed() != 100 { - return Err(format!("{seen} vistas y {} contadas", watcher.missed())); - } - Ok(()) - }); - - b.case("C3", "sondeo desde otro hilo sin perder nada", || { - let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let (tell, hear) = std::sync::mpsc::channel(); - let watching = { - let stop = stop.clone(); - std::thread::spawn(move || { - let mut watcher = Watcher::default(); - let mut fresh = 0u64; - while !stop.load(std::sync::atomic::Ordering::Relaxed) { - if let Seen::Fresh { .. } = - watcher.tick(pasteboard::change_count_from_any_thread()) - { - fresh += 1; - } - std::thread::sleep(Duration::from_millis(2)); - } - let _ = tell.send((fresh, watcher.missed())); - }) - }; - std::thread::sleep(Duration::from_millis(40)); - for round in 0..25 { - pb.write_text(&format!("cp-c3-{round}")); - std::thread::sleep(Duration::from_millis(12)); - } - std::thread::sleep(Duration::from_millis(80)); - stop.store(true, std::sync::atomic::Ordering::Relaxed); - watching.join().map_err(|_| "el hilo murió")?; - let (fresh, missed) = hear.recv().map_err(|why| why.to_string())?; - if fresh + missed != 25 { - return Err(format!("{fresh} vistas y {missed} contadas de 25")); - } - Ok(()) - }); - - b.case( - "C4", - "nuestra escritura no se confunde con una copia", - || { - let mut watcher = Watcher::default(); - watcher.tick(pb.change_count()); - pb.write_text("cp-c4-nuestro"); - let ours = pb.change_count(); - watcher.wrote(ours); - match watcher.tick(ours) { - Seen::Ours => Ok(()), - other => Err(format!("se vio como {other:?}")), - } - }, - ); - - b.group("D · Teclado"); - - b.case("D1", "el layout activo resuelve la «v»", || { - keyboard::keycode_with_command('v') - .map(|_| ()) - .ok_or_else(|| "no se pudo resolver".to_string()) - }); - - b.case( - "D2", - "Dvorak necesita un keycode distinto", - || match keyboard::keycode_with_command_in(keyboard::DVORAK, 'v') { - Some(code) if code != QWERTY_V => Ok(()), - Some(code) => Err(format!("dio 0x{code:02X}, igual que QWERTY")), - None => Err("Dvorak no está instalado".into()), - }, - ); - - b.case("D3", "los demás layouts coinciden con QWERTY", || { - for (name, id) in [ - ("ABC", keyboard::ABC), - ("AZERTY", keyboard::AZERTY), - ("QWERTZ", keyboard::QWERTZ), - ("Español ISO", keyboard::SPANISH_ISO), - ("Colemak", keyboard::COLEMAK), - ("Dvorak-QWERTY ⌘", keyboard::DVORAK_COMMAND_QWERTY), - ] { - if let Some(code) = keyboard::keycode_with_command_in(id, 'v') - && code != QWERTY_V - { - return Err(format!("{name} dio 0x{code:02X}")); - } - } - Ok(()) - }); - - b.case( - "D4", - "una letra que ningún layout produce no inventa nada", - || match keyboard::keycode_with_command_in(keyboard::ABC, '\u{1F600}') { - None => Ok(()), - Some(code) => Err(format!("devolvió 0x{code:02X} para un emoji")), - }, - ); - - b.case( - "D5", - "el keycode se resuelve al pegar, no al arrancar", - || { - let paster = Paster::new().ok_or("sin fuente de eventos")?; - let now = keyboard::keycode_with_command('v').unwrap_or(QWERTY_V); - if paster.keycode() != now { - return Err(format!( - "el pegador dice 0x{:02X} y el sistema 0x{now:02X}", - paster.keycode() - )); - } - Ok(()) - }, - ); - - b.group("E · Permisos"); - - b.case("E1", "pegar depende solo de poder postear eventos", || { - if ready.can_paste() != ready.can_post { - return Err("la regla se torció".into()); - } - Ok(()) - }); - - b.case("E2", "el input seguro no bloquea el pegado", || { - if ready.secure_input && !ready.can_paste() && ready.can_post { - return Err("se está tratando el input seguro como bloqueo".into()); - } - Ok(()) - }); - - b.group("F · Destino y pegado"); - - b.case( - "F1", - "el destino sobrevive a que el panel tome el frente", - || { - let mut tracker = Tracker::new(frontmost::our_pid()); - let (pid, bundle) = frontmost::frontmost().ok_or("nadie al frente")?; - tracker.saw(pid, bundle.as_deref()); - let target = tracker.destination().ok_or("sin destino")?.clone(); - tracker.saw(frontmost::our_pid(), Some("dev.rgdevment.copypaste")); - if tracker.destination() != Some(&target) { - return Err("el destino cambió".into()); - } - Ok(()) - }, - ); - - b.case( - "F2", - "los modificadores físicos se leen de la fuente", - || { - let _ = keystroke::physical_modifiers(); - if keystroke::modifiers_still_held() { - return Err("hay modificadores pulsados; suelta las teclas".into()); - } - Ok(()) - }, - ); - - match Paster::new() { - Some(paster) => { - b.case("F3", "el pegador entrega un keycode utilizable", || { - if paster.keycode() == 0 { - return Err("keycode inválido".into()); - } - Ok(()) - }); - if ready.can_post { - match paste_round_trip(&pb, &paster) { - Ok(()) => { - b.passed += 1; - println!(" ok F4 pegado real en TextEdit, ida y vuelta"); - } - // Que otra aplicación retenga el primer plano no es un - // fallo del núcleo: es la activación cooperativa que este - // proyecto ya midió. Se omite en vez de dar un rojo falso. - Err(why) if why.starts_with("TextEdit no llegó") => { - b.skip("F4", "pegado real en TextEdit", &why); - } - Err(why) => { - b.failed += 1; - println!(" FALLA F4 pegado real en TextEdit"); - println!(" {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"), - } - - println!(); - println!( - " {} pasan · {} fallan · {} omitidas", - b.passed, b.failed, b.skipped - ); - if b.failed == 0 { - std::process::ExitCode::SUCCESS - } else { - std::process::ExitCode::FAILURE - } -} - -/// Pega en TextEdit y comprueba el resultado sin accesibilidad: tras pegar, -/// selecciona todo y copia, así que lo pegado vuelve por el mismo camino. -fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { - let path = "/tmp/cp-probe-target.txt"; - std::fs::write(path, "").map_err(|why| why.to_string())?; - // Arrancar en frío tarda, y otra aplicación puede tener el foco. Se - // insiste con techo en vez de dormir una cantidad fija y confiar. - run_open(&["-a", "TextEdit", path]); - // Se usa la activación del propio núcleo, que es lo que hará el producto, - // en vez de confiar en que `open` gane el primer plano. - let mut front = None; - for _ in 0..25 { - std::thread::sleep(Duration::from_millis(300)); - let textedit = std::process::Command::new("/usr/bin/pgrep") - .args(["-x", "TextEdit"]) - .output() - .ok() - .and_then(|out| String::from_utf8(out.stdout).ok()) - .and_then(|pids| pids.split_whitespace().next()?.parse::().ok()); - if let Some(pid) = textedit { - frontmost::bring_to_front(pid); - std::thread::sleep(Duration::from_millis(250)); - if let Some((front_pid, bundle)) = frontmost::frontmost() - && front_pid == pid - { - front = Some((front_pid, bundle)); - break; - } - } - } - let (pid, bundle) = front.ok_or_else(|| { - format!( - "TextEdit no llegó al frente en 8 s; al frente está {:?}", - frontmost::frontmost().and_then(|(_, b)| b) - ) - })?; - let target = cp_core::destination::Destination { - pid, - bundle_id: bundle, - }; - - let marca = format!("CP-PASTE-{}", pb.change_count()); - pb.write_text(&marca); - std::thread::sleep(Duration::from_millis(120)); - - let started = Instant::now(); - match paster.paste_into(&target, || {}) { - cp_mac::paste::Outcome::Degraded(why) => return Err(format!("degradó: {why:?}")), - cp_mac::paste::Outcome::Sent { .. } => {} - } - std::thread::sleep(Duration::from_millis(400)); - - // Seleccionar todo y copiar: lo que vuelva es lo que se pegó. - let keys = cp_mac_sys::keystroke::Keystroke::new().ok_or("sin fuente")?; - keys.command(0x00); - std::thread::sleep(Duration::from_millis(200)); - keys.command(0x08); - std::thread::sleep(Duration::from_millis(400)); - - let back = pb - .data("public.utf8-plain-text") - .and_then(|bytes| String::from_utf8(bytes).ok()) - .unwrap_or_default(); - - if back.contains(&marca) { - println!(" (ida y vuelta en {:?})", started.elapsed()); - Ok(()) - } else { - Err(format!("volvió «{}», se esperaba «{marca}»", back.trim())) - } -} - -fn run_open(args: &[&str]) { - let mut command = std::process::Command::new("/usr/bin/open"); - command.args(args); - let _ = command.status(); -} +// El cuerpo vive aparte porque un `use` de objc2 no se resuelve fuera de Apple +// y `cargo test --workspace` alcanza los ejemplos. Cargo solo autodescubre +// `examples/*.rs` y `examples/*/main.rs`, así que este no se compila solo. +#[cfg(target_os = "macos")] +include!("probe/battery.rs"); + +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/crates/cp-mac/examples/probe/battery.rs b/crates/cp-mac/examples/probe/battery.rs new file mode 100644 index 00000000..c045a966 --- /dev/null +++ b/crates/cp-mac/examples/probe/battery.rs @@ -0,0 +1,929 @@ +use cp_core::destination::Tracker; +use cp_core::item::Payload; +use cp_core::kind::Kind; +use cp_core::watch::{Cadence, Seen, Watcher}; +use cp_mac::capture::capture; +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 std::time::{Duration, Instant}; + +struct Battery { + passed: u32, + failed: u32, + skipped: u32, +} + +impl Battery { + fn group(&self, name: &str) { + println!("\n {name}"); + } + + fn case(&mut self, id: &str, what: &str, run: impl FnOnce() -> Result<(), String>) { + match run() { + Ok(()) => { + self.passed += 1; + println!(" ok {id:<5} {what}"); + } + Err(why) => { + self.failed += 1; + println!(" FALLA {id:<5} {what}\n {why}"); + } + } + } + + fn skip(&mut self, id: &str, what: &str, why: &str) { + self.skipped += 1; + println!(" — {id:<5} {what} ({why})"); + } +} + +fn main() -> std::process::ExitCode { + let Some(mtm) = MainThreadMarker::new() else { + eprintln!("esto tiene que correr en el hilo principal"); + return std::process::ExitCode::FAILURE; + }; + let pb = Pasteboard::general(mtm); + let ready = Readiness::probe(); + let mut b = Battery { + passed: 0, + failed: 0, + skipped: 0, + }; + + b.group("A · Captura y formatos"); + + b.case("A1", "el texto plano va y vuelve", || { + pb.write_text("cp-a1"); + let item = capture(&pb).ok_or("no se capturó")?; + if item.kind != Some(Kind::Text) { + return Err(format!("se clasificó como {:?}", item.kind)); + } + match &item + .format("public.utf8-plain-text") + .ok_or("falta el texto")? + .payload + { + Payload::Inline(bytes) if bytes == b"cp-a1" => Ok(()), + other => Err(format!("llegó {other:?}")), + } + }); + + b.case("A2", "los gemelos legados no se guardan dos veces", || { + pb.write_text("cp-a2"); + let item = capture(&pb).ok_or("no se capturó")?; + let ids: Vec<&str> = item.formats.iter().map(|f| f.id.as_str()).collect(); + let mut unique = ids.clone(); + unique.sort_unstable(); + unique.dedup(); + if ids.len() != unique.len() { + return Err(format!("repetidos: {ids:?}")); + } + if ids.contains(&"NSStringPboardType") { + return Err("el gemelo legado no colapsó".into()); + } + Ok(()) + }); + + b.case("A3", "un texto vacío sigue siendo un ítem", || { + pb.write_text(""); + let item = capture(&pb).ok_or("no se capturó")?; + if item.formats.is_empty() { + return Err("sin formatos".into()); + } + Ok(()) + }); + + b.case("A4", "diez megabytes van y vuelven enteros", || { + let big = "a".repeat(10 * 1024 * 1024); + pb.write_text(&big); + let item = capture(&pb).ok_or("no se capturó")?; + match &item + .format("public.utf8-plain-text") + .ok_or("falta el texto")? + .payload + { + Payload::Blob(bytes) if bytes.len() == big.len() => Ok(()), + other => Err(format!("llegó {other:?}")), + } + }); + + b.case("A5", "un solo carácter multibyte", || { + pb.write_text("🎯"); + let item = capture(&pb).ok_or("no se capturó")?; + match &item + .format("public.utf8-plain-text") + .ok_or("falta el texto")? + .payload + { + Payload::Inline(bytes) if bytes == "🎯".as_bytes() => Ok(()), + other => Err(format!("llegó {other:?}")), + } + }); + + b.case("A6", "saltos de línea de los tres tipos", || { + let mixed = "uno\r\ndos\rtres\ncuatro"; + pb.write_text(mixed); + let item = capture(&pb).ok_or("no se capturó")?; + match &item + .format("public.utf8-plain-text") + .ok_or("falta el texto")? + .payload + { + Payload::Inline(bytes) if bytes == mixed.as_bytes() => Ok(()), + other => Err(format!("llegó {other:?}")), + } + }); + + b.case("A7", "tres archivos copiados son tres rutas", || { + pb.write_items(&[ + vec![("public.file-url", "file:///tmp/uno.txt")], + vec![("public.file-url", "file:///tmp/dos.txt")], + vec![("public.file-url", "file:///tmp/tres.txt")], + ]); + if pb.item_count() != 3 { + return Err(format!("el portapapeles tiene {} ítems", pb.item_count())); + } + let item = capture(&pb).ok_or("no se capturó")?; + let urls = item.format("public.file-url").ok_or("falta la ruta")?; + let text = match &urls.payload { + Payload::Inline(bytes) => String::from_utf8_lossy(bytes).to_string(), + other => return Err(format!("llegó {other:?}")), + }; + let lines: Vec<&str> = text.lines().collect(); + if lines.len() != 3 { + return Err(format!("se guardaron {} rutas: {lines:?}", lines.len())); + } + Ok(()) + }); + + b.case("A8", "un solo archivo sigue siendo una ruta", || { + pb.write_items(&[vec![("public.file-url", "file:///tmp/solo.txt")]]); + let item = capture(&pb).ok_or("no se capturó")?; + let urls = item.format("public.file-url").ok_or("falta la ruta")?; + match &urls.payload { + Payload::Inline(bytes) if !String::from_utf8_lossy(bytes).contains('\n') => Ok(()), + other => Err(format!("llegó {other:?}")), + } + }); + + b.group("I · Volver al portapapeles"); + + b.case("I1", "un ítem vuelve con todos sus formatos", || { + pb.write_types(&[ + ("public.utf8-plain-text", "texto plano"), + ("public.html", "texto plano"), + ]); + let captured = capture(&pb).ok_or("no se capturó")?; + let had = captured.formats.len(); + + // Se ensucia el portapapeles con otra cosa, como haría el usuario. + pb.write_text("algo distinto"); + + match cp_mac::restore::to_pasteboard(&pb, &captured) { + cp_mac::restore::Restored::Written { formats, .. } if formats >= 2 => {} + other => return Err(format!("restauró {other:?} de {had} formatos")), + } + + let back = capture(&pb).ok_or("no se capturó lo restaurado")?; + let text = back + .format("public.utf8-plain-text") + .ok_or("falta el texto")?; + if text.payload != Payload::Inline(b"texto plano".to_vec()) { + return Err(format!("el texto volvió como {:?}", text.payload)); + } + if back.format("public.html").is_none() { + return Err("el HTML no volvió: pegarlo perdería los estilos".into()); + } + Ok(()) + }); + + b.case("I2", "una imagen vuelve entera", || { + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("falta el fixture: {why}"))?; + let item = cp_core::item::Item { + kind: Some(Kind::Image), + formats: vec![cp_core::item::Format { + id: "public.png".into(), + payload: cp_core::item::Payload::Blob(png.clone()), + }], + }; + pb.write_text("otra cosa"); + match cp_mac::restore::to_pasteboard(&pb, &item) { + cp_mac::restore::Restored::Written { .. } => {} + other => return Err(format!("no se restauró: {other:?}")), + } + let back = pb.data("public.png").ok_or("no volvió el png")?; + if back.len() != png.len() { + return Err(format!("volvieron {} bytes de {}", back.len(), png.len())); + } + Ok(()) + }); + + b.case( + "I3", + "un ítem sin bytes lo dice en vez de vaciar el portapapeles", + || { + let hollow = cp_core::item::Item { + kind: None, + formats: vec![cp_core::item::Format { + id: "com.apple.icns".into(), + payload: cp_core::item::Payload::Announced { size: Some(10) }, + }], + }; + pb.write_text("lo que había antes"); + match cp_mac::restore::to_pasteboard(&pb, &hollow) { + cp_mac::restore::Restored::NothingToWrite => {} + other => return Err(format!("devolvió {other:?}")), + } + let kept = pb + .data("public.utf8-plain-text") + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + if kept != "lo que había antes" { + return Err("se perdió lo que el usuario tenía copiado".into()); + } + Ok(()) + }, + ); + + b.case( + "I4", + "restaurar avisa si el ítem estaba incompleto", + || { + let partial = cp_core::item::Item { + kind: Some(Kind::Text), + formats: vec![ + cp_core::item::Format { + id: "public.utf8-plain-text".into(), + payload: cp_core::item::Payload::Inline(b"algo".to_vec()), + }, + cp_core::item::Format { + id: "public.tiff".into(), + payload: cp_core::item::Payload::Announced { size: Some(999) }, + }, + ], + }; + match cp_mac::restore::to_pasteboard(&pb, &partial) { + cp_mac::restore::Restored::Written { + formats: 1, + incomplete: true, + } => Ok(()), + other => Err(format!("devolvió {other:?}")), + } + }, + ); + + b.group("J · Archivos que ya no están"); + + b.case("J1", "una ruta que existe no se marca como rota", || { + let path = std::env::temp_dir().join("cp-probe-existe.txt"); + std::fs::write(&path, b"aqui estoy").map_err(|why| why.to_string())?; + let url = format!("file://{}", path.display()); + let missing = frontmost::missing_paths(&url); + std::fs::remove_file(&path).ok(); + if !missing.is_empty() { + return Err(format!("dijo que faltaba: {missing:?}")); + } + Ok(()) + }); + + b.case("J2", "una ruta borrada se detecta", || { + let path = std::env::temp_dir().join("cp-probe-borrado.txt"); + std::fs::write(&path, "efimero".as_bytes()).map_err(|why| why.to_string())?; + let url = format!("file://{}", path.display()); + std::fs::remove_file(&path).map_err(|why| why.to_string())?; + let missing = frontmost::missing_paths(&url); + if missing.len() != 1 { + return Err("no se enteró de que el archivo ya no está".into()); + } + Ok(()) + }); + + b.case("J3", "de tres archivos se dice cuál falta", || { + let dir = std::env::temp_dir(); + let uno = dir.join("cp-probe-uno.txt"); + let dos = dir.join("cp-probe-dos.txt"); + std::fs::write(&uno, b"a").map_err(|why| why.to_string())?; + std::fs::write(&dos, b"b").map_err(|why| why.to_string())?; + let urls = format!( + "file://{}\nfile://{}\nfile://{}", + uno.display(), + dos.display(), + dir.join("cp-probe-fantasma.txt").display() + ); + let missing = frontmost::missing_paths(&urls); + std::fs::remove_file(&uno).ok(); + std::fs::remove_file(&dos).ok(); + if missing.len() != 1 || !missing[0].contains("fantasma") { + return Err(format!("dijo que faltaban: {missing:?}")); + } + Ok(()) + }); + + b.case("J4", "una ruta con espacios y acentos se entiende", || { + let path = std::env::temp_dir().join("cp probe ñandú.txt"); + std::fs::write(&path, b"con acentos").map_err(|why| why.to_string())?; + let encoded = format!( + "file://{}", + path.display() + .to_string() + .replace(' ', "%20") + .replace('ñ', "%C3%B1") + .replace('ú', "%C3%BA") + ); + let missing = frontmost::missing_paths(&encoded); + std::fs::remove_file(&path).ok(); + if !missing.is_empty() { + return Err("una ruta escapada se tomó por inexistente".into()); + } + Ok(()) + }); + + b.group("K · Origen"); + + b.case( + "K1", + "la aplicación de origen se guarda con su nombre visible", + || { + let (pid, bundle) = frontmost::frontmost().ok_or("nadie al frente")?; + let name = frontmost::app_name(pid).ok_or("sin nombre visible")?; + if name.is_empty() { + return Err("el nombre llegó vacío".into()); + } + if Some(name.as_str()) == bundle.as_deref() { + return Err(format!("«{name}» es el identificador, 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.search(&name).map_err(|why| why.to_string())?; + if found.len() != 1 { + return Err(format!("buscando «{name}» salieron {} ítems", found.len())); + } + Ok(()) + }, + ); + + b.group("L · Miniaturas y medios"); + + b.case( + "L1", + "una captura da sus dimensiones sin decodificarse", + || { + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("falta el fixture: {why}"))?; + let size = cp_core::thumbnail::size_of(&png).ok_or("no se leyó el tamaño")?; + // El fixture se dibujó a 720×160 puntos en una pantalla Retina, + // así que el archivo tiene el doble de píxeles. Lo que se guarda + // y lo que se enseña son cosas distintas; esto es lo que se guarda. + if size.width != 1440 || size.height != 320 { + return Err(format!("dijo {}×{}", size.width, size.height)); + } + Ok(()) + }, + ); + + b.case( + "L2", + "la miniatura pesa mucho menos y guarda la proporción", + || { + 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 size = cp_core::thumbnail::size_of(&thumb).ok_or("sin tamaño")?; + if size.width != cp_core::thumbnail::MAX_SIDE { + return Err(format!("el lado mayor quedó en {}", size.width)); + } + if thumb.len() >= png.len() { + return Err(format!("pesa {} frente a {}", thumb.len(), png.len())); + } + Ok(()) + }, + ); + + b.case("L3", "la miniatura va al almacén y vuelve", || { + let dir = std::env::temp_dir().join(format!("cp-probe-thumbs-{}", pb.change_count())); + 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.case( + "L4", + "un archivo que no es medio no inventa metadatos", + || { + let path = std::env::temp_dir().join("cp-probe-no-media.txt"); + std::fs::write(&path, b"solo texto").map_err(|why| why.to_string())?; + let info = cp_mac_sys::media::info_for(&path); + std::fs::remove_file(&path).ok(); + match info { + None => Ok(()), + Some(found) if found.duration.is_none() => Ok(()), + Some(found) => Err(format!("se inventó {found:?}")), + } + }, + ); + + b.case("L5", "una ruta inexistente no es un medio", || { + let ghost = std::env::temp_dir().join("cp-probe-no-existe.mp4"); + if cp_mac_sys::media::info_for(&ghost).is_some() { + return Err("devolvió datos de algo que no está".into()); + } + Ok(()) + }); + + b.case("I5", "pegar en plano no mutila el ítem guardado", || { + pb.write_types(&[ + ("public.utf8-plain-text", "con estilos"), + ("public.html", "con estilos"), + ("public.rtf", "{\\rtf1 con estilos}"), + ]); + let item = capture(&pb).ok_or("no se capturó")?; + let had = item.formats.len(); + + // Se pega en plano: el portapapeles queda solo con el texto. + match cp_mac::restore::to_pasteboard_as_plain_text(&pb, &item) { + cp_mac::restore::Restored::Written { formats: 1, .. } => {} + other => return Err(format!("devolvió {other:?}")), + } + if pb.data("public.html").is_some() { + return Err("quedó el HTML: no se pegó en plano".into()); + } + + // Y el ítem guardado sigue teniendo todo, así que la próxima vez se + // puede pegar con estilos. + if item.formats.len() != had { + return Err("el ítem perdió formatos".into()); + } + match cp_mac::restore::to_pasteboard(&pb, &item) { + cp_mac::restore::Restored::Written { .. } => {} + other => return Err(format!("no se pudo restaurar con estilos: {other:?}")), + } + if pb.data("public.html").is_none() { + return Err("el HTML no volvió: el ítem había quedado mutilado".into()); + } + Ok(()) + }); + + b.case( + "I6", + "un ítem sin texto plano no se puede pegar en plano", + || { + let only_image = cp_core::item::Item { + kind: Some(Kind::Image), + formats: vec![cp_core::item::Format { + id: "public.png".into(), + payload: cp_core::item::Payload::Inline(vec![1, 2, 3]), + }], + }; + pb.write_text("lo que había"); + match cp_mac::restore::to_pasteboard_as_plain_text(&pb, &only_image) { + cp_mac::restore::Restored::NothingToWrite => Ok(()), + other => Err(format!("devolvió {other:?}")), + } + }, + ); + + b.group("G · Clasificación"); + + for (id, text, expected) in [ + ("G1", "alguien@ejemplo.test", Kind::Email), + ("G2", "https://ejemplo.test/ruta", Kind::Link), + ("G3", "#FF8800", Kind::Color), + ("G4", "192.168.1.1", Kind::Ip), + ("G5", "7ab3f6de-1c4b-4f5e-8a2d-9f0e1b2c3d4e", Kind::Uuid), + ("G6", "+34 600 123 456", Kind::Phone), + ("G7", "{\"clave\": [1, 2]}", Kind::Json), + ("G8", "fn main() {\n println!(\"hola\");\n}", Kind::Code), + ("G9", "una frase corriente y nada más", Kind::Text), + ] { + b.case( + id, + &format!("se clasifica como {}", expected.as_str()), + || { + pb.write_text(text); + let item = capture(&pb).ok_or("no se capturó")?; + if item.kind != Some(expected) { + return Err(format!("salió {:?}", item.kind)); + } + Ok(()) + }, + ); + } + + b.group("H · Búsqueda inteligente"); + + b.case("H1", "el texto dentro de una imagen se reconoce", || { + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("falta el fixture: {why}"))?; + let text = cp_mac_sys::ocr::searchable_text(&png).ok_or("Vision no pudo con la imagen")?; + if !text.contains("AB-4417") { + return Err(format!("leyó «{text}»")); + } + Ok(()) + }); + + b.case( + "H2", + "una captura copiada se encuentra por lo que pone dentro", + || { + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("falta el fixture: {why}"))?; + pb.write_data("public.png", &png); + + let item = capture(&pb).ok_or("no se capturó")?; + if item.kind != Some(Kind::Image) { + return Err(format!("se clasificó como {:?}", item.kind)); + } + let bytes = match &item.format("public.png").ok_or("falta el png")?.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => bytes.clone(), + other => return Err(format!("llegó {other:?}")), + }; + + let store = cp_store::Store::in_memory().map_err(|why| why.to_string())?; + // El ítem entero no cabe en la fila, así que se guarda la referencia y + // el texto reconocido, que es lo que hace buscable la captura. + let light = cp_core::item::Item { + kind: item.kind, + formats: vec![cp_core::item::Format { + id: "public.png".into(), + payload: cp_core::item::Payload::Announced { + size: Some(bytes.len()), + }, + }], + }; + let id = store + .insert_item("uuid-captura", &light, "", 1) + .map_err(|why| why.to_string())?; + let recognised = cp_mac_sys::ocr::searchable_text(&bytes) + .ok_or("Vision no pudo con lo capturado")?; + store + .set_ocr_text(id, &recognised, 2) + .map_err(|why| why.to_string())?; + + let hits = store.search("pedido").map_err(|why| why.to_string())?; + if hits.len() != 1 { + return Err(format!("buscando «pedido» salieron {} ítems", hits.len())); + } + Ok(()) + }, + ); + + b.group("B · Privacidad"); + + for (id, marker) in [ + ("B1", "org.nspasteboard.ConcealedType"), + ("B2", "org.nspasteboard.TransientType"), + ("B3", "com.agilebits.onepassword"), + ("B4", "net.antelle.keeweb"), + ("B5", "PasswordPboardType"), + ] { + b.case(id, &format!("«{marker}» excluye el ítem"), || { + pb.write_types(&[("public.utf8-plain-text", "secreto"), (marker, "1")]); + if capture(&pb).is_some() { + return Err("se capturó contenido marcado".into()); + } + Ok(()) + }); + } + + b.case("B6", "sin marcador se vuelve a capturar", || { + pb.write_text("esto sí"); + capture(&pb).ok_or("un texto normal debe capturarse")?; + Ok(()) + }); + + b.group("C · Vigilante"); + + b.case("C1", "el contador sube de uno en uno", || { + let before = pb.change_count(); + pb.write_text("cp-c1"); + let after = pb.change_count(); + if after - before != 1 { + return Err(format!("saltó de {before} a {after}")); + } + Ok(()) + }); + + b.case("C2", "cien copias, ninguna perdida en silencio", || { + let mut watcher = Watcher::new(Cadence::OnePerCopy); + watcher.tick(pb.change_count()); + let mut seen = 0u64; + for round in 0..100 { + pb.write_text(&format!("cp-c2-{round}")); + if let Seen::Fresh { .. } = watcher.tick(pb.change_count()) { + seen += 1; + } + } + let missed = watcher.missed().ok_or("la cadencia de macOS cuenta")?; + if seen + missed != 100 { + return Err(format!("{seen} vistas y {missed} contadas")); + } + Ok(()) + }); + + b.case("C3", "sondeo desde otro hilo sin perder nada", || { + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (tell, hear) = std::sync::mpsc::channel(); + let watching = { + let stop = stop.clone(); + std::thread::spawn(move || { + let mut watcher = Watcher::new(Cadence::OnePerCopy); + let mut fresh = 0u64; + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + if let Seen::Fresh { .. } = + watcher.tick(pasteboard::change_count_from_any_thread()) + { + fresh += 1; + } + std::thread::sleep(Duration::from_millis(2)); + } + let _ = tell.send((fresh, watcher.missed().unwrap_or(0))); + }) + }; + std::thread::sleep(Duration::from_millis(40)); + for round in 0..25 { + pb.write_text(&format!("cp-c3-{round}")); + std::thread::sleep(Duration::from_millis(12)); + } + std::thread::sleep(Duration::from_millis(80)); + stop.store(true, std::sync::atomic::Ordering::Relaxed); + watching.join().map_err(|_| "el hilo murió")?; + let (fresh, missed) = hear.recv().map_err(|why| why.to_string())?; + if fresh + missed != 25 { + return Err(format!("{fresh} vistas y {missed} contadas de 25")); + } + Ok(()) + }); + + b.case( + "C4", + "nuestra escritura no se confunde con una copia", + || { + let mut watcher = Watcher::new(Cadence::OnePerCopy); + watcher.tick(pb.change_count()); + pb.write_text("cp-c4-nuestro"); + let ours = pb.change_count(); + watcher.wrote(ours); + match watcher.tick(ours) { + Seen::Ours => Ok(()), + other => Err(format!("se vio como {other:?}")), + } + }, + ); + + b.group("D · Teclado"); + + b.case("D1", "el layout activo resuelve la «v»", || { + keyboard::keycode_with_command('v') + .map(|_| ()) + .ok_or_else(|| "no se pudo resolver".to_string()) + }); + + b.case( + "D2", + "Dvorak necesita un keycode distinto", + || match keyboard::keycode_with_command_in(keyboard::DVORAK, 'v') { + Some(code) if code != QWERTY_V => Ok(()), + Some(code) => Err(format!("dio 0x{code:02X}, igual que QWERTY")), + None => Err("Dvorak no está instalado".into()), + }, + ); + + b.case("D3", "los demás layouts coinciden con QWERTY", || { + for (name, id) in [ + ("ABC", keyboard::ABC), + ("AZERTY", keyboard::AZERTY), + ("QWERTZ", keyboard::QWERTZ), + ("Español ISO", keyboard::SPANISH_ISO), + ("Colemak", keyboard::COLEMAK), + ("Dvorak-QWERTY ⌘", keyboard::DVORAK_COMMAND_QWERTY), + ] { + if let Some(code) = keyboard::keycode_with_command_in(id, 'v') + && code != QWERTY_V + { + return Err(format!("{name} dio 0x{code:02X}")); + } + } + Ok(()) + }); + + b.case( + "D4", + "una letra que ningún layout produce no inventa nada", + || match keyboard::keycode_with_command_in(keyboard::ABC, '\u{1F600}') { + None => Ok(()), + Some(code) => Err(format!("devolvió 0x{code:02X} para un emoji")), + }, + ); + + b.case( + "D5", + "el keycode se resuelve al pegar, no al arrancar", + || { + let paster = Paster::new().ok_or("sin fuente de eventos")?; + let now = keyboard::keycode_with_command('v').unwrap_or(QWERTY_V); + if paster.keycode() != now { + return Err(format!( + "el pegador dice 0x{:02X} y el sistema 0x{now:02X}", + paster.keycode() + )); + } + Ok(()) + }, + ); + + b.group("E · Permisos"); + + b.case("E1", "pegar depende solo de poder postear eventos", || { + if ready.can_paste() != ready.can_post { + return Err("la regla se torció".into()); + } + Ok(()) + }); + + b.case("E2", "el input seguro no bloquea el pegado", || { + if ready.secure_input && !ready.can_paste() && ready.can_post { + return Err("se está tratando el input seguro como bloqueo".into()); + } + Ok(()) + }); + + b.group("F · Destino y pegado"); + + b.case( + "F1", + "el destino sobrevive a que el panel tome el frente", + || { + let mut tracker = Tracker::new(frontmost::our_pid()); + let (pid, bundle) = frontmost::frontmost().ok_or("nadie al frente")?; + tracker.saw(pid, bundle.as_deref()); + let target = tracker.destination().ok_or("sin destino")?.clone(); + tracker.saw(frontmost::our_pid(), Some("dev.rgdevment.copypaste")); + if tracker.destination() != Some(&target) { + return Err("el destino cambió".into()); + } + Ok(()) + }, + ); + + b.case( + "F2", + "los modificadores físicos se leen de la fuente", + || { + let _ = keystroke::physical_modifiers(); + if keystroke::modifiers_still_held() { + return Err("hay modificadores pulsados; suelta las teclas".into()); + } + Ok(()) + }, + ); + + match Paster::new() { + Some(paster) => { + b.case("F3", "el pegador entrega un keycode utilizable", || { + if paster.keycode() == 0 { + return Err("keycode inválido".into()); + } + Ok(()) + }); + if ready.can_post { + match paste_round_trip(&pb, &paster) { + Ok(()) => { + b.passed += 1; + println!(" ok F4 pegado real en TextEdit, ida y vuelta"); + } + // Que otra aplicación retenga el primer plano no es un + // fallo del núcleo: es la activación cooperativa que este + // proyecto ya midió. Se omite en vez de dar un rojo falso. + Err(why) if why.starts_with("TextEdit no llegó") => { + b.skip("F4", "pegado real en TextEdit", &why); + } + Err(why) => { + b.failed += 1; + println!(" FALLA F4 pegado real en TextEdit"); + println!(" {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"), + } + + println!(); + println!( + " {} pasan · {} fallan · {} omitidas", + b.passed, b.failed, b.skipped + ); + if b.failed == 0 { + std::process::ExitCode::SUCCESS + } else { + std::process::ExitCode::FAILURE + } +} + +/// Pega en TextEdit y comprueba el resultado sin accesibilidad: tras pegar, +/// selecciona todo y copia, así que lo pegado vuelve por el mismo camino. +fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { + let path = "/tmp/cp-probe-target.txt"; + std::fs::write(path, "").map_err(|why| why.to_string())?; + // Arrancar en frío tarda, y otra aplicación puede tener el foco. Se + // insiste con techo en vez de dormir una cantidad fija y confiar. + run_open(&["-a", "TextEdit", path]); + // Se usa la activación del propio núcleo, que es lo que hará el producto, + // en vez de confiar en que `open` gane el primer plano. + let mut front = None; + for _ in 0..25 { + std::thread::sleep(Duration::from_millis(300)); + let textedit = std::process::Command::new("/usr/bin/pgrep") + .args(["-x", "TextEdit"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .and_then(|pids| pids.split_whitespace().next()?.parse::().ok()); + if let Some(pid) = textedit { + frontmost::bring_to_front(pid); + std::thread::sleep(Duration::from_millis(250)); + if let Some((front_pid, bundle)) = frontmost::frontmost() + && front_pid == pid + { + front = Some((front_pid, bundle)); + break; + } + } + } + let (pid, bundle) = front.ok_or_else(|| { + format!( + "TextEdit no llegó al frente en 8 s; al frente está {:?}", + frontmost::frontmost().and_then(|(_, b)| b) + ) + })?; + let target = cp_core::destination::Destination { + pid, + bundle_id: bundle, + }; + + let marca = format!("CP-PASTE-{}", pb.change_count()); + pb.write_text(&marca); + std::thread::sleep(Duration::from_millis(120)); + + let started = Instant::now(); + match paster.paste_into(&target, || {}) { + cp_mac::paste::Outcome::Degraded(why) => return Err(format!("degradó: {why:?}")), + cp_mac::paste::Outcome::Sent { .. } => {} + } + std::thread::sleep(Duration::from_millis(400)); + + // Seleccionar todo y copiar: lo que vuelva es lo que se pegó. + let keys = cp_mac_sys::keystroke::Keystroke::new().ok_or("sin fuente")?; + keys.command(0x00); + std::thread::sleep(Duration::from_millis(200)); + keys.command(0x08); + std::thread::sleep(Duration::from_millis(400)); + + let back = pb + .data("public.utf8-plain-text") + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + + if back.contains(&marca) { + println!(" (ida y vuelta en {:?})", started.elapsed()); + Ok(()) + } else { + Err(format!("volvió «{}», se esperaba «{marca}»", back.trim())) + } +} + +fn run_open(args: &[&str]) { + let mut command = std::process::Command::new("/usr/bin/open"); + command.args(args); + let _ = command.status(); +} diff --git a/crates/cp-mac/src/capture.rs b/crates/cp-mac/src/capture.rs index bd3b2965..f25cf8d8 100644 --- a/crates/cp-mac/src/capture.rs +++ b/crates/cp-mac/src/capture.rs @@ -11,12 +11,11 @@ use cp_mac_sys::pasteboard::Pasteboard; pub fn capture(pb: &Pasteboard) -> Option { let offered = pb.types(); let ids: Vec<&str> = offered.iter().map(String::as_str).collect(); - if CATALOG.is_concealed(&ids) { + if CATALOG.refusal(&ids).is_some() { return None; } let family = CATALOG.classify(&ids); - let cheapest_image = CATALOG.preferred_image(&ids); let mut formats: Vec = Vec::new(); for id in &ids { @@ -30,7 +29,7 @@ pub fn capture(pb: &Pasteboard) -> Option { Take::Never => Payload::Announced { size: None }, Take::Presence => Payload::Announced { size: None }, Take::Payload => { - if is_costlier_twin(canonical, cheapest_image) { + if CATALOG.costlier_twin(canonical, &ids) { // Misma imagen, representación cara: se anota y no se // pide. Medido: 52 veces más grande en el mismo copiado. Payload::Announced { size: None } @@ -75,13 +74,6 @@ fn gather_file_urls(pb: &Pasteboard) -> Option> { (!joined.is_empty()).then(|| joined.join("\n").into_bytes()) } -fn is_costlier_twin(id: &str, cheapest: Option<&str>) -> bool { - let Some(cheapest) = cheapest else { - return false; - }; - CATALOG.images_by_preference.contains(&id) && id != cheapest -} - fn refine(family: Option, formats: &[Format]) -> Option { match family? { Family::Image => Some(Kind::Image), diff --git a/crates/cp-mac/src/formats.rs b/crates/cp-mac/src/formats.rs index 7a599640..f0d58d5e 100644 --- a/crates/cp-mac/src/formats.rs +++ b/crates/cp-mac/src/formats.rs @@ -41,6 +41,9 @@ pub const CATALOG: Catalog = Catalog { "net.antelle.keeweb", "PasswordPboardType", ], + // macOS no tiene marcadores que se lean: la convención de + // `org.nspasteboard` es por presencia y nada más. + denied_when_zero: &[], opaque_prefixes: &["dyn.", "CorePasteboardFlavorType"], text: &[ "public.utf8-plain-text", @@ -51,6 +54,13 @@ pub const CATALOG: Catalog = Catalog { ], files: &["public.file-url"], images_by_preference: &["public.png", "public.tiff"], + // El RTFD y el RTF no son el mismo contenido en dos envoltorios: el + // primero lleva las imágenes incrustadas que el segundo no tiene —993.342 + // frente a 395 bytes en el mismo documento—, así que se guardan los dos. + equivalents: &[], + // Medido el 12/09/2026: ninguna de las tres fuentes adjunta una imagen de + // cortesía a un documento de texto, así que aquí no hay nada que desempatar. + embeddable: &[], }; #[cfg(test)] @@ -159,7 +169,9 @@ mod tests { "PasswordPboardType", ] { assert!( - CATALOG.is_concealed(&["public.utf8-plain-text", marker]), + CATALOG + .refusal(&["public.utf8-plain-text", marker]) + .is_some(), "{marker} debe excluir el ítem" ); } diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index f201b57d..6e9fb51c 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -4,7 +4,7 @@ pub mod store; pub use blobs::Blobs; pub use schema::SCHEMA_VERSION; -pub use store::Store; +pub use store::{Restricted, Store}; /// Lo que puede salir mal en el almacén. #[derive(Debug, thiserror::Error)] diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 90981afe..d2e08b16 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -39,6 +39,7 @@ pub struct Filter { pub struct Store { db: Connection, blobs: Option, + exposure: Restricted, } impl Store { @@ -50,7 +51,11 @@ impl Store { pub fn in_memory() -> Result { let db = Connection::open_in_memory()?; crate::schema::create(&db)?; - Ok(Self { db, blobs: None }) + Ok(Self { + db, + blobs: None, + exposure: Restricted::Mode(0o600), + }) } /// Abre —o crea— la base en disco. @@ -67,7 +72,7 @@ impl Store { let db = Connection::open(path)?; crate::schema::create(&db)?; crate::schema::migrate(&db)?; - restrict(path, 0o600)?; + let exposure = restrict(path, 0o600)?; // Las imágenes que CopyPaste captura viven junto a la base, en su // propia carpeta. Lo que el usuario copió del disco se queda donde // estaba: solo se guarda la ruta. @@ -75,7 +80,17 @@ impl Store { .parent() .map(|parent| crate::Blobs::at(&parent.join("blobs"))) .transpose()?; - Ok(Self { db, blobs }) + Ok(Self { + db, + blobs, + exposure, + }) + } + + /// Hasta dónde se pudo proteger el archivo, para que el panel lo diga en + /// vez de que nadie se entere. + pub fn exposure(&self) -> Restricted { + self.exposure } /// Vuelca el WAL al archivo principal. @@ -655,11 +670,57 @@ impl Store { } } +/// Hasta dónde llegó la protección de una ruta, que no es la misma en cada +/// sistema. Se devuelve en vez de suponerse: un historial de portapapeles +/// legible por el resto de la máquina es un fallo que tiene que poder decirse. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Restricted { + /// Los bits de modo quedaron fijados. Nadie salvo el dueño lo abre. + Mode(u32), + /// Windows no tiene bits de modo. La ruta cae bajo el perfil del usuario, + /// cuya lista de control heredada ya excluye a las demás cuentas; apretarla + /// más exige la capa de plataforma, que es la que tiene el `unsafe`. + InheritedFromProfile, + /// La ruta quedó fuera del perfil —una unidad compartida, una carpeta + /// elegida a mano—, donde nada garantiza quién puede leerla. + Unprotected, +} + /// Ajusta los permisos de un archivo o carpeta a lo que se le pide. -pub(crate) fn restrict(path: &std::path::Path, mode: u32) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - let permissions = std::fs::Permissions::from_mode(mode); - std::fs::set_permissions(path, permissions).map_err(Error::Io) +pub(crate) fn restrict(path: &std::path::Path, mode: u32) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(mode); + std::fs::set_permissions(path, permissions).map_err(Error::Io)?; + Ok(Restricted::Mode(mode)) + } + #[cfg(not(unix))] + { + let _ = mode; + let profile = std::env::var_os("USERPROFILE").map(std::path::PathBuf::from); + Ok(match profile { + Some(profile) if under(path, &profile) => Restricted::InheritedFromProfile, + _ => Restricted::Unprotected, + }) + } +} + +/// Si `path` cuelga de `root`, con las dos rutas resueltas antes de comparar: +/// en Windows la misma carpeta se nombra de más de una forma —el nombre corto +/// 8.3 y el largo— y comparar el texto tal cual daría por desprotegido lo que +/// sí lo está. +#[cfg_attr(unix, allow(dead_code))] +fn under(path: &std::path::Path, root: &std::path::Path) -> bool { + let resolved = |one: &std::path::Path| { + one.canonicalize() + .or_else(|_| std::path::absolute(one)) + .ok() + }; + match (resolved(path), resolved(root)) { + (Some(path), Some(root)) => path.starts_with(&root), + _ => false, + } } #[cfg(test)] @@ -1427,6 +1488,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn the_history_is_not_readable_by_other_users() { use std::os::unix::fs::PermissionsExt; @@ -1436,6 +1498,7 @@ mod tests { store .insert_text("uuid-privado", "contraseña", 1) .expect("insert"); + assert_eq!(store.exposure(), Restricted::Mode(0o600)); drop(store); let file = std::fs::metadata(&path) @@ -1452,6 +1515,66 @@ mod tests { assert_eq!(folder, 0o700, "y la carpeta igual"); } + /// En Windows no hay bits de modo que comprobar, así que lo que protege el + /// historial es dónde vive. La prueba no puede afirmar que otras cuentas no + /// lo abren —eso lo decide la lista de control heredada— pero sí que el + /// almacén sabe en cuál de los dos casos está y lo dice. + #[cfg(windows)] + #[test] + fn on_windows_what_protects_the_history_is_living_under_the_profile() { + let Some(profile) = std::env::var_os("USERPROFILE") else { + return; + }; + let dir = tempfile::Builder::new() + .prefix("copypaste-") + .tempdir_in(profile) + .expect("carpeta"); + let path = dir.path().join("datos").join("history.db"); + let store = Store::open(&path).expect("abre"); + store + .insert_text("uuid-privado", "contraseña", 1) + .expect("insert"); + assert_eq!(store.exposure(), Restricted::InheritedFromProfile); + } + + #[test] + fn a_path_outside_the_profile_is_not_under_it() { + let profile = std::path::Path::new(if cfg!(windows) { + r"C:\Users\alguien" + } else { + "/home/alguien" + }); + let inside = profile.join("AppData").join("history.db"); + let outside = std::path::Path::new(if cfg!(windows) { + r"Z:\compartido\history.db" + } else { + "/srv/compartido/history.db" + }); + assert!(under(&inside, profile), "lo que cuelga del perfil"); + assert!(!under(outside, profile), "una unidad compartida no"); + assert!( + !under(profile, &inside), + "estar por encima no es estar dentro" + ); + } + + /// Un prefijo de texto no es un prefijo de ruta: sin comparar componentes, + /// la carpeta de otra cuenta con el mismo comienzo pasaría por propia. + #[test] + fn a_sibling_that_merely_starts_alike_is_outside() { + let profile = std::path::Path::new(if cfg!(windows) { + r"C:\Users\ana" + } else { + "/home/ana" + }); + let sibling = std::path::Path::new(if cfg!(windows) { + r"C:\Users\anabel\history.db" + } else { + "/home/anabel/history.db" + }); + assert!(!under(sibling, profile)); + } + #[test] fn a_checkpoint_leaves_the_data_in_the_main_file() { let dir = tempfile::tempdir().expect("carpeta"); diff --git a/crates/cp-win/Cargo.toml b/crates/cp-win/Cargo.toml new file mode 100644 index 00000000..96c8bfe5 --- /dev/null +++ b/crates/cp-win/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cp-win" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[target.'cfg(target_os = "windows")'.dependencies] +cp-core.workspace = true + +[lints] +workspace = true diff --git a/crates/cp-win/src/formats.rs b/crates/cp-win/src/formats.rs new file mode 100644 index 00000000..e0da4372 --- /dev/null +++ b/crates/cp-win/src/formats.rs @@ -0,0 +1,417 @@ +use cp_core::formats::Catalog; + +/// Los tipos de Windows. Las reglas que se les aplican viven en `cp-core`; +/// esto son solo los datos, y su gemelo de macOS tiene la misma forma. +/// +/// Los `CF_*` son enteros; aquí se nombran por su constante, y la capa de +/// sistema traduce. Los demás llegan con su nombre registrado tal cual. +pub const CATALOG: Catalog = Catalog { + // Ninguno confirmado todavía. En Windows el renderizado diferido no es + // propiedad de un tipo sino de quien copió —`rdpclip` difiere todo—, así + // que la defensa no es una lista: es el reloj de quien pide los bytes. + hangs: &[], + wasteful: &[ + "Embed Source", + "Native", + "OwnerLink", + "ObjectLink", + "Link Source", + "Link Source Descriptor", + "Link", + "Object Descriptor", + "Ole Private Data", + "DataObject", + "DataObjectAttributes", + "DataObjectAttributesRequiringElevation", + "Shell Object Offsets", + "AsyncFlag", + "ZoneIdentifier", + "CF_ENHMETAFILE", + "CF_METAFILEPICT", + "CF_SYLK", + "CF_DIF", + "Biff12", + "Biff8", + "Biff5", + "XML Spreadsheet", + // Un `HBITMAP` de GDI, no un bloque de memoria: medido, `GlobalSize` + // no devuelve nada sobre él. + "CF_BITMAP", + // El descriptor se anota y el contenido no se puede pedir por el API + // plano del portapapeles, que es el hallazgo de los archivos virtuales. + "FileGroupDescriptorW", + "FileContents", + "Shell IDList Array", + "Chromium internal source RFH token", + ], + wanted: &[ + "CF_UNICODETEXT", + "Rich Text Format", + "HTML Format", + "PNG", + "CF_DIBV5", + "CF_DIB", + "CF_HDROP", + "Csv", + "Preferred DropEffect", + "Chromium internal source URL", + "UniformResourceLocatorW", + "text/uri-list", + ], + // `FileName` y `FileNameW` **no** son alias de `CF_HDROP`: llevan una sola + // ruta cuando se copiaron varias, así que tomarlos por equivalentes perdería + // archivos. Van en `wasteful`, que los anota sin pedirlos. + aliases: &[], + concealed: &["ExcludeClipboardContentFromMonitorProcessing"], + denied_when_zero: &["CanIncludeInClipboardHistory"], + opaque_prefixes: &[], + text: &["CF_UNICODETEXT", "Rich Text Format", "HTML Format", "Csv"], + files: &["CF_HDROP"], + images_by_preference: &["PNG", "CF_DIBV5", "CF_DIB"], + equivalents: &[ + // El Unicode primero: los otros dos son el mismo texto pasado a la + // página de códigos del sistema, con la pérdida que eso trae. + &["CF_UNICODETEXT", "CF_TEXT", "CF_OEMTEXT"], + // El de Windows lleva la cabecera de offsets y pesa la mitad. + &["HTML Format", "text/html"], + ], + embeddable: &[ + "Embed Source", + "Native", + "Object Descriptor", + "Link Source Descriptor", + "Biff12", + "Biff8", + "Biff5", + "XML Spreadsheet", + "Csv", + ], +}; + +#[cfg(test)] +mod tests { + use super::CATALOG; + use cp_core::formats::{Family, Refusal, Take}; + + /// Las siete fuentes se midieron el 14/09/2026 sobre Windows 11 26200, + /// enumerando todos los tipos que cada aplicación ofrecía de verdad. + const WORD: &[&str] = &[ + "DataObject", + "Object Descriptor", + "Rich Text Format", + "HTML Format", + "CF_TEXT", + "CF_UNICODETEXT", + "CF_ENHMETAFILE", + "CF_METAFILEPICT", + "Embed Source", + "Native", + "OwnerLink", + "Link Source", + "Link Source Descriptor", + "ObjectLink", + "Ole Private Data", + "CF_LOCALE", + "CF_OEMTEXT", + ]; + + const EXCEL: &[&str] = &[ + "DataObject", + "CF_ENHMETAFILE", + "CF_METAFILEPICT", + "CF_BITMAP", + "Biff12", + "Biff8", + "Biff5", + "CF_SYLK", + "CF_DIF", + "XML Spreadsheet", + "HTML Format", + "CF_UNICODETEXT", + "CF_TEXT", + "Csv", + "Rich Text Format", + "Embed Source", + "Native", + "OwnerLink", + "Object Descriptor", + "Link Source", + "Link Source Descriptor", + "Link", + "ExcludeClipboardContentFromMonitorProcessing", + "ObjectLink", + "Ole Private Data", + "CF_LOCALE", + "CF_OEMTEXT", + "CF_DIB", + "CF_DIBV5", + ]; + + const FIREFOX: &[&str] = &[ + "DataObject", + "text/html", + "HTML Format", + "text/_moz_htmlcontext", + "text/_moz_htmlinfo", + "CF_UNICODETEXT", + "CF_TEXT", + "text/x-moz-url-priv", + "Ole Private Data", + "CF_LOCALE", + "CF_OEMTEXT", + ]; + + const CHROME: &[&str] = &[ + "HTML Format", + "CF_UNICODETEXT", + "Chromium internal source RFH token", + "Chromium internal source URL", + "CF_LOCALE", + "CF_TEXT", + "CF_OEMTEXT", + ]; + + const EXPLORER: &[&str] = &[ + "DataObject", + "Shell IDList Array", + "DataObjectAttributes", + "DataObjectAttributesRequiringElevation", + "Shell Object Offsets", + "Preferred DropEffect", + "AsyncFlag", + "CF_HDROP", + "FileName", + "FileContents", + "FileNameW", + "FileGroupDescriptorW", + "ZoneIdentifier", + "Ole Private Data", + ]; + + const SNIP: &[&str] = &[ + "DataObject", + "CF_BITMAP", + "PNG", + "CanUploadToCloudClipboard", + "CanIncludeInClipboardHistory", + "Ole Private Data", + "CF_DIB", + "CF_DIBV5", + ]; + + const TERMINAL: &[&str] = &["CF_UNICODETEXT", "CF_LOCALE", "CF_TEXT", "CF_OEMTEXT"]; + + fn kept(offered: &[&str]) -> Vec<&'static str> { + offered + .iter() + .filter(|id| CATALOG.decide(id) == Take::Payload) + .filter(|id| !CATALOG.costlier_twin(id, offered)) + .map(|id| { + CATALOG + .wanted + .iter() + .find(|one| **one == CATALOG.canonical(id)) + .copied() + .unwrap_or("?") + }) + .collect() + } + + #[test] + fn word_keeps_its_styles_and_leaves_the_ole_object_alone() { + assert_eq!(CATALOG.classify(WORD), Some(Family::Text)); + let kept = kept(WORD); + assert!(kept.contains(&"Rich Text Format"), "{kept:?}"); + assert!(kept.contains(&"HTML Format"), "{kept:?}"); + assert!(kept.contains(&"CF_UNICODETEXT"), "{kept:?}"); + // 41.833 bytes de RTF y 39.321 de HTML por un párrafo, y ni un byte + // del objeto incrustado, que los arrastra por decenas de megas. + for heavy in ["Embed Source", "Native", "CF_ENHMETAFILE"] { + assert_eq!(CATALOG.decide(heavy), Take::Presence, "{heavy}"); + } + } + + /// El fallo que Windows estrena: Excel adjunta una imagen del rango —medido, + /// 258.380 bytes de `CF_DIBV5` por 500 de texto— y con la regla de macOS + /// copiar celdas se guardaría como una captura de pantalla. + #[test] + fn a_spreadsheet_is_text_and_not_a_picture_of_itself() { + assert_eq!(CATALOG.classify(EXCEL), Some(Family::Text)); + } + + /// Y Excel pide no ser registrado en **cada** copia, incluso de dos celdas + /// y con la aplicación abierta a la vista. Se obedece, y se dice quién lo + /// pidió: un descarte mudo es indistinguible de un fallo. + #[test] + fn excel_asks_not_to_be_recorded_and_says_so_by_name() { + assert_eq!( + CATALOG.refusal(EXCEL), + Some(Refusal::Marked( + "ExcludeClipboardContentFromMonitorProcessing" + )) + ); + assert_eq!(CATALOG.refusal(WORD), None, "Word no lo pide"); + } + + #[test] + fn a_password_manager_that_says_zero_is_obeyed() { + assert_eq!( + CATALOG.declines("CanIncludeInClipboardHistory", &[0, 0, 0, 0]), + Some(Refusal::Declined("CanIncludeInClipboardHistory")) + ); + assert_eq!( + CATALOG.declines("CanIncludeInClipboardHistory", &[1, 0, 0, 0]), + None, + "la captura de pantalla dice que sí y se guarda" + ); + } + + /// Firefox ofrece la misma selección dos veces: 568.458 bytes en + /// `text/html` y 284.561 en `HTML Format`. Se guarda una. + #[test] + fn a_browser_selection_is_not_stored_twice() { + assert_eq!(CATALOG.classify(FIREFOX), Some(Family::Text)); + assert!(CATALOG.costlier_twin("text/html", FIREFOX)); + assert!(!CATALOG.costlier_twin("HTML Format", FIREFOX)); + } + + /// El texto degradado a la página de códigos del sistema no se guarda + /// nunca: es el mismo contenido con pérdida, y en Word llega **antes** que + /// el Unicode, así que quedarse con el primero sería quedarse con el malo. + #[test] + fn the_ansi_halves_of_the_text_are_never_kept() { + for source in [WORD, EXCEL, FIREFOX, CHROME, TERMINAL] { + for degraded in ["CF_TEXT", "CF_OEMTEXT"] { + assert!( + CATALOG.costlier_twin(degraded, source), + "{degraded} se guardó pudiendo guardar el Unicode" + ); + } + assert!(!CATALOG.costlier_twin("CF_UNICODETEXT", source)); + } + } + + /// Una captura ofrece 13.127.103 bytes en total. Con el PNG basta: 164.311. + #[test] + fn a_screen_capture_costs_its_png_and_not_its_bitmap() { + assert_eq!(CATALOG.classify(SNIP), Some(Family::Image)); + assert_eq!(CATALOG.preferred_image(SNIP), Some("PNG")); + assert!(CATALOG.costlier_twin("CF_DIB", SNIP)); + assert!(CATALOG.costlier_twin("CF_DIBV5", SNIP)); + assert!(!CATALOG.costlier_twin("PNG", SNIP)); + } + + /// Sin PNG gana el `CF_DIBV5`, nunca el `CF_DIB`: el clásico pierde el + /// canal alfa y son 84 bytes de diferencia. + #[test] + fn without_a_png_the_bitmap_with_alpha_wins() { + let paint = ["DataObject", "CF_BITMAP", "CF_DIB", "CF_DIBV5"]; + assert_eq!(CATALOG.preferred_image(&paint), Some("CF_DIBV5")); + assert!(CATALOG.costlier_twin("CF_DIB", &paint)); + assert_eq!(CATALOG.classify(&paint), Some(Family::Image)); + } + + /// El explorador ofrece catorce tipos y solo dos hacen falta: las rutas y + /// si fue copia o corte. `FileName` y `FileNameW` llevan una sola ruta + /// cuando se copiaron varias, así que guardarlos perdería archivos. + #[test] + fn the_explorer_needs_only_the_paths_and_the_effect() { + assert_eq!(CATALOG.classify(EXPLORER), Some(Family::Files)); + let kept = kept(EXPLORER); + assert!(kept.contains(&"CF_HDROP"), "{kept:?}"); + assert!(kept.contains(&"Preferred DropEffect"), "{kept:?}"); + assert_eq!(kept.len(), 2, "y nada más: {kept:?}"); + for partial in ["FileName", "FileNameW"] { + assert_eq!(CATALOG.decide(partial), Take::Presence, "{partial}"); + } + } + + /// Los archivos virtuales se anotan y no se piden: `FileContents` no llega + /// por el API plano del portapapeles, y pedirlo sería colgarse esperando. + #[test] + fn virtual_files_are_noted_and_never_asked_for() { + for virtualised in ["FileGroupDescriptorW", "FileContents"] { + assert_eq!(CATALOG.decide(virtualised), Take::Presence); + } + } + + /// Una terminal ofrece lo mínimo, y lo mínimo sigue siendo un ítem. + #[test] + fn the_plainest_copy_there_is_still_an_item() { + assert_eq!(CATALOG.classify(TERMINAL), Some(Family::Text)); + assert_eq!(kept(TERMINAL), vec!["CF_UNICODETEXT"]); + } + + /// El navegador guarda de dónde salió, que es contexto que la 2.x tiraba. + #[test] + fn a_browser_copy_keeps_where_it_came_from() { + assert_eq!( + CATALOG.decide("Chromium internal source URL"), + Take::Payload + ); + assert_eq!( + CATALOG.decide("Chromium internal source RFH token"), + Take::Presence, + "el testigo interno no es contexto de nadie" + ); + } + + /// Lo que ninguna fuente medida ofrece tampoco se pide: el catálogo no + /// promete nada sobre lo que no conoce. + #[test] + fn the_unknown_is_only_noted() { + for unknown in ["ApplicationXYZ", "", "algo/inventado"] { + assert_eq!(CATALOG.decide(unknown), Take::Presence); + } + } + + /// Ninguna de las siete fuentes cae en «no se sabe qué es esto». + #[test] + fn every_measured_source_gets_a_class() { + for (name, source) in [ + ("Word", WORD), + ("Excel", EXCEL), + ("Firefox", FIREFOX), + ("Chrome", CHROME), + ("Explorador", EXPLORER), + ("Recortes", SNIP), + ("Terminal", TERMINAL), + ] { + assert!(CATALOG.classify(source).is_some(), "{name}"); + } + } + + /// Un tipo no puede estar a la vez en lo que se copia y en lo que se anota: + /// la lista larga gana en `decide` y el catálogo mentiría sobre sí mismo. + #[test] + fn nothing_is_both_wanted_and_wasteful() { + for id in CATALOG.wanted { + assert!( + !CATALOG.wasteful.contains(id), + "«{id}» está en las dos listas" + ); + } + } + + /// Todo lo que decide una familia tiene que poder copiarse; si no, la + /// clasificación prometería un contenido que nunca se guardó. + #[test] + fn everything_that_names_a_family_can_be_copied() { + for id in CATALOG.text.iter().chain(CATALOG.files) { + assert_eq!(CATALOG.decide(id), Take::Payload, "«{id}»"); + } + for id in CATALOG.images_by_preference { + assert_eq!(CATALOG.decide(id), Take::Payload, "«{id}»"); + } + } + + /// El primero de cada grupo de equivalentes es el que se guarda, así que + /// tiene que ser uno de los que se copian. + #[test] + fn the_preferred_of_each_group_is_one_that_gets_copied() { + for group in CATALOG.equivalents { + let first = group.first().expect("un grupo vacío no desempata nada"); + assert_eq!(CATALOG.decide(first), Take::Payload, "«{first}»"); + } + } +} diff --git a/crates/cp-win/src/lib.rs b/crates/cp-win/src/lib.rs new file mode 100644 index 00000000..9cb825f4 --- /dev/null +++ b/crates/cp-win/src/lib.rs @@ -0,0 +1,8 @@ +#![cfg(target_os = "windows")] + +pub mod formats; +pub mod transfer; + +/// El pegado nunca se intenta sin el destino en primer plano: `SendInput` +/// entrega a la cola de entrada de quien está al frente, y a nadie más. +pub const REQUIRES_FOREGROUND_TARGET: bool = true; diff --git a/crates/cp-win/src/transfer.rs b/crates/cp-win/src/transfer.rs new file mode 100644 index 00000000..638e0275 --- /dev/null +++ b/crates/cp-win/src/transfer.rs @@ -0,0 +1,108 @@ +//! Si lo que hay en el portapapeles se copió o se cortó. + +/// Los bits de `DROPEFFECT`, tal como Windows los define. +const COPY: u32 = 1; +const MOVE: u32 = 2; +const LINK: u32 = 4; + +/// Qué pidió la fuente que se hiciera con lo que dejó. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transfer { + Copy, + /// Un corte. Las rutas dejan de existir en cuanto alguien pegue, así que + /// guardarlas como si fueran una copia deja el historial lleno de destinos + /// muertos. + Move, + /// La fuente no dijo nada. Es lo normal fuera del explorador. + Unsaid, +} + +/// Lee `Preferred DropEffect`, que son cuatro bytes en little-endian. +/// +/// **Se mira el bit, no el valor.** Medido el 14/09/2026 en Windows 11 26200: +/// el Explorador ofrece **5** al copiar, que es `COPY | LINK`, así que +/// compararlo con `COPY` daría que no es una copia y compararlo con `MOVE` +/// tampoco diría que lo es. +pub fn transfer(value: &[u8]) -> Transfer { + let [a, b, c, d, ..] = value else { + return Transfer::Unsaid; + }; + let effect = u32::from_le_bytes([*a, *b, *c, *d]); + // El corte manda sobre la copia cuando vienen los dos bits: quien mueve + // acepta copiar, pero quien copia nunca borra el origen. + if effect & MOVE != 0 { + return Transfer::Move; + } + // Los dos por separado y no `COPY | LINK`: con bits que no se solapan, el + // «o» y el «o exclusivo» dan lo mismo y ninguna prueba podría distinguirlos. + if effect & COPY != 0 || effect & LINK != 0 { + return Transfer::Copy; + } + Transfer::Unsaid +} + +#[cfg(test)] +mod tests { + use super::*; + + /// El valor que el Explorador ofrece de verdad, medido el 14/09/2026 en + /// trece copias seguidas: siempre cinco, nunca uno. + #[test] + fn what_the_explorer_really_offers_when_copying() { + assert_eq!(transfer(&5u32.to_le_bytes()), Transfer::Copy); + assert_ne!( + 5u32, COPY, + "el valor medido no es COPY a secas, y compararlo por igualdad falla" + ); + } + + #[test] + fn a_cut_is_recognised_by_its_bit() { + assert_eq!(transfer(&MOVE.to_le_bytes()), Transfer::Move); + assert_eq!(transfer(&(MOVE | LINK).to_le_bytes()), Transfer::Move); + } + + /// Con los dos bits puestos manda el corte: tratar como copia algo que el + /// origen va a borrar deja rutas muertas en el historial. + #[test] + fn a_cut_wins_over_a_copy_when_both_bits_are_set() { + assert_eq!(transfer(&(COPY | MOVE).to_le_bytes()), Transfer::Move); + assert_eq!(transfer(&7u32.to_le_bytes()), Transfer::Move); + } + + #[test] + fn a_plain_copy_is_a_copy() { + assert_eq!(transfer(©.to_le_bytes()), Transfer::Copy); + assert_eq!(transfer(&LINK.to_le_bytes()), Transfer::Copy); + } + + /// Lo que no se puede leer no se inventa: fuera del explorador este formato + /// no está, y eso no convierte la copia en un corte. + #[test] + fn what_cannot_be_read_says_nothing() { + assert_eq!(transfer(&[]), Transfer::Unsaid); + assert_eq!(transfer(&[2]), Transfer::Unsaid); + assert_eq!(transfer(&[2, 0, 0]), Transfer::Unsaid); + assert_eq!(transfer(&0u32.to_le_bytes()), Transfer::Unsaid); + } + + #[test] + fn the_four_bytes_are_one_little_endian_number() { + assert_eq!(transfer(&[0, 0, 0, 2]), Transfer::Unsaid); + assert_eq!(transfer(&[2, 0, 0, 0]), Transfer::Move); + } + + #[test] + fn a_longer_value_is_read_by_its_first_four_bytes() { + assert_eq!(transfer(&[5, 0, 0, 0, 9, 9, 9]), Transfer::Copy); + assert_eq!(transfer(&[2, 0, 0, 0, 9, 9, 9]), Transfer::Move); + } + + /// Los bits altos son de otras banderas de `DROPEFFECT` y no dicen nada + /// sobre copiar o mover. + #[test] + fn the_high_bits_decide_nothing() { + assert_eq!(transfer(&0x8000_0000u32.to_le_bytes()), Transfer::Unsaid); + assert_eq!(transfer(&0x8000_0002u32.to_le_bytes()), Transfer::Move); + } +} From c781a57d86de74bcc2bd6ab23e341b951a592e23 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 14 Sep 2026 16:43:35 -0300 Subject: [PATCH 2/4] feat(win): capture, restore and paste, measured against a hostile clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A format promised with no data blocks GetClipboardData for 30 seconds exactly — measured with the owning process suspended, which is rdpclip with the session gone. What is already in the clipboard answers in 1.5 ms even then, so every read runs under a 100 ms ceiling and what does not arrive is recorded as absent rather than waited for. Capture refuses before asking for a byte, notes the whole set while copying only the whitelist, and transcodes DIB to PNG as a synthetic format while recording the original and its size. Restore writes images twice: the PNG for modern targets and a rebuilt CF_DIBV5 for Paint, which does not read PNG. This gives cp_core::dib and Catalog::declines their first production caller; declines was the only privacy rule in the catalogue with no path to execution. Paste adds Failure::TargetElevated, which Windows needs and macOS does not: UIPI drops injected input at a higher integrity level and SendInput still reports the full count. The probe stopped being observational now that Windows can write. One of its cases caught the battery leaving a secrecy marker behind, which made the next run fail — it now cleans up, and a case checks that it did. Mutants run over the crates without a system call, as macOS already did: cp-win-sys is verified by the probe, not by mutation. 376 tests, 25 system cases, cp-win-sys behind the same unsafe rule as cp-mac-sys. --- .github/workflows/ci.yml | 9 +- .github/workflows/rules.yml | 35 +- Cargo.lock | 113 +++++ Cargo.toml | 13 + PRIVACY.md | 1 + crates/cp-core/src/destination.rs | 17 - crates/cp-core/src/dib.rs | 239 +++++----- crates/cp-core/src/formats.rs | 120 +---- crates/cp-core/src/hash.rs | 15 - crates/cp-core/src/item.rs | 36 +- crates/cp-core/src/kind.rs | 38 -- crates/cp-core/src/paste.rs | 51 ++- crates/cp-core/src/thumbnail.rs | 19 - crates/cp-core/src/watch.rs | 69 +-- crates/cp-mac-sys/build.rs | 2 - crates/cp-mac-sys/src/frontmost.rs | 26 -- crates/cp-mac-sys/src/keyboard.rs | 72 +-- crates/cp-mac-sys/src/keystroke.rs | 39 +- crates/cp-mac-sys/src/media.rs | 16 +- crates/cp-mac-sys/src/ocr.rs | 18 - crates/cp-mac-sys/src/pasteboard.rs | 44 -- crates/cp-mac-sys/src/paths.rs | 10 - crates/cp-mac-sys/src/permissions.rs | 37 +- crates/cp-mac-sys/src/runloop.rs | 14 +- crates/cp-mac/examples/probe.rs | 9 - crates/cp-mac/examples/probe/battery.rs | 19 - crates/cp-mac/src/capture.rs | 15 - crates/cp-mac/src/formats.rs | 13 - crates/cp-mac/src/lib.rs | 2 - crates/cp-mac/src/paste.rs | 31 -- crates/cp-mac/src/restore.rs | 30 +- crates/cp-store/examples/budget.rs | 19 - crates/cp-store/src/blobs.rs | 25 - crates/cp-store/src/lib.rs | 3 - crates/cp-store/src/schema.rs | 23 - crates/cp-store/src/store.rs | 292 +++++------- crates/cp-win-sys/Cargo.toml | 19 + crates/cp-win-sys/src/clipboard.rs | 123 +++++ crates/cp-win-sys/src/formats.rs | 123 +++++ crates/cp-win-sys/src/frontmost.rs | 208 +++++++++ crates/cp-win-sys/src/keystroke.rs | 164 +++++++ crates/cp-win-sys/src/lib.rs | 10 + crates/cp-win-sys/src/paths.rs | 65 +++ crates/cp-win-sys/src/reading.rs | 99 ++++ crates/cp-win-sys/src/source.rs | 78 ++++ crates/cp-win-sys/src/writing.rs | 122 +++++ crates/cp-win/Cargo.toml | 2 + crates/cp-win/examples/probe.rs | 582 ++++++++++++++++++++++++ crates/cp-win/src/capture.rs | 288 ++++++++++++ crates/cp-win/src/formats.rs | 74 +-- crates/cp-win/src/lib.rs | 6 +- crates/cp-win/src/paste.rs | 135 ++++++ crates/cp-win/src/restore.rs | 191 ++++++++ crates/cp-win/src/transfer.rs | 26 -- crates/cp-win/src/watch.rs | 59 +++ deny.toml | 2 - 56 files changed, 2782 insertions(+), 1128 deletions(-) create mode 100644 crates/cp-win-sys/Cargo.toml create mode 100644 crates/cp-win-sys/src/clipboard.rs create mode 100644 crates/cp-win-sys/src/formats.rs create mode 100644 crates/cp-win-sys/src/frontmost.rs create mode 100644 crates/cp-win-sys/src/keystroke.rs create mode 100644 crates/cp-win-sys/src/lib.rs create mode 100644 crates/cp-win-sys/src/paths.rs create mode 100644 crates/cp-win-sys/src/reading.rs create mode 100644 crates/cp-win-sys/src/source.rs create mode 100644 crates/cp-win-sys/src/writing.rs create mode 100644 crates/cp-win/examples/probe.rs create mode 100644 crates/cp-win/src/capture.rs create mode 100644 crates/cp-win/src/paste.rs create mode 100644 crates/cp-win/src/restore.rs create mode 100644 crates/cp-win/src/watch.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b6bce2d..1a60a073 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,14 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check + - name: The probe body is formatted too + run: rustfmt --check --edition 2024 crates/cp-mac/examples/probe/battery.rs - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace - # El ciclo real contra el portapapeles del sistema. No cabe en cargo - # test porque NSPasteboard exige el hilo principal y el runner no lo - # garantiza. - run: cargo run -p cp-mac --example probe if: runner.os == 'macOS' + - run: cargo run -p cp-win --example probe + if: runner.os == 'Windows' cross: name: The macOS crates still build @@ -41,8 +42,6 @@ jobs: with: targets: aarch64-apple-darwin - uses: Swatinem/rust-cache@v2 - # Da la señal de que un cambio en el núcleo rompió macOS sin esperar al - # runner de macOS, que es el más lento y el más escaso. - run: cargo check --target aarch64-apple-darwin -p cp-mac -p cp-mac-sys deny: diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 5b2ae1ac..5d3bfb3c 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -25,8 +25,6 @@ jobs: - name: The core produces no terminal output run: | - # Al principio de línea o tras un operador: un `println!` dentro de - # una cadena de prueba es el texto que se clasifica, no una impresión. if grep -rnE '^[^"]*(^|[ \t{;(=>|&])(e?print(ln)?!)' crates/cp-core/src --include='*.rs'; then echo "cp-core no imprime: usa tracing"; exit 1 fi @@ -39,8 +37,6 @@ jobs: - name: No Spanish identifiers run: | - # Un identificador no vive ni dentro de una cadena ni dentro de un - # comentario, y aqui los comentarios van en espanol a proposito. if grep -rnE '\b(fecha|limite|prioridad|filtro|tarea|titulo|etiqueta|imagen|archivo) *:' \ crates/*/src --include='*.rs' | grep -v '"' | grep -vE ':[0-9]+: *///?!?'; then echo "los identificadores van en ingles"; exit 1 @@ -60,10 +56,20 @@ jobs: echo "espanol neutro: archivo, computador, presiona"; exit 1 fi - - name: No prose blocks in the code + - name: The code carries no comments but SAFETY run: | - awk '/^[ \t]*\/\/[^\/!]/ { n++; if (n > 3) { print FILENAME":"FNR": bloque de prosa"; bad = 1 }; next } - { n = 0 } + if grep -rnE '^[ ]*//' crates --include='*.rs' | grep -v 'SAFETY:'; then + echo "el codigo va sin comentarios; lo que haya que explicar va al expediente"; exit 1 + fi + + - name: Every SAFETY note is one line of English + run: | + if ! grep -rh 'SAFETY:' crates --include='*.rs' | iconv -f utf-8 -t ascii >/dev/null; then + echo "las notas SAFETY van en ingles"; exit 1 + fi + awk '/SAFETY:/ { safety = 1; next } + /^[ ]*\/\// { if (safety) { print FILENAME":"FNR": la nota SAFETY sigue en otra linea"; bad = 1 } } + { safety = 0 } END { exit bad }' $(find crates -name '*.rs') deterministic: @@ -84,7 +90,7 @@ jobs: run: for i in $(seq 1 20); do cargo test -p cp-core --quiet || exit 1; done mutants: - name: The suite kills every mutant (${{ matrix.os }}) + name: Every mutant dies in the crates without a system call (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -99,9 +105,6 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@cargo-mutants - # Una prueba que pasa contra un núcleo mutado no está probando nada. El - # criterio de aceptación de la Etapa 0 es exactamente este: la batería - # tiene que fallar contra una implementación que no sepa lo que sabemos. - run: cargo mutants ${{ matrix.crates }} --no-times coverage: @@ -114,8 +117,6 @@ jobs: components: llvm-tools-preview - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@cargo-llvm-cov - # El arnés entra en la medición: sin él, todo el código de plataforma - # aparece a cero y el número miente en las dos direcciones. - run: cargo llvm-cov --no-report --workspace - run: cargo llvm-cov --no-report run -p cp-mac --example probe - run: cargo llvm-cov report --fail-under-lines 90 --summary-only @@ -130,9 +131,9 @@ jobs: components: llvm-tools-preview - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@cargo-llvm-cov - # Sin el arnés de macOS el umbral sería otro, así que se mide solo lo que - # Windows ejecuta de verdad: el núcleo y el almacén. - - run: cargo llvm-cov -p cp-core -p cp-store -p cp-win --fail-under-lines 95 --summary-only + - run: cargo llvm-cov --no-report -p cp-core -p cp-store -p cp-win -p cp-win-sys + - run: cargo llvm-cov --no-report run -p cp-win --example probe + - run: cargo llvm-cov report --fail-under-lines 90 --summary-only budget: name: Latency stays within budget @@ -143,6 +144,4 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - # Busca regresiones de orden de magnitud —una consulta que deja de usar - # el índice—, no microsegundos. Por eso corre en release y con holgura. - run: cargo run -p cp-store --release --example budget diff --git a/Cargo.lock b/Cargo.lock index e635eaca..df10286e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,15 @@ name = "cp-win" version = "3.0.0" dependencies = [ "cp-core", + "cp-win-sys", + "windows", +] + +[[package]] +name = "cp-win-sys" +version = "3.0.0" +dependencies = [ + "windows", ] [[package]] @@ -1041,12 +1050,107 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1056,6 +1160,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 0d2eecc2..4ad12db9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ cp-store = { path = "crates/cp-store" } cp-mac-sys = { path = "crates/cp-mac-sys" } cp-mac = { path = "crates/cp-mac" } cp-win = { path = "crates/cp-win" } +cp-win-sys = { path = "crates/cp-win-sys" } thiserror = "2" tracing = "0.1" @@ -29,6 +30,18 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", proptest = "1" tempfile = "3" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_System_DataExchange", + "Win32_System_Memory", + "Win32_System_Ole", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", + "Win32_Graphics_Gdi", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_Security", +] } + objc2 = "0.6" objc2-foundation = "0.3" objc2-app-kit = "0.3" diff --git a/PRIVACY.md b/PRIVACY.md index 05b439ba..7a54f718 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -50,6 +50,7 @@ For each clipboard item, CopyPaste also stores: - **Timestamp** — When the item was copied - **Content type** — Text, Image, File, Folder, Link, Audio, or Video - **Source application** — The name of the app where you copied from (_window title_) +- **Source URL** — For content copied from a browser, the address of the page it came from, when the browser offers it. Rich text (HTML) can also carry that address embedded in the content itself. **This includes the full query string**, so a page reached through a password-reset link, a signed URL or a session token keeps that token in your history until the entry is deleted or expires under your retention setting. - **User labels** — Custom labels you assign to items (optional) - **Color tags** — Color categories you assign (optional) - **Pin status** — Whether you pinned the item diff --git a/crates/cp-core/src/destination.rs b/crates/cp-core/src/destination.rs index 167127c6..b3338d0d 100644 --- a/crates/cp-core/src/destination.rs +++ b/crates/cp-core/src/destination.rs @@ -1,20 +1,9 @@ -/// Quién recibirá el pegado. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Destination { pub pid: i32, pub bundle_id: Option, } -/// Recuerda la última aplicación al frente **que no éramos nosotros**. -/// -/// Ocultar el panel en macOS deja a la aplicación activa, así que preguntar -/// quién está al frente en ese momento se lee a uno mismo y el pegado vuelve -/// al panel. La 2.x lo resuelve observando las activaciones; aquí basta con -/// recordar, porque el vigilante ya está mirando. -/// -/// Guarda el **pid** además del bundle: identificar el destino solo por bundle -/// elige arbitrariamente entre dos ventanas de la misma aplicación, que es el -/// hallazgo 24 del mapa. #[derive(Debug, Default)] pub struct Tracker { ours: i32, @@ -29,7 +18,6 @@ impl Tracker { } } - /// Se llama con quien esté al frente, tantas veces como se quiera. pub fn saw(&mut self, pid: i32, bundle_id: Option<&str>) { if pid == self.ours { return; @@ -40,14 +28,10 @@ impl Tracker { }); } - /// El destino del pegado: el último que no fuimos nosotros. pub fn destination(&self) -> Option<&Destination> { self.last_foreign.as_ref() } - /// Cuando el destino se cierra, deja de ser un destino. Recapturar no es - /// posible una vez que el panel tiene el foco, así que esto se sabe por - /// el pid y no por la ventana. pub fn gone(&mut self, pid: i32) { if self.last_foreign.as_ref().is_some_and(|one| one.pid == pid) { self.last_foreign = None; @@ -70,7 +54,6 @@ mod tests { fn hiding_the_panel_does_not_lose_the_target() { let mut tracker = Tracker::new(100); tracker.saw(200, Some("com.apple.TextEdit")); - // El panel se muestra y nos volvemos nosotros el frente. tracker.saw(100, Some("dev.rgdevment.copypaste")); assert_eq!( tracker.destination().map(|one| one.pid), diff --git a/crates/cp-core/src/dib.rs b/crates/cp-core/src/dib.rs index 2f537423..8e5c2efe 100644 --- a/crates/cp-core/src/dib.rs +++ b/crates/cp-core/src/dib.rs @@ -1,19 +1,12 @@ -//! El mapa de bits del portapapeles de Windows, convertido a algo que se pueda -//! guardar. -//! -//! `CF_DIB` y `CF_DIBV5` llegan sin comprimir y sin la cabecera de archivo que -//! los haría un `.bmp`. Medido el 14/09/2026 en Windows 11 26200, la misma -//! imagen ocupa **480.052 bytes en DIB y 1.964 en PNG**: guardar el DIB tal -//! cual es pagar 244 veces el precio por cada captura. -//! -//! Todo lo de aquí es de bytes a bytes, así que se prueba sin Windows delante. - const FILE_HEADER: usize = 14; const INFO_HEADER: usize = 40; const BI_BITFIELDS: u32 = 3; const RGBQUAD: usize = 4; -/// Lo que dice la cabecera de un DIB. +pub const LARGEST_BITMAP: usize = 256 * 1024 * 1024; + +const _: () = assert!(7680 * 4320 * 4 < LARGEST_BITMAP); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Header { pub size: u32, @@ -32,8 +25,6 @@ fn u32_at(bytes: &[u8], at: usize) -> Option { pub fn header(dib: &[u8]) -> Option
{ let size = u32_at(dib, 0)?; - // Una cabecera que dice medir menos que la mínima, o más que el buffer - // entero, no es una cabecera: es basura con forma de imagen. if (size as usize) < INFO_HEADER || size as usize > dib.len() { return None; } @@ -47,17 +38,10 @@ pub fn header(dib: &[u8]) -> Option
{ }) } -/// Dónde empiezan los píxeles, contando desde el principio del DIB. -/// -/// Es el número que decide si la imagen sale bien o sale desplazada, y tiene -/// dos trampas que la 2.x documentó tras encontrarlas en campo. pub fn pixel_offset(dib: &[u8]) -> Option { let head = header(dib)?; let mut table = 0usize; - // Las máscaras de `BI_BITFIELDS` solo siguen a la cabecera clásica de 40 - // bytes; en `BITMAPV4HEADER` y `BITMAPV5HEADER` van dentro, y sumarlas otra - // vez desplaza la imagen 12 bytes. if head.compression == BI_BITFIELDS && head.size as usize == INFO_HEADER { table += 3 * 4; } @@ -70,9 +54,6 @@ pub fn pixel_offset(dib: &[u8]) -> Option { }; table += colors as usize * RGBQUAD; } else if head.clr_used != 0 { - // Por encima de 8 bits la paleta es opcional, y los productores dejan - // `biClrUsed` sucio a menudo: honrarlo a ciegas manda los píxeles fuera - // del buffer. Solo se aplica si lo que dice cabe de verdad. let claimed = head.clr_used as usize * RGBQUAD; if head.size as usize + table + claimed <= dib.len() { table += claimed; @@ -83,7 +64,6 @@ pub fn pixel_offset(dib: &[u8]) -> Option { (offset <= dib.len()).then_some(offset) } -/// Un `.bmp` completo: el DIB con su cabecera de archivo delante. pub fn as_bmp(dib: &[u8]) -> Option> { let pixels = pixel_offset(dib)?; let mut bmp = Vec::with_capacity(FILE_HEADER + dib.len()); @@ -96,39 +76,40 @@ pub fn as_bmp(dib: &[u8]) -> Option> { Some(bmp) } -/// Qué hay de verdad en el cuarto byte de cada píxel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Alpha { - /// No hay cuarto byte: la imagen no es de 32 bits. Absent, - /// Lo hay y no dice nada: o está todo a cero porque nadie lo escribió, o - /// está todo opaco. En los dos casos la imagen es opaca. Opaque, - /// Hay transparencia de verdad y hay que respetarla. Real, } -/// La máscara de alfa que la cabecera declara, cuando la lleva dentro. -/// -/// Solo `BITMAPV4HEADER` y `BITMAPV5HEADER` la tienen, en el mismo sitio. Con -/// la cabecera clásica de 40 bytes y `BI_BITFIELDS` las máscaras van detrás, -/// pero son tres y ninguna es la del alfa. +fn pixel_len(head: Header) -> Option { + let width = usize::try_from(head.width.unsigned_abs()).ok()?; + let height = usize::try_from(head.height.unsigned_abs()).ok()?; + let stride = width + .checked_mul(usize::from(head.bit_count))? + .checked_add(31)? + / 32 + * 4; + stride.checked_mul(height) +} + +fn pixels_of(dib: &[u8]) -> Option<&[u8]> { + let head = header(dib)?; + let start = pixel_offset(dib)?; + let pixels = dib.get(start..)?; + Some(match pixel_len(head) { + Some(len) if len <= pixels.len() => &pixels[..len], + _ => pixels, + }) +} + fn declared_alpha_mask(dib: &[u8], head: Header) -> Option { (head.size as usize >= 56) .then(|| u32_at(dib, 52)) .flatten() } -/// Por especificación, el cuarto byte de un `BI_RGB` de 32 bits es -/// **indefinido**; en la práctica los productores modernos escriben el alfa -/// ahí. Honrar un canal entero a cero daría una imagen invisible, así que se -/// mira antes de creérselo. -/// -/// Pero la cabecera manda sobre la heurística. Medido el 14/09/2026: al poner -/// solo un `CF_DIB`, Windows sintetiza un `CF_DIBV5` con `biSize` 124, -/// `BI_RGB` y **`bV5AlphaMask` a cero** —dice que no hay canal alfa— sobre los -/// mismos píxeles. Mirar únicamente el cuarto byte de un sintetizado así es -/// inventarse una transparencia y grabarla en el PNG para siempre. pub fn alpha(dib: &[u8]) -> Alpha { let Some(head) = header(dib) else { return Alpha::Absent; @@ -139,12 +120,9 @@ pub fn alpha(dib: &[u8]) -> Alpha { if declared_alpha_mask(dib, head) == Some(0) { return Alpha::Absent; } - let Some(start) = pixel_offset(dib) else { + let Some(pixels) = pixels_of(dib) else { return Alpha::Absent; }; - // `pixel_offset` ya garantiza que el corte cae dentro; el `unwrap` evita - // una rama que ninguna entrada puede alcanzar. - let pixels = dib.get(start..).unwrap_or_default(); let mut seen_zero = false; let mut seen_full = false; let mut seen_between = false; @@ -162,24 +140,24 @@ pub fn alpha(dib: &[u8]) -> Alpha { } } -/// El DIB como PNG, que es como se guarda. -/// -/// Devuelve `None` si los bytes no son un mapa de bits que se pueda leer. +fn too_large(len: usize) -> bool { + len > LARGEST_BITMAP +} + pub fn to_png(dib: &[u8]) -> Option> { - let mut owned; - // Todo lo que no sea transparencia declarada se escribe opaco: un cuarto - // byte que nadie llenó, leído como alfa, da una imagen invisible. - let source = if alpha(dib) != Alpha::Real && header(dib)?.bit_count == 32 { - owned = dib.to_vec(); - let start = pixel_offset(&owned)?; - for chunk in owned.get_mut(start..)?.as_chunks_mut::<4>().0 { + if too_large(dib.len()) { + return None; + } + let head = header(dib)?; + let mut bmp = as_bmp(dib)?; + if head.bit_count == 32 && alpha(dib) != Alpha::Real { + let from = FILE_HEADER + pixel_offset(dib)?; + let pixels = bmp.get_mut(from..)?; + let to = pixel_len(head).unwrap_or(pixels.len()).min(pixels.len()); + for chunk in pixels.get_mut(..to)?.as_chunks_mut::<4>().0 { chunk[3] = 0xFF; } - &owned - } else { - dib - }; - let bmp = as_bmp(source)?; + } let decoded = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?; let mut out = std::io::Cursor::new(Vec::new()); decoded @@ -188,11 +166,21 @@ pub fn to_png(dib: &[u8]) -> Option> { .map(|()| out.into_inner()) } +pub fn from_png(png: &[u8]) -> Option> { + if too_large(png.len()) { + 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(); + (bmp.len() > FILE_HEADER).then(|| bmp[FILE_HEADER..].to_vec()) +} + #[cfg(test)] mod tests { use super::*; - /// Un DIB armado a mano, para poder mover una pieza cada vez. struct Dib { header_size: u32, width: i32, @@ -244,7 +232,6 @@ mod tests { assert_eq!(pixel_offset(&dib), Some(INFO_HEADER)); } - /// La primera trampa: con la cabecera clásica, las máscaras van detrás. #[test] fn bitfield_masks_follow_the_classic_header() { let mut dib = Dib::rgb32(2, 2); @@ -253,8 +240,6 @@ mod tests { assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 12)); } - /// Y la otra mitad de la trampa: con `BITMAPV4HEADER` y `BITMAPV5HEADER` - /// las máscaras están dentro, y sumarlas otra vez corre la imagen 12 bytes. #[test] fn bitfield_masks_live_inside_the_newer_headers() { for size in [108u32, 124] { @@ -292,8 +277,6 @@ mod tests { assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 16 * RGBQUAD)); } - /// La segunda trampa: por encima de 8 bits los productores dejan - /// `biClrUsed` sucio, y creérselo manda los píxeles fuera del buffer. #[test] fn a_dirty_palette_count_above_eight_bits_is_ignored() { let mut dib = Dib::rgb32(2, 2); @@ -313,8 +296,6 @@ mod tests { assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 2 * RGBQUAD)); } - /// Una cabecera que ocupa el buffer entero sigue siendo legible: dice lo - /// que dice, y que detrás no venga un solo píxel es otro asunto. #[test] fn a_header_that_fills_the_whole_buffer_is_still_a_header() { let mut dib = Dib::rgb32(1, 1); @@ -329,9 +310,6 @@ mod tests { assert_eq!(pixel_offset(&raw), Some(INFO_HEADER)); } - /// La comprobación de que la paleta cabe suma los tres tramos: cabecera, - /// máscaras y paleta. Con la paleta justo fuera del buffer, la cuenta tiene - /// que dar que no cabe y quedarse con lo anterior. #[test] fn a_palette_one_byte_too_long_is_left_out() { let mut dib = Dib::rgb32(2, 2); @@ -346,8 +324,6 @@ mod tests { ); } - /// Y lo mismo con las máscaras por delante: los tres tramos se suman, no - /// se restan ni se multiplican entre sí. #[test] fn the_masks_count_towards_whether_the_palette_fits() { let mut dib = Dib::rgb32(2, 2); @@ -429,8 +405,6 @@ mod tests { assert_eq!(alpha(&dib.build()), Alpha::Real); } - /// Una cabecera que promete máscaras que el buffer no tiene: no hay - /// píxeles donde mirar, así que no hay alfa que leer. #[test] fn a_header_promising_more_than_the_buffer_holds_has_no_alpha() { let mut dib = Dib::rgb32(1, 1); @@ -443,9 +417,6 @@ mod tests { assert_eq!(to_png(&raw), None); } - /// El caso medido el 14/09/2026: al poner solo un `CF_DIB`, Windows - /// sintetiza un `CF_DIBV5` de 124 bytes de cabecera, `BI_RGB`, con la - /// máscara de alfa a cero. La cabecera manda: no hay canal que leer. #[test] fn a_synthesised_v5_declaring_no_alpha_mask_has_no_alpha() { let mut dib = Dib::rgb32(2, 2); @@ -460,8 +431,6 @@ mod tests { ); } - /// Y sin esa corrección la imagen saldría medio transparente: el PNG tiene - /// que quedar opaco. #[test] fn a_synthesised_v5_does_not_become_half_transparent() { let mut dib = Dib::rgb32(4, 4); @@ -475,9 +444,6 @@ mod tests { ); } - /// La cabecera más corta que llega a declarar el alfa mide 56 bytes: es la - /// `BITMAPV3INFOHEADER` que escriben Photoshop y GIMP, cuatro más que la de - /// tres máscaras. Pedir 57 la dejaría fuera y su transparencia se perdería. #[test] fn the_shortest_header_that_declares_alpha_is_fifty_six_bytes() { let mut dib = Dib::rgb32(2, 2); @@ -497,8 +463,6 @@ mod tests { ); } - /// Y la misma cabecera de 56 con la máscara a cero no tiene alfa, igual - /// que la de 124: lo que decide es que el campo esté, no cuánto mide. #[test] fn a_fifty_six_byte_header_with_no_mask_has_no_alpha() { let mut dib = Dib::rgb32(2, 2); @@ -507,8 +471,31 @@ mod tests { assert_eq!(alpha(&dib.build()), Alpha::Absent); } - /// La máscara declarada distingue el sintetizado del real: con una máscara - /// de verdad, los mismos píxeles sí llevan alfa. + #[test] + fn bytes_past_the_pixel_array_do_not_vote_on_the_alpha() { + let mut dib = Dib::rgb32(4, 4); + dib.header_size = 124; + dib.compression = BI_BITFIELDS; + dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); + let mut raw = dib.build(); + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(alpha(&raw), Alpha::Opaque); + + raw.extend_from_slice(&[0x00, 0x00, 0x00, 0xFF]); + assert_eq!( + alpha(&raw), + Alpha::Opaque, + "cuatro bytes de cola daban la captura por transparente" + ); + let back = image::load_from_memory(&to_png(&raw).expect("png")) + .expect("se relee") + .to_rgba8(); + assert!(back.pixels().all(|pixel| pixel[3] == 0xFF)); + } + #[test] fn a_declared_mask_turns_the_same_pixels_into_real_alpha() { let mut dib = Dib::rgb32(2, 2); @@ -519,8 +506,6 @@ mod tests { assert_eq!(alpha(&raw), Alpha::Real); } - /// La cabecera clásica no lleva máscara de alfa ni cuando usa - /// `BI_BITFIELDS`: allí solo hay tres, y la heurística sigue mandando. #[test] fn the_classic_header_has_no_alpha_mask_to_declare() { let mut dib = Dib::rgb32(2, 2); @@ -538,7 +523,6 @@ mod tests { assert_eq!(alpha(&dib.build()), Alpha::Absent); } - /// El número que justifica todo este módulo. #[test] fn a_bitmap_becomes_a_fraction_of_its_size_as_png() { let mut dib = Dib::rgb32(200, 200); @@ -553,6 +537,59 @@ mod tests { ); } + #[test] + fn the_size_limit_falls_between_the_last_accepted_byte_and_the_first_refused() { + assert!(!too_large(LARGEST_BITMAP - 1)); + assert!(!too_large(LARGEST_BITMAP)); + assert!(too_large(LARGEST_BITMAP + 1)); + assert!(too_large(usize::MAX)); + assert!(!too_large(0)); + } + + #[test] + fn a_bitmap_too_large_to_be_a_screen_is_refused_before_it_is_copied() { + let mut dib = Dib::rgb32(1, 1); + dib.pixels = vec![0; LARGEST_BITMAP + 1 - INFO_HEADER]; + let raw = dib.build(); + assert!(too_large(raw.len())); + assert_eq!(to_png(&raw), None); + } + + #[test] + fn a_png_becomes_a_bitmap_the_clipboard_understands() { + let dib = Dib::rgb32(4, 4).build(); + let png = to_png(&dib).expect("png"); + let back = from_png(&png).expect("dib"); + let head = header(&back).expect("cabecera"); + assert_eq!((head.width, head.height.abs()), (4, 4)); + assert!(pixel_offset(&back).is_some()); + } + + #[test] + fn the_round_trip_keeps_the_colours_where_they_were() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = vec![ + 0x00, 0x00, 0xFF, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, + ]; + let once = image::load_from_memory(&to_png(&dib.build()).expect("png")) + .expect("relee") + .to_rgba8(); + let twice = image::load_from_memory( + &to_png(&from_png(&to_png(&dib.build()).expect("png")).expect("dib")).expect("png"), + ) + .expect("relee") + .to_rgba8(); + assert_eq!(once.as_raw(), twice.as_raw()); + } + + #[test] + fn what_is_not_a_png_is_not_a_bitmap_either() { + assert_eq!(from_png(&[]), None); + assert_eq!(from_png(b"esto no es un png"), None); + assert_eq!(from_png(&vec![0u8; LARGEST_BITMAP + 1]), None); + } + #[test] fn a_png_from_a_dib_is_a_png() { let dib = Dib::rgb32(4, 4).build(); @@ -562,8 +599,6 @@ mod tests { assert_eq!((back.width(), back.height()), (4, 4)); } - /// Un canal alfa a cero se corrige **antes** de decodificar: si no, el PNG - /// sale entero transparente y la captura se pierde sin que nadie lo vea. #[test] fn a_capture_with_an_unwritten_alpha_does_not_become_invisible() { let mut dib = Dib::rgb32(4, 4); @@ -576,10 +611,6 @@ mod tests { ); } - /// El caso que de verdad salva la imagen: la cabecera **declara** la - /// máscara de alfa, así que el decodificador va a leer ese canal, y la - /// fuente lo dejó entero a cero. Sin corregirlo, la captura se guarda - /// completamente transparente y el usuario ve una tarjeta vacía. #[test] fn a_declared_alpha_channel_left_at_zero_is_not_an_invisible_capture() { let mut dib = Dib::rgb32(4, 4); @@ -604,8 +635,6 @@ mod tests { ); } - /// Y la transparencia de verdad sobrevive al viaje, que es la otra mitad: - /// forzar el alfa siempre es el fallo que la 2.x tiene al escribir. #[test] fn real_transparency_survives_the_trip() { let mut dib = Dib::rgb32(2, 2); @@ -618,7 +647,6 @@ mod tests { 0x20, 0x60, 0xA0, 0xFF, ]; let mut raw = dib.build(); - // Las máscaras de un BITMAPV5HEADER, en su sitio dentro de la cabecera. raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); @@ -633,8 +661,6 @@ mod tests { ); } - /// Un DIB cuyos píxeles no llegan hasta donde la cabecera promete no puede - /// tumbar el proceso: se dice que no en vez de indexar fuera. #[test] fn a_truncated_bitmap_is_refused_not_panicked_on() { let dib = Dib::rgb32(100, 100).build(); @@ -655,8 +681,6 @@ mod properties { use proptest::prelude::*; proptest! { - /// Ningún montón de bytes puede tumbar el proceso. El portapapeles lo - /// llena cualquiera, así que esto no es una precaución teórica. #[test] fn no_pile_of_bytes_can_bring_the_process_down( bytes in prop::collection::vec(any::(), 0..600), @@ -668,8 +692,6 @@ mod properties { let _ = to_png(&bytes); } - /// Donde empiezan los píxeles cae siempre dentro del buffer: es el - /// número con el que después se indexa. #[test] fn the_pixels_always_start_inside_the_buffer( bytes in prop::collection::vec(any::(), 0..600), @@ -680,7 +702,6 @@ mod properties { } } - /// El `.bmp` es el DIB con catorce bytes delante, ni uno más. #[test] fn the_bmp_is_the_dib_with_a_header_in_front( bytes in prop::collection::vec(any::(), 0..600), diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index 46730815..407022c2 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -1,17 +1,10 @@ -/// Qué hacer con un tipo que la fuente ofreció. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Take { - /// Se copian los bytes. Payload, - /// Se anota que existía, con su tamaño, sin pedir los datos. Presence, - /// No se toca nunca. Never, } -/// La categoría que se deduce **del formato**. La clasificación fina —si ese -/// texto es un correo, un color o código— la hace `crate::kind`, que mira el -/// contenido. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Family { Text, @@ -19,14 +12,9 @@ pub enum Family { Files, } -/// Por qué una copia no se registra. Se devuelve en vez de un booleano para -/// que la interfaz pueda decirlo: un descarte mudo es indistinguible de un -/// fallo, y quien copió desde Excel merece saber que Excel pidió esto. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Refusal { - /// Un tipo dedicado a marcar secretos. Basta con que esté. Marked(&'static str), - /// Un marcador cuyo **valor** pide no registrar. Declined(&'static str), } @@ -38,50 +26,18 @@ impl Refusal { } } -/// Los nombres de los formatos son de cada plataforma —UTIs en macOS, -/// `CF_*` y cadenas registradas en Windows— pero las reglas que se les -/// aplican son las mismas. El catálogo son los datos; la lógica vive aquí y -/// se escribe una sola vez. pub struct Catalog { - /// Pedir sus datos cuelga la aplicación. pub hangs: &'static [&'static str], - /// Arrastran megabytes sin aportar nada recuperable. pub wasteful: &'static [&'static str], - /// Se copian enteros. pub wanted: &'static [&'static str], - /// Nombre viejo y su equivalente moderno, mismo contenido. pub aliases: &'static [(&'static str, &'static str)], - /// Su sola presencia significa «no registres esto». pub concealed: &'static [&'static str], - /// Marcadores cuyo valor decide: un entero de 32 bits en little-endian, - /// donde cero significa «no registres esto». - /// - /// Leerlos no rompe la regla de decidir por el tipo y nunca por el - /// contenido: cuatro bytes de un flag no son el secreto que el payload - /// guarda. Medido el 14/09/2026 en Windows 11 26200, - /// `CanIncludeInClipboardHistory` es exactamente eso. pub denied_when_zero: &'static [&'static str], - /// Prefijos de tipos generados, que nunca llevan payload útil. pub opaque_prefixes: &'static [&'static str], pub text: &'static [&'static str], pub files: &'static [&'static str], - /// Representaciones de imagen, de la más barata a la más cara. pub images_by_preference: &'static [&'static str], - /// Otros grupos de representaciones del mismo contenido, cada uno de la - /// más barata a la más cara. Las imágenes tienen el suyo aparte porque - /// además deciden la familia. - /// - /// Medido el 14/09/2026: Firefox ofrece la misma selección como - /// `text/html` en 568.458 bytes y como `HTML Format` en 284.561. pub equivalents: &'static [&'static [&'static str]], - /// Formatos de documento incrustable. Que estén significa que la fuente es - /// un documento, y entonces la imagen que ofrece es el render y no el - /// contenido. - /// - /// Medido el 14/09/2026: un rango de Excel de 20×2 ofrece 258.380 bytes de - /// `CF_DIBV5` junto a 500 de texto, y clasificarlo como imagen sería - /// guardar una captura de pantalla de unas celdas. macOS lo deja vacío: allí - /// ninguna aplicación de documentos adjunta una imagen de cortesía. pub embeddable: &'static [&'static str], } @@ -90,10 +46,7 @@ impl Catalog { self.aliases .iter() .find(|(legacy, _)| *legacy == id) - .map_or(id, |(_, modern)| { - // El alias apunta a una cadena estática, que vive más que 'a. - *modern - }) + .map_or(id, |(_, modern)| *modern) } pub fn decide(&self, id: &str) -> Take { @@ -110,11 +63,6 @@ impl Catalog { Take::Presence } - /// Si la fuente marcó el contenido como secreto con un tipo dedicado. - /// - /// Se decide por el tipo, nunca por el contenido: hay gestores que ponen - /// el secreto en claro dentro del payload, así que leerlo para decidir ya - /// sería haberlo leído. pub fn refusal(&self, offered: &[&str]) -> Option { offered.iter().find_map(|id| { self.concealed @@ -124,21 +72,14 @@ impl Catalog { }) } - /// Si un marcador de los que se leen pide no registrar. - /// - /// Un marcador presente pero vacío, o más corto de cuatro bytes, no dice - /// nada: se ignora en vez de tomarlo por cero, que sería descartar la copia - /// por no haber sabido leerla. pub fn declines(&self, id: &str, value: &[u8]) -> Option { let marker = self.denied_when_zero.iter().find(|marker| **marker == id)?; let [a, b, c, d, ..] = value else { - return None; + return Some(Refusal::Declined(marker)); }; (u32::from_le_bytes([*a, *b, *c, *d]) == 0).then_some(Refusal::Declined(marker)) } - /// El tipo mostrado es una clasificación sobre el conjunto, nunca una - /// elección que descarte lo demás. pub fn classify(&self, offered: &[&str]) -> Option { let known: Vec<&str> = offered.iter().map(|id| self.canonical(id)).collect(); if known.iter().any(|id| self.files.contains(id)) { @@ -148,9 +89,6 @@ impl Catalog { .iter() .any(|id| self.images_by_preference.contains(id)); let has_text = known.iter().any(|id| self.text.contains(id)); - // Una imagen junto a texto en un documento incrustable es el render de - // ese documento. Sin esa compañía manda la imagen, que es como llega - // del navegador: acompañada de su dirección y sin dejar de ser imagen. let courtesy = has_text && known.iter().any(|id| self.embeddable.contains(id)); if has_image && !courtesy { return Some(Family::Image); @@ -165,8 +103,6 @@ impl Catalog { self.cheapest(self.images_by_preference, offered) } - /// Si `id` lleva el mismo contenido que otro que la fuente ofreció y sale - /// más caro. El barato se guarda; este se anota. pub fn costlier_twin(&self, id: &str, offered: &[&str]) -> bool { let id = self.canonical(id); std::iter::once(self.images_by_preference) @@ -335,33 +271,23 @@ mod tests { } } -/// Lo que Windows necesita y macOS no tuvo que resolver. Cada caso sale de una -/// medición del 14/09/2026 sobre Windows 11 26200. #[cfg(test)] mod windows_needs { use super::tests::PROBE; use super::*; - /// Excel ofrece una imagen del rango junto al texto de las celdas. Sin la - /// señal del documento incrustable, copiar dos celdas se guardaría como una - /// captura de pantalla de 258 KB. #[test] fn a_spreadsheet_is_text_even_when_it_offers_a_picture_of_itself() { let excel = ["plain/text", "cheap/image", "embedded/document"]; assert_eq!(PROBE.classify(&excel), Some(Family::Text)); } - /// Y la regla no puede pasarse de lista: una imagen copiada del navegador - /// llega con su dirección al lado y sigue siendo una imagen. Es el hallazgo - /// 13 de la 2.x, que macOS ya tenía resuelto y no se puede perder. #[test] fn an_image_with_its_address_alongside_is_still_an_image() { let browser = ["plain/text", "cheap/image"]; assert_eq!(PROBE.classify(&browser), Some(Family::Image)); } - /// Un documento incrustable sin texto ninguno no convierte una imagen en - /// otra cosa: no hay texto que mostrar en su lugar. #[test] fn an_embeddable_marker_without_text_does_not_hide_the_image() { assert_eq!( @@ -370,8 +296,6 @@ mod windows_needs { ); } - /// Los archivos siguen ganando a todo, que es lo que hace que copiar en el - /// explorador se vea como archivos y no como el texto de sus rutas. #[test] fn files_still_win_over_everything() { assert_eq!( @@ -380,8 +304,6 @@ mod windows_needs { ); } - /// El mismo contenido en dos envoltorios: se guarda el barato y el caro se - /// anota. Hasta ahora esto solo existía para imágenes. #[test] fn the_costlier_wrapping_of_the_same_text_is_only_noted() { let firefox = ["costly/markup", "cheap/markup", "plain/text"]; @@ -400,7 +322,6 @@ mod windows_needs { assert!(!PROBE.costlier_twin("plain/text", &["plain/text", "cheap/markup"])); } - /// El marcador que se lee: cero pide no registrar, uno lo permite. #[test] fn a_marker_that_says_zero_refuses_the_copy() { assert_eq!( @@ -410,24 +331,23 @@ mod windows_needs { assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0]), None); } - /// No haber sabido leer el marcador no es que el marcador dijera que no. #[test] - fn a_marker_too_short_to_read_decides_nothing() { - assert_eq!(PROBE.declines("may/record", &[]), None); - assert_eq!(PROBE.declines("may/record", &[0]), None); - assert_eq!(PROBE.declines("may/record", &[0, 0, 0]), None); + fn a_marker_too_short_to_read_is_obeyed_anyway() { + for unreadable in [&[][..], &[0][..], &[0, 0, 0][..], &[1, 0, 0][..]] { + assert_eq!( + PROBE.declines("may/record", unreadable), + Some(Refusal::Declined("may/record")), + "{unreadable:?}" + ); + } } - /// Solo los marcadores declarados se leen. Que un formato cualquiera - /// empiece por cuatro ceros no lo convierte en una negativa. #[test] fn only_a_declared_marker_is_read() { assert_eq!(PROBE.declines("plain/text", &[0, 0, 0, 0]), None); assert_eq!(PROBE.declines("secret/marker", &[0, 0, 0, 0]), None); } - /// Un marcador más largo de cuatro bytes se lee por sus cuatro primeros, - /// que es lo que la 2.x hacía y lo que el sistema documenta. #[test] fn a_longer_marker_is_read_by_its_first_four_bytes() { assert_eq!( @@ -437,15 +357,12 @@ mod windows_needs { assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0, 0, 0]), None); } - /// Los cuatro bytes son un entero, no cuatro banderas sueltas. #[test] fn the_four_bytes_are_one_little_endian_number() { assert_eq!(PROBE.declines("may/record", &[0, 0, 0, 1]), None); assert_eq!(PROBE.declines("may/record", &[0, 1, 0, 0]), None); } - /// Cualquiera de los dos caminos basta para no registrar, y cada uno dice - /// cuál fue: el panel tiene que poder nombrar al que lo pidió. #[test] fn either_road_to_a_refusal_names_the_marker() { assert_eq!( @@ -488,16 +405,12 @@ mod properties { } proptest! { - /// Un alias no puede apuntar a otro alias: si el nombre moderno - /// volviera a traducirse, el ítem se guardaría bajo un tercer nombre - /// y la deduplicación dejaría de reconocerlo. #[test] fn canonical_is_idempotent(id in any_id()) { let once = PROBE.canonical(&id).to_string(); prop_assert_eq!(PROBE.canonical(&once), once.as_str()); } - /// Lo que cuelga no se pide jamás, se llame como se llame. #[test] fn what_hangs_is_never_payload(id in any_id()) { if PROBE.hangs.contains(&PROBE.canonical(&id)) { @@ -505,13 +418,11 @@ mod properties { } } - /// Decidir dos veces sobre lo mismo da lo mismo. #[test] fn deciding_is_deterministic(id in any_id()) { prop_assert_eq!(PROBE.decide(&id), PROBE.decide(&id)); } - /// La imagen elegida siempre es una de las que se ofrecieron. #[test] fn the_chosen_image_was_on_offer(ids in prop::collection::vec(any_id(), 0..8)) { let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); @@ -521,7 +432,6 @@ mod properties { } } - /// Clasificar no inventa: si dice que son archivos, había un archivo. #[test] fn a_classification_is_backed_by_a_type(ids in prop::collection::vec(any_id(), 0..8)) { let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); @@ -542,8 +452,6 @@ mod properties { } } - /// De cada grupo de representaciones se guarda exactamente una: nunca - /// las dos, y nunca ninguna cuando el grupo estaba en la oferta. #[test] fn exactly_one_of_each_group_is_kept(ids in prop::collection::vec(any_id(), 0..8)) { let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); @@ -562,8 +470,6 @@ mod properties { } } - /// Un marcador que no dice cero nunca rechaza, y uno que no está - /// declarado no decide nada por mucho que valga cero. #[test] fn only_a_zero_in_a_declared_marker_refuses( id in any_id(), @@ -572,15 +478,15 @@ mod properties { match PROBE.declines(&id, &value) { Some(Refusal::Declined(marker)) => { prop_assert!(PROBE.denied_when_zero.contains(&marker)); - prop_assert!(value.len() >= 4); - prop_assert_eq!(u32::from_le_bytes([value[0], value[1], value[2], value[3]]), 0); + if let [a, b, c, d, ..] = value[..] { + prop_assert_eq!(u32::from_le_bytes([a, b, c, d]), 0); + } } Some(other) => prop_assert!(false, "no es una negativa por valor: {:?}", other), None => {} } } - /// La clase nunca depende del orden en que la fuente enumeró sus tipos. #[test] fn the_class_does_not_depend_on_the_order_offered( ids in prop::collection::vec(any_id(), 0..8), diff --git a/crates/cp-core/src/hash.rs b/crates/cp-core/src/hash.rs index e6ea18e3..991f13aa 100644 --- a/crates/cp-core/src/hash.rs +++ b/crates/cp-core/src/hash.rs @@ -1,27 +1,18 @@ use xxhash_rust::xxh3::xxh3_64; -/// Por debajo de esto se mira el contenido entero; por encima, un muestreo. const WHOLE_UP_TO: usize = 256 * 1024; const BLOCKS: usize = 16; const BLOCK: usize = 4 * 1024; -/// Por debajo del umbral se mira todo, así que el muestreo no puede abarcar -/// más bytes de los que el umbral deja pasar enteros. const _: () = assert!(WHOLE_UP_TO >= BLOCKS * BLOCK); -/// Ambos se alinean a página. const _: () = assert!(BLOCK.is_power_of_two() && WHOLE_UP_TO.is_power_of_two()); -/// Identidad del contenido. El tamaño entra siempre en la mezcla, así que dos -/// payloads de distinta longitud nunca colisionan aunque se muestree lo mismo. pub fn content_hash(bytes: &[u8]) -> u64 { if bytes.len() <= WHOLE_UP_TO { return xxh3_64(bytes); } let mut mixed = Vec::with_capacity(BLOCKS * BLOCK + 8); mixed.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); - // Repartidos por todo el buffer, no al principio: dos capturas de pantalla - // del mismo tamaño comparten cabecera y barra de menús, y un muestreo de - // los primeros bytes las da por idénticas. for block in 0..BLOCKS { let start = bytes.len().saturating_sub(BLOCK) * block / (BLOCKS - 1); let end = (start + BLOCK).min(bytes.len()); @@ -85,12 +76,6 @@ mod tests { ); } - /// El límite conocido, escrito a propósito: dieciséis bloques de 4 KB - /// cubren 64 KB, así que en un buffer de 4 MB se mira el 1,5 %. Un byte - /// que cambie en un hueco entre bloques no se ve. Es aceptable para lo - /// que este hash hace —decir «esto es lo mismo que se acaba de copiar»— - /// y es exactamente la razón por la que los blobs se direccionan con - /// blake3 sobre el contenido completo y no con esto. #[test] fn a_change_between_blocks_is_invisible_and_that_is_the_deal() { let big = vec![0x11; 4 * 1024 * 1024]; diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index 9081636e..fc7d7069 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -1,18 +1,9 @@ -/// Qué se guardó de un formato concreto. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Payload { - /// Cabe en la fila. Inline(Vec), - /// Va a disco, direccionado por contenido. Blob(Vec), - /// Existía y era demasiado grande. Se anota el tamaño y se dice en la - /// tarjeta: nunca un descarte silencioso. TooBig { size: usize }, - /// La fuente lo ofrecía y no se le pidió, porque cuelga o porque - /// derrocha. El tamaño solo se conoce donde la plataforma lo regala. Announced { size: Option }, - /// Se pidió y no entregó nada. Ocurre de verdad: medido el 12/09/2026, - /// `com.apple.linkpresentation.metadata` y `fndf` hacen exactamente esto. Absent, } @@ -22,7 +13,6 @@ pub const BLOB_UP_TO: usize = 64 * 1024 * 1024; const _: () = assert!(INLINE_UP_TO < BLOB_UP_TO); const _: () = assert!(INLINE_UP_TO.is_power_of_two() && BLOB_UP_TO.is_power_of_two()); -/// Dónde acaba un payload, decidido solo por su tamaño. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Placement { Row, @@ -39,7 +29,6 @@ pub fn placement(size: usize) -> Placement { } impl Payload { - /// Decide dónde va lo que ya se leyó. pub fn stored(bytes: Vec) -> Self { match placement(bytes.len()) { Placement::Row => Payload::Inline(bytes), @@ -64,25 +53,17 @@ pub struct Format { pub payload: Payload, } -/// Un ítem guarda **el conjunto** de formatos que la fuente ofreció. El tipo -/// mostrado es una clasificación sobre ese conjunto, nunca una elección que -/// descarte el resto. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Item { pub kind: Option, pub formats: Vec, } -/// Identificador del único formato de un ítem que nadie capturó del sistema. -/// No es un UTI ni un `CF_*`: nombrarlo con el de una plataforma haría que la -/// identidad de un texto sintético dependiera de dónde se ejecuta. pub const SYNTHETIC_TEXT: &str = "text/plain"; +pub const SYNTHETIC_IMAGE: &str = "image/png"; + impl Item { - /// Un ítem de texto que no viene del portapapeles: el que se guarda desde - /// una prueba, un ejemplo o una importación. Su identidad **no** coincide - /// con la del mismo texto capturado de verdad, que llega acompañado de sus - /// otros formatos y con el identificador de la plataforma. pub fn plain(text: &str) -> Self { Self { kind: None, @@ -93,12 +74,6 @@ impl Item { } } - /// Identidad del ítem por lo que **contiene**, no por cómo se muestra. - /// - /// Hashear el texto de vista previa parece equivalente y no lo es: el de - /// una imagen o un archivo es vacío o un nombre corto, así que dos - /// capturas de pantalla distintas darían la misma identidad y la - /// deduplicación tomaría la segunda por repetida. pub fn fingerprint(&self) -> u64 { let mut mixed: Vec = Vec::new(); let mut ordered: Vec<&Format> = self.formats.iter().collect(); @@ -117,13 +92,10 @@ impl Item { crate::hash::content_hash(&mixed) } - /// Si alguno de sus formatos necesita el almacén de blobs, que todavía no - /// existe. pub fn needs_blob_store(&self) -> bool { self.oversized_format().is_some() } - /// El primer formato que no cabe en la fila, con su tamaño. pub fn oversized_format(&self) -> Option<(String, usize)> { self.formats.iter().find_map(|one| match &one.payload { Payload::Blob(bytes) => Some((one.id.clone(), bytes.len())), @@ -135,7 +107,6 @@ impl Item { self.formats.iter().find(|one| one.id == id) } - /// Lo que de verdad ocupa, sin contar lo que no se guardó. pub fn stored_bytes(&self) -> usize { self.formats .iter() @@ -331,21 +302,18 @@ mod properties { } proptest! { - /// Cuanto más grande, más lejos se guarda. Nunca al revés. #[test] fn bigger_never_lands_closer(a in 0usize..usize::MAX, b in 0usize..usize::MAX) { let (small, big) = if a <= b { (a, b) } else { (b, a) }; prop_assert!(rank(placement(small)) <= rank(placement(big))); } - /// Lo que se guarda conserva su tamaño, esté donde esté. #[test] fn the_size_survives_the_decision(len in 0usize..200_000) { let payload = Payload::stored(vec![0u8; len]); prop_assert_eq!(payload.size(), Some(len)); } - /// Solo lo que de verdad se guardó cuenta para el peso del ítem. #[test] fn only_real_bytes_are_counted(lens in prop::collection::vec(0usize..2000, 0..12)) { let expected: usize = lens.iter().sum(); diff --git a/crates/cp-core/src/kind.rs b/crates/cp-core/src/kind.rs index d5ae4217..4995d5a8 100644 --- a/crates/cp-core/src/kind.rs +++ b/crates/cp-core/src/kind.rs @@ -1,9 +1,3 @@ -/// Lo que un ítem es, a ojos del producto. -/// -/// La 2.x distingue trece tipos y de ellos vive el filtro por pestañas, así -/// que la clasificación fina no es un adorno: perderla sería perder una vista -/// entera de la interfaz. Se hereda la lista y se le añade el código, que la -/// 2.x no reconoce. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { Text, @@ -43,8 +37,6 @@ impl Kind { } } -/// Afina un texto plano. El orden importa: lo más específico primero, y las -/// formas de una sola línea antes que las que admiten varias. pub fn classify_text(content: &str) -> Kind { let text = content.trim(); if text.is_empty() { @@ -81,7 +73,6 @@ pub fn classify_text(content: &str) -> Kind { Kind::Text } -/// Un archivo, por su extensión. Una carpeta lo dice quien la lee. pub fn classify_file(name: &str, is_directory: bool) -> Kind { if is_directory { return Kind::Folder; @@ -116,9 +107,6 @@ fn is_email(text: &str) -> bool { let Some((user, host)) = text.split_once('@') else { return false; }; - // Sin esta guarda `@ejemplo.test` pasaría: `all` sobre un usuario vacío - // devuelve cierto. Las otras dos que hubo aquí —dominio corto y segunda - // arroba— las cubren `host_ok` y `tld_ok`; `the_guards_that_were_here_were_redundant` lo prueba. if user.is_empty() { return false; } @@ -218,9 +206,6 @@ fn is_json(text: &str) -> bool { balanced(text) } -/// Sin traer un analizador entero: se comprueba que los delimitadores cierran -/// y que las comillas están emparejadas, que es lo que separa un objeto real -/// de un texto que empieza por llave. fn balanced(text: &str) -> bool { let mut stack = Vec::new(); let mut in_string = false; @@ -246,8 +231,6 @@ fn balanced(text: &str) -> bool { stack.is_empty() && !in_string } -/// Heurística deliberadamente conservadora: es mejor llamar texto a un -/// fragmento de código que llamar código a una frase. fn looks_like_code(text: &str) -> bool { const MARKERS: &[&str] = &[ "fn ", @@ -410,9 +393,6 @@ mod tests { } } -/// Cada aserción de aquí abajo mató un mutante que sobrevivía a la batería -/// anterior: la clase se reconocía, pero ninguna prueba distinguía el borde de -/// la condición que la reconoce. #[cfg(test)] mod borders { use super::*; @@ -543,10 +523,6 @@ mod redundancy { } proptest! { - /// Dos mutantes sobrevivían a toda la batería porque las guardas que - /// mataban eran inalcanzables: un dominio de menos de tres caracteres - /// no puede tener a la vez nombre y extensión válidos, y una segunda - /// arroba cae siempre en la parte que `host_ok` o `tld_ok` rechazan. #[test] fn the_guards_that_were_here_were_redundant(text in ".{0,40}") { prop_assert_eq!(is_email(&text), with_the_old_guards(&text)); @@ -563,14 +539,10 @@ mod redundancy { } } -/// Lo que la 2.x reconocía en Windows y la 3.0 había dejado fuera, más los -/// bordes que solo aparecen con rutas de Windows delante. #[cfg(test)] mod inherited_from_2x { use super::*; - /// Las tres clases que la tabla de la 2.x tenía y esta no: dos extensiones - /// y un esquema. Perderlas era degradar a un usuario que actualiza. #[test] fn the_three_the_rewrite_had_dropped() { assert_eq!(classify_file("video.flv", false), Kind::Video); @@ -578,8 +550,6 @@ mod inherited_from_2x { assert_eq!(classify_text("mailto:alguien@ejemplo.test"), Kind::Link); } - /// Y las que la 3.0 añadió sobre la tabla de la 2.x no se pierden al - /// traerlas de vuelta. #[test] fn what_the_rewrite_added_survives() { for (name, kind) in [ @@ -594,22 +564,17 @@ mod inherited_from_2x { } } - /// Un esquema sin nada detras no es una direccion, tampoco el de correo. #[test] fn a_bare_mail_scheme_is_not_a_link() { assert_eq!(classify_text("mailto:"), Kind::Text); assert_eq!(classify_text("MAILTO:alguien@ejemplo.test"), Kind::Link); } - /// Una direccion de correo suelta sigue siendo un correo y no un enlace: - /// son dos clases distintas y el filtro por pestañas las separa. #[test] fn an_address_without_the_scheme_is_still_an_address() { assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); } - /// Las rutas de Windows llevan otro separador y otra forma. La extension - /// es siempre la del ultimo tramo, aunque la carpeta tenga un punto. #[test] fn a_windows_path_is_classified_by_its_last_segment() { for (path, kind) in [ @@ -623,14 +588,12 @@ mod inherited_from_2x { } } - /// Una carpeta lo es aunque su nombre termine en algo que parece extension. #[test] fn a_folder_named_like_a_file_is_still_a_folder() { assert_eq!(classify_file(r"C:\copias\respaldo.zip", true), Kind::Folder); assert_eq!(classify_file("fotos.png", true), Kind::Folder); } - /// Un archivo que es solo extension no tiene extension. #[test] fn a_name_that_is_only_a_dot_has_no_extension() { assert_eq!(classify_file(".png", false), Kind::Image); @@ -638,7 +601,6 @@ mod inherited_from_2x { assert_eq!(classify_file("", false), Kind::File); } - /// La ruta local que Windows entrega en CF_HDROP no es una direccion web. #[test] fn a_local_windows_path_is_not_a_link() { assert_eq!(classify_text(r"C:\Users\ana\documento.txt"), Kind::Text); diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs index df19bbb6..f3d81b5e 100644 --- a/crates/cp-core/src/paste.rs +++ b/crates/cp-core/src/paste.rs @@ -8,8 +8,6 @@ pub enum Phase { Send, } -/// El portapapeles se escribe antes que nada que toque el foco: así el peor -/// resultado posible sigue siendo «está en tu portapapeles, pégalo tú». pub const ORDER: &[Phase] = &[ Phase::WriteClipboard, Phase::HidePanel, @@ -28,12 +26,12 @@ pub enum Focus { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Failure { - /// El destino no está al frente, y sin eso no hay pegado posible. NotForeground, ForegroundTimeout, NoKeyboardFocus, TargetGone, SendDenied, + TargetElevated, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -76,27 +74,54 @@ impl Attempt { } } -/// Regla ganada en campo: lo desconocido pega. Solo una respuesta positiva de -/// que el foco está en otro sitio justifica abortar. pub fn aborts(focus: Focus) -> bool { focus == Focus::Elsewhere } -/// Secure Input no se traga los eventos sintéticos: lo que rompe es la -/// activación. Abortar dejaría sin pegar justo a quien tiene el flag pegado -/// por una aplicación ajena, que es a quien la comprobación pretendía ayudar. pub fn aborts_on(_warning: Warning) -> bool { false } -/// Medido el 12/09/2026 en macOS 26.6.2: ni `CGEventPostToPid` ni `AXPress` -/// entregan a una aplicación que no está al frente. pub const REQUIRES_FOREGROUND: bool = true; +impl Failure { + pub fn is_permanent(self) -> bool { + matches!(self, Failure::TargetElevated) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn an_elevated_target_is_never_retried() { + let mut attempt = Attempt::default(); + assert_eq!(attempt.on_failure(Failure::TargetElevated), Next::Degrade); + assert_eq!(attempt.on_failure(Failure::TargetElevated), Next::Degrade); + assert!(Failure::TargetElevated.is_permanent()); + } + + #[test] + fn what_a_second_try_could_fix_is_not_permanent() { + for failure in [ + Failure::NotForeground, + Failure::ForegroundTimeout, + Failure::NoKeyboardFocus, + ] { + assert!(!failure.is_permanent(), "{failure:?}"); + assert_eq!(Attempt::default().on_failure(failure), Next::Retry); + } + } + + #[test] + fn a_target_that_is_gone_is_not_worth_retrying_either() { + assert_eq!( + Attempt::default().on_failure(Failure::TargetGone), + Next::Degrade + ); + } + #[test] fn the_clipboard_is_written_before_anything_touches_focus() { let write = ORDER.iter().position(|p| *p == Phase::WriteClipboard); @@ -178,7 +203,6 @@ mod properties { } proptest! { - /// Por muchos fallos que lleguen, el número de reintentos tiene techo. #[test] fn the_retries_are_bounded(failures in prop::collection::vec(any_failure(), 1..40)) { let mut attempt = Attempt::default(); @@ -189,8 +213,6 @@ mod properties { prop_assert!(retried <= RACE_RETRIES as usize, "reintentó {retried} veces"); } - /// Después de un envío con éxito no hay nada que reintentar, venga el - /// fallo que venga: un pegado doble es peor que ninguno. #[test] fn nothing_is_retried_after_a_send(failures in prop::collection::vec(any_failure(), 1..10)) { let mut attempt = Attempt::default(); @@ -200,7 +222,6 @@ mod properties { } } - /// Un envío denegado es UIPI o TCC: reintentar no cambia nada. #[test] fn a_denial_never_becomes_a_retry(before in prop::collection::vec(any_failure(), 0..3)) { let mut attempt = Attempt::default(); @@ -210,8 +231,6 @@ mod properties { prop_assert_eq!(attempt.on_failure(Failure::SendDenied), Next::Degrade); } - /// El portapapeles se escribe antes que cualquier fase que toque el - /// foco, y enviar es siempre lo último. #[test] fn the_order_never_puts_the_send_before_a_check(index in 0usize..ORDER.len()) { if ORDER[index] == Phase::Send { diff --git a/crates/cp-core/src/thumbnail.rs b/crates/cp-core/src/thumbnail.rs index 5ce56f52..2361e091 100644 --- a/crates/cp-core/src/thumbnail.rs +++ b/crates/cp-core/src/thumbnail.rs @@ -1,5 +1,3 @@ -/// El lado mayor de una miniatura. La tarjeta del panel no necesita más, y -/// cada píxel de sobra se paga en disco y en tiempo de scroll. pub const MAX_SIDE: u32 = 256; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -8,11 +6,6 @@ pub struct Size { pub height: u32, } -/// Las dimensiones de una imagen **sin decodificarla entera**. -/// -/// Leer la cabecera cuesta microsegundos; decodificar una captura de pantalla -/// de 5K cuesta bastante más, y para pintar «2880×1800» en la tarjeta no hace -/// falta ningún píxel. pub fn size_of(bytes: &[u8]) -> Option { let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() @@ -21,15 +14,8 @@ pub fn size_of(bytes: &[u8]) -> Option { Some(Size { width, height }) } -/// Una miniatura en PNG, con la proporción intacta. -/// -/// Devuelve `None` si el formato no se reconoce. Una imagen ya pequeña se -/// devuelve reescalada igualmente, para que todas las miniaturas pesen y se -/// dibujen de forma parecida. pub fn of_image(bytes: &[u8], max_side: u32) -> Option> { let decoded = image::load_from_memory(bytes).ok()?; - // `thumbnail` **amplía** si la imagen es menor que el destino, y una - // miniatura mayor que su original ocuparía más y se vería peor. let longest_side = decoded.width().max(decoded.height()); let scaled = if longest_side > max_side { decoded.thumbnail(max_side, max_side) @@ -152,11 +138,6 @@ mod tests { ); } - /// Centinela de la exclusión declarada en `mutants.toml`: cambiar `>` por - /// `>=` en `of_image` no se puede distinguir porque, con un lado igual al - /// máximo, el ratio interno de `thumbnail` es exactamente 1. Si una versión - /// futura de `image` dejara de cumplirlo, esta prueba cae y la exclusión deja - /// de estar justificada. #[test] fn scaling_to_the_size_it_already_has_changes_nothing() { use image::{DynamicImage, RgbaImage}; diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index 4935991f..8a1d8f3e 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -1,39 +1,16 @@ -/// Lo que el vigilante ve en un sondeo. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Seen { - /// El contador no se movió. Nothing, - /// Se movió por nuestra propia escritura. Ours, - /// Hay contenido nuevo. `skipped` son las copias que ocurrieron entre este - /// sondeo y el anterior y que ya no se pueden recuperar: el sistema solo - /// guarda la última. No se ignoran, se cuentan —y donde el contador no - /// permite contarlas, se dice que no se sabe en vez de decir cero. Fresh { skipped: Option }, } -/// Qué significa un salto del contador, que no es lo mismo en cada sistema. -/// -/// Elegir mal es contar copias perdidas que nunca ocurrieron, así que el -/// vigilante no tiene valor por defecto: quien lo construye lo declara. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Cadence { - /// Una unidad por copia. Medido el 12/09/2026 en macOS 26.6.2: el - /// `changeCount` sube exactamente de uno en uno por escritura, incluso con - /// cinco escrituras separadas por 5 ms, así que un salto mayor que uno son - /// copias que no se vieron. OnePerCopy, - /// Una cantidad que depende de cuántos formatos sintetiza el sistema y de - /// si quien copió pasó por OLE. Medido el 14/09/2026 en Windows 11 26200: - /// `GetClipboardSequenceNumber` sube **+5** al escribir `CF_UNICODETEXT` - /// directamente, **+12** con el mismo texto a través de OLE, **+11** con - /// `CF_HDROP` y **+9** con una imagen. El salto dice que hubo cambio; - /// cuántas copias hubo no lo dice. Opaque, } -/// Sondea el contador de secuencia del portapapeles —`changeCount` en macOS, -/// `GetClipboardSequenceNumber` en Windows— y decide qué ha pasado. #[derive(Debug)] pub struct Watcher { last: Option, @@ -52,16 +29,12 @@ impl Watcher { } } - /// Registra el contador que dejó nuestra propia escritura, para - /// descartar exactamente esa y no una ventana de tiempo alrededor. pub fn wrote(&mut self, count: i64) { self.ours = Some(count); } pub fn tick(&mut self, count: i64) -> Seen { let Some(last) = self.last else { - // El primer sondeo solo fija el punto de partida: lo que hubiera - // antes de arrancar no es una copia que hayamos perdido. self.last = Some(count); return Seen::Nothing; }; @@ -69,13 +42,11 @@ impl Watcher { return Seen::Nothing; } self.last = Some(count); - if self.ours == Some(count) { - self.ours = None; + let ours = self.ours.take(); + if ours == Some(count) { return Seen::Ours; } let skipped = match self.cadence { - // El contador retrocede al reiniciarse la sesión de ventanas, y eso - // no es una ráfaga de copias perdidas: el `max` deja ese caso en cero. Cadence::OnePerCopy => { let skipped = count.saturating_sub(last).saturating_sub(1).max(0) as u64; self.missed = self.missed.saturating_add(skipped); @@ -86,8 +57,6 @@ impl Watcher { Seen::Fresh { skipped } } - /// Cuántas copias se sabe que ocurrieron y no se pudieron capturar. - /// `None` donde el contador de la plataforma no permite saberlo. pub fn missed(&self) -> Option { match self.cadence { Cadence::OnePerCopy => Some(self.missed), @@ -112,7 +81,6 @@ mod tests { fn the_counter_at_its_limits_does_not_overflow() { let mut watcher = mac(); watcher.tick(i64::MIN); - // La resta de los dos extremos no cabe en i64. let seen = watcher.tick(i64::MAX); assert!(matches!(seen, Seen::Fresh { .. })); @@ -208,8 +176,6 @@ mod tests { } } -/// El contador de Windows sube una cantidad que no es el número de copias. -/// Medido el 14/09/2026 en Windows 11 26200 con `GetClipboardSequenceNumber`. #[cfg(test)] mod windows_counter { use super::*; @@ -218,8 +184,6 @@ mod windows_counter { Watcher::new(Cadence::Opaque) } - /// El salto medido de una copia de texto corriente. Con la cadencia de - /// macOS, esa copia se anotaría como cuatro pérdidas que nunca existieron. #[test] fn one_ordinary_copy_is_not_a_burst_of_four() { let mut watcher = windows(); @@ -236,8 +200,6 @@ mod windows_counter { ); } - /// Los cuatro saltos medidos, uno por forma de copiar. Ninguno vale uno, y - /// entre ellos no hay proporción: por eso el número no se interpreta. #[test] fn every_measured_jump_is_a_single_copy() { for jump in [5, 9, 11, 12] { @@ -251,11 +213,8 @@ mod windows_counter { } } - /// El contador vale cero cuando el proceso no alcanza la estación de - /// ventanas —escritorio seguro, pantalla de bloqueo—. Volver de ahí es un - /// salto enorme desde cero, y no una ráfaga de mil copias. #[test] - fn coming_back_from_a_locked_desktop_is_one_copy() { + fn a_counter_that_jumps_backwards_still_reports_each_poll() { let mut watcher = windows(); watcher.tick(1000); assert_eq!(watcher.tick(0), Seen::Fresh { skipped: None }); @@ -263,8 +222,6 @@ mod windows_counter { assert_eq!(watcher.missed(), None); } - /// Lo que no depende de la cadencia: nuestra propia escritura se descarta - /// por el valor exacto, y la copia que llega justo después no se traga. #[test] fn our_own_write_is_still_discarded_exactly() { let mut watcher = windows(); @@ -281,8 +238,6 @@ mod windows_counter { assert_eq!(watcher.tick(7), Seen::Nothing); } - /// El vigilante dice con qué cadencia se construyó: el diagnóstico tiene - /// que poder distinguir «no hubo pérdidas» de «no se pueden contar». #[test] fn the_watcher_says_which_counter_it_is_reading() { assert_eq!(windows().cadence(), Cadence::Opaque); @@ -298,15 +253,11 @@ mod properties { use super::*; use proptest::prelude::*; - /// Una secuencia de sondeos del contador. fn ticks() -> impl Strategy> { prop::collection::vec(0i64..40, 1..60) } proptest! { - /// Nada se pierde ni se inventa. Cada evento emitido corresponde a un - /// avance del contador o a un retroceso —que es una sesión nueva, no - /// una ráfaga—, y lo que avanzó sin emitirse está contado como perdido. #[test] fn every_change_is_either_seen_or_counted(seq in ticks()) { let mut watcher = Watcher::new(Cadence::OnePerCopy); @@ -317,8 +268,6 @@ mod properties { } } - // El primer sondeo solo fija la marca: lo anterior a arrancar no - // es una copia perdida. let mut climbed: i64 = 0; let mut restarts: u64 = 0; for pair in seq.windows(2) { @@ -332,8 +281,6 @@ mod properties { prop_assert_eq!(climbed as u64 + restarts, emitted + watcher.missed().unwrap()); } - /// Un contador que no avanza nunca produce un evento, se cuente como - /// se cuente. #[test] fn a_still_counter_never_fires( start in 0i64..1000, @@ -349,7 +296,6 @@ mod properties { prop_assert_eq!(watcher.missed().unwrap_or(0), 0); } - /// Lo perdido solo puede crecer. #[test] fn what_was_missed_never_shrinks(seq in ticks()) { let mut watcher = Watcher::new(Cadence::OnePerCopy); @@ -361,7 +307,6 @@ mod properties { } } - /// Un contador opaco no inventa una cifra jamás, salte lo que salte. #[test] fn an_opaque_counter_never_invents_a_number( seq in prop::collection::vec(0i64..100_000, 1..40), @@ -375,8 +320,6 @@ mod properties { } } - /// Las dos cadencias ven **los mismos eventos**: lo único que cambia es - /// si se puede decir cuántas copias se perdieron, nunca si hubo copia. #[test] fn the_cadence_changes_the_count_never_the_event(seq in ticks()) { let mut counted = Watcher::new(Cadence::OnePerCopy); @@ -393,8 +336,6 @@ mod properties { } } - /// Solo el contador exacto que registramos se descarta como nuestro, y - /// solo una vez. #[test] fn only_the_exact_registered_count_is_ours(start in 0i64..1000, gap in 1i64..10) { let mut watcher = Watcher::new(Cadence::OnePerCopy); @@ -402,19 +343,17 @@ mod properties { watcher.wrote(start + gap); let landed = watcher.tick(start + gap); prop_assert_eq!(landed, Seen::Ours); - // El mismo valor otra vez ya no es nuestro: es que alguien copió. prop_assert_eq!(watcher.tick(start + gap), Seen::Nothing); prop_assert_eq!(watcher.tick(start + gap + 1), Seen::Fresh { skipped: Some(0) }); } - /// Registrar una escritura que nunca llega no puede tragarse una copia - /// ajena posterior. #[test] fn a_write_that_never_lands_swallows_nothing(start in 0i64..1000) { let mut watcher = Watcher::new(Cadence::OnePerCopy); watcher.tick(start); watcher.wrote(start + 99); prop_assert_eq!(watcher.tick(start + 1), Seen::Fresh { skipped: Some(0) }); + prop_assert_ne!(watcher.tick(start + 99), Seen::Ours); } } } diff --git a/crates/cp-mac-sys/build.rs b/crates/cp-mac-sys/build.rs index 1ba2c03e..edfab489 100644 --- a/crates/cp-mac-sys/build.rs +++ b/crates/cp-mac-sys/build.rs @@ -1,6 +1,4 @@ fn main() { - // Sin la guarda, `cargo test --workspace` en Windows falla antes de - // compilar una sola linea del nucleo: los frameworks solo existen en Apple. if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { return; } diff --git a/crates/cp-mac-sys/src/frontmost.rs b/crates/cp-mac-sys/src/frontmost.rs index e515d6bd..d512718d 100644 --- a/crates/cp-mac-sys/src/frontmost.rs +++ b/crates/cp-mac-sys/src/frontmost.rs @@ -1,11 +1,5 @@ use objc2_app_kit::NSWorkspace; -/// Quién está al frente ahora mismo. -/// -/// Es la **única** sonda válida para saber si le hemos robado el primer plano -/// al destino: medido el 12/09/2026, `NSApplication::isActive` pasa a `true` -/// con un panel no-activador visible, y `AXFocusedApplication` también nos -/// señala, mientras que esta sigue devolviendo la aplicación de destino. pub fn frontmost() -> Option<(i32, Option)> { let app = NSWorkspace::sharedWorkspace().frontmostApplication()?; let pid = app.processIdentifier(); @@ -13,19 +7,11 @@ pub fn frontmost() -> Option<(i32, Option)> { Some((pid, bundle)) } -/// El nombre que el usuario ve, no el identificador. -/// -/// Es «Safari», no «com.apple.Safari». Guardar el bundle y enseñárselo a -/// alguien que escribe «safari» en el buscador sería no encontrarlo. pub fn app_name(pid: i32) -> Option { let app = NSRunningApplication::runningApplicationWithProcessIdentifier(pid)?; app.localizedName().map(|name| name.to_string()) } -/// Si las rutas de un ítem de archivos siguen existiendo. -/// -/// Copiar la ruta de algo que después se mueve o se borra es corriente, y la -/// diferencia entre un historial útil y uno que miente es avisar de ello. pub fn missing_paths(file_urls: &str) -> Vec { file_urls .lines() @@ -41,7 +27,6 @@ pub fn missing_paths(file_urls: &str) -> Vec { .collect() } -/// Las rutas llegan con los espacios y los acentos escapados. fn percent_decoded(text: &str) -> String { let bytes = text.as_bytes(); let mut out: Vec = Vec::with_capacity(bytes.len()); @@ -61,29 +46,18 @@ fn percent_decoded(text: &str) -> String { String::from_utf8_lossy(&out).into_owned() } -/// El pid de este proceso, para que el seguidor sepa a quién ignorar. pub fn our_pid() -> i32 { std::process::id() as i32 } use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; -/// Trae al frente la aplicación con ese pid. -/// -/// El valor que devuelve la API **no es de fiar**: medido el 12/09/2026, con -/// Secure Input activo devuelve `true` y la aplicación no pasa al frente. Se -/// devuelve igualmente por si sirve de pista, pero quien llama tiene que -/// comprobar el resultado mirando quién está al frente de verdad. pub fn bring_to_front(pid: i32) -> bool { let Some(app) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid) else { return false; }; - // `ActivateAllWindows` es lo que hace `open -a` y lo que la 2.x pedía: - // traer la aplicación entera, no solo su ventana principal. app.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows) } -/// Si el proceso sigue vivo. Un destino que se cerró entre la captura y el -/// pegado no es un fallo de foco, y confundirlos da un diagnóstico inútil. pub fn is_alive(pid: i32) -> bool { NSRunningApplication::runningApplicationWithProcessIdentifier(pid).is_some() } diff --git a/crates/cp-mac-sys/src/keyboard.rs b/crates/cp-mac-sys/src/keyboard.rs index e5f09422..626e6407 100644 --- a/crates/cp-mac-sys/src/keyboard.rs +++ b/crates/cp-mac-sys/src/keyboard.rs @@ -1,12 +1,3 @@ -//! Qué tecla física hay que presionar para que salga una letra concreta. -//! -//! El keycode `0x09` es la posición de la V en QWERTY. Medido y corregido en -//! el expediente: AZERTY, Colemak y los layouts no latinos funcionan igual -//! porque la capa de Comando conmuta a una distribución latina, y **solo -//! Dvorak plano falla**. La resolución correcta no es «busca el keycode que -//! produce V» sino «busca el que la produce **con Command pulsado**», que -//! cubre los cinco casos de una vez, incluido Dvorak-QWERTY ⌘. - use std::ffi::c_void; type CFStringRef = *const c_void; @@ -14,8 +5,7 @@ type CFDataRef = *const c_void; type CFArrayRef = *const c_void; type TISInputSourceRef = *const c_void; -// SAFETY: firmas de Carbon y CoreFoundation tal como las declaran sus -// cabeceras. `kTISPropertyUnicodeKeyLayoutData` es una constante global. +// SAFETY: Carbon and CoreFoundation signatures as declared in their headers; `kTISPropertyUnicodeKeyLayoutData` is a global constant. unsafe extern "C" { static kTISPropertyUnicodeKeyLayoutData: CFStringRef; fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef; @@ -46,18 +36,14 @@ unsafe extern "C" { const ACTION_DOWN: u16 = 0; const NO_DEAD_KEYS: u32 = 1; -/// `UCKeyTranslate` quiere los modificadores desplazados ocho bits, así que -/// `cmdKey` (0x0100) llega como 1. const COMMAND_DOWN: u32 = 1; const HIGHEST_KEYCODE: u16 = 127; -/// Guarda vivo el layout mientras se consulta. struct Layout { source: TISInputSourceRef, bytes: *const u8, } -/// Los identificadores de los layouts que el expediente manda probar. pub const DVORAK: &str = "com.apple.keylayout.Dvorak"; pub const DVORAK_COMMAND_QWERTY: &str = "com.apple.keylayout.DVORAK-QWERTYCMD"; pub const AZERTY: &str = "com.apple.keylayout.French"; @@ -69,13 +55,13 @@ pub const ABC: &str = "com.apple.keylayout.ABC"; const UTF8: u32 = 0x0800_0100; fn source_id(source: TISInputSourceRef) -> Option { - // SAFETY: `source` viene de la lista del sistema y la clave es constante. + // SAFETY: `source` comes from the system list and the key is a constant. let value = unsafe { TISGetInputSourceProperty(source, kTISPropertyInputSourceID) }; if value.is_null() { return None; } let mut buffer = [0u8; 256]; - // SAFETY: el buffer existe y se declara su tamaño real. + // SAFETY: the buffer exists and its real size is declared. let ok = unsafe { CFStringGetCString(value, buffer.as_mut_ptr(), buffer.len() as isize, UTF8) }; if !ok { return None; @@ -84,42 +70,39 @@ fn source_id(source: TISInputSourceRef) -> Option { String::from_utf8(buffer[..end].to_vec()).ok() } -/// Los layouts de teclado instalados, por su identificador. pub fn installed_layouts() -> Vec { - // SAFETY: pasar null pide la lista completa; devuelve +1 y se libera abajo. + // SAFETY: null asks for the whole list; returns +1, released below. let list = unsafe { TISCreateInputSourceList(std::ptr::null(), true) }; if list.is_null() { return Vec::new(); } - // SAFETY: `list` no es nulo. + // SAFETY: `list` is non-null. let count = unsafe { CFArrayGetCount(list) }; let mut found = Vec::new(); for index in 0..count { - // SAFETY: el índice está dentro del rango que acaba de devolver. + // SAFETY: the index is within the range just returned. let source = unsafe { CFArrayGetValueAtIndex(list, index) }; if let Some(id) = source_id(source) { found.push(id); } } - // SAFETY: `list` vino de una función Create, así que hay que soltarlo. + // SAFETY: `list` came from a Create function, so it must be released. unsafe { CFRelease(list) }; found } impl Layout { - /// Un layout concreto por su identificador, **sin activarlo**: así se - /// puede comprobar Dvorak sin tocarle el teclado a nadie. fn named(wanted: &str) -> Option { - // SAFETY: pasar null pide la lista completa; devuelve +1. + // SAFETY: null asks for the whole list; returns +1. let list = unsafe { TISCreateInputSourceList(std::ptr::null(), true) }; if list.is_null() { return None; } - // SAFETY: `list` no es nulo. + // SAFETY: `list` is non-null. let count = unsafe { CFArrayGetCount(list) }; let mut chosen = None; for index in 0..count { - // SAFETY: el índice está dentro del rango devuelto. + // SAFETY: the index is within the returned range. let source = unsafe { CFArrayGetValueAtIndex(list, index) }; if source_id(source).as_deref() == Some(wanted) { chosen = Some(source); @@ -127,26 +110,26 @@ impl Layout { } } let result = chosen.and_then(|source| { - // SAFETY: `source` pertenece al array, que sigue vivo aquí. + // SAFETY: `source` belongs to the array, still alive here. let data = unsafe { TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) }; if data.is_null() { return None; } - // SAFETY: `data` no es nulo mientras el array viva. + // SAFETY: `data` is non-null while the array lives. let bytes = unsafe { CFDataGetBytePtr(data) }; (!bytes.is_null()).then_some((source, bytes)) }); match result { Some((source, bytes)) => { - // SAFETY: se retiene el source para que sobreviva al array. + // SAFETY: the source is retained so it outlives the array. unsafe { CFRetain(source) }; - // SAFETY: el array ya no hace falta. + // SAFETY: the array is no longer needed. unsafe { CFRelease(list) }; Some(Self { source, bytes }) } None => { - // SAFETY: el array vino de una función Create. + // SAFETY: the array came from a Create function. unsafe { CFRelease(list) }; None } @@ -154,22 +137,22 @@ impl Layout { } fn current() -> Option { - // SAFETY: devuelve una referencia con +1 que este tipo libera al caer. + // SAFETY: returns a +1 reference that this type releases on drop. let source = unsafe { TISCopyCurrentKeyboardLayoutInputSource() }; if source.is_null() { return None; } - // SAFETY: `source` no es nulo y la clave es la constante del sistema. + // SAFETY: `source` is non-null and the key is the system constant. let data = unsafe { TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) }; if data.is_null() { - // SAFETY: `source` vino de una función Copy, así que hay que soltarlo. + // SAFETY: `source` came from a Copy function, so it must be released. unsafe { CFRelease(source) }; return None; } - // SAFETY: `data` no es nulo y pertenece al input source, que sigue vivo. + // SAFETY: `data` is non-null and belongs to the input source, still alive. let bytes = unsafe { CFDataGetBytePtr(data) }; if bytes.is_null() { - // SAFETY: mismo motivo que arriba. + // SAFETY: same reason as above. unsafe { CFRelease(source) }; return None; } @@ -180,8 +163,7 @@ impl Layout { let mut dead_keys: u32 = 0; let mut produced: usize = 0; let mut buffer = [0u16; 4]; - // SAFETY: el layout sigue vivo, el buffer tiene el tamaño que se - // declara, y los dos punteros de salida apuntan a locales válidas. + // SAFETY: the layout is alive, the buffer has the declared size, and both out pointers target valid locals. let status = unsafe { UCKeyTranslate( self.bytes, @@ -207,21 +189,16 @@ impl Layout { impl Drop for Layout { fn drop(&mut self) { - // SAFETY: `source` vino de `TISCopyCurrentKeyboardLayoutInputSource`, - // que entrega +1, y no se ha liberado antes. + // SAFETY: `source` came from `TISCopyCurrentKeyboardLayoutInputSource` at +1 and has not been released. unsafe { CFRelease(self.source) }; } } fn keyboard_type() -> u8 { - // SAFETY: la función no toma argumentos y devuelve un entero. + // SAFETY: the function takes no arguments and returns an integer. unsafe { LMGetKbdType() } } -/// El keycode que produce `wanted` con Command pulsado, en el layout activo. -/// -/// Devuelve `None` si el layout no puede producir esa letra, y entonces el -/// llamante debe quedarse con el keycode físico de QWERTY antes que no pegar. pub fn keycode_with_command(wanted: char) -> Option { let layout = Layout::current()?; (0..=HIGHEST_KEYCODE).find(|code| { @@ -231,8 +208,6 @@ pub fn keycode_with_command(wanted: char) -> Option { }) } -/// Lo mismo, en un layout concreto, sin activarlo. Devuelve `None` si ese -/// layout no está instalado. pub fn keycode_with_command_in(layout: &str, wanted: char) -> Option { let layout = Layout::named(layout)?; (0..=HIGHEST_KEYCODE).find(|code| { @@ -242,5 +217,4 @@ pub fn keycode_with_command_in(layout: &str, wanted: char) -> Option { }) } -/// Lo que se usa si no se puede resolver nada: la posición de la V en QWERTY. pub const QWERTY_V: u16 = 0x09; diff --git a/crates/cp-mac-sys/src/keystroke.rs b/crates/cp-mac-sys/src/keystroke.rs index a9382547..e95b19a0 100644 --- a/crates/cp-mac-sys/src/keystroke.rs +++ b/crates/cp-mac-sys/src/keystroke.rs @@ -1,11 +1,9 @@ -//! Enviar ⌘V, con los tres detalles que deciden si llega o no. - use std::ffi::c_void; type CGEventSourceRef = *const c_void; type CGEventRef = *const c_void; -// SAFETY: firmas de CoreGraphics tal como las declara su cabecera. +// SAFETY: CoreGraphics signatures as declared in its header. unsafe extern "C" { fn CGEventSourceCreate(state_id: i32) -> CGEventSourceRef; fn CGEventSourceFlagsState(state_id: i32) -> u64; @@ -24,28 +22,18 @@ unsafe extern "C" { fn CFRelease(item: *const c_void); } -/// `kCGEventSourceStateCombinedSessionState`. const COMBINED_SESSION: i32 = 0; -/// `kCGHIDEventTap`: entra lo más cerca posible del hardware, que es lo más -/// compatible aunque también lo más visible para otras utilidades. const HID_TAP: u32 = 0; const MASK_COMMAND: u64 = 0x0010_0000; -/// El bit device-dependent del ⌘ izquierdo. Hay aplicaciones que lo exigen. const LEFT_COMMAND: u64 = 0x0000_0008; const PERMIT_ALL: u32 = 3; const SUPPRESSION_INTERVAL: i32 = 0; -/// Las teclas modificadoras que están **físicamente** pulsadas ahora. -/// -/// Se pregunta a la fuente de eventos y no al teclado de la aplicación: un -/// atajo global llega sin pasar por ella, así que preguntarle da una respuesta -/// ciega. Es el hallazgo 22. pub fn physical_modifiers() -> u64 { - // SAFETY: la función solo lee el estado global de modificadores. + // SAFETY: the function only reads the global modifier state. unsafe { CGEventSourceFlagsState(COMBINED_SESSION) } } -/// Si hay algún modificador del atajo todavía pulsado. pub fn modifiers_still_held() -> bool { const ANY: u64 = 0x000e_0000; physical_modifiers() & ANY != 0 @@ -57,12 +45,12 @@ pub struct Keystroke { impl Keystroke { pub fn new() -> Option { - // SAFETY: devuelve +1 y este tipo lo libera al caer. + // SAFETY: returns +1 and this type releases it on drop. let source = unsafe { CGEventSourceCreate(COMBINED_SESSION) }; if source.is_null() { return None; } - // SAFETY: `source` acaba de comprobarse no nulo. + // SAFETY: `source` was just checked non-null. unsafe { CGEventSourceSetLocalEventsFilterDuringSuppressionState( source, @@ -73,13 +61,8 @@ impl Keystroke { Some(Self { source }) } - /// Manda la tecla con Command. Entre presionar y soltar van 9 ms: con - /// separación cero, Chromium deduplica y el pegado «a veces no funciona». pub fn command(&self, keycode: u16) -> bool { let flags = MASK_COMMAND | LEFT_COMMAND; - // Los dos eventos se crean **antes** de postear ninguno: postear el de - // presionar y fallar luego al soltar deja la tecla hundida con - // Command encima, y la máquina inutilizable hasta que alguien la toque. let (Some(down), Some(up)) = ( self.event(keycode, true, flags), self.event(keycode, false, flags), @@ -87,27 +70,27 @@ impl Keystroke { return false; }; - // SAFETY: `down` es un evento válido recién creado. + // SAFETY: `down` is a valid, freshly created event. unsafe { CGEventPost(HID_TAP, down) }; - // SAFETY: ya se posteó, se suelta. + // SAFETY: already posted, so it is released. unsafe { CFRelease(down) }; std::thread::sleep(std::time::Duration::from_millis(9)); - // SAFETY: `up` es un evento válido recién creado. + // SAFETY: `up` is a valid, freshly created event. unsafe { CGEventPost(HID_TAP, up) }; - // SAFETY: ya se posteó, se suelta. + // SAFETY: already posted, so it is released. unsafe { CFRelease(up) }; true } fn event(&self, keycode: u16, down: bool, flags: u64) -> Option { - // SAFETY: `self.source` sigue vivo mientras exista este tipo. + // SAFETY: `self.source` lives as long as this type does. let event = unsafe { CGEventCreateKeyboardEvent(self.source, keycode, down) }; if event.is_null() { return None; } - // SAFETY: `event` acaba de comprobarse no nulo. + // SAFETY: `event` was just checked non-null. unsafe { CGEventSetFlags(event, flags) }; Some(event) } @@ -115,7 +98,7 @@ impl Keystroke { impl Drop for Keystroke { fn drop(&mut self) { - // SAFETY: `source` vino de una función Create y no se ha soltado. + // SAFETY: `source` came from a Create function and has not been released. unsafe { CFRelease(self.source) }; } } diff --git a/crates/cp-mac-sys/src/media.rs b/crates/cp-mac-sys/src/media.rs index 527bad36..2dac6f85 100644 --- a/crates/cp-mac-sys/src/media.rs +++ b/crates/cp-mac-sys/src/media.rs @@ -1,11 +1,8 @@ -//! Lo que un archivo de audio o vídeo sabe decir de sí mismo. - use objc2_av_foundation::AVURLAsset; use objc2_foundation::{NSString, NSURL}; #[derive(Debug, Clone, Default, PartialEq)] pub struct MediaInfo { - /// En segundos. Es lo que la tarjeta pinta como «3:47». pub duration: Option, pub width: Option, pub height: Option, @@ -19,8 +16,6 @@ impl MediaInfo { *self == Self::default() } - /// Lo que merece entrar en el índice: quien busca «la canción de los - /// créditos» escribe el título o el artista, nunca el nombre del archivo. pub fn searchable(&self) -> String { [&self.title, &self.artist, &self.album] .into_iter() @@ -31,19 +26,18 @@ impl MediaInfo { } } -/// Lee lo que el archivo declara, sin decodificar su contenido. pub fn info_for(path: &std::path::Path) -> Option { if !path.exists() { return None; } let text = NSString::from_str(path.to_str()?); let url = NSURL::fileURLWithPath(&text); - // SAFETY: la URL es válida y sin opciones se usan las de por defecto. + // SAFETY: the URL is valid and omitting options uses the defaults. let asset = unsafe { AVURLAsset::URLAssetWithURL_options(&url, None) }; let mut info = MediaInfo::default(); - // SAFETY: el asset sigue vivo; devuelve una estructura por valor. + // SAFETY: the asset is alive; returns a struct by value. let duration = unsafe { asset.duration() }; if duration.timescale != 0 { let seconds = duration.value as f64 / duration.timescale as f64; @@ -52,12 +46,10 @@ pub fn info_for(path: &std::path::Path) -> Option { } } - // La resolución sale de la primera pista de vídeo, si la hay: un archivo - // de audio no tiene ninguna y eso ya es la respuesta. - // SAFETY: el asset sigue vivo mientras se recorren sus pistas. + // SAFETY: the asset is alive while its tracks are walked. let tracks = unsafe { asset.tracks() }; for track in tracks.iter() { - // SAFETY: la pista pertenece al array, que sigue vivo. + // SAFETY: the track belongs to the array, still alive. let size = unsafe { track.naturalSize() }; if size.width >= 1.0 && size.height >= 1.0 { info.width = Some(size.width as u32); diff --git a/crates/cp-mac-sys/src/ocr.rs b/crates/cp-mac-sys/src/ocr.rs index 1b70a33d..e847d018 100644 --- a/crates/cp-mac-sys/src/ocr.rs +++ b/crates/cp-mac-sys/src/ocr.rs @@ -1,25 +1,11 @@ -//! Leer el texto que hay dentro de una imagen. -//! -//! Es lo que convierte una captura de pantalla en algo que se puede -//! encontrar. La 2.x guarda las imágenes como un bloque opaco: si copiaste -//! una captura con un número de pedido, la única forma de recuperarla es -//! recordar cuándo fue y bajar por la lista. - use objc2::AllocAnyThread; use objc2_foundation::{NSArray, NSData, NSDictionary}; use objc2_vision::{ VNImageRequestHandler, VNRecognizeTextRequest, VNRequest, VNRequestTextRecognitionLevel, }; -/// Rápido frente a preciso. Para buscar en el historial interesa lo primero: -/// el usuario escribe un trozo de palabra, no espera una transcripción. const LEVEL: VNRequestTextRecognitionLevel = VNRequestTextRecognitionLevel::Fast; -/// El texto reconocido en la imagen, línea a línea. -/// -/// Devuelve `None` si Vision no puede con el formato, y una lista vacía si -/// la imagen simplemente no tiene texto. Son cosas distintas y quien llame -/// puede querer distinguirlas. pub fn text_in_image(bytes: &[u8]) -> Option> { let data = NSData::with_bytes(bytes); let options = NSDictionary::new(); @@ -31,9 +17,6 @@ pub fn text_in_image(bytes: &[u8]) -> Option> { let request = VNRecognizeTextRequest::new(); request.setRecognitionLevel(LEVEL); - // La corrección lingüística estorba aquí: convierte identificadores, - // rutas y códigos en palabras del diccionario, que es justo lo que el - // usuario quiere encontrar tal cual lo copió. request.setUsesLanguageCorrection(false); let requests: Vec<&VNRequest> = vec![&request]; @@ -57,7 +40,6 @@ pub fn text_in_image(bytes: &[u8]) -> Option> { Some(lines) } -/// Todo el texto de la imagen en una sola cadena, listo para el índice. pub fn searchable_text(bytes: &[u8]) -> Option { let lines = text_in_image(bytes)?; (!lines.is_empty()).then(|| lines.join(" ")) diff --git a/crates/cp-mac-sys/src/pasteboard.rs b/crates/cp-mac-sys/src/pasteboard.rs index 7272783e..02b1d9cb 100644 --- a/crates/cp-mac-sys/src/pasteboard.rs +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -1,13 +1,6 @@ use objc2_app_kit::{NSPasteboard, NSPasteboardItem, NSPasteboardWriting}; use objc2_foundation::{MainThreadMarker, NSString}; -/// El pasteboard del sistema. -/// -/// `NSPasteboard` tiene un fallo de concurrencia documentado con file -/// promises, así que solo se toca desde el hilo principal. El -/// `MainThreadMarker` lo vuelve imposible de incumplir en compilación: el -/// hilo vigilante sondea el contador y el salto al principal se limita a -/// copiar bytes. pub struct Pasteboard { inner: objc2::rc::Retained, } @@ -19,9 +12,6 @@ impl Pasteboard { } } - /// Recibo del estado. Sube exactamente de uno en uno por escritura, así - /// que un salto mayor que uno significa copias perdidas, y eso se cuenta - /// en lugar de ignorarse. pub fn change_count(&self) -> i64 { self.inner.changeCount() as i64 } @@ -33,21 +23,12 @@ impl Pasteboard { types.iter().map(|one| one.to_string()).collect() } - /// Devuelve `None` tanto si el tipo no está como si el proveedor lo - /// anunció y no entregó nada, que ocurre de verdad: medido el 12/09/2026, - /// `com.apple.linkpresentation.metadata` y `fndf` hacen exactamente eso. pub fn data(&self, uti: &str) -> Option> { let name = NSString::from_str(uti); let data = self.inner.dataForType(&name)?; Some(data.to_vec()) } - /// Los datos de un tipo, **de cada ítem** del portapapeles. - /// - /// `dataForType:` sobre el pasteboard plano devuelve solo el primer ítem, - /// así que copiar tres archivos en Finder daba una sola ruta. La API por - /// ítems es la que ve el conjunto: es lo que hace la 2.x con - /// `readObjects(forClasses:)`. pub fn data_per_item(&self, uti: &str) -> Vec> { let Some(items) = self.inner.pasteboardItems() else { return Vec::new(); @@ -59,13 +40,10 @@ impl Pasteboard { .collect() } - /// Cuántos ítems distintos hay en el portapapeles. pub fn item_count(&self) -> usize { self.inner.pasteboardItems().map_or(0, |items| items.len()) } - /// Escribe varios ítems, que es como el sistema representa «tres - /// archivos» y no «un archivo con tres rutas dentro». pub fn write_items(&self, items: &[Vec<(&str, &str)>]) -> bool { self.inner.clearContents(); let written: Vec> = items @@ -88,7 +66,6 @@ impl Pasteboard { self.inner.writeObjects(&array) } - /// Escribe datos binarios, que es como llega una imagen de verdad. pub fn write_data(&self, uti: &str, bytes: &[u8]) -> bool { self.inner.clearContents(); let name = NSString::from_str(uti); @@ -96,10 +73,6 @@ impl Pasteboard { self.inner.setData_forType(Some(&data), &name) } - /// Escribe varios formatos del **mismo** ítem, en una sola operación. - /// - /// Uno por uno no vale: cada `clearContents` empieza de cero y dejaría el - /// portapapeles con el último formato escrito en lugar de con el conjunto. pub fn write_all(&self, entries: &[(&str, &[u8])]) -> bool { self.inner.clearContents(); entries.iter().all(|(uti, bytes)| { @@ -116,8 +89,6 @@ impl Pasteboard { self.inner.setString_forType(&value, &name) } - /// Escribe varios tipos a la vez, para poder reproducir lo que hace una - /// aplicación real —incluido un gestor de contraseñas marcando su copia—. pub fn write_types(&self, entries: &[(&str, &str)]) -> bool { self.inner.clearContents(); entries.iter().all(|(uti, value)| { @@ -128,21 +99,6 @@ impl Pasteboard { } } -/// El contador de cambios, leído **sin** el marcador de hilo principal. -/// -/// El invariante 1 pide que el vigilante sondee desde su propio hilo y que el -/// salto al principal se limite a copiar bytes. Esto es lo que hace posible -/// esa mitad: leer el contador es una consulta de un entero y no materializa -/// ninguna representación, así que no dispara el `NSPasteboardItemDataProvider` -/// de nadie ni toca el camino de file promises, que es donde está el fallo de -/// concurrencia documentado de `NSPasteboard`. -/// -/// Todo lo demás —tipos y datos— sigue exigiendo el hilo principal, y el tipo -/// `Pasteboard` lo obliga en compilación. Esa exigencia es **nuestra**, no de -/// la API: `objc2` expone estas llamadas como seguras, así que el marcador es -/// lo único que impide que alguien lea datos desde un hilo cualquiera. pub fn change_count_from_any_thread() -> i64 { - // `NSInteger` es isize; el núcleo habla i64 porque en Windows el contador - // es un DWORD. La conversión se hace aquí, en la frontera. objc2_app_kit::NSPasteboard::generalPasteboard().changeCount() as i64 } diff --git a/crates/cp-mac-sys/src/paths.rs b/crates/cp-mac-sys/src/paths.rs index 05555417..c964ef03 100644 --- a/crates/cp-mac-sys/src/paths.rs +++ b/crates/cp-mac-sys/src/paths.rs @@ -1,13 +1,5 @@ -//! Dónde viven los datos en macOS. - use std::path::PathBuf; -/// La carpeta de la aplicación, la misma que usa la 2.x. -/// -/// Se comparte a propósito: así el usuario tiene una sola carpeta y la -/// migración manual puede leer el archivo viejo sin buscarlo. Lo que **no** -/// se comparte es el nombre de la base, para que instalar la 3.0 no pise el -/// historial de la 2.x. pub fn data_dir() -> Option { let home = std::env::var_os("HOME")?; Some( @@ -18,12 +10,10 @@ pub fn data_dir() -> Option { ) } -/// La base de la 3.0. La 2.x usa `clipboard.db` en esta misma carpeta. pub fn database() -> Option { Some(data_dir()?.join("history.db")) } -/// El archivo de la 2.x, para cuando haya importación manual. pub fn legacy_database() -> Option { Some(data_dir()?.join("clipboard.db")) } diff --git a/crates/cp-mac-sys/src/permissions.rs b/crates/cp-mac-sys/src/permissions.rs index 7058a8a5..db8116c8 100644 --- a/crates/cp-mac-sys/src/permissions.rs +++ b/crates/cp-mac-sys/src/permissions.rs @@ -1,7 +1,4 @@ -//! Las tres sondas del pegado en macOS, que no son la misma cosa. - -// SAFETY: declaraciones de funciones C de los frameworks del sistema. Las -// firmas se corresponden con las de CoreGraphics y HIToolbox. +// SAFETY: C function declarations from the system frameworks; signatures match CoreGraphics and HIToolbox. unsafe extern "C" { fn CGPreflightPostEventAccess() -> bool; fn CGRequestPostEventAccess() -> bool; @@ -9,46 +6,26 @@ unsafe extern "C" { fn IsSecureEventInputEnabled() -> bool; } -/// Si se pueden postear eventos de teclado. -/// -/// Es la sonda correcta para lo que hace CopyPaste, y **no** es -/// `AXIsProcessTrusted`: son dos servicios distintos de TCC, con dos filas -/// separadas, que pueden divergir. La 2.x consulta el equivocado. pub fn can_post_events() -> bool { - // SAFETY: la función no toma argumentos ni devuelve punteros. + // SAFETY: the function takes no arguments and returns no pointers. unsafe { CGPreflightPostEventAccess() } } -/// Pide el permiso, lo que muestra el diálogo del sistema la primera vez. -/// -/// En una segunda llamada, con la decisión ya registrada en TCC, **no vuelve -/// a preguntar**: por eso un botón de «comprobar de nuevo» no puede arreglar -/// un permiso obsoleto, y hay que decirle al usuario que lo quite y lo -/// vuelva a añadir. pub fn request_post_events() -> bool { - // SAFETY: la función no toma argumentos ni devuelve punteros. + // SAFETY: the function takes no arguments and returns no pointers. unsafe { CGRequestPostEventAccess() } } -/// Si el proceso está en la lista de Accesibilidad. Solo hace falta para el -/// respaldo de pegar por el menú Editar, no para el ⌘V sintético. pub fn is_accessibility_trusted() -> bool { - // SAFETY: la función no toma argumentos ni devuelve punteros. + // SAFETY: the function takes no arguments and returns no pointers. unsafe { AXIsProcessTrusted() } } -/// Si algún proceso tiene el input seguro activado. -/// -/// Medido el 12/09/2026: con esto activo, el ⌘V sintético **sí llega** y -/// `AXPress` sobre el menú **también**. Lo que rompe es la activación. Por -/// eso esto se usa para avisar y jamás para abortar: abortar dejaría sin -/// pegar precisamente a quien tiene el flag pegado por una aplicación ajena. pub fn is_secure_input_enabled() -> bool { - // SAFETY: la función no toma argumentos ni devuelve punteros. + // SAFETY: the function takes no arguments and returns no pointers. unsafe { IsSecureEventInputEnabled() } } -/// Lo que hace falta para pegar, resumido. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Readiness { pub can_post: bool, @@ -65,14 +42,10 @@ impl Readiness { } } - /// El pegado se intenta siempre que se puedan postear eventos. El input - /// seguro no lo impide y la accesibilidad solo abre el respaldo. pub fn can_paste(&self) -> bool { self.can_post } - /// El respaldo por el menú Editar necesita el segundo permiso, y además - /// solo sirve con el destino ya al frente. pub fn can_use_menu_fallback(&self) -> bool { self.accessibility } diff --git a/crates/cp-mac-sys/src/runloop.rs b/crates/cp-mac-sys/src/runloop.rs index c71450eb..fc725669 100644 --- a/crates/cp-mac-sys/src/runloop.rs +++ b/crates/cp-mac-sys/src/runloop.rs @@ -1,10 +1,8 @@ -//! Esperar sin bloquear el hilo principal. - use std::ffi::c_void; type CFStringRef = *const c_void; -// SAFETY: firmas de CoreFoundation tal como las declara su cabecera. +// SAFETY: CoreFoundation signatures as declared in its header. unsafe extern "C" { static kCFRunLoopDefaultMode: CFStringRef; fn CFRunLoopRunInMode( @@ -14,15 +12,7 @@ unsafe extern "C" { ) -> i32; } -/// Deja correr el run loop del hilo actual durante un rato. -/// -/// Es lo que hay que usar en lugar de `thread::sleep` cuando se espera **a -/// algo que este mismo hilo tiene que procesar**. Dormir el hilo principal -/// mientras se espera a que el panel termine de ocultarse y el destino -/// recupere el primer plano es esperar a un trabajo que nadie va a hacer, -/// porque quien lo haría está dormido. pub fn pump(seconds: f64) { - // SAFETY: el modo es la constante del sistema y la llamada solo cede el - // hilo al run loop durante el tiempo indicado. + // SAFETY: the mode is the system constant and the call only yields the thread to the run loop for the given time. unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, seconds, false) }; } diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 2a7c1daf..a9c2b6ba 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -1,12 +1,3 @@ -//! Batería de pruebas del núcleo contra macOS de verdad. -//! -//! No son pruebas de `cargo test` a propósito: `NSPasteboard` exige el hilo -//! principal y el runner no lo garantiza, y varias tocan aplicaciones reales. -//! Se ejecuta con `cargo run -p cp-mac --example probe`. - -// El cuerpo vive aparte porque un `use` de objc2 no se resuelve fuera de Apple -// y `cargo test --workspace` alcanza los ejemplos. Cargo solo autodescubre -// `examples/*.rs` y `examples/*/main.rs`, así que este no se compila solo. #[cfg(target_os = "macos")] include!("probe/battery.rs"); diff --git a/crates/cp-mac/examples/probe/battery.rs b/crates/cp-mac/examples/probe/battery.rs index c045a966..053a9941 100644 --- a/crates/cp-mac/examples/probe/battery.rs +++ b/crates/cp-mac/examples/probe/battery.rs @@ -180,7 +180,6 @@ fn main() -> std::process::ExitCode { let captured = capture(&pb).ok_or("no se capturó")?; let had = captured.formats.len(); - // Se ensucia el portapapeles con otra cosa, como haría el usuario. pb.write_text("algo distinto"); match cp_mac::restore::to_pasteboard(&pb, &captured) { @@ -382,9 +381,6 @@ fn main() -> std::process::ExitCode { let png = std::fs::read("fixtures/texto-en-imagen.png") .map_err(|why| format!("falta el fixture: {why}"))?; let size = cp_core::thumbnail::size_of(&png).ok_or("no se leyó el tamaño")?; - // El fixture se dibujó a 720×160 puntos en una pantalla Retina, - // así que el archivo tiene el doble de píxeles. Lo que se guarda - // y lo que se enseña son cosas distintas; esto es lo que se guarda. if size.width != 1440 || size.height != 320 { return Err(format!("dijo {}×{}", size.width, size.height)); } @@ -463,7 +459,6 @@ fn main() -> std::process::ExitCode { let item = capture(&pb).ok_or("no se capturó")?; let had = item.formats.len(); - // Se pega en plano: el portapapeles queda solo con el texto. match cp_mac::restore::to_pasteboard_as_plain_text(&pb, &item) { cp_mac::restore::Restored::Written { formats: 1, .. } => {} other => return Err(format!("devolvió {other:?}")), @@ -472,8 +467,6 @@ fn main() -> std::process::ExitCode { return Err("quedó el HTML: no se pegó en plano".into()); } - // Y el ítem guardado sigue teniendo todo, así que la próxima vez se - // puede pegar con estilos. if item.formats.len() != had { return Err("el ítem perdió formatos".into()); } @@ -563,8 +556,6 @@ fn main() -> std::process::ExitCode { }; let store = cp_store::Store::in_memory().map_err(|why| why.to_string())?; - // El ítem entero no cabe en la fila, así que se guarda la referencia y - // el texto reconocido, que es lo que hace buscable la captura. let light = cp_core::item::Item { kind: item.kind, formats: vec![cp_core::item::Format { @@ -815,9 +806,6 @@ fn main() -> std::process::ExitCode { b.passed += 1; println!(" ok F4 pegado real en TextEdit, ida y vuelta"); } - // Que otra aplicación retenga el primer plano no es un - // fallo del núcleo: es la activación cooperativa que este - // proyecto ya midió. Se omite en vez de dar un rojo falso. Err(why) if why.starts_with("TextEdit no llegó") => { b.skip("F4", "pegado real en TextEdit", &why); } @@ -850,16 +838,10 @@ fn main() -> std::process::ExitCode { } } -/// Pega en TextEdit y comprueba el resultado sin accesibilidad: tras pegar, -/// selecciona todo y copia, así que lo pegado vuelve por el mismo camino. fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { let path = "/tmp/cp-probe-target.txt"; std::fs::write(path, "").map_err(|why| why.to_string())?; - // Arrancar en frío tarda, y otra aplicación puede tener el foco. Se - // insiste con techo en vez de dormir una cantidad fija y confiar. run_open(&["-a", "TextEdit", path]); - // Se usa la activación del propio núcleo, que es lo que hará el producto, - // en vez de confiar en que `open` gane el primer plano. let mut front = None; for _ in 0..25 { std::thread::sleep(Duration::from_millis(300)); @@ -902,7 +884,6 @@ fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { } std::thread::sleep(Duration::from_millis(400)); - // Seleccionar todo y copiar: lo que vuelva es lo que se pegó. let keys = cp_mac_sys::keystroke::Keystroke::new().ok_or("sin fuente")?; keys.command(0x00); std::thread::sleep(Duration::from_millis(200)); diff --git a/crates/cp-mac/src/capture.rs b/crates/cp-mac/src/capture.rs index f25cf8d8..ff421575 100644 --- a/crates/cp-mac/src/capture.rs +++ b/crates/cp-mac/src/capture.rs @@ -4,10 +4,6 @@ use cp_core::item::{Format, Item, Payload}; use cp_core::kind::{self, Kind}; use cp_mac_sys::pasteboard::Pasteboard; -/// Lee todo lo que la fuente ofreció y construye el ítem. -/// -/// Devuelve `None` cuando el contenido está marcado como secreto, y esa -/// decisión se toma **antes** de pedir un solo byte. pub fn capture(pb: &Pasteboard) -> Option { let offered = pb.types(); let ids: Vec<&str> = offered.iter().map(String::as_str).collect(); @@ -21,8 +17,6 @@ pub fn capture(pb: &Pasteboard) -> Option { for id in &ids { let canonical = CATALOG.canonical(id); if formats.iter().any(|kept| kept.id == canonical) { - // Los gemelos legados llevan los mismos bytes: guardar los dos - // duplica el ítem entero. continue; } let payload = match CATALOG.decide(canonical) { @@ -30,12 +24,8 @@ pub fn capture(pb: &Pasteboard) -> Option { Take::Presence => Payload::Announced { size: None }, Take::Payload => { if CATALOG.costlier_twin(canonical, &ids) { - // Misma imagen, representación cara: se anota y no se - // pide. Medido: 52 veces más grande en el mismo copiado. Payload::Announced { size: None } } else if canonical == "public.file-url" { - // Copiar tres archivos en Finder son tres ítems en el - // portapapeles, y `dataForType:` solo ve el primero. match gather_file_urls(pb) { Some(bytes) => Payload::stored(bytes), None => Payload::Absent, @@ -54,14 +44,10 @@ pub fn capture(pb: &Pasteboard) -> Option { }); } - // La familia sale del formato; la clase fina, del contenido. Un texto que - // resulta ser un correo o un color se guarda como tal, que es de lo que - // vive el filtro por pestañas de la interfaz. let kind = refine(family, &formats); Some(Item { kind, formats }) } -/// Todas las rutas, una por línea, como las guarda la 2.x. fn gather_file_urls(pb: &Pasteboard) -> Option> { let each = pb.data_per_item("public.file-url"); if each.is_empty() { @@ -103,7 +89,6 @@ fn refine(family: Option, formats: &[Format]) -> Option { Some(url) => { let path = url.trim_end_matches('/'); let name = path.rsplit('/').next().unwrap_or(path); - // Una URL de archivo que termina en barra es una carpeta. kind::classify_file(name, url.ends_with('/')) } None => Kind::File, diff --git a/crates/cp-mac/src/formats.rs b/crates/cp-mac/src/formats.rs index f0d58d5e..382162ba 100644 --- a/crates/cp-mac/src/formats.rs +++ b/crates/cp-mac/src/formats.rs @@ -1,7 +1,5 @@ use cp_core::formats::Catalog; -/// Los tipos de macOS. Las reglas que se les aplican viven en `cp-core`; esto -/// son solo los datos, y su gemelo de Windows tendrá la misma forma. pub const CATALOG: Catalog = Catalog { hangs: &["com.apple.pasteboard.promised-suggested-file-name"], wasteful: &[ @@ -41,8 +39,6 @@ pub const CATALOG: Catalog = Catalog { "net.antelle.keeweb", "PasswordPboardType", ], - // macOS no tiene marcadores que se lean: la convención de - // `org.nspasteboard` es por presencia y nada más. denied_when_zero: &[], opaque_prefixes: &["dyn.", "CorePasteboardFlavorType"], text: &[ @@ -54,12 +50,7 @@ pub const CATALOG: Catalog = Catalog { ], files: &["public.file-url"], images_by_preference: &["public.png", "public.tiff"], - // El RTFD y el RTF no son el mismo contenido en dos envoltorios: el - // primero lleva las imágenes incrustadas que el segundo no tiene —993.342 - // frente a 395 bytes en el mismo documento—, así que se guardan los dos. equivalents: &[], - // Medido el 12/09/2026: ninguna de las tres fuentes adjunta una imagen de - // cortesía a un documento de texto, así que aquí no hay nada que desempatar. embeddable: &[], }; @@ -68,8 +59,6 @@ mod tests { use super::CATALOG; use cp_core::formats::{Family, Take}; - /// Los tres casos se midieron el 12/09/2026 sobre macOS 26.6.2, leyendo - /// todos los tipos que cada aplicación ofrecía de verdad. const SAFARI: &[&str] = &[ "com.apple.webarchive", "Apple Web Archive pasteboard type", @@ -113,8 +102,6 @@ mod tests { .filter(|id| CATALOG.decide(id) == Take::Payload) .collect(); assert_eq!(CATALOG.classify(SAFARI), Some(Family::Text)); - // Cinco tipos distintos más sus gemelos legados: 14.636 bytes de los - // que la 2.x guarda 53. assert!(kept.len() >= 5, "se guardaron {} tipos", kept.len()); assert!(kept.iter().any(|id| **id == "com.apple.flat-rtfd")); assert!(kept.iter().any(|id| **id == "com.apple.webarchive")); diff --git a/crates/cp-mac/src/lib.rs b/crates/cp-mac/src/lib.rs index 33f875e5..7be00e19 100644 --- a/crates/cp-mac/src/lib.rs +++ b/crates/cp-mac/src/lib.rs @@ -5,6 +5,4 @@ pub mod formats; pub mod paste; pub mod restore; -/// El pegado nunca se intenta sin el destino en primer plano: medido el -/// 12/09/2026, ni `CGEventPostToPid` ni `AXPress` entregan a una app de fondo. pub const REQUIRES_FOREGROUND_TARGET: bool = true; diff --git a/crates/cp-mac/src/paste.rs b/crates/cp-mac/src/paste.rs index 3b545ed1..f69b735e 100644 --- a/crates/cp-mac/src/paste.rs +++ b/crates/cp-mac/src/paste.rs @@ -7,10 +7,7 @@ use cp_mac_sys::runloop; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { - /// Se envió la pulsación. No significa que la aplicación la haya usado: - /// eso solo lo sabe quien mire el destino. Sent { took: std::time::Duration }, - /// El contenido está en el portapapeles y el usuario puede pegarlo. Degraded(Failure), } @@ -25,36 +22,20 @@ impl Paster { }) } - /// Se resuelve en **cada** pegado, no al arrancar. - /// - /// Guardarlo parecía la optimización obvia y era un fallo: cambiar de - /// distribución con la aplicación abierta dejaba el keycode viejo, y en - /// Dvorak eso escribe otra letra. Medido, resolverlo cuesta 458 ns, así - /// que la caché solo aportaba el fallo. pub fn keycode(&self) -> u16 { keyboard::keycode_with_command('v').unwrap_or(QWERTY_V) } - /// La secuencia completa, en el orden que fija `cp_core::paste::ORDER`. - /// - /// El portapapeles ya tiene que estar escrito: es la fase cero y ocurre - /// antes de que esto se llame, para que cualquier fallo de aquí en - /// adelante degrade a «está en tu portapapeles». pub fn paste_into(&self, target: &Destination, hide_panel: impl FnOnce()) -> Outcome { let started = std::time::Instant::now(); let mut attempt = Attempt::default(); hide_panel(); - // Un destino que ya no existe no es un problema de foco, y decir que - // lo es deja al usuario sin saber qué pasó. if !frontmost::is_alive(target.pid) { return Outcome::Degraded(Failure::TargetGone); } - // Traerlo al frente. Con el panel no-activador esto suele ser - // innecesario porque el destino nunca se desactivó, pero si algo se - // interpuso hay que recuperarlo: no existe pegado sin activación. if self.focus_of(target) != Focus::OnTarget { frontmost::bring_to_front(target.pid); } @@ -70,8 +51,6 @@ impl Paster { self.wait(0.060); } - // Los modificadores del atajo pueden seguir pulsados: el hotkey llega - // en key-down. Se espera a que se suelten, con techo. let waiting = std::time::Instant::now(); while keystroke::modifiers_still_held() && waiting.elapsed().as_millis() < 120 { self.wait(0.004); @@ -87,18 +66,10 @@ impl Paster { } } - /// Esperar **girando el run loop**, no durmiendo. - /// - /// Esto corre en el hilo principal, que es el mismo que tiene que - /// procesar el `orderOut` del panel y la desactivación que este bucle - /// está esperando. Dormirlo es esperar un trabajo que nadie hará. fn wait(&self, seconds: f64) { runloop::pump(seconds); } - /// Medido: no existe pegado sin activación, así que lo único que importa - /// es si el destino está al frente. La sonda válida es `frontmost`, no - /// `NSApplication::isActive`. fn focus_of(&self, target: &Destination) -> Focus { match frontmost::frontmost() { Some((pid, _)) if pid == target.pid => Focus::OnTarget, @@ -108,8 +79,6 @@ impl Paster { } } -/// Las fases que esta implementación recorre, para que la prueba compruebe -/// que no se ha saltado ninguna ni las ha reordenado. pub fn phases() -> &'static [Phase] { ORDER } diff --git a/crates/cp-mac/src/restore.rs b/crates/cp-mac/src/restore.rs index 51b2c4d9..d0b6ebba 100644 --- a/crates/cp-mac/src/restore.rs +++ b/crates/cp-mac/src/restore.rs @@ -1,15 +1,6 @@ use cp_core::item::{Item, Payload}; use cp_mac_sys::pasteboard::Pasteboard; -/// Devuelve un ítem del historial al portapapeles, con **todos** los formatos -/// que se guardaron de él. -/// -/// Es la otra mitad del pegado y la razón de que el ítem guarde el conjunto -/// entero: restaurar solo el texto de algo que se copió de Word haría que -/// pegarlo perdiera los estilos que sí se habían capturado. -/// -/// Se escribe **antes** de tocar el foco. Así, si algo falla después, el peor -/// resultado posible sigue siendo «está en tu portapapeles, pégalo tú». pub fn to_pasteboard(pb: &Pasteboard, item: &Item) -> Restored { let writable: Vec<(&str, &[u8])> = item .formats @@ -18,9 +9,6 @@ pub fn to_pasteboard(pb: &Pasteboard, item: &Item) -> Restored { Payload::Inline(bytes) | Payload::Blob(bytes) => { Some((format.id.as_str(), bytes.as_slice())) } - // Lo que solo se anotó no tiene bytes que devolver, y lo que no - // cupo tampoco: decirlo es mejor que escribir un ítem a medias - // sin que nadie se entere. _ => None, }) .collect(); @@ -40,15 +28,6 @@ pub fn to_pasteboard(pb: &Pasteboard, item: &Item) -> Restored { } } -/// Deja en el portapapeles **solo** el texto plano del ítem. -/// -/// Pegar sin formato es una opción de **esta** vez, no un cambio en lo -/// guardado: el ítem conserva su RTF, su HTML y su RTFD intactos, y la -/// siguiente vez se puede pegar con estilos. La 2.x tiene aquí un fallo -/// —escribe el texto plano y no restaura lo enriquecido, así que el ítem -/// queda mutilado a partir de entonces—, y es el hallazgo 27 del mapa. -/// -/// Tampoco duplica: no crea un ítem nuevo «en versión plana». pub fn to_pasteboard_as_plain_text(pb: &Pasteboard, item: &Item) -> Restored { let Some(text) = item .formats @@ -66,7 +45,6 @@ pub fn to_pasteboard_as_plain_text(pb: &Pasteboard, item: &Item) -> Restored { if pb.write_all(&[(PLAIN_TEXT, text)]) { Restored::Written { formats: 1, - // Es intencionado: se pidió solo el texto. No es un ítem a medias. incomplete: false, } } else { @@ -78,13 +56,7 @@ const PLAIN_TEXT: &str = "public.utf8-plain-text"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Restored { - /// Se escribieron `formats` formatos. `incomplete` avisa de que el ítem - /// tenía alguno más que no se pudo devolver. - Written { - formats: usize, - incomplete: bool, - }, - /// El ítem no tiene ningún byte que devolver. + Written { formats: usize, incomplete: bool }, NothingToWrite, Failed, } diff --git a/crates/cp-store/examples/budget.rs b/crates/cp-store/examples/budget.rs index f7a4e253..c332ff54 100644 --- a/crates/cp-store/examples/budget.rs +++ b/crates/cp-store/examples/budget.rs @@ -1,14 +1,3 @@ -//! Presupuesto de latencia del almacén. -//! -//! No es un banco de pruebas de precisión: los umbrales están puestos muy por -//! encima de lo medido a propósito, para cazar una regresión de orden de -//! magnitud —una consulta que deja de usar el índice, un `fold` que empieza a -//! reservar— sin dar falsos positivos en una máquina compartida. -//! -//! Vive fuera de `cargo test` porque el tiempo no es determinista y el CI -//! exige que los tests lo sean. Se ejecuta con -//! `cargo run -p cp-store --release --example budget`. - use cp_core::item::Item; use cp_store::Store; use std::time::{Duration, Instant}; @@ -20,9 +9,6 @@ struct Budget { ceiling: Duration, } -/// Una máquina compartida es varias veces más lenta que un escritorio. El -/// presupuesto busca regresiones de orden de magnitud, no microsegundos, así -/// que en CI se le da holgura en vez de bajar la guardia del todo. fn slack() -> u32 { std::env::var("CP_BUDGET_SLACK") .ok() @@ -37,9 +23,6 @@ fn main() -> std::process::ExitCode { println!(" (techos multiplicados por {})", slack()); } - // Un historial real no repite la misma palabra en todas las filas. Con un - // corpus artificial, cualquier búsqueda casa con todo y se mide un caso - // que no le ocurre a nadie. let vocabulary = [ "informe", "factura", @@ -182,8 +165,6 @@ fn main() -> std::process::ExitCode { } } -/// Diez pasadas y se mira la **mediana**, que es lo que el usuario percibe; -/// una máquina compartida siempre tendrá alguna pasada mala. fn measure(budget: Budget, mut run: impl FnMut()) -> u32 { let mut taken: Vec = (0..10) .map(|_| { diff --git a/crates/cp-store/src/blobs.rs b/crates/cp-store/src/blobs.rs index 1054f355..db8926d4 100644 --- a/crates/cp-store/src/blobs.rs +++ b/crates/cp-store/src/blobs.rs @@ -1,16 +1,6 @@ use crate::{Error, Result}; use std::path::{Path, PathBuf}; -/// Los bytes que no caben en la fila, guardados por su contenido. -/// -/// El nombre del archivo **es** el hash de lo que contiene, así que dos -/// copias del mismo contenido ocupan un archivo, y comprobar que un blob no -/// se ha corrompido es recalcular su nombre. -/// -/// Aquí solo viven las cosas que CopyPaste crea: imágenes copiadas y sus -/// miniaturas. **Un archivo que el usuario copió desde el disco se queda -/// donde está** y solo se guarda su ruta: duplicar un vídeo de 4 GB porque -/// alguien lo copió sería inaceptable, y moverlo, peor. pub struct Blobs { root: PathBuf, } @@ -25,15 +15,12 @@ impl Blobs { } fn path_for(&self, digest: &str) -> PathBuf { - // Dos niveles de subcarpeta: un directorio con cien mil entradas es - // lento de listar en cualquier sistema de archivos. self.root .join(&digest[0..2]) .join(&digest[2..4]) .join(digest) } - /// Guarda los bytes y devuelve su nombre. Si ya estaban, no escribe nada. pub fn put(&self, bytes: &[u8]) -> Result { let digest = blake3::hash(bytes).to_hex().to_string(); let path = self.path_for(&digest); @@ -44,9 +31,6 @@ impl Blobs { std::fs::create_dir_all(parent).map_err(Error::Io)?; crate::store::restrict(parent, 0o700)?; } - // Se escribe a un temporal y se renombra: un corte a mitad deja un - // archivo suelto, nunca un blob con el nombre de un contenido que no - // tiene. let temporary = path.with_extension("partial"); std::fs::write(&temporary, bytes).map_err(Error::Io)?; crate::store::restrict(&temporary, 0o600)?; @@ -63,12 +47,6 @@ impl Blobs { } } - /// Borra un blob **sobrescribiéndolo antes**. - /// - /// `secure_delete` de SQLite cubre las páginas de la base y no los - /// archivos de al lado. Si el historial promete que una contraseña - /// borrada deja de ser legible, el blob que la contenía tiene que - /// desaparecer de verdad. pub fn remove(&self, digest: &str) -> Result<()> { let path = self.path_for(digest); let Ok(metadata) = std::fs::metadata(&path) else { @@ -126,9 +104,6 @@ mod tests { fn an_io_error_that_is_not_a_missing_file_is_not_swallowed() { let (_dir, blobs) = temporary(); let digest = "a".repeat(64); - // Un directorio en la ruta exacta del blob: leerlo falla con un error - // que no es `NotFound`, y ese error no puede confundirse con «no hay - // nada que leer». std::fs::create_dir_all(blobs.path_for(&digest)).expect("crea carpeta"); assert!( blobs.get(&digest).is_err(), diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index 6e9fb51c..e0dfc7df 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -6,13 +6,10 @@ pub use blobs::Blobs; pub use schema::SCHEMA_VERSION; pub use store::{Restricted, Store}; -/// Lo que puede salir mal en el almacén. #[derive(Debug, thiserror::Error)] pub enum Error { #[error("la base de datos: {0}")] Db(#[from] rusqlite::Error), - /// Un formato que no cabe en la fila y necesita el almacén de blobs, que - /// todavía no existe. Se rechaza en vez de guardar la fila sin sus bytes. #[error("«{format}» ocupa {size} bytes y el almacén de blobs no existe todavía")] NeedsBlobStore { format: String, size: usize }, #[error("el archivo: {0}")] diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs index f52f5ea6..f4a9e0bb 100644 --- a/crates/cp-store/src/schema.rs +++ b/crates/cp-store/src/schema.rs @@ -2,42 +2,21 @@ use rusqlite::{Connection, Result}; pub const SCHEMA_VERSION: u32 = 1; -/// Lleva la base a la versión que esta copia entiende. -/// -/// `user_version` es un entero que SQLite guarda en la cabecera del archivo y -/// que no cuesta nada leer. La 2.x llegó a la versión 4 de su esquema con -/// cuatro migraciones, así que esto va a hacer falta: montarlo ahora, cuando -/// no hay nada que migrar, es gratis. -/// -/// Devuelve si tuvo que migrar algo, para quien quiera distinguir una base -/// que ya estaba al día de una recién puesta al día. pub fn migrate(db: &Connection) -> crate::Result { let found: u32 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?; if found > SCHEMA_VERSION { - // Una base escrita por una versión más nueva no se toca: abrirla y - // «arreglarla» es la forma más rápida de destrozar el historial de - // alguien que alterna dos instalaciones. return Err(crate::Error::FromTheFuture { found, supported: SCHEMA_VERSION, }); } if found < SCHEMA_VERSION { - // Aquí irán los pasos, uno por versión. Hoy no hay ninguno: la - // primera versión la crea `create`. db.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?; return Ok(true); } Ok(false) } -/// Ajustes de la conexión, en el orden en que hay que darlos. -/// -/// `auto_vacuum` es el que tiene trampa: **SQLite lo ignora en silencio si la -/// base ya tiene tablas**, y cambiarlo después exige un `VACUUM` completo. La -/// 2.x lo aprendió en producción, donde el pragma nunca llegó a aplicarse y -/// todos sus `incremental_vacuum` fueron una operación vacía durante meses. -/// Por eso va antes que nada. pub fn configure(db: &Connection) -> Result<()> { db.execute_batch( r#" @@ -51,8 +30,6 @@ pub fn configure(db: &Connection) -> Result<()> { ) } -/// El almacén guarda **el conjunto** de formatos que la fuente ofreció, no -/// uno elegido. `kind` es una clasificación sobre ese conjunto. pub fn create(db: &Connection) -> Result<()> { configure(db)?; db.execute_batch( diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index d2e08b16..594d3a87 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -3,11 +3,6 @@ use cp_core::item::{Item, Payload}; use cp_core::search::fold; use rusqlite::{Connection, OptionalExtension, params}; -/// Lo que el usuario escribe no es sintaxis FTS5, y tratarlo como si lo fuera -/// rompe la búsqueda con una comilla, un asterisco o un guion delante —y con -/// el buscador vacío, que ocurre cada vez que se borra lo escrito—. Cada -/// palabra se envuelve entre comillas para que FTS la lea como texto literal, -/// y el prefijo se pide fuera de ellas. fn fts_expression(folded: &str) -> Option { let terms: Vec = folded .split_whitespace() @@ -17,7 +12,6 @@ fn fts_expression(folded: &str) -> Option { (!terms.is_empty()).then(|| terms.join(" ")) } -/// Lo que el panel enseña de cada ítem en la lista. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Listed { pub id: i64, @@ -27,7 +21,6 @@ pub struct Listed { pub pinned: bool, } -/// Lo que el usuario tiene puesto en el panel. #[derive(Debug, Clone, Default)] pub struct Filter { pub query: Option, @@ -43,7 +36,6 @@ pub struct Store { } impl Store { - /// Solo para diagnóstico: mirar planes de consulta desde un ejemplo. pub fn raw(&self) -> &Connection { &self.db } @@ -58,12 +50,6 @@ impl Store { }) } - /// Abre —o crea— la base en disco. - /// - /// El archivo se crea con permisos `0600` y su carpeta con `0700`: un - /// historial de portapapeles es de los archivos más sensibles de una - /// cuenta, y en un equipo compartido el resto de usuarios no tiene por - /// qué poder leerlo. pub fn open(path: &std::path::Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(Error::Io)?; @@ -73,9 +59,11 @@ impl Store { crate::schema::create(&db)?; crate::schema::migrate(&db)?; let exposure = restrict(path, 0o600)?; - // Las imágenes que CopyPaste captura viven junto a la base, en su - // propia carpeta. Lo que el usuario copió del disco se queda donde - // estaba: solo se guarda la ruta. + for side in sidecars(path) { + if side.exists() { + restrict(&side, 0o600)?; + } + } let blobs = path .parent() .map(|parent| crate::Blobs::at(&parent.join("blobs"))) @@ -87,33 +75,21 @@ impl Store { }) } - /// Hasta dónde se pudo proteger el archivo, para que el panel lo diga en - /// vez de que nadie se entere. pub fn exposure(&self) -> Restricted { self.exposure } - /// Vuelca el WAL al archivo principal. - /// - /// Sin esto, lo recién escrito vive en el `-wal` y una copia del archivo - /// principal sale incompleta: es la trampa que la 2.x documenta en su - /// servicio de copia de seguridad. pub fn checkpoint(&self) -> Result<()> { self.db.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; Ok(()) } - /// Recupera espacio de las páginas liberadas, poco a poco. pub fn vacuum_step(&self, pages: u32) -> Result<()> { self.db .execute_batch(&format!("PRAGMA incremental_vacuum({pages});"))?; Ok(()) } - /// El texto se normaliza **al escribir**, con la misma función que - /// normaliza el término al buscar. Ese es el invariante que hoy falta: la - /// 2.x normaliza solo el término, así que `Straße` no se encuentra ni - /// escribiendo `strasse` ni escribiendo `Straße`. pub fn insert_text(&self, uuid: &str, text: &str, created_at: i64) -> Result { let hash = Item::plain(text).fingerprint() as i64; self.db.execute( @@ -125,12 +101,6 @@ impl Store { Ok(self.db.last_insert_rowid()) } - /// Volver a copiar algo que ya estaba lo sube en la lista, no lo duplica. - /// - /// **No toca el contador de pegados**, que es lo que la tarjeta enseña - /// como «×4». Volver a copiar es la operación más frecuente del sistema: - /// si sumara ahí, el número dejaría de significar lo que dice. La 2.x - /// separa las dos cosas a propósito. pub fn reactivate(&self, id: i64, at: i64) -> Result<()> { self.db.execute( "UPDATE items SET modified_at = ?2, updated_at = ?2 WHERE id = ?1", @@ -139,7 +109,6 @@ impl Store { Ok(()) } - /// Se pegó desde el historial: eso sí cuenta. pub fn record_paste(&self, id: i64, at: i64) -> Result<()> { self.db.execute( "UPDATE items @@ -150,7 +119,6 @@ impl Store { Ok(()) } - /// El color de la tarjeta, que es una de las vistas del panel. pub fn set_color(&self, id: i64, color: i64, at: i64) -> Result<()> { self.db.execute( "UPDATE items SET card_color = ?2, updated_at = ?3 WHERE id = ?1", @@ -167,13 +135,6 @@ impl Store { })?) } - /// El texto que Vision leyó dentro de una imagen. - /// - /// Va en su propia columna del índice y no en `search_text` para que se - /// pueda distinguir «lo que el usuario copió» de «lo que había escrito en - /// la imagen», y para poder rehacerlo sin tocar lo demás. Se escribe - /// después de capturar, nunca durante: reconocer texto cuesta unos 180 ms - /// y el camino de captura no puede pagarlos. pub fn set_ocr_text(&self, id: i64, text: &str, at: i64) -> Result<()> { self.db.execute( "UPDATE items SET search_ocr = ?2, updated_at = ?3 WHERE id = ?1", @@ -182,7 +143,6 @@ impl Store { Ok(()) } - /// Los ítems de imagen a los que todavía no se les ha pasado el OCR. pub fn pending_ocr(&self, limit: usize) -> Result> { let mut stmt = self.db.prepare( "SELECT id FROM items @@ -194,7 +154,6 @@ impl Store { Ok(rows.collect::>()?) } - /// Guarda un dato derivado del ítem. pub fn set_meta(&self, id: i64, key: &str, value: &str) -> Result<()> { self.db.execute( "INSERT INTO item_meta (item_id, key, value) VALUES (?1, ?2, ?3) @@ -223,7 +182,6 @@ impl Store { Ok(rows.collect::>()?) } - /// Apunta un trabajo de enriquecimiento pendiente. pub fn enqueue(&self, id: i64, job: &str) -> Result<()> { self.db.execute( "INSERT OR IGNORE INTO pending_work (item_id, job) VALUES (?1, ?2)", @@ -232,7 +190,6 @@ impl Store { Ok(()) } - /// Los siguientes trabajos de ese tipo que ya se pueden intentar. pub fn take_pending(&self, job: &str, now: i64, limit: usize) -> Result> { let mut stmt = self.db.prepare( "SELECT w.item_id @@ -254,9 +211,6 @@ impl Store { Ok(()) } - /// Un intento fallido. A partir del tercero el trabajo se abandona: hay - /// imágenes que Vision no sabe leer y reintentarlas para siempre es - /// gastar batería en un resultado que no va a cambiar. pub const MAX_ATTEMPTS: i64 = 3; pub fn work_failed(&self, id: i64, job: &str, why: &str, retry_at: i64) -> Result { @@ -282,7 +236,6 @@ impl Store { Ok(true) } - /// La etiqueta entra en el índice, así que se busca por ella. pub fn set_label(&self, id: i64, label: Option<&str>, at: i64) -> Result<()> { self.db.execute( "UPDATE items SET label = ?2, search_label = ?3, updated_at = ?4 WHERE id = ?1", @@ -299,12 +252,7 @@ impl Store { Ok(()) } - /// Una tumba, no un borrado: la nube que vendrá necesita saber que algo - /// dejó de existir, y sin esto una sincronización lo resucitaría. pub fn mark_deleted(&self, id: i64, at: i64) -> Result<()> { - // La lápida guarda identidad y fechas para que la sincronización sepa - // que esto dejó de existir. El contenido, su copia en el índice y las - // filas de formato se van: dejarlos incumple lo que PRIVACY.md promete. self.db.execute( "UPDATE items SET deleted_at = ?2, updated_at = ?2, @@ -314,9 +262,6 @@ impl Store { WHERE id = ?1", params![id, at], )?; - // Los blobs de este ítem se sobrescriben antes de desaparecer, y - // solo si ningún otro ítem los comparte: el nombre es el contenido, - // así que dos copias iguales apuntan al mismo archivo. if let Some(blobs) = &self.blobs { for digest in self.blobs_of(id)? { if self.blob_is_shared(&digest, id)? { @@ -327,8 +272,6 @@ impl Store { } self.db .execute("DELETE FROM item_formats WHERE item_id = ?1", [id])?; - // Lo derivado también es del usuario: dimensiones, duración, artista, - // y el trabajo pendiente que ya no hay que hacer. self.db .execute("DELETE FROM item_meta WHERE item_id = ?1", [id])?; self.db @@ -353,7 +296,6 @@ impl Store { Ok(count > 0) } - /// Los bytes de un formato, vengan de la fila o del disco. pub fn payload_of(&self, id: i64, format: &str) -> Result>> { let found: Option<(Option>, Option)> = self .db @@ -374,8 +316,6 @@ impl Store { } } - /// Lo que cambió después de un punto, que es lo que una sincronización - /// necesita preguntar. pub fn changed_since(&self, version: i64) -> Result> { let mut stmt = self .db @@ -384,9 +324,6 @@ impl Store { Ok(rows.collect::>()?) } - /// Guarda el ítem con **todas** sus filas de formato. El `content_hash` - /// es del contenido completo, así que dos copias iguales no crean dos - /// ítems y una copia distinta nunca se toma por repetida. pub fn insert_item( &self, uuid: &str, @@ -394,9 +331,6 @@ impl Store { preview: &str, created_at: i64, ) -> Result { - // Sin almacén de blobs —una base en memoria— aceptar un `Blob` sería - // guardar la fila con su tamaño y tirar los bytes. Es preferible - // negarse a guardar un ítem vacío del que nadie sospecharía. if self.blobs.is_none() && let Some(oversized) = item.oversized_format() { @@ -437,9 +371,6 @@ impl Store { Ok(id) } - /// Busca por la **identidad del ítem**, que es la que `insert_item` - /// guarda. Recibía un `&str` y calculaba el hash del texto desnudo, que no - /// es lo que hay en la columna: así nunca encontraba nada capturado. pub fn find_by_hash(&self, item: &Item) -> Result> { let hash = item.fingerprint() as i64; Ok(self @@ -460,9 +391,6 @@ impl Store { Ok(rows.collect::>()?) } - /// Un ítem cuyo archivo ya no existe **no se borra**: se marca. La - /// diferencia entre «se me borró el historial» y «esto ya no está en el - /// disco» la nota el usuario de inmediato. pub fn mark_broken(&self, id: i64, at: i64) -> Result<()> { self.db.execute( "UPDATE items SET broken_since = ?2 WHERE id = ?1 AND broken_since IS NULL", @@ -471,8 +399,6 @@ impl Store { Ok(()) } - /// Los rotos se van cuando cumplen su plazo, nunca antes. Un ítem fijado - /// es una decisión del usuario y la limpieza no la revoca. pub fn purge_broken_before(&self, cutoff: i64) -> Result { Ok(self.db.execute( "DELETE FROM items @@ -487,8 +413,6 @@ impl Store { Ok(()) } - /// Cuántos ítems tiene el usuario. Las lápidas no se cuentan: para él - /// están borradas. pub fn count(&self) -> Result { Ok(self.db.query_row( "SELECT COUNT(*) FROM items WHERE deleted_at IS NULL", @@ -497,18 +421,11 @@ impl Store { )?) } - /// Lo que el panel pide: una ventana del historial, con o sin término de - /// búsqueda y con los filtros que el usuario tenga puestos. - /// - /// Una sola consulta para las dos cosas. Tener una para «buscar» y otra - /// para «listar» es el camino corto a que los filtros funcionen en una y - /// no en la otra. pub fn list(&self, filter: &Filter, limit: usize, after: Option) -> Result> { let expression = filter .query .as_deref() .map(|text| fts_expression(&fold(text))); - // Se pidió buscar algo que no deja ningún término utilizable. if matches!(expression, Some(None)) { return Ok(Vec::new()); } @@ -552,8 +469,6 @@ impl Store { ); let mut stmt = self.db.prepare(&sql)?; - // Solo se pasan los parámetros que la consulta construida usa: SQLite - // rechaza un nombre que no aparezca en ella. let limit = limit as i64; let mut bound: Vec<(&str, &dyn rusqlite::ToSql)> = vec![(":after", &after), (":limit", &limit)]; @@ -572,10 +487,6 @@ impl Store { Ok(rows.collect::>()?) } - /// Retención: lo viejo se va, lo fijado se queda. - /// - /// Fijar un ítem es una decisión del usuario y la limpieza no la revoca, - /// que es exactamente lo que hace `clearOldItems` en la 2.x. pub fn clear_older_than(&self, cutoff: i64) -> Result { let doomed: Vec = { let mut stmt = self.db.prepare( @@ -591,7 +502,6 @@ impl Store { Ok(doomed.len()) } - /// Vaciar el historial dejando lo fijado. pub fn clear_all_unpinned(&self, at: i64) -> Result { self.clear_older_than_matching(at, "pinned = 0") } @@ -610,21 +520,12 @@ impl Store { Ok(doomed.len()) } - /// Cuántas filas pide una lista antes de que el usuario haga scroll. Sin - /// tope, una consulta que casa con todo materializa el historial entero - /// en cada pulsación. pub const PAGE: usize = 100; pub fn search(&self, query: &str) -> Result> { self.search_page(query, Self::PAGE, 0) } - /// Página siguiente a partir del último visto, en vez de `OFFSET`. - /// - /// Con `OFFSET`, SQLite ordena el resultado entero y descarta lo saltado, - /// así que la página diez cuesta diez veces la primera. Con el corte por - /// `modified_at` puede recorrer el índice de recencia y parar al llenar - /// la página: cada página cuesta lo mismo que la primera. pub fn search_after( &self, query: &str, @@ -670,23 +571,21 @@ impl Store { } } -/// Hasta dónde llegó la protección de una ruta, que no es la misma en cada -/// sistema. Se devuelve en vez de suponerse: un historial de portapapeles -/// legible por el resto de la máquina es un fallo que tiene que poder decirse. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Restricted { - /// Los bits de modo quedaron fijados. Nadie salvo el dueño lo abre. Mode(u32), - /// Windows no tiene bits de modo. La ruta cae bajo el perfil del usuario, - /// cuya lista de control heredada ya excluye a las demás cuentas; apretarla - /// más exige la capa de plataforma, que es la que tiene el `unsafe`. InheritedFromProfile, - /// La ruta quedó fuera del perfil —una unidad compartida, una carpeta - /// elegida a mano—, donde nada garantiza quién puede leerla. Unprotected, } -/// Ajusta los permisos de un archivo o carpeta a lo que se le pide. +fn sidecars(path: &std::path::Path) -> [std::path::PathBuf; 2] { + ["-wal", "-shm"].map(|suffix| { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + std::path::PathBuf::from(name) + }) +} + pub(crate) fn restrict(path: &std::path::Path, mode: u32) -> Result { #[cfg(unix)] { @@ -699,26 +598,22 @@ pub(crate) fn restrict(path: &std::path::Path, mode: u32) -> Result { let _ = mode; let profile = std::env::var_os("USERPROFILE").map(std::path::PathBuf::from); - Ok(match profile { - Some(profile) if under(path, &profile) => Restricted::InheritedFromProfile, - _ => Restricted::Unprotected, - }) + Ok(exposure_of(path, profile.as_deref())) + } +} + +#[cfg_attr(unix, allow(dead_code))] +fn exposure_of(path: &std::path::Path, profile: Option<&std::path::Path>) -> Restricted { + match profile { + Some(profile) if under(path, profile) => Restricted::InheritedFromProfile, + _ => Restricted::Unprotected, } } -/// Si `path` cuelga de `root`, con las dos rutas resueltas antes de comparar: -/// en Windows la misma carpeta se nombra de más de una forma —el nombre corto -/// 8.3 y el largo— y comparar el texto tal cual daría por desprotegido lo que -/// sí lo está. #[cfg_attr(unix, allow(dead_code))] fn under(path: &std::path::Path, root: &std::path::Path) -> bool { - let resolved = |one: &std::path::Path| { - one.canonicalize() - .or_else(|_| std::path::absolute(one)) - .ok() - }; - match (resolved(path), resolved(root)) { - (Some(path), Some(root)) => path.starts_with(&root), + match (path.canonicalize(), root.canonicalize()) { + (Ok(path), Ok(root)) => path.starts_with(root), _ => false, } } @@ -748,8 +643,6 @@ mod tests { store } - /// Los cuatro casos del fallo verificado con SQLite en la 2.x, donde solo - /// el primero funcionaba —y por suerte, gracias al tokenizer—. #[test] fn the_four_cases_that_2x_gets_wrong() { let store = seeded(); @@ -767,8 +660,6 @@ mod tests { } } - /// Y en la otra dirección, que es la mitad que nadie prueba: escribir la - /// palabra con el carácter especial también tiene que encontrarla. #[test] fn it_works_in_both_directions() { let store = seeded(); @@ -950,8 +841,6 @@ mod tests { assert_eq!(store.count().expect("cuenta"), 1); } - /// Lo que un usuario puede escribir en el buscador sin querer decir nada - /// especial. Ninguna de estas puede devolver un error de SQL. #[test] fn no_query_a_person_can_type_breaks_the_search() { let store = seeded(); @@ -1142,9 +1031,6 @@ mod tests { } let mut seen = Vec::new(); let mut after = None; - // Tope en vez de `loop`: con 25 ítems y páginas de 10 sobran tres - // vueltas, así que un cursor que nunca vuelva vacío falla la - // aserción de abajo en vez de colgar la prueba para siempre. for _ in 0..10 { let page = store.search_after("cursor", 10, after).expect("consulta"); if page.is_empty() { @@ -1488,6 +1374,35 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn the_write_ahead_log_is_as_private_as_the_database() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("carpeta"); + let path = dir.path().join("history.db"); + let store = Store::open(&path).expect("abre"); + store + .insert_text("uuid-privado", "contraseña", 1) + .expect("insert"); + + let wal = sidecars(&path) + .into_iter() + .find(|side| side.exists()) + .expect("el WAL existe mientras la base está abierta"); + let mode = std::fs::metadata(&wal).expect("wal").permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "lo recién copiado vive aquí antes que en la base" + ); + } + + #[test] + fn the_sidecars_are_named_after_the_database() { + let [wal, shm] = sidecars(std::path::Path::new("/datos/history.db")); + assert!(wal.to_string_lossy().ends_with("history.db-wal")); + assert!(shm.to_string_lossy().ends_with("history.db-shm")); + } + #[cfg(unix)] #[test] fn the_history_is_not_readable_by_other_users() { @@ -1515,10 +1430,6 @@ mod tests { assert_eq!(folder, 0o700, "y la carpeta igual"); } - /// En Windows no hay bits de modo que comprobar, así que lo que protege el - /// historial es dónde vive. La prueba no puede afirmar que otras cuentas no - /// lo abren —eso lo decide la lista de control heredada— pero sí que el - /// almacén sabe en cuál de los dos casos está y lo dice. #[cfg(windows)] #[test] fn on_windows_what_protects_the_history_is_living_under_the_profile() { @@ -1537,42 +1448,72 @@ mod tests { assert_eq!(store.exposure(), Restricted::InheritedFromProfile); } + fn a_profile_with(entry: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let profile = tempfile::tempdir().expect("perfil"); + let path = profile.path().join(entry); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("carpeta"); + } + std::fs::write(&path, b"x").expect("archivo"); + (profile, path) + } + #[test] - fn a_path_outside_the_profile_is_not_under_it() { - let profile = std::path::Path::new(if cfg!(windows) { - r"C:\Users\alguien" - } else { - "/home/alguien" - }); - let inside = profile.join("AppData").join("history.db"); - let outside = std::path::Path::new(if cfg!(windows) { - r"Z:\compartido\history.db" - } else { - "/srv/compartido/history.db" - }); - assert!(under(&inside, profile), "lo que cuelga del perfil"); - assert!(!under(outside, profile), "una unidad compartida no"); - assert!( - !under(profile, &inside), - "estar por encima no es estar dentro" + fn a_history_under_the_profile_is_covered_by_its_permissions() { + let (profile, history) = a_profile_with("AppData/Local/history.db"); + assert_eq!( + exposure_of(&history, Some(profile.path())), + Restricted::InheritedFromProfile ); } - /// Un prefijo de texto no es un prefijo de ruta: sin comparar componentes, - /// la carpeta de otra cuenta con el mismo comienzo pasaría por propia. + #[test] + fn a_history_outside_the_profile_is_unprotected() { + let (profile, _) = a_profile_with("AppData/history.db"); + let (_shared, elsewhere) = a_profile_with("compartido/history.db"); + assert_eq!( + exposure_of(&elsewhere, Some(profile.path())), + Restricted::Unprotected + ); + } + + #[test] + fn without_a_profile_nothing_is_promised() { + let (_profile, history) = a_profile_with("history.db"); + assert_eq!(exposure_of(&history, None), Restricted::Unprotected); + } + + #[test] + fn a_path_that_does_not_exist_is_never_taken_for_protected() { + let profile = tempfile::tempdir().expect("perfil"); + let ghost = profile.path().join("todavia-no").join("history.db"); + assert!(!under(&ghost, profile.path())); + assert_eq!( + exposure_of(&ghost, Some(profile.path())), + Restricted::Unprotected + ); + } + + #[test] + fn being_above_is_not_being_inside() { + let (profile, history) = a_profile_with("AppData/history.db"); + assert!(under(&history, profile.path())); + assert!(!under(profile.path(), &history)); + } + #[test] fn a_sibling_that_merely_starts_alike_is_outside() { - let profile = std::path::Path::new(if cfg!(windows) { - r"C:\Users\ana" - } else { - "/home/ana" - }); - let sibling = std::path::Path::new(if cfg!(windows) { - r"C:\Users\anabel\history.db" - } else { - "/home/anabel/history.db" - }); - assert!(!under(sibling, profile)); + let root = tempfile::tempdir().expect("raiz"); + let ana = root.path().join("ana"); + let anabel = root.path().join("anabel"); + std::fs::create_dir_all(&ana).expect("ana"); + std::fs::create_dir_all(&anabel).expect("anabel"); + let history = anabel.join("history.db"); + std::fs::write(&history, b"x").expect("archivo"); + assert!( + !under(&history, &ana), + "un prefijo de texto no es un prefijo de ruta" + ); } #[test] @@ -2112,9 +2053,6 @@ mod identity { } } - /// La regresión: `find_by_hash` calculaba el hash del texto desnudo y - /// `insert_item` guardaba `fingerprint()`. Nunca coincidían, así que la - /// ruta de captura real no deduplicaba nada. #[test] fn what_was_captured_is_found_again() { let store = Store::in_memory().expect("abre"); @@ -2129,8 +2067,6 @@ mod identity { ); } - /// Pegar el mismo texto «como Markdown» o «en plano» produce un contenido - /// distinto, y eso es un ítem distinto: la identidad mira los bytes. #[test] fn a_different_rendering_is_a_different_item() { let store = Store::in_memory().expect("abre"); @@ -2146,8 +2082,6 @@ mod identity { ); } - /// Un texto guardado sin pasar por el portapapeles no lleva los formatos - /// que trae una copia real, así que no puede compartir identidad con ella. #[test] fn a_synthetic_text_is_not_a_captured_one() { assert_ne!( diff --git a/crates/cp-win-sys/Cargo.toml b/crates/cp-win-sys/Cargo.toml new file mode 100644 index 00000000..915b6011 --- /dev/null +++ b/crates/cp-win-sys/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cp-win-sys" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +all = { level = "deny", priority = -1 } +undocumented_unsafe_blocks = "deny" +multiple_unsafe_ops_per_block = "deny" diff --git a/crates/cp-win-sys/src/clipboard.rs b/crates/cp-win-sys/src/clipboard.rs new file mode 100644 index 00000000..362ef214 --- /dev/null +++ b/crates/cp-win-sys/src/clipboard.rs @@ -0,0 +1,123 @@ +use windows::Win32::Foundation::{HGLOBAL, HWND}; +use windows::Win32::System::DataExchange::{ + CloseClipboard, EnumClipboardFormats, GetClipboardData, GetClipboardOwner, + GetClipboardSequenceNumber, OpenClipboard, +}; +use windows::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock}; + +const BACKOFF_MS: &[u64] = &[0, 1, 2, 5, 10, 20, 50, 100, 200, 400]; + +pub struct Clipboard { + _private: (), +} + +impl Clipboard { + pub fn open() -> Option { + for wait in BACKOFF_MS { + // SAFETY: a null window makes the calling task the owner. + if unsafe { OpenClipboard(Some(HWND::default())) }.is_ok() { + return Some(Self { _private: () }); + } + std::thread::sleep(std::time::Duration::from_millis(*wait)); + } + None + } + + pub fn offered(&self) -> Vec { + let mut found = Vec::new(); + let mut id = 0u32; + loop { + // SAFETY: the clipboard is open for as long as `self` lives. + id = unsafe { EnumClipboardFormats(id) }; + if id == 0 { + break; + } + found.push(id); + } + found + } + + pub fn owner(&self) -> Option { + // SAFETY: returns a borrowed handle that is not released here. + let owner = unsafe { GetClipboardOwner() }.ok()?; + (!owner.is_invalid()).then_some(owner) + } + + pub fn bytes(&self, id: u32) -> Option> { + // SAFETY: returns a borrowed handle owned by whoever filled the clipboard. + let handle = unsafe { GetClipboardData(id) }.ok()?; + if handle.is_invalid() { + return None; + } + let global = HGLOBAL(handle.0); + // SAFETY: the handle came from the clipboard and is still owned by it. + let size = unsafe { GlobalSize(global) }; + if size == 0 { + return None; + } + // SAFETY: the handle is valid and the lock is released below. + let address = unsafe { GlobalLock(global) }; + if address.is_null() { + return None; + } + // SAFETY: the system reports `size` readable bytes at `address`. + let bytes = unsafe { std::slice::from_raw_parts(address.cast::(), size) }.to_vec(); + // SAFETY: matches the lock above. + let _ = unsafe { GlobalUnlock(global) }; + Some(bytes) + } + + pub fn size_of(&self, id: u32) -> Option { + // SAFETY: returns a borrowed handle owned by the clipboard. + let handle = unsafe { GetClipboardData(id) }.ok()?; + if handle.is_invalid() { + return None; + } + // SAFETY: the handle came from the clipboard and is still owned by it. + let size = unsafe { GlobalSize(HGLOBAL(handle.0)) }; + (size > 0).then_some(size) + } +} + +impl Drop for Clipboard { + fn drop(&mut self) { + // SAFETY: pairs with the successful open that produced this value. + let _ = unsafe { CloseClipboard() }; + } +} + +pub fn sequence() -> Option { + // SAFETY: the call takes no arguments and returns a plain integer. + let raw = unsafe { GetClipboardSequenceNumber() }; + (raw != 0).then_some(i64::from(raw)) +} + +pub fn register(name: &str) -> Option { + let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: the string is null terminated and outlives the call. + let id = unsafe { + windows::Win32::System::DataExchange::RegisterClipboardFormatW(windows::core::PCWSTR( + wide.as_ptr(), + )) + }; + (id != 0).then_some(id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_backoff_covers_most_of_a_second_without_a_busy_loop() { + assert_eq!(BACKOFF_MS.first(), Some(&0), "el primer intento no espera"); + let total: u64 = BACKOFF_MS.iter().sum(); + assert!( + (700..=900).contains(&total), + "la cobertura total es de {total} ms" + ); + assert!( + BACKOFF_MS.windows(2).all(|pair| pair[0] <= pair[1]), + "el retroceso no puede acortarse" + ); + } +} diff --git a/crates/cp-win-sys/src/formats.rs b/crates/cp-win-sys/src/formats.rs new file mode 100644 index 00000000..89f0371c --- /dev/null +++ b/crates/cp-win-sys/src/formats.rs @@ -0,0 +1,123 @@ +use windows::Win32::System::DataExchange::GetClipboardFormatNameW; + +pub const CF_TEXT: u32 = 1; +pub const CF_BITMAP: u32 = 2; +pub const CF_METAFILEPICT: u32 = 3; +pub const CF_SYLK: u32 = 4; +pub const CF_DIF: u32 = 5; +pub const CF_TIFF: u32 = 6; +pub const CF_OEMTEXT: u32 = 7; +pub const CF_DIB: u32 = 8; +pub const CF_PALETTE: u32 = 9; +pub const CF_UNICODETEXT: u32 = 13; +pub const CF_ENHMETAFILE: u32 = 14; +pub const CF_HDROP: u32 = 15; +pub const CF_LOCALE: u32 = 16; +pub const CF_DIBV5: u32 = 17; + +const STANDARD: &[(u32, &str)] = &[ + (CF_TEXT, "CF_TEXT"), + (CF_BITMAP, "CF_BITMAP"), + (CF_METAFILEPICT, "CF_METAFILEPICT"), + (CF_SYLK, "CF_SYLK"), + (CF_DIF, "CF_DIF"), + (CF_TIFF, "CF_TIFF"), + (CF_OEMTEXT, "CF_OEMTEXT"), + (CF_DIB, "CF_DIB"), + (CF_PALETTE, "CF_PALETTE"), + (CF_UNICODETEXT, "CF_UNICODETEXT"), + (CF_ENHMETAFILE, "CF_ENHMETAFILE"), + (CF_HDROP, "CF_HDROP"), + (CF_LOCALE, "CF_LOCALE"), + (CF_DIBV5, "CF_DIBV5"), +]; + +pub fn standard_name(id: u32) -> Option<&'static str> { + STANDARD + .iter() + .find(|(known, _)| *known == id) + .map(|(_, name)| *name) +} + +pub fn standard_id(name: &str) -> Option { + STANDARD + .iter() + .find(|(_, known)| *known == name) + .map(|(id, _)| *id) +} + +pub fn id_of(name: &str) -> Option { + standard_id(name).or_else(|| crate::clipboard::register(name)) +} + +pub fn name_of(id: u32) -> String { + if let Some(known) = standard_name(id) { + return known.to_owned(); + } + let mut buffer = [0u16; 256]; + // SAFETY: the buffer is live and its length is passed as declared. + let written = unsafe { GetClipboardFormatNameW(id, &mut buffer) }; + if written > 0 { + String::from_utf16_lossy(&buffer[..written as usize]) + } else { + format!("#{id}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_standard_ids_are_the_ones_windows_documents() { + for (id, name) in [ + (1u32, "CF_TEXT"), + (8, "CF_DIB"), + (13, "CF_UNICODETEXT"), + (15, "CF_HDROP"), + (17, "CF_DIBV5"), + ] { + assert_eq!(standard_name(id), Some(name)); + } + } + + #[test] + fn a_registered_id_is_not_a_standard_one() { + assert_eq!(standard_name(49_161), None); + assert_eq!(standard_name(0), None); + } + + #[test] + fn every_standard_id_maps_to_exactly_one_name() { + for (id, _) in STANDARD { + let matches = STANDARD.iter().filter(|(other, _)| other == id).count(); + assert_eq!(matches, 1, "el identificador {id} está dos veces"); + } + } + + #[test] + fn a_name_goes_back_to_the_id_it_came_from() { + for id in [CF_TEXT, CF_DIB, CF_UNICODETEXT, CF_HDROP, CF_DIBV5] { + assert_eq!(standard_id(&name_of(id)), Some(id)); + } + } + + #[test] + fn a_name_that_is_not_standard_has_no_standard_id() { + assert_eq!(standard_id("Rich Text Format"), None); + assert_eq!(standard_id(""), None); + } + + #[test] + fn a_registered_name_still_resolves_to_an_id() { + let id = id_of("Rich Text Format").expect("se registra"); + assert!(id >= 0xC000, "los registrados viven por encima de 0xC000"); + assert_eq!(id_of("Rich Text Format"), Some(id), "y siempre el mismo"); + assert_eq!(name_of(id), "Rich Text Format"); + } + + #[test] + fn an_unknown_id_still_gets_a_name() { + assert_eq!(name_of(0), "#0"); + } +} diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs new file mode 100644 index 00000000..5dd68061 --- /dev/null +++ b/crates/cp-win-sys/src/frontmost.rs @@ -0,0 +1,208 @@ +use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, WPARAM}; +use windows::Win32::Security::{ + GetTokenInformation, TOKEN_MANDATORY_LABEL, TOKEN_QUERY, TokenIntegrityLevel, +}; +use windows::Win32::System::Threading::{ + AttachThreadInput, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, +}; +use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus; +use windows::Win32::UI::WindowsAndMessaging::{ + GUITHREADINFO, GetForegroundWindow, GetGUIThreadInfo, IsWindow, SMTO_ABORTIFHUNG, SMTO_BLOCK, + SendMessageTimeoutW, SetForegroundWindow, WM_NULL, +}; + +use crate::source::process_of; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Target { + pub window: HWND, + pub focus: Option, + pub thread: u32, +} + +pub fn foreground() -> Option { + // SAFETY: returns a borrowed handle that is not released here. + let window = unsafe { GetForegroundWindow() }; + (!window.is_invalid()).then_some(window) +} + +pub fn capture_target() -> Option { + let window = foreground()?; + // SAFETY: the window came from the system and is still borrowed. + let thread = + unsafe { windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(window, None) }; + Some(Target { + window, + focus: inner_focus(thread), + thread, + }) +} + +fn inner_focus(thread: u32) -> Option { + let mut info = GUITHREADINFO { + cbSize: u32::try_from(std::mem::size_of::()).ok()?, + ..Default::default() + }; + // SAFETY: the struct declares its own size as the call requires. + unsafe { GetGUIThreadInfo(thread, &mut info) }.ok()?; + (!info.hwndFocus.is_invalid()).then_some(info.hwndFocus) +} + +pub fn is_alive(window: HWND) -> bool { + // SAFETY: asking about a handle never dereferences it. + unsafe { IsWindow(Some(window)) }.as_bool() +} + +pub fn bring_forward(window: HWND) -> bool { + // SAFETY: the value is not to be trusted; callers check who is in front. + unsafe { SetForegroundWindow(window) }.as_bool() +} + +pub fn answers(window: HWND, patience_ms: u32) -> bool { + let mut ignored = 0usize; + // SAFETY: a cross-thread send that gives up rather than hanging on a stuck target. + let replied = unsafe { + SendMessageTimeoutW( + window, + WM_NULL, + WPARAM(0), + LPARAM(0), + SMTO_ABORTIFHUNG | SMTO_BLOCK, + patience_ms, + Some(&mut ignored), + ) + }; + replied.0 != 0 +} + +pub struct Attached { + ours: u32, + theirs: u32, +} + +impl Attached { + pub fn to(thread: u32) -> Option { + // SAFETY: the call only reads the current thread id. + let ours = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; + if thread == 0 || thread == ours { + return None; + } + // SAFETY: detached in Drop, after the input has been sent. + unsafe { AttachThreadInput(ours, thread, true) } + .as_bool() + .then_some(Self { + ours, + theirs: thread, + }) + } + + pub fn focus_on(&self, window: HWND) -> bool { + // SAFETY: the queues are attached, so focus can cross. + unsafe { SetFocus(Some(window)) }.is_ok() + } +} + +impl Drop for Attached { + fn drop(&mut self) { + // SAFETY: pairs with the attach that built this value. + let _ = unsafe { AttachThreadInput(self.ours, self.theirs, false) }; + } +} + +pub fn integrity_of(pid: u32) -> Option { + // SAFETY: the handle is closed on every path below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?; + let mut token = windows::Win32::Foundation::HANDLE::default(); + // SAFETY: the out parameter points at a live local. + let opened = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }; + // SAFETY: pairs with the OpenProcess above. + let _ = unsafe { CloseHandle(process) }; + opened.ok()?; + let level = level_of(token); + // SAFETY: pairs with the OpenProcessToken above. + let _ = unsafe { CloseHandle(token) }; + level +} + +fn level_of(token: windows::Win32::Foundation::HANDLE) -> Option { + let mut size = 0u32; + // SAFETY: a null buffer asks only for the size it would need. + let _ = unsafe { GetTokenInformation(token, TokenIntegrityLevel, None, 0, &mut size) }; + if size == 0 { + return None; + } + let mut buffer = vec![0u8; size as usize]; + // SAFETY: the buffer holds the size the system just asked for. + unsafe { + GetTokenInformation( + token, + TokenIntegrityLevel, + Some(buffer.as_mut_ptr().cast()), + size, + &mut size, + ) + } + .ok()?; + let label = buffer.as_ptr().cast::(); + // SAFETY: the system filled the buffer with the label it declared. + let sid = unsafe { (*label).Label.Sid }; + // SAFETY: the sid came from the system and is read, never written. + let count = unsafe { windows::Win32::Security::GetSidSubAuthorityCount(sid) }; + if count.is_null() { + return None; + } + // SAFETY: the pointer is not null and the last index is in range. + let last = unsafe { u32::from(*count) }.checked_sub(1)?; + // SAFETY: `last` is within the count the system reported. + let authority = unsafe { windows::Win32::Security::GetSidSubAuthority(sid, last) }; + // SAFETY: the pointer came from the sid and is read, never written. + Some(unsafe { *authority }) +} + +pub fn out_of_reach(target: HWND) -> bool { + let Some(theirs) = process_of(target).and_then(integrity_of) else { + return false; + }; + let Some(ours) = integrity_of(std::process::id()) else { + return false; + }; + theirs > ours +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn this_process_has_an_integrity_level() { + assert!(integrity_of(std::process::id()).is_some()); + } + + #[test] + fn a_process_that_does_not_exist_has_no_level() { + assert_eq!(integrity_of(0), None); + } + + #[test] + fn we_are_never_out_of_our_own_reach() { + let Some(window) = foreground() else { + return; + }; + if process_of(window) == Some(std::process::id()) { + assert!(!out_of_reach(window)); + } + } + + #[test] + fn an_invalid_window_is_not_alive() { + assert!(!is_alive(HWND::default())); + } + + #[test] + fn attaching_to_our_own_thread_is_refused() { + // SAFETY: the call only reads the current thread id. + let ours = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; + assert!(Attached::to(ours).is_none()); + assert!(Attached::to(0).is_none()); + } +} diff --git a/crates/cp-win-sys/src/keystroke.rs b/crates/cp-win-sys/src/keystroke.rs new file mode 100644 index 00000000..ebae0b53 --- /dev/null +++ b/crates/cp-win-sys/src/keystroke.rs @@ -0,0 +1,164 @@ +use windows::Win32::UI::Input::KeyboardAndMouse::{ + INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, MAP_VIRTUAL_KEY_TYPE, + MapVirtualKeyW, SendInput, VIRTUAL_KEY, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, VK_SHIFT, +}; + +pub const OURS: usize = 0x0C0B_9A57; + +const VK_V: VIRTUAL_KEY = VIRTUAL_KEY(b'V' as u16); + +pub fn paste_batch() -> Vec { + let mut batch = Vec::with_capacity(9); + for held in [VK_MENU, VK_SHIFT, VK_LWIN, VK_RWIN, VK_CONTROL] { + batch.push(key(held, true)); + } + batch.push(key(VK_CONTROL, false)); + batch.push(key(VK_V, false)); + batch.push(key(VK_V, true)); + batch.push(key(VK_CONTROL, true)); + batch +} + +fn key(code: VIRTUAL_KEY, up: bool) -> INPUT { + let mut flags = KEYBD_EVENT_FLAGS(0); + if up { + flags |= KEYEVENTF_KEYUP; + } + // SAFETY: the union holds a keyboard event because the type says so. + let scan = unsafe { MapVirtualKeyW(u32::from(code.0), MAP_VIRTUAL_KEY_TYPE(0)) } as u16; + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: windows::Win32::UI::Input::KeyboardAndMouse::KEYBDINPUT { + wVk: code, + wScan: scan, + dwFlags: flags, + time: 0, + dwExtraInfo: OURS, + }, + }, + } +} + +pub fn send(batch: &[INPUT]) -> bool { + // SAFETY: every entry is a keyboard event of the declared size. + let sent = unsafe { SendInput(batch, std::mem::size_of::() as i32) }; + sent as usize == batch.len() +} + +pub fn modifiers_still_held() -> bool { + [VK_CONTROL, VK_MENU, VK_SHIFT, VK_LWIN, VK_RWIN] + .into_iter() + .any(pressed) +} + +fn pressed(code: VIRTUAL_KEY) -> bool { + // SAFETY: the call only reads the asynchronous state of one key. + let state = + unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(i32::from(code.0)) }; + state as u16 & 0x8000 != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use windows::Win32::UI::Input::KeyboardAndMouse::KEYEVENTF_SCANCODE; + + #[test] + fn the_batch_releases_before_it_presses() { + let batch = paste_batch(); + assert_eq!(batch.len(), 9); + let ups: Vec = batch + .iter() + // SAFETY: every entry was built as a keyboard event. + .map(|one| unsafe { one.Anonymous.ki.dwFlags }.contains(KEYEVENTF_KEYUP)) + .collect(); + assert_eq!(ups[..5], [true; 5], "los cinco modificadores se sueltan"); + assert_eq!(ups[5..], [false, false, true, true]); + } + + #[test] + fn both_windows_keys_are_released_because_there_is_no_generic_one() { + let batch = paste_batch(); + // SAFETY: every entry was built as a keyboard event. + let codes: Vec = batch + .iter() + .map(|one| unsafe { one.Anonymous.ki.wVk }.0) + .collect(); + assert!(codes.contains(&VK_LWIN.0)); + assert!(codes.contains(&VK_RWIN.0)); + } + + #[test] + fn the_windows_key_is_released_before_the_v_is_pressed() { + let batch = paste_batch(); + // SAFETY: every entry was built as a keyboard event. + let codes: Vec = batch + .iter() + .map(|one| unsafe { one.Anonymous.ki.wVk }.0) + .collect(); + let win = codes + .iter() + .position(|code| *code == VK_LWIN.0) + .expect("win"); + let v = codes.iter().position(|code| *code == VK_V.0).expect("v"); + assert!( + win < v, + "con la tecla Windows pisada, la V abre el historial del sistema" + ); + } + + #[test] + fn every_event_carries_a_scan_code() { + for one in paste_batch() { + // SAFETY: the entry was built as a keyboard event. + let scan = unsafe { one.Anonymous.ki.wScan }; + assert_ne!(scan, 0, "hay destinos que leen el scancode y no el virtual"); + } + } + + #[test] + fn every_event_is_marked_as_ours() { + for one in paste_batch() { + // SAFETY: the entry was built as a keyboard event. + assert_eq!(unsafe { one.Anonymous.ki.dwExtraInfo }, OURS); + } + } + + #[test] + fn the_control_that_presses_is_not_the_one_that_releases() { + let batch = paste_batch(); + // SAFETY: every entry was built as a keyboard event. + let control: Vec = batch + .iter() + .filter(|one| unsafe { one.Anonymous.ki.wVk } == VK_CONTROL) + .map(|one| unsafe { one.Anonymous.ki.dwFlags }.contains(KEYEVENTF_KEYUP)) + .collect(); + assert_eq!( + control, + [true, false, true], + "suelta, pulsa y vuelve a soltar" + ); + } + + #[test] + fn the_batch_is_not_sent_in_pieces() { + assert_eq!( + paste_batch().len(), + 9, + "partirlo deja que otro inyector se intercale" + ); + } + + #[test] + fn nothing_is_flagged_as_a_bare_scan_code() { + for one in paste_batch() { + // SAFETY: the entry was built as a keyboard event. + let flags = unsafe { one.Anonymous.ki.dwFlags }; + assert!( + !flags.contains(KEYEVENTF_SCANCODE), + "el codigo virtual es el que respeta la distribucion" + ); + } + } +} diff --git a/crates/cp-win-sys/src/lib.rs b/crates/cp-win-sys/src/lib.rs new file mode 100644 index 00000000..1ed70b90 --- /dev/null +++ b/crates/cp-win-sys/src/lib.rs @@ -0,0 +1,10 @@ +#![cfg(target_os = "windows")] + +pub mod clipboard; +pub mod formats; +pub mod frontmost; +pub mod keystroke; +pub mod paths; +pub mod reading; +pub mod source; +pub mod writing; diff --git a/crates/cp-win-sys/src/paths.rs b/crates/cp-win-sys/src/paths.rs new file mode 100644 index 00000000..0a236abe --- /dev/null +++ b/crates/cp-win-sys/src/paths.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; + +pub fn data_dir() -> Option { + let local = std::env::var_os("LOCALAPPDATA")?; + Some(PathBuf::from(local).join("CopyPaste")) +} + +pub fn database() -> Option { + Some(data_dir()?.join("history.db")) +} + +pub fn legacy_database() -> Option { + Some(data_dir()?.join("clipboard.db")) +} + +pub fn blobs_dir() -> Option { + Some(data_dir()?.join("blobs")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn everything_hangs_from_one_folder() { + let root = data_dir().expect("LOCALAPPDATA"); + for path in [ + database().expect("base"), + legacy_database().expect("base vieja"), + blobs_dir().expect("blobs"), + ] { + assert!(path.starts_with(&root), "{path:?} se salió de {root:?}"); + } + } + + #[test] + fn the_data_folder_is_local_and_never_roaming() { + let root = data_dir().expect("LOCALAPPDATA"); + let text = root.to_string_lossy().to_ascii_lowercase(); + assert!(text.contains("local"), "{text}"); + assert!( + !text.contains("roaming"), + "un perfil itinerante subiría el historial a la red: {text}" + ); + } + + #[test] + fn the_new_database_never_touches_the_one_from_2x() { + assert_ne!(database(), legacy_database()); + assert_eq!( + database().and_then(|p| p.file_name().map(std::ffi::OsString::from)), + Some("history.db".into()) + ); + assert_eq!( + legacy_database().and_then(|p| p.file_name().map(std::ffi::OsString::from)), + Some("clipboard.db".into()) + ); + } + + #[test] + fn the_folder_is_the_one_2x_already_uses() { + let root = data_dir().expect("LOCALAPPDATA"); + assert_eq!(root.file_name().and_then(|n| n.to_str()), Some("CopyPaste")); + } +} diff --git a/crates/cp-win-sys/src/reading.rs b/crates/cp-win-sys/src/reading.rs new file mode 100644 index 00000000..025b3474 --- /dev/null +++ b/crates/cp-win-sys/src/reading.rs @@ -0,0 +1,99 @@ +use std::time::Duration; + +pub const PATIENCE: Duration = Duration::from_millis(100); + +const _: () = assert!(PATIENCE.as_millis() > 10); +const _: () = assert!(PATIENCE.as_millis() < 30_000); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reading { + Delivered(Vec), + Empty, + TooSlow, +} + +impl Reading { + pub fn bytes(self) -> Option> { + match self { + Reading::Delivered(bytes) => Some(bytes), + Reading::Empty | Reading::TooSlow => None, + } + } + + pub fn is_too_slow(&self) -> bool { + matches!(self, Reading::TooSlow) + } +} + +pub fn within( + patience: Duration, + read: impl FnOnce() -> Option> + Send + 'static, +) -> Reading { + let (tell, hear) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tell.send(read()); + }); + match hear.recv_timeout(patience) { + Ok(Some(bytes)) => Reading::Delivered(bytes), + Ok(None) => Reading::Empty, + Err(_) => Reading::TooSlow, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_answers_in_time_is_delivered() { + let seen = within(PATIENCE, || Some(vec![1, 2, 3])); + assert_eq!(seen, Reading::Delivered(vec![1, 2, 3])); + assert_eq!(seen.bytes(), Some(vec![1, 2, 3])); + } + + #[test] + fn what_answers_nothing_is_empty_not_slow() { + let seen = within(PATIENCE, || None); + assert_eq!(seen, Reading::Empty); + assert!(!seen.is_too_slow()); + assert_eq!(seen.bytes(), None); + } + + #[test] + fn what_does_not_answer_in_time_is_abandoned() { + let seen = within(Duration::from_millis(20), || { + std::thread::sleep(Duration::from_millis(400)); + Some(vec![9]) + }); + assert_eq!(seen, Reading::TooSlow); + assert!(seen.is_too_slow()); + assert_eq!(seen.bytes(), None); + } + + #[test] + fn abandoning_one_read_does_not_poison_the_next() { + let abandoned = within(Duration::from_millis(10), || { + std::thread::sleep(Duration::from_millis(300)); + Some(vec![0]) + }); + assert!(abandoned.is_too_slow()); + assert_eq!( + within(PATIENCE, || Some(vec![7])), + Reading::Delivered(vec![7]) + ); + } + + #[test] + fn the_patience_sits_between_the_two_measured_worlds() { + let good = Duration::from_millis(2); + let hung = Duration::from_millis(30_000); + assert!(good < PATIENCE, "lo que entrega responde en 1,5 ms"); + assert!(PATIENCE < hung, "lo que cuelga tarda 30 s en rendirse"); + } + + #[test] + fn a_read_that_panics_is_abandoned_like_any_other() { + let seen = within(Duration::from_millis(50), || panic!("el proveedor murió")); + assert_eq!(seen, Reading::TooSlow); + } +} diff --git a/crates/cp-win-sys/src/source.rs b/crates/cp-win-sys/src/source.rs new file mode 100644 index 00000000..525bffac --- /dev/null +++ b/crates/cp-win-sys/src/source.rs @@ -0,0 +1,78 @@ +use windows::Win32::Foundation::{CloseHandle, HWND, MAX_PATH}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, +}; +use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; + +pub fn process_of(window: HWND) -> Option { + let mut pid = 0u32; + // SAFETY: the out parameter points at a live local. + let thread = unsafe { GetWindowThreadProcessId(window, Some(&mut pid)) }; + (thread != 0 && pid != 0).then_some(pid) +} + +pub fn name_of(pid: u32) -> Option { + // SAFETY: the handle is closed below on every path. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?; + let mut buffer = [0u16; MAX_PATH as usize]; + let mut written = buffer.len() as u32; + // SAFETY: the buffer is live and its length is passed by reference as declared. + let queried = unsafe { + QueryFullProcessImageNameW( + process, + PROCESS_NAME_FORMAT(0), + windows::core::PWSTR(buffer.as_mut_ptr()), + &mut written, + ) + }; + // SAFETY: pairs with the OpenProcess above. + let _ = unsafe { CloseHandle(process) }; + queried.ok()?; + let path = String::from_utf16_lossy(&buffer[..written as usize]); + Some(stem_of(&path)) +} + +fn stem_of(path: &str) -> String { + let file = path.rsplit(['\\', '/']).next().unwrap_or(path); + file.rsplit_once('.') + .map_or(file, |(stem, _)| stem) + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_name_is_what_a_person_recognises() { + assert_eq!( + stem_of(r"C:\Program Files\Google\Chrome\chrome.exe"), + "chrome" + ); + assert_eq!(stem_of(r"C:\Windows\explorer.exe"), "explorer"); + assert_eq!(stem_of("WINWORD.EXE"), "WINWORD"); + } + + #[test] + fn a_name_without_a_folder_or_an_extension_still_works() { + assert_eq!(stem_of("notepad"), "notepad"); + assert_eq!(stem_of(""), ""); + } + + #[test] + fn a_folder_with_a_dot_does_not_eat_the_name() { + assert_eq!(stem_of(r"C:\apps\v1.2\editor.exe"), "editor"); + assert_eq!(stem_of(r"C:\apps\v1.2\editor"), "editor"); + } + + #[test] + fn both_separators_are_understood() { + assert_eq!(stem_of("C:/Windows/System32/cmd.exe"), "cmd"); + } + + #[test] + fn this_very_process_can_be_named() { + let mine = std::process::id(); + assert!(name_of(mine).is_some()); + } +} diff --git a/crates/cp-win-sys/src/writing.rs b/crates/cp-win-sys/src/writing.rs new file mode 100644 index 00000000..0faab213 --- /dev/null +++ b/crates/cp-win-sys/src/writing.rs @@ -0,0 +1,122 @@ +use windows::Win32::Foundation::{GlobalFree, HANDLE, HGLOBAL}; +use windows::Win32::System::DataExchange::{EmptyClipboard, SetClipboardData}; +use windows::Win32::System::Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock}; + +use crate::clipboard::Clipboard; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Written { + Placed { formats: usize }, + Refused, +} + +impl Clipboard { + pub fn replace(&self, entries: &[(u32, &[u8])]) -> Written { + if entries.is_empty() { + return Written::Refused; + } + // SAFETY: the clipboard is open and owned by this task for as long as `self` lives. + if unsafe { EmptyClipboard() }.is_err() { + return Written::Refused; + } + let mut placed = 0; + for (id, bytes) in entries { + if handed_over(*id, bytes) { + placed += 1; + } + } + if placed == 0 { + Written::Refused + } else { + Written::Placed { formats: placed } + } + } +} + +fn handed_over(id: u32, bytes: &[u8]) -> bool { + let Some(block) = block_of(bytes) else { + return false; + }; + // SAFETY: on success the system takes ownership of the block. + match unsafe { SetClipboardData(id, Some(HANDLE(block.0))) } { + Ok(_) => true, + Err(_) => { + // SAFETY: ownership stayed here because the call failed. + let _ = unsafe { GlobalFree(Some(block)) }; + false + } + } +} + +fn block_of(bytes: &[u8]) -> Option { + // SAFETY: a moveable block of the requested size, released below on failure. + let block = unsafe { GlobalAlloc(GMEM_MOVEABLE, bytes.len()) }.ok()?; + // SAFETY: the block was just allocated and is unlocked. + let address = unsafe { GlobalLock(block) }; + if address.is_null() { + // SAFETY: nothing else holds the block. + let _ = unsafe { GlobalFree(Some(block)) }; + return None; + } + // SAFETY: the block holds exactly `bytes.len()` writable bytes. + unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), address.cast::(), bytes.len()) }; + // SAFETY: matches the lock above. + let _ = unsafe { GlobalUnlock(block) }; + Some(block) +} + +pub fn utf16_of(text: &str) -> Vec { + let mut units: Vec = text.encode_utf16().collect(); + units.push(0); + units.iter().flat_map(|unit| unit.to_le_bytes()).collect() +} + +pub fn text_of(bytes: &[u8]) -> Option { + let units: Vec = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .take_while(|unit| *unit != 0) + .collect(); + String::from_utf16(&units).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_survives_the_round_trip() { + for original in ["hola", "", "acentos: ñáéíóú", "emoji: 🦀", "日本語"] { + let bytes = utf16_of(original); + assert_eq!(text_of(&bytes).as_deref(), Some(original), "«{original}»"); + } + } + + #[test] + fn the_encoding_ends_where_the_terminator_says() { + let bytes = utf16_of("ab"); + assert_eq!(bytes.len(), 6, "dos unidades más el cero"); + assert_eq!(&bytes[4..], &[0, 0]); + } + + #[test] + fn what_comes_after_the_terminator_is_not_text() { + let mut bytes = utf16_of("hola"); + bytes.extend_from_slice(&utf16_of("basura")); + assert_eq!(text_of(&bytes).as_deref(), Some("hola")); + } + + #[test] + fn an_odd_number_of_bytes_does_not_panic() { + assert_eq!(text_of(&[0x68]), Some(String::new())); + assert_eq!(text_of(&[]), Some(String::new())); + } + + #[test] + fn a_lone_surrogate_is_refused_instead_of_mangled() { + let broken = [0x00u8, 0xD8, 0x41, 0x00]; + assert_eq!(text_of(&broken), None); + } +} diff --git a/crates/cp-win/Cargo.toml b/crates/cp-win/Cargo.toml index 96c8bfe5..6eff7585 100644 --- a/crates/cp-win/Cargo.toml +++ b/crates/cp-win/Cargo.toml @@ -9,6 +9,8 @@ publish = false [target.'cfg(target_os = "windows")'.dependencies] cp-core.workspace = true +cp-win-sys.workspace = true +windows.workspace = true [lints] workspace = true diff --git a/crates/cp-win/examples/probe.rs b/crates/cp-win/examples/probe.rs new file mode 100644 index 00000000..dedbefe3 --- /dev/null +++ b/crates/cp-win/examples/probe.rs @@ -0,0 +1,582 @@ +#![cfg(target_os = "windows")] + +use cp_core::formats::{Family, Take}; +use cp_core::watch::{Cadence, Seen, Watcher}; +use cp_win::capture::{Captured, capture}; +use cp_win::formats::CATALOG; +use cp_win::restore::{Restored, to_clipboard, to_clipboard_as_plain_text}; +use cp_win::transfer::{self, Transfer}; +use cp_win_sys::clipboard::{self, Clipboard}; +use cp_win_sys::formats::{CF_UNICODETEXT, name_of}; +use cp_win_sys::reading::{self, PATIENCE, Reading}; +use cp_win_sys::writing::{Written, text_of, utf16_of}; + +struct Battery { + passed: u32, + failed: u32, + skipped: u32, +} + +impl Battery { + fn group(&self, name: &str) { + println!("\n {name}"); + } + + fn case(&mut self, id: &str, what: &str, run: impl FnOnce() -> Result<(), String>) { + match run() { + Ok(()) => { + self.passed += 1; + println!(" ok {id:<5} {what}"); + } + Err(why) => { + self.failed += 1; + println!(" FALLA {id:<5} {what}\n {why}"); + } + } + } + + fn skip(&mut self, id: &str, what: &str, why: &str) { + self.skipped += 1; + println!(" salta {id:<5} {what}\n {why}"); + } +} + +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()) +} + +fn main() -> std::process::ExitCode { + println!("\nBatería del núcleo contra el portapapeles de Windows"); + + let mut b = Battery { + passed: 0, + failed: 0, + skipped: 0, + }; + + b.group("A · El portapapeles responde"); + + b.case("A1", "se abre y se cierra sin quedarse tomado", || { + { + let _first = Clipboard::open().ok_or("no abrió")?; + } + let _second = Clipboard::open().ok_or("no abrió la segunda vez")?; + Ok(()) + }); + + b.case("A2", "el contador de secuencia se lee", || { + clipboard::sequence() + .map(|_| ()) + .ok_or_else(|| "devolvió cero: no se alcanza la estación de ventanas".into()) + }); + + b.case("A3", "enumerar no pide un solo byte", || { + let names = offered_names()?; + println!(" {} formatos: {}", names.len(), names.join(", ")); + Ok(()) + }); + + b.group("B · El catálogo contra lo que hay de verdad"); + + b.case("B1", "todo lo ofrecido recibe una decisión", || { + let names = offered_names()?; + if names.is_empty() { + return Err("el portapapeles está vacío: copia algo y repite".into()); + } + for name in &names { + let take = CATALOG.decide(name); + let mark = match take { + Take::Payload => "copia", + Take::Presence => "anota", + Take::Never => "nunca", + }; + println!(" {mark} {name}"); + } + Ok(()) + }); + + b.case("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()); + } + 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) { + continue; + } + match clipboard.size_of(*id) { + Some(size) => { + read += 1; + bytes += size; + println!(" {size:>9} B {name}"); + } + None => println!(" {:>9} {name}", "sin datos"), + } + } + if read == 0 { + return Err("nada de lo que el catálogo quiere entregó bytes".into()); + } + println!(" {read} formatos, {bytes} bytes"); + Ok(()) + }); + + b.case("B3", "la clase que sale es una de las tres", || { + let names = offered_names()?; + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + match CATALOG.classify(&refs) { + Some(family) => { + println!(" {family:?}"); + Ok(()) + } + None => Err(format!("ninguna clase para {names:?}")), + } + }); + + let names = offered_names().unwrap_or_default(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let courtesy = refs.iter().any(|id| CATALOG.embeddable.contains(id)) + && CATALOG.preferred_image(&refs).is_some(); + if courtesy { + b.case( + "B4", + "una hoja de cálculo no se guarda como foto", + || match CATALOG.classify(&refs) { + Some(Family::Text) => Ok(()), + other => Err(format!("se clasificó como {other:?}")), + }, + ); + } else { + b.skip( + "B4", + "una hoja de cálculo no se guarda como foto", + "lo copiado no es un documento con imagen: copia un rango de Excel y repite", + ); + } + + b.group("F · Ida y vuelta, montada por nosotros"); + + b.case("F1", "lo que escribimos se vuelve a leer igual", || { + let written = "cp-f1-ida-y-vuelta ñ 🦀"; + { + let clipboard = Clipboard::open().ok_or("no abrió para escribir")?; + match clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]) { + Written::Placed { formats: 1 } => {} + other => return Err(format!("la escritura dio {other:?}")), + } + } + let clipboard = Clipboard::open().ok_or("no abrió para leer")?; + let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("no devolvió bytes")?; + match text_of(&bytes).as_deref() { + Some(back) if back == written => Ok(()), + other => Err(format!("volvió «{other:?}»")), + } + }); + + b.case("F2", "escribir mueve el contador y leer no", || { + let before = clipboard::sequence().ok_or("sin contador")?; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f2"))]); + } + let after = clipboard::sequence().ok_or("sin contador")?; + if after == before { + return Err("el contador no se movió al escribir".into()); + } + let quiet = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let _ = clipboard.bytes(CF_UNICODETEXT); + clipboard::sequence().ok_or("sin contador")? + }; + if quiet != after { + return Err(format!("leer movió el contador de {after} a {quiet}")); + } + println!(" {before} → {after} por una escritura"); + Ok(()) + }); + + b.case( + "F3", + "el vigilante ve nuestra escritura como nuestra", + || { + let mut watcher = Watcher::new(Cadence::Opaque); + watcher.tick(clipboard::sequence().ok_or("sin contador")?); + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f3"))]); + } + let ours = clipboard::sequence().ok_or("sin contador")?; + watcher.wrote(ours); + match watcher.tick(ours) { + Seen::Ours => Ok(()), + other => Err(format!("se vio como {other:?}")), + } + }, + ); + + b.case("F4", "una copia ajena tras la nuestra no se traga", || { + let mut watcher = Watcher::new(Cadence::Opaque); + watcher.tick(clipboard::sequence().ok_or("sin contador")?); + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f4-nuestro"))]); + } + let ours = clipboard::sequence().ok_or("sin contador")?; + watcher.wrote(ours); + if watcher.tick(ours) != Seen::Ours { + return Err("la nuestra no se reconoció".into()); + } + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f4-ajeno"))]); + } + let theirs = clipboard::sequence().ok_or("sin contador")?; + match watcher.tick(theirs) { + Seen::Fresh { .. } => Ok(()), + other => Err(format!("la siguiente copia se vio como {other:?}")), + } + }); + + b.group("G · Lo que no entrega a tiempo se abandona"); + + b.case( + "G1", + "un formato con datos responde muy por debajo del techo", + || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-g1"))]); + } + let started = std::time::Instant::now(); + let seen = reading::within(PATIENCE, || { + let clipboard = Clipboard::open()?; + clipboard.bytes(CF_UNICODETEXT) + }); + let took = started.elapsed(); + if seen.is_too_slow() { + return Err(format!("no llegó en {PATIENCE:?}")); + } + println!(" {took:?} contra un techo de {PATIENCE:?}"); + Ok(()) + }, + ); + + b.case("G2", "lo que no contesta se abandona en el techo", || { + let started = std::time::Instant::now(); + let seen = reading::within(PATIENCE, || { + std::thread::sleep(std::time::Duration::from_secs(30)); + Some(Vec::new()) + }); + let took = started.elapsed(); + if seen != Reading::TooSlow { + return Err(format!("se esperó de más y dio {seen:?}")); + } + if took > PATIENCE * 3 { + return Err(format!("tardó {took:?} en rendirse")); + } + println!(" abandonado en {took:?}, no en los 30 s medidos"); + Ok(()) + }); + + b.group("H · La captura, de punta a punta"); + + b.case("H1", "un texto copiado se convierte en un ítem", || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("alguien@ejemplo.test"))]); + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(item) => { + if item.kind != Some(cp_core::kind::Kind::Email) { + return Err(format!("la clase salió {:?}", item.kind)); + } + println!( + " {} formatos, {} bytes guardados, clase {:?}", + item.formats.len(), + item.stored_bytes(), + item.kind + ); + Ok(()) + } + other => Err(format!("no se capturó: {other:?}")), + } + }); + + b.case( + "H2", + "el conjunto entero se anota, no solo lo que se copia", + || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let offered = clipboard.offered().len(); + match capture(&clipboard) { + Captured::Kept(item) => { + if item.formats.len() < offered { + return Err(format!( + "se ofrecieron {offered} y solo se anotaron {}", + item.formats.len() + )); + } + Ok(()) + } + other => Err(format!("no se capturó: {other:?}")), + } + }, + ); + + b.case("H3", "dos copias iguales tienen la misma huella", || { + let write = |text: &str| { + let clipboard = Clipboard::open()?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(text))]); + Some(()) + }; + let taken = |()| { + let clipboard = Clipboard::open()?; + match capture(&clipboard) { + Captured::Kept(item) => Some(item.fingerprint()), + _ => None, + } + }; + write("cp-h3-mismo").ok_or("no escribió")?; + let first = taken(()).ok_or("no capturó")?; + write("cp-h3-mismo").ok_or("no escribió")?; + let again = taken(()).ok_or("no capturó")?; + write("cp-h3-distinto").ok_or("no escribió")?; + let other = taken(()).ok_or("no capturó")?; + if first != again { + return Err("lo mismo dio dos huellas".into()); + } + if first == other { + return Err("dos contenidos distintos dieron la misma huella".into()); + } + Ok(()) + }); + + b.case("H4", "un marcador de secreto detiene la captura", || { + let marker = cp_win_sys::formats::name_of( + cp_win_sys::clipboard::register("Clipboard Viewer Ignore").ok_or("no registró")?, + ); + if marker != "Clipboard Viewer Ignore" { + return Err(format!("el formato se registró como «{marker}»")); + } + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let id = + cp_win_sys::clipboard::register("Clipboard Viewer Ignore").ok_or("no registró")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("una-contrasena")), (id, &[1u8])]); + } + let seen = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + capture(&clipboard) + }; + { + let clipboard = Clipboard::open().ok_or("no abrió para limpiar")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-h4-limpio"))]); + } + match seen { + Captured::Refused(why) => { + println!(" rechazada por {why:?}, y el marcador se retiró"); + Ok(()) + } + other => Err(format!("se capturó igualmente: {other:?}")), + } + }); + + b.case("H5", "la batería no deja marcadores puestos", || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let names: Vec = clipboard.offered().into_iter().map(name_of).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + match CATALOG.refusal(&refs) { + None => Ok(()), + Some(left) => Err(format!("quedó {left:?} del caso anterior")), + } + }); + + b.group("I · Restaurar"); + + b.case( + "I1", + "un ítem vuelve al portapapeles con sus formatos", + || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i1-original"))]); + } + 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:?}")), + } + }; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i1-otra-cosa"))]); + } + let written = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard(&clipboard, &item) + }; + match written { + Restored::Written { formats, .. } => { + println!(" {formats} formatos devueltos"); + } + other => return Err(format!("no se restauró: {other:?}")), + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("sin texto")?; + match text_of(&bytes).as_deref() { + Some("cp-i1-original") => Ok(()), + other => Err(format!("volvió «{other:?}»")), + } + }, + ); + + b.case("I2", "capturar lo restaurado da la misma huella", || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i2-ida-y-vuelta"))]); + } + let (first, item) = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(item) => (item.fingerprint(), item), + other => return Err(format!("no se capturó: {other:?}")), + } + }; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard(&clipboard, &item); + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(again) => { + if again.fingerprint() == first { + Ok(()) + } else { + Err("la huella cambió al ir y volver".into()) + } + } + other => Err(format!("no se recapturó: {other:?}")), + } + }); + + b.case("I3", "pegar en plano no muda lo guardado", || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i3-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:?}")), + } + }; + let before = item.clone(); + let written = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard_as_plain_text(&clipboard, &item) + }; + if !matches!( + written, + Restored::Written { + incomplete: false, + .. + } + ) { + return Err(format!("la escritura plana dio {written:?}")); + } + if item != before { + return Err("el ítem se mutiló al pegarlo en plano".into()); + } + Ok(()) + }); + + b.group("C · Los formatos que cuelgan no se piden"); + + b.case("C1", "nada marcado como presencia se llega a pedir", || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let ids = clipboard.offered(); + let risky: Vec = ids + .iter() + .map(|id| name_of(*id)) + .filter(|name| CATALOG.decide(name) != Take::Payload) + .collect(); + println!( + " {} anotados sin pedir: {}", + risky.len(), + risky.join(", ") + ); + Ok(()) + }); + + b.group("D · El vigilante"); + + b.case("D1", "una escritura ajena se ve como copia nueva", || { + let mut watcher = Watcher::new(Cadence::Opaque); + let start = clipboard::sequence().ok_or("sin contador")?; + watcher.tick(start); + if watcher.tick(start) != Seen::Nothing { + return Err("un contador quieto produjo un evento".into()); + } + Ok(()) + }); + + b.case("D2", "el contador no se mueve al leer", || { + let before = clipboard::sequence().ok_or("sin contador")?; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + for id in clipboard.offered() { + let _ = clipboard.size_of(id); + } + } + let after = clipboard::sequence().ok_or("sin contador")?; + if before != after { + return Err(format!("pasó de {before} a {after}")); + } + Ok(()) + }); + + b.group("E · Copiar o cortar"); + + let effect = { + let clipboard = Clipboard::open(); + clipboard.and_then(|clipboard| { + let ids = clipboard.offered(); + ids.iter() + .find(|id| name_of(**id) == "Preferred DropEffect") + .and_then(|id| clipboard.bytes(*id)) + }) + }; + match effect { + Some(bytes) => b.case("E1", "el efecto se lee por sus bits", || { + let seen = transfer::transfer(&bytes); + println!(" {seen:?} sobre {bytes:?}"); + if seen == Transfer::Unsaid { + return Err("el explorador siempre dice copia o corte".into()); + } + Ok(()) + }), + None => b.skip( + "E1", + "el efecto se lee por sus bits", + "no hay archivos copiados: hazlo en el explorador y repite", + ), + } + + println!( + "\n {} ok, {} fallan, {} saltadas\n", + b.passed, b.failed, b.skipped + ); + if b.failed > 0 { + std::process::ExitCode::FAILURE + } else { + std::process::ExitCode::SUCCESS + } +} diff --git a/crates/cp-win/src/capture.rs b/crates/cp-win/src/capture.rs new file mode 100644 index 00000000..23d446db --- /dev/null +++ b/crates/cp-win/src/capture.rs @@ -0,0 +1,288 @@ +use cp_core::dib; +use cp_core::formats::{Family, Refusal, Take}; +use cp_core::item::{Format, Item, Payload, SYNTHETIC_IMAGE}; +use cp_core::kind::{self, Kind}; +use cp_win_sys::clipboard::Clipboard; +use cp_win_sys::formats::name_of; +use cp_win_sys::reading::{self, PATIENCE, Reading}; +use cp_win_sys::writing::text_of; + +use crate::formats::CATALOG; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Captured { + Kept(Item), + Refused(Refusal), + Nothing, +} + +pub fn capture(clipboard: &Clipboard) -> Captured { + let ids = clipboard.offered(); + if ids.is_empty() { + return Captured::Nothing; + } + let names: Vec = ids.iter().map(|id| name_of(*id)).collect(); + let offered: Vec<&str> = names.iter().map(String::as_str).collect(); + + if let Some(refusal) = CATALOG.refusal(&offered) { + return Captured::Refused(refusal); + } + if let Some(refusal) = asked_not_to_be_kept(clipboard, &ids, &names) { + return Captured::Refused(refusal); + } + + let family = CATALOG.classify(&offered); + 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); + formats.push(Format { + id: name.clone(), + payload, + }); + } + if let Some(png) = transcoded_image(clipboard, &ids, &names, &offered) { + formats.push(png); + } + + Captured::Kept(Item { + kind: refine(family, &formats), + formats, + }) +} + +fn asked_not_to_be_kept(clipboard: &Clipboard, ids: &[u32], names: &[String]) -> Option { + ids.iter().zip(names).find_map(|(id, name)| { + if !CATALOG.denied_when_zero.contains(&name.as_str()) { + return None; + } + let value = read(clipboard, *id).bytes().unwrap_or_default(); + CATALOG.declines(name, &value) + }) +} + +fn payload_for(clipboard: &Clipboard, id: u32, name: &str, offered: &[&str]) -> Payload { + if CATALOG.decide(name) != Take::Payload || CATALOG.costlier_twin(name, offered) { + return Payload::Announced { + size: clipboard.size_of(id), + }; + } + match read(clipboard, id) { + Reading::Delivered(bytes) => Payload::stored(bytes), + Reading::Empty | Reading::TooSlow => Payload::Absent, + } +} + +fn read(clipboard: &Clipboard, id: u32) -> Reading { + let bytes = clipboard.bytes(id); + reading::within(PATIENCE, move || bytes) +} + +fn transcoded_image( + clipboard: &Clipboard, + ids: &[u32], + names: &[String], + offered: &[&str], +) -> Option { + let chosen = CATALOG.preferred_image(offered)?; + if !chosen.starts_with("CF_DIB") { + return None; + } + let id = ids + .iter() + .zip(names) + .find(|(_, name)| name.as_str() == chosen) + .map(|(id, _)| *id)?; + let raw = read(clipboard, id).bytes()?; + let png = dib::to_png(&raw)?; + Some(Format { + id: SYNTHETIC_IMAGE.into(), + payload: Payload::stored(png), + }) +} + +fn refine(family: Option, formats: &[Format]) -> Option { + match family? { + Family::Image => Some(Kind::Image), + Family::Text => { + let text = bytes_of(formats, "CF_UNICODETEXT").and_then(text_of); + Some(text.as_deref().map_or(Kind::Text, kind::classify_text)) + } + Family::Files => Some(match first_path(formats) { + Some(path) => { + let name = path.rsplit(['\\', '/']).next().unwrap_or(&path).to_owned(); + kind::classify_file(&name, path.ends_with('\\')) + } + None => Kind::File, + }), + } +} + +fn bytes_of<'a>(formats: &'a [Format], id: &str) -> Option<&'a [u8]> { + formats + .iter() + .find(|one| one.id == id) + .and_then(|one| match &one.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => Some(bytes.as_slice()), + _ => None, + }) +} + +fn first_path(formats: &[Format]) -> Option { + let drop = bytes_of(formats, "CF_HDROP")?; + 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 + } + + #[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)), + }]; + assert_eq!(refine(Some(Family::Files), &formats), Some(Kind::Video)); + } + + #[test] + 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)), + }]; + assert_eq!(refine(Some(Family::Files), &formats), Some(Kind::Folder)); + } + + #[test] + fn text_is_refined_by_what_it_says() { + let formats = vec![Format { + id: "CF_UNICODETEXT".into(), + payload: Payload::Inline(cp_win_sys::writing::utf16_of("alguien@ejemplo.test")), + }]; + assert_eq!(refine(Some(Family::Text), &formats), Some(Kind::Email)); + } + + #[test] + fn text_that_could_not_be_read_is_still_text() { + let formats = vec![Format { + id: "CF_UNICODETEXT".into(), + payload: Payload::Absent, + }]; + assert_eq!(refine(Some(Family::Text), &formats), Some(Kind::Text)); + } + + #[test] + fn an_image_needs_no_refining() { + assert_eq!(refine(Some(Family::Image), &[]), Some(Kind::Image)); + } + + #[test] + fn nothing_offered_has_no_class() { + assert_eq!(refine(None, &[]), None); + } + + #[test] + fn a_drop_with_no_paths_is_still_a_file() { + let formats = vec![Format { + id: "CF_HDROP".into(), + payload: Payload::Absent, + }]; + assert_eq!(refine(Some(Family::Files), &formats), Some(Kind::File)); + } +} diff --git a/crates/cp-win/src/formats.rs b/crates/cp-win/src/formats.rs index e0da4372..3d0c264f 100644 --- a/crates/cp-win/src/formats.rs +++ b/crates/cp-win/src/formats.rs @@ -1,14 +1,6 @@ use cp_core::formats::Catalog; -/// Los tipos de Windows. Las reglas que se les aplican viven en `cp-core`; -/// esto son solo los datos, y su gemelo de macOS tiene la misma forma. -/// -/// Los `CF_*` son enteros; aquí se nombran por su constante, y la capa de -/// sistema traduce. Los demás llegan con su nombre registrado tal cual. pub const CATALOG: Catalog = Catalog { - // Ninguno confirmado todavía. En Windows el renderizado diferido no es - // propiedad de un tipo sino de quien copió —`rdpclip` difiere todo—, así - // que la defensa no es una lista: es el reloj de quien pide los bytes. hangs: &[], wasteful: &[ "Embed Source", @@ -34,15 +26,13 @@ pub const CATALOG: Catalog = Catalog { "Biff8", "Biff5", "XML Spreadsheet", - // Un `HBITMAP` de GDI, no un bloque de memoria: medido, `GlobalSize` - // no devuelve nada sobre él. "CF_BITMAP", - // El descriptor se anota y el contenido no se puede pedir por el API - // plano del portapapeles, que es el hallazgo de los archivos virtuales. "FileGroupDescriptorW", "FileContents", "Shell IDList Array", "Chromium internal source RFH token", + "FileName", + "FileNameW", ], wanted: &[ "CF_UNICODETEXT", @@ -55,24 +45,21 @@ pub const CATALOG: Catalog = Catalog { "Csv", "Preferred DropEffect", "Chromium internal source URL", - "UniformResourceLocatorW", - "text/uri-list", + "CanIncludeInClipboardHistory", ], - // `FileName` y `FileNameW` **no** son alias de `CF_HDROP`: llevan una sola - // ruta cuando se copiaron varias, así que tomarlos por equivalentes perdería - // archivos. Van en `wasteful`, que los anota sin pedirlos. aliases: &[], - concealed: &["ExcludeClipboardContentFromMonitorProcessing"], + concealed: &[ + "Clipboard Viewer Ignore", + "ExcludeClipboardContentFromMonitorProcessing", + "ExcludeClipboardDataFromMonitorProcessing", + ], denied_when_zero: &["CanIncludeInClipboardHistory"], opaque_prefixes: &[], text: &["CF_UNICODETEXT", "Rich Text Format", "HTML Format", "Csv"], files: &["CF_HDROP"], images_by_preference: &["PNG", "CF_DIBV5", "CF_DIB"], equivalents: &[ - // El Unicode primero: los otros dos son el mismo texto pasado a la - // página de códigos del sistema, con la pérdida que eso trae. &["CF_UNICODETEXT", "CF_TEXT", "CF_OEMTEXT"], - // El de Windows lleva la cabecera de offsets y pesa la mitad. &["HTML Format", "text/html"], ], embeddable: &[ @@ -93,8 +80,6 @@ mod tests { use super::CATALOG; use cp_core::formats::{Family, Refusal, Take}; - /// Las siete fuentes se midieron el 14/09/2026 sobre Windows 11 26200, - /// enumerando todos los tipos que cada aplicación ofrecía de verdad. const WORD: &[&str] = &[ "DataObject", "Object Descriptor", @@ -224,24 +209,16 @@ mod tests { assert!(kept.contains(&"Rich Text Format"), "{kept:?}"); assert!(kept.contains(&"HTML Format"), "{kept:?}"); assert!(kept.contains(&"CF_UNICODETEXT"), "{kept:?}"); - // 41.833 bytes de RTF y 39.321 de HTML por un párrafo, y ni un byte - // del objeto incrustado, que los arrastra por decenas de megas. for heavy in ["Embed Source", "Native", "CF_ENHMETAFILE"] { assert_eq!(CATALOG.decide(heavy), Take::Presence, "{heavy}"); } } - /// El fallo que Windows estrena: Excel adjunta una imagen del rango —medido, - /// 258.380 bytes de `CF_DIBV5` por 500 de texto— y con la regla de macOS - /// copiar celdas se guardaría como una captura de pantalla. #[test] fn a_spreadsheet_is_text_and_not_a_picture_of_itself() { assert_eq!(CATALOG.classify(EXCEL), Some(Family::Text)); } - /// Y Excel pide no ser registrado en **cada** copia, incluso de dos celdas - /// y con la aplicación abierta a la vista. Se obedece, y se dice quién lo - /// pidió: un descarte mudo es indistinguible de un fallo. #[test] fn excel_asks_not_to_be_recorded_and_says_so_by_name() { assert_eq!( @@ -266,8 +243,6 @@ mod tests { ); } - /// Firefox ofrece la misma selección dos veces: 568.458 bytes en - /// `text/html` y 284.561 en `HTML Format`. Se guarda una. #[test] fn a_browser_selection_is_not_stored_twice() { assert_eq!(CATALOG.classify(FIREFOX), Some(Family::Text)); @@ -275,9 +250,6 @@ mod tests { assert!(!CATALOG.costlier_twin("HTML Format", FIREFOX)); } - /// El texto degradado a la página de códigos del sistema no se guarda - /// nunca: es el mismo contenido con pérdida, y en Word llega **antes** que - /// el Unicode, así que quedarse con el primero sería quedarse con el malo. #[test] fn the_ansi_halves_of_the_text_are_never_kept() { for source in [WORD, EXCEL, FIREFOX, CHROME, TERMINAL] { @@ -291,7 +263,6 @@ mod tests { } } - /// Una captura ofrece 13.127.103 bytes en total. Con el PNG basta: 164.311. #[test] fn a_screen_capture_costs_its_png_and_not_its_bitmap() { assert_eq!(CATALOG.classify(SNIP), Some(Family::Image)); @@ -301,8 +272,6 @@ mod tests { assert!(!CATALOG.costlier_twin("PNG", SNIP)); } - /// Sin PNG gana el `CF_DIBV5`, nunca el `CF_DIB`: el clásico pierde el - /// canal alfa y son 84 bytes de diferencia. #[test] fn without_a_png_the_bitmap_with_alpha_wins() { let paint = ["DataObject", "CF_BITMAP", "CF_DIB", "CF_DIBV5"]; @@ -311,9 +280,6 @@ mod tests { assert_eq!(CATALOG.classify(&paint), Some(Family::Image)); } - /// El explorador ofrece catorce tipos y solo dos hacen falta: las rutas y - /// si fue copia o corte. `FileName` y `FileNameW` llevan una sola ruta - /// cuando se copiaron varias, así que guardarlos perdería archivos. #[test] fn the_explorer_needs_only_the_paths_and_the_effect() { assert_eq!(CATALOG.classify(EXPLORER), Some(Family::Files)); @@ -326,8 +292,6 @@ mod tests { } } - /// Los archivos virtuales se anotan y no se piden: `FileContents` no llega - /// por el API plano del portapapeles, y pedirlo sería colgarse esperando. #[test] fn virtual_files_are_noted_and_never_asked_for() { for virtualised in ["FileGroupDescriptorW", "FileContents"] { @@ -335,14 +299,12 @@ mod tests { } } - /// Una terminal ofrece lo mínimo, y lo mínimo sigue siendo un ítem. #[test] fn the_plainest_copy_there_is_still_an_item() { assert_eq!(CATALOG.classify(TERMINAL), Some(Family::Text)); assert_eq!(kept(TERMINAL), vec!["CF_UNICODETEXT"]); } - /// El navegador guarda de dónde salió, que es contexto que la 2.x tiraba. #[test] fn a_browser_copy_keeps_where_it_came_from() { assert_eq!( @@ -356,8 +318,6 @@ mod tests { ); } - /// Lo que ninguna fuente medida ofrece tampoco se pide: el catálogo no - /// promete nada sobre lo que no conoce. #[test] fn the_unknown_is_only_noted() { for unknown in ["ApplicationXYZ", "", "algo/inventado"] { @@ -365,7 +325,6 @@ mod tests { } } - /// Ninguna de las siete fuentes cae en «no se sabe qué es esto». #[test] fn every_measured_source_gets_a_class() { for (name, source) in [ @@ -381,8 +340,6 @@ mod tests { } } - /// Un tipo no puede estar a la vez en lo que se copia y en lo que se anota: - /// la lista larga gana en `decide` y el catálogo mentiría sobre sí mismo. #[test] fn nothing_is_both_wanted_and_wasteful() { for id in CATALOG.wanted { @@ -393,8 +350,6 @@ mod tests { } } - /// Todo lo que decide una familia tiene que poder copiarse; si no, la - /// clasificación prometería un contenido que nunca se guardó. #[test] fn everything_that_names_a_family_can_be_copied() { for id in CATALOG.text.iter().chain(CATALOG.files) { @@ -405,8 +360,17 @@ mod tests { } } - /// El primero de cada grupo de equivalentes es el que se guarda, así que - /// tiene que ser uno de los que se copian. + #[test] + fn every_marker_that_is_read_by_value_can_be_read_at_all() { + for id in CATALOG.denied_when_zero { + assert_eq!( + CATALOG.decide(id), + Take::Payload, + "«{id}» decide por su valor y nadie pediría sus bytes" + ); + } + } + #[test] fn the_preferred_of_each_group_is_one_that_gets_copied() { for group in CATALOG.equivalents { diff --git a/crates/cp-win/src/lib.rs b/crates/cp-win/src/lib.rs index 9cb825f4..4a8e838d 100644 --- a/crates/cp-win/src/lib.rs +++ b/crates/cp-win/src/lib.rs @@ -1,8 +1,10 @@ #![cfg(target_os = "windows")] +pub mod capture; pub mod formats; +pub mod paste; +pub mod restore; pub mod transfer; +pub mod watch; -/// El pegado nunca se intenta sin el destino en primer plano: `SendInput` -/// entrega a la cola de entrada de quien está al frente, y a nadie más. pub const REQUIRES_FOREGROUND_TARGET: bool = true; diff --git a/crates/cp-win/src/paste.rs b/crates/cp-win/src/paste.rs new file mode 100644 index 00000000..97fc7137 --- /dev/null +++ b/crates/cp-win/src/paste.rs @@ -0,0 +1,135 @@ +use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase}; +use cp_win_sys::frontmost::{self, Attached, Target}; +use cp_win_sys::keystroke; +use std::time::{Duration, Instant}; + +const SETTLE: Duration = Duration::from_millis(60); +const MODIFIERS_GO: Duration = Duration::from_millis(120); +const TARGET_ANSWERS_MS: u32 = 200; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + Sent { took: Duration }, + Degraded(Failure), +} + +pub fn phases() -> &'static [Phase] { + ORDER +} + +pub fn paste_into(target: &Target, hide_panel: impl FnOnce()) -> Outcome { + let started = Instant::now(); + let mut attempt = Attempt::default(); + + hide_panel(); + + if !frontmost::is_alive(target.window) { + return Outcome::Degraded(Failure::TargetGone); + } + if frontmost::out_of_reach(target.window) { + return Outcome::Degraded(Failure::TargetElevated); + } + + if focus_of(target) != Focus::OnTarget { + frontmost::bring_forward(target.window); + } + while focus_of(target) != Focus::OnTarget { + if !frontmost::is_alive(target.window) { + return Outcome::Degraded(Failure::TargetGone); + } + if attempt.on_failure(Failure::NotForeground) != Next::Retry { + return Outcome::Degraded(Failure::NotForeground); + } + frontmost::bring_forward(target.window); + std::thread::sleep(SETTLE); + } + + let attached = Attached::to(target.thread); + if let (Some(attached), Some(inner)) = (attached.as_ref(), target.focus) + && frontmost::is_alive(inner) + && frontmost::answers(inner, TARGET_ANSWERS_MS) + { + attached.focus_on(inner); + } + + let waiting = Instant::now(); + while keystroke::modifiers_still_held() && waiting.elapsed() < MODIFIERS_GO { + std::thread::sleep(Duration::from_millis(4)); + } + + attempt.sending(); + let sent = keystroke::send(&keystroke::paste_batch()); + drop(attached); + + if sent { + Outcome::Sent { + took: started.elapsed(), + } + } else { + Outcome::Degraded(Failure::SendDenied) + } +} + +fn focus_of(target: &Target) -> Focus { + match frontmost::foreground() { + Some(window) if window == target.window => Focus::OnTarget, + Some(_) => Focus::Elsewhere, + None => Focus::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_clipboard_is_written_before_anything_touches_the_focus() { + assert_eq!(phases().first(), Some(&Phase::WriteClipboard)); + assert_eq!(phases().last(), Some(&Phase::Send)); + } + + #[test] + fn the_order_is_the_one_the_core_fixes() { + assert_eq!(phases(), ORDER); + } + + #[test] + fn an_elevated_target_degrades_without_retrying() { + let mut attempt = Attempt::default(); + assert_eq!(attempt.on_failure(Failure::TargetElevated), Next::Degrade); + } + + #[test] + fn the_waits_are_shorter_than_the_paste_they_guard() { + assert!(SETTLE < MODIFIERS_GO); + assert!(MODIFIERS_GO < Duration::from_millis(500)); + assert!(u128::from(TARGET_ANSWERS_MS) < MODIFIERS_GO.as_millis() * 2); + } + + #[test] + fn a_target_that_is_us_is_on_target() { + let Some(window) = frontmost::foreground() else { + return; + }; + let target = Target { + window, + focus: None, + thread: 0, + }; + assert_eq!(focus_of(&target), Focus::OnTarget); + } + + #[test] + fn a_window_that_is_not_in_front_is_elsewhere() { + let Some(front) = frontmost::foreground() else { + return; + }; + let other = Target { + window: windows::Win32::Foundation::HWND(std::ptr::dangling_mut()), + focus: None, + thread: 0, + }; + assert_ne!(other.window, front); + assert_eq!(focus_of(&other), Focus::Elsewhere); + } +} diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs new file mode 100644 index 00000000..4cdc95c3 --- /dev/null +++ b/crates/cp-win/src/restore.rs @@ -0,0 +1,191 @@ +use cp_core::dib; +use cp_core::item::{Item, Payload, SYNTHETIC_IMAGE, SYNTHETIC_TEXT}; +use cp_win_sys::clipboard::Clipboard; +use cp_win_sys::formats::{CF_DIBV5, CF_UNICODETEXT, id_of}; +use cp_win_sys::writing::{Written, utf16_of}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Restored { + Written { formats: usize, incomplete: bool }, + NothingToWrite, + Failed, +} + +pub fn to_clipboard(clipboard: &Clipboard, item: &Item) -> Restored { + let mut owned: Vec<(u32, Vec)> = Vec::new(); + for format in &item.formats { + let Some(bytes) = payload_of(format) else { + continue; + }; + for (id, bytes) in writable(&format.id, bytes) { + if !owned.iter().any(|(kept, _)| *kept == id) { + owned.push((id, bytes)); + } + } + } + if owned.is_empty() { + return Restored::NothingToWrite; + } + let entries: Vec<(u32, &[u8])> = owned + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + match clipboard.replace(&entries) { + Written::Placed { formats } => Restored::Written { + formats, + incomplete: formats != item.formats.len(), + }, + Written::Refused => Restored::Failed, + } +} + +pub fn to_clipboard_as_plain_text(clipboard: &Clipboard, item: &Item) -> Restored { + let Some(text) = plain_text_of(item) else { + return Restored::NothingToWrite; + }; + match clipboard.replace(&[(CF_UNICODETEXT, &text)]) { + Written::Placed { .. } => Restored::Written { + formats: 1, + incomplete: false, + }, + Written::Refused => Restored::Failed, + } +} + +fn payload_of(format: &cp_core::item::Format) -> Option<&[u8]> { + match &format.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => Some(bytes.as_slice()), + _ => None, + } +} + +fn writable(id: &str, bytes: &[u8]) -> Vec<(u32, Vec)> { + if id == SYNTHETIC_TEXT { + return match std::str::from_utf8(bytes) { + Ok(text) => vec![(CF_UNICODETEXT, utf16_of(text))], + Err(_) => Vec::new(), + }; + } + 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; + } + 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); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use cp_core::item::Format; + + fn inline(id: &str, bytes: &[u8]) -> Format { + Format { + id: id.into(), + payload: Payload::Inline(bytes.to_vec()), + } + } + + #[test] + fn a_synthetic_text_goes_back_as_the_unicode_the_system_wants() { + let written = writable(SYNTHETIC_TEXT, b"hola"); + assert_eq!(written, vec![(CF_UNICODETEXT, utf16_of("hola"))]); + } + + #[test] + fn a_synthetic_image_goes_back_as_both_a_png_and_a_bitmap() { + let png = image_bytes(); + let written = writable(SYNTHETIC_IMAGE, &png); + assert_eq!(written.len(), 2, "el moderno y el clasico"); + assert!(written.iter().any(|(id, _)| *id == CF_DIBV5)); + assert!( + written.iter().any(|(_, bytes)| bytes == &png), + "el PNG viaja intacto" + ); + } + + #[test] + fn a_name_the_system_does_not_know_is_registered_not_dropped() { + let written = writable("Rich Text Format", b"{\\rtf1}"); + assert_eq!(written.len(), 1); + assert!(written[0].0 >= 0xC000); + } + + #[test] + fn nothing_readable_is_nothing_to_write() { + let item = Item { + kind: None, + formats: vec![Format { + id: "CF_UNICODETEXT".into(), + payload: Payload::Announced { size: Some(10) }, + }], + }; + assert_eq!(plain_text_of(&item), None); + } + + #[test] + fn the_plain_text_is_the_one_the_system_uses() { + let item = Item { + kind: None, + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 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"))); + } + + #[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 image_bytes() -> Vec { + let mut dib = Vec::new(); + dib.extend_from_slice(&40u32.to_le_bytes()); + dib.extend_from_slice(&2i32.to_le_bytes()); + dib.extend_from_slice(&2i32.to_le_bytes()); + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&32u16.to_le_bytes()); + dib.extend_from_slice(&0u32.to_le_bytes()); + dib.extend_from_slice(&16u32.to_le_bytes()); + dib.resize(40, 0); + dib.extend_from_slice(&[0x20, 0x60, 0xA0, 0xFF].repeat(4)); + dib::to_png(&dib).expect("png") + } +} diff --git a/crates/cp-win/src/transfer.rs b/crates/cp-win/src/transfer.rs index 638e0275..5dc16960 100644 --- a/crates/cp-win/src/transfer.rs +++ b/crates/cp-win/src/transfer.rs @@ -1,40 +1,22 @@ -//! Si lo que hay en el portapapeles se copió o se cortó. - -/// Los bits de `DROPEFFECT`, tal como Windows los define. const COPY: u32 = 1; const MOVE: u32 = 2; const LINK: u32 = 4; -/// Qué pidió la fuente que se hiciera con lo que dejó. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Transfer { Copy, - /// Un corte. Las rutas dejan de existir en cuanto alguien pegue, así que - /// guardarlas como si fueran una copia deja el historial lleno de destinos - /// muertos. Move, - /// La fuente no dijo nada. Es lo normal fuera del explorador. Unsaid, } -/// Lee `Preferred DropEffect`, que son cuatro bytes en little-endian. -/// -/// **Se mira el bit, no el valor.** Medido el 14/09/2026 en Windows 11 26200: -/// el Explorador ofrece **5** al copiar, que es `COPY | LINK`, así que -/// compararlo con `COPY` daría que no es una copia y compararlo con `MOVE` -/// tampoco diría que lo es. pub fn transfer(value: &[u8]) -> Transfer { let [a, b, c, d, ..] = value else { return Transfer::Unsaid; }; let effect = u32::from_le_bytes([*a, *b, *c, *d]); - // El corte manda sobre la copia cuando vienen los dos bits: quien mueve - // acepta copiar, pero quien copia nunca borra el origen. if effect & MOVE != 0 { return Transfer::Move; } - // Los dos por separado y no `COPY | LINK`: con bits que no se solapan, el - // «o» y el «o exclusivo» dan lo mismo y ninguna prueba podría distinguirlos. if effect & COPY != 0 || effect & LINK != 0 { return Transfer::Copy; } @@ -45,8 +27,6 @@ pub fn transfer(value: &[u8]) -> Transfer { mod tests { use super::*; - /// El valor que el Explorador ofrece de verdad, medido el 14/09/2026 en - /// trece copias seguidas: siempre cinco, nunca uno. #[test] fn what_the_explorer_really_offers_when_copying() { assert_eq!(transfer(&5u32.to_le_bytes()), Transfer::Copy); @@ -62,8 +42,6 @@ mod tests { assert_eq!(transfer(&(MOVE | LINK).to_le_bytes()), Transfer::Move); } - /// Con los dos bits puestos manda el corte: tratar como copia algo que el - /// origen va a borrar deja rutas muertas en el historial. #[test] fn a_cut_wins_over_a_copy_when_both_bits_are_set() { assert_eq!(transfer(&(COPY | MOVE).to_le_bytes()), Transfer::Move); @@ -76,8 +54,6 @@ mod tests { assert_eq!(transfer(&LINK.to_le_bytes()), Transfer::Copy); } - /// Lo que no se puede leer no se inventa: fuera del explorador este formato - /// no está, y eso no convierte la copia en un corte. #[test] fn what_cannot_be_read_says_nothing() { assert_eq!(transfer(&[]), Transfer::Unsaid); @@ -98,8 +74,6 @@ mod tests { assert_eq!(transfer(&[2, 0, 0, 0, 9, 9, 9]), Transfer::Move); } - /// Los bits altos son de otras banderas de `DROPEFFECT` y no dicen nada - /// sobre copiar o mover. #[test] fn the_high_bits_decide_nothing() { assert_eq!(transfer(&0x8000_0000u32.to_le_bytes()), Transfer::Unsaid); diff --git a/crates/cp-win/src/watch.rs b/crates/cp-win/src/watch.rs new file mode 100644 index 00000000..4986e3a2 --- /dev/null +++ b/crates/cp-win/src/watch.rs @@ -0,0 +1,59 @@ +pub fn sequence(raw: u32) -> Option { + (raw != 0).then_some(i64::from(raw)) +} + +#[cfg(test)] +mod tests { + use super::*; + use cp_core::watch::{Cadence, Seen, Watcher}; + + #[test] + fn a_zero_is_not_a_counter() { + assert_eq!(sequence(0), None); + } + + #[test] + fn any_other_value_is_one() { + assert_eq!(sequence(1), Some(1)); + assert_eq!(sequence(915), Some(915)); + assert_eq!(sequence(u32::MAX), Some(i64::from(u32::MAX))); + } + + #[test] + fn the_upper_half_of_the_range_does_not_turn_negative() { + for raw in [0x8000_0000u32, 0xFFFF_FFFF] { + let counted = sequence(raw).expect("no es cero"); + assert!(counted > 0, "{raw} salió como {counted}"); + } + } + + #[test] + fn locking_the_screen_without_copying_adds_nothing() { + let mut watcher = Watcher::new(Cadence::Opaque); + let mut fresh = 0; + for raw in [915u32, 0, 0, 915] { + let Some(counted) = sequence(raw) else { + continue; + }; + if let Seen::Fresh { .. } = watcher.tick(counted) { + fresh += 1; + } + } + assert_eq!(fresh, 0, "nadie copió, y nada se registró"); + } + + #[test] + fn a_copy_made_across_the_lock_is_still_seen() { + let mut watcher = Watcher::new(Cadence::Opaque); + let mut fresh = 0; + for raw in [915u32, 0, 920] { + let Some(counted) = sequence(raw) else { + continue; + }; + if let Seen::Fresh { .. } = watcher.tick(counted) { + fresh += 1; + } + } + assert_eq!(fresh, 1); + } +} diff --git a/deny.toml b/deny.toml index dbdc9463..6cffa099 100644 --- a/deny.toml +++ b/deny.toml @@ -14,8 +14,6 @@ allow = [ "Zlib", ] confidence-threshold = 0.9 -# La lista cubre lo que se admite, no solo lo que hoy se usa: una dependencia -# nueva con una de estas no debería obligar a tocar este archivo. unused-allowed-license = "allow" [bans] From 40c6dc37a7ebb6d2b5964ee2c7d0572ffd21199f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 14 Sep 2026 16:57:13 -0300 Subject: [PATCH 3/4] feat(win): paste, permissions, watcher and OCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste is verified end to end: the probe creates its own target window, brings it forward, sends the nine-event batch and reads what arrived in the control. The other half is verified too — against a dead target it degrades to TargetGone and the text stays in the clipboard, so the worst outcome is still "paste it yourself". Polling the sequence number costs 267 ns, so the watcher polls instead of registering a message-only window: AddClipboardFormatListener would have bought nothing measurable. OCR reads the shared fixture in 35 ms against Vision's 180 on macOS. The decision does not change — it still runs after capture, never during. windows-future 0.3 dropped synchronous waiting, so completion is awaited by polling status under a five second ceiling rather than pulling in an executor. Tests that touch the clipboard moved to the probe: it is a single global resource, so they raced each other under cargo test's parallelism. That is the same split macOS reached from the other side, where NSPasteboard demands the main thread. 388 tests, 34 system cases. --- Cargo.lock | 3 + Cargo.toml | 6 + crates/cp-core/src/dib.rs | 4 +- crates/cp-win-sys/Cargo.toml | 4 + crates/cp-win-sys/src/frontmost.rs | 11 ++ crates/cp-win-sys/src/lib.rs | 3 + crates/cp-win-sys/src/ocr.rs | 74 +++++++++++ crates/cp-win-sys/src/permissions.rs | 101 ++++++++++++++ crates/cp-win-sys/src/window.rs | 92 +++++++++++++ crates/cp-win/Cargo.toml | 3 + crates/cp-win/examples/probe.rs | 190 +++++++++++++++++++++++++++ crates/cp-win/src/lib.rs | 1 + crates/cp-win/src/watching.rs | 74 +++++++++++ 13 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 crates/cp-win-sys/src/ocr.rs create mode 100644 crates/cp-win-sys/src/permissions.rs create mode 100644 crates/cp-win-sys/src/window.rs create mode 100644 crates/cp-win/src/watching.rs diff --git a/Cargo.lock b/Cargo.lock index df10286e..0a24c49a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,7 @@ version = "3.0.0" dependencies = [ "cp-core", "cp-win-sys", + "image", "windows", ] @@ -162,7 +163,9 @@ dependencies = [ name = "cp-win-sys" version = "3.0.0" dependencies = [ + "image", "windows", + "windows-future", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4ad12db9..efc05aa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,8 +40,14 @@ windows = { version = "0.62", features = [ "Win32_Graphics_Gdi", "Win32_UI_Input_KeyboardAndMouse", "Win32_Security", + "Media_Ocr", + "Graphics_Imaging", + "Storage_Streams", + "Foundation_Collections", ] } +windows-future = "0.3" + objc2 = "0.6" objc2-foundation = "0.3" objc2-app-kit = "0.3" diff --git a/crates/cp-core/src/dib.rs b/crates/cp-core/src/dib.rs index 8e5c2efe..8a10c820 100644 --- a/crates/cp-core/src/dib.rs +++ b/crates/cp-core/src/dib.rs @@ -174,7 +174,9 @@ pub fn from_png(png: &[u8]) -> Option> { let mut bmp = std::io::Cursor::new(Vec::new()); decoded.write_to(&mut bmp, image::ImageFormat::Bmp).ok()?; let bmp = bmp.into_inner(); - (bmp.len() > FILE_HEADER).then(|| bmp[FILE_HEADER..].to_vec()) + bmp.get(FILE_HEADER..) + .filter(|pixels| !pixels.is_empty()) + .map(<[u8]>::to_vec) } #[cfg(test)] diff --git a/crates/cp-win-sys/Cargo.toml b/crates/cp-win-sys/Cargo.toml index 915b6011..b1899177 100644 --- a/crates/cp-win-sys/Cargo.toml +++ b/crates/cp-win-sys/Cargo.toml @@ -9,6 +9,10 @@ publish = false [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true +windows-future.workspace = true + +[target.'cfg(target_os = "windows")'.dev-dependencies] +image.workspace = true [lints.rust] unsafe_code = "allow" diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index 5dd68061..9d24b5ba 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -38,6 +38,17 @@ pub fn capture_target() -> Option { }) } +pub fn target_for(window: HWND) -> Target { + // SAFETY: the window came from the caller and is only read. + let thread = + unsafe { windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(window, None) }; + Target { + window, + focus: inner_focus(thread), + thread, + } +} + fn inner_focus(thread: u32) -> Option { let mut info = GUITHREADINFO { cbSize: u32::try_from(std::mem::size_of::()).ok()?, diff --git a/crates/cp-win-sys/src/lib.rs b/crates/cp-win-sys/src/lib.rs index 1ed70b90..0a04820a 100644 --- a/crates/cp-win-sys/src/lib.rs +++ b/crates/cp-win-sys/src/lib.rs @@ -4,7 +4,10 @@ pub mod clipboard; pub mod formats; pub mod frontmost; pub mod keystroke; +pub mod ocr; pub mod paths; +pub mod permissions; pub mod reading; pub mod source; +pub mod window; pub mod writing; diff --git a/crates/cp-win-sys/src/ocr.rs b/crates/cp-win-sys/src/ocr.rs new file mode 100644 index 00000000..170128d0 --- /dev/null +++ b/crates/cp-win-sys/src/ocr.rs @@ -0,0 +1,74 @@ +use windows::Graphics::Imaging::{BitmapDecoder, SoftwareBitmap}; +use windows::Media::Ocr::OcrEngine; +use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream}; +use windows::core::RuntimeType; +use windows_future::AsyncStatus; +use windows_future::IAsyncOperation; + +pub const PATIENCE: std::time::Duration = std::time::Duration::from_secs(5); + +pub fn is_available() -> bool { + OcrEngine::TryCreateFromUserProfileLanguages().is_ok() +} + +pub fn text_in(image: &[u8]) -> Option { + let engine = OcrEngine::TryCreateFromUserProfileLanguages().ok()?; + let bitmap = bitmap_of(image)?; + let result = finished(engine.RecognizeAsync(&bitmap).ok()?)?; + let text = result.Text().ok()?.to_string_lossy(); + (!text.trim().is_empty()).then_some(text) +} + +fn finished(operation: IAsyncOperation) -> Option { + let until = std::time::Instant::now() + PATIENCE; + while operation.Status().ok()? == AsyncStatus::Started { + if std::time::Instant::now() > until { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + operation.GetResults().ok() +} + +fn bitmap_of(image: &[u8]) -> Option { + let stream = InMemoryRandomAccessStream::new().ok()?; + let writer = DataWriter::CreateDataWriter(&stream.GetOutputStreamAt(0).ok()?).ok()?; + writer.WriteBytes(image).ok()?; + finished(writer.StoreAsync().ok()?)?; + finished(writer.FlushAsync().ok()?)?; + stream.Seek(0).ok()?; + let decoder = finished(BitmapDecoder::CreateAsync(&stream).ok()?)?; + finished(decoder.GetSoftwareBitmapAsync().ok()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_system_offers_an_engine() { + assert!(is_available(), "Windows trae OCR desde la 10"); + } + + #[test] + fn what_is_not_an_image_reads_as_nothing() { + assert_eq!(text_in(&[]), None); + assert_eq!(text_in(b"esto no es una imagen"), None); + } + + #[test] + fn an_image_without_text_reads_as_nothing() { + let blank = image::RgbaImage::from_pixel(64, 64, image::Rgba([255, 255, 255, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(blank) + .write_to(&mut png, image::ImageFormat::Png) + .expect("png"); + assert_eq!(text_in(&png.into_inner()), None); + } + + #[test] + fn the_patience_is_generous_but_finite() { + assert!(PATIENCE >= std::time::Duration::from_secs(1)); + assert!(PATIENCE <= std::time::Duration::from_secs(30)); + } +} diff --git a/crates/cp-win-sys/src/permissions.rs b/crates/cp-win-sys/src/permissions.rs new file mode 100644 index 00000000..400bc201 --- /dev/null +++ b/crates/cp-win-sys/src/permissions.rs @@ -0,0 +1,101 @@ +use crate::clipboard; +use crate::frontmost; + +pub const MEDIUM: u32 = 0x2000; +pub const HIGH: u32 = 0x3000; + +const _: () = assert!(MEDIUM < HIGH); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Readiness { + pub reaches_window_station: bool, + pub integrity: Option, +} + +impl Readiness { + pub fn probe() -> Self { + Self { + reaches_window_station: clipboard::sequence().is_some(), + integrity: frontmost::integrity_of(std::process::id()), + } + } + + pub fn can_watch(&self) -> bool { + self.reaches_window_station + } + + pub fn can_paste_into(&self, target: u32) -> bool { + self.integrity.is_some_and(|ours| ours >= target) + } + + pub fn is_elevated(&self) -> bool { + self.integrity.is_some_and(|ours| ours >= HIGH) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn this_process_is_ready_to_watch() { + let ready = Readiness::probe(); + assert!(ready.can_watch()); + assert!(ready.integrity.is_some()); + } + + #[test] + fn a_target_at_our_own_level_is_reachable() { + let ready = Readiness::probe(); + let ours = ready.integrity.expect("nivel propio"); + assert!(ready.can_paste_into(ours)); + } + + #[test] + fn a_target_above_us_is_not_reachable() { + let ready = Readiness { + reaches_window_station: true, + integrity: Some(MEDIUM), + }; + assert!(!ready.can_paste_into(HIGH)); + assert!(ready.can_paste_into(MEDIUM)); + } + + #[test] + fn without_a_level_of_our_own_nothing_is_promised() { + let blind = Readiness { + reaches_window_station: true, + integrity: None, + }; + assert!(!blind.can_paste_into(MEDIUM)); + assert!(!blind.is_elevated()); + } + + #[test] + fn a_locked_station_stops_the_watcher_and_nothing_else() { + let locked = Readiness { + reaches_window_station: false, + integrity: Some(MEDIUM), + }; + assert!(!locked.can_watch()); + assert!(locked.can_paste_into(MEDIUM)); + } + + #[test] + fn running_elevated_is_told_apart_from_running_normal() { + assert!( + Readiness { + reaches_window_station: true, + integrity: Some(HIGH), + } + .is_elevated() + ); + assert!( + !Readiness { + reaches_window_station: true, + integrity: Some(MEDIUM), + } + .is_elevated() + ); + } +} diff --git a/crates/cp-win-sys/src/window.rs b/crates/cp-win-sys/src/window.rs new file mode 100644 index 00000000..e2d3a7ad --- /dev/null +++ b/crates/cp-win-sys/src/window.rs @@ -0,0 +1,92 @@ +use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DestroyWindow, DispatchMessageW, ES_MULTILINE, MSG, PM_REMOVE, PeekMessageW, + SW_SHOW, SendMessageW, SetForegroundWindow, ShowWindow, TranslateMessage, WINDOW_EX_STYLE, + WM_GETTEXT, WM_GETTEXTLENGTH, WS_OVERLAPPEDWINDOW, WS_VISIBLE, +}; +use windows::core::{PCWSTR, w}; + +pub struct EditWindow { + window: HWND, +} + +impl EditWindow { + pub fn open(title: &str) -> Option { + let wide: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: the class is a system one and both strings outlive the call. + let window = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("EDIT"), + PCWSTR(wide.as_ptr()), + WS_OVERLAPPEDWINDOW + | WS_VISIBLE + | windows::Win32::UI::WindowsAndMessaging::WINDOW_STYLE(ES_MULTILINE as u32), + 100, + 100, + 420, + 220, + None, + None, + None, + None, + ) + } + .ok()?; + // SAFETY: the window was just created and is still ours. + let _ = unsafe { ShowWindow(window, SW_SHOW) }; + // SAFETY: the value is not to be trusted; callers check who is in front. + let _ = unsafe { SetForegroundWindow(window) }; + Some(Self { window }) + } + + pub fn window(&self) -> HWND { + self.window + } + + pub fn pump(&self, how_long: std::time::Duration) { + let until = std::time::Instant::now() + how_long; + while std::time::Instant::now() < until { + let mut message = MSG::default(); + // SAFETY: the out parameter points at a live local. + while unsafe { PeekMessageW(&mut message, None, 0, 0, PM_REMOVE) }.as_bool() { + // SAFETY: the message was just filled by PeekMessage. + let _ = unsafe { TranslateMessage(&message) }; + // SAFETY: same message, still live. + unsafe { DispatchMessageW(&message) }; + } + std::thread::sleep(std::time::Duration::from_millis(4)); + } + } + + pub fn text(&self) -> String { + // SAFETY: the window is alive for as long as this value is. + let length = unsafe { SendMessageW(self.window, WM_GETTEXTLENGTH, None, None) }.0; + let Ok(length) = usize::try_from(length) else { + return String::new(); + }; + if length == 0 { + return String::new(); + } + let mut buffer = vec![0u16; length + 1]; + // SAFETY: the buffer holds the length just reported plus the terminator. + let read = unsafe { + SendMessageW( + self.window, + WM_GETTEXT, + Some(WPARAM(buffer.len())), + Some(LPARAM(buffer.as_mut_ptr() as isize)), + ) + } + .0; + let read = usize::try_from(read).unwrap_or(0).min(buffer.len()); + String::from_utf16_lossy(&buffer[..read]) + } +} + +impl Drop for EditWindow { + fn drop(&mut self) { + // SAFETY: the window was created here and is destroyed once. + let _ = unsafe { DestroyWindow(self.window) }; + } +} diff --git a/crates/cp-win/Cargo.toml b/crates/cp-win/Cargo.toml index 6eff7585..a7c80a9d 100644 --- a/crates/cp-win/Cargo.toml +++ b/crates/cp-win/Cargo.toml @@ -12,5 +12,8 @@ cp-core.workspace = true cp-win-sys.workspace = true windows.workspace = true +[target.'cfg(target_os = "windows")'.dev-dependencies] +image.workspace = true + [lints] workspace = true diff --git a/crates/cp-win/examples/probe.rs b/crates/cp-win/examples/probe.rs index dedbefe3..b3b290d9 100644 --- a/crates/cp-win/examples/probe.rs +++ b/crates/cp-win/examples/probe.rs @@ -4,11 +4,17 @@ use cp_core::formats::{Family, Take}; use cp_core::watch::{Cadence, Seen, Watcher}; use cp_win::capture::{Captured, capture}; 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::transfer::{self, Transfer}; +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::frontmost::{self, Target}; +use cp_win_sys::ocr; +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}; struct Battery { @@ -498,6 +504,190 @@ fn main() -> std::process::ExitCode { Ok(()) }); + b.group("J · El vigilante en marcha"); + + b.case("J1", "una copia despierta al vigilante", || { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + let seen = Arc::new(AtomicUsize::new(0)); + let counter = seen.clone(); + let watching = Watching::every(std::time::Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::Relaxed); + }); + std::thread::sleep(std::time::Duration::from_millis(60)); + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j1"))]); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + drop(watching); + match seen.load(Ordering::Relaxed) { + 0 => Err("la copia no se vio".into()), + n => { + println!(" {n} aviso(s) por una copia"); + Ok(()) + } + } + }); + + b.case("J2", "un portapapeles quieto no despierta a nadie", || { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j2-quieto"))]); + } + std::thread::sleep(std::time::Duration::from_millis(60)); + let seen = Arc::new(AtomicUsize::new(0)); + let counter = seen.clone(); + let watching = Watching::every(std::time::Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::Relaxed); + }); + std::thread::sleep(std::time::Duration::from_millis(250)); + drop(watching); + match seen.load(Ordering::Relaxed) { + 0 => Ok(()), + n => Err(format!("{n} avisos sin que nadie copiara")), + } + }); + + b.case("J3", "sondear el contador es casi gratis", || { + let rounds = 10_000; + let started = std::time::Instant::now(); + for _ in 0..rounds { + let _ = clipboard::sequence(); + } + let each = started.elapsed() / rounds; + println!(" {each:?} por sondeo"); + if each > std::time::Duration::from_micros(50) { + return Err(format!("{each:?} es demasiado para sondear seguido")); + } + Ok(()) + }); + + b.group("K · Permisos"); + + b.case("K1", "se sabe qué se puede hacer y qué no", || { + let ready = Readiness::probe(); + println!( + " estación: {} nivel: {:?} elevado: {}", + ready.can_watch(), + ready.integrity, + ready.is_elevated() + ); + if !ready.can_watch() { + return Err("no se alcanza la estación de ventanas".into()); + } + let ours = ready.integrity.ok_or("sin nivel propio")?; + if !ready.can_paste_into(ours) { + return Err("no se puede pegar en nuestro propio nivel".into()); + } + Ok(()) + }); + + b.group("L · Pegar de verdad"); + + let stage = EditWindow::open("destino de la bateria").and_then(|target| { + let until = std::time::Instant::now() + std::time::Duration::from_secs(2); + while frontmost::foreground() != Some(target.window()) { + if std::time::Instant::now() > until { + return None; + } + frontmost::bring_forward(target.window()); + target.pump(std::time::Duration::from_millis(50)); + } + Some(target) + }); + + match stage { + Some(target) => b.case("L1", "el texto llega a una ventana de destino", || { + let written = "cp-l1-pegado-real"; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]); + } + let seen = frontmost::target_for(target.window()); + match paste_into(&seen, || {}) { + Outcome::Sent { took } => println!(" enviado en {took:?}"), + Outcome::Degraded(why) => return Err(format!("degradó a {why:?}")), + } + target.pump(std::time::Duration::from_millis(400)); + let arrived = target.text(); + if arrived.contains(written) { + Ok(()) + } else { + Err(format!("llegó «{arrived}» en vez de «{written}»")) + } + }), + None => b.skip( + "L1", + "el texto llega a una ventana de destino", + "Windows solo deja cambiar el primer plano a quien ya lo tiene: ejecuta la bateria desde una consola con el foco", + ), + } + + b.case( + "L2", + "el peor resultado sigue siendo pegarlo a mano", + || { + let written = "cp-l2-degradado"; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]); + } + let gone = Target { + window: windows::Win32::Foundation::HWND(std::ptr::dangling_mut()), + focus: None, + thread: 0, + }; + match paste_into(&gone, || {}) { + Outcome::Degraded(cp_core::paste::Failure::TargetGone) => {} + other => return Err(format!("con un destino muerto dio {other:?}")), + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("sin texto")?; + match text_of(&bytes).as_deref() { + Some(back) if back == written => Ok(()), + other => Err(format!("el portapapeles quedó con «{other:?}»")), + } + }, + ); + + b.group("M · Texto dentro de una imagen"); + + b.case("M1", "el sistema ofrece un motor de lectura", || { + if ocr::is_available() { + Ok(()) + } else { + Err("no hay motor para los idiomas del perfil".into()) + } + }); + + b.case("M2", "se lee el texto de una imagen real", || { + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("no se pudo leer el fixture: {why}"))?; + let started = std::time::Instant::now(); + let text = ocr::text_in(&png).ok_or("no se reconoció nada")?; + println!( + " {:?} para leer «{}»", + started.elapsed(), + text.lines().next().unwrap_or("").trim() + ); + Ok(()) + }); + + b.case("M3", "una imagen en blanco no inventa texto", || { + let blank = image::RgbaImage::from_pixel(120, 60, image::Rgba([255, 255, 255, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(blank) + .write_to(&mut png, image::ImageFormat::Png) + .map_err(|why| why.to_string())?; + match ocr::text_in(&png.into_inner()) { + None => Ok(()), + Some(invented) => Err(format!("se inventó «{invented}»")), + } + }); + b.group("C · Los formatos que cuelgan no se piden"); b.case("C1", "nada marcado como presencia se llega a pedir", || { diff --git a/crates/cp-win/src/lib.rs b/crates/cp-win/src/lib.rs index 4a8e838d..2214e06d 100644 --- a/crates/cp-win/src/lib.rs +++ b/crates/cp-win/src/lib.rs @@ -6,5 +6,6 @@ pub mod paste; pub mod restore; pub mod transfer; pub mod watch; +pub mod watching; pub const REQUIRES_FOREGROUND_TARGET: bool = true; diff --git a/crates/cp-win/src/watching.rs b/crates/cp-win/src/watching.rs new file mode 100644 index 00000000..c64570b0 --- /dev/null +++ b/crates/cp-win/src/watching.rs @@ -0,0 +1,74 @@ +use cp_core::watch::{Cadence, Seen, Watcher}; +use cp_win_sys::clipboard; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +pub const EVERY: Duration = Duration::from_millis(60); + +const _: () = assert!(EVERY.as_millis() >= 16); +const _: () = assert!(EVERY.as_millis() <= 250); + +pub struct Watching { + stop: Arc, + thread: Option>, +} + +impl Watching { + pub fn every(period: Duration, mut on_fresh: impl FnMut() + Send + 'static) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let mine = stop.clone(); + let thread = std::thread::spawn(move || { + let mut watcher = Watcher::new(Cadence::Opaque); + while !mine.load(Ordering::Relaxed) { + if let Some(count) = clipboard::sequence() + && let Seen::Fresh { .. } = watcher.tick(count) + { + on_fresh(); + } + std::thread::sleep(period); + } + }); + Self { + stop, + thread: Some(thread), + } + } + + pub fn start(on_fresh: impl FnMut() + Send + 'static) -> Self { + Self::every(EVERY, on_fresh) + } +} + +impl Drop for Watching { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_period_keeps_up_with_a_person_without_spinning() { + assert!( + EVERY <= Duration::from_millis(250), + "una copia no puede tardar en verse" + ); + assert!( + EVERY >= Duration::from_millis(16), + "ni sondear mas rapido que la pantalla" + ); + } + + #[test] + fn stopping_is_what_drop_does_and_it_waits_for_the_thread() { + let watching = Watching::every(Duration::from_millis(5), || {}); + assert!(watching.thread.is_some()); + drop(watching); + } +} From 2e38c311dae59ba4f1dcc709c3fbedd3bd64c788 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 14 Sep 2026 17:21:12 -0300 Subject: [PATCH 4/4] =?UTF-8?q?feat(win):=20seal=20the=20core=20=E2=80=94?= =?UTF-8?q?=20media,=20thumbnails=20and=20the=20shell=20heuristics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.x thumbnail rule throws away wide images. It calls anything with *either* side under 64 px a generic icon, and this project's own fixture comes back from the shell at 256x57, so its thumbnail was discarded. A generic icon is small *and* square; a thumbnail keeps the source ratio, so the test is both sides, not one. It now yields 5,895 bytes against 126,497 of the original, in 32 ms. Media metadata arrives through IPropertyStore and thumbnails through IShellItemImageFactory, both behind a COM apartment and a path the shell accepts — canonicalize hands back a \\?\ prefix that it refuses. Windows now matches macOS piece for piece: catalogue, fourteen-class classification, watcher, capture, restore, paste, permissions, bounded reads, copy-vs-cut, DIB to PNG and back, OCR, media metadata and shell thumbnails. 402 tests, 37 system cases, 95.10% coverage with the harness included, zero surviving mutants. The Windows coverage gate moves from 90 to 95. --- .github/workflows/rules.yml | 2 +- Cargo.toml | 5 + crates/cp-win-sys/src/com.rs | 64 +++++++++++ crates/cp-win-sys/src/lib.rs | 3 + crates/cp-win-sys/src/media.rs | 173 +++++++++++++++++++++++++++++ crates/cp-win-sys/src/thumbnail.rs | 160 ++++++++++++++++++++++++++ crates/cp-win/examples/probe.rs | 52 ++++++++- 7 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 crates/cp-win-sys/src/com.rs create mode 100644 crates/cp-win-sys/src/media.rs create mode 100644 crates/cp-win-sys/src/thumbnail.rs diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 5d3bfb3c..c9847fb5 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -133,7 +133,7 @@ jobs: - uses: taiki-e/install-action@cargo-llvm-cov - run: cargo llvm-cov --no-report -p cp-core -p cp-store -p cp-win -p cp-win-sys - run: cargo llvm-cov --no-report run -p cp-win --example probe - - run: cargo llvm-cov report --fail-under-lines 90 --summary-only + - run: cargo llvm-cov report --fail-under-lines 95 --summary-only budget: name: Latency stays within budget diff --git a/Cargo.toml b/Cargo.toml index efc05aa6..376ef387 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,11 @@ windows = { version = "0.62", features = [ "Graphics_Imaging", "Storage_Streams", "Foundation_Collections", + "Win32_UI_Shell", + "Win32_UI_Shell_PropertiesSystem", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Variant", ] } windows-future = "0.3" diff --git a/crates/cp-win-sys/src/com.rs b/crates/cp-win-sys/src/com.rs new file mode 100644 index 00000000..7cb1746a --- /dev/null +++ b/crates/cp-win-sys/src/com.rs @@ -0,0 +1,64 @@ +use windows::Win32::System::Com::{ + COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE, CoInitializeEx, CoUninitialize, +}; + +const VERBATIM: &str = r"\\?\"; + +pub struct Apartment { + ours: bool, +} + +impl Apartment { + pub fn enter() -> Self { + // SAFETY: idempotent when the apartment matches; released in Drop when ours. + let entered = + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) }; + Self { + ours: entered.is_ok(), + } + } +} + +impl Drop for Apartment { + fn drop(&mut self) { + if self.ours { + // SAFETY: pairs with the initialisation that this value owns. + unsafe { CoUninitialize() }; + } + } +} + +pub fn shell_path(path: &std::path::Path) -> Option { + let absolute = path.canonicalize().ok()?; + let text = absolute.to_str()?; + Some(std::path::PathBuf::from( + text.strip_prefix(VERBATIM).unwrap_or(text), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_verbatim_prefix_is_stripped_for_the_shell() { + let here = shell_path(std::path::Path::new(".")).expect("ruta"); + let text = here.to_string_lossy(); + assert!(!text.starts_with(VERBATIM), "el shell no entiende {text}"); + assert!(here.is_absolute()); + } + + #[test] + fn a_path_that_is_not_there_has_no_shell_path() { + let missing = std::path::Path::new(r"C:\no-existe-nada-de-nada"); + assert_eq!(shell_path(missing), None); + } + + #[test] + fn entering_twice_is_not_a_problem() { + let first = Apartment::enter(); + let second = Apartment::enter(); + drop(second); + drop(first); + } +} diff --git a/crates/cp-win-sys/src/lib.rs b/crates/cp-win-sys/src/lib.rs index 0a04820a..2ae4b366 100644 --- a/crates/cp-win-sys/src/lib.rs +++ b/crates/cp-win-sys/src/lib.rs @@ -1,13 +1,16 @@ #![cfg(target_os = "windows")] pub mod clipboard; +pub mod com; pub mod formats; pub mod frontmost; pub mod keystroke; +pub mod media; pub mod ocr; pub mod paths; pub mod permissions; pub mod reading; pub mod source; +pub mod thumbnail; pub mod window; pub mod writing; diff --git a/crates/cp-win-sys/src/media.rs b/crates/cp-win-sys/src/media.rs new file mode 100644 index 00000000..3cf521b3 --- /dev/null +++ b/crates/cp-win-sys/src/media.rs @@ -0,0 +1,173 @@ +use std::os::windows::ffi::OsStrExt; +use windows::Win32::Foundation::PROPERTYKEY; +use windows::Win32::System::Com::CoTaskMemFree; +use windows::Win32::System::Com::StructuredStorage::{PROPVARIANT, PropVariantToStringAlloc}; +use windows::Win32::UI::Shell::PropertiesSystem::{ + GPS_DEFAULT, IPropertyStore, SHGetPropertyStoreFromParsingName, +}; +use windows::core::{GUID, PCWSTR}; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MediaInfo { + pub duration: Option, + pub width: Option, + pub height: Option, + pub title: Option, + pub artist: Option, + pub album: Option, +} + +impl MediaInfo { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + pub fn searchable(&self) -> String { + [&self.title, &self.artist, &self.album] + .into_iter() + .flatten() + .map(String::as_str) + .collect::>() + .join(" ") + } +} + +const PKEY_MEDIA_DURATION: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0x64440490_4c8b_11d1_8b70_080036b11a03), + pid: 3, +}; +const PKEY_VIDEO_WIDTH: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0x64440491_4c8b_11d1_8b70_080036b11a03), + pid: 3, +}; +const PKEY_VIDEO_HEIGHT: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0x64440491_4c8b_11d1_8b70_080036b11a03), + pid: 4, +}; +const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0xf29f85e0_4ff9_1068_ab91_08002b27b3d9), + pid: 2, +}; +const PKEY_MUSIC_ARTIST: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0x56a3372e_ce9c_11d2_9f0e_006097c686f6), + pid: 13, +}; +const PKEY_MUSIC_ALBUM: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0x56a3372e_ce9c_11d2_9f0e_006097c686f6), + pid: 4, +}; + +pub fn info_for(path: &std::path::Path) -> Option { + if !path.exists() { + return None; + } + let _apartment = crate::com::Apartment::enter(); + let absolute = crate::com::shell_path(path)?; + let wide: Vec = absolute + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + // SAFETY: the string is null terminated and outlives the call. + let store: IPropertyStore = + unsafe { SHGetPropertyStoreFromParsingName(PCWSTR(wide.as_ptr()), None, GPS_DEFAULT) } + .ok()?; + + let info = MediaInfo { + duration: hundred_nanos(&store, PKEY_MEDIA_DURATION), + width: number(&store, PKEY_VIDEO_WIDTH), + height: number(&store, PKEY_VIDEO_HEIGHT), + title: text(&store, PKEY_TITLE), + artist: text(&store, PKEY_MUSIC_ARTIST), + album: text(&store, PKEY_MUSIC_ALBUM), + }; + + (!info.is_empty()).then_some(info) +} + +fn value_of(store: &IPropertyStore, key: PROPERTYKEY) -> Option { + // SAFETY: the key is a constant and the value is cleared by its own Drop. + unsafe { store.GetValue(&key) }.ok() +} + +fn hundred_nanos(store: &IPropertyStore, key: PROPERTYKEY) -> Option { + let raw = number_u64(store, key)?; + let seconds = raw as f64 / 10_000_000.0; + (seconds.is_finite() && seconds > 0.0).then_some(seconds) +} + +fn number_u64(store: &IPropertyStore, key: PROPERTYKEY) -> Option { + let value = value_of(store, key)?; + let raw = u64::try_from(&value).ok()?; + (raw > 0).then_some(raw) +} + +fn number(store: &IPropertyStore, key: PROPERTYKEY) -> Option { + let value = value_of(store, key)?; + let raw = u32::try_from(&value).ok()?; + (raw > 0).then_some(raw) +} + +fn text(store: &IPropertyStore, key: PROPERTYKEY) -> Option { + let value = value_of(store, key)?; + // SAFETY: the allocation is handed back to the system below. + let raw = unsafe { PropVariantToStringAlloc(&value) }.ok()?; + if raw.is_null() { + return None; + } + // SAFETY: the system returned a null terminated string. + let text = unsafe { raw.to_string() }.ok(); + // SAFETY: pairs with the allocation above. + unsafe { CoTaskMemFree(Some(raw.as_ptr().cast())) }; + text.filter(|text| !text.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_file_that_is_not_there_has_no_metadata() { + assert_eq!( + info_for(std::path::Path::new(r"C:\no-existe-nada.mp3")), + None + ); + } + + #[test] + fn a_file_without_media_metadata_says_nothing() { + let dir = std::env::temp_dir().join("cp-media"); + std::fs::create_dir_all(&dir).expect("carpeta"); + let path = dir.join("texto.txt"); + std::fs::write(&path, b"no soy un video").expect("archivo"); + assert_eq!(info_for(&path), None); + } + + #[test] + fn nothing_known_is_nothing_to_search() { + assert!(MediaInfo::default().is_empty()); + assert_eq!(MediaInfo::default().searchable(), ""); + } + + #[test] + fn what_is_known_becomes_searchable() { + let info = MediaInfo { + title: Some("Canción".into()), + artist: Some("Alguien".into()), + album: None, + ..Default::default() + }; + assert!(!info.is_empty()); + assert_eq!(info.searchable(), "Canción Alguien"); + } + + #[test] + fn a_duration_alone_is_metadata_too() { + let info = MediaInfo { + duration: Some(12.5), + ..Default::default() + }; + assert!(!info.is_empty()); + assert_eq!(info.searchable(), "", "una duración no se busca por texto"); + } +} diff --git a/crates/cp-win-sys/src/thumbnail.rs b/crates/cp-win-sys/src/thumbnail.rs new file mode 100644 index 00000000..d98a9aba --- /dev/null +++ b/crates/cp-win-sys/src/thumbnail.rs @@ -0,0 +1,160 @@ +use std::os::windows::ffi::OsStrExt; +use windows::Win32::Graphics::Gdi::{ + BI_RGB, BITMAP, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, DeleteObject, GetDC, GetDIBits, + GetObjectW, HBITMAP, ReleaseDC, +}; +use windows::Win32::UI::Shell::{ + IShellItemImageFactory, SHCreateItemFromParsingName, SIIGBF_INCACHEONLY, SIIGBF_THUMBNAILONLY, +}; +use windows::core::PCWSTR; + +pub const SIDE: i32 = 256; +pub const SMALLEST: i32 = 64; + +const _: () = assert!(SMALLEST < SIDE); + +pub fn dib_of_file(path: &std::path::Path, side: i32) -> Option> { + if side <= 0 { + return None; + } + let _apartment = crate::com::Apartment::enter(); + let absolute = crate::com::shell_path(path)?; + let wide: Vec = absolute + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + // SAFETY: the string is null terminated and outlives the call. + let factory: IShellItemImageFactory = + unsafe { SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None) }.ok()?; + let wanted = windows::Win32::Foundation::SIZE { cx: side, cy: side }; + + // SAFETY: the factory is alive and the bitmap is released below. + let cached = unsafe { factory.GetImage(wanted, SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY) }; + let bitmap = match cached { + Ok(bitmap) => bitmap, + // SAFETY: same factory, asking the shell to build the entry this time. + Err(_) => unsafe { factory.GetImage(wanted, SIIGBF_THUMBNAILONLY) }.ok()?, + }; + let dib = as_dib(bitmap, side); + // SAFETY: the bitmap came from GetImage and is released once. + let _ = unsafe { DeleteObject(bitmap.into()) }; + dib +} + +fn as_dib(bitmap: HBITMAP, side: i32) -> Option> { + let mut shape = BITMAP::default(); + let wrote = i32::try_from(std::mem::size_of::()).ok()?; + // SAFETY: the struct is the size declared and the bitmap is alive. + let read = unsafe { GetObjectW(bitmap.into(), wrote, Some((&raw mut shape).cast())) }; + if read == 0 || is_an_icon(shape.bmWidth, shape.bmHeight, side) { + return None; + } + let stride = usize::try_from(shape.bmWidth).ok()? * 4; + let height = usize::try_from(shape.bmHeight).ok()?; + let pixels = stride.checked_mul(height)?; + + let mut info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: u32::try_from(std::mem::size_of::()).ok()?, + biWidth: shape.bmWidth, + biHeight: shape.bmHeight, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + biSizeImage: u32::try_from(pixels).ok()?, + ..Default::default() + }, + ..Default::default() + }; + + let mut dib = vec![0u8; std::mem::size_of::() + pixels]; + // SAFETY: a screen device context, released below. + let screen = unsafe { GetDC(None) }; + // SAFETY: the buffer holds the size the header declares. + let lines = unsafe { + GetDIBits( + screen, + bitmap, + 0, + u32::try_from(shape.bmHeight).ok()?, + Some( + dib[std::mem::size_of::()..] + .as_mut_ptr() + .cast(), + ), + &raw mut info, + DIB_RGB_COLORS, + ) + }; + // SAFETY: pairs with the GetDC above. + unsafe { ReleaseDC(None, screen) }; + if lines == 0 { + return None; + } + // SAFETY: the header is a plain struct of the size declared. + let header = unsafe { + std::slice::from_raw_parts( + (&raw const info.bmiHeader).cast::(), + std::mem::size_of::(), + ) + }; + dib[..std::mem::size_of::()].copy_from_slice(header); + Some(dib) +} + +pub fn is_an_icon(width: i32, height: i32, asked_for: i32) -> bool { + asked_for >= SIDE && width <= SMALLEST && height <= SMALLEST +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_file_that_is_not_there_has_no_thumbnail() { + assert_eq!( + dib_of_file(std::path::Path::new(r"C:\no-existe.png"), SIDE), + None + ); + } + + #[test] + fn a_side_of_zero_is_refused() { + let dir = std::env::temp_dir(); + assert_eq!(dib_of_file(&dir, 0), None); + assert_eq!(dib_of_file(&dir, -1), None); + } + + #[test] + fn a_tiny_square_at_full_size_is_the_generic_icon() { + assert!( + is_an_icon(32, 32, SIDE), + "el shell devuelve el icono del tipo" + ); + assert!(is_an_icon(SMALLEST, SMALLEST, SIDE)); + } + + #[test] + fn a_wide_thumbnail_is_not_an_icon() { + assert!( + !is_an_icon(256, 57, SIDE), + "una imagen apaisada tiene un lado corto y sigue siendo una miniatura" + ); + assert!(!is_an_icon(57, 256, SIDE)); + } + + #[test] + fn a_real_thumbnail_is_not_an_icon() { + assert!(!is_an_icon(SMALLEST + 1, SMALLEST + 1, SIDE)); + assert!(!is_an_icon(256, 144, SIDE)); + } + + #[test] + fn asking_for_a_small_image_never_calls_it_an_icon() { + assert!( + !is_an_icon(32, 32, 32), + "si se pidió pequeño, pequeño está bien" + ); + } +} diff --git a/crates/cp-win/examples/probe.rs b/crates/cp-win/examples/probe.rs index b3b290d9..04751e42 100644 --- a/crates/cp-win/examples/probe.rs +++ b/crates/cp-win/examples/probe.rs @@ -11,11 +11,11 @@ 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::frontmost::{self, Target}; -use cp_win_sys::ocr; 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}; struct Battery { passed: u32, @@ -688,6 +688,56 @@ fn main() -> std::process::ExitCode { } }); + b.group("N · Miniaturas y medios por el shell"); + + b.case( + "N1", + "el shell da miniatura de una imagen del disco", + || { + let png = std::path::Path::new("fixtures/texto-en-imagen.png"); + let started = std::time::Instant::now(); + let dib = thumbnail::dib_of_file(png, thumbnail::SIDE) + .ok_or("el shell no devolvió miniatura")?; + let took = started.elapsed(); + let small = cp_core::dib::to_png(&dib).ok_or("el DIB no se pudo convertir")?; + let original = std::fs::metadata(png).map_err(|why| why.to_string())?.len(); + println!( + " {took:?}, {} B de miniatura contra {original} del original", + small.len() + ); + if small.len() as u64 >= original { + return Err("la miniatura no es más pequeña que el original".into()); + } + Ok(()) + }, + ); + + b.case("N2", "lo que no tiene miniatura no inventa uno", || { + let dir = std::env::temp_dir().join("cp-sin-miniatura"); + std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + let path = dir.join("vacio.bin"); + std::fs::write(&path, b"").map_err(|why| why.to_string())?; + match thumbnail::dib_of_file(&path, thumbnail::SIDE) { + None => Ok(()), + Some(_) => Err("devolvió algo para un archivo sin vista previa".into()), + } + }); + + b.case( + "N3", + "un archivo sin metadatos de medios no los inventa", + || { + let dir = std::env::temp_dir().join("cp-sin-medios"); + std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + let path = dir.join("nota.txt"); + std::fs::write(&path, b"solo texto").map_err(|why| why.to_string())?; + match media::info_for(&path) { + None => Ok(()), + Some(info) => Err(format!("se inventó {info:?}")), + } + }, + ); + b.group("C · Los formatos que cuelgan no se piden"); b.case("C1", "nada marcado como presencia se llega a pedir", || {