diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc18992..1a60a07 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 @@ -20,12 +24,25 @@ 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 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + - uses: Swatinem/rust-cache@v2 + - 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 863b6ad..c9847fb 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 @@ -40,7 +38,7 @@ jobs: - name: No Spanish identifiers run: | 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 @@ -58,15 +56,32 @@ 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: - 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,17 +90,22 @@ 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: Every mutant dies in the crates without a system call (${{ 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 - 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 -p cp-core -p cp-store --no-times + - run: cargo mutants ${{ matrix.crates }} --no-times coverage: name: Coverage does not slip @@ -97,12 +117,24 @@ 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 + 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 + - 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 95 --summary-only + budget: name: Latency stays within budget runs-on: macos-15 @@ -112,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 25f47aa..0a24c49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,25 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "cp-win" +version = "3.0.0" +dependencies = [ + "cp-core", + "cp-win-sys", + "image", + "windows", +] + +[[package]] +name = "cp-win-sys" +version = "3.0.0" +dependencies = [ + "image", + "windows", + "windows-future", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1034,12 +1053,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" @@ -1049,6 +1163,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 d26c4d6..376ef38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ 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" } +cp-win-sys = { path = "crates/cp-win-sys" } thiserror = "2" tracing = "0.1" @@ -28,6 +30,29 @@ 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", + "Media_Ocr", + "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" + objc2 = "0.6" objc2-foundation = "0.3" objc2-app-kit = "0.3" diff --git a/PRIVACY.md b/PRIVACY.md index 05b439b..7a54f71 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 167127c..b3338d0 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 new file mode 100644 index 0000000..8a10c82 --- /dev/null +++ b/crates/cp-core/src/dib.rs @@ -0,0 +1,717 @@ +const FILE_HEADER: usize = 14; +const INFO_HEADER: usize = 40; +const BI_BITFIELDS: u32 = 3; +const RGBQUAD: usize = 4; + +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, + 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)?; + 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)?, + }) +} + +pub fn pixel_offset(dib: &[u8]) -> Option { + let head = header(dib)?; + let mut table = 0usize; + + 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 { + 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) +} + +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) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alpha { + Absent, + Opaque, + Real, +} + +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() +} + +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(pixels) = pixels_of(dib) else { + return Alpha::Absent; + }; + 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 + } +} + +fn too_large(len: usize) -> bool { + len > LARGEST_BITMAP +} + +pub fn to_png(dib: &[u8]) -> Option> { + 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; + } + } + 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()) +} + +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.get(FILE_HEADER..) + .filter(|pixels| !pixels.is_empty()) + .map(<[u8]>::to_vec) +} + +#[cfg(test)] +mod tests { + use super::*; + + 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)); + } + + #[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)); + } + + #[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)); + } + + #[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)); + } + + #[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)); + } + + #[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" + ); + } + + #[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); + } + + #[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); + } + + #[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" + ); + } + + #[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" + ); + } + + #[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" + ); + } + + #[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); + } + + #[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); + 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); + } + + #[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); + } + + #[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 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(); + 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)); + } + + #[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" + ); + } + + #[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" + ); + } + + #[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(); + 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:?}" + ); + } + + #[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! { + #[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); + } + + #[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); + } + } + + #[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 eba3941..407022c 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,27 +12,33 @@ pub enum Family { Files, } -/// 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + Marked(&'static str), + Declined(&'static str), +} + +impl Refusal { + pub fn marker(self) -> &'static str { + match self { + Refusal::Marked(id) | Refusal::Declined(id) => id, + } + } +} + 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], - /// Prefijos de tipos generados, que nunca llevan payload útil. + pub denied_when_zero: &'static [&'static str], 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], + pub equivalents: &'static [&'static [&'static str]], + pub embeddable: &'static [&'static str], } impl Catalog { @@ -47,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 { @@ -67,38 +63,57 @@ impl Catalog { Take::Presence } - /// 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)) + }) + } + + 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 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)) { 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)); + 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) + } + + 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 +133,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 +148,13 @@ mod tests { wanted: &[], aliases: &[], concealed: &[], + denied_when_zero: &[], opaque_prefixes: &[], text: &[], files: &[], images_by_preference: &[], + equivalents: &[], + embeddable: &[], }; #[test] @@ -142,7 +163,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 +173,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 +202,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 +239,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 +271,115 @@ mod tests { } } +#[cfg(test)] +mod windows_needs { + use super::tests::PROBE; + use super::*; + + #[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)); + } + + #[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)); + } + + #[test] + fn an_embeddable_marker_without_text_does_not_hide_the_image() { + assert_eq!( + PROBE.classify(&["cheap/image", "embedded/document"]), + Some(Family::Image) + ); + } + + #[test] + fn files_still_win_over_everything() { + assert_eq!( + PROBE.classify(&["one/file", "plain/text", "cheap/image", "embedded/document"]), + Some(Family::Files) + ); + } + + #[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"])); + } + + #[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); + } + + #[test] + 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:?}" + ); + } + } + + #[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); + } + + #[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); + } + + #[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); + } + + #[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 +394,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}", @@ -264,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)) { @@ -281,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(); @@ -297,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(); @@ -317,5 +451,50 @@ mod properties { None => {} } } + + #[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)); + } + } + + #[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)); + 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 => {} + } + } + + #[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/hash.rs b/crates/cp-core/src/hash.rs index e6ea18e..991f13a 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 9081636..fc7d706 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 a27b195..4995d5a 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; @@ -94,10 +85,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; @@ -115,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; } @@ -137,7 +126,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; @@ -217,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; @@ -245,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 ", @@ -409,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::*; @@ -494,6 +475,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"); @@ -536,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)); @@ -555,3 +538,71 @@ mod redundancy { } } } + +#[cfg(test)] +mod inherited_from_2x { + use super::*; + + #[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); + } + + #[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}»"); + } + } + + #[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); + } + + #[test] + fn an_address_without_the_scheme_is_still_an_address() { + assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); + } + + #[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}»"); + } + } + + #[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); + } + + #[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); + } + + #[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 06c0615..d65efb4 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/paste.rs b/crates/cp-core/src/paste.rs index df19bbb..f3d81b5 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 5ce56f5..2361e09 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 70f445f..8a1d8f3 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -1,40 +1,40 @@ -/// 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. - Fresh { skipped: u64 }, + Fresh { skipped: Option }, } -/// 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, Clone, Copy, PartialEq, Eq)] +pub enum Cadence { + OnePerCopy, + Opaque, +} + +#[derive(Debug)] pub struct Watcher { last: Option, ours: Option, missed: u64, + cadence: Cadence, } impl Watcher { - /// Registra el contador que dejó nuestra propia escritura, para - /// descartar exactamente esa y no una ventana de tiempo alrededor. + pub fn new(cadence: Cadence) -> Self { + Self { + last: None, + ours: None, + missed: 0, + cadence, + } + } + 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; }; @@ -42,20 +42,30 @@ 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; } - // 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 { + 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 + 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 +73,178 @@ 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)); + } +} + +#[cfg(test)] +mod windows_counter { + use super::*; + + fn windows() -> Watcher { + Watcher::new(Cadence::Opaque) + } + + #[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" + ); + } + + #[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" + ); + } + } + + #[test] + 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 }); + assert_eq!(watcher.tick(1005), Seen::Fresh { skipped: None }); + assert_eq!(watcher.missed(), None); + } + + #[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); + } + + #[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 + ); } } @@ -161,18 +253,14 @@ 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::default(); + let mut watcher = Watcher::new(Cadence::OnePerCopy); let mut emitted: u64 = 0; for count in &seq { if let Seen::Fresh { .. } = watcher.tick(*count) { @@ -180,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) { @@ -192,25 +278,28 @@ 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. #[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 +307,53 @@ mod properties { } } - /// Solo el contador exacto que registramos se descarta como nuestro, y - /// solo una vez. + #[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); + } + } + + #[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); + } + } + #[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) }); + 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 f6da49f..edfab48 100644 --- a/crates/cp-mac-sys/build.rs +++ b/crates/cp-mac-sys/build.rs @@ -1,4 +1,7 @@ fn main() { + 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-sys/src/frontmost.rs b/crates/cp-mac-sys/src/frontmost.rs index e515d6b..d512718 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 e5f0942..626e640 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 a938254..e95b19a 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 527bad3..2dac6f8 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 1b70a33..e847d01 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 7272783..02b1d9c 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 0555541..c964ef0 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 7058a8a..db8116c 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 c71450e..fc72566 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/Cargo.toml b/crates/cp-mac/Cargo.toml index 9006a08..2871f50 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 fed168c..a9c2b6b 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -1,934 +1,5 @@ -//! 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`. +#[cfg(target_os = "macos")] +include!("probe/battery.rs"); -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(); -} +#[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 0000000..053a994 --- /dev/null +++ b/crates/cp-mac/examples/probe/battery.rs @@ -0,0 +1,910 @@ +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(); + + 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")?; + 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(); + + 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()); + } + + 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())?; + 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"); + } + 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 + } +} + +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())?; + run_open(&["-a", "TextEdit", path]); + 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)); + + 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 bd3b296..ff42157 100644 --- a/crates/cp-mac/src/capture.rs +++ b/crates/cp-mac/src/capture.rs @@ -4,39 +4,28 @@ 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(); - 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 { 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) { Take::Never => Payload::Announced { size: None }, Take::Presence => Payload::Announced { size: None }, Take::Payload => { - if is_costlier_twin(canonical, cheapest_image) { - // Misma imagen, representación cara: se anota y no se - // pide. Medido: 52 veces más grande en el mismo copiado. + if CATALOG.costlier_twin(canonical, &ids) { 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, @@ -55,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() { @@ -75,13 +60,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), @@ -111,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 7a59964..382162b 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,6 +39,7 @@ pub const CATALOG: Catalog = Catalog { "net.antelle.keeweb", "PasswordPboardType", ], + denied_when_zero: &[], opaque_prefixes: &["dyn.", "CorePasteboardFlavorType"], text: &[ "public.utf8-plain-text", @@ -51,6 +50,8 @@ pub const CATALOG: Catalog = Catalog { ], files: &["public.file-url"], images_by_preference: &["public.png", "public.tiff"], + equivalents: &[], + embeddable: &[], }; #[cfg(test)] @@ -58,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", @@ -103,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")); @@ -159,7 +156,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-mac/src/lib.rs b/crates/cp-mac/src/lib.rs index 33f875e..7be00e1 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 3b545ed..f69b735 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 51b2c4d..d0b6ebb 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 f7a4e25..c332ff5 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 1054f35..db8926d 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 f201b57..e0dfc7d 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -4,15 +4,12 @@ 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)] 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 f52f5ea..f4a9e0b 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 90981af..594d3a8 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, @@ -39,10 +32,10 @@ pub struct Filter { pub struct Store { db: Connection, blobs: Option, + exposure: Restricted, } impl Store { - /// Solo para diagnóstico: mirar planes de consulta desde un ejemplo. pub fn raw(&self) -> &Connection { &self.db } @@ -50,15 +43,13 @@ 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. - /// - /// 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)?; @@ -67,38 +58,38 @@ impl Store { let db = Connection::open(path)?; crate::schema::create(&db)?; crate::schema::migrate(&db)?; - 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. + let exposure = restrict(path, 0o600)?; + for side in sidecars(path) { + if side.exists() { + restrict(&side, 0o600)?; + } + } let blobs = path .parent() .map(|parent| crate::Blobs::at(&parent.join("blobs"))) .transpose()?; - Ok(Self { db, blobs }) + Ok(Self { + db, + blobs, + exposure, + }) + } + + 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( @@ -110,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", @@ -124,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 @@ -135,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", @@ -152,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", @@ -167,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 @@ -179,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) @@ -208,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)", @@ -217,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 @@ -239,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 { @@ -267,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", @@ -284,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, @@ -299,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)? { @@ -312,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 @@ -338,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 @@ -359,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 @@ -369,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, @@ -379,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() { @@ -422,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 @@ -445,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", @@ -456,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 @@ -472,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", @@ -482,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()); } @@ -537,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)]; @@ -557,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( @@ -576,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") } @@ -595,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, @@ -655,11 +571,51 @@ impl Store { } } -/// 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) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Restricted { + Mode(u32), + InheritedFromProfile, + Unprotected, +} + +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)] + { + 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(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, + } +} + +#[cfg_attr(unix, allow(dead_code))] +fn under(path: &std::path::Path, root: &std::path::Path) -> bool { + match (path.canonicalize(), root.canonicalize()) { + (Ok(path), Ok(root)) => path.starts_with(root), + _ => false, + } } #[cfg(test)] @@ -687,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(); @@ -706,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(); @@ -889,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(); @@ -1081,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() { @@ -1427,6 +1374,36 @@ 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() { use std::os::unix::fs::PermissionsExt; @@ -1436,6 +1413,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 +1430,92 @@ mod tests { assert_eq!(folder, 0o700, "y la carpeta igual"); } + #[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); + } + + 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_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 + ); + } + + #[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 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] fn a_checkpoint_leaves_the_data_in_the_main_file() { let dir = tempfile::tempdir().expect("carpeta"); @@ -1989,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"); @@ -2006,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"); @@ -2023,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 0000000..b189917 --- /dev/null +++ b/crates/cp-win-sys/Cargo.toml @@ -0,0 +1,23 @@ +[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 +windows-future.workspace = true + +[target.'cfg(target_os = "windows")'.dev-dependencies] +image.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 0000000..362ef21 --- /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/com.rs b/crates/cp-win-sys/src/com.rs new file mode 100644 index 0000000..7cb1746 --- /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/formats.rs b/crates/cp-win-sys/src/formats.rs new file mode 100644 index 0000000..89f0371 --- /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 0000000..9d24b5b --- /dev/null +++ b/crates/cp-win-sys/src/frontmost.rs @@ -0,0 +1,219 @@ +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, + }) +} + +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()?, + ..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 0000000..ebae0b5 --- /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 0000000..2ae4b36 --- /dev/null +++ b/crates/cp-win-sys/src/lib.rs @@ -0,0 +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 0000000..3cf521b --- /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/ocr.rs b/crates/cp-win-sys/src/ocr.rs new file mode 100644 index 0000000..170128d --- /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/paths.rs b/crates/cp-win-sys/src/paths.rs new file mode 100644 index 0000000..0a236ab --- /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/permissions.rs b/crates/cp-win-sys/src/permissions.rs new file mode 100644 index 0000000..400bc20 --- /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/reading.rs b/crates/cp-win-sys/src/reading.rs new file mode 100644 index 0000000..025b347 --- /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 0000000..525bffa --- /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/thumbnail.rs b/crates/cp-win-sys/src/thumbnail.rs new file mode 100644 index 0000000..d98a9ab --- /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-sys/src/window.rs b/crates/cp-win-sys/src/window.rs new file mode 100644 index 0000000..e2d3a7a --- /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-sys/src/writing.rs b/crates/cp-win-sys/src/writing.rs new file mode 100644 index 0000000..0faab21 --- /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 new file mode 100644 index 0000000..a7c80a9 --- /dev/null +++ b/crates/cp-win/Cargo.toml @@ -0,0 +1,19 @@ +[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 +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 new file mode 100644 index 0000000..04751e4 --- /dev/null +++ b/crates/cp-win/examples/probe.rs @@ -0,0 +1,822 @@ +#![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::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::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, + 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("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("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", || { + 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 0000000..23d446d --- /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 new file mode 100644 index 0000000..3d0c264 --- /dev/null +++ b/crates/cp-win/src/formats.rs @@ -0,0 +1,381 @@ +use cp_core::formats::Catalog; + +pub const CATALOG: Catalog = Catalog { + 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", + "CF_BITMAP", + "FileGroupDescriptorW", + "FileContents", + "Shell IDList Array", + "Chromium internal source RFH token", + "FileName", + "FileNameW", + ], + wanted: &[ + "CF_UNICODETEXT", + "Rich Text Format", + "HTML Format", + "PNG", + "CF_DIBV5", + "CF_DIB", + "CF_HDROP", + "Csv", + "Preferred DropEffect", + "Chromium internal source URL", + "CanIncludeInClipboardHistory", + ], + aliases: &[], + 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: &[ + &["CF_UNICODETEXT", "CF_TEXT", "CF_OEMTEXT"], + &["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}; + + 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:?}"); + for heavy in ["Embed Source", "Native", "CF_ENHMETAFILE"] { + assert_eq!(CATALOG.decide(heavy), Take::Presence, "{heavy}"); + } + } + + #[test] + fn a_spreadsheet_is_text_and_not_a_picture_of_itself() { + assert_eq!(CATALOG.classify(EXCEL), Some(Family::Text)); + } + + #[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" + ); + } + + #[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)); + } + + #[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)); + } + } + + #[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)); + } + + #[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)); + } + + #[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}"); + } + } + + #[test] + fn virtual_files_are_noted_and_never_asked_for() { + for virtualised in ["FileGroupDescriptorW", "FileContents"] { + assert_eq!(CATALOG.decide(virtualised), Take::Presence); + } + } + + #[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"]); + } + + #[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" + ); + } + + #[test] + fn the_unknown_is_only_noted() { + for unknown in ["ApplicationXYZ", "", "algo/inventado"] { + assert_eq!(CATALOG.decide(unknown), Take::Presence); + } + } + + #[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}"); + } + } + + #[test] + fn nothing_is_both_wanted_and_wasteful() { + for id in CATALOG.wanted { + assert!( + !CATALOG.wasteful.contains(id), + "«{id}» está en las dos listas" + ); + } + } + + #[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}»"); + } + } + + #[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 { + 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 0000000..2214e06 --- /dev/null +++ b/crates/cp-win/src/lib.rs @@ -0,0 +1,11 @@ +#![cfg(target_os = "windows")] + +pub mod capture; +pub mod formats; +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/paste.rs b/crates/cp-win/src/paste.rs new file mode 100644 index 0000000..97fc713 --- /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 0000000..4cdc95c --- /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 new file mode 100644 index 0000000..5dc1696 --- /dev/null +++ b/crates/cp-win/src/transfer.rs @@ -0,0 +1,82 @@ +const COPY: u32 = 1; +const MOVE: u32 = 2; +const LINK: u32 = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transfer { + Copy, + Move, + Unsaid, +} + +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]); + if effect & MOVE != 0 { + return Transfer::Move; + } + if effect & COPY != 0 || effect & LINK != 0 { + return Transfer::Copy; + } + Transfer::Unsaid +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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); + } + + #[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); + } + + #[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); + } + + #[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); + } +} diff --git a/crates/cp-win/src/watch.rs b/crates/cp-win/src/watch.rs new file mode 100644 index 0000000..4986e3a --- /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/crates/cp-win/src/watching.rs b/crates/cp-win/src/watching.rs new file mode 100644 index 0000000..c64570b --- /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); + } +} diff --git a/deny.toml b/deny.toml index dbdc946..6cffa09 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]