From 75a6283adf4ac714973a1048f3a231062d37d135 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 17:25:59 -0300 Subject: [PATCH 01/18] feat(core): first executable invariants of the clipboard core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four modules under forbid(unsafe), no platform dependencies, 19 tests. search: the fold applied to index and term alike, which is what 2.x gets wrong in both directions for Straße, encyclopædia and Łódź. formats: payload, presence or never. Fixes the type that hangs, the ten megabytes of icon Finder offers for a nine-byte file, PNG over TIFF, the legacy twins, and password detection by type rather than content. hash: whole content up to 256 KB, sixteen spread blocks above it. Two screenshots sharing half an image no longer collide. paste: the clipboard is written before anything touches focus, unknown focus still pastes, a successful send is never retried, races get two retries and a denied send none, and Secure Input warns without aborting. Also fixes three CI guards that failed on their own first run: the SPDX identifier in the licence allow-list, wildcard paths for workspace-internal dependencies, and the synthetic merge commit GitHub checks out on a PR. --- .github/workflows/commits.yml | 2 +- .github/workflows/rules.yml | 7 +- Cargo.lock | 7 ++ crates/cp-core/Cargo.toml | 1 + crates/cp-core/src/formats.rs | 219 ++++++++++++++++++++++++++++++++++ crates/cp-core/src/hash.rs | 70 +++++++++++ crates/cp-core/src/lib.rs | 10 +- crates/cp-core/src/paste.rs | 161 +++++++++++++++++++++++++ deny.toml | 3 +- 9 files changed, 469 insertions(+), 11 deletions(-) create mode 100644 crates/cp-core/src/formats.rs create mode 100644 crates/cp-core/src/hash.rs create mode 100644 crates/cp-core/src/paste.rs diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index 1978048..cd31550 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -20,5 +20,5 @@ jobs: if ! printf '%s' "$subject" | grep -qE "$pattern"; then echo "no sigue la convencion: $subject"; fail=1 fi - done < <(git rev-list origin/${{ github.base_ref }}..HEAD) + done < <(git rev-list --no-merges origin/${{ github.base_ref }}..HEAD) exit $fail diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 3200c1c..76979e1 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -44,7 +44,7 @@ jobs: - name: No voseo in the Spanish anywhere run: | - if grep -rniE '\b(vos|tenes|queres|podes|anda|mira|hace|che)\b' \ + if grep -rniE '\b(vos|tenés|querés|podés|andá|mirá|hacé|che)\b' \ crates docs README.md --include='*.rs' --include='*.md' 2>/dev/null; then echo "espanol neutro, sin voseo"; exit 1 fi @@ -58,8 +58,9 @@ jobs: - name: No prose blocks in the code run: | - if awk '/^\s*\/\/[^\/]/{n++; if (n>3) {print FILENAME": bloque de prosa"; exit 1}} !/^\s*\/\//{n=0}' \ - $(find crates -name '*.rs'); then :; else exit 1; fi + awk '/^[ \t]*\/\/[^\/]/ { n++; if (n > 3) { print FILENAME":"NR": bloque de prosa"; bad = 1 }; next } + { n = 0 } + END { exit bad }' $(find crates -name '*.rs') deterministic: name: Tests are deterministic diff --git a/Cargo.lock b/Cargo.lock index 37cecba..feb221b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,7 @@ version = "3.0.0" dependencies = [ "thiserror", "unicode-normalization", + "xxhash-rust", ] [[package]] @@ -455,3 +456,9 @@ name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" diff --git a/crates/cp-core/Cargo.toml b/crates/cp-core/Cargo.toml index 63644c6..d9556ad 100644 --- a/crates/cp-core/Cargo.toml +++ b/crates/cp-core/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] thiserror.workspace = true unicode-normalization.workspace = true +xxhash-rust.workspace = true [lints] workspace = true diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs new file mode 100644 index 0000000..dc09881 --- /dev/null +++ b/crates/cp-core/src/formats.rs @@ -0,0 +1,219 @@ +/// 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, +} + +/// Pedir los datos de este tipo cuelga la aplicación indefinidamente. +const HANGS: &[&str] = &["com.apple.pasteboard.promised-suggested-file-name"]; + +/// Arrastran megabytes sin aportar nada recuperable. +const WASTEFUL: &[&str] = &[ + "com.microsoft.Link-Source", + "com.microsoft.ObjectLink", + "com.apple.icns", +]; + +const WANTED: &[&str] = &[ + "public.utf8-plain-text", + "public.rtf", + "com.apple.flat-rtfd", + "public.html", + "com.apple.webarchive", + "public.png", + "public.tiff", + "public.file-url", + "public.url", + "public.url-name", + "org.chromium.source-url", +]; + +const ALIASES: &[(&str, &str)] = &[ + ("NSStringPboardType", "public.utf8-plain-text"), + ("NeXT TIFF v4.0 pasteboard type", "public.tiff"), + ("Apple PNG pasteboard type", "public.png"), + ("NeXT Rich Text Format v1.0 pasteboard type", "public.rtf"), + ("NeXT RTFD pasteboard type", "com.apple.flat-rtfd"), + ("Apple HTML pasteboard type", "public.html"), +]; + +/// La presencia de cualquiera de estos significa «no registres esto». Se mira +/// el tipo, nunca el contenido: KeePassXC y KeePassium ponen el secreto en +/// claro dentro del payload, así que leerlo para decidir ya sería demasiado. +const CONCEALED: &[&str] = &[ + "org.nspasteboard.ConcealedType", + "org.nspasteboard.TransientType", + "org.nspasteboard.AutoGeneratedType", + "com.agilebits.onepassword", + "de.petermaurer.TransientPasteboardType", + "com.typeit4me.clipping", + "Pasteboard generator type", + "net.antelle.keeweb", + "PasswordPboardType", +]; + +pub fn canonical(uti: &str) -> &str { + ALIASES + .iter() + .find(|(legacy, _)| *legacy == uti) + .map_or(uti, |(_, modern)| *modern) +} + +pub fn decide(uti: &str) -> Take { + let uti = canonical(uti); + if HANGS.contains(&uti) { + return Take::Never; + } + if WASTEFUL.contains(&uti) + || uti.starts_with("dyn.") + || uti.starts_with("CorePasteboardFlavorType") + { + return Take::Presence; + } + if WANTED.contains(&uti) { + return Take::Payload; + } + Take::Presence +} + +pub fn is_concealed(offered: &[&str]) -> bool { + offered.iter().any(|uti| CONCEALED.contains(uti)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Text, + Image, + Files, +} + +/// El tipo mostrado es una clasificación sobre el conjunto, nunca una elección +/// que descarte lo demás. +pub fn classify(offered: &[&str]) -> Option { + let known: Vec<&str> = offered.iter().map(|uti| canonical(uti)).collect(); + if known.contains(&"public.file-url") { + return Some(Kind::Files); + } + if known.contains(&"public.png") || known.contains(&"public.tiff") { + return Some(Kind::Image); + } + if known.iter().any(|uti| { + matches!( + *uti, + "public.utf8-plain-text" | "public.rtf" | "com.apple.flat-rtfd" | "public.html" + ) + }) { + return Some(Kind::Text); + } + None +} + +/// Entre dos representaciones del mismo pixel, la más barata. Medido el +/// 12/09/2026: la misma imagen desde Preview pesa 381 B en PNG y 19.844 en +/// TIFF, y el TIFF además pierde la representación Retina. +pub fn preferred_image(offered: &[&str]) -> Option<&'static str> { + let known: Vec<&str> = offered.iter().map(|uti| canonical(uti)).collect(); + if known.contains(&"public.png") { + return Some("public.png"); + } + if known.contains(&"public.tiff") { + return Some("public.tiff"); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_type_that_hangs_is_never_asked_for() { + assert_eq!( + decide("com.apple.pasteboard.promised-suggested-file-name"), + Take::Never + ); + } + + #[test] + fn copying_one_file_does_not_drag_ten_megabytes_of_icon() { + let finder = [ + "public.file-url", + "NSFilenamesPboardType", + "com.apple.icns", + "public.tiff", + "public.utf8-plain-text", + "com.apple.finder.noderef", + ]; + for uti in ["com.apple.icns", "public.tiff", "NSFilenamesPboardType"] { + let taken = decide(uti); + if uti == "public.tiff" { + assert_eq!(taken, Take::Payload, "el TIFF es la imagen, no el icono"); + } else { + assert_ne!(taken, Take::Payload, "{uti} no se copia entero"); + } + } + assert_eq!(decide("public.file-url"), Take::Payload); + assert_eq!(classify(&finder), Some(Kind::Files)); + } + + #[test] + fn png_wins_over_tiff_when_both_are_offered() { + assert_eq!( + preferred_image(&["public.tiff", "public.png"]), + Some("public.png") + ); + assert_eq!(preferred_image(&["public.tiff"]), Some("public.tiff")); + } + + #[test] + fn legacy_twins_collapse_onto_the_modern_type() { + assert_eq!(canonical("NeXT TIFF v4.0 pasteboard type"), "public.tiff"); + assert_eq!(canonical("Apple PNG pasteboard type"), "public.png"); + assert_eq!(canonical("NSStringPboardType"), "public.utf8-plain-text"); + assert_eq!(canonical("public.png"), "public.png"); + } + + #[test] + fn a_password_is_recognised_by_the_type_alone() { + assert!(is_concealed(&[ + "public.utf8-plain-text", + "org.nspasteboard.ConcealedType" + ])); + assert!(!is_concealed(&["public.utf8-plain-text"])); + } + + #[test] + fn safari_keeps_everything_it_offered() { + let safari = [ + "com.apple.webarchive", + "com.apple.flat-rtfd", + "public.rtf", + "public.html", + "public.utf8-plain-text", + "com.apple.WebKit.custom-pasteboard-data", + ]; + let kept: Vec<&str> = safari + .iter() + .filter(|uti| decide(uti) == Take::Payload) + .copied() + .collect(); + assert_eq!( + kept.len(), + 5, + "solo el tipo privado de WebKit se deja fuera" + ); + assert_eq!(classify(&safari), Some(Kind::Text)); + } + + #[test] + fn an_image_copied_with_its_url_is_still_an_image() { + let safari = ["public.url", "public.utf8-plain-text", "public.png"]; + assert_eq!(classify(&safari), Some(Kind::Image)); + assert_eq!(decide("public.url"), Take::Payload); + } +} diff --git a/crates/cp-core/src/hash.rs b/crates/cp-core/src/hash.rs new file mode 100644 index 0000000..5cb20ba --- /dev/null +++ b/crates/cp-core/src/hash.rs @@ -0,0 +1,70 @@ +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; + +/// 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()); + mixed.extend_from_slice(&bytes[start..end]); + } + xxh3_64(&mixed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn screenshot(shared_header: usize, tail: u8) -> Vec { + let mut one = vec![0xAB; shared_header]; + one.resize(1024 * 1024, tail); + one + } + + #[test] + fn two_captures_sharing_a_menu_bar_are_not_the_same_capture() { + let a = screenshot(512 * 1024, 0x01); + let b = screenshot(512 * 1024, 0x02); + assert_eq!(a.len(), b.len(), "mismo tamaño, media imagen idéntica"); + assert_ne!( + content_hash(&a), + content_hash(&b), + "muestrear solo la cabecera las daría por iguales" + ); + } + + #[test] + fn two_long_texts_that_begin_alike_are_different_items() { + let shared = "x".repeat(100); + let a = format!("{shared}primero"); + let b = format!("{shared}segundo"); + assert_ne!(content_hash(a.as_bytes()), content_hash(b.as_bytes())); + } + + #[test] + fn the_same_content_always_hashes_the_same() { + let big = screenshot(512 * 1024, 0x07); + assert_eq!(content_hash(&big), content_hash(&big.clone())); + } + + #[test] + fn length_alone_separates_two_otherwise_identical_samples() { + let a = vec![0x5A; 2 * 1024 * 1024]; + let mut b = a.clone(); + b.push(0x5A); + assert_ne!(content_hash(&a), content_hash(&b)); + } +} diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index c0d77af..731e0af 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -1,8 +1,6 @@ +pub mod formats; +pub mod hash; +pub mod paste; pub mod search; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Kind { - Text, - Image, - Files, -} +pub use formats::Kind; diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs new file mode 100644 index 0000000..0c6c356 --- /dev/null +++ b/crates/cp-core/src/paste.rs @@ -0,0 +1,161 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + WriteClipboard, + HidePanel, + BringTargetForward, + VerifyForeground, + VerifyKeyboardFocus, + 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, + Phase::BringTargetForward, + Phase::VerifyForeground, + Phase::VerifyKeyboardFocus, + Phase::Send, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + OnTarget, + Elsewhere, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Failure { + ForegroundTimeout, + NoKeyboardFocus, + TargetGone, + SendDenied, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Next { + Retry, + Degrade, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Warning { + SecureInputActive, +} + +const RACE_RETRIES: u8 = 2; + +#[derive(Debug, Default)] +pub struct Attempt { + tries: u8, + sent: bool, +} + +impl Attempt { + pub fn sending(&mut self) { + self.sent = true; + } + + pub fn on_failure(&mut self, failure: Failure) -> Next { + if self.sent { + return Next::Degrade; + } + let retriable = matches!( + failure, + Failure::ForegroundTimeout | Failure::NoKeyboardFocus + ); + if !retriable || self.tries >= RACE_RETRIES { + return Next::Degrade; + } + self.tries += 1; + Next::Retry + } +} + +/// 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; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_clipboard_is_written_before_anything_touches_focus() { + let write = ORDER.iter().position(|p| *p == Phase::WriteClipboard); + let hide = ORDER.iter().position(|p| *p == Phase::HidePanel); + let forward = ORDER.iter().position(|p| *p == Phase::BringTargetForward); + assert_eq!(write, Some(0)); + assert!(write < hide && write < forward); + } + + #[test] + fn focus_is_verified_before_sending_and_never_after() { + let verify = ORDER + .iter() + .position(|p| *p == Phase::VerifyKeyboardFocus) + .unwrap(); + let send = ORDER.iter().position(|p| *p == Phase::Send).unwrap(); + assert!(verify < send); + assert_eq!(send, ORDER.len() - 1); + } + + #[test] + fn what_is_unknown_gets_pasted() { + assert!(!aborts(Focus::Unknown)); + assert!(!aborts(Focus::OnTarget)); + assert!(aborts(Focus::Elsewhere)); + } + + #[test] + fn a_successful_send_is_never_retried() { + let mut attempt = Attempt::default(); + attempt.sending(); + assert_eq!( + attempt.on_failure(Failure::ForegroundTimeout), + Next::Degrade, + "un pegado doble es peor que ninguno" + ); + } + + #[test] + fn races_are_retried_twice_and_then_given_up() { + let mut attempt = Attempt::default(); + assert_eq!(attempt.on_failure(Failure::ForegroundTimeout), Next::Retry); + assert_eq!(attempt.on_failure(Failure::NoKeyboardFocus), Next::Retry); + assert_eq!( + attempt.on_failure(Failure::ForegroundTimeout), + Next::Degrade + ); + } + + #[test] + fn a_denied_send_is_reported_not_retried() { + let mut attempt = Attempt::default(); + assert_eq!( + attempt.on_failure(Failure::SendDenied), + Next::Degrade, + "es UIPI o TCC: reintentar no cambia nada" + ); + } + + #[test] + fn secure_input_warns_but_never_aborts() { + assert!(!aborts_on(Warning::SecureInputActive)); + } +} diff --git a/deny.toml b/deny.toml index 28e86d0..ac5403c 100644 --- a/deny.toml +++ b/deny.toml @@ -3,7 +3,7 @@ yanked = "deny" [licenses] allow = [ - "GPL-3.0", + "GPL-3.0-only", "Apache-2.0", "MIT", "BSD-2-Clause", @@ -17,6 +17,7 @@ confidence-threshold = 0.9 [bans] multiple-versions = "warn" wildcards = "deny" +allow-wildcard-paths = true [sources] unknown-registry = "deny" From c6ab2ec4d9aaebd743932b5c8ebba58ff6c059fd Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 17:34:02 -0300 Subject: [PATCH 02/18] refactor(core): make the format catalogue platform-neutral The UTI tables had landed inside cp-core, which ties the core to macOS: Windows speaks CF_UNICODETEXT and CF_HDROP, not public.utf8-plain-text. cp-core now holds a Catalog of data only, with the rules written once on top of it. cp-mac supplies the macOS table; cp-win will supply its own without touching the core. The core's tests use invented type names to keep that boundary honest, while cp-mac's assert against the real type lists measured from Safari, Finder and Preview. Also drops three unsafe blocks the -sys crate did not need: objc2 exposes the whole pasteboard surface as safe, so macOS needs none so far. --- crates/cp-core/src/formats.rs | 289 ++++++++++++---------------- crates/cp-mac-sys/src/pasteboard.rs | 53 ++++- crates/cp-mac/src/formats.rs | 167 ++++++++++++++++ crates/cp-mac/src/lib.rs | 2 + 4 files changed, 344 insertions(+), 167 deletions(-) create mode 100644 crates/cp-mac/src/formats.rs diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index dc09881..f3caf48 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -9,82 +9,6 @@ pub enum Take { Never, } -/// Pedir los datos de este tipo cuelga la aplicación indefinidamente. -const HANGS: &[&str] = &["com.apple.pasteboard.promised-suggested-file-name"]; - -/// Arrastran megabytes sin aportar nada recuperable. -const WASTEFUL: &[&str] = &[ - "com.microsoft.Link-Source", - "com.microsoft.ObjectLink", - "com.apple.icns", -]; - -const WANTED: &[&str] = &[ - "public.utf8-plain-text", - "public.rtf", - "com.apple.flat-rtfd", - "public.html", - "com.apple.webarchive", - "public.png", - "public.tiff", - "public.file-url", - "public.url", - "public.url-name", - "org.chromium.source-url", -]; - -const ALIASES: &[(&str, &str)] = &[ - ("NSStringPboardType", "public.utf8-plain-text"), - ("NeXT TIFF v4.0 pasteboard type", "public.tiff"), - ("Apple PNG pasteboard type", "public.png"), - ("NeXT Rich Text Format v1.0 pasteboard type", "public.rtf"), - ("NeXT RTFD pasteboard type", "com.apple.flat-rtfd"), - ("Apple HTML pasteboard type", "public.html"), -]; - -/// La presencia de cualquiera de estos significa «no registres esto». Se mira -/// el tipo, nunca el contenido: KeePassXC y KeePassium ponen el secreto en -/// claro dentro del payload, así que leerlo para decidir ya sería demasiado. -const CONCEALED: &[&str] = &[ - "org.nspasteboard.ConcealedType", - "org.nspasteboard.TransientType", - "org.nspasteboard.AutoGeneratedType", - "com.agilebits.onepassword", - "de.petermaurer.TransientPasteboardType", - "com.typeit4me.clipping", - "Pasteboard generator type", - "net.antelle.keeweb", - "PasswordPboardType", -]; - -pub fn canonical(uti: &str) -> &str { - ALIASES - .iter() - .find(|(legacy, _)| *legacy == uti) - .map_or(uti, |(_, modern)| *modern) -} - -pub fn decide(uti: &str) -> Take { - let uti = canonical(uti); - if HANGS.contains(&uti) { - return Take::Never; - } - if WASTEFUL.contains(&uti) - || uti.starts_with("dyn.") - || uti.starts_with("CorePasteboardFlavorType") - { - return Take::Presence; - } - if WANTED.contains(&uti) { - return Take::Payload; - } - Take::Presence -} - -pub fn is_concealed(offered: &[&str]) -> bool { - offered.iter().any(|uti| CONCEALED.contains(uti)) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { Text, @@ -92,128 +16,169 @@ pub enum Kind { Files, } -/// El tipo mostrado es una clasificación sobre el conjunto, nunca una elección -/// que descarte lo demás. -pub fn classify(offered: &[&str]) -> Option { - let known: Vec<&str> = offered.iter().map(|uti| canonical(uti)).collect(); - if known.contains(&"public.file-url") { - return Some(Kind::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. +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 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], +} + +impl Catalog { + pub fn canonical<'a>(&self, id: &'a str) -> &'a str { + 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 + }) } - if known.contains(&"public.png") || known.contains(&"public.tiff") { - return Some(Kind::Image); + + pub fn decide(&self, id: &str) -> Take { + let id = self.canonical(id); + if self.hangs.contains(&id) { + return Take::Never; + } + if self.wasteful.contains(&id) || self.opaque_prefixes.iter().any(|p| id.starts_with(p)) { + return Take::Presence; + } + if self.wanted.contains(&id) { + return Take::Payload; + } + Take::Presence } - if known.iter().any(|uti| { - matches!( - *uti, - "public.utf8-plain-text" | "public.rtf" | "com.apple.flat-rtfd" | "public.html" - ) - }) { - return Some(Kind::Text); + + /// 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)) } - None -} -/// Entre dos representaciones del mismo pixel, la más barata. Medido el -/// 12/09/2026: la misma imagen desde Preview pesa 381 B en PNG y 19.844 en -/// TIFF, y el TIFF además pierde la representación Retina. -pub fn preferred_image(offered: &[&str]) -> Option<&'static str> { - let known: Vec<&str> = offered.iter().map(|uti| canonical(uti)).collect(); - if known.contains(&"public.png") { - return Some("public.png"); + /// 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(Kind::Files); + } + if known + .iter() + .any(|id| self.images_by_preference.contains(id)) + { + return Some(Kind::Image); + } + if known.iter().any(|id| self.text.contains(id)) { + return Some(Kind::Text); + } + None } - if known.contains(&"public.tiff") { - return Some("public.tiff"); + + pub fn preferred_image(&self, 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() } - None } #[cfg(test)] mod tests { use super::*; + const PROBE: Catalog = Catalog { + hangs: &["hangs/forever"], + wasteful: &["huge/icon"], + wanted: &[ + "plain/text", + "rich/text", + "cheap/image", + "costly/image", + "one/file", + ], + aliases: &[("old/text", "plain/text")], + concealed: &["secret/marker"], + opaque_prefixes: &["dyn."], + text: &["plain/text", "rich/text"], + files: &["one/file"], + images_by_preference: &["cheap/image", "costly/image"], + }; + #[test] - fn the_type_that_hangs_is_never_asked_for() { - assert_eq!( - decide("com.apple.pasteboard.promised-suggested-file-name"), - Take::Never - ); + fn what_hangs_is_never_asked_for() { + assert_eq!(PROBE.decide("hangs/forever"), Take::Never); } #[test] - fn copying_one_file_does_not_drag_ten_megabytes_of_icon() { - let finder = [ - "public.file-url", - "NSFilenamesPboardType", - "com.apple.icns", - "public.tiff", - "public.utf8-plain-text", - "com.apple.finder.noderef", - ]; - for uti in ["com.apple.icns", "public.tiff", "NSFilenamesPboardType"] { - let taken = decide(uti); - if uti == "public.tiff" { - assert_eq!(taken, Take::Payload, "el TIFF es la imagen, no el icono"); - } else { - assert_ne!(taken, Take::Payload, "{uti} no se copia entero"); - } - } - assert_eq!(decide("public.file-url"), Take::Payload); - assert_eq!(classify(&finder), Some(Kind::Files)); + fn the_expensive_and_the_unknown_are_only_noted() { + assert_eq!(PROBE.decide("huge/icon"), Take::Presence); + assert_eq!(PROBE.decide("dyn.abc123"), Take::Presence); + assert_eq!(PROBE.decide("who/knows"), Take::Presence); } #[test] - fn png_wins_over_tiff_when_both_are_offered() { - assert_eq!( - preferred_image(&["public.tiff", "public.png"]), - Some("public.png") - ); - assert_eq!(preferred_image(&["public.tiff"]), Some("public.tiff")); + fn a_legacy_name_is_the_modern_one() { + assert_eq!(PROBE.canonical("old/text"), "plain/text"); + assert_eq!(PROBE.decide("old/text"), Take::Payload); } #[test] - fn legacy_twins_collapse_onto_the_modern_type() { - assert_eq!(canonical("NeXT TIFF v4.0 pasteboard type"), "public.tiff"); - assert_eq!(canonical("Apple PNG pasteboard type"), "public.png"); - assert_eq!(canonical("NSStringPboardType"), "public.utf8-plain-text"); - assert_eq!(canonical("public.png"), "public.png"); + fn the_cheapest_representation_wins() { + assert_eq!( + PROBE.preferred_image(&["costly/image", "cheap/image"]), + Some("cheap/image") + ); + assert_eq!( + PROBE.preferred_image(&["costly/image"]), + Some("costly/image") + ); + assert_eq!(PROBE.preferred_image(&["plain/text"]), None); } #[test] - fn a_password_is_recognised_by_the_type_alone() { - assert!(is_concealed(&[ - "public.utf8-plain-text", - "org.nspasteboard.ConcealedType" - ])); - assert!(!is_concealed(&["public.utf8-plain-text"])); + fn a_secret_is_recognised_by_the_type_alone() { + assert!(PROBE.is_concealed(&["plain/text", "secret/marker"])); + assert!(!PROBE.is_concealed(&["plain/text"])); } #[test] - fn safari_keeps_everything_it_offered() { - let safari = [ - "com.apple.webarchive", - "com.apple.flat-rtfd", - "public.rtf", - "public.html", - "public.utf8-plain-text", - "com.apple.WebKit.custom-pasteboard-data", - ]; - let kept: Vec<&str> = safari + fn classifying_never_discards_the_rest() { + let mixed = ["plain/text", "cheap/image", "one/file"]; + assert_eq!(PROBE.classify(&mixed), Some(Kind::Files)); + let kept: Vec<&str> = mixed .iter() - .filter(|uti| decide(uti) == Take::Payload) + .filter(|id| PROBE.decide(id) == Take::Payload) .copied() .collect(); assert_eq!( kept.len(), - 5, - "solo el tipo privado de WebKit se deja fuera" + 3, + "clasificar no es elegir uno y tirar el resto" ); - assert_eq!(classify(&safari), Some(Kind::Text)); } #[test] - fn an_image_copied_with_its_url_is_still_an_image() { - let safari = ["public.url", "public.utf8-plain-text", "public.png"]; - assert_eq!(classify(&safari), Some(Kind::Image)); - assert_eq!(decide("public.url"), Take::Payload); + fn an_image_offered_with_its_text_is_still_an_image() { + assert_eq!( + PROBE.classify(&["plain/text", "cheap/image"]), + Some(Kind::Image) + ); } } diff --git a/crates/cp-mac-sys/src/pasteboard.rs b/crates/cp-mac-sys/src/pasteboard.rs index 2f8b29a..9e96fe6 100644 --- a/crates/cp-mac-sys/src/pasteboard.rs +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -1,8 +1,51 @@ use objc2_app_kit::NSPasteboard; -use objc2_foundation::MainThreadMarker; +use objc2_foundation::{MainThreadMarker, NSString}; -/// `NSPasteboard` tiene un fallo de concurrencia con file promises, así que solo -/// se toca desde el hilo principal: el `MainThreadMarker` lo obliga en compilación. -pub fn change_count(_mtm: MainThreadMarker) -> isize { - NSPasteboard::generalPasteboard().changeCount() +/// 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, +} + +impl Pasteboard { + pub fn general(_mtm: MainThreadMarker) -> Self { + Self { + inner: NSPasteboard::generalPasteboard(), + } + } + + /// 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) -> isize { + self.inner.changeCount() + } + + pub fn types(&self) -> Vec { + let Some(types) = self.inner.types() else { + return Vec::new(); + }; + 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()) + } + + pub fn write_text(&self, text: &str) -> bool { + self.inner.clearContents(); + let name = NSString::from_str("public.utf8-plain-text"); + let value = NSString::from_str(text); + self.inner.setString_forType(&value, &name) + } } diff --git a/crates/cp-mac/src/formats.rs b/crates/cp-mac/src/formats.rs new file mode 100644 index 0000000..76e0e1f --- /dev/null +++ b/crates/cp-mac/src/formats.rs @@ -0,0 +1,167 @@ +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: &[ + "com.apple.icns", + "com.microsoft.Link-Source", + "com.microsoft.ObjectLink", + ], + wanted: &[ + "public.utf8-plain-text", + "public.rtf", + "com.apple.flat-rtfd", + "public.html", + "com.apple.webarchive", + "public.png", + "public.tiff", + "public.file-url", + "public.url", + "public.url-name", + "org.chromium.source-url", + ], + aliases: &[ + ("NSStringPboardType", "public.utf8-plain-text"), + ("NeXT TIFF v4.0 pasteboard type", "public.tiff"), + ("Apple PNG pasteboard type", "public.png"), + ("NeXT Rich Text Format v1.0 pasteboard type", "public.rtf"), + ("NeXT RTFD pasteboard type", "com.apple.flat-rtfd"), + ("Apple HTML pasteboard type", "public.html"), + ], + concealed: &[ + "org.nspasteboard.ConcealedType", + "org.nspasteboard.TransientType", + "org.nspasteboard.AutoGeneratedType", + "com.agilebits.onepassword", + "de.petermaurer.TransientPasteboardType", + "com.typeit4me.clipping", + "Pasteboard generator type", + "net.antelle.keeweb", + "PasswordPboardType", + ], + opaque_prefixes: &["dyn.", "CorePasteboardFlavorType"], + text: &[ + "public.utf8-plain-text", + "public.rtf", + "com.apple.flat-rtfd", + "public.html", + "com.apple.webarchive", + ], + files: &["public.file-url"], + images_by_preference: &["public.png", "public.tiff"], +}; + +#[cfg(test)] +mod tests { + use super::CATALOG; + use cp_core::formats::{Kind, 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", + "com.apple.flat-rtfd", + "NeXT RTFD pasteboard type", + "public.rtf", + "NeXT Rich Text Format v1.0 pasteboard type", + "public.html", + "Apple HTML pasteboard type", + "public.utf8-plain-text", + "NSStringPboardType", + "com.apple.WebKit.custom-pasteboard-data", + ]; + + const FINDER: &[&str] = &[ + "public.file-url", + "CorePasteboardFlavorType 0x6675726C", + "dyn.ah62d4rv4gu8y6y4grf0gn5xbrzw1gydcr7u1e3cytf2gn", + "NSFilenamesPboardType", + "com.apple.finder.noderef", + "fndf", + "public.utf8-plain-text", + "com.apple.icns", + "public.tiff", + "NeXT TIFF v4.0 pasteboard type", + ]; + + const PREVIEW: &[&str] = &[ + "public.png", + "Apple PNG pasteboard type", + "dyn.ah62d4rv4gu8zazwuqm10c6xemf1gq54uqm10c6xenv61a3k", + "PVPboardInfoPboardType", + "public.tiff", + "NeXT TIFF v4.0 pasteboard type", + ]; + + #[test] + fn safari_keeps_the_context_that_2x_threw_away() { + let kept: Vec<&&str> = SAFARI + .iter() + .filter(|id| CATALOG.decide(id) == Take::Payload) + .collect(); + assert_eq!(CATALOG.classify(SAFARI), Some(Kind::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")); + } + + #[test] + fn one_copied_file_does_not_cost_ten_megabytes() { + assert_eq!(CATALOG.classify(FINDER), Some(Kind::Files)); + assert_eq!(CATALOG.decide("com.apple.icns"), Take::Presence); + for opaque in [ + "CorePasteboardFlavorType 0x6675726C", + "dyn.ah62d4rv4gu8y6y4grf0gn5xbrzw1gydcr7u1e3cytf2gn", + ] { + assert_eq!(CATALOG.decide(opaque), Take::Presence); + } + assert_eq!(CATALOG.decide("public.file-url"), Take::Payload); + } + + #[test] + fn preview_gives_up_the_cheap_representation() { + assert_eq!(CATALOG.preferred_image(PREVIEW), Some("public.png")); + assert_eq!(CATALOG.classify(PREVIEW), Some(Kind::Image)); + assert_eq!(CATALOG.decide("PVPboardInfoPboardType"), Take::Presence); + } + + #[test] + fn the_legacy_twins_of_every_measured_app_collapse() { + for (legacy, modern) in [ + ("NSStringPboardType", "public.utf8-plain-text"), + ("NeXT TIFF v4.0 pasteboard type", "public.tiff"), + ("Apple PNG pasteboard type", "public.png"), + ("NeXT RTFD pasteboard type", "com.apple.flat-rtfd"), + ] { + assert_eq!(CATALOG.canonical(legacy), modern); + } + } + + #[test] + fn the_type_that_hangs_the_app_is_never_asked_for() { + assert_eq!( + CATALOG.decide("com.apple.pasteboard.promised-suggested-file-name"), + Take::Never + ); + } + + #[test] + fn every_known_password_marker_is_covered() { + for marker in [ + "org.nspasteboard.ConcealedType", + "com.agilebits.onepassword", + "net.antelle.keeweb", + "PasswordPboardType", + ] { + assert!( + CATALOG.is_concealed(&["public.utf8-plain-text", marker]), + "{marker} debe excluir el ítem" + ); + } + } +} diff --git a/crates/cp-mac/src/lib.rs b/crates/cp-mac/src/lib.rs index 1164671..7a98ea9 100644 --- a/crates/cp-mac/src/lib.rs +++ b/crates/cp-mac/src/lib.rs @@ -1,5 +1,7 @@ #![cfg(target_os = "macos")] +pub mod formats; + /// 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; From d13561d3d0e0e73d785202434a497e65ff8557d9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 17:46:41 -0300 Subject: [PATCH 03/18] feat(core): seal the clipboard watcher with properties and mutation testing watch: the sequence-number receipt that invariant 2 asks for. A jump of three means two copies happened and were lost, and they get counted rather than ignored. Our own write is discarded by the exact count that produced it, so a real copy right after it is no longer swallowed. Five proptest properties cover generated tick sequences, the central one being conservation: every emitted event matches a step forward or a session restart, and whatever advanced without being emitted is counted as missed. The regression seed for the shrunk counterexample is checked in, so a backwards counter is retried before any generated case. cargo-mutants went from 11 survivors to none over 59 mutants, and the survivors taught three things: the backwards-counter branch was dead code already covered by max(0); the sampling hash cannot see a change that falls between blocks, which is now a documented test rather than a surprise; and wasteful now explicitly beats wanted when a type is in both lists. Threshold coherence moved to compile-time assertions. CI gains a mutants job so the suite cannot quietly stop proving anything. --- .github/workflows/rules.yml | 13 + .gitignore | 2 + Cargo.lock | 294 +++++++++++++++++- Cargo.toml | 1 + crates/cp-core/Cargo.toml | 3 + crates/cp-core/proptest-regressions/watch.txt | 7 + crates/cp-core/src/formats.rs | 21 ++ crates/cp-core/src/hash.rs | 69 ++++ crates/cp-core/src/lib.rs | 1 + crates/cp-core/src/watch.rs | 213 +++++++++++++ 10 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 crates/cp-core/proptest-regressions/watch.txt create mode 100644 crates/cp-core/src/watch.rs diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 76979e1..6b4c7cc 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -71,3 +71,16 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Twenty consecutive runs of the core 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 + 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 --no-times diff --git a/.gitignore b/.gitignore index ecbd5bb..cd443e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target **/*.rs.bk .DS_Store +/mutants.out +/mutants.out.old diff --git a/Cargo.lock b/Cargo.lock index feb221b..e6c0bed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,27 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.2" @@ -62,6 +83,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" name = "cp-core" version = "3.0.0" dependencies = [ + "proptest", "thiserror", "unicode-normalization", "xxhash-rust", @@ -115,6 +137,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -127,18 +159,53 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -174,6 +241,21 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "objc2" version = "0.6.4" @@ -340,12 +422,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -355,6 +452,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -364,6 +486,62 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rusqlite" version = "0.37.0" @@ -378,6 +556,31 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "shlex" version = "2.0.1" @@ -390,6 +593,17 @@ version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.5" @@ -401,6 +615,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -418,7 +645,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -436,6 +663,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -457,8 +690,67 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "xxhash-rust" version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml index 1be0c90..d65ca83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ blake3 = "1" xxhash-rust = { version = "0.8", features = ["xxh3"] } unicode-normalization = "0.1" image = { version = "0.25", default-features = false, features = ["png"] } +proptest = "1" objc2 = "0.6" objc2-foundation = "0.3" diff --git a/crates/cp-core/Cargo.toml b/crates/cp-core/Cargo.toml index d9556ad..3fa8531 100644 --- a/crates/cp-core/Cargo.toml +++ b/crates/cp-core/Cargo.toml @@ -12,5 +12,8 @@ thiserror.workspace = true unicode-normalization.workspace = true xxhash-rust.workspace = true +[dev-dependencies] +proptest.workspace = true + [lints] workspace = true diff --git a/crates/cp-core/proptest-regressions/watch.txt b/crates/cp-core/proptest-regressions/watch.txt new file mode 100644 index 0000000..02eea57 --- /dev/null +++ b/crates/cp-core/proptest-regressions/watch.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 9723df32a3cda6dd66ae13010bb71340970adca444a6122d2b7a273f28956b6f # shrinks to seq = [(13, false), (0, false)] diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index f3caf48..a9a0cdb 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -133,6 +133,27 @@ mod tests { assert_eq!(PROBE.decide("who/knows"), Take::Presence); } + #[test] + fn wasteful_beats_wanted_when_a_type_is_in_both() { + const GREEDY: Catalog = Catalog { + hangs: &[], + wasteful: &["costly/image"], + wanted: &["costly/image", "plain/text"], + aliases: &[], + concealed: &[], + opaque_prefixes: &[], + text: &["plain/text"], + files: &[], + images_by_preference: &["costly/image"], + }; + assert_eq!( + GREEDY.decide("costly/image"), + Take::Presence, + "estar en la lista de deseados no salva a un tipo que derrocha" + ); + assert_eq!(GREEDY.decide("plain/text"), Take::Payload); + } + #[test] fn a_legacy_name_is_the_modern_one() { assert_eq!(PROBE.canonical("old/text"), "plain/text"); diff --git a/crates/cp-core/src/hash.rs b/crates/cp-core/src/hash.rs index 5cb20ba..fa3088d 100644 --- a/crates/cp-core/src/hash.rs +++ b/crates/cp-core/src/hash.rs @@ -5,6 +5,12 @@ 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 { @@ -60,6 +66,69 @@ mod tests { assert_eq!(content_hash(&big), content_hash(&big.clone())); } + #[test] + fn the_sampling_reaches_the_end_of_the_buffer() { + let big = vec![0x11; 4 * 1024 * 1024]; + let mut tail_changed = big.clone(); + *tail_changed.last_mut().unwrap() = 0x22; + assert_ne!( + content_hash(&big), + content_hash(&tail_changed), + "si los bloques cayeran todos al principio, esto no se vería" + ); + } + + /// 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]; + let mut hole = big.clone(); + hole[2 * 1024 * 1024] = 0x22; + assert_eq!( + content_hash(&big), + content_hash(&hole), + "si esto cambiara, el muestreo dejó de ser un muestreo" + ); + } + + #[test] + fn the_two_paths_meet_at_the_threshold() { + let under = vec![0x33; WHOLE_UP_TO]; + let over = vec![0x33; WHOLE_UP_TO + 1]; + assert_eq!( + content_hash(&under), + xxh3_64(&under), + "hasta el umbral se mira el contenido entero" + ); + assert_ne!( + content_hash(&over), + xxh3_64(&over), + "por encima se muestrea, así que no coincide con el hash directo" + ); + } + + #[test] + fn each_of_the_sixteen_blocks_is_looked_at() { + let size = 4 * 1024 * 1024; + let base = vec![0x44; size]; + let reference = content_hash(&base); + for block in 0..BLOCKS { + let mut poked = base.clone(); + let at = (size - BLOCK) * block / (BLOCKS - 1); + poked[at] = 0x55; + assert_ne!( + content_hash(&poked), + reference, + "el bloque {block}, que empieza en {at}, no entra en la mezcla" + ); + } + } + #[test] fn length_alone_separates_two_otherwise_identical_samples() { let a = vec![0x5A; 2 * 1024 * 1024]; diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index 731e0af..c8ce7b1 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -2,5 +2,6 @@ pub mod formats; pub mod hash; pub mod paste; pub mod search; +pub mod watch; pub use formats::Kind; diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs new file mode 100644 index 0000000..0f24b69 --- /dev/null +++ b/crates/cp-core/src/watch.rs @@ -0,0 +1,213 @@ +/// 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 }, +} + +/// 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)] +pub struct Watcher { + last: Option, + ours: Option, + missed: u64, +} + +impl Watcher { + /// Registra el contador que dejó nuestra propia escritura, para + /// descartar exactamente esa y no una ventana de tiempo alrededor. + pub fn wrote(&mut self, count: i64) { + self.ours = Some(count); + } + + pub fn tick(&mut self, count: i64) -> Seen { + let Some(last) = self.last else { + // El primer sondeo solo fija el punto de partida: lo que hubiera + // antes de arrancar no es una copia que hayamos perdido. + self.last = Some(count); + return Seen::Nothing; + }; + if count == last { + return Seen::Nothing; + } + self.last = Some(count); + if self.ours == Some(count) { + self.ours = None; + return Seen::Ours; + } + // El contador retrocede al reiniciarse la sesión de ventanas, y eso + // no es una ráfaga de copias perdidas: el `max` deja ese caso en cero. + let skipped = (count - last - 1).max(0) as u64; + self.missed += skipped; + Seen::Fresh { skipped } + } + + /// Cuántas copias se sabe que ocurrieron y no se pudieron capturar. + pub fn missed(&self) -> u64 { + self.missed + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_look_only_sets_the_mark() { + let mut watcher = Watcher::default(); + assert_eq!(watcher.tick(42), Seen::Nothing); + assert_eq!(watcher.missed(), 0); + } + + #[test] + fn a_still_counter_is_not_an_event() { + let mut watcher = Watcher::default(); + 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(); + watcher.tick(7); + assert_eq!(watcher.tick(8), Seen::Fresh { skipped: 0 }); + assert_eq!(watcher.missed(), 0); + } + + #[test] + fn a_burst_is_counted_not_ignored() { + let mut watcher = Watcher::default(); + watcher.tick(10); + assert_eq!(watcher.tick(14), Seen::Fresh { skipped: 3 }); + assert_eq!(watcher.missed(), 3, "tres copias ocurrieron y se perdieron"); + } + + #[test] + fn our_own_write_is_discarded_exactly() { + let mut watcher = Watcher::default(); + watcher.tick(20); + watcher.wrote(21); + assert_eq!(watcher.tick(21), Seen::Ours); + assert_eq!(watcher.missed(), 0); + } + + #[test] + fn a_real_copy_right_after_ours_is_not_swallowed() { + let mut watcher = Watcher::default(); + watcher.tick(20); + watcher.wrote(21); + assert_eq!(watcher.tick(21), Seen::Ours); + assert_eq!( + watcher.tick(22), + Seen::Fresh { skipped: 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(); + watcher.tick(5000); + assert_eq!(watcher.tick(3), Seen::Fresh { skipped: 0 }); + assert_eq!(watcher.missed(), 0); + } +} + +#[cfg(test)] +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 emitted: u64 = 0; + for count in &seq { + if let Seen::Fresh { .. } = watcher.tick(*count) { + emitted += 1; + } + } + + // 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) { + if pair[1] > pair[0] { + climbed += pair[1] - pair[0]; + } else if pair[1] < pair[0] { + restarts += 1; + } + } + + prop_assert_eq!(climbed as u64 + restarts, emitted + watcher.missed()); + } + + /// 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(); + watcher.tick(start); + for _ in 0..repeats { + prop_assert_eq!(watcher.tick(start), Seen::Nothing); + } + prop_assert_eq!(watcher.missed(), 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; + for count in seq { + watcher.tick(count); + prop_assert!(watcher.missed() >= floor); + floor = watcher.missed(); + } + } + + /// Solo el contador exacto que registramos se descarta como nuestro, y + /// solo una vez. + #[test] + fn only_the_exact_registered_count_is_ours(start in 0i64..1000, gap in 1i64..10) { + let mut watcher = Watcher::default(); + 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 }); + } + + /// 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(); + watcher.tick(start); + watcher.wrote(start + 99); + prop_assert_eq!(watcher.tick(start + 1), Seen::Fresh { skipped: 0 }); + } + } +} From ee64c02750ff913128bdd4af3bb8331639c0b235 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 17:53:32 -0300 Subject: [PATCH 04/18] feat(mac): capture a full item from the real pasteboard The item model now distinguishes five states of a format instead of present or absent: inline, blob, too big with its size recorded, announced but never asked for, and asked for but empty. The last one is not theoretical: linkpresentation.metadata and fndf both do exactly that. capture applies the catalogue against the live pasteboard. Concealed content is decided before a single byte is read, legacy twins collapse so the item is not stored twice, and the costlier image representation is recorded without ever being requested, so the source application does not have to materialise it either. System invariants cannot live in cargo test: NSPasteboard requires the main thread and the test runner does not guarantee it. They move to an executable harness, cargo run -p cp-mac --example probe, which is wired into CI and currently passes all three checks against the real pasteboard. --- .github/workflows/ci.yml | 4 + Cargo.lock | 1 + crates/cp-core/src/item.rs | 145 ++++++++++++++++++++++++++++++++ crates/cp-core/src/lib.rs | 1 + crates/cp-mac/Cargo.toml | 5 ++ crates/cp-mac/examples/probe.rs | 83 ++++++++++++++++++ crates/cp-mac/src/capture.rs | 58 +++++++++++++ crates/cp-mac/src/lib.rs | 1 + 8 files changed, 298 insertions(+) create mode 100644 crates/cp-core/src/item.rs create mode 100644 crates/cp-mac/examples/probe.rs create mode 100644 crates/cp-mac/src/capture.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f7de4e..dc18992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ jobs: - run: cargo fmt --all -- --check - 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 deny: name: Dependency audit diff --git a/Cargo.lock b/Cargo.lock index e6c0bed..49dce56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,7 @@ version = "3.0.0" dependencies = [ "cp-core", "cp-mac-sys", + "objc2-foundation", "thiserror", ] diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs new file mode 100644 index 0000000..316fd74 --- /dev/null +++ b/crates/cp-core/src/item.rs @@ -0,0 +1,145 @@ +/// 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, +} + +pub const INLINE_UP_TO: usize = 64 * 1024; +pub const BLOB_UP_TO: usize = 64 * 1024 * 1024; + +const _: () = assert!(INLINE_UP_TO < BLOB_UP_TO); + +impl Payload { + /// Decide dónde va lo que ya se leyó. + pub fn stored(bytes: Vec) -> Self { + match bytes.len() { + 0..=INLINE_UP_TO => Payload::Inline(bytes), + len if len <= BLOB_UP_TO => Payload::Blob(bytes), + len => Payload::TooBig { size: len }, + } + } + + pub fn size(&self) -> Option { + match self { + Payload::Inline(bytes) | Payload::Blob(bytes) => Some(bytes.len()), + Payload::TooBig { size } => Some(*size), + Payload::Announced { size } => *size, + Payload::Absent => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Format { + pub id: String, + 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, +} + +impl Item { + pub fn format(&self, id: &str) -> Option<&Format> { + 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() + .filter_map(|one| match &one.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => Some(bytes.len()), + _ => None, + }) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_things_stay_in_the_row() { + let small = vec![0u8; 1024]; + assert!(matches!(Payload::stored(small), Payload::Inline(_))); + } + + #[test] + fn the_boundary_belongs_to_the_row() { + assert!(matches!( + Payload::stored(vec![0u8; INLINE_UP_TO]), + Payload::Inline(_) + )); + assert!(matches!( + Payload::stored(vec![0u8; INLINE_UP_TO + 1]), + Payload::Blob(_) + )); + } + + #[test] + fn what_does_not_fit_is_recorded_not_dropped() { + let huge = Payload::TooBig { + size: BLOB_UP_TO + 1, + }; + assert_eq!(huge.size(), Some(BLOB_UP_TO + 1), "el tamaño se conserva"); + } + + #[test] + fn an_announced_type_without_a_size_is_still_a_format() { + let announced = Format { + id: "com.apple.icns".into(), + payload: Payload::Announced { size: None }, + }; + let item = Item { + kind: None, + formats: vec![announced], + }; + assert!(item.format("com.apple.icns").is_some()); + assert_eq!(item.stored_bytes(), 0, "anotado no es guardado"); + } + + #[test] + fn only_what_was_really_kept_counts_towards_the_size() { + let item = Item { + kind: None, + formats: vec![ + Format { + id: "a".into(), + payload: Payload::Inline(vec![0u8; 100]), + }, + Format { + id: "b".into(), + payload: Payload::Blob(vec![0u8; 900]), + }, + Format { + id: "c".into(), + payload: Payload::TooBig { size: 999_999 }, + }, + Format { + id: "d".into(), + payload: Payload::Absent, + }, + ], + }; + assert_eq!(item.stored_bytes(), 1000); + } +} diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index c8ce7b1..f4aab5a 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod formats; pub mod hash; +pub mod item; pub mod paste; pub mod search; pub mod watch; diff --git a/crates/cp-mac/Cargo.toml b/crates/cp-mac/Cargo.toml index 50b3e76..782b5fa 100644 --- a/crates/cp-mac/Cargo.toml +++ b/crates/cp-mac/Cargo.toml @@ -11,6 +11,11 @@ publish = false cp-core.workspace = true cp-mac-sys.workspace = true thiserror.workspace = true +objc2-foundation.workspace = true + +[dev-dependencies] +cp-mac-sys.workspace = true +objc2-foundation.workspace = true [lints] workspace = true diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs new file mode 100644 index 0000000..c930a57 --- /dev/null +++ b/crates/cp-mac/examples/probe.rs @@ -0,0 +1,83 @@ +//! Arnés de las invariantes que solo se comprueban contra el sistema. +//! +//! No son pruebas de `cargo test` a propósito: `NSPasteboard` exige el hilo +//! principal y el runner de cargo no lo garantiza. Se ejecuta con +//! `cargo run -p cp-mac --example probe`. + +use cp_core::formats::Kind; +use cp_core::item::Payload; +use cp_mac::capture::capture; +use cp_mac_sys::pasteboard::Pasteboard; +use objc2_foundation::MainThreadMarker; + +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 mut failed = 0; + + failed += check("el texto plano va y vuelve", || { + pb.write_text("cp-capture-probe"); + let item = capture(&pb).ok_or("se tomó por contenido secreto")?; + if item.kind != Some(Kind::Text) { + return Err(format!("se clasificó como {:?}", item.kind)); + } + let text = item + .format("public.utf8-plain-text") + .ok_or("falta el texto plano")?; + if text.payload != Payload::Inline(b"cp-capture-probe".to_vec()) { + return Err(format!("llegó {:?}", text.payload)); + } + Ok(()) + }); + + failed += check("los gemelos legados no se guardan dos veces", || { + pb.write_text("cp-twin-probe"); + let item = capture(&pb).ok_or("se tomó por contenido secreto")?; + 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!("hay repetidos: {ids:?}")); + } + if ids.contains(&"NSStringPboardType") { + return Err("el gemelo legado no colapsó al moderno".into()); + } + Ok(()) + }); + + failed += check("el contador sube de uno en uno", || { + let before = pb.change_count(); + pb.write_text("cp-count-probe"); + let after = pb.change_count(); + if after - before != 1 { + return Err(format!("saltó de {before} a {after}")); + } + Ok(()) + }); + + println!(); + if failed == 0 { + println!("todo en verde"); + std::process::ExitCode::SUCCESS + } else { + println!("{failed} comprobaciones fallaron"); + std::process::ExitCode::FAILURE + } +} + +fn check(what: &str, run: impl Fn() -> Result<(), String>) -> u32 { + match run() { + Ok(()) => { + println!(" ok {what}"); + 0 + } + Err(why) => { + println!(" FALLA {what}: {why}"); + 1 + } + } +} diff --git a/crates/cp-mac/src/capture.rs b/crates/cp-mac/src/capture.rs new file mode 100644 index 0000000..596d660 --- /dev/null +++ b/crates/cp-mac/src/capture.rs @@ -0,0 +1,58 @@ +use crate::formats::CATALOG; +use cp_core::formats::Take; +use cp_core::item::{Format, Item, Payload}; +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) { + return None; + } + + let kind = 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. + Payload::Announced { size: None } + } else { + match pb.data(canonical) { + Some(bytes) => Payload::stored(bytes), + None => Payload::Absent, + } + } + } + }; + formats.push(Format { + id: canonical.to_string(), + payload, + }); + } + + Some(Item { kind, formats }) +} + +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 +} diff --git a/crates/cp-mac/src/lib.rs b/crates/cp-mac/src/lib.rs index 7a98ea9..03a672b 100644 --- a/crates/cp-mac/src/lib.rs +++ b/crates/cp-mac/src/lib.rs @@ -1,5 +1,6 @@ #![cfg(target_os = "macos")] +pub mod capture; pub mod formats; /// El pegado nunca se intenta sin el destino en primer plano: medido el From ede189d3d76c64f19a200627711d39d8418692a1 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 18:04:22 -0300 Subject: [PATCH 05/18] feat(store): schema, search that actually works, and the broken-item policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FTS index is fed from search_text, which stores the same fold applied to the query. Both sides match because it is the same function, so the four cases 2.x gets wrong now resolve: strasse finds Straße, encyclopaedia finds encyclopædia, lodz finds Łódź. And the direction nobody tests, typing the special character to find it, works too. An item keeps a row per offered format, including the ones carrying no bytes. Broken items are marked rather than deleted, survive until their deadline, do not restart their clock when marked again, and are never purged while pinned. cargo-mutants on cp-store: 25 mutants, none survive on the first pass. The CI job now covers both crates. --- .github/workflows/rules.yml | 2 +- Cargo.lock | 1 + crates/cp-store/Cargo.toml | 1 + crates/cp-store/src/lib.rs | 6 +- crates/cp-store/src/schema.rs | 67 ++++++++ crates/cp-store/src/store.rs | 304 ++++++++++++++++++++++++++++++++++ 6 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 crates/cp-store/src/schema.rs create mode 100644 crates/cp-store/src/store.rs diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 6b4c7cc..4a47f64 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -83,4 +83,4 @@ jobs: # Una prueba que pasa contra un núcleo mutado no está probando nada. El # criterio de aceptación de la Etapa 0 es exactamente este: la batería # tiene que fallar contra una implementación que no sepa lo que sabemos. - - run: cargo mutants -p cp-core --no-times + - run: cargo mutants -p cp-core -p cp-store --no-times diff --git a/Cargo.lock b/Cargo.lock index 49dce56..0a95d0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,7 @@ dependencies = [ "cp-core", "rusqlite", "thiserror", + "xxhash-rust", ] [[package]] diff --git a/crates/cp-store/Cargo.toml b/crates/cp-store/Cargo.toml index 435f3e1..a1ae5ca 100644 --- a/crates/cp-store/Cargo.toml +++ b/crates/cp-store/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] cp-core.workspace = true rusqlite.workspace = true +xxhash-rust.workspace = true blake3.workspace = true thiserror.workspace = true diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index a71a0b4..f218662 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -1 +1,5 @@ -pub const SCHEMA_VERSION: u32 = 1; +pub mod schema; +pub mod store; + +pub use schema::SCHEMA_VERSION; +pub use store::Store; diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs new file mode 100644 index 0000000..829d132 --- /dev/null +++ b/crates/cp-store/src/schema.rs @@ -0,0 +1,67 @@ +use rusqlite::{Connection, Result}; + +pub const SCHEMA_VERSION: u32 = 1; + +/// 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<()> { + db.execute_batch( + r#" + PRAGMA journal_mode = WAL; + PRAGMA secure_delete = ON; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + uuid TEXT NOT NULL UNIQUE, + kind TEXT, + preview_text TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + paste_count INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + label TEXT, + color TEXT, + app_source TEXT, + content_hash INTEGER NOT NULL, + search_text TEXT NOT NULL, + broken_since INTEGER, + source_modified_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS items_by_recency ON items(created_at DESC); + CREATE INDEX IF NOT EXISTS items_by_hash ON items(content_hash); + CREATE INDEX IF NOT EXISTS items_broken ON items(broken_since) + WHERE broken_since IS NOT NULL; + + CREATE TABLE IF NOT EXISTS item_formats ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + format TEXT NOT NULL, + size_bytes INTEGER, + inline_data BLOB, + blob_path TEXT, + PRIMARY KEY (item_id, format) + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5( + search_text, + content = 'items', + content_rowid = 'id', + tokenize = 'unicode61 remove_diacritics 2' + ); + + CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN + INSERT INTO items_fts(rowid, search_text) VALUES (new.id, new.search_text); + END; + CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN + INSERT INTO items_fts(items_fts, rowid, search_text) + VALUES ('delete', old.id, old.search_text); + END; + CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN + INSERT INTO items_fts(items_fts, rowid, search_text) + VALUES ('delete', old.id, old.search_text); + INSERT INTO items_fts(rowid, search_text) VALUES (new.id, new.search_text); + END; + "#, + ) +} diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs new file mode 100644 index 0000000..368f7dd --- /dev/null +++ b/crates/cp-store/src/store.rs @@ -0,0 +1,304 @@ +use cp_core::item::{Item, Payload}; +use cp_core::search::fold; +use rusqlite::{Connection, OptionalExtension, Result, params}; + +pub struct Store { + db: Connection, +} + +impl Store { + pub fn in_memory() -> Result { + let db = Connection::open_in_memory()?; + crate::schema::create(&db)?; + Ok(Self { db }) + } + + /// 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 = cp_core::hash::content_hash(text.as_bytes()) as i64; + self.db.execute( + "INSERT INTO items (uuid, kind, preview_text, created_at, content_hash, search_text) + VALUES (?1, 'text', ?2, ?3, ?4, ?5)", + params![uuid, text, created_at, hash, fold(text)], + )?; + Ok(self.db.last_insert_rowid()) + } + + /// 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, + item: &Item, + preview: &str, + created_at: i64, + ) -> Result { + let hash = cp_core::hash::content_hash(preview.as_bytes()) as i64; + let kind = item.kind.map(|k| format!("{k:?}").to_lowercase()); + self.db.execute( + "INSERT INTO items (uuid, kind, preview_text, created_at, content_hash, search_text) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![uuid, kind, preview, created_at, hash, fold(preview)], + )?; + let id = self.db.last_insert_rowid(); + for format in &item.formats { + let (inline, size) = match &format.payload { + Payload::Inline(bytes) => (Some(bytes.clone()), Some(bytes.len() as i64)), + Payload::Blob(bytes) => (None, Some(bytes.len() as i64)), + Payload::TooBig { size } => (None, Some(*size as i64)), + Payload::Announced { size } => (None, size.map(|s| s as i64)), + Payload::Absent => (None, None), + }; + self.db.execute( + "INSERT INTO item_formats (item_id, format, size_bytes, inline_data) + VALUES (?1, ?2, ?3, ?4)", + params![id, format.id, size, inline], + )?; + } + Ok(id) + } + + pub fn find_by_hash(&self, content: &str) -> Result> { + let hash = cp_core::hash::content_hash(content.as_bytes()) as i64; + self.db + .query_row( + "SELECT id FROM items WHERE content_hash = ?1 LIMIT 1", + [hash], + |row| row.get(0), + ) + .optional() + } + + pub fn formats_of(&self, id: i64) -> Result> { + let mut stmt = self + .db + .prepare("SELECT format FROM item_formats WHERE item_id = ?1 ORDER BY format")?; + let rows = stmt.query_map([id], |row| row.get::<_, String>(0))?; + 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", + params![id, at], + )?; + 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 { + self.db.execute( + "DELETE FROM items + WHERE broken_since IS NOT NULL AND broken_since < ?1 AND pinned = 0", + [cutoff], + ) + } + + pub fn pin(&self, id: i64) -> Result<()> { + self.db + .execute("UPDATE items SET pinned = 1 WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn count(&self) -> Result { + self.db + .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) + } + + pub fn search(&self, query: &str) -> Result> { + let folded = fold(query); + let mut stmt = self.db.prepare( + "SELECT items.preview_text + FROM items_fts + JOIN items ON items.id = items_fts.rowid + WHERE items_fts MATCH ?1 + ORDER BY items.created_at DESC", + )?; + let rows = stmt.query_map([format!("{folded}*")], |row| row.get::<_, String>(0))?; + rows.collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cp_core::item::Format; + + fn seeded() -> Store { + let store = Store::in_memory().expect("esquema"); + for (at, text) in [ + "el café de la esquina", + "Straße Hauptbahnhof", + "encyclopædia britannica", + "Łódź centrum", + "Peçanha e Gonçalves", + ] + .iter() + .enumerate() + { + store + .insert_text(&format!("uuid-{at}"), text, at as i64) + .expect("insert"); + } + 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(); + for (query, expected) in [ + ("cafe", "el café de la esquina"), + ("strasse", "Straße Hauptbahnhof"), + ("encyclopaedia", "encyclopædia britannica"), + ("lodz", "Łódź centrum"), + ] { + let hits = store.search(query).expect("consulta"); + assert!( + hits.iter().any(|hit| hit == expected), + "buscando «{query}» no apareció «{expected}»: {hits:?}" + ); + } + } + + /// 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(); + for (query, expected) in [ + ("café", "el café de la esquina"), + ("Straße", "Straße Hauptbahnhof"), + ("encyclopædia", "encyclopædia britannica"), + ("Łódź", "Łódź centrum"), + ("Gonçalves", "Peçanha e Gonçalves"), + ] { + let hits = store.search(query).expect("consulta"); + assert!( + hits.iter().any(|hit| hit == expected), + "buscando «{query}» no apareció «{expected}»: {hits:?}" + ); + } + } + + #[test] + fn the_stored_text_keeps_its_accents() { + let store = seeded(); + let hits = store.search("cafe").expect("consulta"); + assert_eq!( + hits.first().map(String::as_str), + Some("el café de la esquina"), + "se busca sin tildes pero se muestra como se copió" + ); + } + + fn sample_item() -> Item { + Item { + kind: Some(cp_core::formats::Kind::Text), + formats: vec![ + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hola".to_vec()), + }, + Format { + id: "public.rtf".into(), + payload: Payload::Inline(vec![0u8; 400]), + }, + Format { + id: "com.apple.icns".into(), + payload: Payload::Announced { size: None }, + }, + Format { + id: "fndf".into(), + payload: Payload::Absent, + }, + ], + } + } + + #[test] + fn an_item_keeps_every_format_it_was_offered() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_item("uuid-multi", &sample_item(), "hola", 1) + .expect("insert"); + let formats = store.formats_of(id).expect("formatos"); + assert_eq!(formats.len(), 4, "las cuatro filas, incluidas las vacías"); + assert!(formats.contains(&"com.apple.icns".to_string())); + assert!(formats.contains(&"fndf".to_string())); + } + + #[test] + fn the_same_content_is_found_by_its_hash() { + let store = Store::in_memory().expect("esquema"); + store.insert_text("uuid-a", "repetido", 1).expect("insert"); + assert!(store.find_by_hash("repetido").expect("busca").is_some()); + assert!(store.find_by_hash("distinto").expect("busca").is_none()); + } + + #[test] + fn deleting_an_item_takes_its_formats_with_it() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_item("uuid-cascade", &sample_item(), "hola", 1) + .expect("insert"); + store.mark_broken(id, 10).expect("marca"); + store.purge_broken_before(20).expect("purga"); + assert_eq!(store.formats_of(id).expect("formatos").len(), 0); + } + + #[test] + fn a_broken_item_survives_until_its_time_is_up() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-roto", "archivo ido", 1) + .expect("insert"); + store.mark_broken(id, 100).expect("marca"); + assert_eq!(store.purge_broken_before(50).expect("purga"), 0); + assert_eq!(store.count().expect("cuenta"), 1, "aún no cumple el plazo"); + assert_eq!(store.purge_broken_before(150).expect("purga"), 1); + assert_eq!(store.count().expect("cuenta"), 0); + } + + #[test] + fn marking_a_broken_item_twice_does_not_restart_its_clock() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-roto", "archivo ido", 1) + .expect("insert"); + store.mark_broken(id, 100).expect("primera"); + store.mark_broken(id, 900).expect("segunda"); + assert_eq!( + store.purge_broken_before(150).expect("purga"), + 1, + "vale la primera vez que se vio roto, no la última" + ); + } + + #[test] + fn a_pinned_item_is_never_purged_even_when_broken() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-fijado", "importante", 1) + .expect("insert"); + store.pin(id).expect("fija"); + store.mark_broken(id, 100).expect("marca"); + assert_eq!(store.purge_broken_before(9999).expect("purga"), 0); + assert_eq!(store.count().expect("cuenta"), 1); + } + + #[test] + fn a_word_that_is_not_there_finds_nothing() { + let store = seeded(); + assert!(store.search("berlin").expect("consulta").is_empty()); + } +} From 754e6cdd71d4b4e743aaf84ca35b3dd83dac93d9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 18:30:58 -0300 Subject: [PATCH 06/18] feat(mac): permission probes and per-layout keycode resolution Three probes that are not the same thing: CGPreflightPostEventAccess is the one this app needs, AXIsProcessTrusted only opens the menu fallback, and IsSecureEventInputEnabled is read to warn and never to abort. can_paste() depends on the first alone. Keycode resolution asks which key produces "v" with the Command bit set, which covers every layout in one formulation. Layouts can be queried by identifier without activating them, so checking Dvorak no longer means changing anyone's keyboard. Measured: every layout resolves to 0x09 except plain Dvorak, which needs 0x2F. The 2.x constant types Cmd+K there. This is the first unsafe in the project. It stays inside cp-mac-sys, one operation per block, each with its SAFETY note, enforced by clippy. CLA moves to version 1.2, adopting the successor clause: the rights granted travel with the project if it is ever assigned. Signatures already given stay valid, since a later version only governs later contributions. --- .github/workflows/rules.yml | 2 +- CLA.md | 44 +++-- crates/cp-core/src/formats.rs | 35 ++++ crates/cp-core/src/hash.rs | 7 + crates/cp-core/src/item.rs | 35 +++- crates/cp-core/src/watch.rs | 36 +++- crates/cp-mac-sys/build.rs | 5 + crates/cp-mac-sys/src/keyboard.rs | 246 +++++++++++++++++++++++++++ crates/cp-mac-sys/src/lib.rs | 2 + crates/cp-mac-sys/src/permissions.rs | 79 +++++++++ crates/cp-mac/examples/probe.rs | 65 +++++++ crates/cp-store/src/store.rs | 169 +++++++++++++++++- deny.toml | 3 + 13 files changed, 703 insertions(+), 25 deletions(-) create mode 100644 crates/cp-mac-sys/build.rs create mode 100644 crates/cp-mac-sys/src/keyboard.rs create mode 100644 crates/cp-mac-sys/src/permissions.rs diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 4a47f64..887d444 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -58,7 +58,7 @@ jobs: - name: No prose blocks in the code run: | - awk '/^[ \t]*\/\/[^\/]/ { n++; if (n > 3) { print FILENAME":"NR": bloque de prosa"; bad = 1 }; next } + awk '/^[ \t]*\/\/[^\/!]/ { n++; if (n > 3) { print FILENAME":"NR": bloque de prosa"; bad = 1 }; next } { n = 0 } END { exit bad }' $(find crates -name '*.rs') diff --git a/CLA.md b/CLA.md index e7c5d15..bdfc8ef 100644 --- a/CLA.md +++ b/CLA.md @@ -1,13 +1,14 @@ # Contributor License Agreement -CopyPaste Individual Contributor License Agreement, version 1.1. +CopyPaste Individual Contributor License Agreement, version 1.2. Thank you for contributing to CopyPaste. This agreement exists for one reason: CopyPaste is released under the GPL-3.0 **and** offered under separate -commercial terms to organisations that cannot comply with the GPL. Offering -those commercial terms requires the right to license the whole codebase that -way — including your contribution. Without this agreement from every -contributor, that right does not exist and the dual model collapses. +[commercial terms](COMMERCIAL.md) to organisations that cannot comply +with the GPL. Offering those commercial terms requires the right to license +the whole codebase that way — including your contribution. Without this +agreement from every contributor, that right does not exist and the dual model +collapses. You keep the copyright on everything you write. This is a licence you grant, not a transfer of ownership. @@ -26,15 +27,19 @@ modification to or addition to an existing work, that You intentionally submit to this project for inclusion in it. "Submit" means any form of electronic, verbal, or written communication sent to the project or its maintainers, including but not limited to Pull Requests, issues, patches, and code -suggestions made during review — excluding -any communication you conspicuously mark "Not a Contribution". +suggestions made during review — excluding any communication you +conspicuously mark "Not a Contribution". + This agreement covers every Contribution You submit after signing **and every one You submitted before**. Code reaches a project by more routes than a Pull Request, and a signature that only looked forward would leave those earlier routes unaccounted for. **"Project Owner"** means Mario Hidalgo G. (rgdevment), the copyright holder -and maintainer of CopyPaste. +and maintainer of CopyPaste, and whoever holds those rights after him — a +successor, or anyone the project is assigned or transferred to, in whole or in +part. The licences You grant below run to them on the same terms, and to no +wider ones. ## 2. Copyright licence @@ -47,9 +52,10 @@ Contribution and such derivative works. **Including under licences other than the GPL-3.0.** This is the clause that makes commercial licensing possible; it is stated explicitly so nobody has to infer it from the word "sublicense". + It also covers **distributing signed binaries through application stores**, -whose terms of service are not compatible with the licence this project uses. -The source stays available either way; what needs separate terms is the +whose terms of service are not compatible with the GPL-3.0. The source stays +available under the GPL-3.0 either way; what needs separate terms is the compiled build that a store hands to its users. **Moral rights.** To the fullest extent permitted by the law that applies to @@ -124,6 +130,12 @@ mandatory law where You live. **If a clause fails.** If any provision is held unenforceable, the rest stays in force and that provision is read as narrowly as needed to make it valid. +**This agreement travels with the project.** The Project Owner may assign it, +together with the rights it grants, to whoever the project passes to. You need +not be asked, and nothing about what You granted changes: what a successor +receives is what You gave, neither more nor less. You keep the copyright on +everything You wrote, and section 6 binds them as it binds him. + **This is the whole agreement** between You and the Project Owner about Your Contributions, and it replaces anything discussed before it. A later version of this document applies only to Contributions You submit after signing that @@ -144,13 +156,13 @@ I have read the CLA Document and I hereby sign the CLA You sign once. Every later Pull Request from the same GitHub account is covered automatically. -This signature covers CopyPaste only. Other projects by the same owner have -their own agreement and their own signature record. +This signature covers CopyPaste only. Other projects by the same owner have their +own agreement and their own signature record. ## If you would rather not sign That is a legitimate choice, and it costs you nothing else in this project. -You can still use CopyPaste, report bugs, request features, help with -translations feedback, discuss design, package it for your distribution, and -fork the project under the GPL-3.0. Only merging code into this repository -requires the agreement. +You can still use CopyPaste, report bugs, request features, discuss design, +package it for your distribution, help with translations, and fork the project +under the GPL-3.0. Only merging code into this repository requires the +agreement. diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index a9a0cdb..899d468 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -121,6 +121,41 @@ mod tests { images_by_preference: &["cheap/image", "costly/image"], }; + const NOTHING: Catalog = Catalog { + hangs: &[], + wasteful: &[], + wanted: &[], + aliases: &[], + concealed: &[], + opaque_prefixes: &[], + text: &[], + files: &[], + images_by_preference: &[], + }; + + #[test] + fn an_empty_catalogue_notes_everything_and_promises_nothing() { + assert_eq!(NOTHING.decide("cualquier/cosa"), Take::Presence); + 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.canonical("x"), "x"); + } + + #[test] + fn nothing_offered_at_all_is_not_an_item() { + assert_eq!(PROBE.classify(&[]), None); + assert_eq!(PROBE.preferred_image(&[]), None); + assert!(!PROBE.is_concealed(&[])); + } + + #[test] + fn an_empty_type_name_is_just_unknown() { + assert_eq!(PROBE.decide(""), Take::Presence); + assert_eq!(PROBE.canonical(""), ""); + } + #[test] fn what_hangs_is_never_asked_for() { assert_eq!(PROBE.decide("hangs/forever"), Take::Never); diff --git a/crates/cp-core/src/hash.rs b/crates/cp-core/src/hash.rs index fa3088d..e6ea18e 100644 --- a/crates/cp-core/src/hash.rs +++ b/crates/cp-core/src/hash.rs @@ -60,6 +60,13 @@ mod tests { assert_ne!(content_hash(a.as_bytes()), content_hash(b.as_bytes())); } + #[test] + fn nothing_at_all_still_has_an_identity() { + assert_eq!(content_hash(&[]), content_hash(&[])); + assert_ne!(content_hash(&[]), content_hash(&[0])); + assert_ne!(content_hash(&[0]), content_hash(&[1])); + } + #[test] fn the_same_content_always_hashes_the_same() { let big = screenshot(512 * 1024, 0x07); diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index 316fd74..29ea6ea 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -20,14 +20,31 @@ pub const INLINE_UP_TO: usize = 64 * 1024; 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, + Blob, + Refused, +} + +pub fn placement(size: usize) -> Placement { + match size { + 0..=INLINE_UP_TO => Placement::Row, + size if size <= BLOB_UP_TO => Placement::Blob, + _ => Placement::Refused, + } +} impl Payload { /// Decide dónde va lo que ya se leyó. pub fn stored(bytes: Vec) -> Self { - match bytes.len() { - 0..=INLINE_UP_TO => Payload::Inline(bytes), - len if len <= BLOB_UP_TO => Payload::Blob(bytes), - len => Payload::TooBig { size: len }, + match placement(bytes.len()) { + Placement::Row => Payload::Inline(bytes), + Placement::Blob => Payload::Blob(bytes), + Placement::Refused => Payload::TooBig { size: bytes.len() }, } } @@ -95,6 +112,16 @@ mod tests { )); } + #[test] + fn the_three_placements_have_exact_boundaries() { + assert_eq!(placement(0), Placement::Row); + assert_eq!(placement(INLINE_UP_TO), Placement::Row); + assert_eq!(placement(INLINE_UP_TO + 1), Placement::Blob); + assert_eq!(placement(BLOB_UP_TO), Placement::Blob); + assert_eq!(placement(BLOB_UP_TO + 1), Placement::Refused); + assert_eq!(placement(usize::MAX), Placement::Refused); + } + #[test] fn what_does_not_fit_is_recorded_not_dropped() { let huge = Payload::TooBig { diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index 0f24b69..70f445f 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -48,8 +48,8 @@ impl Watcher { } // 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 - last - 1).max(0) as u64; - self.missed += skipped; + let skipped = count.saturating_sub(last).saturating_sub(1).max(0) as u64; + self.missed = self.missed.saturating_add(skipped); Seen::Fresh { skipped } } @@ -63,6 +63,38 @@ impl Watcher { mod tests { use super::*; + #[test] + fn the_counter_at_its_limits_does_not_overflow() { + let mut watcher = Watcher::default(); + 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(); + other.tick(i64::MAX); + assert_eq!(other.tick(i64::MAX), Seen::Nothing); + assert!(matches!(other.tick(i64::MIN), Seen::Fresh { skipped: 0 })); + } + + #[test] + fn repeated_giant_jumps_do_not_wrap_the_counter_of_losses() { + let mut watcher = Watcher::default(); + watcher.tick(0); + for step in 1..=4 { + watcher.tick(i64::MAX / 4 * step); + } + assert!(watcher.missed() > 0, "algo se perdió, y se sabe"); + } + + #[test] + fn a_negative_counter_is_handled_like_any_other() { + let mut watcher = Watcher::default(); + watcher.tick(-100); + assert_eq!(watcher.tick(-99), Seen::Fresh { skipped: 0 }); + assert_eq!(watcher.tick(-95), Seen::Fresh { skipped: 3 }); + } + #[test] fn the_first_look_only_sets_the_mark() { let mut watcher = Watcher::default(); diff --git a/crates/cp-mac-sys/build.rs b/crates/cp-mac-sys/build.rs new file mode 100644 index 0000000..f6da49f --- /dev/null +++ b/crates/cp-mac-sys/build.rs @@ -0,0 +1,5 @@ +fn main() { + 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/keyboard.rs b/crates/cp-mac-sys/src/keyboard.rs new file mode 100644 index 0000000..02bfd13 --- /dev/null +++ b/crates/cp-mac-sys/src/keyboard.rs @@ -0,0 +1,246 @@ +//! Qué tecla física hay que pulsar 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; +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. +unsafe extern "C" { + static kTISPropertyUnicodeKeyLayoutData: CFStringRef; + fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef; + fn TISGetInputSourceProperty(source: TISInputSourceRef, key: CFStringRef) -> CFDataRef; + fn CFDataGetBytePtr(data: CFDataRef) -> *const u8; + fn CFRelease(item: *const c_void); + fn CFRetain(item: *const c_void) -> *const c_void; + static kTISPropertyInputSourceID: CFStringRef; + fn TISCreateInputSourceList(properties: *const c_void, include_all: bool) -> CFArrayRef; + fn CFArrayGetCount(array: CFArrayRef) -> isize; + fn CFArrayGetValueAtIndex(array: CFArrayRef, index: isize) -> *const c_void; + fn CFStringGetCString(string: CFStringRef, buffer: *mut u8, size: isize, encoding: u32) + -> bool; + fn LMGetKbdType() -> u8; + fn UCKeyTranslate( + layout: *const u8, + virtual_key: u16, + key_action: u16, + modifier_state: u32, + keyboard_type: u32, + options: u32, + dead_keys: *mut u32, + max_len: usize, + actual_len: *mut usize, + out: *mut u16, + ) -> i32; +} + +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"; +pub const QWERTZ: &str = "com.apple.keylayout.German"; +pub const SPANISH_ISO: &str = "com.apple.keylayout.Spanish-ISO"; +pub const COLEMAK: &str = "com.apple.keylayout.Colemak"; +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. + 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. + let ok = unsafe { CFStringGetCString(value, buffer.as_mut_ptr(), buffer.len() as isize, UTF8) }; + if !ok { + return None; + } + let end = buffer.iter().position(|byte| *byte == 0)?; + 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. + let list = unsafe { TISCreateInputSourceList(std::ptr::null(), true) }; + if list.is_null() { + return Vec::new(); + } + // SAFETY: `list` no es nulo. + 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. + 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. + 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. + let list = unsafe { TISCreateInputSourceList(std::ptr::null(), true) }; + if list.is_null() { + return None; + } + // SAFETY: `list` no es nulo. + let count = unsafe { CFArrayGetCount(list) }; + let mut chosen = None; + for index in 0..count { + // SAFETY: el índice está dentro del rango devuelto. + let source = unsafe { CFArrayGetValueAtIndex(list, index) }; + if source_id(source).as_deref() == Some(wanted) { + chosen = Some(source); + break; + } + } + let result = chosen.and_then(|source| { + // SAFETY: `source` pertenece al array, que sigue vivo aquí. + let data = + unsafe { TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) }; + if data.is_null() { + return None; + } + // SAFETY: `data` no es nulo mientras el array viva. + 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. + unsafe { CFRetain(source) }; + // SAFETY: el array ya no hace falta. + unsafe { CFRelease(list) }; + Some(Self { source, bytes }) + } + None => { + // SAFETY: el array vino de una función Create. + unsafe { CFRelease(list) }; + None + } + } + } + + fn current() -> Option { + // SAFETY: devuelve una referencia con +1 que este tipo libera al caer. + let source = unsafe { TISCopyCurrentKeyboardLayoutInputSource() }; + if source.is_null() { + return None; + } + // SAFETY: `source` no es nulo y la clave es la constante del sistema. + let data = unsafe { TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) }; + if data.is_null() { + // SAFETY: `source` vino de una función Copy, así que hay que soltarlo. + unsafe { CFRelease(source) }; + return None; + } + // SAFETY: `data` no es nulo y pertenece al input source, que sigue vivo. + let bytes = unsafe { CFDataGetBytePtr(data) }; + if bytes.is_null() { + // SAFETY: mismo motivo que arriba. + unsafe { CFRelease(source) }; + return None; + } + Some(Self { source, bytes }) + } + + fn character_for(&self, keycode: u16, modifiers: u32) -> Option { + 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. + let status = unsafe { + UCKeyTranslate( + self.bytes, + keycode, + ACTION_DOWN, + modifiers, + u32::from(keyboard_type()), + NO_DEAD_KEYS, + &mut dead_keys, + buffer.len(), + &mut produced, + buffer.as_mut_ptr(), + ) + }; + if status != 0 || produced == 0 { + return None; + } + char::decode_utf16(buffer[..produced].iter().copied()) + .next()? + .ok() + } +} + +impl Drop for Layout { + fn drop(&mut self) { + // SAFETY: `source` vino de `TISCopyCurrentKeyboardLayoutInputSource`, + // que entrega +1, y no se ha liberado antes. + unsafe { CFRelease(self.source) }; + } +} + +fn keyboard_type() -> u8 { + // SAFETY: la función no toma argumentos y devuelve un entero. + 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| { + layout + .character_for(*code, COMMAND_DOWN) + .is_some_and(|got| got.eq_ignore_ascii_case(&wanted)) + }) +} + +/// 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| { + layout + .character_for(*code, COMMAND_DOWN) + .is_some_and(|got| got.eq_ignore_ascii_case(&wanted)) + }) +} + +/// 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/lib.rs b/crates/cp-mac-sys/src/lib.rs index 76d88c6..97863d5 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -1,3 +1,5 @@ #![cfg(target_os = "macos")] +pub mod keyboard; pub mod pasteboard; +pub mod permissions; diff --git a/crates/cp-mac-sys/src/permissions.rs b/crates/cp-mac-sys/src/permissions.rs new file mode 100644 index 0000000..b538dbc --- /dev/null +++ b/crates/cp-mac-sys/src/permissions.rs @@ -0,0 +1,79 @@ +//! 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. +unsafe extern "C" { + fn CGPreflightPostEventAccess() -> bool; + fn CGRequestPostEventAccess() -> bool; + fn AXIsProcessTrusted() -> bool; + 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. + 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. + 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. + 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. + unsafe { IsSecureEventInputEnabled() } +} + +/// Lo que hace falta para pegar, resumido. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Readiness { + pub can_post: bool, + pub accessibility: bool, + pub secure_input: bool, +} + +impl Readiness { + pub fn probe() -> Self { + Self { + can_post: can_post_events(), + accessibility: is_accessibility_trusted(), + secure_input: is_secure_input_enabled(), + } + } + + /// 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/examples/probe.rs b/crates/cp-mac/examples/probe.rs index c930a57..44900da 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -7,7 +7,9 @@ use cp_core::formats::Kind; use cp_core::item::Payload; use cp_mac::capture::capture; +use cp_mac_sys::keyboard::{self, QWERTY_V}; use cp_mac_sys::pasteboard::Pasteboard; +use cp_mac_sys::permissions::Readiness; use objc2_foundation::MainThreadMarker; fn main() -> std::process::ExitCode { @@ -59,6 +61,69 @@ fn main() -> std::process::ExitCode { Ok(()) }); + let ready = Readiness::probe(); + println!(); + println!(" permisos:"); + println!( + " postear eventos (kTCCServicePostEvent) : {}", + ready.can_post + ); + println!( + " accesibilidad (respaldo por menú) : {}", + ready.accessibility + ); + println!( + " input seguro activo ahora : {}", + ready.secure_input + ); + println!( + " -> se puede pegar : {}", + ready.can_paste() + ); + + println!(); + println!(" teclado:"); + match keyboard::keycode_with_command('v') { + Some(code) => { + println!(" keycode que da «v» con ⌘ : 0x{code:02X}"); + if code == QWERTY_V { + println!(" coincide con QWERTY, así que este layout no era el problema"); + } else { + println!( + " DISTINTO de QWERTY (0x{QWERTY_V:02X}): la 2.x pegaría otra tecla aquí" + ); + } + } + None => println!(" el layout activo no produce «v» con ⌘; se usaría 0x{QWERTY_V:02X}"), + } + + println!(); + println!(" el mismo cálculo en otros layouts, sin activarlos:"); + for (name, id) in [ + ("ABC", keyboard::ABC), + ("Dvorak", keyboard::DVORAK), + ("Dvorak-QWERTY ⌘", keyboard::DVORAK_COMMAND_QWERTY), + ("AZERTY (French)", keyboard::AZERTY), + ("QWERTZ (German)", keyboard::QWERTZ), + ("Español ISO", keyboard::SPANISH_ISO), + ("Colemak", keyboard::COLEMAK), + ] { + match keyboard::keycode_with_command_in(id, 'v') { + Some(code) if code == QWERTY_V => { + println!(" {name:18} 0x{code:02X} (igual que QWERTY)") + } + Some(code) => println!(" {name:18} 0x{code:02X} <- AQUÍ la 2.x pega otra tecla"), + None => println!(" {name:18} no instalado"), + } + } + + failed += check("el input seguro no impide pegar", || { + if !ready.can_post && ready.secure_input { + return Err("se está confundiendo input seguro con falta de permiso".into()); + } + Ok(()) + }); + println!(); if failed == 0 { println!("todo en verde"); diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 368f7dd..da4f0c3 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -2,6 +2,20 @@ use cp_core::item::{Item, Payload}; use cp_core::search::fold; use rusqlite::{Connection, OptionalExtension, Result, 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() + .filter(|word| word.chars().any(char::is_alphanumeric)) + .map(|word| format!("\"{}\"*", word.replace('"', "\"\""))) + .collect(); + (!terms.is_empty()).then(|| terms.join(" ")) +} + pub struct Store { db: Connection, } @@ -114,7 +128,9 @@ impl Store { } pub fn search(&self, query: &str) -> Result> { - let folded = fold(query); + let Some(expression) = fts_expression(&fold(query)) else { + return Ok(Vec::new()); + }; let mut stmt = self.db.prepare( "SELECT items.preview_text FROM items_fts @@ -122,7 +138,7 @@ impl Store { WHERE items_fts MATCH ?1 ORDER BY items.created_at DESC", )?; - let rows = stmt.query_map([format!("{folded}*")], |row| row.get::<_, String>(0))?; + let rows = stmt.query_map([expression], |row| row.get::<_, String>(0))?; rows.collect() } } @@ -296,6 +312,155 @@ 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(); + for query in [ + "\"", + "\"\"", + "*", + "(", + ")", + "()", + "a AND b", + "NOT café", + "search_text:café", + "NEAR(a b)", + "-café", + "^café", + "café*", + "{café}", + "[café]", + "café OR", + "OR", + "AND OR NOT", + "", + " ", + "\t\n", + "...", + "!!!", + "\\", + "%", + "_", + "'; DROP TABLE items; --", + ] { + store + .search(query) + .unwrap_or_else(|why| panic!("«{query}» rompió la búsqueda: {why}")); + } + } + + #[test] + fn an_empty_search_returns_nothing_rather_than_everything() { + let store = seeded(); + for empty in ["", " ", "\t", "-", "!!", "***"] { + assert!( + store.search(empty).expect("consulta").is_empty(), + "«{empty}» debería no devolver nada" + ); + } + } + + #[test] + fn the_punctuation_around_a_word_does_not_hide_it() { + let store = seeded(); + for query in ["-café", "^café", "(café)", "«café»", "café!"] { + let hits = store.search(query).expect("consulta"); + assert!( + hits.iter().any(|hit| hit.contains("café")), + "«{query}» no encontró el café" + ); + } + } + + #[test] + fn scripts_that_are_not_latin_go_in_and_come_out() { + let store = Store::in_memory().expect("esquema"); + for (at, text) in [ + "日本語のテキスト", + "Привет мир", + "مرحبا بالعالم", + "🎉 fiesta 🎊", + "한국어 텍스트", + ] + .iter() + .enumerate() + { + store + .insert_text(&format!("uuid-{at}"), text, at as i64) + .expect("insert"); + } + for (query, expected) in [ + ("日本語", "日本語のテキスト"), + ("Привет", "Привет мир"), + ("fiesta", "🎉 fiesta 🎊"), + ("한국어", "한국어 텍스트"), + ] { + let hits = store.search(query).expect("consulta"); + assert!( + hits.iter().any(|hit| hit == expected), + "buscando «{query}» faltó «{expected}»: {hits:?}" + ); + } + } + + #[test] + fn a_very_long_text_is_stored_and_found() { + let store = Store::in_memory().expect("esquema"); + let long = format!( + "{} aguja {}", + "paja ".repeat(50_000), + "paja ".repeat(50_000) + ); + store.insert_text("uuid-largo", &long, 1).expect("insert"); + assert_eq!(store.search("aguja").expect("consulta").len(), 1); + } + + #[test] + fn the_same_uuid_twice_is_refused_not_duplicated() { + let store = Store::in_memory().expect("esquema"); + store + .insert_text("uuid-unico", "primero", 1) + .expect("insert"); + assert!( + store.insert_text("uuid-unico", "segundo", 2).is_err(), + "el uuid es único por contrato" + ); + assert_eq!(store.count().expect("cuenta"), 1); + } + + #[test] + fn marking_an_item_that_does_not_exist_is_not_a_failure() { + let store = Store::in_memory().expect("esquema"); + store.mark_broken(9999, 1).expect("no existe, no pasa nada"); + assert_eq!(store.count().expect("cuenta"), 0); + } + + #[test] + fn a_purge_with_nothing_to_purge_removes_nothing() { + let store = seeded(); + let before = store.count().expect("cuenta"); + assert_eq!(store.purge_broken_before(-1).expect("purga"), 0); + assert_eq!(store.purge_broken_before(i64::MAX).expect("purga"), 0); + assert_eq!(store.count().expect("cuenta"), before); + } + + #[test] + fn an_item_with_no_formats_at_all_is_still_an_item() { + let store = Store::in_memory().expect("esquema"); + let empty = Item { + kind: None, + formats: vec![], + }; + let id = store + .insert_item("uuid-vacio", &empty, "", 1) + .expect("insert"); + assert_eq!(store.formats_of(id).expect("formatos").len(), 0); + assert_eq!(store.count().expect("cuenta"), 1); + } + #[test] fn a_word_that_is_not_there_finds_nothing() { let store = seeded(); diff --git a/deny.toml b/deny.toml index ac5403c..c4dccb2 100644 --- a/deny.toml +++ b/deny.toml @@ -13,6 +13,9 @@ 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] multiple-versions = "warn" From 63e9e67157c48437a009185dc2c17f19574a59d2 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 18:40:30 -0300 Subject: [PATCH 07/18] test: raise the core to 97% coverage and prove privacy against the system The concealed-content invariant was only tested against in-memory lists. The harness now writes what a password manager writes, the secret plus its marker, and asserts nothing is captured: ConcealedType, the transient type, and three legacy markers from 1Password, KeeWeb and Flycut. Plus the way back, so exclusion does not quietly become "nothing is ever stored". The watcher can finally poll from its own thread. Reading the counter alone materialises no representation, so it stays clear of the file-promise path where NSPasteboard's documented concurrency bug lives; everything else still requires the main thread, enforced at compile time. Twelve properties added where only examples existed: canonical must be idempotent or deduplication silently breaks, retries are bounded, nothing is retried after a successful send, and placement is monotonic in size. Coverage measured with the harness included, because without it every platform module reads as zero: 96.92% lines, 98.45% functions. A CI job holds the floor at 90% and includes the harness, so the difficult half cannot be deleted to make the number look better. --- .github/workflows/rules.yml | 16 +++ crates/cp-core/src/formats.rs | 80 ++++++++++++++- crates/cp-core/src/item.rs | 56 ++++++++++ crates/cp-core/src/paste.rs | 61 +++++++++++ crates/cp-core/src/search.rs | 47 +++++++++ crates/cp-mac-sys/src/pasteboard.rs | 34 +++++- crates/cp-mac-sys/src/permissions.rs | 38 +++++++ crates/cp-mac/Cargo.toml | 1 + crates/cp-mac/examples/probe.rs | 148 +++++++++++++++++++++++++++ 9 files changed, 478 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 887d444..7c44598 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -84,3 +84,19 @@ jobs: # 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 + + coverage: + name: Coverage does not slip + runs-on: macos-15 + 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 + # 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 diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index 899d468..e4133ac 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -103,7 +103,7 @@ impl Catalog { mod tests { use super::*; - const PROBE: Catalog = Catalog { + pub(super) const PROBE: Catalog = Catalog { hangs: &["hangs/forever"], wasteful: &["huge/icon"], wanted: &[ @@ -238,3 +238,81 @@ mod tests { ); } } + +#[cfg(test)] +mod properties { + use super::tests::PROBE; + use super::*; + use proptest::prelude::*; + + fn any_id() -> impl Strategy { + prop_oneof![ + Just("plain/text".to_string()), + Just("old/text".to_string()), + Just("hangs/forever".to_string()), + Just("huge/icon".to_string()), + Just("cheap/image".to_string()), + Just("costly/image".to_string()), + Just("one/file".to_string()), + "[a-z]{0,12}/[a-z]{0,12}", + "dyn\\.[a-z0-9]{0,20}", + Just(String::new()), + ] + } + + 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)) { + prop_assert_eq!(PROBE.decide(&id), Take::Never); + } + } + + /// 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(); + if let Some(chosen) = PROBE.preferred_image(&refs) { + let canonical: Vec<&str> = refs.iter().map(|id| PROBE.canonical(id)).collect(); + prop_assert!(canonical.contains(&chosen)); + } + } + + /// 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(); + let canonical: Vec<&str> = refs.iter().map(|id| PROBE.canonical(id)).collect(); + match PROBE.classify(&refs) { + Some(Kind::Files) => { + prop_assert!(canonical.iter().any(|id| PROBE.files.contains(id))) + } + Some(Kind::Image) => prop_assert!( + canonical + .iter() + .any(|id| PROBE.images_by_preference.contains(id)) + ), + Some(Kind::Text) => { + prop_assert!(canonical.iter().any(|id| PROBE.text.contains(id))) + } + None => {} + } + } + } +} diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index 29ea6ea..10123be 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -170,3 +170,59 @@ mod tests { assert_eq!(item.stored_bytes(), 1000); } } + +#[cfg(test)] +mod properties { + use super::*; + use proptest::prelude::*; + + fn rank(placement: Placement) -> u8 { + match placement { + Placement::Row => 0, + Placement::Blob => 1, + Placement::Refused => 2, + } + } + + 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(); + let item = Item { + kind: None, + formats: lens + .iter() + .enumerate() + .map(|(at, len)| Format { + id: format!("t{at}"), + payload: Payload::Inline(vec![0u8; *len]), + }) + .chain(std::iter::once(Format { + id: "announced".into(), + payload: Payload::Announced { size: Some(999_999) }, + })) + .chain(std::iter::once(Format { + id: "absent".into(), + payload: Payload::Absent, + })) + .collect(), + }; + prop_assert_eq!(item.stored_bytes(), expected); + } + } +} diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs index 0c6c356..d7dc644 100644 --- a/crates/cp-core/src/paste.rs +++ b/crates/cp-core/src/paste.rs @@ -159,3 +159,64 @@ mod tests { assert!(!aborts_on(Warning::SecureInputActive)); } } + +#[cfg(test)] +mod properties { + use super::*; + use proptest::prelude::*; + + fn any_failure() -> impl Strategy { + prop_oneof![ + Just(Failure::ForegroundTimeout), + Just(Failure::NoKeyboardFocus), + Just(Failure::TargetGone), + Just(Failure::SendDenied), + ] + } + + 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(); + let retried = failures + .iter() + .filter(|failure| attempt.on_failure(**failure) == Next::Retry) + .count(); + 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(); + attempt.sending(); + for failure in failures { + prop_assert_eq!(attempt.on_failure(failure), Next::Degrade); + } + } + + /// 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(); + for failure in before { + attempt.on_failure(failure); + } + 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 { + prop_assert_eq!(index, ORDER.len() - 1); + } + if index > 0 { + prop_assert_ne!(ORDER[index], Phase::WriteClipboard); + } + } + } +} diff --git a/crates/cp-core/src/search.rs b/crates/cp-core/src/search.rs index 6be482c..2920d32 100644 --- a/crates/cp-core/src/search.rs +++ b/crates/cp-core/src/search.rs @@ -31,6 +31,53 @@ fn expand_ligature(c: char) -> std::vec::IntoIter { mod tests { use super::fold; + #[test] + fn every_ligature_in_the_table_is_expanded() { + for (given, expected) in [ + ("ß", "ss"), + ("æ", "ae"), + ("Æ", "ae"), + ("œ", "oe"), + ("Œ", "oe"), + ("ø", "o"), + ("Ø", "o"), + ("ł", "l"), + ("Ł", "l"), + ("đ", "d"), + ("Đ", "d"), + ("þ", "th"), + ("Þ", "th"), + ] { + assert_eq!(fold(given), expected, "«{given}» no se expandió bien"); + } + } + + #[test] + fn combining_marks_disappear_whatever_the_script() { + assert_eq!(fold("ñ"), "n"); + assert_eq!(fold("ç"), "c"); + assert_eq!(fold("ü"), "u"); + assert_eq!(fold("å"), "a"); + assert_eq!(fold("ĉ"), "c"); + assert_eq!(fold("ṩ"), "s"); + } + + #[test] + fn what_has_nothing_to_fold_comes_out_as_it_went_in() { + assert_eq!(fold(""), ""); + assert_eq!(fold("plain ascii"), "plain ascii"); + assert_eq!(fold("日本語"), "日本語"); + assert_eq!(fold("123 !?"), "123 !?"); + } + + #[test] + fn folding_twice_changes_nothing() { + for text in ["Straße", "encyclopædia", "Łódź", "café", "ÞÓRR"] { + let once = fold(text); + assert_eq!(fold(&once), once, "«{text}» no era estable"); + } + } + #[test] fn folds_both_sides_of_the_index() { assert_eq!(fold("Straße"), "strasse"); diff --git a/crates/cp-mac-sys/src/pasteboard.rs b/crates/cp-mac-sys/src/pasteboard.rs index 9e96fe6..131e86b 100644 --- a/crates/cp-mac-sys/src/pasteboard.rs +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -22,8 +22,8 @@ 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) -> isize { - self.inner.changeCount() + pub fn change_count(&self) -> i64 { + self.inner.changeCount() as i64 } pub fn types(&self) -> Vec { @@ -48,4 +48,34 @@ impl Pasteboard { let value = NSString::from_str(text); 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)| { + let name = NSString::from_str(uti); + let text = NSString::from_str(value); + self.inner.setString_forType(&text, &name) + }) + } +} + +/// 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/permissions.rs b/crates/cp-mac-sys/src/permissions.rs index b538dbc..7058a8a 100644 --- a/crates/cp-mac-sys/src/permissions.rs +++ b/crates/cp-mac-sys/src/permissions.rs @@ -77,3 +77,41 @@ impl Readiness { self.accessibility } } + +#[cfg(test)] +mod tests { + use super::Readiness; + + fn with(can_post: bool, accessibility: bool, secure_input: bool) -> Readiness { + Readiness { + can_post, + accessibility, + secure_input, + } + } + + #[test] + fn pasting_depends_on_posting_events_and_nothing_else() { + assert!(with(true, false, false).can_paste()); + assert!( + with(true, false, true).can_paste(), + "el input seguro no manda" + ); + assert!(with(true, true, true).can_paste()); + assert!(!with(false, true, false).can_paste()); + } + + #[test] + fn the_menu_fallback_needs_its_own_permission() { + assert!(!with(true, false, false).can_use_menu_fallback()); + assert!(with(true, true, false).can_use_menu_fallback()); + } + + #[test] + fn the_two_permissions_are_independent() { + assert!(with(true, false, false).can_paste()); + assert!(!with(true, false, false).can_use_menu_fallback()); + assert!(!with(false, true, false).can_paste()); + assert!(with(false, true, false).can_use_menu_fallback()); + } +} diff --git a/crates/cp-mac/Cargo.toml b/crates/cp-mac/Cargo.toml index 782b5fa..3df34ca 100644 --- a/crates/cp-mac/Cargo.toml +++ b/crates/cp-mac/Cargo.toml @@ -14,6 +14,7 @@ thiserror.workspace = true objc2-foundation.workspace = true [dev-dependencies] +cp-core.workspace = true cp-mac-sys.workspace = true objc2-foundation.workspace = true diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 44900da..cc2d924 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -61,6 +61,150 @@ fn main() -> std::process::ExitCode { Ok(()) }); + failed += check("el vigilante no pierde nada desde su propio hilo", || { + use cp_core::watch::{Seen, Watcher}; + use std::sync::mpsc; + + let (tell, hear) = mpsc::channel(); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let watching = { + let stop = stop.clone(); + std::thread::spawn(move || { + let mut watcher = Watcher::default(); + let mut fresh = 0; + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + if let Seen::Fresh { .. } = + watcher.tick(cp_mac_sys::pasteboard::change_count_from_any_thread()) + { + fresh += 1; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let _ = tell.send((fresh, watcher.missed())); + }) + }; + + std::thread::sleep(std::time::Duration::from_millis(40)); + let writes = 25; + for round in 0..writes { + pb.write_text(&format!("cp-race-{round}")); + std::thread::sleep(std::time::Duration::from_millis(12)); + } + std::thread::sleep(std::time::Duration::from_millis(80)); + stop.store(true, std::sync::atomic::Ordering::Relaxed); + watching.join().map_err(|_| "el hilo vigilante murió")?; + let (fresh, missed) = hear.recv().map_err(|why| why.to_string())?; + + if fresh + missed as i32 != writes { + return Err(format!( + "{writes} escrituras, {fresh} vistas y {missed} contadas como perdidas" + )); + } + if missed > 0 { + println!(" ({missed} de {writes} se perdieron, y se supo)"); + } + Ok(()) + }); + + failed += check("cien copias seguidas, ninguna perdida en silencio", || { + use cp_core::watch::{Seen, Watcher}; + + let mut watcher = Watcher::default(); + watcher.tick(pb.change_count()); + let mut seen = 0; + for round in 0..100 { + pb.write_text(&format!("cp-burst-{round}")); + if let Seen::Fresh { .. } = watcher.tick(pb.change_count()) { + seen += 1; + } + } + if seen + watcher.missed() as i32 != 100 { + return Err(format!( + "100 escrituras, {seen} vistas y {} contadas", + watcher.missed() + )); + } + if watcher.missed() > 0 { + println!(" ({} perdidas, y contadas)", watcher.missed()); + } + Ok(()) + }); + + failed += check("un texto de diez megabytes va y vuelve entero", || { + let big = "a".repeat(10 * 1024 * 1024); + pb.write_text(&big); + let item = capture(&pb).ok_or("no se capturó")?; + let text = item + .format("public.utf8-plain-text") + .ok_or("falta el texto")?; + match &text.payload { + Payload::Blob(bytes) if bytes.len() == big.len() => Ok(()), + other => Err(format!( + "llegó {other:?} en vez de un blob de {}", + big.len() + )), + } + }); + + failed += check("un texto vacío sigue siendo un ítem", || { + pb.write_text(""); + match capture(&pb) { + Some(item) => { + if item.formats.is_empty() { + return Err("no se registró ningún formato".into()); + } + Ok(()) + } + None => Err("se tomó por contenido secreto".into()), + } + }); + + failed += check("una copia marcada como secreta no se registra", || { + // Lo que hace un gestor de contraseñas: pone el secreto y lo marca. + pb.write_types(&[ + ("public.utf8-plain-text", "contraseña-que-no-debe-guardarse"), + ("org.nspasteboard.ConcealedType", "1"), + ]); + match capture(&pb) { + None => Ok(()), + Some(item) => Err(format!( + "se capturaron {} formatos de algo marcado como oculto", + item.formats.len() + )), + } + }); + + failed += check("el marcador transitorio también se respeta", || { + pb.write_types(&[ + ("public.utf8-plain-text", "algo efímero"), + ("org.nspasteboard.TransientType", "1"), + ]); + if capture(&pb).is_some() { + return Err("lo transitorio no debería registrarse".into()); + } + Ok(()) + }); + + failed += check("un gestor legado también queda cubierto", || { + for marker in [ + "com.agilebits.onepassword", + "net.antelle.keeweb", + "PasswordPboardType", + ] { + pb.write_types(&[("public.utf8-plain-text", "secreto"), (marker, "1")]); + if capture(&pb).is_some() { + return Err(format!("«{marker}» no excluyó el ítem")); + } + } + Ok(()) + }); + + failed += check("quitar el marcador vuelve a permitir la captura", || { + pb.write_text("esto sí se guarda"); + capture(&pb).ok_or("un texto normal debería capturarse")?; + Ok(()) + }); + let ready = Readiness::probe(); println!(); println!(" permisos:"); @@ -98,6 +242,10 @@ fn main() -> std::process::ExitCode { } println!(); + println!( + " layouts instalados: {}", + keyboard::installed_layouts().len() + ); println!(" el mismo cálculo en otros layouts, sin activarlos:"); for (name, id) in [ ("ABC", keyboard::ABC), From 58cacc14ba9c9310e7b7e4cf18f826ba8b612c44 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 19:05:54 -0300 Subject: [PATCH 08/18] feat(store): rebuild the schema on what 2.x learned in production The schema had been designed from the case file rather than from the DDL of something that has been running for years. Reading it found five things that were about to be lost and one bug about to be repeated. auto_vacuum is ignored silently on a database that already has tables, so 2.x ran for months believing it compacted when it did not. Pragmas now run before any CREATE TABLE, with a test that fails if the mode is not INCREMENTAL. Recovered: modified_at as the list order, so copying something again lifts it instead of burying it; label and app_source in the search index, so a label or a source application can be searched for; a colour index; the thumbnail column; and the composite index the type tab actually queries. Added for the sync that will come, cheap now and expensive later: a monotonic updated_at, distinct from modified_at because pinning an old item should not lift it but must still sync, and deleted_at tombstones, without which a first sync resurrects everything deleted elsewhere. The budget caught the regression this caused and the fix was not what it looked like: the corpus was artificial, repeating one word in every row. With realistic data the correct ordering costs 2.7ms, not 34. --- .github/workflows/rules.yml | 13 ++ crates/cp-store/examples/budget.rs | 193 ++++++++++++++++++ crates/cp-store/examples/plan.rs | 66 ++++++ crates/cp-store/src/schema.rs | 113 +++++++++-- crates/cp-store/src/store.rs | 314 ++++++++++++++++++++++++++++- 5 files changed, 673 insertions(+), 26 deletions(-) create mode 100644 crates/cp-store/examples/budget.rs create mode 100644 crates/cp-store/examples/plan.rs diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 7c44598..efcb305 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -100,3 +100,16 @@ jobs: - 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 + + budget: + name: Latency stays within budget + runs-on: macos-15 + env: + CP_BUDGET_SLACK: "4" + steps: + - 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/crates/cp-store/examples/budget.rs b/crates/cp-store/examples/budget.rs new file mode 100644 index 0000000..383ea9f --- /dev/null +++ b/crates/cp-store/examples/budget.rs @@ -0,0 +1,193 @@ +//! 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_store::Store; +use std::time::{Duration, Instant}; + +const ITEMS: usize = 50_000; + +struct Budget { + what: &'static str, + 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() + .and_then(|value| value.parse().ok()) + .unwrap_or(1) + .max(1) +} + +fn main() -> std::process::ExitCode { + let store = Store::in_memory().expect("esquema"); + if slack() > 1 { + 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", "reunión", "contraseña", "dirección", "teléfono", + "proyecto", "cliente", "presupuesto", "contrato", "pedido", "entrega", + "https://ejemplo.test/ruta", "SELECT * FROM tabla", "def función():", + "Straße", "encyclopædia", "日本語", "correo@ejemplo.test", "#FF8800", + ]; + let filling = Instant::now(); + for at in 0..ITEMS { + let word = vocabulary[at % vocabulary.len()]; + let other = vocabulary[(at * 7) % vocabulary.len()]; + store + .insert_text( + &format!("uuid-{at}"), + &format!("{word} {at} sobre {other} con algo de texto alrededor"), + at as i64, + ) + .expect("insert"); + } + let filled = filling.elapsed(); + println!( + " {ITEMS} ítems insertados en {:?} ({:?} por ítem)", + filled, + filled / ITEMS as u32 + ); + + let mut over = 0; + over += measure( + Budget { + what: "búsqueda por prefijo", + ceiling: Duration::from_millis(20), + }, + || { + store.search("factur").expect("consulta"); + }, + ); + over += measure( + Budget { + what: "búsqueda que no encuentra nada", + ceiling: Duration::from_millis(20), + }, + || { + store.search("inexistente").expect("consulta"); + }, + ); + over += measure( + Budget { + what: "búsqueda con acentos y ligaduras", + ceiling: Duration::from_millis(20), + }, + || { + store.search("straße").expect("consulta"); + }, + ); + over += measure( + Budget { + what: "peor caso: casa con todo el historial", + ceiling: Duration::from_millis(60), + }, + || { + store.search("con").expect("consulta"); + }, + ); + over += measure( + Budget { + what: "página diez por OFFSET (lo que no se usa)", + ceiling: Duration::from_millis(40), + }, + || { + store.search_page("con", 100, 1000).expect("consulta"); + }, + ); + over += measure( + Budget { + what: "página diez por cursor", + ceiling: Duration::from_millis(20), + }, + || { + store + .search_after("factura", 100, Some(48_000)) + .expect("consulta"); + }, + ); + over += measure( + Budget { + what: "página muy profunda por cursor", + ceiling: Duration::from_millis(20), + }, + || { + store + .search_after("elemento", 100, Some(200)) + .expect("consulta"); + }, + ); + over += measure( + Budget { + what: "deduplicación por hash", + ceiling: Duration::from_millis(5), + }, + || { + store + .find_by_hash("elemento número 5000 con algo de texto alrededor para que pese") + .expect("busca"); + }, + ); + over += measure( + Budget { + what: "una inserción más", + ceiling: Duration::from_millis(5), + }, + || { + let _ = store.insert_text("uuid-extra", "uno más", 999_999); + }, + ); + + println!(); + if over == 0 { + println!("todo dentro de presupuesto"); + std::process::ExitCode::SUCCESS + } else { + println!("{over} operaciones fuera de presupuesto"); + std::process::ExitCode::FAILURE + } +} + +/// 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(|_| { + let at = Instant::now(); + run(); + at.elapsed() + }) + .collect(); + taken.sort_unstable(); + let median = taken[taken.len() / 2]; + let worst = *taken.last().expect("hay muestras"); + let ceiling = budget.ceiling * slack(); + if median <= ceiling { + println!( + " ok {:34} p50 {median:>10?} max {worst:>10?} (techo {ceiling:?})", + budget.what + ); + 0 + } else { + println!( + " FUERA {:34} p50 {median:>10?} max {worst:>10?} (techo {ceiling:?})", + budget.what + ); + 1 + } +} diff --git a/crates/cp-store/examples/plan.rs b/crates/cp-store/examples/plan.rs new file mode 100644 index 0000000..b1d37bc --- /dev/null +++ b/crates/cp-store/examples/plan.rs @@ -0,0 +1,66 @@ +use std::time::Instant; + +fn main() { + let store = cp_store::Store::in_memory().unwrap(); + for at in 0..50_000 { + store + .insert_text(&format!("u{at}"), &format!("elemento {at} con texto"), at) + .unwrap(); + } + let db = store.raw(); + + let variants = [ + ( + "join + order by items (la actual)", + "SELECT items.modified_at, items.preview_text + FROM items_fts JOIN items ON items.id = items_fts.rowid + WHERE items_fts MATCH ?1 AND (?2 IS NULL OR items.modified_at < ?2) + ORDER BY items.modified_at DESC LIMIT 100", + ), + ( + "IN (subconsulta fts)", + "SELECT modified_at, preview_text FROM items + WHERE id IN (SELECT rowid FROM items_fts WHERE items_fts MATCH ?1) + AND (?2 IS NULL OR modified_at < ?2) + ORDER BY modified_at DESC LIMIT 100", + ), + ( + "order by rowid del fts", + "SELECT items.modified_at, items.preview_text + FROM items_fts JOIN items ON items.id = items_fts.rowid + WHERE items_fts MATCH ?1 AND (?2 IS NULL OR items.modified_at < ?2) + ORDER BY items_fts.rowid DESC LIMIT 100", + ), + ]; + + for (name, sql) in variants { + let mut plan = db.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap(); + let steps: Vec = plan + .query_map(rusqlite::params!["\"element\"*", None::], |r| { + r.get::<_, String>(3) + }) + .unwrap() + .map(Result::unwrap) + .collect(); + + let mut stmt = db.prepare(sql).unwrap(); + let mut taken = Vec::new(); + for _ in 0..7 { + let at = Instant::now(); + let rows: Vec<(i64, String)> = stmt + .query_map(rusqlite::params!["\"element\"*", None::], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap() + .map(Result::unwrap) + .collect(); + assert_eq!(rows.len(), 100); + taken.push(at.elapsed()); + } + taken.sort_unstable(); + println!("\n{name}\n p50 {:?}", taken[taken.len() / 2]); + for step in steps { + println!(" · {step}"); + } + } +} diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs index 829d132..386bb32 100644 --- a/crates/cp-store/src/schema.rs +++ b/crates/cp-store/src/schema.rs @@ -2,37 +2,67 @@ use rusqlite::{Connection, Result}; pub const SCHEMA_VERSION: u32 = 1; -/// 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<()> { +/// 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#" + PRAGMA auto_vacuum = INCREMENTAL; PRAGMA journal_mode = WAL; - PRAGMA secure_delete = ON; + PRAGMA synchronous = NORMAL; + PRAGMA cache_size = -2000; PRAGMA foreign_keys = ON; + PRAGMA secure_delete = ON; + "#, + ) +} +/// 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( + r#" CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY, uuid TEXT NOT NULL UNIQUE, kind TEXT, - preview_text TEXT, + preview_text TEXT NOT NULL DEFAULT '', + app_source TEXT, + label TEXT, + card_color INTEGER NOT NULL DEFAULT 0, + thumb_path TEXT, created_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, last_used_at INTEGER, + source_modified_at INTEGER, + broken_since INTEGER, paste_count INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, - label TEXT, - color TEXT, - app_source TEXT, content_hash INTEGER NOT NULL, - search_text TEXT NOT NULL, - broken_since INTEGER, - source_modified_at INTEGER + search_text TEXT NOT NULL DEFAULT '', + search_label TEXT NOT NULL DEFAULT '', + search_app TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + deleted_at INTEGER ); - CREATE INDEX IF NOT EXISTS items_by_recency ON items(created_at DESC); - CREATE INDEX IF NOT EXISTS items_by_hash ON items(content_hash); - CREATE INDEX IF NOT EXISTS items_broken ON items(broken_since) + CREATE INDEX IF NOT EXISTS items_by_recency ON items(modified_at DESC); + CREATE INDEX IF NOT EXISTS items_by_creation ON items(created_at); + CREATE INDEX IF NOT EXISTS items_by_hash ON items(content_hash); + CREATE INDEX IF NOT EXISTS items_by_kind ON items(kind, modified_at DESC); + CREATE INDEX IF NOT EXISTS items_by_color ON items(card_color); + CREATE INDEX IF NOT EXISTS items_pinned ON items(pinned) WHERE pinned = 1; + CREATE INDEX IF NOT EXISTS items_broken ON items(broken_since) WHERE broken_since IS NOT NULL; + CREATE INDEX IF NOT EXISTS items_by_version ON items(updated_at); + CREATE INDEX IF NOT EXISTS items_deleted ON items(deleted_at) + WHERE deleted_at IS NOT NULL; CREATE TABLE IF NOT EXISTS item_formats ( item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, @@ -45,23 +75,66 @@ pub fn create(db: &Connection) -> Result<()> { CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5( search_text, + search_label, + search_app, content = 'items', content_rowid = 'id', tokenize = 'unicode61 remove_diacritics 2' ); CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN - INSERT INTO items_fts(rowid, search_text) VALUES (new.id, new.search_text); + INSERT INTO items_fts(rowid, search_text, search_label, search_app) + VALUES (new.id, new.search_text, new.search_label, new.search_app); END; CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN - INSERT INTO items_fts(items_fts, rowid, search_text) - VALUES ('delete', old.id, old.search_text); + INSERT INTO items_fts(items_fts, rowid, search_text, search_label, search_app) + VALUES ('delete', old.id, old.search_text, old.search_label, old.search_app); END; CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN - INSERT INTO items_fts(items_fts, rowid, search_text) - VALUES ('delete', old.id, old.search_text); - INSERT INTO items_fts(rowid, search_text) VALUES (new.id, new.search_text); + INSERT INTO items_fts(items_fts, rowid, search_text, search_label, search_app) + VALUES ('delete', old.id, old.search_text, old.search_label, old.search_app); + INSERT INTO items_fts(rowid, search_text, search_label, search_app) + VALUES (new.id, new.search_text, new.search_label, new.search_app); END; "#, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_vacuum_actually_took() { + let db = Connection::open_in_memory().expect("abre"); + create(&db).expect("esquema"); + let mode: i64 = db + .query_row("PRAGMA auto_vacuum", [], |row| row.get(0)) + .expect("consulta"); + assert_eq!( + mode, 2, + "INCREMENTAL es 2; si sale 0 el pragma llegó tarde y se ignoró" + ); + } + + #[test] + fn the_pragmas_that_protect_the_data_are_on() { + let db = Connection::open_in_memory().expect("abre"); + create(&db).expect("esquema"); + let secure: i64 = db + .query_row("PRAGMA secure_delete", [], |row| row.get(0)) + .expect("consulta"); + assert_eq!(secure, 1, "una contraseña borrada no puede quedar legible"); + let foreign: i64 = db + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .expect("consulta"); + assert_eq!(foreign, 1, "sin esto la cascada de formatos no ocurre"); + } + + #[test] + fn creating_twice_is_harmless() { + let db = Connection::open_in_memory().expect("abre"); + create(&db).expect("primera"); + create(&db).expect("segunda"); + } +} diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index da4f0c3..c417a1c 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -21,6 +21,11 @@ pub struct Store { } impl Store { + /// Solo para diagnóstico: mirar planes de consulta desde un ejemplo. + pub fn raw(&self) -> &Connection { + &self.db + } + pub fn in_memory() -> Result { let db = Connection::open_in_memory()?; crate::schema::create(&db)?; @@ -34,13 +39,64 @@ impl Store { pub fn insert_text(&self, uuid: &str, text: &str, created_at: i64) -> Result { let hash = cp_core::hash::content_hash(text.as_bytes()) as i64; self.db.execute( - "INSERT INTO items (uuid, kind, preview_text, created_at, content_hash, search_text) - VALUES (?1, 'text', ?2, ?3, ?4, ?5)", + "INSERT INTO items (uuid, kind, preview_text, created_at, modified_at, updated_at, + content_hash, search_text) + VALUES (?1, 'text', ?2, ?3, ?3, ?3, ?4, ?5)", params![uuid, text, created_at, hash, fold(text)], )?; Ok(self.db.last_insert_rowid()) } + /// Volver a copiar algo que ya estaba lo sube en la lista, no lo duplica. + /// La 2.x ordena por `modified_at` justamente por esto. + pub fn touch(&self, id: i64, at: i64) -> Result<()> { + self.db.execute( + "UPDATE items + SET modified_at = ?2, last_used_at = ?2, updated_at = ?2, + paste_count = paste_count + 1 + WHERE id = ?1", + params![id, at], + )?; + Ok(()) + } + + /// 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", + params![id, label, label.map(fold).unwrap_or_default(), at], + )?; + Ok(()) + } + + pub fn set_source(&self, id: i64, app: &str, at: i64) -> Result<()> { + self.db.execute( + "UPDATE items SET app_source = ?2, search_app = ?3, updated_at = ?4 WHERE id = ?1", + params![id, app, fold(app), at], + )?; + 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<()> { + self.db.execute( + "UPDATE items SET deleted_at = ?2, updated_at = ?2 WHERE id = ?1", + params![id, at], + )?; + Ok(()) + } + + /// 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 + .prepare("SELECT uuid FROM items WHERE updated_at > ?1 ORDER BY updated_at")?; + let rows = stmt.query_map([version], |row| row.get::<_, String>(0))?; + 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. @@ -54,8 +110,9 @@ impl Store { let hash = cp_core::hash::content_hash(preview.as_bytes()) as i64; let kind = item.kind.map(|k| format!("{k:?}").to_lowercase()); self.db.execute( - "INSERT INTO items (uuid, kind, preview_text, created_at, content_hash, search_text) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO items (uuid, kind, preview_text, created_at, modified_at, updated_at, + content_hash, search_text) + VALUES (?1, ?2, ?3, ?4, ?4, ?4, ?5, ?6)", params![uuid, kind, preview, created_at, hash, fold(preview)], )?; let id = self.db.last_insert_rowid(); @@ -127,7 +184,45 @@ impl Store { .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) } + /// 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, + limit: usize, + after: Option, + ) -> Result> { + let Some(expression) = fts_expression(&fold(query)) else { + return Ok(Vec::new()); + }; + let mut stmt = self.db.prepare( + "SELECT items.modified_at, items.preview_text + FROM items_fts + JOIN items ON items.id = items_fts.rowid + WHERE items_fts MATCH ?1 AND (?2 IS NULL OR items.modified_at < ?2) + ORDER BY items.modified_at DESC + LIMIT ?3", + )?; + let rows = stmt.query_map(rusqlite::params![expression, after, limit as i64], |row| { + Ok((row.get(0)?, row.get(1)?)) + })?; + rows.collect() + } + + pub fn search_page(&self, query: &str, limit: usize, offset: usize) -> Result> { let Some(expression) = fts_expression(&fold(query)) else { return Ok(Vec::new()); }; @@ -136,9 +231,13 @@ impl Store { FROM items_fts JOIN items ON items.id = items_fts.rowid WHERE items_fts MATCH ?1 - ORDER BY items.created_at DESC", + ORDER BY items.modified_at DESC + LIMIT ?2 OFFSET ?3", + )?; + let rows = stmt.query_map( + rusqlite::params![expression, limit as i64, offset as i64], + |row| row.get::<_, String>(0), )?; - let rows = stmt.query_map([expression], |row| row.get::<_, String>(0))?; rows.collect() } } @@ -461,6 +560,209 @@ mod tests { assert_eq!(store.count().expect("cuenta"), 1); } + #[test] + fn a_search_that_matches_everything_still_returns_one_page() { + let store = Store::in_memory().expect("esquema"); + for at in 0..250 { + store + .insert_text(&format!("uuid-{at}"), &format!("comun {at}"), at) + .expect("insert"); + } + assert_eq!(store.search("comun").expect("consulta").len(), Store::PAGE); + assert_eq!( + store.search_page("comun", 10, 0).expect("consulta").len(), + 10 + ); + } + + #[test] + fn paging_walks_the_whole_result_without_repeating() { + let store = Store::in_memory().expect("esquema"); + for at in 0..25 { + store + .insert_text(&format!("uuid-{at}"), &format!("pagina {at}"), at) + .expect("insert"); + } + let first = store.search_page("pagina", 10, 0).expect("consulta"); + let second = store.search_page("pagina", 10, 10).expect("consulta"); + let last = store.search_page("pagina", 10, 20).expect("consulta"); + assert_eq!((first.len(), second.len(), last.len()), (10, 10, 5)); + assert!( + first.iter().all(|one| !second.contains(one)), + "las páginas no pueden solaparse" + ); + } + + #[test] + fn the_cursor_walks_the_result_without_repeating_or_skipping() { + let store = Store::in_memory().expect("esquema"); + for at in 0..25 { + store + .insert_text(&format!("uuid-{at}"), &format!("cursor {at}"), at) + .expect("insert"); + } + let mut seen = Vec::new(); + let mut after = None; + loop { + let page = store.search_after("cursor", 10, after).expect("consulta"); + if page.is_empty() { + break; + } + after = page.last().map(|(at, _)| *at); + seen.extend(page.into_iter().map(|(_, text)| text)); + } + assert_eq!(seen.len(), 25, "recorrió todo"); + let mut unique = seen.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), 25, "sin repetir"); + } + + #[test] + fn a_cursor_past_the_oldest_item_is_empty() { + let store = seeded(); + assert!( + store + .search_after("café", 10, Some(-1)) + .expect("consulta") + .is_empty() + ); + } + + #[test] + fn an_offset_past_the_end_is_empty_not_an_error() { + let store = seeded(); + assert!( + store + .search_page("café", 10, 9999) + .expect("consulta") + .is_empty() + ); + } + + #[test] + fn copying_something_again_lifts_it_instead_of_duplicating_it() { + let store = Store::in_memory().expect("esquema"); + let first = store.insert_text("uuid-a", "lo viejo", 10).expect("insert"); + store.insert_text("uuid-b", "lo nuevo", 20).expect("insert"); + + let before = store.search("lo").expect("consulta"); + assert_eq!(before.first().map(String::as_str), Some("lo nuevo")); + + store.touch(first, 30).expect("recopiado"); + let after = store.search("lo").expect("consulta"); + assert_eq!( + after.first().map(String::as_str), + Some("lo viejo"), + "recopiar algo lo sube al principio" + ); + } + + #[test] + fn a_label_can_be_searched_for() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-etq", "un texto cualquiera", 1) + .expect("insert"); + assert!(store.search("factura").expect("consulta").is_empty()); + store + .set_label(id, Some("Factura Mayo"), 2) + .expect("etiqueta"); + let hits = store.search("factura").expect("consulta"); + assert_eq!(hits.len(), 1, "la etiqueta entra en el índice"); + } + + #[test] + fn the_source_application_can_be_searched_for() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-app", "algo copiado", 1) + .expect("insert"); + store.set_source(id, "Safari", 2).expect("origen"); + assert_eq!(store.search("safari").expect("consulta").len(), 1); + } + + #[test] + fn a_label_with_accents_is_found_without_them() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-tilde", "contenido", 1) + .expect("insert"); + store + .set_label(id, Some("Reunión Diseño"), 2) + .expect("etiqueta"); + assert_eq!(store.search("reunion").expect("consulta").len(), 1); + assert_eq!(store.search("diseño").expect("consulta").len(), 1); + } + + #[test] + fn removing_a_label_takes_it_out_of_the_index() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-quita", "contenido", 1) + .expect("insert"); + store.set_label(id, Some("temporal"), 2).expect("pone"); + assert_eq!(store.search("temporal").expect("consulta").len(), 1); + store.set_label(id, None, 3).expect("quita"); + assert!(store.search("temporal").expect("consulta").is_empty()); + } + + #[test] + fn a_deletion_leaves_a_tombstone_for_the_sync_that_will_come() { + let store = Store::in_memory().expect("esquema"); + let id = store.insert_text("uuid-tumba", "se va", 1).expect("insert"); + store.mark_deleted(id, 50).expect("tumba"); + assert_eq!( + store.count().expect("cuenta"), + 1, + "la fila sigue, marcada: sin esto una sincronización la resucita" + ); + assert!( + store + .changed_since(40) + .expect("cambios") + .contains(&"uuid-tumba".to_string()) + ); + } + + #[test] + fn only_what_changed_after_the_mark_is_reported() { + let store = Store::in_memory().expect("esquema"); + let old = store + .insert_text("uuid-viejo", "antiguo", 10) + .expect("insert"); + store + .insert_text("uuid-nuevo", "reciente", 100) + .expect("insert"); + let changed = store.changed_since(50).expect("cambios"); + assert_eq!(changed, vec!["uuid-nuevo".to_string()]); + + store.touch(old, 200).expect("recopiado"); + let after = store.changed_since(50).expect("cambios"); + assert_eq!( + after, + vec!["uuid-nuevo".to_string(), "uuid-viejo".to_string()], + "ordenados por versión, y el recopiado ahora cuenta" + ); + } + + #[test] + fn touching_an_item_counts_the_paste() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-cuenta", "pegado", 1) + .expect("insert"); + store.touch(id, 2).expect("uno"); + store.touch(id, 3).expect("dos"); + let count: i64 = store + .db + .query_row("SELECT paste_count FROM items WHERE id = ?1", [id], |row| { + row.get(0) + }) + .expect("consulta"); + assert_eq!(count, 2); + } + #[test] fn a_word_that_is_not_there_finds_nothing() { let store = seeded(); From e15886e00d4fb6c9db7d8e56ba767e08f9400332 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 19:10:04 -0300 Subject: [PATCH 09/18] feat(mac): track the paste destination without observers Hiding the panel with orderOut leaves CopyPaste active, so asking who is frontmost at that moment reads ourselves and the paste returns to the panel. That reason is real and is inherited literally; the observer 2.x uses to work around it is not needed, because the watcher is already looking. The logic is "the last one in front that was not us" and it lives in cp-core with eight tests, while the platform crate only reports who is frontmost. Two improvements fall out of writing it that way: the pid is kept alongside the bundle, which closes finding 24 because identifying a target by bundle alone picks arbitrarily between two windows of the same application, and an application that quits stops being a destination instead of staying pinned. The timer in kCFRunLoopCommonModes turns out not to be an invariant at all. It stops firing during menu tracking because it shares a run loop with the interface; a watcher on its own thread has no such run loop to be blocked by. It is a patch, and it goes away with the architecture that caused it. --- crates/cp-core/src/destination.rs | 128 +++++++++++++++++++++++++++++ crates/cp-core/src/lib.rs | 1 + crates/cp-mac-sys/src/frontmost.rs | 19 +++++ crates/cp-mac-sys/src/lib.rs | 1 + crates/cp-mac/examples/probe.rs | 22 +++++ crates/cp-store/examples/budget.rs | 24 +++++- 6 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 crates/cp-core/src/destination.rs create mode 100644 crates/cp-mac-sys/src/frontmost.rs diff --git a/crates/cp-core/src/destination.rs b/crates/cp-core/src/destination.rs new file mode 100644 index 0000000..167127c --- /dev/null +++ b/crates/cp-core/src/destination.rs @@ -0,0 +1,128 @@ +/// 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, + last_foreign: Option, +} + +impl Tracker { + pub fn new(our_pid: i32) -> Self { + Self { + ours: our_pid, + last_foreign: None, + } + } + + /// 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; + } + self.last_foreign = Some(Destination { + pid, + bundle_id: bundle_id.map(str::to_owned), + }); + } + + /// 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; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn we_are_never_our_own_destination() { + let mut tracker = Tracker::new(100); + tracker.saw(100, Some("dev.rgdevment.copypaste")); + assert!(tracker.destination().is_none()); + } + + #[test] + 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), + Some(200), + "el destino sigue siendo quien estaba antes del panel" + ); + } + + #[test] + fn the_newest_foreign_application_wins() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.TextEdit")); + tracker.saw(300, Some("com.apple.Safari")); + assert_eq!(tracker.destination().map(|one| one.pid), Some(300)); + } + + #[test] + fn two_windows_of_the_same_application_are_told_apart_by_pid() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.Terminal")); + tracker.saw(201, Some("com.apple.Terminal")); + let destination = tracker.destination().expect("hay destino"); + assert_eq!(destination.pid, 201, "la instancia concreta, no el bundle"); + assert_eq!(destination.bundle_id.as_deref(), Some("com.apple.Terminal")); + } + + #[test] + fn an_application_without_a_bundle_is_still_a_destination() { + let mut tracker = Tracker::new(100); + tracker.saw(200, None); + assert_eq!(tracker.destination().map(|one| one.pid), Some(200)); + } + + #[test] + fn a_closed_target_stops_being_one() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.TextEdit")); + tracker.gone(200); + assert!(tracker.destination().is_none()); + } + + #[test] + fn another_application_closing_changes_nothing() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.TextEdit")); + tracker.gone(999); + assert_eq!(tracker.destination().map(|one| one.pid), Some(200)); + } + + #[test] + fn nothing_seen_means_nowhere_to_paste() { + let tracker = Tracker::new(100); + assert!(tracker.destination().is_none()); + } +} diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index f4aab5a..f73db8a 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod destination; pub mod formats; pub mod hash; pub mod item; diff --git a/crates/cp-mac-sys/src/frontmost.rs b/crates/cp-mac-sys/src/frontmost.rs new file mode 100644 index 0000000..733b48d --- /dev/null +++ b/crates/cp-mac-sys/src/frontmost.rs @@ -0,0 +1,19 @@ +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(); + let bundle = app.bundleIdentifier().map(|id| id.to_string()); + Some((pid, bundle)) +} + +/// El pid de este proceso, para que el seguidor sepa a quién ignorar. +pub fn our_pid() -> i32 { + std::process::id() as i32 +} diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index 97863d5..4cc2e6d 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -1,5 +1,6 @@ #![cfg(target_os = "macos")] +pub mod frontmost; pub mod keyboard; pub mod pasteboard; pub mod permissions; diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index cc2d924..3b8b446 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -205,6 +205,28 @@ fn main() -> std::process::ExitCode { Ok(()) }); + failed += check("el destino sobrevive a que el panel tome el frente", || { + use cp_core::destination::Tracker; + + let mut tracker = Tracker::new(cp_mac_sys::frontmost::our_pid()); + let (pid, bundle) = cp_mac_sys::frontmost::frontmost().ok_or("nadie al frente")?; + tracker.saw(pid, bundle.as_deref()); + let target = tracker + .destination() + .ok_or("no se registró destino")? + .clone(); + + // Lo que ocurre al mostrar el panel: pasamos a ser nosotros. + tracker.saw( + cp_mac_sys::frontmost::our_pid(), + Some("dev.rgdevment.copypaste"), + ); + match tracker.destination() { + Some(still) if *still == target => Ok(()), + other => Err(format!("el destino cambió a {other:?}")), + } + }); + let ready = Readiness::probe(); println!(); println!(" permisos:"); diff --git a/crates/cp-store/examples/budget.rs b/crates/cp-store/examples/budget.rs index 383ea9f..911df46 100644 --- a/crates/cp-store/examples/budget.rs +++ b/crates/cp-store/examples/budget.rs @@ -40,10 +40,26 @@ fn main() -> std::process::ExitCode { // corpus artificial, cualquier búsqueda casa con todo y se mide un caso // que no le ocurre a nadie. let vocabulary = [ - "informe", "factura", "reunión", "contraseña", "dirección", "teléfono", - "proyecto", "cliente", "presupuesto", "contrato", "pedido", "entrega", - "https://ejemplo.test/ruta", "SELECT * FROM tabla", "def función():", - "Straße", "encyclopædia", "日本語", "correo@ejemplo.test", "#FF8800", + "informe", + "factura", + "reunión", + "contraseña", + "dirección", + "teléfono", + "proyecto", + "cliente", + "presupuesto", + "contrato", + "pedido", + "entrega", + "https://ejemplo.test/ruta", + "SELECT * FROM tabla", + "def función():", + "Straße", + "encyclopædia", + "日本語", + "correo@ejemplo.test", + "#FF8800", ]; let filling = Instant::now(); for at in 0..ITEMS { From 540081fbfc819b74445591f194a4a81a67dd76e8 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 19:17:51 -0300 Subject: [PATCH 10/18] feat(mac): complete the paste sequence and turn the harness into a battery The paste sequence applies the core state machine against the real system: the keycode is resolved once per layout rather than on every paste, physical modifiers are read from the event source because a global hotkey never reaches the application's keyboard, and the send carries the left-command device bit with 9ms between down and up, without which Chromium deduplicates and pasting "sometimes fails". The harness becomes a battery of 26 numbered cases across six groups: capture and formats, privacy, watcher, keyboard, permissions, destination and paste. F4 is the one that was missing, a real round trip through TextEdit, verified without accessibility by pasting, selecting all, copying and checking what comes back. When another application holds the foreground and open cannot activate the target, the case is skipped with its reason instead of failing: that is the cooperative activation this project already measured, not a core defect. --- crates/cp-core/src/paste.rs | 5 +- crates/cp-mac-sys/src/keystroke.rs | 117 ++++++ crates/cp-mac-sys/src/lib.rs | 1 + crates/cp-mac/examples/probe.rs | 565 ++++++++++++++++++----------- crates/cp-mac/src/lib.rs | 1 + crates/cp-mac/src/paste.rs | 87 +++++ 6 files changed, 563 insertions(+), 213 deletions(-) create mode 100644 crates/cp-mac-sys/src/keystroke.rs create mode 100644 crates/cp-mac/src/paste.rs diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs index d7dc644..df19bbb 100644 --- a/crates/cp-core/src/paste.rs +++ b/crates/cp-core/src/paste.rs @@ -28,6 +28,8 @@ 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, @@ -64,7 +66,7 @@ impl Attempt { } let retriable = matches!( failure, - Failure::ForegroundTimeout | Failure::NoKeyboardFocus + Failure::ForegroundTimeout | Failure::NoKeyboardFocus | Failure::NotForeground ); if !retriable || self.tries >= RACE_RETRIES { return Next::Degrade; @@ -168,6 +170,7 @@ mod properties { fn any_failure() -> impl Strategy { prop_oneof![ Just(Failure::ForegroundTimeout), + Just(Failure::NotForeground), Just(Failure::NoKeyboardFocus), Just(Failure::TargetGone), Just(Failure::SendDenied), diff --git a/crates/cp-mac-sys/src/keystroke.rs b/crates/cp-mac-sys/src/keystroke.rs new file mode 100644 index 0000000..fd9a627 --- /dev/null +++ b/crates/cp-mac-sys/src/keystroke.rs @@ -0,0 +1,117 @@ +//! 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. +unsafe extern "C" { + fn CGEventSourceCreate(state_id: i32) -> CGEventSourceRef; + fn CGEventSourceFlagsState(state_id: i32) -> u64; + fn CGEventSourceSetLocalEventsFilterDuringSuppressionState( + source: CGEventSourceRef, + filter: u32, + state: i32, + ); + fn CGEventCreateKeyboardEvent( + source: CGEventSourceRef, + keycode: u16, + key_down: bool, + ) -> CGEventRef; + fn CGEventSetFlags(event: CGEventRef, flags: u64); + fn CGEventPost(tap: u32, event: CGEventRef); + 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. + 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 +} + +pub struct Keystroke { + source: CGEventSourceRef, +} + +impl Keystroke { + pub fn new() -> Option { + // SAFETY: devuelve +1 y este tipo lo libera al caer. + let source = unsafe { CGEventSourceCreate(COMBINED_SESSION) }; + if source.is_null() { + return None; + } + // SAFETY: `source` acaba de comprobarse no nulo. + unsafe { + CGEventSourceSetLocalEventsFilterDuringSuppressionState( + source, + PERMIT_ALL, + SUPPRESSION_INTERVAL, + ) + }; + Some(Self { source }) + } + + /// Manda la tecla con Command. Entre pulsar 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; + let Some(down) = self.event(keycode, true, flags) else { + return false; + }; + // SAFETY: `down` es un evento válido recién creado. + unsafe { CGEventPost(HID_TAP, down) }; + // SAFETY: ya se posteó, se suelta. + unsafe { CFRelease(down) }; + + std::thread::sleep(std::time::Duration::from_millis(9)); + + let Some(up) = self.event(keycode, false, flags) else { + return false; + }; + // SAFETY: `up` es un evento válido recién creado. + unsafe { CGEventPost(HID_TAP, up) }; + // SAFETY: ya se posteó, se suelta. + unsafe { CFRelease(up) }; + true + } + + fn event(&self, keycode: u16, down: bool, flags: u64) -> Option { + // SAFETY: `self.source` sigue vivo mientras exista este tipo. + let event = unsafe { CGEventCreateKeyboardEvent(self.source, keycode, down) }; + if event.is_null() { + return None; + } + // SAFETY: `event` acaba de comprobarse no nulo. + unsafe { CGEventSetFlags(event, flags) }; + Some(event) + } +} + +impl Drop for Keystroke { + fn drop(&mut self) { + // SAFETY: `source` vino de una función Create y no se ha soltado. + unsafe { CFRelease(self.source) }; + } +} diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index 4cc2e6d..a20dad3 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -2,5 +2,6 @@ pub mod frontmost; pub mod keyboard; +pub mod keystroke; pub mod pasteboard; pub mod permissions; diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 3b8b446..4756e77 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -1,16 +1,51 @@ -//! Arnés de las invariantes que solo se comprueban contra el sistema. +//! 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 de cargo no lo garantiza. Se ejecuta con -//! `cargo run -p cp-mac --example probe`. +//! principal y el runner no lo garantiza, y varias tocan aplicaciones reales. +//! Se ejecuta con `cargo run -p cp-mac --example probe`. +use cp_core::destination::Tracker; use cp_core::formats::Kind; use cp_core::item::Payload; +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::Pasteboard; +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 { @@ -18,42 +53,126 @@ fn main() -> std::process::ExitCode { return std::process::ExitCode::FAILURE; }; let pb = Pasteboard::general(mtm); - let mut failed = 0; + let ready = Readiness::probe(); + let mut b = Battery { + passed: 0, + failed: 0, + skipped: 0, + }; - failed += check("el texto plano va y vuelve", || { - pb.write_text("cp-capture-probe"); - let item = capture(&pb).ok_or("se tomó por contenido secreto")?; + 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)); } - let text = item + match &item .format("public.utf8-plain-text") - .ok_or("falta el texto plano")?; - if text.payload != Payload::Inline(b"cp-capture-probe".to_vec()) { - return Err(format!("llegó {:?}", text.payload)); + .ok_or("falta el texto")? + .payload + { + Payload::Inline(bytes) if bytes == b"cp-a1" => Ok(()), + other => Err(format!("llegó {other:?}")), } - Ok(()) }); - failed += check("los gemelos legados no se guardan dos veces", || { - pb.write_text("cp-twin-probe"); - let item = capture(&pb).ok_or("se tomó por contenido secreto")?; + 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!("hay repetidos: {ids:?}")); + return Err(format!("repetidos: {ids:?}")); } if ids.contains(&"NSStringPboardType") { - return Err("el gemelo legado no colapsó al moderno".into()); + 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.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(()) }); - failed += check("el contador sube de uno en uno", || { + b.group("C · Vigilante"); + + b.case("C1", "el contador sube de uno en uno", || { let before = pb.change_count(); - pb.write_text("cp-count-probe"); + pb.write_text("cp-c1"); let after = pb.change_count(); if after - before != 1 { return Err(format!("saltó de {before} a {after}")); @@ -61,258 +180,280 @@ fn main() -> std::process::ExitCode { Ok(()) }); - failed += check("el vigilante no pierde nada desde su propio hilo", || { - use cp_core::watch::{Seen, Watcher}; - use std::sync::mpsc; + 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(()) + }); - let (tell, hear) = mpsc::channel(); + 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 = 0; + let mut fresh = 0u64; while !stop.load(std::sync::atomic::Ordering::Relaxed) { if let Seen::Fresh { .. } = - watcher.tick(cp_mac_sys::pasteboard::change_count_from_any_thread()) + watcher.tick(pasteboard::change_count_from_any_thread()) { fresh += 1; } - std::thread::sleep(std::time::Duration::from_millis(2)); + std::thread::sleep(Duration::from_millis(2)); } let _ = tell.send((fresh, watcher.missed())); }) }; - - std::thread::sleep(std::time::Duration::from_millis(40)); - let writes = 25; - for round in 0..writes { - pb.write_text(&format!("cp-race-{round}")); - std::thread::sleep(std::time::Duration::from_millis(12)); + 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(std::time::Duration::from_millis(80)); + std::thread::sleep(Duration::from_millis(80)); stop.store(true, std::sync::atomic::Ordering::Relaxed); - watching.join().map_err(|_| "el hilo vigilante murió")?; + watching.join().map_err(|_| "el hilo murió")?; let (fresh, missed) = hear.recv().map_err(|why| why.to_string())?; - - if fresh + missed as i32 != writes { - return Err(format!( - "{writes} escrituras, {fresh} vistas y {missed} contadas como perdidas" - )); - } - if missed > 0 { - println!(" ({missed} de {writes} se perdieron, y se supo)"); + if fresh + missed != 25 { + return Err(format!("{fresh} vistas y {missed} contadas de 25")); } Ok(()) }); - failed += check("cien copias seguidas, ninguna perdida en silencio", || { - use cp_core::watch::{Seen, Watcher}; - - let mut watcher = Watcher::default(); - watcher.tick(pb.change_count()); - let mut seen = 0; - for round in 0..100 { - pb.write_text(&format!("cp-burst-{round}")); - if let Seen::Fresh { .. } = watcher.tick(pb.change_count()) { - seen += 1; + 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:?}")), } - } - if seen + watcher.missed() as i32 != 100 { - return Err(format!( - "100 escrituras, {seen} vistas y {} contadas", - watcher.missed() - )); - } - if watcher.missed() > 0 { - println!(" ({} perdidas, y contadas)", watcher.missed()); - } - Ok(()) - }); + }, + ); - failed += check("un texto de diez megabytes va y vuelve entero", || { - let big = "a".repeat(10 * 1024 * 1024); - pb.write_text(&big); - let item = capture(&pb).ok_or("no se capturó")?; - let text = item - .format("public.utf8-plain-text") - .ok_or("falta el texto")?; - match &text.payload { - Payload::Blob(bytes) if bytes.len() == big.len() => Ok(()), - other => Err(format!( - "llegó {other:?} en vez de un blob de {}", - big.len() - )), - } + 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()) }); - failed += check("un texto vacío sigue siendo un ítem", || { - pb.write_text(""); - match capture(&pb) { - Some(item) => { - if item.formats.is_empty() { - return Err("no se registró ningún formato".into()); - } - Ok(()) + 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}")); } - None => Err("se tomó por contenido secreto".into()), } + Ok(()) }); - failed += check("una copia marcada como secreta no se registra", || { - // Lo que hace un gestor de contraseñas: pone el secreto y lo marca. - pb.write_types(&[ - ("public.utf8-plain-text", "contraseña-que-no-debe-guardarse"), - ("org.nspasteboard.ConcealedType", "1"), - ]); - match capture(&pb) { + 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(item) => Err(format!( - "se capturaron {} formatos de algo marcado como oculto", - item.formats.len() - )), - } - }); + Some(code) => Err(format!("devolvió 0x{code:02X} para un emoji")), + }, + ); - failed += check("el marcador transitorio también se respeta", || { - pb.write_types(&[ - ("public.utf8-plain-text", "algo efímero"), - ("org.nspasteboard.TransientType", "1"), - ]); - if capture(&pb).is_some() { - return Err("lo transitorio no debería registrarse".into()); - } - Ok(()) - }); + b.group("E · Permisos"); - failed += check("un gestor legado también queda cubierto", || { - for marker in [ - "com.agilebits.onepassword", - "net.antelle.keeweb", - "PasswordPboardType", - ] { - pb.write_types(&[("public.utf8-plain-text", "secreto"), (marker, "1")]); - if capture(&pb).is_some() { - return Err(format!("«{marker}» no excluyó el ítem")); - } + 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(()) }); - failed += check("quitar el marcador vuelve a permitir la captura", || { - pb.write_text("esto sí se guarda"); - capture(&pb).ok_or("un texto normal debería capturarse")?; + 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(()) }); - failed += check("el destino sobrevive a que el panel tome el frente", || { - use cp_core::destination::Tracker; - - let mut tracker = Tracker::new(cp_mac_sys::frontmost::our_pid()); - let (pid, bundle) = cp_mac_sys::frontmost::frontmost().ok_or("nadie al frente")?; - tracker.saw(pid, bundle.as_deref()); - let target = tracker - .destination() - .ok_or("no se registró destino")? - .clone(); - - // Lo que ocurre al mostrar el panel: pasamos a ser nosotros. - tracker.saw( - cp_mac_sys::frontmost::our_pid(), - Some("dev.rgdevment.copypaste"), - ); - match tracker.destination() { - Some(still) if *still == target => Ok(()), - other => Err(format!("el destino cambió a {other:?}")), - } - }); + b.group("F · Destino y pegado"); - let ready = Readiness::probe(); - println!(); - println!(" permisos:"); - println!( - " postear eventos (kTCCServicePostEvent) : {}", - ready.can_post - ); - println!( - " accesibilidad (respaldo por menú) : {}", - ready.accessibility - ); - println!( - " input seguro activo ahora : {}", - ready.secure_input + 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(()) + }, ); - println!( - " -> se puede pegar : {}", - ready.can_paste() + + 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(()) + }, ); - println!(); - println!(" teclado:"); - match keyboard::keycode_with_command('v') { - Some(code) => { - println!(" keycode que da «v» con ⌘ : 0x{code:02X}"); - if code == QWERTY_V { - println!(" coincide con QWERTY, así que este layout no era el problema"); + match Paster::new() { + Some(paster) => { + b.case( + "F3", + "el pegador resuelve su keycode al construirse", + || { + 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 { - println!( - " DISTINTO de QWERTY (0x{QWERTY_V:02X}): la 2.x pegaría otra tecla aquí" + b.skip( + "F4", + "pegado real en TextEdit", + "sin permiso para postear eventos", ); } } - None => println!(" el layout activo no produce «v» con ⌘; se usaría 0x{QWERTY_V:02X}"), + None => b.skip("F3", "el pegador se construye", "no hay fuente de eventos"), } println!(); println!( - " layouts instalados: {}", - keyboard::installed_layouts().len() + " {} pasan · {} fallan · {} omitidas", + b.passed, b.failed, b.skipped ); - println!(" el mismo cálculo en otros layouts, sin activarlos:"); - for (name, id) in [ - ("ABC", keyboard::ABC), - ("Dvorak", keyboard::DVORAK), - ("Dvorak-QWERTY ⌘", keyboard::DVORAK_COMMAND_QWERTY), - ("AZERTY (French)", keyboard::AZERTY), - ("QWERTZ (German)", keyboard::QWERTZ), - ("Español ISO", keyboard::SPANISH_ISO), - ("Colemak", keyboard::COLEMAK), - ] { - match keyboard::keycode_with_command_in(id, 'v') { - Some(code) if code == QWERTY_V => { - println!(" {name:18} 0x{code:02X} (igual que QWERTY)") - } - Some(code) => println!(" {name:18} 0x{code:02X} <- AQUÍ la 2.x pega otra tecla"), - None => println!(" {name:18} no instalado"), - } - } - - failed += check("el input seguro no impide pegar", || { - if !ready.can_post && ready.secure_input { - return Err("se está confundiendo input seguro con falta de permiso".into()); - } - Ok(()) - }); - - println!(); - if failed == 0 { - println!("todo en verde"); + if b.failed == 0 { std::process::ExitCode::SUCCESS } else { - println!("{failed} comprobaciones fallaron"); std::process::ExitCode::FAILURE } } -fn check(what: &str, run: impl Fn() -> Result<(), String>) -> u32 { - match run() { - Ok(()) => { - println!(" ok {what}"); - 0 +/// 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. + let mut front = None; + for round in 0..20 { + if round % 5 == 0 { + run_open(&["-a", "TextEdit", path]); } - Err(why) => { - println!(" FALLA {what}: {why}"); - 1 + std::thread::sleep(Duration::from_millis(400)); + if let Some((pid, bundle)) = frontmost::frontmost() + && bundle.as_deref() == Some("com.apple.TextEdit") + { + front = Some((pid, bundle)); + break; } } + let (pid, bundle) = front.ok_or_else(|| { + format!( + "TextEdit no llegó al frente en 8 s; al frente está {:?}", + frontmost::frontmost().and_then(|(_, b)| b) + ) + })?; + let target = cp_core::destination::Destination { + pid, + bundle_id: bundle, + }; + + let marca = format!("CP-PASTE-{}", pb.change_count()); + pb.write_text(&marca); + std::thread::sleep(Duration::from_millis(120)); + + let started = Instant::now(); + match paster.paste_into(&target, || {}) { + cp_mac::paste::Outcome::Degraded(why) => return Err(format!("degradó: {why:?}")), + cp_mac::paste::Outcome::Sent { .. } => {} + } + std::thread::sleep(Duration::from_millis(400)); + + // Seleccionar todo y copiar: lo que vuelva es lo que se pegó. + let keys = cp_mac_sys::keystroke::Keystroke::new().ok_or("sin fuente")?; + keys.command(0x00); + std::thread::sleep(Duration::from_millis(200)); + keys.command(0x08); + std::thread::sleep(Duration::from_millis(400)); + + let back = pb + .data("public.utf8-plain-text") + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + + if back.contains(&marca) { + println!(" (ida y vuelta en {:?})", started.elapsed()); + Ok(()) + } else { + Err(format!("volvió «{}», se esperaba «{marca}»", back.trim())) + } +} + +fn run_open(args: &[&str]) { + let mut command = std::process::Command::new("/usr/bin/open"); + command.args(args); + let _ = command.status(); } diff --git a/crates/cp-mac/src/lib.rs b/crates/cp-mac/src/lib.rs index 03a672b..3df44f3 100644 --- a/crates/cp-mac/src/lib.rs +++ b/crates/cp-mac/src/lib.rs @@ -2,6 +2,7 @@ pub mod capture; pub mod formats; +pub mod paste; /// 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. diff --git a/crates/cp-mac/src/paste.rs b/crates/cp-mac/src/paste.rs new file mode 100644 index 0000000..deb3c27 --- /dev/null +++ b/crates/cp-mac/src/paste.rs @@ -0,0 +1,87 @@ +use cp_core::destination::Destination; +use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase}; +use cp_mac_sys::frontmost; +use cp_mac_sys::keyboard::{self, QWERTY_V}; +use cp_mac_sys::keystroke::{self, Keystroke}; + +#[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), +} + +pub struct Paster { + keys: Keystroke, + keycode: u16, +} + +impl Paster { + pub fn new() -> Option { + Some(Self { + keys: Keystroke::new()?, + // Se resuelve una vez y se guarda: recorrer el layout entero en + // cada pegado sería trabajo repetido para un dato que solo cambia + // al cambiar de teclado. + keycode: keyboard::keycode_with_command('v').unwrap_or(QWERTY_V), + }) + } + + pub fn keycode(&self) -> u16 { + self.keycode + } + + /// 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(); + + while self.focus_of(target) == Focus::Elsewhere { + if attempt.on_failure(Failure::NotForeground) != Next::Retry { + return Outcome::Degraded(Failure::NotForeground); + } + std::thread::sleep(std::time::Duration::from_millis(60)); + } + + // 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 { + std::thread::sleep(std::time::Duration::from_millis(4)); + } + + attempt.sending(); + if self.keys.command(self.keycode) { + Outcome::Sent { + took: started.elapsed(), + } + } else { + Outcome::Degraded(Failure::SendDenied) + } + } + + /// 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, + Some(_) => Focus::Elsewhere, + None => Focus::Unknown, + } + } +} + +/// 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 +} From 4c58ff9cd2102cab0b99efa3f6bd5abfa786b009 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 19:27:06 -0300 Subject: [PATCH 11/18] fix: declare BSL-1.0 and stop caching the keycode cargo-deny had been failing in CI for three pushes: xxhash-rust is BSL-1.0 and the licence was never declared. It fails locally too, so the tool was right and the mistake was running it once and treating the result as permanent. The paster cached its keycode at construction, so changing keyboard layout with the application running kept the stale one, which in Dvorak types a different letter. Resolving it costs 458ns, so the cache bought nothing and carried the bug. Case D5 now checks the paster agrees with the system. --- crates/cp-mac/examples/probe.rs | 32 ++++++++++++++++++++++---------- crates/cp-mac/src/paste.rs | 15 ++++++++------- deny.toml | 1 + 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 4756e77..666c049 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -291,6 +291,22 @@ fn main() -> std::process::ExitCode { }, ); + 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", || { @@ -339,16 +355,12 @@ fn main() -> std::process::ExitCode { match Paster::new() { Some(paster) => { - b.case( - "F3", - "el pegador resuelve su keycode al construirse", - || { - if paster.keycode() == 0 { - return Err("keycode inválido".into()); - } - Ok(()) - }, - ); + 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(()) => { diff --git a/crates/cp-mac/src/paste.rs b/crates/cp-mac/src/paste.rs index deb3c27..ad99d4e 100644 --- a/crates/cp-mac/src/paste.rs +++ b/crates/cp-mac/src/paste.rs @@ -15,22 +15,23 @@ pub enum Outcome { pub struct Paster { keys: Keystroke, - keycode: u16, } impl Paster { pub fn new() -> Option { Some(Self { keys: Keystroke::new()?, - // Se resuelve una vez y se guarda: recorrer el layout entero en - // cada pegado sería trabajo repetido para un dato que solo cambia - // al cambiar de teclado. - keycode: keyboard::keycode_with_command('v').unwrap_or(QWERTY_V), }) } + /// 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 { - self.keycode + keyboard::keycode_with_command('v').unwrap_or(QWERTY_V) } /// La secuencia completa, en el orden que fija `cp_core::paste::ORDER`. @@ -59,7 +60,7 @@ impl Paster { } attempt.sending(); - if self.keys.command(self.keycode) { + if self.keys.command(self.keycode()) { Outcome::Sent { took: started.elapsed(), } diff --git a/deny.toml b/deny.toml index c4dccb2..dbdc946 100644 --- a/deny.toml +++ b/deny.toml @@ -10,6 +10,7 @@ allow = [ "BSD-3-Clause", "ISC", "Unicode-3.0", + "BSL-1.0", "Zlib", ] confidence-threshold = 0.9 From 1778b92cca1abeddfbb174e43a3f136261186cd4 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 19:54:49 -0300 Subject: [PATCH 12/18] feat(core): recover the full content classification from 2.x The core knew three kinds; 2.x distinguishes thirteen, and its tab filter lives off that. Losing it would have removed a whole view of the interface without anyone noticing until it was missed. Recovered the six 2.x actually recognised: email, colour in hex, rgb and hsl, ip, uuid, phone and json. Plus the five it declared and never assigned: link, audio, video, image by extension, and folder. Added code, which 2.x does not detect, with a deliberately conservative heuristic and a test that ordinary prose never trips it. No regex: these are hand-written parsers, because this runs on every copy. Family and Kind are now separate things. Family is what the format tells you -- text, image, files -- and Kind is the fine class the content tells you. They were the same type before, which is why the fine classification had nowhere to live. --- crates/cp-core/src/formats.rs | 23 +- crates/cp-core/src/item.rs | 2 +- crates/cp-core/src/kind.rs | 407 ++++++++++++++++++++++++++++++++ crates/cp-core/src/lib.rs | 4 +- crates/cp-mac/examples/probe.rs | 29 ++- crates/cp-mac/src/capture.rs | 47 +++- crates/cp-mac/src/formats.rs | 8 +- crates/cp-store/src/store.rs | 4 +- 8 files changed, 503 insertions(+), 21 deletions(-) create mode 100644 crates/cp-core/src/kind.rs diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index e4133ac..eba3941 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -9,8 +9,11 @@ pub enum Take { 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 Kind { +pub enum Family { Text, Image, Files, @@ -73,19 +76,19 @@ impl Catalog { /// 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 { + 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(Kind::Files); + return Some(Family::Files); } if known .iter() .any(|id| self.images_by_preference.contains(id)) { - return Some(Kind::Image); + return Some(Family::Image); } if known.iter().any(|id| self.text.contains(id)) { - return Some(Kind::Text); + return Some(Family::Text); } None } @@ -217,7 +220,7 @@ mod tests { #[test] fn classifying_never_discards_the_rest() { let mixed = ["plain/text", "cheap/image", "one/file"]; - assert_eq!(PROBE.classify(&mixed), Some(Kind::Files)); + assert_eq!(PROBE.classify(&mixed), Some(Family::Files)); let kept: Vec<&str> = mixed .iter() .filter(|id| PROBE.decide(id) == Take::Payload) @@ -234,7 +237,7 @@ mod tests { fn an_image_offered_with_its_text_is_still_an_image() { assert_eq!( PROBE.classify(&["plain/text", "cheap/image"]), - Some(Kind::Image) + Some(Family::Image) ); } } @@ -300,15 +303,15 @@ mod properties { let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); let canonical: Vec<&str> = refs.iter().map(|id| PROBE.canonical(id)).collect(); match PROBE.classify(&refs) { - Some(Kind::Files) => { + Some(Family::Files) => { prop_assert!(canonical.iter().any(|id| PROBE.files.contains(id))) } - Some(Kind::Image) => prop_assert!( + Some(Family::Image) => prop_assert!( canonical .iter() .any(|id| PROBE.images_by_preference.contains(id)) ), - Some(Kind::Text) => { + Some(Family::Text) => { prop_assert!(canonical.iter().any(|id| PROBE.text.contains(id))) } None => {} diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index 10123be..b15bc98 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -69,7 +69,7 @@ pub struct Format { /// descarte el resto. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Item { - pub kind: Option, + pub kind: Option, pub formats: Vec, } diff --git a/crates/cp-core/src/kind.rs b/crates/cp-core/src/kind.rs new file mode 100644 index 0000000..a9b951e --- /dev/null +++ b/crates/cp-core/src/kind.rs @@ -0,0 +1,407 @@ +/// 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, + Code, + Json, + Link, + Email, + Phone, + Color, + Ip, + Uuid, + Image, + File, + Folder, + Audio, + Video, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Kind::Text => "text", + Kind::Code => "code", + Kind::Json => "json", + Kind::Link => "link", + Kind::Email => "email", + Kind::Phone => "phone", + Kind::Color => "color", + Kind::Ip => "ip", + Kind::Uuid => "uuid", + Kind::Image => "image", + Kind::File => "file", + Kind::Folder => "folder", + Kind::Audio => "audio", + Kind::Video => "video", + } + } +} + +/// 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() { + return Kind::Text; + } + + if !text.contains('\n') { + if is_email(text) { + return Kind::Email; + } + if is_url(text) { + return Kind::Link; + } + if is_color(text) { + return Kind::Color; + } + if is_ip(text) { + return Kind::Ip; + } + if is_uuid(text) { + return Kind::Uuid; + } + if is_phone(text) { + return Kind::Phone; + } + } + + if is_json(text) { + return Kind::Json; + } + if looks_like_code(text) { + return Kind::Code; + } + 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; + } + let extension = name + .rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) + .unwrap_or_default(); + const AUDIO: &[&str] = &[ + "mp3", "m4a", "aac", "wav", "aiff", "aif", "flac", "ogg", "opus", "wma", + ]; + const VIDEO: &[&str] = &[ + "mp4", "mov", "m4v", "avi", "mkv", "webm", "wmv", "mpg", "mpeg", + ]; + const IMAGE: &[&str] = &[ + "png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "tiff", "tif", "bmp", "svg", "avif", + ]; + if AUDIO.contains(&extension.as_str()) { + return Kind::Audio; + } + if VIDEO.contains(&extension.as_str()) { + return Kind::Video; + } + if IMAGE.contains(&extension.as_str()) { + return Kind::Image; + } + Kind::File +} + +fn is_email(text: &str) -> bool { + let Some((user, host)) = text.split_once('@') else { + return false; + }; + if user.is_empty() || host.len() < 3 || host.contains('@') { + return false; + } + let Some((label, tld)) = host.rsplit_once('.') else { + return false; + }; + let user_ok = user + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._%+-".contains(c)); + let host_ok = !label.is_empty() + && label + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".-".contains(c)); + let tld_ok = tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()); + user_ok && host_ok && tld_ok +} + +fn is_url(text: &str) -> bool { + const SCHEMES: &[&str] = &[ + "http://", "https://", "ftp://", "ftps://", "file://", "ssh://", + ]; + if text.contains(char::is_whitespace) { + return false; + } + SCHEMES + .iter() + .any(|scheme| text.len() > scheme.len() && text.to_ascii_lowercase().starts_with(scheme)) +} + +fn is_color(text: &str) -> bool { + if let Some(hex) = text.strip_prefix('#') { + return matches!(hex.len(), 3 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit()); + } + let lower = text.to_ascii_lowercase(); + for prefix in ["rgba(", "rgb(", "hsla(", "hsl("] { + if let Some(rest) = lower.strip_prefix(prefix) + && let Some(inner) = rest.strip_suffix(')') + { + let parts: Vec<&str> = inner.split(',').map(str::trim).collect(); + let expected = if prefix.starts_with("rgba") || prefix.starts_with("hsla") { + 4 + } else { + 3 + }; + return parts.len() == expected + && parts.iter().all(|part| { + let value = part.trim_end_matches('%'); + !value.is_empty() && value.parse::().is_ok() + }); + } + } + false +} + +fn is_ip(text: &str) -> bool { + let parts: Vec<&str> = text.split('.').collect(); + parts.len() == 4 + && parts.iter().all(|part| { + !part.is_empty() + && part.len() <= 3 + && part.chars().all(|c| c.is_ascii_digit()) + && part.parse::().is_ok_and(|value| value <= 255) + }) +} + +fn is_uuid(text: &str) -> bool { + let groups: Vec<&str> = text.split('-').collect(); + groups.len() == 5 + && [8, 4, 4, 4, 12] + .iter() + .zip(&groups) + .all(|(len, group)| group.len() == *len) + && groups + .iter() + .all(|group| group.chars().all(|c| c.is_ascii_hexdigit())) +} + +fn is_phone(text: &str) -> bool { + let digits = text.chars().filter(char::is_ascii_digit).count(); + if !(7..=15).contains(&digits) { + return false; + } + let shaped = text + .chars() + .all(|c| c.is_ascii_digit() || " ()+-.".contains(c)); + shaped && (text.starts_with('+') || text.starts_with('(') || digits >= 9) +} + +fn is_json(text: &str) -> bool { + let bytes = text.as_bytes(); + let (Some(first), Some(last)) = (bytes.first(), bytes.last()) else { + return false; + }; + if !matches!((first, last), (b'{', b'}') | (b'[', b']')) { + return false; + } + 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; + let mut escaped = false; + for c in text.chars() { + if in_string { + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '"' => in_string = false, + _ => {} + } + continue; + } + match c { + '"' => in_string = true, + '{' | '[' => stack.push(c), + '}' if stack.pop() != Some('{') => return false, + ']' if stack.pop() != Some('[') => return false, + _ => {} + } + } + 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 ", + "def ", + "class ", + "function ", + "const ", + "let ", + "var ", + "import ", + "#include", + "public ", + "private ", + "SELECT ", + "INSERT ", + "UPDATE ", + "=> ", + "->", + "();", + "{}", + "= 2 || (hits >= 1 && lines > 1 && indented > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_classes_2x_already_recognised() { + assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); + assert_eq!(classify_text("#FF8800"), Kind::Color); + assert_eq!(classify_text("rgb(12, 34, 56)"), Kind::Color); + assert_eq!(classify_text("hsla(120, 50%, 50%, 0.5)"), Kind::Color); + assert_eq!(classify_text("192.168.1.1"), Kind::Ip); + assert_eq!( + classify_text("7ab3f6de-1c4b-4f5e-8a2d-9f0e1b2c3d4e"), + Kind::Uuid + ); + assert_eq!(classify_text("+34 600 123 456"), Kind::Phone); + assert_eq!(classify_text(r#"{"a": 1}"#), Kind::Json); + } + + #[test] + fn the_classes_2x_had_a_type_for_but_never_assigned() { + assert_eq!(classify_text("https://ejemplo.test/ruta?q=1"), Kind::Link); + assert_eq!(classify_file("cancion.mp3", false), Kind::Audio); + assert_eq!(classify_file("pelicula.MKV", false), Kind::Video); + assert_eq!(classify_file("foto.heic", false), Kind::Image); + assert_eq!(classify_file("Documentos", true), Kind::Folder); + assert_eq!(classify_file("informe.pdf", false), Kind::File); + } + + #[test] + fn code_is_recognised_which_2x_never_did() { + assert_eq!( + classify_text("fn main() {\n println!(\"hola\");\n}"), + Kind::Code + ); + assert_eq!( + classify_text("def suma(a, b):\n return a + b"), + Kind::Code + ); + assert_eq!( + classify_text("SELECT * FROM tabla WHERE id = 1;\nINSERT INTO otra VALUES (1);"), + Kind::Code + ); + } + + #[test] + fn ordinary_prose_is_never_code() { + for prose in [ + "Esto es una frase normal y corriente.", + "Nos vemos mañana si puedes", + "La reunión es a las cinco, en la sala grande", + "return", // una palabra suelta no basta + ] { + assert_eq!(classify_text(prose), Kind::Text, "«{prose}»"); + } + } + + #[test] + fn something_that_only_looks_like_json_is_not_json() { + for fake in [ + "{esto no es json", + "[1, 2, 3", + "{\"sin cerrar\": \"comilla}", + ] { + assert_ne!(classify_text(fake), Kind::Json, "«{fake}»"); + } + assert_eq!(classify_text("[1, 2, 3]"), Kind::Json); + assert_eq!(classify_text(r#"{"anidado": {"a": [1, 2]}}"#), Kind::Json); + } + + #[test] + fn the_edges_of_each_class() { + assert_eq!(classify_text("#FFF"), Kind::Color); + assert_eq!(classify_text("#FF8800AA"), Kind::Color); + assert_ne!(classify_text("#GGGGGG"), Kind::Color); + assert_ne!(classify_text("256.1.1.1"), Kind::Ip); + assert_ne!(classify_text("1.2.3"), Kind::Ip); + assert_ne!(classify_text("no-es-un-uuid-de-verdad"), Kind::Uuid); + assert_ne!(classify_text("arroba sin@"), Kind::Email); + assert_ne!(classify_text("12345"), Kind::Phone); + } + + #[test] + fn several_lines_never_become_a_single_line_class() { + assert_eq!( + classify_text("alguien@ejemplo.test\notra línea"), + Kind::Text + ); + assert_eq!(classify_text("#FF8800\n#00FF88"), Kind::Text); + } + + #[test] + fn empty_and_blank_are_plain_text() { + assert_eq!(classify_text(""), Kind::Text); + assert_eq!(classify_text(" \n "), Kind::Text); + } + + #[test] + fn every_kind_has_a_stable_name_for_the_database() { + for kind in [ + Kind::Text, + Kind::Code, + Kind::Json, + Kind::Link, + Kind::Email, + Kind::Phone, + Kind::Color, + Kind::Ip, + Kind::Uuid, + Kind::Image, + Kind::File, + Kind::Folder, + Kind::Audio, + Kind::Video, + ] { + assert!(!kind.as_str().is_empty()); + assert_eq!(kind.as_str(), kind.as_str().to_lowercase()); + } + } +} diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index f73db8a..c009d1f 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -2,8 +2,10 @@ pub mod destination; pub mod formats; pub mod hash; pub mod item; +pub mod kind; pub mod paste; pub mod search; pub mod watch; -pub use formats::Kind; +pub use formats::Family; +pub use kind::Kind; diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 666c049..2b4ab65 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -5,8 +5,8 @@ //! Se ejecuta con `cargo run -p cp-mac --example probe`. use cp_core::destination::Tracker; -use cp_core::formats::Kind; 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; @@ -144,6 +144,33 @@ fn main() -> std::process::ExitCode { } }); + 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("B · Privacidad"); for (id, marker) in [ diff --git a/crates/cp-mac/src/capture.rs b/crates/cp-mac/src/capture.rs index 596d660..464b84a 100644 --- a/crates/cp-mac/src/capture.rs +++ b/crates/cp-mac/src/capture.rs @@ -1,6 +1,7 @@ use crate::formats::CATALOG; -use cp_core::formats::Take; +use cp_core::formats::{Family, Take}; use cp_core::item::{Format, Item, Payload}; +use cp_core::kind::{self, Kind}; use cp_mac_sys::pasteboard::Pasteboard; /// Lee todo lo que la fuente ofreció y construye el ítem. @@ -14,7 +15,7 @@ pub fn capture(pb: &Pasteboard) -> Option { return None; } - let kind = CATALOG.classify(&ids); + let family = CATALOG.classify(&ids); let cheapest_image = CATALOG.preferred_image(&ids); let mut formats: Vec = Vec::new(); @@ -47,6 +48,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 }) } @@ -56,3 +61,41 @@ fn is_costlier_twin(id: &str, cheapest: Option<&str>) -> bool { }; CATALOG.images_by_preference.contains(&id) && id != cheapest } + +fn refine(family: Option, formats: &[Format]) -> Option { + match family? { + Family::Image => Some(Kind::Image), + Family::Text => { + let text = formats + .iter() + .find(|one| one.id == "public.utf8-plain-text") + .and_then(|one| match &one.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => { + std::str::from_utf8(bytes).ok() + } + _ => None, + }); + Some(text.map_or(Kind::Text, kind::classify_text)) + } + Family::Files => { + let first = formats + .iter() + .find(|one| one.id == "public.file-url") + .and_then(|one| match &one.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => { + std::str::from_utf8(bytes).ok() + } + _ => None, + }); + Some(match first { + 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 76e0e1f..7a59964 100644 --- a/crates/cp-mac/src/formats.rs +++ b/crates/cp-mac/src/formats.rs @@ -56,7 +56,7 @@ pub const CATALOG: Catalog = Catalog { #[cfg(test)] mod tests { use super::CATALOG; - use cp_core::formats::{Kind, Take}; + 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. @@ -102,7 +102,7 @@ mod tests { .iter() .filter(|id| CATALOG.decide(id) == Take::Payload) .collect(); - assert_eq!(CATALOG.classify(SAFARI), Some(Kind::Text)); + 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()); @@ -112,7 +112,7 @@ mod tests { #[test] fn one_copied_file_does_not_cost_ten_megabytes() { - assert_eq!(CATALOG.classify(FINDER), Some(Kind::Files)); + assert_eq!(CATALOG.classify(FINDER), Some(Family::Files)); assert_eq!(CATALOG.decide("com.apple.icns"), Take::Presence); for opaque in [ "CorePasteboardFlavorType 0x6675726C", @@ -126,7 +126,7 @@ mod tests { #[test] fn preview_gives_up_the_cheap_representation() { assert_eq!(CATALOG.preferred_image(PREVIEW), Some("public.png")); - assert_eq!(CATALOG.classify(PREVIEW), Some(Kind::Image)); + assert_eq!(CATALOG.classify(PREVIEW), Some(Family::Image)); assert_eq!(CATALOG.decide("PVPboardInfoPboardType"), Take::Presence); } diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index c417a1c..3d5a2a2 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -108,7 +108,7 @@ impl Store { created_at: i64, ) -> Result { let hash = cp_core::hash::content_hash(preview.as_bytes()) as i64; - let kind = item.kind.map(|k| format!("{k:?}").to_lowercase()); + let kind = item.kind.map(|k| k.as_str()); self.db.execute( "INSERT INTO items (uuid, kind, preview_text, created_at, modified_at, updated_at, content_hash, search_text) @@ -318,7 +318,7 @@ mod tests { fn sample_item() -> Item { Item { - kind: Some(cp_core::formats::Kind::Text), + kind: Some(cp_core::kind::Kind::Text), formats: vec![ Format { id: "public.utf8-plain-text".into(), From 270560af691ba7c2779ce262098d23c677449009 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 21:34:50 -0300 Subject: [PATCH 13/18] fix: close the six blockers the audit found --- crates/cp-core/src/item.rs | 127 +++++++++++++++++++++ crates/cp-mac-sys/src/frontmost.rs | 22 ++++ crates/cp-mac-sys/src/keystroke.rs | 13 ++- crates/cp-mac-sys/src/lib.rs | 1 + crates/cp-mac-sys/src/pasteboard.rs | 48 +++++++- crates/cp-mac-sys/src/runloop.rs | 28 +++++ crates/cp-mac/examples/probe.rs | 62 ++++++++-- crates/cp-mac/src/capture.rs | 22 +++- crates/cp-mac/src/paste.rs | 33 +++++- crates/cp-store/src/lib.rs | 13 +++ crates/cp-store/src/store.rs | 170 ++++++++++++++++++++++++---- 11 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 crates/cp-mac-sys/src/runloop.rs diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index b15bc98..ed019ed 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -74,6 +74,44 @@ pub struct Item { } 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(); + ordered.sort_by(|a, b| a.id.cmp(&b.id)); + for format in ordered { + match &format.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) => { + mixed.extend_from_slice(format.id.as_bytes()); + mixed.push(0); + mixed.extend_from_slice(bytes); + mixed.push(0); + } + _ => {} + } + } + 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())), + _ => None, + }) + } + pub fn format(&self, id: &str) -> Option<&Format> { self.formats.iter().find(|one| one.id == id) } @@ -144,6 +182,95 @@ mod tests { assert_eq!(item.stored_bytes(), 0, "anotado no es guardado"); } + #[test] + fn two_items_that_look_alike_but_differ_have_different_identities() { + let one = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![1, 2, 3]), + }], + }; + let other = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![1, 2, 4]), + }], + }; + assert_ne!( + one.fingerprint(), + other.fingerprint(), + "dos capturas distintas con el mismo preview vacío" + ); + } + + #[test] + fn the_identity_does_not_depend_on_the_order_of_the_formats() { + let a = Format { + id: "public.rtf".into(), + payload: Payload::Inline(vec![9]), + }; + let b = Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(vec![8]), + }; + let one = Item { + kind: None, + formats: vec![a.clone(), b.clone()], + }; + let other = Item { + kind: None, + formats: vec![b, a], + }; + assert_eq!(one.fingerprint(), other.fingerprint()); + } + + #[test] + fn what_was_only_announced_does_not_change_the_identity() { + let with_note = Item { + kind: None, + formats: vec![ + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hola".to_vec()), + }, + Format { + id: "com.apple.icns".into(), + payload: Payload::Announced { size: Some(999) }, + }, + ], + }; + let without = Item { + kind: None, + formats: vec![Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hola".to_vec()), + }], + }; + assert_eq!(with_note.fingerprint(), without.fingerprint()); + } + + #[test] + fn an_item_says_when_it_needs_a_blob_store() { + let small = Item { + kind: None, + formats: vec![Format { + id: "t".into(), + payload: Payload::Inline(vec![0; 10]), + }], + }; + let big = Item { + kind: None, + formats: vec![Format { + id: "t".into(), + payload: Payload::Blob(vec![0; 10]), + }], + }; + assert!(!small.needs_blob_store()); + assert!(big.needs_blob_store()); + } + #[test] fn only_what_was_really_kept_counts_towards_the_size() { let item = Item { diff --git a/crates/cp-mac-sys/src/frontmost.rs b/crates/cp-mac-sys/src/frontmost.rs index 733b48d..4d90c64 100644 --- a/crates/cp-mac-sys/src/frontmost.rs +++ b/crates/cp-mac-sys/src/frontmost.rs @@ -17,3 +17,25 @@ pub fn frontmost() -> Option<(i32, Option)> { 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/keystroke.rs b/crates/cp-mac-sys/src/keystroke.rs index fd9a627..9ae84ce 100644 --- a/crates/cp-mac-sys/src/keystroke.rs +++ b/crates/cp-mac-sys/src/keystroke.rs @@ -77,9 +77,17 @@ impl Keystroke { /// 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; - let Some(down) = self.event(keycode, true, flags) else { + // Los dos eventos se crean **antes** de postear ninguno. Crear el de + // pulsar, postearlo y fallar luego al crear el de soltar dejaría la + // tecla pulsada a nivel de sistema, con Command encima: el usuario se + // queda con la máquina inutilizable hasta que toque esa tecla. + let (Some(down), Some(up)) = ( + self.event(keycode, true, flags), + self.event(keycode, false, flags), + ) else { return false; }; + // SAFETY: `down` es un evento válido recién creado. unsafe { CGEventPost(HID_TAP, down) }; // SAFETY: ya se posteó, se suelta. @@ -87,9 +95,6 @@ impl Keystroke { std::thread::sleep(std::time::Duration::from_millis(9)); - let Some(up) = self.event(keycode, false, flags) else { - return false; - }; // SAFETY: `up` es un evento válido recién creado. unsafe { CGEventPost(HID_TAP, up) }; // SAFETY: ya se posteó, se suelta. diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index a20dad3..2dcef6a 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -5,3 +5,4 @@ pub mod keyboard; pub mod keystroke; pub mod pasteboard; pub mod permissions; +pub mod runloop; diff --git a/crates/cp-mac-sys/src/pasteboard.rs b/crates/cp-mac-sys/src/pasteboard.rs index 131e86b..3f35c7d 100644 --- a/crates/cp-mac-sys/src/pasteboard.rs +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -1,4 +1,4 @@ -use objc2_app_kit::NSPasteboard; +use objc2_app_kit::{NSPasteboard, NSPasteboardItem, NSPasteboardWriting}; use objc2_foundation::{MainThreadMarker, NSString}; /// El pasteboard del sistema. @@ -42,6 +42,52 @@ impl Pasteboard { 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(); + }; + let name = NSString::from_str(uti); + items + .iter() + .filter_map(|item| item.dataForType(&name).map(|data| data.to_vec())) + .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 + .iter() + .map(|entries| { + let item = NSPasteboardItem::new(); + for (uti, value) in entries { + let name = NSString::from_str(uti); + let text = NSString::from_str(value); + item.setString_forType(&text, &name); + } + item + }) + .collect(); + let refs: Vec<&objc2::runtime::ProtocolObject> = written + .iter() + .map(|item| objc2::runtime::ProtocolObject::from_ref(&**item)) + .collect(); + let array = objc2_foundation::NSArray::from_slice(&refs); + self.inner.writeObjects(&array) + } + pub fn write_text(&self, text: &str) -> bool { self.inner.clearContents(); let name = NSString::from_str("public.utf8-plain-text"); diff --git a/crates/cp-mac-sys/src/runloop.rs b/crates/cp-mac-sys/src/runloop.rs new file mode 100644 index 0000000..c71450e --- /dev/null +++ b/crates/cp-mac-sys/src/runloop.rs @@ -0,0 +1,28 @@ +//! 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. +unsafe extern "C" { + static kCFRunLoopDefaultMode: CFStringRef; + fn CFRunLoopRunInMode( + mode: CFStringRef, + seconds: f64, + return_after_source_handled: bool, + ) -> 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. + unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, seconds, false) }; +} diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 2b4ab65..b5a28c6 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -144,6 +144,38 @@ fn main() -> std::process::ExitCode { } }); + 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("G · Clasificación"); for (id, text, expected) in [ @@ -436,17 +468,27 @@ fn paste_round_trip(pb: &Pasteboard, paster: &Paster) -> Result<(), String> { 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 round in 0..20 { - if round % 5 == 0 { - run_open(&["-a", "TextEdit", path]); - } - std::thread::sleep(Duration::from_millis(400)); - if let Some((pid, bundle)) = frontmost::frontmost() - && bundle.as_deref() == Some("com.apple.TextEdit") - { - front = Some((pid, bundle)); - break; + 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(|| { diff --git a/crates/cp-mac/src/capture.rs b/crates/cp-mac/src/capture.rs index 464b84a..bd3b296 100644 --- a/crates/cp-mac/src/capture.rs +++ b/crates/cp-mac/src/capture.rs @@ -34,6 +34,13 @@ pub fn capture(pb: &Pasteboard) -> Option { // Misma imagen, representación cara: se anota y no se // pide. Medido: 52 veces más grande en el mismo copiado. Payload::Announced { size: None } + } else if canonical == "public.file-url" { + // Copiar tres archivos en Finder son tres ítems en el + // portapapeles, y `dataForType:` solo ve el primero. + match gather_file_urls(pb) { + Some(bytes) => Payload::stored(bytes), + None => Payload::Absent, + } } else { match pb.data(canonical) { Some(bytes) => Payload::stored(bytes), @@ -55,6 +62,19 @@ pub fn capture(pb: &Pasteboard) -> Option { 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() { + return pb.data("public.file-url"); + } + let joined: Vec = each + .iter() + .filter_map(|bytes| String::from_utf8(bytes.clone()).ok()) + .collect(); + (!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; @@ -87,7 +107,7 @@ fn refine(family: Option, formats: &[Format]) -> Option { } _ => None, }); - Some(match first { + Some(match first.and_then(|urls| urls.lines().next()) { Some(url) => { let path = url.trim_end_matches('/'); let name = path.rsplit('/').next().unwrap_or(path); diff --git a/crates/cp-mac/src/paste.rs b/crates/cp-mac/src/paste.rs index ad99d4e..3b545ed 100644 --- a/crates/cp-mac/src/paste.rs +++ b/crates/cp-mac/src/paste.rs @@ -3,6 +3,7 @@ use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase}; use cp_mac_sys::frontmost; use cp_mac_sys::keyboard::{self, QWERTY_V}; use cp_mac_sys::keystroke::{self, Keystroke}; +use cp_mac_sys::runloop; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { @@ -45,18 +46,35 @@ impl Paster { hide_panel(); - while self.focus_of(target) == Focus::Elsewhere { + // 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); + } + + while self.focus_of(target) != Focus::OnTarget { + if !frontmost::is_alive(target.pid) { + return Outcome::Degraded(Failure::TargetGone); + } if attempt.on_failure(Failure::NotForeground) != Next::Retry { return Outcome::Degraded(Failure::NotForeground); } - std::thread::sleep(std::time::Duration::from_millis(60)); + frontmost::bring_to_front(target.pid); + 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 { - std::thread::sleep(std::time::Duration::from_millis(4)); + self.wait(0.004); } attempt.sending(); @@ -69,6 +87,15 @@ 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`. diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index f218662..f7cb503 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -3,3 +3,16 @@ pub mod store; pub use schema::SCHEMA_VERSION; pub use store::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 }, +} + +pub type Result = std::result::Result; diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 3d5a2a2..2be523d 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -1,6 +1,7 @@ +use crate::{Error, Result}; use cp_core::item::{Item, Payload}; use cp_core::search::fold; -use rusqlite::{Connection, OptionalExtension, Result, params}; +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 @@ -80,10 +81,21 @@ impl Store { /// 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. Todo lo demás se va: el contenido en claro, + // su copia plegada en el índice, y las filas de formato con sus bytes. + // Dejarlo era incumplir lo que PRIVACY.md promete al usuario. self.db.execute( - "UPDATE items SET deleted_at = ?2, updated_at = ?2 WHERE id = ?1", + "UPDATE items + SET deleted_at = ?2, updated_at = ?2, + preview_text = '', search_text = '', search_label = '', + search_app = '', label = NULL, app_source = NULL, + thumb_path = NULL, content_hash = 0 + WHERE id = ?1", params![id, at], )?; + self.db + .execute("DELETE FROM item_formats WHERE item_id = ?1", [id])?; Ok(()) } @@ -94,7 +106,7 @@ impl Store { .db .prepare("SELECT uuid FROM items WHERE updated_at > ?1 ORDER BY updated_at")?; let rows = stmt.query_map([version], |row| row.get::<_, String>(0))?; - rows.collect() + Ok(rows.collect::>()?) } /// Guarda el ítem con **todas** sus filas de formato. El `content_hash` @@ -107,7 +119,16 @@ impl Store { preview: &str, created_at: i64, ) -> Result { - let hash = cp_core::hash::content_hash(preview.as_bytes()) as i64; + // Mientras el almacén de blobs no exista, aceptar un `Blob` sería + // guardar la fila con su tamaño y tirar los bytes: el ítem quedaría + // vacío y nadie se enteraría. Es preferible negarse. + if let Some(oversized) = item.oversized_format() { + return Err(Error::NeedsBlobStore { + format: oversized.0, + size: oversized.1, + }); + } + let hash = item.fingerprint() as i64; let kind = item.kind.map(|k| k.as_str()); self.db.execute( "INSERT INTO items (uuid, kind, preview_text, created_at, modified_at, updated_at, @@ -135,13 +156,14 @@ impl Store { pub fn find_by_hash(&self, content: &str) -> Result> { let hash = cp_core::hash::content_hash(content.as_bytes()) as i64; - self.db + Ok(self + .db .query_row( - "SELECT id FROM items WHERE content_hash = ?1 LIMIT 1", + "SELECT id FROM items WHERE content_hash = ?1 AND deleted_at IS NULL LIMIT 1", [hash], |row| row.get(0), ) - .optional() + .optional()?) } pub fn formats_of(&self, id: i64) -> Result> { @@ -149,7 +171,7 @@ impl Store { .db .prepare("SELECT format FROM item_formats WHERE item_id = ?1 ORDER BY format")?; let rows = stmt.query_map([id], |row| row.get::<_, String>(0))?; - rows.collect() + Ok(rows.collect::>()?) } /// Un ítem cuyo archivo ya no existe **no se borra**: se marca. La @@ -166,11 +188,11 @@ impl Store { /// 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 { - self.db.execute( + Ok(self.db.execute( "DELETE FROM items WHERE broken_since IS NOT NULL AND broken_since < ?1 AND pinned = 0", [cutoff], - ) + )?) } pub fn pin(&self, id: i64) -> Result<()> { @@ -179,9 +201,14 @@ 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 { - self.db - .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) + Ok(self.db.query_row( + "SELECT COUNT(*) FROM items WHERE deleted_at IS NULL", + [], + |row| row.get(0), + )?) } /// Cuántas filas pide una lista antes de que el usuario haga scroll. Sin @@ -212,14 +239,16 @@ impl Store { "SELECT items.modified_at, items.preview_text FROM items_fts JOIN items ON items.id = items_fts.rowid - WHERE items_fts MATCH ?1 AND (?2 IS NULL OR items.modified_at < ?2) + WHERE items_fts MATCH ?1 + AND items.deleted_at IS NULL + AND (?2 IS NULL OR items.modified_at < ?2) ORDER BY items.modified_at DESC LIMIT ?3", )?; let rows = stmt.query_map(rusqlite::params![expression, after, limit as i64], |row| { Ok((row.get(0)?, row.get(1)?)) })?; - rows.collect() + Ok(rows.collect::>()?) } pub fn search_page(&self, query: &str, limit: usize, offset: usize) -> Result> { @@ -230,7 +259,7 @@ impl Store { "SELECT items.preview_text FROM items_fts JOIN items ON items.id = items_fts.rowid - WHERE items_fts MATCH ?1 + WHERE items_fts MATCH ?1 AND items.deleted_at IS NULL ORDER BY items.modified_at DESC LIMIT ?2 OFFSET ?3", )?; @@ -238,7 +267,7 @@ impl Store { rusqlite::params![expression, limit as i64, offset as i64], |row| row.get::<_, String>(0), )?; - rows.collect() + Ok(rows.collect::>()?) } } @@ -340,6 +369,53 @@ mod tests { } } + #[test] + fn an_item_too_big_for_the_row_is_refused_not_emptied() { + let store = Store::in_memory().expect("esquema"); + let big = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Blob(vec![0u8; 100_000]), + }], + }; + assert!( + store.insert_item("uuid-grande", &big, "", 1).is_err(), + "mejor negarse que guardar un ítem sin sus bytes" + ); + assert_eq!(store.count().expect("cuenta"), 0); + } + + #[test] + fn two_images_with_no_preview_are_two_items() { + let store = Store::in_memory().expect("esquema"); + let image = |byte: u8| Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![byte; 512]), + }], + }; + store + .insert_item("uuid-a", &image(1), "", 1) + .expect("insert"); + store + .insert_item("uuid-b", &image(2), "", 2) + .expect("insert"); + let hashes: Vec = store + .db + .prepare("SELECT content_hash FROM items ORDER BY id") + .expect("prepara") + .query_map([], |row| row.get(0)) + .expect("consulta") + .map(|row| row.expect("fila")) + .collect(); + assert_ne!( + hashes[0], hashes[1], + "hashear el preview vacío las haría la misma" + ); + } + #[test] fn an_item_keeps_every_format_it_was_offered() { let store = Store::in_memory().expect("esquema"); @@ -708,20 +784,68 @@ mod tests { } #[test] - fn a_deletion_leaves_a_tombstone_for_the_sync_that_will_come() { + fn deleting_hides_the_item_from_everything_the_user_can_see() { + let store = seeded(); + let id = store + .insert_text("uuid-secreto", "contraseña del banco", 500) + .expect("insert"); + let before = store.count().expect("cuenta"); + + store.mark_deleted(id, 600).expect("borra"); + + assert_eq!(store.count().expect("cuenta"), before - 1, "deja de contar"); + assert!( + store.search("contraseña").expect("consulta").is_empty(), + "no puede seguir encontrándose" + ); + assert!( + store + .find_by_hash("contraseña del banco") + .expect("hash") + .is_none(), + "volver a copiarlo debe crear un ítem nuevo, no resucitar la lápida" + ); + } + + #[test] + fn a_deleted_item_leaves_no_content_behind() { let store = Store::in_memory().expect("esquema"); - let id = store.insert_text("uuid-tumba", "se va", 1).expect("insert"); - store.mark_deleted(id, 50).expect("tumba"); + let item = sample_item(); + let id = store + .insert_item("uuid-borrado", &item, "texto en claro", 1) + .expect("insert"); + store.set_label(id, Some("etiqueta"), 2).expect("etiqueta"); + store.mark_deleted(id, 3).expect("borra"); + + let (preview, search, label): (String, String, Option) = store + .db + .query_row( + "SELECT preview_text, search_text, label FROM items WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("consulta"); + assert_eq!(preview, "", "el contenido en claro se va"); + assert_eq!(search, "", "y su copia en el índice también"); + assert_eq!(label, None); assert_eq!( - store.count().expect("cuenta"), - 1, - "la fila sigue, marcada: sin esto una sincronización la resucita" + store.formats_of(id).expect("formatos").len(), + 0, + "los bytes de los formatos se van con el ítem" ); + } + + #[test] + fn the_tombstone_still_tells_the_sync_what_happened() { + let store = Store::in_memory().expect("esquema"); + let id = store.insert_text("uuid-tumba", "se va", 1).expect("insert"); + store.mark_deleted(id, 50).expect("borra"); assert!( store .changed_since(40) .expect("cambios") - .contains(&"uuid-tumba".to_string()) + .contains(&"uuid-tumba".to_string()), + "sin esto, otra máquina lo resucita" ); } From 672b8f6d0fbfbd86484125361dfd1390c339aa30 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 22:09:25 -0300 Subject: [PATCH 14/18] feat(mac): read the text inside images so captures become searchable --- Cargo.lock | 89 +++++++++++++++++++++++ Cargo.toml | 1 + crates/cp-mac-sys/Cargo.toml | 1 + crates/cp-mac-sys/src/lib.rs | 1 + crates/cp-mac-sys/src/ocr.rs | 64 +++++++++++++++++ crates/cp-mac-sys/src/pasteboard.rs | 8 +++ crates/cp-mac/Cargo.toml | 1 + crates/cp-mac/examples/probe.rs | 58 +++++++++++++++ crates/cp-store/src/schema.rs | 18 ++--- crates/cp-store/src/store.rs | 106 +++++++++++++++++++++++++++- fixtures/texto-en-imagen.png | Bin 0 -> 126497 bytes 11 files changed, 338 insertions(+), 9 deletions(-) create mode 100644 crates/cp-mac-sys/src/ocr.rs create mode 100644 fixtures/texto-en-imagen.png diff --git a/Cargo.lock b/Cargo.lock index 0a95d0f..1c74faa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,7 @@ version = "3.0.0" dependencies = [ "cp-core", "cp-mac-sys", + "cp-store", "objc2-foundation", "thiserror", ] @@ -107,6 +108,7 @@ dependencies = [ "objc2-app-kit", "objc2-core-graphics", "objc2-foundation", + "objc2-vision", ] [[package]] @@ -288,6 +290,16 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-av-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -299,6 +311,28 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -347,6 +381,31 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + +[[package]] +name = "objc2-core-ml" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201b055e6acfa0f9f15568255d3f03ce2b54bc86d1814442dc69138e36813e18" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -391,6 +450,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-image-io" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -424,6 +494,25 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-vision" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc194758a2d5d7540b1ad283bfb9ca318ec608991892326e95b428230b2689b" +dependencies = [ + "block2", + "objc2", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-media", + "objc2-core-ml", + "objc2-core-video", + "objc2-foundation", + "objc2-image-io", +] + [[package]] name = "once_cell" version = "1.21.4" diff --git a/Cargo.toml b/Cargo.toml index d65ca83..12f505a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ objc2-foundation = "0.3" objc2-app-kit = "0.3" objc2-core-graphics = "0.3" objc2-application-services = "0.3" +objc2-vision = "0.3" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/cp-mac-sys/Cargo.toml b/crates/cp-mac-sys/Cargo.toml index 9db33da..f7362f2 100644 --- a/crates/cp-mac-sys/Cargo.toml +++ b/crates/cp-mac-sys/Cargo.toml @@ -12,6 +12,7 @@ objc2.workspace = true objc2-foundation.workspace = true objc2-app-kit.workspace = true objc2-core-graphics.workspace = true +objc2-vision.workspace = true [lints.rust] unsafe_code = "allow" diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index 2dcef6a..6d8d321 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -3,6 +3,7 @@ pub mod frontmost; pub mod keyboard; pub mod keystroke; +pub mod ocr; pub mod pasteboard; pub mod permissions; pub mod runloop; diff --git a/crates/cp-mac-sys/src/ocr.rs b/crates/cp-mac-sys/src/ocr.rs new file mode 100644 index 0000000..1b70a33 --- /dev/null +++ b/crates/cp-mac-sys/src/ocr.rs @@ -0,0 +1,64 @@ +//! 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(); + let handler = VNImageRequestHandler::initWithData_options( + VNImageRequestHandler::alloc(), + &data, + &options, + ); + + 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]; + let array = NSArray::from_slice(&requests); + let performed = handler.performRequests_error(&array); + if performed.is_err() { + return None; + } + + let results = request.results()?; + let mut lines = Vec::new(); + for observation in results.iter() { + let candidates = observation.topCandidates(1); + if let Some(best) = candidates.iter().next() { + let text = best.string().to_string(); + if !text.trim().is_empty() { + lines.push(text); + } + } + } + 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 3f35c7d..bc4cb1a 100644 --- a/crates/cp-mac-sys/src/pasteboard.rs +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -88,6 +88,14 @@ 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); + let data = objc2_foundation::NSData::with_bytes(bytes); + self.inner.setData_forType(Some(&data), &name) + } + pub fn write_text(&self, text: &str) -> bool { self.inner.clearContents(); let name = NSString::from_str("public.utf8-plain-text"); diff --git a/crates/cp-mac/Cargo.toml b/crates/cp-mac/Cargo.toml index 3df34ca..9006a08 100644 --- a/crates/cp-mac/Cargo.toml +++ b/crates/cp-mac/Cargo.toml @@ -14,6 +14,7 @@ thiserror.workspace = true objc2-foundation.workspace = true [dev-dependencies] +cp-store.workspace = true cp-core.workspace = true cp-mac-sys.workspace = true objc2-foundation.workspace = true diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index b5a28c6..338dca3 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -203,6 +203,64 @@ fn main() -> std::process::ExitCode { ); } + 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 [ diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs index 386bb32..ba48606 100644 --- a/crates/cp-store/src/schema.rs +++ b/crates/cp-store/src/schema.rs @@ -48,6 +48,7 @@ pub fn create(db: &Connection) -> Result<()> { search_text TEXT NOT NULL DEFAULT '', search_label TEXT NOT NULL DEFAULT '', search_app TEXT NOT NULL DEFAULT '', + search_ocr TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL, deleted_at INTEGER ); @@ -77,24 +78,25 @@ pub fn create(db: &Connection) -> Result<()> { search_text, search_label, search_app, + search_ocr, content = 'items', content_rowid = 'id', tokenize = 'unicode61 remove_diacritics 2' ); CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN - INSERT INTO items_fts(rowid, search_text, search_label, search_app) - VALUES (new.id, new.search_text, new.search_label, new.search_app); + INSERT INTO items_fts(rowid, search_text, search_label, search_app, search_ocr) + VALUES (new.id, new.search_text, new.search_label, new.search_app, new.search_ocr); END; CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN - INSERT INTO items_fts(items_fts, rowid, search_text, search_label, search_app) - VALUES ('delete', old.id, old.search_text, old.search_label, old.search_app); + INSERT INTO items_fts(items_fts, rowid, search_text, search_label, search_app, search_ocr) + VALUES ('delete', old.id, old.search_text, old.search_label, old.search_app, old.search_ocr); END; CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN - INSERT INTO items_fts(items_fts, rowid, search_text, search_label, search_app) - VALUES ('delete', old.id, old.search_text, old.search_label, old.search_app); - INSERT INTO items_fts(rowid, search_text, search_label, search_app) - VALUES (new.id, new.search_text, new.search_label, new.search_app); + INSERT INTO items_fts(items_fts, rowid, search_text, search_label, search_app, search_ocr) + VALUES ('delete', old.id, old.search_text, old.search_label, old.search_app, old.search_ocr); + INSERT INTO items_fts(rowid, search_text, search_label, search_app, search_ocr) + VALUES (new.id, new.search_text, new.search_label, new.search_app, new.search_ocr); END; "#, ) diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 2be523d..1b0b97d 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -61,6 +61,33 @@ impl Store { Ok(()) } + /// 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", + params![id, fold(text), at], + )?; + 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 + WHERE kind = 'image' AND search_ocr = '' AND deleted_at IS NULL + ORDER BY modified_at DESC + LIMIT ?1", + )?; + let rows = stmt.query_map([limit as i64], |row| row.get(0))?; + Ok(rows.collect::>()?) + } + /// 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( @@ -89,7 +116,7 @@ impl Store { "UPDATE items SET deleted_at = ?2, updated_at = ?2, preview_text = '', search_text = '', search_label = '', - search_app = '', label = NULL, app_source = NULL, + search_app = '', search_ocr = '', label = NULL, app_source = NULL, thumb_path = NULL, content_hash = 0 WHERE id = ?1", params![id, at], @@ -887,6 +914,83 @@ mod tests { assert_eq!(count, 2); } + #[test] + fn an_image_becomes_findable_by_what_is_written_inside_it() { + let store = Store::in_memory().expect("esquema"); + let image = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![137, 80, 78, 71]), + }], + }; + let id = store + .insert_item("uuid-captura", &image, "", 1) + .expect("insert"); + + assert!( + store.search("pedido").expect("consulta").is_empty(), + "todavía no se le ha pasado el OCR" + ); + assert_eq!(store.pending_ocr(10).expect("pendientes"), vec![id]); + + store + .set_ocr_text(id, "Pedido AB-4417 entrega 12 marzo", 2) + .expect("ocr"); + + assert_eq!( + store.search("pedido").expect("consulta").len(), + 1, + "una captura de pantalla se encuentra por lo que pone dentro" + ); + assert_eq!(store.search("AB-4417").expect("consulta").len(), 1); + assert!( + store.pending_ocr(10).expect("pendientes").is_empty(), + "ya no está pendiente" + ); + } + + #[test] + fn the_ocr_text_is_folded_like_everything_else() { + let store = Store::in_memory().expect("esquema"); + let image = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![1]), + }], + }; + let id = store + .insert_item("uuid-tilde", &image, "", 1) + .expect("insert"); + store.set_ocr_text(id, "Reunión en Múnich", 2).expect("ocr"); + assert_eq!(store.search("reunion").expect("consulta").len(), 1); + assert_eq!(store.search("munich").expect("consulta").len(), 1); + } + + #[test] + fn deleting_takes_the_recognised_text_with_it() { + let store = Store::in_memory().expect("esquema"); + let image = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![1]), + }], + }; + let id = store + .insert_item("uuid-secreta", &image, "", 1) + .expect("insert"); + store + .set_ocr_text(id, "clave de recuperación 8842", 2) + .expect("ocr"); + store.mark_deleted(id, 3).expect("borra"); + assert!( + store.search("recuperacion").expect("consulta").is_empty(), + "lo leído dentro de la imagen también es contenido del usuario" + ); + } + #[test] fn a_word_that_is_not_there_finds_nothing() { let store = seeded(); diff --git a/fixtures/texto-en-imagen.png b/fixtures/texto-en-imagen.png new file mode 100644 index 0000000000000000000000000000000000000000..e2b0f629a2983a4dcdd949aa9ff7113b67709596 GIT binary patch literal 126497 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

&u{Mk$s zmg^cQyT!VEZqo7w_)sEJC+Hk&EYENc#=NO%RDbY@{lnbfwfyn>LlHz!Ye4k#sqw&2 z6G!Gjh-y-+I$_DtS&}Ug7ASl5`rB`83TiXF|D3aU{Yj9)QBqTB6$req9;AGp2ujAP zDe*wUQGMR~!QUv>g{T&9!|P2y3>=B2n6d`5BsSxPL{utBu5f(SzlQ>fbVtA1xcxBr zLiBXkC&tQ37#wVJaJs>Mljiq)L+xjAWtlN&S+Naq-8WNmF}y7&*eDw2SK9p%b5_jG zk-%&{{d}Q67S`reHIXmKuDH*uMedOFY^ce(>VxW0ra$q%GSaz6L4lU&P^+7s@Ab!z ze57u3e+fNfd7`uyJfG9$Q|(mIt0JuN_mci|pj-_xT@$Qu_JGTCSy)v^ZE4Fr;bvnM z@rl2G<*qhSOLaMN0?cZat)n(3fEdoM7=ZZ~<{2BVKN=~Bme694+m36ze(`tAdt}@J zs5Ru6`?hiqd3z*w%&0HsF>qbr?3^kE7wv8_)kfQvA$x6C|DXz@@ShY!1I01t$i|_V zNr&Rl|FCVF+C&}Bp_DS+?aEOXu-<8e{*ZlEDC|h2&KQH`48+jf&RdA32{bxJo0EAl#aj@brNf37e+3<1Ti_pT{;^(bf(OR-~0khxFH7w7NB2w$!O+)H_ z+BftK$px;C(3%b@g{jN*^`KXQM9?7dLo zngeRW&eq?R$Os|Gj(o$)0aZQsrFX6XyuW>;8Ycm8#4#T3E}g$~OU!Uz*odixm|c#N zVt~tacJP(l-m9v!{c;>1Ay$pmr;UTj~>LOWQbZFX{&oCg<-K zM$fdx{$c$CIhxV2KE8IKp|Abz@iSv8t~_I?RL7kPgZ_wK03&D-l;PWQOmF*hNho?F zSo@qFjI(j-4aUoUC#0S_`)BHc%a>;jNvly892ASYLhDW|S^vtGgKMDYWvJ~&v7D*n z{grj4K|VP*=){A-FEhwjZihQ6E)dY;tG%^@#<#RXzrHZ$U3+$kVJO^+vqzytUV8z! z)Ga<2z^0+0&+%3M>zB8U9=JRe234G>@Ro3yOOl^!OUSz)H+=q7x;?|403|U2uT$Xd zE!C!7B&7lN9uCxTs^6%ZxAOCQ=?Hop9@a^_qR?B%5jh<(_#Bxzpm3sguq%E&(ck7t z!jVHr+J|cz!gmy!B9pUFK5W1I-uF+UwW#=54PV>WoV@n9LK8h_^`7|>lk{hUgJl{K zg?=kv?9#sazRQ6Q-A~$x-k^@}o+trx1Hv)z~3> z;#+>k^B1^-$|MriQ+%ATv&o*zJC17Q++vH zNbtUI`{Y9%p|9M%Mf^%F3xTiuso&jY=ogl9$`nW83oRijtZ-KYe7YJX^ya)1qxyt-ICd-^El*PijYXkz;A6 z+Rtw*7?p)jG7CQ?4albW%g;{=1)e-X*?76m_a|`Qg4?l$pY|ylk1;cX`5K@{;58K* z^kZeYy(l*1!kXf4+oh)cDlby1LC%gPtaQd;1o!$oUy5MGpFd=OmBnj3n6&u746=V=0w6i#w) z5ItFi^?TSJ*_n*zV3R1_m2MSubZz^&c_c{Fy!MUB?8k#xC|l7fJUJQ)PE(v?G4u2` zyu2pq9D!YtCRwou;C#Gbv6n)uLsoGvfMB|Mu`vE zMpi?1U4s{oJgmIJw%o6GMWB~>@Pjo)KjREr)+Gqw*iNJ7gZQdNEbvTC4&c`YM{peC zHtFo7*FFvUYx?pEJnRN6V5742)rydG*q7rDvmoTwdUgok@q#S>Utbnei1+O(#}Y%Y z?T7tA*mcWo$;;E_b>c=>ps)yf@xyI-9^DhsKRC19N6NP0!2-M^*P~b{*ImwrlbiSV z!?5skS0GB}bH7u>ygt-4z({`bhLiCkxmN0rPbZq`H!p>rdu4koO%hW$@W{ey7Oq~7 z|LpVbx4J6#Q^;|7g_4shXDmY&+d>?FPv4o2nWZD=8A6}@mfIg*yja*W{al6exI@IX z;VFIh8;-AEi{kVf2iNZ3W_-t9ku4QPFuIlD6ow?7f2RFMstBY-Q6CBIi4<~Kx;*(Z zI&cBs!Y%(b-AggW_*mUrB`?6dCr*3!FIVeED-7aiocHbOnyv(Af=p>_TTaL4fcvI_ z3~4|e-OL?Pg@4PlSxu+~pK=LiU%)l|qoZY>d;HnQO&-0gr)(n|N!gZ@-aU32+s&3G z24CYf{Kzw7do=6Ftd16QOn>HexfBf+5~j-WZAFfAM4};a|2B_`R1 z4irxSnuk@BOwG1E=2VdJh2Whbl_Kb-KJ>TG2I(4aXS%?^YxXxKQ#z zXyU&_DeeerrPnK``A`gW{`=3hqQF(&FSnNuzw;K#JH;u;IXnRO^?nkR1VbV}zYyoSqAK8MW zhmngxzsKF^oEXg5$K9f;Z&$svZqUXstQ*sAt-R!%-;!G!+Q=#zqaXbqzP;quE$7>L zHuzn+*@pb*%7yQYX#sk|V9@UDLskcn{OOVqciAllH`W2v=JR54+#n2PoBWRvZQdvO zqiw4uwR|wzpeHjnJ>*^`0U{NNYkC+$DY1D*kL9PPA@(YuwA!uj*d6=v4gAVWWlu$*$6;If#SYuba`UBC6!&-^}B zSiUbpt&n$viP97>`Ib#S5>+w{E=B`Sk8w}FXb0EHvNfyia_8d%M62%7n-%qCAKy#$ zR?hvNeE|1Vg18Wu=PNS?`Reu9=E*1Ie5laNh90qzf&=O%4*z}(zRG>95|5JLlw>Q4 zT0FmEQ77Ov)GCYCB3bmu?VPa|4eQ58&BZyUQ#$9yS|)tqQeTSkzZbtJ7_>Z;OFj+i zB@`NY!=Lql-7vQ3b8EY$pR!fZ0AL#{ou$hkI?s$t=wM$9ds=Oj+MlSj$e{i{> z`@zr*i>9D}(-QCVpwPnmXL%!_~hwon@-src=U6OuQ&93>nM~aw16zY$gxm8^%JDhx=N`&iV znNCnMip)OUuo$!#tnIu%SEYveS@OuvT<9X<(>9*dm0w22aJ(PR5c3HAp9oKod@#?S ztXli5F^#Sg=-zb2Jfj5YskkC@JuvJ0I>$gNx8YJnU-EfX>BxHFWkkR%Cw0>Kv2`!a zf;rJ6+fmGt(Isuyb9A_$*RVLAeT9>b?A|kM3<&@Lw*w*?rP$ zpSVGqG-W4Bx$m#n-^C<6Y2%n{nDQ1Q3YMY0OSf%D+dkg-{YzM&H1e>rg#Y(LbsTK$ zYg37m?A$U-=8~JS)OWM)#pA~xjlypr=?eT|5MVB>%_u9zMK-S(oe=N6%3#T;BS~TZ*`P*mV4$G-gsSk!ktn?R6 zWxjWC5IPjf@l*4?pc95Zvvli>hvl8ekv9aS6E43zc^N|VscwS|e-7jzN`FCOl4ZwTxnJM)r{L$x;w=HqnvOekB? zw)`OniWy6}lRD=gFkRDbqt4=lZeWt0g$ql35rtgA?sUHuXStoQbku$eqR5A)P5a2TiNURZyY_4uRNC4w znLeR>E&A|=;()M|E6KYZ)@-mp=f%r3eu+Is6glnAGA6P#L?)bHYvqqyvmc@UvV(|K za7rvRE5law2tOZ($?}fg^Q${FFX_dztn1?M)f$3b^GLOO^3%b~(H0jK?+f~Y9|PNv{&xNlhmCntz3M_$_5rB@gYzT77b@OLCV3w` zkaIP3--p}x4t=x})V-W{{(&`VJ}{uB6-i&xR@$0rn5JyZXLyi&XtM-1$+e{c&Kk+4 zObFBc-UlK? zzxeb-)k%iQ#Cxt~2*LyjSY>lfZ^;KSW`}aE%Y@_)mqA|;j2k}PW}(+9)pN=CpSHrK zkOJ|HX}j^Z;1M}_-kD=Pn63oTD8n+Xsaw2Ru@nFjk3JCxxpK~s=l`?DyuC6NLXeqr!G(iD+;s&clL93q)wg=33`2RV`P7D-U zmXompsba5?GRTh;jnNG?j8C|r5Zu(;2OYxa-J@E+?aLMFs{bPIpMf9+{&XaixQsIy z#B3H5TX{KiU%b!Tphv6l0l)~8NQ|XdZ1cIXNWRTt`NM`#tESC)QuK)o45S`1LdgPw zRnn5xA466@IH8l%fMCOqv~0Y5Oc;C2Z(@-cc6kkY^lAM$et-6wT#(|J+!EqBCT&?U zmaQX#bx7tbOdxtay+lD;i%+sXOmjm`H$zEPu&$&oB;GydWB0k~)SHkxEpzp^b9JuD(q+YAbmFAO66`uYuVfNPUpY#&zaWgiBC zqI55XS_Uc7?y5GGYrp1H23@sT3bo?Q%WV%A@mml^2W(3Fn~)FWM;4e#a{tzNlQMyf z<7&kZd_Zanum7!S25p!2SL(@A*+s))CWdh&dt{wuSmbxUnOizb-)SY)8nh(u z5~koK2y-AT-?V`&IP3^xIdsnSPcimkf--r9KyWImS_4U6^LVl0I<7r5?K<8&HtHT9eGP1d`PqHKp*;%-Qp zXWDII+w+4;pPo#rRBeR>St5lz5U{VY_bm}_O!ry>D)W5I^?&tQn7(2lpkWdCfmDNv z%1_Sw{b1-{r_x0Tu3yC#NEp^KPMLi}%k2UlcnW4CelM;nBX(1FyRu*gtKZ*KGymy{oMN2qjOkIXg#YR7NC!g2g$mX!>vvDdGfJU}gD?gmib5j#-dT9K@0*IujNqo9P9F2asd!+i;*Yn>ZcD|u3%ons5S)((o9m-b z?f_o80c(W?j}!vuxfflD0pm2!DaG1IxZ;Xpuf^;Iqy>_qYFLe7Z7c~W;5342wIG9- zQbUc^7i1Sg|D$BYO33iuV%s;+g3aEuFz>%Q;k-{9qj{_Nw*0*@K`e&9&c!xR{Qo|#`XkLlzuPE?n@>T#IIZH__w9J6=!q2~2X8P$*2 z#apFf)#9$A$mTD@deFY|)t9g=Ox}BDAK>b_YmQyCim%Y!#9B)=vYFl8s@jA%h^rOb zKn=eRI9Yq!r>nC-G=X%o3U+!7KGp{s2+bb5(>Lk=&*h7cghC(0W0h{`M>c9d+vu9e zA?GlSP&(dyy?vL*7Po*g2E3P0mtY7NU)02PS+bZ!1shtaxL5T}|1P5}o7(q&gM9@4 zp!(4u1l_kjh=ulGdc}0s%ex&NO2YG;{R0#kGtgGu!3W=CA$uBhD^E|6%n7GA=C87tpU$h)gEh77cam#<^v!IYn!CehdBY6Su$8vRB=8b8j zX_Lb7un#RRfA%d~=ao`$9@BZ{Z@X6ZAGCZ&!K~;hT&dkGe@eu0Thm*_QAr5)?i8Ott)wl&(IU%wUks(Nq+y%z`c zYL(!xgS5bY+F!b3tu*SB#%{P_;Dwm-tj=v6@V5VZulAf-DzKDI3PSHWjl$fby`u$+ zABI^X7j1j6i^Jm0c#RGZ)~@TGcfGs?1-X`h(kk1Qfj+B#;iw4aC0jd$W$4%VaCWhIE%d$6x%6sB1W5N-hV}?u5ZP`D%XLv4K2QC*nWfY z-EFv9=Wex0A$`9!JJgoeL~E4PzaY=&$0|i-p_eG$tY*4BN^0AN)}nR-+IOXN2^Hw> z>Rl2*iDmq5tDH5Ifwg%&(n<7O?@@;Ht1Vy3*a`UK|1Xhbi(M4!gEw$x;$kX$ZeZ9{ z`iM8J9=9=m5JJBh87s$L%1n2ExukaR$j^i5p>rT>{rrgaKxt*0!pnWct!V4IpJtwYSnrs%tqj^*a=X=dKj!O(w*Lx*z65d&GeHID)_ecZ7)O@Jk&FU=eoF#Y z@mrH+JQ1u#0+#<|qbvyiTOs0F@{h~c!ypSy(0>Cs%LM9NTwLKpd8bcjdVbtllmTCo z9b&2OtWJ+Hj}^+^jWd~}=;y6`)<`d4YIawJD+VmkTIxiU$c|ye9iyQuW1bFEBqofY zL3MTfS=HNM5y9!b%g^#Ei5MN-jmphtM&kPxqd)uZK20n;26WSo{OeU7x-LnUdS9#? ze-X(UwLPmx^|1pyJ1>@{6RcK`Pqi9;RNNIr!H7NMHaZRdM-NU4C}Luw;rM+Sv8S>7 zYfw#ItV#@ZauwDcn^e=S7BaNL{k}FlyDC4#iv^5~J*cR=k5gVs#=rKbe!N-JIrlsfEthanB7=P?M;Ub);AJONrs2NGJDJWd7q>wav z&>P3W3W_OO1@p)~j$#VY7Wb=>#^q7l$#{8<92YDBaK4nfg3;QWXb&-#G-4S1jDKd# zTb6;D>^=eA7Jw?Uw)nx%QIZE&&1^lAzg>EqZq4<~LYSXb-N!LJb5Utr1%vSP&!-yT z85g>Joj2!|_-|Y_yP458q+jPtR(9`f=R);bEZZadyI&X zM?-xx{w5CI^=#V|PSrxP=AjWomHKlY@K4|7XC-!HdgQ@TazDCOG%q6-kFdEc1`;8u zUlOjyNIW(TWMk@feyy4!siu-Yh_!f)AXEc!tE8g69+jE&s&0W2o6u^k;;;QlfWr!y zYG`l5du}kNnlE-{g5SpS+c8`TS4wG4K2CMtW~Vv|&^rRA<2_#Uv05k5il+GN9yIFB zP~NkRd~ua1oJCZ+z_|y5)4Eq}Yvl#QceB9l*ksa0@!Y$pl$*hT5u2)W8~2!vMFQAi z5~~_ljSj7$&j?N3uMOC#8Z;gP`TH>5n&XTDl%Wl!yS{gam?=(<3uFFu(fA9rB#3`- zLEPp$c)3~WfUM4Q}IUUx> zPUKO~*81(mrlurM?J|5|^zi4>?mF&wsoF(Yxs6eV{-Z(Tgp`+dn(rXpipx_6xw(K)x=V_~-wV z!ELYjejh`Kf?c|uag_jK5Q>~3VBQx7rcWo>!P2n@wn<}3V*=%!=sCM z`M~Oah#34Wb2W4N>!bKH_(JS{Iwa(-&!bbie0uLZXSBwtsC*O3SMm}xevUwP(BnUi*e!^QO&%G6Drw5H0J-@|31I%EP7 zn#I$M_?6k(30DN`9?o%5(%CT3hEdb#ohb3ui)E-P_d&Ptrpj0V-gg5S5ilh^uh#PM z@3!D9S~kXlbqJjpm9kL3{?` z)qTi}+8qruU>>MmhO9>qcBLE{IVU+DKWpIUJ2mw&jKalhd&u!xT8ii!SIkYNPa?|% z*O2bbdXVK~2yQ=({WYwq^vs9FOtuxv#CBY$brwhZKY?Ds907mf=+Ub?!(`&gYt@yL zCPyY0a6#BkbG)xO(kB7J=N}e`+R7yPC!ZD9*YZzC?1=00@GA-%I})HrM6{oIzX8|# zXVtBMO&?Mx=(Boou3U`7U|j;VmI3^__|6|L1J#D+RJD(@MG2z+I2pMk->wEylMX*3 z=xAtaPdeC+8oUz3@4=$h4`K*|I7ev14lmBlFg1uofrLpT`jVId{MSB8{G+&yf#(63 z7Xdat;pKi5_q*g)&*yb!^!10*>TteS=jX(}mUbk{yG!l~VgZpSZwpgxo1U zJ4>#r?uwNQDvc>gSUt@Zf%GWR+|XEtIuKp&xKU3v@MKpKIn^Bhg&)S2P*5rV7E}o0 z!*eP+PzyxR5=e=_k>Q>(T*xRA5@3#`&SPh3s3kM3qS!hRJ*&pugA`+E#G7K6iNgX* z`rCiNSxWbbQNO4sU^Nw1Y9Z*~0-+pu% zr5TlU4l=g);|zhHe`nkk(U=sFg|Rvj^=S|;zt;mj;GqrLT@~}jFu3|>=@X)8y2mUQ zK7L5%6UgernS05zc&rlkLbf6Lq(k?8AiNa*-S&6MioD{1>>x&Rs-nL(N&|y9Jmv_a zOs>|A)tAv7RBF9Q07TL(WN`7S*rZz5M08)%_IDW4Z)$3G^llrYxYXG_&Raep6M6*g zai)ZZtlLOKj+%cO-y^}&_tnD#%7fVTGsw5ju}6Qkc4eY$JCvxgtd&>;%(jR^0$N@j-KpcTo#dev~z)iJ2TdgfF0j~IS1OO{IunVLu;jyyTjfl+>Szr<(o{GE|s zwPrWJYA8?j<=(gCx+djv+W16JRPL{%y1OKgb2^hex>2WD`^q{1olio>%}J%tm?aT5 z?en(SQ(;Db1XD3Q8U}=G1T?>E)^?QrBswLF@jg$0ivBXdl4tX;k*Qz zllVK^%?S|sQ9fuJl?h#cg_A({HJKw8`SE-2=ij) z)U+dbRBM{4`CQst&E9kw8-$oI27ofYClt6QLM}yg+v?-7T=Xx=O?R61)={BFE#dc8 zrThgfjTks00Ph9G}~z%=Qrzt(#*V!`a0l@q4XC5<5XYP zldk=|H6Q~Y{JKPOdc*_a_U+KQ$e|i}M%dkr+fP<)TH-k68&phZKRCzVWX|SL8wKb# z)?o8$_7f4@5x=9&_!CG@nN?~yl1)TYx8aXXWIZfB2ldFCgbH;0HgadBa&dOqJ|7kj zdkbCt?x-prJ6@y^v7eR>+9~Yu)r&3hGPU=I&zz8 zG#HZn_@e}N8m{B=c`z3e-0!+g?y-M_A$MbJwtvw89Vuz;4rT!@&Cs_6zk#K=xYAmJ z*sWxcB@_hid`WH?Z>j*!Q$!wzTufI#3TN-=7wvGhic?mwf4>Dxxh&R;VC(-n6qQq+8(Ht>LM~PzX!H0mB}G$L_(9aSLTSV`_8J zbR!gN0f&Y6H}=7_Ke#-KZJZ?O$!i>L%p*MxUt!di4U=gh1P-;UV(9^v!{~JGuK{k9z!Y~)3PRz zunXAWPMS6{-6}R))JQZw<}KHX?v(Z2uShcWBH3w9ZaBF!c4V^39!T7r6i#oZGK+EH ztbEnRPOwVe%84w29zlv}^j&1`FQK4=_yX9YTr{>~ZhtEyQEcL7K(M0<_>9+W6`RVh z=zHSADNX%xeRj%$JG^{~CGaaBfMNKeETKme`w>6DqqtGugKG4$NpvzR9F^`={EiJz zfe1_%c3CQg0TuZ1c>uF_GByq}S?lY+lM9g}8LPE&IUCW{={o&AV#$R6^dJpmgQoS! zxu=plV&i}e;LqU1f(DauKJF0|GKftya`+@U%v}9kJCXF3H*8RZzL-!^}?r20$oInn&D`Fs1@vGnXgFdr<;*3ztf2)fvA>zlD54xMj znjSI8y?3Dlsu^?eFD_KbTCI_iNlHVk6=b~s_Q#}q5JF!xafJdXNOh@LPcd}+<6H_ zmBae=rXVw(sG~ndlszD##SAQqx-j6A_0Js*9rKR2EKKFQKMU#jY*01tuD3wK zA^d5p}HPxq4aaiiS>~nr$+568r zXAX_%nqoqF(OGuIA>;pS*$cmL0CS7M0l3uzX2U-8aPAv!kYo(foS$nz366h zAF@Y{>D&t*trtQh&v3(No_`;rpnTnbW{J4V1sO`(oA6T#$$Sm^6#JHPD7HrKUbv`f>w9l_!$KUqCIK?0Xzi7 zwnjt^S0_@S7eQuwJgtGyIPBA1!0RkR5veL~54M~B7~`pci`HO`60KEPyCk5BRg<$j zR#COL+t`N_kYA8i$4Rn>?!T$cVW=JvkxA6;U+zzkm&hWZ?Hz+6ao-kT$A(M!Z2oWC z(utksn$%>^*xO-lAmK9#?0Nqm6zqNHUjBL>MB8%m`(@+#;5-lf}vs=u^u9u zYvy>c&!;JOn$+OopT>5UgBCT^ioHgxmY_HgK^Ej%Wgo7qBjfESS=Nb*(BU z-fShU@26kUEa9iNbd~&tlD`=8)(?Fac9lg6Ky7W2SIfxf4OBf#J9sJ>R4WT~0%<=o zI&giBqf!^0l>+W48^EGM%az&GadHp9L!7m@?#5K~X*n{RA%S0jSJIdapv&|zNN%Eu z_f2-3CG%7E=dF8K8tTaJ)?jr;ZRV+DB85LuNYL&2rsAjHF)}LPj!-sq8_LEmq_B>9 zqH&n3{0OYK-6+NtXb&(@1%D|dNS=#&U{wBqESVPaK(TK@0gcIu@{XB3J4nS_Hg?Kh z*sRh+D`A=hHl$UXRy~^ndwYU518}SJ{K#?7LA?NnQT9T!1cpju95$Sh_cF zcW`y$+_fcZ3No)Pa+({tj+U~{x-r9s(bL5qkQ6a0{`+1k#WHw0y@GVZ+Pf>V^0wV8 z4)#OB-W6QHlj4zN+b6@cZWwxg#l43Rza&NKDuRn5%y?xAjX>PQZ7q z70frFEZkY8{p`#+VO=(Fbd6{QE^biuY6Gp2l*z6HV(xnWZW{g$%gwRa!0RMvT~~M3 z@ZXuVc}TP#gHEp-fA1FuiqR;=7s=Tbw!hx-pW*bLNbw;H_R^rnP$POoO?j{b+Ox!Y zHiV9IWhuI*KWL9b9x*b1jk1lBZ|bI_8)p>u&a31mKl0pg6e~CUKADj+UiaB8gMm#i zSp)vabjlnutlRa~9TH3dU+A*}foU&XKN!Jg5%A*a24gk25M;EFB^c5UQkSBw(+XU_ z`qb-?<749tg{*P;t#*Zkh5bi&cd8@$G7xL4hA6h>S{U%8lvWY5hR4=>vqp+gr_|lQ z2PW?w2N7VBi<5f};W_p;ErU#C_A$gzyIlg^nKMvQIcSu1rU zwR57)oPBF|_jAyd<<8w|s?>0?HMM9}hgR!K^onCtK8HTHRtv)Iu>+YiV<40)a*SL} zJ-?5VYGcPnL@n5{0A}&yY8Kr`X67LQp8;xIAz)4J5QWBe`Sf2}H8P+VVEHk_gT$2Wy|a)hCYUjHj2_4WOq$~p#JkDQS=X=W%0Y0S!y%xZR8lOJ>Dzc3j_+vaL3 zC%a$Gg)p`}i?p;@9|Eht0}pmRlLCGIF$gHtAl4Ypwr(y)=+W5F;n%DdD)*CBm4_yx zF(3^C1`TuRxOMJ)d$`2mG~N?84sv4_AlWTDK1iBR0+3V9Le6I@WQeM_iuccKAQfDl_X>G$V8O6m2nZIj~yVq1TcRN6jQdM2s zYa$P_AI$br-eZ97)nDE6nequ{dmVPj8&bq|2~uNr`f-%3)n|ZpIiy~wGTEl2215&#%7&qFvwS-B|kj#b4hwgIwHCrJLS|1X@c&*9zh8g|l4U+S_4Eh$Rj3 zLPmQxuhjD$tX6+=z?Xn&JTYI>(ha-%g4yb8VR1~@r!YbX39%VB7PdNY2MF5q058$r zBlzj%u|YkX-(0RvqvSy3h-pw|!mr<6$dl?-?jE-kd0rFR2LDR8C zMt?HesRA?q02ZQGRc`9NK=xyZEbzM0XIA@X8GWmG@=p*A-Q8YIK+!*n5(|ezEuRlktK6hpuNs+(5RONY%YbBbyfwy}(<*689JAS#);`$@Iom45-y z9=*J(FJ%ao@^esFw1E>1z-;~Ck3@mjd?(w132{?t>5oK^kIEL*Uv}%{! zSt06|C5f~p;-;(}V+{ziYe~bUeT|jBtA80vad7cs(tC#n7L=^D`!O)xc!yeLTa%w` zDrT|=O&;V5_IY~N)0|eP z$j;zuhqWI&@#5OoQi~#Ow|&0<=K=Ws|KCfdl5Yd6reh6HW)GY93!AC?P7Y_OXfryY zXPwk^Z(JBDcG=tytvtlH56ro?@@vy;@7Wx*?+Zb}c#G+%b&Z`0mLQ)BhaN|BcX9sC z2(?#kL|kp3|LOnzzGD!Agt?({O33ir1l=v6ru_qXmw4(*0%iuF_K%JI)6mGnTy^0$ zG`ZW;bM?ZT#ZD`PY-V4(Ix2AymixWJvHfl7$;gh05K01g^1S%DGkH`|rFGOjq#JXTSbt3w*zJRT;V`Vx>_Zv^AL~ z+8X|btq9%?_I>MX*0a!c@QN)K?^dojFyk}x$KxSW!X~Tk`b%c{Av;sIYB#GbClRW1O+WUFflwnMQgkckW?e>2D zT4-dd-AzulECJaLp}3ZCHL2aXVZ*X#C3n=Ul_GcdO|}?ARO}o4&vqvdj(h1oanz)p zOk~i{uc}ij&^xI}bS%0{=OO{-{*Wzph5ks6- z6Bal#4P^{@XpKU`@3AEAvL>qn=SuW*AP@U6qdShYwRS4(^6jk{j(A|`(J^Q=Zy9I_ zI<}qe*+c!x;cM3!MAw^3365Hv9gsDK{JX0 zWm-SQL(=#~^U`rwZ%G(rKv!UY6&WZ3WiDd7;LhtailEj7rNm$}k~8{hMu zbKkk&%y;KLGtYfy&Qt$%?OwZf?b@}gR;}M!wVeiClVQf>i~^54x?k-yf~ubeef{YV zCuSd6UPidH31k#&sY8xLd5_`7nSEZ7q8xy7>HyE3mxSjhcY%j;7toC4PqK6`t zl3Hi-O}p##2$@b+c!srZ>3*r>W3>KsFBW$~<=-a&trP$TEcA+zD@&0jXwu4^Pd}wa zmPEQgu0TDIm5#`vFR0_`d(3I^imER_W2>Qus@17nC#PSG{2$UM97C6oZi+Y?_j1Ux zdx4Q~Nr)M%AHIu2`%vTmMQuB7jkypp8|j|Um=`J^DN95_qhwxN2>Se#TLaGVf{}5T znJBG5>qhT|El00@$i*;I!DZINg(r-#l=1GzZ&>h&c(A~GD=eGIIvZ&C`g&bcQ%U{M z&mk5WjmaXmDaWpw5EX~krkcqm+%bT+KI_E%ES}i2)^$AL_|-^NB|RL4#{y1+(mw^S z!R@ghe{L{Cm19^DTqzfSgjA~;^yOuVsMa&I-{XiS(Hb_xA`4Kw`(a3d*2 zVWYoLV?I+?G535XoZEJsxo3P<6L}jfoum8e&UE;%;HnYzAFVMgSclLYV1MaQ-48BS zR}urSx--o&yY^lP6f_$^h%=KTV_RL8zd9jZC|G!O4Ji@Lb`1H%cDQ;k|50y~=N$`@ z%Yj|{Rt@4}FxPX@9RteUX1kCdmbQTCD5yqki-3aWc9jroKJiS!#QoB3ArCj*P`Mb{ zYu=XBp!T|3YjxL#4)T?pf*&4DpFWSXp3eMEc>67P;kF;VhQ(nLw8*^(ZURsEWHH9T zJz>;I9Vuk1`Dsnz&oOO4a#(@wd#WE7>T^pt28{`3VbHTJHKscK1AU>*_g{ahQtCPk zL9%*Y=SIIDsWL{LVDAt?1-Tqwlz|>sZ@Y#bM+44?*KPnxH&pGoMxolJvlY_2jRa6m#7LVn}-b&1y2ML@fUfy zfhVJMz+G}D_qcLYlrP?heM_)w zx6r-J{e~@7>Q^LJ@dFFX$OjbW6HO1VqF?VvLcYMEXUIrsg!XPLM&;>+0YzAgHNAsN zZA55;A}p-N{wGr}pjB;Be=$FnXcIV=w7{D=H1PwDqFe!`Bvbt#wRL_?ZE!KF6IY$6 zMaU6<96v*2GNd!J?|zcj@Y{^3rdXG?Qr0a-MRVlTHTv`@4jVu1wuED0&#`Qnoe-AQ zV#-jC3C~k(*wNX;tYBjBpM_g>(NXrN6kDNckM!w`VM}(_CNPu=UfJ(IyL5n*4JgnDK#bFBi z(%hbDCPbl~CTTEZ47vQ)TJ}vAXOt{ff~kc)beL@s(!d(aT=x6YV7oEvneS0H*kfCl zw~Xm_y05bjFLcid3`fN0Ezk1mEf#Ez?C`GNH}CWC`*edh21PZ=5lk@!u@h0)f*$vs z9>;aGA70S+NC5&Hy@bf!{wN12LJNi%ehF@<5{TO zN=jMX7J1!e5Mny45YRh(CmSwHf7@{DX4JNn#XJT#fZwpq_dKp%?^iUR9K1e@JB)n{ zo&@bcn9^*St-&hzS9c$ioMK$?aao-uI;j z1LR{j%(YT9tq`;J^R=g}$f+&z{Rf_iz<$S)DG-)Q@%n2I-;{X!kzDMEJR2;=9=;7< z`;MGWwcKt$>;cL?GRA~s2hO+~Sv#ak6H4vh<1V{${rs}rixB!-;7k23^<~xLVpeJ@ z4K*_(Mr}()Le1Q=sjss^Ty<4uvk!WKzakoIWD<(prg86$f4)aH^CWaCS^qf3Y&*pU zeDjN#fv4fG1}H9-JC)wib7;Zby;G4`7GZmYr%-j!Rw8bF4Re&$0C3!Is} zxLhrpPCp;s`*!3V0rz!KxN^2UUAT%|d})OJi2%2gz^lh)TOIjJv|bDic%=un+GCMR z5H2Jz?5|!W_!6#CP4!gu0Si3sp~eu{vm(e6M4+N_n;#y-Ssmc zjDh4184Uuf=Qcwv+n}w3F1)L8m1q68%|A*}UwzfMI}xNY+b&zw65{yS&*x*{!^%j_ zne2hCs!;F#Pr-K^=Wow{lq&lgj?Rn6_e(z#l6WOOsr&l-D0F7%lv{QHbxUYEp=#TL zKA)4x!|=*SS!|rt&RP=d&YE1f;w8!ICoSJvWNv+3euNMX0hWG~53-K-gCXc*wIobj zPdVbloG0icS>K!XsGydK1=`A9l4nya4*x^ANN@E_4T4}%%bH_byQ*@aq%rDg4 zkn(4dt$PelR5*ftmQ8sGv+-HsR7`7Xon>K**7^ztU%ClWotbr-P zmc`m)Z>rpV@H%D5L4fcgbFqM#D46&qO^B|%yInKq%sr2+FJ(Wseb~rYr?qRa{?vP3b|W#;1XsB< z&~Egh=YReJACsWv8k_VocZ$Dni+1={XkrT|s%kLm1#dpc#gRM|85HKUTvnU{Sr>Se z4o^F1y)(e1tEOKWb|hsLjzuVCq*f%sJuNuBi41~%X@1?O9Y(r-0v4G(x?3$65!wc* zsfezf+W=CvuIUoK;N>$OxF^~&yAxymEdZ^ll{UGKEY}G@o6_m(O|mtCToBWPx9@D42F*;#b6VtL8&AG zjLA6z<$0+od+_x6XBS|(tM{;>I*A~W^=4xO$jo`M&>6jr%$b;u$OOJGh2&p|g-I_t zlEyBC#(}X1n1PF~3MygPp|fUqciBs>i2Kog3J=1teP`696szC^jCQ}%YaP9S$Ic58 zpB5qIm~HlQ+m8N9dZqf&0lbl$Xw!rCg#zqN1q?W}d>XcxcdK@X4iO?TEA z*Z0QTJORZ3*E2&&a~h?@n^=gNKz3l?ka>-DbYcbBKGa7~M-ceAx=PG>(;W8b#IXmP zeW4E$ekJkJolcBx_W`E7Sw_L66JwARfNtcaC8t_)Y=YWq+z+tU@8H}AHL0I)AM(dV z$im-T@N<|V3@Y#&sdR7Bzf5!C!%#zQz+bY(ccMSJe+=|4^fjX*h`KB5EB2SHV)ZSa zd&_t2Ym%gubYUNo z_)e=^jx=VfGX@YP%%s06_-e)(Ozsv?ZE>59oZ{0n3F4NZ1bxlp2jDy zqEVAw?UlUwmu+l`tMgmDW%62e7Cw-onek_IRn z+qJEaPeSEcTIGE7=cqO#@Qpe3pxvZ?VFSDjoR@-0@Vbvv2#hCF746n(QmCF>j=QiD zel*je8DRpOeF96T~^RW;Nv#X-ZX_ z^$rGmyapJl%JxbNwMk}5tOUXU1J62HqDjciBrn02Abl={mRj@ul| zh_DGv-8yPG=3B4WDOSM5O{MF2Fx&nlWpN$zOL9Uv2$5M_JtX(mpntu4^qpIyc1bSq#X=JH9 z%jtS#RDBRJ0Q|&!%Vym5Z5c+H$9y2F%Dm^bq95jpVP#~}Y-7)2;D`ZP%3#w7mj%VA zmYmWN)dU@s*LnA6x^oVOQSxFEF>XqHqRIr|IwuS(d7I(Q;^scKV`EYu_QzVQWue0B z=?G5?Yl5*mI%WYR7>hoXJd;~NgR*06hp#neS+x|N8#$LFvFy^aH)t!kt(EgOWI;xE z!o%3LdttWR@WOFD-L5b;4On+C#M!bKTn+53ZVh9GM&Vh5#Q>TnP_ah`k?%9s+&OgVmdORVBT)E2p&OWOjC zYM{@FXP(pN(ogLk2;MLdvC!J#vb+oZshI21dDC)eou;?({#o2{XTTRfk)eQwx|9Ll zs9Pt$NIj>dEl%&S;_Lq(lSt)AONrxRYX zxh@0-mX;h;c%Qmo8Y|0V;>d;_byLe2_)Y5SHUdaKFTRfKFFX{skip2=QdF`;n^`g@ zYF~Ky<2(IS8mKJhuwd3jWQZiUu1=ZbVrQs7B)|hj%4juo`n>t`%tQsV*-Ke6+PrB>b4nY+dUeG8FtBoYw!9{R)zOKHI#6lP)e_8Jlb+9Za;#q0k!q>} zd^vdo6>GRlM3J10x+ZB-{coe_kp1vMB)=NNk%@gu3jE$+b})VdjTV<- zo^-~g73Q_F5YT?P*f1o(Vie!?Do~yG>*#_JB0e2G*}X1{&7nQEV!^vx4_B`#OoDh@ zGl}n}rXW1a22jWEHR4D_iLMj?UtF+{eLnc;)AU)OrF7mOdjZrWAcO^&j(YEy0;aN% z$6zk@BpTkK(p|H3=5pVF{P$UB;oJ){*tkJ0uJ@8+JCJy)<7roXTD}UFpLTVJE+Ldt zbgDr>)t=NFV8-yhK&{GI%ElKEm0GHa2T6lpG9WY}4yHoxJuHNGwotLH z28m8b`*AXWX-QJs8mueoftK{mo;S3(PM*oolTTF$qgK|24Qd$BY4X6DgXt++F%)6| z$-CZyar-g@Wie6zCUhf?_c?~oV@5?bv)7zP$gW#b%^FO^*Z>j~5(tpn)`48y2j`~s z5yPlmh#KkfN!19QfmZybpkY`9phabQxZl`-rX&jh}qG1(K@V8c&kd_N?lIqMhcD$_{f_W zZRn(}e0#!&tn_P7qC^dGW19TAiR1K$wWI4X5!bu zEpNM8uXwqjX+7d77T4JLXl17;m$=c zV|@bL%Eqs2fhY~w>EXSedT~Lw)e9PRJ8&nav<0_-ixo>*bRGCbLNA}(3-@F&^Y3L@ z;!aPm|Alm=E`=9d51m*tu^(2K-cx1`|LJ$xNwsz7!n;Zdek9FXm7;R$0#4ZnUmQHi zg1gFqfu088iSmvBdK^KcZ22AQ0VMrGKD+=nJ(_H7)P;E#KqP3RuFFZ98e69@2^?=u zAX;+d)s*9-AJqevv3pt+xQJBwdfZ|kRCblac*WZ;a%iZk+kaRjwV^z5%ShE$0D6^@uiQS9`rEL0XSw?J< z*yC_)b1sYi7$-2C`@50B_C=%`pf#0%-bk0l%dg%(#^z%Xi05es&dr)C)bEzh&Q`^( zVfoCcS}>G$2(=x-Q>MyJb+^DCFG1un9 zy{xw;H^=dXT!l9h4}~8PvQL6ii5IyulCrEC$2g93Tmd^aC*DR`AF`f;MbO3yP|8JZ zRAUNRjUP&nBVnlaei*N%vW0G_E7}=-sF4K9vj%&F#>1DyuA;tAB7Iq(kJ2}R#>sKd zg^M^}@VlbUW7^AK6O|7zu_xS2!dz@b8Nl}3bgrAC&lSU@Ul_aJe@(!a>y;|%^noru z@WOM6$*XEzcNg>3yOKfde}Tp zjX{h6@Z;^A(9N1`(IRy_>`NQDiGDkV-?Ly*N2S|%6*X*H(ePaOoZWanf028A`KL2_ z*<(zCJlNDuA1=(Tu{2@(XktG2@`HkeN}*!5Q4I@Y_?id3W}eNE5~*;{Kl-0~}X$oK-0L5hZK~v^ta;m{{5j z!C}YW{NEK5jbl{*6EA^HB)&l>;BlrYG0*3sA^=tF*fx; z$2)g9;Qn`A4!#_~GNAaj_owxK0PLRS`97Hp(+9a5R*bd18*kVLt-U5h>@6!Urk>rQ zaV?W953$T5llbvUGcEo_+B?guVlAEfM6$pM27jOM|3E9bOMr=gep`S3#_YW%--A|Uwn3|wkF(%my{Ib9dn@J~)hu4V{gZ?RIs~vevG4{4IU;C0#*ac<$^F)iZy) zzP0RV{RM!w0b|bqG8M&I=xpSc%u??VSx*^By$aB90043yQ+NIU#?BXjCjq2)o^A;W zS%SE+m<~T%kd_XQMmf=^{N;tkHE}2=pPQGXa^^{+=XONnia(;R#Nk6Lo~?LufR^<4 zJwK;O<%a(+DxHAvbmmXfV{`YY4boc#bj3*l!=~Uu9G9mEvvV74!BBam(KUetx_@4SGG`=)3omkIl7(z?;AZ#^x6lG3l4W?lg z5d{jTrRj$!!u*G0h=}(=D%?0PJmMc88pAfY9D|1#U-&Dof>~7i*v(R4`$>#|Y{75Y zrL`%*d9OjfJOt2nb)pKa-7kfI8iremk9C4*lX5qZsxHKFcHyx7EZ2gGoE^GX5pEII zCY+KFzw`46Ce?LuF z295#iE+>1O(`2|jjwa)*>$ueJG~Z1V?->>ESM@k~aUw)Kq0R-E6LD!a#uU~sqB{G# z#D{;DIIZv!!;voh)|BpO{dqoz-Gc$sJ47=EvWnGugjUE@ikL*>4+}$iRWKrUNlQTG z;%5_Jl294HGnnu5ZpUC4(#B^;@hF?y^3}VLmC_Qnf;14#AkOqB(9PajDdX4VhM|mk z3^ve7rlz$9XXjk!5EOzZgkcBZ#CZL1;_KEvdyinoCRv{X&>b@}T5+kg{9_rz>EcF9 zl*69DjoE-{DI1_pd?{0-l6iAG2CGPhy&zj<@ENXlm1>VjHxMvny zK5LM+gMa%BTCzgG4bc4D(dc&#P{5}YR>f@g@m62%MZ6nrH*Y^% ztR$U~B7ZJ6t}PY&A^li-?BmR(8$~!GdhV4vO?IOqriv0a-4+HgO(XCAwt;?*iZktp zi`YKaO@CH3_9sl{M%H(&AG3n(u$>tFXDt|A(V4bB`!i*pBF{^B@J4tbEzYppdt^>oH`{J%T*j+lM4eVY}Gl1{r3eiLmANWyE1pn&^{k|MJ|~(ZU)?^ z_uTdgC%pEGf}6R2D4_<_RvLVITiZ9rR~ee=r@@zQI5kWGS`+7YGm> zSu?duZcLoM+c;cM1#8_vrkAh197}a{@JmkhFaO9=?C9myBhYaq7EKP!R|U?2JcB50 z+g6M<<1E|U*EI45Xt0CaYinRVYkdiDSwI%KuWKa1r1OENVh0v<55q>svA|Sp;)z`t_S!J?sG-wyi0uQVclWN({Rfz%2fJx zjb*iF>LnFKH#uO9W7Djk?K}PGrb{siqU2b+Igkh+IZ6SZD)rJDoH5}9)PsHA;Tnnu z0L6wT*kn@l3HRoXYpTFG%WO+Lhl?`SjpewpJ&|zOC*%O*>g&CbS8&2T9^+rX9=yG)P%O+olzm|X`0$*F93P|v zgt#xu1NN13n}=RwE}%;|NOWP2F!(17Y_vVt@gkB{Fd1_Xwn>zhwExT)eJr&%TEUuh zvetv$K`ZS;!?AvZtM@ z*YEu$8`{r$_b(`Rx!5w~FIkm!idPdaHs8rDRdA48_9TIh16ov-nU582LP4$)aR%>KOf>tzqfX?URb!F^{8858aY!FU7yN-unyOqnHCa!Vf0!)+(Ng}QN zNSJ6p02Ij@8EnkOwvgovfoRQ;uwD%@g(1pe#?t$NI4cybsY^U8qnZRzH^th`+>B~7 zVHy7{ah^em#VYDY9&Fl_!9{s%K!x&ojlp4Uv`L|YaN`>m!#55Qy@&V$ET;1q(Kt1l zpI&&TNzZ{6xe;Ab}NE?PNH|4v{BJ1S2ClQrfnJyIH$=mSjKl`>+S>1cl zN8eK(s&puf)s2@GeswwavvCQ_pRDrq|0t_e*n!Z^E6T+8Xv1#wvmZ~_w2)-4BvpK- zLDj|}JS9e@Yy#g9`A&iaYy6zZ!V?!u;*@qErp~kiuW0rAcu&mQk=6KV391klK$o9wG1iThOp;;_m9E9@#WC^)8P$! zV4G4WG%b-B`)@y8z-9HEZ7%%rrwml=x5cytV{KFuUuMCu$nEZA97J{t$2I|y`fq9J zzeTCT3T2onE{*9MCO4EJ0|m;WWhSJB_bryu?>?HOtlRzx4LAEMUzRZpwEl@LFGEe% z*KEGiy7x3@T3Ip*=V$;lllV;%3jQwsnnfwyFKM%>DaX`OCG0W+21ow1gQuRDr3mLQazR3^ z{3mm-cWGf62PUK1p`w7>IIZCnC5skGxP;{!wRa(o4-(&@dot=+!2PDY)M<=BbzA^- z`F|BrvH>xU)}#2Zt`4D|&p2bkZ}VuyF~mMKRB%XZ>a%gV_G35FVgNA1k%v3Ita%J( zQkev0Q0gRQy37RE4yVp4C>sKpwkN}myU0fFG!V_K1 zlLpw=T;o;v;^6m8`jEF=w8*aI+s#(@BJ15K)H^Xee=yErz!F5g#O=%@C&04==}%^O zWETWr&LwU^*MmMdv25&Oax}ry7#SQ0!kO*Yk-j!2=Iddfz@zwHxvQ;Ol!1D3j`r`5 zSr|C=h4YYlEH@?Ymh#n~WzCAd>JRi%hPLERa0BXtdy}=-;3EXH1&I^dJC7koW&kPf zHVVR(8;)JC*KZOPVbGQzY!5~oNqvpr@0zEu1KSE{eg@XPR?Low>MwvAbd}$bn9d#8 zG3D;!+ExtwrCcw(y0uJjSTU8E?-uS(?Tuxj30#%pfOh6LQb{GW9DdF$S~tT8k@K-^L z-1jduLfz_ay-6H!{}y=hNBJYyS{7m{>H5xk1$WljL@({c)(E2XJF}5d0a620CE)=W zt#TPHjA1E23pulq<*jA&aUQ*VsR{!l#U&GM&9(!$;0hoJ8Jfw%nnmhXp>r4KvfA)( zaOD9en1;7_z7YpEw~_UDF5UvPfb96F6z)+qIOzahy|Q9t+}W~)>K*TVVh}ew5^Mnb zmBJ;7Gyy)jsOzk=LxAIlFCjl)41B3ZPyHKSdx4Erf_@VD?cT>BR?9^ds=vV})w*c> z2c^jFA4NM`dsi-b1yRf;aU+$JOr3u@Pl}!azO~$0{ip0>Lnv)Yz_OQ_M8SGkX0&Kx z-Ue_^%@Av^pO0nr?|DqT2iP)Pi7y_uKg!btb>OYA(^=OD)GSgMJm;u_?x~>cROhslif7qa{hph58@AgPrWNS?-zM>35a05KE46i*&oceKE zNfI}Vjc#-LbY`1ZFRkKkevWo<7UyQhp=ayovUO+K3Gp4syX>w_gkT-3u^l#77+|9r zrdGB&vK80A3?o%Zf_#c|Z5#m8LxuJ0+4o1MbTXUC@N3~N=)+U5_z~^O2H=BecW}0D zE|T)%8ku^)8`2;}+633NIx#-#ql)$PJ=g%VPK<7Vb=v5a;jr-rN_B%$b=Ns%vv)IE zjvw0v>+TMnb4pyu_nMj?-@ZZBZREGS%1MxZGhy=8KA^`BAfMaZ!X0kG+WaUEA}e)K30o2XT?0H`*!&&>N+iqMllg+ za#ow6YJjJBtrm}4bN<2>h~?1Q1=aBzTkdd+;LOlh{}XWYpYhcHV^T^5#hj{^cATVb z;o_QQ&zdd(sB`mg)cH5x{qHHb$?(xVjcG1*n0UOsVD^oMTR7C|Le0WsQOkOVg}l+I zRCQ_%FxhS4*r*;RwwfTC%&-Z+z>5_ei?`!QO`1icw$h7|chI1M8UHJiY=B-7)X_Q$ z!N+-(*oVm7;BzvIr+LR9pJuJ)+qJvcL`Pck%6ZRuuveWZA(qk4T9(N+By+6WKp^A? z(Ud=cIeBHAqD(41I-?ESx4<&Jr!Zb#x|nKqz-^Lz)ERU2UzqDbr8#&Tul<2UrCY+l zQ`#ze%RusP`&!Z%JiP2lWErtFV6F5-4UNkmi9Oer1U%O?fmgC{BssvP%xpuD2MC&d z{27_UN^k?`F0+kPZLTlm^jSxW3y=ufT%ij_&yt89($PenixvG-tX^SWX*zR~Y9R>C zNEesgz?IyE@rla~BPHpDGher3Tv`KSU)ffJY8ELPmKGUj8Jrdeez>^dJyGKk9^OW2 z8MNpC&Kz|3qlRx^u(6AA-$`A)_}h>=3dv$!)X7h3{krh-!_3L&KVxiF6{h(N<<_(q zFK|*X{F99SBN}*zd-1GP;6jqB1^a74Ts(3Mh9^?j!spMoY?KmP6{`v0FD$#J-fQo>xGB_w-qHD_i z2%@W@=_eFD8G%3#bp6sYp}dWw%}6NBvREU?Gg|Y49tO@vOol^p<4^a_MED=XAk1< zaS6FJFzTwNQvv#4#e!k>{@XkIpBL1>mP22vD2B`yaNUm8%2958q72@RMcpEacpnx; z;DKW0?MvJ(Ndnf6(Qx9d=xiXofCY~iH&c2@cm)7)H^)F6&?@+I(@Ki)k3$73JLfZx zO>|-_eGhMCC+3kgPPXe@HURzoK>&J4Mc#mT38G05mTak;Gva3l?#NC%qmR}TZG!UO z)fmltiQ&3@w@b}iscHG_(XoH5FunR}LY;FGIBuj@>S{wb_-z9qfS)yGqY1TN_#7C%q1cfD`< z+f^*LLT~g82ux`Lg<8&j`i%5t?9aDwwDMy$@)*vcep$FbqbzkFCZf8yre&aoi(pw? zlYWczr7nGaCxW-*HRE+rn$H5hLqb0G0DEkcseh)nC0DbejLq($G}>&(F-m!0P?N;`RlqPdG@G5LMZ%e-Y5;@lJC3lB%&88CnAqql^H%8i58}>$my0^0PE~z~U70^Lzu=6~qF+#C2?A@U8pnRm^@jx>Zm%7%IrSb`C*stPtRj7sG*TTsv@N2e1OQ>>CZx?ZlArzj(a& zq3l~)_i|BdOYiuf^1*1r%eZ$o2n}CIfHhkZ0*DnD;?v%{Jo8O#lmF`BKiq#2_5;@3 zgO3a371xPz>B6nStjrQlxb>1uL1EZR9bg@~HJp_4 z0rj=C+zROO2h*1Pp}PTYu78! zX?_*(@Ti%#osBaMYUDB4i<;{_1mZQV!4l0l`ik4G&KdBnI0M~My~#|o3LgF1J(b21 zhLyde{z7j94S2N(+{XobN(1qqD&x4Z-P)-^7dZSP|0eK>acr5y=?gY>tCF*f@?Ryw z%J1*4{~Rr{+*;>JFR_A;uzdJBQMeH${E=LVWpEekzke%NpexWm1C3qldGmmTr>5cd zeKEx!5wB|k1T^VHL(BEV?C0j5-!*YTr?!g2(2Za#n*!#E*4Us>bQe|)tOkZ3B28Q5K# z6+&|1*9Y^Jsvdesp{krZ?S+OrVe5b9zI9Z08-)w2%inW*z<0rV<)Hu1+0XcjOK$1uSH(LBS@0WIJuK|v-=xRu{hGjh z=g*wO{ko62_cr8%uMG=dj9Q?S{`6<=^)HK;qWgoMJCD+g2`<#LJ9Pe;yZ^oR&Y!n* zQ>o|g8~pt||LZ)#3`mr^bQEeXHlmVIqNP?EtU`O@RQufIaq>^Ob!HkVkyY$y{>9|I zi?_sJ)cUqEVP@vn^q07wkTY&$j!|PfnGTxCVVI}2S;uZ3DO%HOxud((b>-wlPk&kn z8;JLMgkvHw9#{1m!!UPKPf?f4`Gkii8RFH!W_%D(%_Aw6FgbG-Q(HX6iQSB8NCQbN zBLuXBTDMMX21BloEl=n}x&|n$Iduq~Xiy_{h&n_yY9$#m z64mj)y1KjPG`;pzAh$X<=8pHZ`T-OO0xG9AsbzQs0k;`wrPC38Ew*Mf5mQju#)zBh zW}6XrS8_H|&7pU(SmnykyPyz*P<`d6P#d-~T~hqe8x3_rc~1&A5VxMC2~<*r7^>QG zR_!+L?u!ymJN~<(N5&=4L+XZKSfPzulA7cCrYw^Bt5Qyj`)@ODIgJrVM^oN>Le(b( zKIk2M(^3)B+e;*Fl<1TL3UhLCv^LXpa*EBX(ry{!R+DL!mZ;^CY3VbV<9)wH&Gq;_ zbG`))%X_EFFEqE0h^eoCBR3Qyp}5`hE`&r85lbxeD_)S1fJo8WTZNzq)rU`i)}Kvw z5ud%`5@(DN=5us%q_aNZ-z=r5=~%(l}qO>>YPs40jvqjQG z&ASNB1v{|lOoXQBPtMypT1qF5`lgHw1?KwmG)VVew3XPPyM_1bM?OEG3|_BG8IV*j z^vjCQnk#C^QYLBzd`Qr8pE2T$8*+8+4%ZY;9Ld@pOvoaMKUU1VMO&F!5Dsly&?&?{ zWDUUPkp;3^+BTOzIg=)|l@^JA6YFQTw}TNEt&7Ly14dOe~#2S}O-=YgH zHZtdGt@SC2h6H2t7%VjdFX!n{mN^oA0#|C;>E=ptIxSroChlTg0x*i; zlK)!*P6|0wcFbdj7Y8JyDb-JRGtQIo??2SQX3^Y3v>kER^i0&?YTzs9$yaF4DKq)q z!AM0*{58GL2zs&_Hf$W?e&mJ7$k;>5>D>b}VKXwZ(YGeh@ByhyQnhSm*Dq>%f_E|> ztadsxvlZx2iV8)SJ_iNs{lf5G#ZG-~s(JT4HjK?o&ER6_FHfq@8rZ*xopGC;q9Hz_ zm!!zAYNzP)?0uW&$hHUb#>4#UDrY(+82(w}U35G{6SOv8YqQPdQpIC}m?_L8?=qYj zuz*+DlBG{*&1SmLk*J)>C%P>;0wGdG|EWSzoUrDHgxB5}A#`^PK>@?p;!E)P^$6w9 zzBsB)TE-RK>4|83Q;q%{(sIaPZ) zXsgOi4D5qOe4Gg3tAch1gQoIj;5tAt8yIrht7f_W*N> zIy@Kk4~>4&58XuYt49s*T@juS%wRtI(x>jI{cJ5Ech!M(LbvM8%s~$3W0&_G!b6=N zaLuI=2>c$}sM*5%U;|UmfTspt6o{uE55pv|973kKA<&1Iy*alj28tB=%RjavF^`xc zR&}HssyAkr*WE?Tyg_2uOz4`IX|=6uDXpWegS~^`xAf0p=+WfzFgSI%M3gECTKp z#3Jz&st*1&Ut`0z`Am$!-7NR~CiMw#*p~*0(a4_q!6V%2&oJ`4#*r6Fu-V>kc083o zj4-Zg1ZRgmN;!@n;jX{bk;cJ~;Qf&7+_<8-tGN2`!dTn4WX6HB)U>iBc!5?Ry5us$ zv9K?HrSq1?A;0Rj`PQOY6Px5WOfc3xG7OVQGD|zBAxQRW0=l{anPBk#0mm#t@_?|2x7(Nji9^&_wo_s; zQcqsKJqFi&CY+XRuRO*l47aQ9J^-5Ez48=wg>&oprLayKITaNJ^ZPs4tFv zyqqIN&$kZB(XduRg@IvUvI;|f@;y^4dl_@p z21s|_Q&{uQ(amVSw%S|Uvn?Wlf#@zw9aHzEZi%KhLD))(In@tW)(l{ve{U$+o*@Kz$@f|O$CT8lThrExdS zl(PQr6Y-}|Q3GxhT)RBLcz_0BPv{?Fyi6Zp+LY^bH&{0xj29Du^LQ@r=7r`y>*H&> z8x(s9(kYkGPS67d`ud9Bz?+;K;oGSB${$d#=ENkr13*PTM1_|~z7uWcn1^&X<(r)Mrz3QGIHSnrs-6X#x-ZVFmAWO@9B}e1HDOR<(VsUj-13P%8hK!5vLIW|A z#4||0m9_8RbIz`-@e5FsyyHIf&P25!x9C<*c zrp5o@jCait5So4_{6z4GDp@%56_~t*siEPy_68{f@*a(E-?*5nz*CJfFmWqzxb5%5 z|2MaZYa@nq4>qui%IW7MlUABqx>2m0oh2;bK+y zqDeO3jLmOpf|f^}`4{Mfi99UkWrxhOr0uPi>Ki@?iX&2WHPk)-8%;wg+% zWZ^S~D$ae^Nd$8xZ?bd{+5L=YIZQrR>dJzjeIRbYLkS2=ruX>}8kin&~3nqW_-pDt__+--O3 zEGxU$KK)*B&Yyzz^RQ!Ek80G}G%&_#pA}Du3`hlH*L$i32xxv3NbFO628!84#Qr)j zXao33uRvqppUtDOj>_I%0aPjM@v#+#wqKrXq9^j^)BVunr(mA~|L@^=`RSl_9kp*= zuX>jc-7>6(&oVs9%|<@F#F$$&PKXT0)Mvtsa|5xK%W-_Ox^p(HY7VT^klI4iTG#LQ zF<}^u=%&-rnV*Te>Pjq`Lx=v+gDe2IX8nCnht$9s zih{|z%@zr}{^x>jLWVfO<9j|H7GJH;oBBKwas$sEN}ZN^D0r4iK;42Z+?*oir-o%Vi{c{pLbN)xqlE;Oyev zoG33*2(=hz!yC8msyAacOB^|OKwUhPgV!^Ew%Wv9R+N*2G zSC~)W%Q7KS0S6ohqxR7gjjzOh@s{zP&z8V^$-$z+JoUaTe5coG=wHf7gqvOFW<})Nx2%;L_H4_c)#|0YQ&* zJRZd%4lZA#T1EA9C=qW}C^zSelu-TRmLpIv9uuyNW&D_I>!$@&uAJ=|Q=Q6IkdHWj z4y+U=ZAmKmNVnWLLDjmoWV9$hzv;iVcivG=Y~LP76e;4R2wo8(q99GhLJ*K zJ%c@~^_2|T$km_Po1`l-@6r=^z}w!w1>B#qA%wI^o&7v=p{qAw5sw3>y<0}Eg%a~7 zh4DCM*g*2~%tnufLnX@9m=f4NlH{uiH+@vuyl1 zPn`5T@vQtD+5TjYROPi;oy}29sp9r^28l)@Cl@G{nE&hal>C%8l5fWRubNgyAhSbc@+TOvXC-=i{iT8bg z7Pn~2dOhT?NsyHGgu;g(IQraG+3f>{tsVEu%&k8iGRBlDh5)Y@7=WrDOXn_G?x~m8 zra1{qfMbC{g#!FO9!+&^0Q-U__tc$ot3X40NNi%&SP`X8nykY(AE++Nq_urYE&B>R z-!JuPGrORNCTgeFkVoE`tZEp`J=ma5M1AA@5l}**_fzRk#TL# z(Lv9vGi$Z>0g-8q`^-<5j)<-&AD)$N|B#S5EV2MQkeOYH76>tzh@B36x1Qx@Tr#-u zTFjRO>+W5?1+LaFF)OPSj@ye6CM`>2O$YAVkXQQEuRhsNWjH*LN;1M9wC>gcWbTt6 zbIQQb_iM~Ym6n(4Aq`qTRqO2XZzcf)j0fn%GtU-@d?%qb>xs?ed1{l5Pitd4_~N!hi!8!E*PHcNoLFI~WZik3O;V zV{vaxBmpGxKBp$PW6I64^&NuQBVxT0bzjb&F2FY)9d_na5>vWY2{dWz%eEdGYDWazoC~Tr$}UxwX$V> z0`gAZ1(`bo-P~85M^G?R+r(2DQvD(Xz1kVnN*jD>$&mC=r;s9I(ci z`R37|Mk2r}5qRB90<@so41GOce(e%TG7HW4H5TA=rEH*d-ik2^DnOFzWz23j=wA%bqx0QN{+A6lK0MI zHe+Kp&9=&6?JE&v*#6E$k$HT~F*M)&7!?D)l}ijQEwmZtuB4^8K$5=G;d!_XA|L@8 zWqFGu9IK95N{t=SGUmO_{w`ajjT74hg&%;6Rl_C!uEV8aL>sn&=5AmrX_f+^@IywU3oZ}ETAD#*VpU;N9>78QUh%KK-?KB4~`i0fr({#t9my%rY4T#0YX6E6-~WygH~HcZ zt<`IjFHSSk1eglV} zCailnW!g%#0z#6{4V{Gk)oc<>=~@77%!+*N6#gWczpd|3bOfxA#Js#xs*7_-mI8nnI2hTe+Xvw*5XsTRy9UZ9*(;cW5v?W5XZ2ZvaM`{0BQ1XIh|gx1y+?$ zE#LT6;{DP@mYfYYwE2gdzpMz5{veB&*T%6KIus^!V#4muX;{Dg!G zJ5qn)p$UfxzI`a8aXElL7%aI0QN;rutm;Tda30A2dGu)c)$JNy&d#mL+5m!AL}=I$ zQGhScw3k$f7Nyvu+FOQ}mBx2nhX5wT96e;G^^WsHNFb2O9BqgY;I|S8RPk(2cQmf+ zOv)wE-MU%OL)|f!7ZLM#B`G?Zv|UNoK=ECB=_=}$R6nPm$utoGeC`2IABPY!4Rpdy6*L_O9 ztm;TGT366_xF#m|;6=i)T=;yW{ZUDE@GwNtN@Gyww*}q&x^WoG%o!PJ9vE4D*z90*EZGRRM!JDs;%^^y<0AfoZUl9Q|D7*dDK1~Qwk}VQ_R~8vjxe+&9r}X3n zzCeI7`G;xsj(Tyn6aluhMb&6Qv*`Zqws#gn-x+&FS?Z-L-;uWJCst!(u`81U=lqo8 z<)>NtI88P*^~gH}hm3+`dK}*rqwbJmp5;rLMt3~Ok>gis#@IuCWiFO^uYKhgXhHQv z+My6nNFS_0Msmogw{*u6`q^Eww@{LVlt(&;Q0f52I|%6W&&dSTLF2RSRnAie-5|K@ zQa4Zu*;%CJC@TD6cXwXJ5tO2pL%f@Pf?8vlvEG=4wK=NPQ5;g&HOJG^bxrP0LQeJj z$7k@3AvX%>7E;jO3MGk;PO+(&xrUrj8s9U?XJvXh} z&n8brn{A7$yfV9|S7^{&@Y=VZv>KPbZ#BuhVo|f_Bic>Sum&#%J@~AzC>w4kskrkX zZz60=5qqF9^VX<`D;>L`LVZDl8^qtw-mYZz&S?`-XK!*s#LDZRw0Q4f;mN{92^|np zgd~Bh0#92xJ6{>mnYhTXhB)Qm@nKyp&$`Za++bG$o-_IDypffN^%-%u z01o7P``pD4!_E{dF%IRLxcn^YY>ZDg53;xHWkXw=O>ItaKYspxTS^q zC?&@lCfubGRxdUXaf4TUS2H$LuH<~2VMIhKw^QZ;@tvcbb;fW2Gia*h9AdqPkYStylALO^Mjf-M$TJlBJC9jNnqaDb^!A zb>XJu_k0c|EI%3m8s641NDto>LJ8FCsSuLHu-mF_a*XY{f1ta^b%t7>GiDgHTGSP3 zPks!f;z1SzyXVp`-X{}Uk(+LRNTON}rPj`xU=r;rw1lfSm3`A2JAQRqLMHf33On8+ zYBp|EKI^OV>|c;^!vP?{z_!4UgM$+WjTYa3r-t)z=(HuUH1zd|ItDkfBftCjk#)6! zzsV%C;mfBB4`LHaKZW+fGf?>U=I<(EO8A_yk!7t$dhr}|&qh{Hvg$(onVu){bZ^f> z-WaXt>(;5Ya->NmaaG%~wkB{tr>If!(u*_cAwo5i{ob#7ML#Vv^bK1wp(uvZi^}M~ zOMsb7L%~ewZ&-z~m~+_hfmW5&AQSC~ah5}PgUgkCDN&0;B0kD?$At@HGE3V8Sf}Hm z=8S)u|C5i&o@=WXXV5TRGFEKf64UJ45jfqQdQV$si~cT6)fyfL$=RL_Z_}XXl2QLqwCF=K$ACjT z)6wD4+XT_F4#xal!|I(m-VcO3X{-8}~fm`Or~Gmmj5S5?QQtbXC+ zPig`LoYtbb4wkWTyl(BB2@*@QnxSeFsS?m|OZWkXJsu#MYg1{`F3gQ#BlxlKVLf6u zl&&C6n(mlC|N0(bQdmYdXYTkrvc-k?ksUJOn^-dFPb{f%)dSz64*^UHO~ZiC)ICJ- zJL94FfmHp literal 0 HcmV?d00001 From 1d62ecdd549df78fdf1d57414c66eaa139309dfb Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 22:14:36 -0300 Subject: [PATCH 15/18] feat(store): the history finally survives closing the application --- Cargo.lock | 1 + Cargo.toml | 1 + crates/cp-mac-sys/src/lib.rs | 1 + crates/cp-mac-sys/src/paths.rs | 33 +++++++++ crates/cp-store/Cargo.toml | 3 + crates/cp-store/src/lib.rs | 4 ++ crates/cp-store/src/schema.rs | 57 +++++++++++++++ crates/cp-store/src/store.rs | 124 +++++++++++++++++++++++++++++++++ 8 files changed, 224 insertions(+) create mode 100644 crates/cp-mac-sys/src/paths.rs diff --git a/Cargo.lock b/Cargo.lock index 1c74faa..9004140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ "blake3", "cp-core", "rusqlite", + "tempfile", "thiserror", "xxhash-rust", ] diff --git a/Cargo.toml b/Cargo.toml index 12f505a..4a1791a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ xxhash-rust = { version = "0.8", features = ["xxh3"] } unicode-normalization = "0.1" image = { version = "0.25", default-features = false, features = ["png"] } proptest = "1" +tempfile = "3" objc2 = "0.6" objc2-foundation = "0.3" diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index 6d8d321..10e2e92 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -5,5 +5,6 @@ pub mod keyboard; pub mod keystroke; pub mod ocr; pub mod pasteboard; +pub mod paths; pub mod permissions; pub mod runloop; diff --git a/crates/cp-mac-sys/src/paths.rs b/crates/cp-mac-sys/src/paths.rs new file mode 100644 index 0000000..0555541 --- /dev/null +++ b/crates/cp-mac-sys/src/paths.rs @@ -0,0 +1,33 @@ +//! 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( + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("CopyPaste"), + ) +} + +/// 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")) +} + +pub fn blobs_dir() -> Option { + Some(data_dir()?.join("blobs")) +} diff --git a/crates/cp-store/Cargo.toml b/crates/cp-store/Cargo.toml index a1ae5ca..9e828f0 100644 --- a/crates/cp-store/Cargo.toml +++ b/crates/cp-store/Cargo.toml @@ -14,5 +14,8 @@ xxhash-rust.workspace = true blake3.workspace = true thiserror.workspace = true +[dev-dependencies] +tempfile.workspace = true + [lints] workspace = true diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index f7cb503..3e1c001 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -13,6 +13,10 @@ pub enum Error { /// 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}")] + Io(#[from] std::io::Error), + #[error("la base es de la versión {found} y esta copia entiende hasta la {supported}")] + FromTheFuture { found: u32, supported: u32 }, } pub type Result = std::result::Result; diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs index ba48606..ec012a8 100644 --- a/crates/cp-store/src/schema.rs +++ b/crates/cp-store/src/schema.rs @@ -2,6 +2,31 @@ 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. +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};"))?; + } + Ok(()) +} + /// 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 @@ -133,6 +158,38 @@ mod tests { assert_eq!(foreign, 1, "sin esto la cascada de formatos no ocurre"); } + #[test] + fn a_fresh_database_is_stamped_with_its_version() { + let db = Connection::open_in_memory().expect("abre"); + create(&db).expect("esquema"); + migrate(&db).expect("migra"); + let version: u32 = db + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .expect("consulta"); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn a_database_from_the_future_is_refused_not_repaired() { + let db = Connection::open_in_memory().expect("abre"); + create(&db).expect("esquema"); + db.execute_batch("PRAGMA user_version = 99;") + .expect("sella"); + let refused = migrate(&db); + assert!( + matches!(refused, Err(crate::Error::FromTheFuture { found: 99, .. })), + "abrir y «arreglar» una base más nueva destroza el historial" + ); + } + + #[test] + fn migrating_twice_changes_nothing() { + let db = Connection::open_in_memory().expect("abre"); + create(&db).expect("esquema"); + migrate(&db).expect("primera"); + migrate(&db).expect("segunda"); + } + #[test] fn creating_twice_is_harmless() { let db = Connection::open_in_memory().expect("abre"); diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 1b0b97d..8d99706 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -33,6 +33,41 @@ impl Store { Ok(Self { db }) } + /// 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)?; + restrict(parent, 0o700)?; + } + let db = Connection::open(path)?; + crate::schema::create(&db)?; + crate::schema::migrate(&db)?; + restrict(path, 0o600)?; + Ok(Self { db }) + } + + /// 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 @@ -298,6 +333,13 @@ impl Store { } } +/// Ajusta los permisos de un archivo o carpeta a lo que se le pide. +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) +} + #[cfg(test)] mod tests { use super::*; @@ -991,6 +1033,88 @@ mod tests { ); } + #[test] + fn what_is_written_survives_closing_the_application() { + let dir = tempfile::tempdir().expect("carpeta"); + let path = dir.path().join("sub").join("history.db"); + + { + let store = Store::open(&path).expect("abre"); + store + .insert_text("uuid-persiste", "sobrevive", 1) + .expect("insert"); + store.checkpoint().expect("checkpoint"); + } + + let reopened = Store::open(&path).expect("reabre"); + assert_eq!(reopened.count().expect("cuenta"), 1); + assert_eq!( + reopened.search("sobrevive").expect("consulta").len(), + 1, + "y el índice también sobrevive" + ); + } + + #[test] + fn the_history_is_not_readable_by_other_users() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().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"); + drop(store); + + let file = std::fs::metadata(&path) + .expect("archivo") + .permissions() + .mode() + & 0o777; + let folder = std::fs::metadata(path.parent().expect("padre")) + .expect("carpeta") + .permissions() + .mode() + & 0o777; + assert_eq!(file, 0o600, "solo su dueño"); + assert_eq!(folder, 0o700, "y la carpeta igual"); + } + + #[test] + fn a_checkpoint_leaves_the_data_in_the_main_file() { + let dir = tempfile::tempdir().expect("carpeta"); + let path = dir.path().join("history.db"); + let store = Store::open(&path).expect("abre"); + for at in 0..50 { + store + .insert_text(&format!("uuid-{at}"), &format!("linea {at}"), at) + .expect("insert"); + } + store.checkpoint().expect("checkpoint"); + let wal = path.with_extension("db-wal"); + let wal_size = std::fs::metadata(&wal).map(|m| m.len()).unwrap_or(0); + assert!( + wal_size == 0 || !wal.exists(), + "tras el checkpoint el WAL queda vacío, no con {wal_size} bytes" + ); + } + + #[test] + fn reopening_keeps_the_pragmas_that_protect_the_data() { + let dir = tempfile::tempdir().expect("carpeta"); + let path = dir.path().join("history.db"); + drop(Store::open(&path).expect("abre")); + let store = Store::open(&path).expect("reabre"); + let vacuum: i64 = store + .db + .query_row("PRAGMA auto_vacuum", [], |row| row.get(0)) + .expect("consulta"); + assert_eq!( + vacuum, 2, + "el modo se guarda en el archivo y debe seguir ahí" + ); + } + #[test] fn a_word_that_is_not_there_finds_nothing() { let store = seeded(); From 0aa8f071fdc5a70f93d4f850d80d275cfd87e309 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 22:25:41 -0300 Subject: [PATCH 16/18] feat(store): metadata, work queue, blob store, listing and retention --- crates/cp-store/src/blobs.rs | 179 +++++++++ crates/cp-store/src/lib.rs | 2 + crates/cp-store/src/schema.rs | 27 ++ crates/cp-store/src/store.rs | 706 +++++++++++++++++++++++++++++++++- 4 files changed, 898 insertions(+), 16 deletions(-) create mode 100644 crates/cp-store/src/blobs.rs diff --git a/crates/cp-store/src/blobs.rs b/crates/cp-store/src/blobs.rs new file mode 100644 index 0000000..a94f7f6 --- /dev/null +++ b/crates/cp-store/src/blobs.rs @@ -0,0 +1,179 @@ +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, +} + +impl Blobs { + pub fn at(root: &Path) -> Result { + std::fs::create_dir_all(root).map_err(Error::Io)?; + crate::store::restrict(root, 0o700)?; + Ok(Self { + root: root.to_path_buf(), + }) + } + + 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); + if path.exists() { + return Ok(digest); + } + if let Some(parent) = path.parent() { + 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)?; + std::fs::rename(&temporary, &path).map_err(Error::Io)?; + Ok(digest) + } + + pub fn get(&self, digest: &str) -> Result>> { + let path = self.path_for(digest); + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(why) if why.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(why) => Err(Error::Io(why)), + } + } + + /// 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 { + return Ok(()); + }; + let zeros = vec![0u8; metadata.len() as usize]; + std::fs::write(&path, &zeros).map_err(Error::Io)?; + std::fs::remove_file(&path).map_err(Error::Io)?; + Ok(()) + } + + pub fn exists(&self, digest: &str) -> bool { + self.path_for(digest).exists() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temporary() -> (tempfile::TempDir, Blobs) { + let dir = tempfile::tempdir().expect("carpeta"); + let blobs = Blobs::at(&dir.path().join("blobs")).expect("almacén"); + (dir, blobs) + } + + #[test] + fn what_goes_in_comes_out() { + let (_dir, blobs) = temporary(); + let digest = blobs.put(b"unos bytes cualesquiera").expect("guarda"); + assert_eq!( + blobs.get(&digest).expect("lee").as_deref(), + Some(&b"unos bytes cualesquiera"[..]) + ); + } + + #[test] + fn the_same_content_is_stored_once() { + let (_dir, blobs) = temporary(); + let first = blobs.put(b"repetido").expect("guarda"); + let second = blobs.put(b"repetido").expect("guarda otra vez"); + assert_eq!(first, second, "el nombre es el contenido"); + } + + #[test] + fn different_content_never_shares_a_name() { + let (_dir, blobs) = temporary(); + assert_ne!( + blobs.put(b"uno").expect("a"), + blobs.put(b"otro").expect("b") + ); + } + + #[test] + fn asking_for_something_that_is_not_there_is_not_an_error() { + let (_dir, blobs) = temporary(); + let missing = "0".repeat(64); + assert!(blobs.get(&missing).expect("lee").is_none()); + assert!(!blobs.exists(&missing)); + blobs + .remove(&missing) + .expect("borrar lo que no está no falla"); + } + + #[test] + fn deleting_overwrites_before_unlinking() { + let (_dir, blobs) = temporary(); + let digest = blobs.put(b"contrasena del banco").expect("guarda"); + assert!(blobs.exists(&digest)); + blobs.remove(&digest).expect("borra"); + assert!(!blobs.exists(&digest)); + assert!(blobs.get(&digest).expect("lee").is_none()); + } + + #[test] + fn an_empty_blob_is_still_a_blob() { + let (_dir, blobs) = temporary(); + let digest = blobs.put(b"").expect("guarda"); + assert_eq!(blobs.get(&digest).expect("lee"), Some(Vec::new())); + } + + #[test] + fn nothing_is_left_behind_when_writing_succeeds() { + let (dir, blobs) = temporary(); + blobs.put(b"algo").expect("guarda"); + let partials = walk(&dir.path().join("blobs")) + .into_iter() + .filter(|p| p.extension().is_some_and(|e| e == "partial")) + .count(); + assert_eq!(partials, 0, "el temporal se renombra, no se queda"); + } + + fn walk(root: &Path) -> Vec { + let mut found = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { + return found; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + found.extend(walk(&path)); + } else { + found.push(path); + } + } + found + } +} diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index 3e1c001..f201b57 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -1,6 +1,8 @@ +pub mod blobs; pub mod schema; pub mod store; +pub use blobs::Blobs; pub use schema::SCHEMA_VERSION; pub use store::Store; diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs index ec012a8..e0660cb 100644 --- a/crates/cp-store/src/schema.rs +++ b/crates/cp-store/src/schema.rs @@ -99,6 +99,33 @@ pub fn create(db: &Connection) -> Result<()> { PRIMARY KEY (item_id, format) ); + -- Lo que se deriva de un ítem y no es su contenido: dimensiones, + -- duración, tamaño, artista. Tabla y no columna JSON porque así se + -- puede filtrar e indexar por clave sin traer el módulo JSON. + CREATE TABLE IF NOT EXISTS item_meta ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (item_id, key) + ); + + CREATE INDEX IF NOT EXISTS meta_by_key ON item_meta(key, value); + + -- El enriquecimiento va en cola: reconocer texto, generar una + -- miniatura o leer la duración de un vídeo cuestan demasiado para el + -- camino de captura. Con su estado, para no reintentar en bucle lo + -- que siempre falla. + CREATE TABLE IF NOT EXISTS pending_work ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + job TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + not_before INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (item_id, job) + ); + + CREATE INDEX IF NOT EXISTS work_ready ON pending_work(job, not_before); + CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5( search_text, search_label, diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 8d99706..0f8bded 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -17,8 +17,28 @@ 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, + pub modified_at: i64, + pub kind: Option, + pub preview: String, + pub pinned: bool, +} + +/// Lo que el usuario tiene puesto en el panel. +#[derive(Debug, Clone, Default)] +pub struct Filter { + pub query: Option, + pub kinds: Vec, + pub colors: Vec, + pub pinned_only: bool, +} + pub struct Store { db: Connection, + blobs: Option, } impl Store { @@ -30,7 +50,7 @@ impl Store { pub fn in_memory() -> Result { let db = Connection::open_in_memory()?; crate::schema::create(&db)?; - Ok(Self { db }) + Ok(Self { db, blobs: None }) } /// Abre —o crea— la base en disco. @@ -48,7 +68,14 @@ impl Store { crate::schema::create(&db)?; crate::schema::migrate(&db)?; restrict(path, 0o600)?; - Ok(Self { db }) + // 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 blobs = path + .parent() + .map(|parent| crate::Blobs::at(&parent.join("blobs"))) + .transpose()?; + Ok(Self { db, blobs }) } /// Vuelca el WAL al archivo principal. @@ -123,6 +150,94 @@ 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) + ON CONFLICT(item_id, key) DO UPDATE SET value = excluded.value", + params![id, key, value], + )?; + Ok(()) + } + + pub fn meta(&self, id: i64, key: &str) -> Result> { + Ok(self + .db + .query_row( + "SELECT value FROM item_meta WHERE item_id = ?1 AND key = ?2", + params![id, key], + |row| row.get(0), + ) + .optional()?) + } + + pub fn all_meta(&self, id: i64) -> Result> { + let mut stmt = self + .db + .prepare("SELECT key, value FROM item_meta WHERE item_id = ?1 ORDER BY key")?; + let rows = stmt.query_map([id], |row| Ok((row.get(0)?, row.get(1)?)))?; + 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)", + params![id, job], + )?; + 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 + FROM pending_work w + JOIN items i ON i.id = w.item_id + WHERE w.job = ?1 AND w.not_before <= ?2 AND i.deleted_at IS NULL + ORDER BY i.modified_at DESC + LIMIT ?3", + )?; + let rows = stmt.query_map(params![job, now, limit as i64], |row| row.get(0))?; + Ok(rows.collect::>()?) + } + + pub fn work_done(&self, id: i64, job: &str) -> Result<()> { + self.db.execute( + "DELETE FROM pending_work WHERE item_id = ?1 AND job = ?2", + params![id, job], + )?; + 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 { + self.db.execute( + "UPDATE pending_work + SET attempts = attempts + 1, last_error = ?3, not_before = ?4 + WHERE item_id = ?1 AND job = ?2", + params![id, job, why, retry_at], + )?; + let attempts: i64 = self + .db + .query_row( + "SELECT attempts FROM pending_work WHERE item_id = ?1 AND job = ?2", + params![id, job], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0); + if attempts >= Self::MAX_ATTEMPTS { + self.work_done(id, job)?; + return Ok(false); + } + 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( @@ -156,11 +271,66 @@ 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)? { + continue; + } + blobs.remove(&digest)?; + } + } 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 + .execute("DELETE FROM pending_work WHERE item_id = ?1", [id])?; Ok(()) } + fn blobs_of(&self, id: i64) -> Result> { + let mut stmt = self.db.prepare( + "SELECT blob_path FROM item_formats WHERE item_id = ?1 AND blob_path IS NOT NULL", + )?; + let rows = stmt.query_map([id], |row| row.get(0))?; + Ok(rows.collect::>()?) + } + + fn blob_is_shared(&self, digest: &str, besides: i64) -> Result { + let count: i64 = self.db.query_row( + "SELECT COUNT(*) FROM item_formats WHERE blob_path = ?1 AND item_id != ?2", + params![digest, besides], + |row| row.get(0), + )?; + 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 + .query_row( + "SELECT inline_data, blob_path FROM item_formats + WHERE item_id = ?1 AND format = ?2", + params![id, format], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + match found { + Some((Some(bytes), _)) => Ok(Some(bytes)), + Some((None, Some(digest))) => match &self.blobs { + Some(blobs) => blobs.get(&digest), + None => Ok(None), + }, + _ => Ok(None), + } + } + /// 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> { @@ -181,10 +351,12 @@ impl Store { preview: &str, created_at: i64, ) -> Result { - // Mientras el almacén de blobs no exista, aceptar un `Blob` sería - // guardar la fila con su tamaño y tirar los bytes: el ítem quedaría - // vacío y nadie se enteraría. Es preferible negarse. - if let Some(oversized) = item.oversized_format() { + // 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() + { return Err(Error::NeedsBlobStore { format: oversized.0, size: oversized.1, @@ -200,17 +372,23 @@ impl Store { )?; let id = self.db.last_insert_rowid(); for format in &item.formats { - let (inline, size) = match &format.payload { - Payload::Inline(bytes) => (Some(bytes.clone()), Some(bytes.len() as i64)), - Payload::Blob(bytes) => (None, Some(bytes.len() as i64)), - Payload::TooBig { size } => (None, Some(*size as i64)), - Payload::Announced { size } => (None, size.map(|s| s as i64)), - Payload::Absent => (None, None), + let (inline, blob, size) = match &format.payload { + Payload::Inline(bytes) => (Some(bytes.clone()), None, Some(bytes.len() as i64)), + Payload::Blob(bytes) => { + let digest = match &self.blobs { + Some(blobs) => Some(blobs.put(bytes)?), + None => None, + }; + (None, digest, Some(bytes.len() as i64)) + } + Payload::TooBig { size } => (None, None, Some(*size as i64)), + Payload::Announced { size } => (None, None, size.map(|s| s as i64)), + Payload::Absent => (None, None, None), }; self.db.execute( - "INSERT INTO item_formats (item_id, format, size_bytes, inline_data) - VALUES (?1, ?2, ?3, ?4)", - params![id, format.id, size, inline], + "INSERT INTO item_formats (item_id, format, size_bytes, inline_data, blob_path) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, format.id, size, inline, blob], )?; } Ok(id) @@ -273,6 +451,119 @@ 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()); + } + let expression = expression.flatten(); + + let mut sql = String::from( + "SELECT items.id, items.modified_at, items.kind, items.preview_text, items.pinned + FROM items ", + ); + if expression.is_some() { + sql.push_str("JOIN items_fts ON items.id = items_fts.rowid "); + } + sql.push_str("WHERE items.deleted_at IS NULL "); + if expression.is_some() { + sql.push_str("AND items_fts MATCH :match "); + } + if !filter.kinds.is_empty() { + let list = filter + .kinds + .iter() + .map(|kind| format!("'{}'", kind.as_str())) + .collect::>() + .join(", "); + sql.push_str(&format!("AND items.kind IN ({list}) ")); + } + if !filter.colors.is_empty() { + let list = filter + .colors + .iter() + .map(i64::to_string) + .collect::>() + .join(", "); + sql.push_str(&format!("AND items.card_color IN ({list}) ")); + } + if filter.pinned_only { + sql.push_str("AND items.pinned = 1 "); + } + sql.push_str( + "AND (:after IS NULL OR items.modified_at < :after) + ORDER BY items.modified_at DESC LIMIT :limit", + ); + + 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)]; + if let Some(text) = &expression { + bound.push((":match", text)); + } + let rows = stmt.query_map(bound.as_slice(), |row| { + Ok(Listed { + id: row.get(0)?, + modified_at: row.get(1)?, + kind: row.get(2)?, + preview: row.get(3)?, + pinned: row.get::<_, i64>(4)? == 1, + }) + })?; + 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( + "SELECT id FROM items + WHERE modified_at < ?1 AND pinned = 0 AND deleted_at IS NULL", + )?; + let rows = stmt.query_map([cutoff], |row| row.get(0))?; + rows.collect::>()? + }; + for id in &doomed { + self.mark_deleted(*id, cutoff)?; + } + 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") + } + + fn clear_older_than_matching(&self, at: i64, condition: &str) -> Result { + let doomed: Vec = { + let mut stmt = self.db.prepare(&format!( + "SELECT id FROM items WHERE {condition} AND deleted_at IS NULL" + ))?; + let rows = stmt.query_map([], |row| row.get(0))?; + rows.collect::>()? + }; + for id in &doomed { + self.mark_deleted(*id, at)?; + } + 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. @@ -334,7 +625,7 @@ impl Store { } /// Ajusta los permisos de un archivo o carpeta a lo que se le pide. -fn restrict(path: &std::path::Path, mode: u32) -> Result<()> { +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) @@ -344,6 +635,7 @@ fn restrict(path: &std::path::Path, mode: u32) -> Result<()> { mod tests { use super::*; use cp_core::item::Format; + use cp_core::kind::Kind; fn seeded() -> Store { let store = Store::in_memory().expect("esquema"); @@ -1115,6 +1407,388 @@ mod tests { ); } + fn on_disk() -> (tempfile::TempDir, Store) { + let dir = tempfile::tempdir().expect("carpeta"); + let store = Store::open(&dir.path().join("history.db")).expect("abre"); + (dir, store) + } + + fn big_image(byte: u8) -> Item { + Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Blob(vec![byte; 200_000]), + }], + } + } + + #[test] + fn an_image_too_big_for_the_row_goes_to_disk_and_comes_back() { + let (_dir, store) = on_disk(); + let id = store + .insert_item("uuid-imagen", &big_image(7), "", 1) + .expect("insert"); + let bytes = store + .payload_of(id, "public.png") + .expect("lee") + .expect("está"); + assert_eq!(bytes.len(), 200_000); + assert!(bytes.iter().all(|b| *b == 7)); + } + + #[test] + fn two_copies_of_the_same_image_share_one_file() { + let (dir, store) = on_disk(); + store + .insert_item("uuid-a", &big_image(9), "", 1) + .expect("a"); + store + .insert_item("uuid-b", &big_image(9), "", 2) + .expect("b"); + let files = std::fs::read_dir(dir.path().join("blobs")) + .expect("carpeta") + .count(); + assert_eq!( + files, 1, + "el nombre es el contenido, así que es el mismo archivo" + ); + } + + #[test] + fn deleting_an_image_takes_its_bytes_off_the_disk() { + let (_dir, store) = on_disk(); + let id = store + .insert_item("uuid-borrar", &big_image(3), "", 1) + .expect("insert"); + assert!(store.payload_of(id, "public.png").expect("lee").is_some()); + store.mark_deleted(id, 2).expect("borra"); + assert!( + store.payload_of(id, "public.png").expect("lee").is_none(), + "los bytes de una imagen borrada no pueden seguir en disco" + ); + } + + #[test] + fn a_shared_blob_survives_deleting_one_of_its_owners() { + let (_dir, store) = on_disk(); + let first = store + .insert_item("uuid-1", &big_image(5), "", 1) + .expect("a"); + let second = store + .insert_item("uuid-2", &big_image(5), "", 2) + .expect("b"); + store.mark_deleted(first, 3).expect("borra el primero"); + assert!( + store + .payload_of(second, "public.png") + .expect("lee") + .is_some(), + "el otro ítem sigue necesitando esos bytes" + ); + } + + #[test] + fn an_in_memory_store_refuses_what_it_cannot_keep() { + let store = Store::in_memory().expect("esquema"); + assert!( + store + .insert_item("uuid-grande", &big_image(1), "", 1) + .is_err(), + "sin carpeta donde escribir, mejor negarse" + ); + } + + #[test] + fn the_queue_hands_out_work_and_forgets_it_when_done() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-trabajo", "algo", 1) + .expect("insert"); + store.enqueue(id, "ocr").expect("encola"); + store + .enqueue(id, "ocr") + .expect("encolar dos veces no duplica"); + assert_eq!( + store.take_pending("ocr", 10, 5).expect("pendientes"), + vec![id] + ); + assert!( + store + .take_pending("thumbnail", 10, 5) + .expect("otro tipo") + .is_empty(), + "cada cola es la suya" + ); + store.work_done(id, "ocr").expect("hecho"); + assert!( + store + .take_pending("ocr", 10, 5) + .expect("pendientes") + .is_empty() + ); + } + + #[test] + fn a_job_that_keeps_failing_is_given_up_on() { + let store = Store::in_memory().expect("esquema"); + let id = store.insert_text("uuid-falla", "algo", 1).expect("insert"); + store.enqueue(id, "ocr").expect("encola"); + for attempt in 1..Store::MAX_ATTEMPTS { + assert!( + store + .work_failed(id, "ocr", "no se pudo", 0) + .expect("falla"), + "intento {attempt} todavía se reintenta" + ); + } + assert!( + !store + .work_failed(id, "ocr", "no se pudo", 0) + .expect("falla"), + "al agotar los intentos se abandona" + ); + assert!( + store + .take_pending("ocr", 10, 5) + .expect("pendientes") + .is_empty() + ); + } + + #[test] + fn a_failed_job_waits_before_being_retried() { + let store = Store::in_memory().expect("esquema"); + let id = store.insert_text("uuid-espera", "algo", 1).expect("insert"); + store.enqueue(id, "ocr").expect("encola"); + store + .work_failed(id, "ocr", "temporal", 500) + .expect("falla"); + assert!( + store + .take_pending("ocr", 100, 5) + .expect("aún no") + .is_empty(), + "no antes de su hora" + ); + assert_eq!(store.take_pending("ocr", 500, 5).expect("ya"), vec![id]); + } + + #[test] + fn deleted_items_drop_out_of_the_queue() { + let store = Store::in_memory().expect("esquema"); + let id = store.insert_text("uuid-fuera", "algo", 1).expect("insert"); + store.enqueue(id, "ocr").expect("encola"); + store.mark_deleted(id, 2).expect("borra"); + assert!( + store + .take_pending("ocr", 10, 5) + .expect("pendientes") + .is_empty(), + "no se enriquece lo que el usuario borró" + ); + } + + #[test] + fn metadata_is_kept_per_key_and_replaced_not_duplicated() { + let store = Store::in_memory().expect("esquema"); + let id = store + .insert_text("uuid-meta", "un vídeo", 1) + .expect("insert"); + store.set_meta(id, "duration", "227").expect("pone"); + store.set_meta(id, "width", "1920").expect("pone"); + store.set_meta(id, "duration", "228").expect("corrige"); + assert_eq!( + store.meta(id, "duration").expect("lee").as_deref(), + Some("228") + ); + assert_eq!(store.all_meta(id).expect("todo").len(), 2); + assert!(store.meta(id, "artist").expect("lee").is_none()); + } + + #[test] + fn metadata_goes_away_with_the_item() { + let store = Store::in_memory().expect("esquema"); + let id = store.insert_text("uuid-meta", "algo", 1).expect("insert"); + store.set_meta(id, "artist", "alguien").expect("pone"); + store.mark_deleted(id, 2).expect("borra"); + assert!( + store.all_meta(id).expect("todo").is_empty(), + "los datos derivados son del usuario igual que el contenido" + ); + } + + fn a_little_history() -> Store { + let store = Store::in_memory().expect("esquema"); + let rows = [ + ("uuid-1", "primera nota", Kind::Text, 10), + ("uuid-2", "alguien@ejemplo.test", Kind::Email, 20), + ("uuid-3", "#FF8800", Kind::Color, 30), + ("uuid-4", "segunda nota", Kind::Text, 40), + ]; + for (uuid, text, kind, at) in rows { + let item = Item { + kind: Some(kind), + formats: vec![Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(text.as_bytes().to_vec()), + }], + }; + store.insert_item(uuid, &item, text, at).expect("insert"); + } + store + } + + #[test] + fn the_panel_can_ask_for_the_latest_without_searching_anything() { + let store = a_little_history(); + let listed = store.list(&Filter::default(), 10, None).expect("listado"); + assert_eq!(listed.len(), 4, "sin término se devuelve el historial"); + assert_eq!( + listed.first().map(|one| one.preview.as_str()), + Some("segunda nota"), + "lo más reciente primero" + ); + } + + #[test] + fn the_list_can_be_filtered_by_kind() { + let store = a_little_history(); + let filter = Filter { + kinds: vec![Kind::Text], + ..Default::default() + }; + let listed = store.list(&filter, 10, None).expect("listado"); + assert_eq!(listed.len(), 2); + assert!(listed.iter().all(|one| one.kind.as_deref() == Some("text"))); + } + + #[test] + fn several_kinds_can_be_asked_for_at_once() { + let store = a_little_history(); + let filter = Filter { + kinds: vec![Kind::Email, Kind::Color], + ..Default::default() + }; + assert_eq!(store.list(&filter, 10, None).expect("listado").len(), 2); + } + + #[test] + fn filtering_and_searching_work_together() { + let store = a_little_history(); + let filter = Filter { + query: Some("nota".into()), + kinds: vec![Kind::Text], + ..Default::default() + }; + assert_eq!(store.list(&filter, 10, None).expect("listado").len(), 2); + + let narrower = Filter { + query: Some("nota".into()), + kinds: vec![Kind::Email], + ..Default::default() + }; + assert!( + store.list(&narrower, 10, None).expect("listado").is_empty(), + "el filtro y el término se aplican los dos" + ); + } + + #[test] + fn only_pinned_can_be_asked_for() { + let store = a_little_history(); + let listed = store.list(&Filter::default(), 10, None).expect("listado"); + let id = listed.first().expect("hay").id; + store.pin(id).expect("fija"); + let filter = Filter { + pinned_only: true, + ..Default::default() + }; + let pinned = store.list(&filter, 10, None).expect("listado"); + assert_eq!(pinned.len(), 1); + assert!(pinned[0].pinned); + } + + #[test] + fn the_list_pages_with_the_same_cursor_as_the_search() { + let store = a_little_history(); + let first = store.list(&Filter::default(), 2, None).expect("página"); + assert_eq!(first.len(), 2); + let next = store + .list( + &Filter::default(), + 2, + first.last().map(|one| one.modified_at), + ) + .expect("siguiente"); + assert_eq!(next.len(), 2); + assert!(next.iter().all(|one| !first.contains(one))); + } + + #[test] + fn a_search_with_nothing_usable_returns_nothing_not_everything() { + let store = a_little_history(); + let filter = Filter { + query: Some("!!!".into()), + ..Default::default() + }; + assert!( + store.list(&filter, 10, None).expect("listado").is_empty(), + "pedir buscar algo imposible no puede devolver el historial entero" + ); + } + + #[test] + fn deleted_items_never_show_up_in_the_list() { + let store = a_little_history(); + let listed = store.list(&Filter::default(), 10, None).expect("listado"); + store.mark_deleted(listed[0].id, 99).expect("borra"); + assert_eq!( + store + .list(&Filter::default(), 10, None) + .expect("listado") + .len(), + 3 + ); + } + + #[test] + fn retention_takes_the_old_and_leaves_what_was_pinned() { + let store = a_little_history(); + let listed = store.list(&Filter::default(), 10, None).expect("listado"); + let oldest = listed.last().expect("hay").id; + store.pin(oldest).expect("fija el más viejo"); + + let removed = store.clear_older_than(35).expect("retención"); + assert_eq!( + removed, 2, + "se van los de antes del corte que no estén fijados" + ); + let left = store.list(&Filter::default(), 10, None).expect("listado"); + assert_eq!(left.len(), 2); + assert!( + left.iter().any(|one| one.id == oldest), + "un ítem fijado no lo borra la limpieza" + ); + } + + #[test] + fn clearing_everything_still_respects_what_was_pinned() { + let store = a_little_history(); + let listed = store.list(&Filter::default(), 10, None).expect("listado"); + store.pin(listed[0].id).expect("fija"); + let removed = store.clear_all_unpinned(100).expect("vacía"); + assert_eq!(removed, 3); + assert_eq!(store.count().expect("cuenta"), 1); + } + + #[test] + fn retention_with_nothing_old_enough_removes_nothing() { + let store = a_little_history(); + assert_eq!(store.clear_older_than(0).expect("retención"), 0); + assert_eq!(store.count().expect("cuenta"), 4); + } + #[test] fn a_word_that_is_not_there_finds_nothing() { let store = seeded(); From 086e0503024529e046d9b60eb68e8f1e9245585d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 22:45:31 -0300 Subject: [PATCH 17/18] feat: thumbnails and media metadata close the macOS core --- Cargo.lock | 257 ++++++++++++++++++++++++- Cargo.toml | 3 +- crates/cp-core/Cargo.toml | 1 + crates/cp-core/src/lib.rs | 1 + crates/cp-core/src/thumbnail.rs | 128 +++++++++++++ crates/cp-mac-sys/Cargo.toml | 1 + crates/cp-mac-sys/src/frontmost.rs | 48 +++++ crates/cp-mac-sys/src/lib.rs | 1 + crates/cp-mac-sys/src/media.rs | 70 +++++++ crates/cp-mac-sys/src/pasteboard.rs | 13 ++ crates/cp-mac/examples/probe.rs | 284 ++++++++++++++++++++++++++++ crates/cp-mac/src/lib.rs | 1 + crates/cp-mac/src/restore.rs | 54 ++++++ 13 files changed, 860 insertions(+), 2 deletions(-) create mode 100644 crates/cp-core/src/thumbnail.rs create mode 100644 crates/cp-mac-sys/src/media.rs create mode 100644 crates/cp-mac/src/restore.rs diff --git a/Cargo.lock b/Cargo.lock index 9004140..25f47aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "arrayvec" version = "0.7.8" @@ -57,6 +63,18 @@ dependencies = [ "objc2", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cc" version = "1.4.5" @@ -73,6 +91,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -83,6 +107,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" name = "cp-core" version = "3.0.0" dependencies = [ + "image", "proptest", "thiserror", "unicode-normalization", @@ -106,6 +131,7 @@ version = "3.0.0" dependencies = [ "objc2", "objc2-app-kit", + "objc2-av-foundation", "objc2-core-graphics", "objc2-foundation", "objc2-vision", @@ -132,6 +158,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "dispatch2" version = "0.3.1" @@ -139,6 +180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags", + "block2", "objc2", ] @@ -170,12 +212,38 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -211,6 +279,27 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -229,6 +318,35 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + [[package]] name = "libc" version = "0.2.189" @@ -252,6 +370,36 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -296,6 +444,29 @@ name = "objc2-av-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "objc2", + "objc2-avf-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation", + "objc2-image-io", + "objc2-media-toolbox", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ "objc2", "objc2-foundation", @@ -473,6 +644,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-media-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" +dependencies = [ + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-media", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -526,6 +709,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -563,12 +759,24 @@ dependencies = [ "unarray", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.47" @@ -668,7 +876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -679,6 +887,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "smallvec" version = "1.16.1" @@ -740,6 +954,20 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinyvec" version = "1.13.2" @@ -800,6 +1028,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "windows-link" version = "0.2.1" @@ -846,3 +1080,24 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 4a1791a..d26c4d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ rusqlite = { version = "0.37", features = ["bundled"] } blake3 = "1" xxhash-rust = { version = "0.8", features = ["xxh3"] } unicode-normalization = "0.1" -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "gif", "bmp", "webp"] } proptest = "1" tempfile = "3" @@ -34,6 +34,7 @@ objc2-app-kit = "0.3" objc2-core-graphics = "0.3" objc2-application-services = "0.3" objc2-vision = "0.3" +objc2-av-foundation = { version = "0.3", features = ["objc2-core-media", "AVAssetTrack"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/cp-core/Cargo.toml b/crates/cp-core/Cargo.toml index 3fa8531..4f3bcbd 100644 --- a/crates/cp-core/Cargo.toml +++ b/crates/cp-core/Cargo.toml @@ -11,6 +11,7 @@ publish = false thiserror.workspace = true unicode-normalization.workspace = true xxhash-rust.workspace = true +image.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs index c009d1f..06c0615 100644 --- a/crates/cp-core/src/lib.rs +++ b/crates/cp-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod item; pub mod kind; pub mod paste; pub mod search; +pub mod thumbnail; pub mod watch; pub use formats::Family; diff --git a/crates/cp-core/src/thumbnail.rs b/crates/cp-core/src/thumbnail.rs new file mode 100644 index 0000000..0f91aa2 --- /dev/null +++ b/crates/cp-core/src/thumbnail.rs @@ -0,0 +1,128 @@ +/// 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)] +pub struct Size { + pub width: u32, + 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() + .ok()?; + let (width, height) = reader.into_dimensions().ok()?; + 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 más pequeña que el destino, y + // una miniatura mayor que su original no tiene sentido: ocuparía más y + // se vería peor. + let scaled = if decoded.width() > max_side || decoded.height() > max_side { + decoded.thumbnail(max_side, max_side) + } else { + decoded + }; + let mut out = std::io::Cursor::new(Vec::new()); + scaled + .write_to(&mut out, image::ImageFormat::Png) + .ok() + .map(|()| out.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png(width: u32, height: u32) -> Vec { + let buffer = image::RgbaImage::from_fn(width, height, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]) + }); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(buffer) + .write_to(&mut out, image::ImageFormat::Png) + .expect("png"); + out.into_inner() + } + + #[test] + fn the_size_is_read_without_decoding_the_whole_image() { + let bytes = png(1920, 1080); + assert_eq!( + size_of(&bytes), + Some(Size { + width: 1920, + height: 1080 + }) + ); + } + + #[test] + fn a_thumbnail_keeps_the_proportions() { + let bytes = png(1600, 400); + let thumb = of_image(&bytes, MAX_SIDE).expect("miniatura"); + let size = size_of(&thumb).expect("tamaño"); + assert_eq!(size.width, MAX_SIDE); + assert_eq!(size.height, MAX_SIDE / 4, "4:1 sigue siendo 4:1"); + } + + #[test] + fn a_tall_image_is_bounded_by_its_height() { + let bytes = png(300, 1200); + let size = size_of(&of_image(&bytes, MAX_SIDE).expect("miniatura")).expect("tamaño"); + assert_eq!(size.height, MAX_SIDE); + assert!(size.width < MAX_SIDE); + } + + #[test] + fn an_image_that_already_fits_is_not_enlarged() { + for (width, height) in [(1, 1), (64, 64), (MAX_SIDE, MAX_SIDE), (100, 250)] { + let bytes = png(width, height); + let size = size_of(&of_image(&bytes, MAX_SIDE).expect("miniatura")).expect("tamaño"); + assert_eq!( + size, + Size { width, height }, + "una miniatura mayor que su original ocupa más y se ve peor" + ); + } + } + + #[test] + fn a_thumbnail_weighs_much_less_than_the_original() { + let bytes = png(2000, 2000); + let thumb = of_image(&bytes, MAX_SIDE).expect("miniatura"); + assert!( + thumb.len() * 10 < bytes.len(), + "miniatura de {} bytes para un original de {}", + thumb.len(), + bytes.len() + ); + } + + #[test] + fn something_that_is_not_an_image_is_refused_not_guessed() { + assert!(size_of(b"esto no es una imagen").is_none()); + assert!(of_image(b"esto no es una imagen", MAX_SIDE).is_none()); + assert!(size_of(&[]).is_none()); + } + + #[test] + fn a_truncated_image_does_not_panic() { + let bytes = png(500, 500); + let half = &bytes[..bytes.len() / 2]; + assert!(of_image(half, MAX_SIDE).is_none()); + } +} diff --git a/crates/cp-mac-sys/Cargo.toml b/crates/cp-mac-sys/Cargo.toml index f7362f2..7c843b3 100644 --- a/crates/cp-mac-sys/Cargo.toml +++ b/crates/cp-mac-sys/Cargo.toml @@ -13,6 +13,7 @@ objc2-foundation.workspace = true objc2-app-kit.workspace = true objc2-core-graphics.workspace = true objc2-vision.workspace = true +objc2-av-foundation.workspace = true [lints.rust] unsafe_code = "allow" diff --git a/crates/cp-mac-sys/src/frontmost.rs b/crates/cp-mac-sys/src/frontmost.rs index 4d90c64..e515d6b 100644 --- a/crates/cp-mac-sys/src/frontmost.rs +++ b/crates/cp-mac-sys/src/frontmost.rs @@ -13,6 +13,54 @@ 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() + .filter(|line| !line.trim().is_empty()) + .filter(|url| { + let path = url + .strip_prefix("file://") + .map(percent_decoded) + .unwrap_or_else(|| (*url).to_string()); + !std::path::Path::new(&path).exists() + }) + .map(str::to_owned) + .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()); + let mut at = 0; + while at < bytes.len() { + if bytes[at] == b'%' && at + 2 < bytes.len() { + let pair = std::str::from_utf8(&bytes[at + 1..at + 3]).ok(); + if let Some(byte) = pair.and_then(|hex| u8::from_str_radix(hex, 16).ok()) { + out.push(byte); + at += 3; + continue; + } + } + out.push(bytes[at]); + at += 1; + } + 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 diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs index 10e2e92..9423bcc 100644 --- a/crates/cp-mac-sys/src/lib.rs +++ b/crates/cp-mac-sys/src/lib.rs @@ -3,6 +3,7 @@ pub mod frontmost; pub mod keyboard; pub mod keystroke; +pub mod media; pub mod ocr; pub mod pasteboard; pub mod paths; diff --git a/crates/cp-mac-sys/src/media.rs b/crates/cp-mac-sys/src/media.rs new file mode 100644 index 0000000..527bad3 --- /dev/null +++ b/crates/cp-mac-sys/src/media.rs @@ -0,0 +1,70 @@ +//! 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, + pub title: Option, + pub artist: Option, + pub album: Option, +} + +impl MediaInfo { + pub fn is_empty(&self) -> bool { + *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() + .flatten() + .map(String::as_str) + .collect::>() + .join(" ") + } +} + +/// 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. + let asset = unsafe { AVURLAsset::URLAssetWithURL_options(&url, None) }; + + let mut info = MediaInfo::default(); + + // SAFETY: el asset sigue vivo; devuelve una estructura por valor. + let duration = unsafe { asset.duration() }; + if duration.timescale != 0 { + let seconds = duration.value as f64 / duration.timescale as f64; + if seconds.is_finite() && seconds > 0.0 { + info.duration = Some(seconds); + } + } + + // 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. + let tracks = unsafe { asset.tracks() }; + for track in tracks.iter() { + // SAFETY: la pista pertenece al array, que sigue vivo. + let size = unsafe { track.naturalSize() }; + if size.width >= 1.0 && size.height >= 1.0 { + info.width = Some(size.width as u32); + info.height = Some(size.height as u32); + break; + } + } + + (!info.is_empty()).then_some(info) +} diff --git a/crates/cp-mac-sys/src/pasteboard.rs b/crates/cp-mac-sys/src/pasteboard.rs index bc4cb1a..7272783 100644 --- a/crates/cp-mac-sys/src/pasteboard.rs +++ b/crates/cp-mac-sys/src/pasteboard.rs @@ -96,6 +96,19 @@ 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)| { + let name = NSString::from_str(uti); + let data = objc2_foundation::NSData::with_bytes(bytes); + self.inner.setData_forType(Some(&data), &name) + }) + } + pub fn write_text(&self, text: &str) -> bool { self.inner.clearContents(); let name = NSString::from_str("public.utf8-plain-text"); diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 338dca3..1153c6a 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -176,6 +176,290 @@ fn main() -> std::process::ExitCode { } }); + 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.group("G · Clasificación"); for (id, text, expected) in [ diff --git a/crates/cp-mac/src/lib.rs b/crates/cp-mac/src/lib.rs index 3df44f3..33f875e 100644 --- a/crates/cp-mac/src/lib.rs +++ b/crates/cp-mac/src/lib.rs @@ -3,6 +3,7 @@ pub mod capture; 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. diff --git a/crates/cp-mac/src/restore.rs b/crates/cp-mac/src/restore.rs new file mode 100644 index 0000000..80f1b8a --- /dev/null +++ b/crates/cp-mac/src/restore.rs @@ -0,0 +1,54 @@ +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 + .iter() + .filter_map(|format| match &format.payload { + 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(); + + if writable.is_empty() { + return Restored::NothingToWrite; + } + + let entries: Vec<(&str, &[u8])> = writable.clone(); + if pb.write_all(&entries) { + Restored::Written { + formats: writable.len(), + incomplete: writable.len() != item.formats.len(), + } + } else { + Restored::Failed + } +} + +#[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. + NothingToWrite, + Failed, +} From 6f27750d996de6336ffc6de8f710697b0e091b9e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Sun, 13 Sep 2026 23:06:37 -0300 Subject: [PATCH 18/18] feat: plain-text paste stops mutilating the item, and copying stops --- crates/cp-mac/examples/probe.rs | 52 +++++++++++++++++ crates/cp-mac/src/restore.rs | 36 ++++++++++++ crates/cp-store/src/store.rs | 99 +++++++++++++++++++++++++++------ 3 files changed, 170 insertions(+), 17 deletions(-) diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/probe.rs index 1153c6a..fed168c 100644 --- a/crates/cp-mac/examples/probe.rs +++ b/crates/cp-mac/examples/probe.rs @@ -460,6 +460,58 @@ fn main() -> std::process::ExitCode { 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 [ diff --git a/crates/cp-mac/src/restore.rs b/crates/cp-mac/src/restore.rs index 80f1b8a..51b2c4d 100644 --- a/crates/cp-mac/src/restore.rs +++ b/crates/cp-mac/src/restore.rs @@ -40,6 +40,42 @@ 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 + .iter() + .find_map(|format| match &format.payload { + Payload::Inline(bytes) | Payload::Blob(bytes) if format.id == PLAIN_TEXT => { + Some(bytes.as_slice()) + } + _ => None, + }) + else { + return Restored::NothingToWrite; + }; + + 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 { + Restored::Failed + } +} + +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 diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 0f8bded..abececa 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -111,18 +111,47 @@ impl Store { } /// Volver a copiar algo que ya estaba lo sube en la lista, no lo duplica. - /// La 2.x ordena por `modified_at` justamente por esto. - pub fn touch(&self, id: i64, at: i64) -> Result<()> { + /// + /// **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", + params![id, at], + )?; + Ok(()) + } + + /// Se pegó desde el historial: eso sí cuenta. + pub fn record_paste(&self, id: i64, at: i64) -> Result<()> { self.db.execute( "UPDATE items - SET modified_at = ?2, last_used_at = ?2, updated_at = ?2, - paste_count = paste_count + 1 + SET last_used_at = ?2, updated_at = ?2, paste_count = paste_count + 1 WHERE id = ?1", params![id, at], )?; 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", + params![id, color, at], + )?; + Ok(()) + } + + pub fn paste_count(&self, id: i64) -> Result { + Ok(self + .db + .query_row("SELECT paste_count FROM items WHERE id = ?1", [id], |row| { + row.get(0) + })?) + } + /// El texto que Vision leyó dentro de una imagen. /// /// Va en su propia columna del índice y no en `search_text` para que se @@ -1086,7 +1115,7 @@ mod tests { let before = store.search("lo").expect("consulta"); assert_eq!(before.first().map(String::as_str), Some("lo nuevo")); - store.touch(first, 30).expect("recopiado"); + store.reactivate(first, 30).expect("recopiado"); let after = store.search("lo").expect("consulta"); assert_eq!( after.first().map(String::as_str), @@ -1222,7 +1251,7 @@ mod tests { let changed = store.changed_since(50).expect("cambios"); assert_eq!(changed, vec!["uuid-nuevo".to_string()]); - store.touch(old, 200).expect("recopiado"); + store.reactivate(old, 200).expect("recopiado"); let after = store.changed_since(50).expect("cambios"); assert_eq!( after, @@ -1232,20 +1261,56 @@ mod tests { } #[test] - fn touching_an_item_counts_the_paste() { + fn copying_something_again_does_not_inflate_the_paste_counter() { let store = Store::in_memory().expect("esquema"); let id = store - .insert_text("uuid-cuenta", "pegado", 1) + .insert_text("uuid-recopiado", "algo", 1) .expect("insert"); - store.touch(id, 2).expect("uno"); - store.touch(id, 3).expect("dos"); - let count: i64 = store - .db - .query_row("SELECT paste_count FROM items WHERE id = ?1", [id], |row| { - row.get(0) - }) - .expect("consulta"); - assert_eq!(count, 2); + for at in 2..10 { + store.reactivate(id, at).expect("recopiado"); + } + assert_eq!( + store.paste_count(id).expect("cuenta"), + 0, + "volver a copiar no es pegar, y el ×N de la tarjeta lo enseña" + ); + } + + #[test] + fn pasting_from_the_history_is_what_counts() { + let store = Store::in_memory().expect("esquema"); + let id = store.insert_text("uuid-pegado", "algo", 1).expect("insert"); + store.record_paste(id, 2).expect("pega"); + store.record_paste(id, 3).expect("pega"); + assert_eq!(store.paste_count(id).expect("cuenta"), 2); + } + + #[test] + fn pasting_does_not_move_the_item_up_the_list() { + let store = a_little_history(); + let listed = store.list(&Filter::default(), 10, None).expect("listado"); + let oldest = listed.last().expect("hay").id; + store.record_paste(oldest, 999).expect("pega"); + let after = store.list(&Filter::default(), 10, None).expect("listado"); + assert_eq!( + after.last().map(|one| one.id), + Some(oldest), + "pegar cuenta, pero no reordena el historial" + ); + } + + #[test] + fn the_colour_can_be_set_and_filtered_by() { + let store = a_little_history(); + let listed = store.list(&Filter::default(), 10, None).expect("listado"); + store.set_color(listed[0].id, 3, 100).expect("color"); + let filter = Filter { + colors: vec![3], + ..Default::default() + }; + let coloured = store.list(&filter, 10, None).expect("listado"); + assert_eq!(coloured.len(), 1); + assert_eq!(coloured[0].id, listed[0].id); } #[test]