diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 1815ee9..5917349 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -1,10 +1,10 @@ -# Un mutante equivalente no es un hueco de cobertura: es código que se puede -# escribir de dos formas con el mismo comportamiento. Excluirlo es honesto solo -# si está demostrado y si algo avisa cuando deje de serlo. -# -# `of_image` decide escalar con `longest_side > max_side`. El único caso donde -# `>` y `>=` discrepan es cuando el lado mayor vale exactamente `max_side`, y -# ahí el ratio interno de `image::thumbnail` es 1: devuelve la imagen sin tocar -# un solo píxel. Lo prueba `scaling_to_the_size_it_already_has_changes_nothing`, -# que falla si una versión futura de `image` cambia ese comportamiento. + + exclude_re = ["replace > with >= in of_image"] + +exclude_globs = [ + "crates/cp-win/src/capture.rs", + "crates/cp-win/src/paste.rs", + "crates/cp-win/src/restore.rs", + "crates/cp-win/src/watching.rs", +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a60a07..ece0500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,12 +24,16 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - - name: The probe body is formatted too - run: rustfmt --check --edition 2024 crates/cp-mac/examples/probe/battery.rs + - name: The probe bodies are formatted too + run: rustfmt --check --edition 2024 crates/cp-mac/examples/probe/battery.rs crates/cp-win/examples/probe/battery.rs - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace - run: cargo run -p cp-mac --example probe if: runner.os == 'macOS' + - name: The probe reads a real clipboard, so leave something on it + if: runner.os == 'Windows' + shell: pwsh + run: Set-Clipboard -Value "cp-probe" - run: cargo run -p cp-win --example probe if: runner.os == 'Windows' diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index e5e6814..40c3d29 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -1,15 +1,5 @@ name: CLA -# Dual licensing only works if every merged line can be relicensed, so a -# contribution cannot land before its author has signed. Signatures are stored -# in a separate repository rather than this one to keep the audit trail intact -# even if this repo's history is ever rewritten. -# -# Tools are deliberately NOT on the allowlist. A co-author that cannot sign -# blocks its own pull request, and that is the intended outcome: CONTRIBUTING -# asks for the credit line to name people only, so a blocked PR means the guide -# went unread. Reopening it without that trailer costs a minute. - on: issue_comment: types: [created] @@ -26,8 +16,7 @@ jobs: cla: name: Check CLA signature runs-on: ubuntu-latest - # Only react to the signature phrase or to PR events, never to ordinary - # comment traffic. + if: > (github.event_name == 'issue_comment' && github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || @@ -37,13 +26,10 @@ jobs: uses: contributor-assistant/github-action@v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Fine-grained PAT with contents:write on the signatures repository. - # Without it the action cannot record a signature. + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }} with: - # Scoped per project: one signatures repository serves every - # dual-licensed repo, but a signature given for one CLA must never - # count as consent for another project's. + path-to-signatures: "signatures/copypaste/v1/cla.json" path-to-document: "https://github.com/rgdevment/CopyPaste/blob/main/CLA.md" branch: "main" diff --git a/.github/workflows/mutants-sweep.yml b/.github/workflows/mutants-sweep.yml new file mode 100644 index 0000000..1a4b1eb --- /dev/null +++ b/.github/workflows/mutants-sweep.yml @@ -0,0 +1,128 @@ +name: Mutants sweep + +on: + schedule: + - cron: "0 4 * * 0" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + core: + name: cp-core and cp-store / shard ${{ matrix.shard }} of 2 + runs-on: macos-15 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@cargo-mutants + + - name: Half of every mutant the pure crates carry + id: run + run: | + set +e + cargo mutants -p cp-core -p cp-store --shard $(( ${{ matrix.shard }} - 1 ))/2 --no-times -j 2 + echo "code=$?" >> "$GITHUB_OUTPUT" + + - name: What survived + if: always() + env: + CODE: ${{ steps.run.outputs.code }} + TITLE: cp-core and cp-store, shard ${{ matrix.shard }} of 2 + run: bash scripts/mutants-report.sh + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: mutants-core-${{ matrix.shard }} + path: mutants.out/ + retention-days: 14 + + windows: + name: cp-win / shard ${{ matrix.shard }} of 2 + runs-on: windows-2025 + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@cargo-mutants + + - name: Half of every mutant the Windows crate carries + id: run + shell: bash + run: | + set +e + cargo mutants -p cp-win --shard $(( ${{ matrix.shard }} - 1 ))/2 --no-times -j 2 + echo "code=$?" >> "$GITHUB_OUTPUT" + + - name: What survived + if: always() + shell: bash + env: + CODE: ${{ steps.run.outputs.code }} + TITLE: cp-win, shard ${{ matrix.shard }} of 2 + run: bash scripts/mutants-report.sh + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: mutants-windows-${{ matrix.shard }} + path: mutants.out/ + retention-days: 14 + + score: + name: what the whole sweep came to + if: always() && !cancelled() + needs: [core, windows] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + pattern: mutants-* + path: outcomes + + - name: Every shard told as one number + env: + WANT: 4 + run: python3 scripts/mutants_score.py + + - name: The number, on the branch that holds nothing else + run: | + mv mutants.json "$RUNNER_TEMP/mutants.json" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git fetch origin score --depth 1 2>/dev/null; then + git switch -C score FETCH_HEAD + else + git switch --orphan score + fi + git rm -rq --cached . 2>/dev/null || true + + cp "$RUNNER_TEMP/mutants.json" mutants.json + git add -- mutants.json + if git diff --cached --quiet; then + echo "the score has not moved" + exit 0 + fi + git commit -m "chore: what the sweep of $(date -u +%Y-%m-%d) came to" + git push origin score diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml new file mode 100644 index 0000000..fad75b2 --- /dev/null +++ b/.github/workflows/mutants.yml @@ -0,0 +1,75 @@ +name: Mutants + +on: + pull_request: + paths: + - crates/** + - .cargo/mutants.toml + - .github/workflows/mutants.yml + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + on-the-branch: + name: ${{ matrix.name }} / the lines this branch changed + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: windows-2025 + name: windows + crates: -p cp-core -p cp-store -p cp-win + - os: macos-15 + name: macos + crates: -p cp-core -p cp-store + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: What the branch changed in the crates + id: touched + shell: bash + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + from=$(git merge-base "$BASE" HEAD) + git diff "$from" -- crates > branch.diff + if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi + + - uses: dtolnay/rust-toolchain@stable + if: steps.touched.outputs.any == 'yes' + - uses: Swatinem/rust-cache@v2 + if: steps.touched.outputs.any == 'yes' + - uses: taiki-e/install-action@cargo-mutants + if: steps.touched.outputs.any == 'yes' + + - name: A mutant that lives on a line this branch wrote + id: run + if: steps.touched.outputs.any == 'yes' + shell: bash + run: | + set +e + cargo mutants ${{ matrix.crates }} --in-diff branch.diff --no-times -j 2 + echo "code=$?" >> "$GITHUB_OUTPUT" + + - name: What survived + if: always() && steps.touched.outputs.any == 'yes' + shell: bash + env: + CODE: ${{ steps.run.outputs.code }} + TITLE: ${{ matrix.name }}, the lines this branch changed + run: bash scripts/mutants-report.sh + + - uses: actions/upload-artifact@v4 + if: always() && steps.touched.outputs.any == 'yes' + with: + name: mutants-branch-${{ matrix.name }} + path: mutants.out/ + retention-days: 7 diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index c9847fb..6d4ba12 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -45,32 +45,25 @@ jobs: - name: No voseo in the Spanish anywhere run: | 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 + crates docs README.md --include='*.rs' --include='*.md' 2>/dev/null | grep -q .; then echo "espanol neutro, sin voseo"; exit 1 fi - name: No peninsular Spanish in what a person reads run: | if grep -rniE '\b(fichero|ficheros|ordenador|pulsa|pulsar|pulsando)\b' \ - crates docs README.md --include='*.rs' --include='*.md' 2>/dev/null; then + crates docs README.md --include='*.rs' --include='*.md' 2>/dev/null | grep -q .; then echo "espanol neutro: archivo, computador, presiona"; exit 1 fi - - name: The code carries no comments but SAFETY + - name: The code carries no comments run: | - if grep -rnE '^[ ]*//' crates --include='*.rs' | grep -v 'SAFETY:'; then + if grep -rnE '[/]{2}' crates --include='*.rs' | grep -vE '[a-z]+:[/][/]' | grep -q .; then echo "el codigo va sin comentarios; lo que haya que explicar va al expediente"; exit 1 fi - - - name: Every SAFETY note is one line of English - run: | - if ! grep -rh 'SAFETY:' crates --include='*.rs' | iconv -f utf-8 -t ascii >/dev/null; then - echo "las notas SAFETY van en ingles"; exit 1 + if grep -rnE '[/][*]' crates --include='*.rs' | grep -q .; then + echo "tampoco comentarios de bloque"; exit 1 fi - awk '/SAFETY:/ { safety = 1; next } - /^[ ]*\/\// { if (safety) { print FILENAME":"FNR": la nota SAFETY sigue en otra linea"; bad = 1 } } - { safety = 0 } - END { exit bad }' $(find crates -name '*.rs') deterministic: name: Tests are deterministic (${{ matrix.os }}) @@ -89,24 +82,6 @@ jobs: - 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: Every mutant dies in the crates without a system call (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: macos-15 - crates: -p cp-core -p cp-store - - os: windows-2025 - crates: -p cp-core -p cp-store -p cp-win - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-mutants - - run: cargo mutants ${{ matrix.crates }} --no-times - coverage: name: Coverage does not slip runs-on: macos-15 @@ -132,6 +107,9 @@ jobs: - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@cargo-llvm-cov - run: cargo llvm-cov --no-report -p cp-core -p cp-store -p cp-win -p cp-win-sys + - name: The probe reads a real clipboard, so leave something on it + shell: pwsh + run: Set-Clipboard -Value "cp-probe" - run: cargo llvm-cov --no-report run -p cp-win --example probe - run: cargo llvm-cov report --fail-under-lines 95 --summary-only diff --git a/README.md b/README.md index 0598469..4291ec3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ Coverage + + Mutation score + Latest Release diff --git a/crates/cp-core/src/dib.rs b/crates/cp-core/src/dib.rs index 8a10c82..bd57194 100644 --- a/crates/cp-core/src/dib.rs +++ b/crates/cp-core/src/dib.rs @@ -388,10 +388,8 @@ mod tests { fn a_mixed_alpha_channel_is_real_transparency() { let mut dib = Dib::rgb32(2, 2); dib.pixels = vec![ - 0x40, 0x80, 0xC0, 0xFF, // - 0x40, 0x80, 0xC0, 0x00, // - 0x40, 0x80, 0xC0, 0xFF, // - 0x40, 0x80, 0xC0, 0xFF, + 0x40, 0x80, 0xC0, 0xFF, 0x40, 0x80, 0xC0, 0x00, 0x40, 0x80, 0xC0, 0xFF, 0x40, 0x80, + 0xC0, 0xFF, ]; assert_eq!( alpha(&dib.build()), @@ -643,10 +641,8 @@ mod tests { dib.compression = BI_BITFIELDS; dib.header_size = 124; dib.pixels = vec![ - 0x20, 0x60, 0xA0, 0x00, // - 0x20, 0x60, 0xA0, 0x80, // - 0x20, 0x60, 0xA0, 0xC0, // - 0x20, 0x60, 0xA0, 0xFF, + 0x20, 0x60, 0xA0, 0x00, 0x20, 0x60, 0xA0, 0x80, 0x20, 0x60, 0xA0, 0xC0, 0x20, 0x60, + 0xA0, 0xFF, ]; let mut raw = dib.build(); raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); diff --git a/crates/cp-core/src/kind.rs b/crates/cp-core/src/kind.rs index 4995d5a..3dacfea 100644 --- a/crates/cp-core/src/kind.rs +++ b/crates/cp-core/src/kind.rs @@ -323,7 +323,7 @@ mod tests { "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 + "return", ] { assert_eq!(classify_text(prose), Kind::Text, "«{prose}»"); } diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs index f3d81b5..6f62deb 100644 --- a/crates/cp-core/src/paste.rs +++ b/crates/cp-core/src/paste.rs @@ -45,7 +45,12 @@ pub enum Warning { SecureInputActive, } -const RACE_RETRIES: u8 = 2; +pub const RACE_RETRIES: u8 = 11; +pub const SETTLE: std::time::Duration = std::time::Duration::from_millis(60); +const _: () = assert!( + (RACE_RETRIES as u128 + 1) * SETTLE.as_millis() < 1_000, + "a paste that takes a second is a paste the user already gave up on" +); #[derive(Debug, Default)] pub struct Attempt { @@ -94,6 +99,16 @@ impl Failure { mod tests { use super::*; + #[test] + fn a_slow_app_gets_the_dozen_tries_the_2x_needed() { + let mut attempt = Attempt::default(); + let mut tries = 1; + while attempt.on_failure(Failure::NotForeground) == Next::Retry { + tries += 1; + } + assert_eq!(tries, 12, "Office y Electron tardan cientos de ms en venir"); + } + #[test] fn an_elevated_target_is_never_retried() { let mut attempt = Attempt::default(); @@ -161,13 +176,15 @@ mod tests { } #[test] - fn races_are_retried_twice_and_then_given_up() { + fn races_are_retried_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); + for _ in 0..RACE_RETRIES { + assert_eq!(attempt.on_failure(Failure::ForegroundTimeout), Next::Retry); + } assert_eq!( - attempt.on_failure(Failure::ForegroundTimeout), - Next::Degrade + attempt.on_failure(Failure::NoKeyboardFocus), + Next::Degrade, + "el presupuesto se comparte entre las dos carreras" ); } diff --git a/crates/cp-mac-sys/Cargo.toml b/crates/cp-mac-sys/Cargo.toml index 7c843b3..c22e9bb 100644 --- a/crates/cp-mac-sys/Cargo.toml +++ b/crates/cp-mac-sys/Cargo.toml @@ -20,5 +20,4 @@ unsafe_code = "allow" [lints.clippy] all = { level = "deny", priority = -1 } -undocumented_unsafe_blocks = "deny" multiple_unsafe_ops_per_block = "deny" diff --git a/crates/cp-mac-sys/src/keyboard.rs b/crates/cp-mac-sys/src/keyboard.rs index 626e640..7214cb3 100644 --- a/crates/cp-mac-sys/src/keyboard.rs +++ b/crates/cp-mac-sys/src/keyboard.rs @@ -5,7 +5,6 @@ type CFDataRef = *const c_void; type CFArrayRef = *const c_void; type TISInputSourceRef = *const c_void; -// SAFETY: Carbon and CoreFoundation signatures as declared in their headers; `kTISPropertyUnicodeKeyLayoutData` is a global constant. unsafe extern "C" { static kTISPropertyUnicodeKeyLayoutData: CFStringRef; fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef; @@ -55,13 +54,12 @@ pub const ABC: &str = "com.apple.keylayout.ABC"; const UTF8: u32 = 0x0800_0100; fn source_id(source: TISInputSourceRef) -> Option { - // SAFETY: `source` comes from the system list and the key is a constant. let value = unsafe { TISGetInputSourceProperty(source, kTISPropertyInputSourceID) }; if value.is_null() { return None; } let mut buffer = [0u8; 256]; - // SAFETY: the buffer exists and its real size is declared. + let ok = unsafe { CFStringGetCString(value, buffer.as_mut_ptr(), buffer.len() as isize, UTF8) }; if !ok { return None; @@ -71,38 +69,34 @@ fn source_id(source: TISInputSourceRef) -> Option { } pub fn installed_layouts() -> Vec { - // SAFETY: null asks for the whole list; returns +1, released below. let list = unsafe { TISCreateInputSourceList(std::ptr::null(), true) }; if list.is_null() { return Vec::new(); } - // SAFETY: `list` is non-null. + let count = unsafe { CFArrayGetCount(list) }; let mut found = Vec::new(); for index in 0..count { - // SAFETY: the index is within the range just returned. let source = unsafe { CFArrayGetValueAtIndex(list, index) }; if let Some(id) = source_id(source) { found.push(id); } } - // SAFETY: `list` came from a Create function, so it must be released. + unsafe { CFRelease(list) }; found } impl Layout { fn named(wanted: &str) -> Option { - // SAFETY: null asks for the whole list; returns +1. let list = unsafe { TISCreateInputSourceList(std::ptr::null(), true) }; if list.is_null() { return None; } - // SAFETY: `list` is non-null. + let count = unsafe { CFArrayGetCount(list) }; let mut chosen = None; for index in 0..count { - // SAFETY: the index is within the returned range. let source = unsafe { CFArrayGetValueAtIndex(list, index) }; if source_id(source).as_deref() == Some(wanted) { chosen = Some(source); @@ -110,26 +104,23 @@ impl Layout { } } let result = chosen.and_then(|source| { - // SAFETY: `source` belongs to the array, still alive here. let data = unsafe { TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) }; if data.is_null() { return None; } - // SAFETY: `data` is non-null while the array lives. + let bytes = unsafe { CFDataGetBytePtr(data) }; (!bytes.is_null()).then_some((source, bytes)) }); match result { Some((source, bytes)) => { - // SAFETY: the source is retained so it outlives the array. unsafe { CFRetain(source) }; - // SAFETY: the array is no longer needed. + unsafe { CFRelease(list) }; Some(Self { source, bytes }) } None => { - // SAFETY: the array came from a Create function. unsafe { CFRelease(list) }; None } @@ -137,22 +128,19 @@ impl Layout { } fn current() -> Option { - // SAFETY: returns a +1 reference that this type releases on drop. let source = unsafe { TISCopyCurrentKeyboardLayoutInputSource() }; if source.is_null() { return None; } - // SAFETY: `source` is non-null and the key is the system constant. + let data = unsafe { TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) }; if data.is_null() { - // SAFETY: `source` came from a Copy function, so it must be released. unsafe { CFRelease(source) }; return None; } - // SAFETY: `data` is non-null and belongs to the input source, still alive. + let bytes = unsafe { CFDataGetBytePtr(data) }; if bytes.is_null() { - // SAFETY: same reason as above. unsafe { CFRelease(source) }; return None; } @@ -163,7 +151,7 @@ impl Layout { let mut dead_keys: u32 = 0; let mut produced: usize = 0; let mut buffer = [0u16; 4]; - // SAFETY: the layout is alive, the buffer has the declared size, and both out pointers target valid locals. + let status = unsafe { UCKeyTranslate( self.bytes, @@ -189,13 +177,11 @@ impl Layout { impl Drop for Layout { fn drop(&mut self) { - // SAFETY: `source` came from `TISCopyCurrentKeyboardLayoutInputSource` at +1 and has not been released. unsafe { CFRelease(self.source) }; } } fn keyboard_type() -> u8 { - // SAFETY: the function takes no arguments and returns an integer. unsafe { LMGetKbdType() } } diff --git a/crates/cp-mac-sys/src/keystroke.rs b/crates/cp-mac-sys/src/keystroke.rs index e95b19a..f1348b2 100644 --- a/crates/cp-mac-sys/src/keystroke.rs +++ b/crates/cp-mac-sys/src/keystroke.rs @@ -3,7 +3,6 @@ use std::ffi::c_void; type CGEventSourceRef = *const c_void; type CGEventRef = *const c_void; -// SAFETY: CoreGraphics signatures as declared in its header. unsafe extern "C" { fn CGEventSourceCreate(state_id: i32) -> CGEventSourceRef; fn CGEventSourceFlagsState(state_id: i32) -> u64; @@ -30,7 +29,6 @@ const PERMIT_ALL: u32 = 3; const SUPPRESSION_INTERVAL: i32 = 0; pub fn physical_modifiers() -> u64 { - // SAFETY: the function only reads the global modifier state. unsafe { CGEventSourceFlagsState(COMBINED_SESSION) } } @@ -45,12 +43,11 @@ pub struct Keystroke { impl Keystroke { pub fn new() -> Option { - // SAFETY: returns +1 and this type releases it on drop. let source = unsafe { CGEventSourceCreate(COMBINED_SESSION) }; if source.is_null() { return None; } - // SAFETY: `source` was just checked non-null. + unsafe { CGEventSourceSetLocalEventsFilterDuringSuppressionState( source, @@ -70,27 +67,24 @@ impl Keystroke { return false; }; - // SAFETY: `down` is a valid, freshly created event. unsafe { CGEventPost(HID_TAP, down) }; - // SAFETY: already posted, so it is released. + unsafe { CFRelease(down) }; std::thread::sleep(std::time::Duration::from_millis(9)); - // SAFETY: `up` is a valid, freshly created event. unsafe { CGEventPost(HID_TAP, up) }; - // SAFETY: already posted, so it is released. + unsafe { CFRelease(up) }; true } fn event(&self, keycode: u16, down: bool, flags: u64) -> Option { - // SAFETY: `self.source` lives as long as this type does. let event = unsafe { CGEventCreateKeyboardEvent(self.source, keycode, down) }; if event.is_null() { return None; } - // SAFETY: `event` was just checked non-null. + unsafe { CGEventSetFlags(event, flags) }; Some(event) } @@ -98,7 +92,6 @@ impl Keystroke { impl Drop for Keystroke { fn drop(&mut self) { - // SAFETY: `source` came from a Create function and has not been released. unsafe { CFRelease(self.source) }; } } diff --git a/crates/cp-mac-sys/src/media.rs b/crates/cp-mac-sys/src/media.rs index 2dac6f8..8c3fa35 100644 --- a/crates/cp-mac-sys/src/media.rs +++ b/crates/cp-mac-sys/src/media.rs @@ -32,12 +32,11 @@ pub fn info_for(path: &std::path::Path) -> Option { } let text = NSString::from_str(path.to_str()?); let url = NSURL::fileURLWithPath(&text); - // SAFETY: the URL is valid and omitting options uses the defaults. + let asset = unsafe { AVURLAsset::URLAssetWithURL_options(&url, None) }; let mut info = MediaInfo::default(); - // SAFETY: the asset is alive; returns a struct by value. let duration = unsafe { asset.duration() }; if duration.timescale != 0 { let seconds = duration.value as f64 / duration.timescale as f64; @@ -46,10 +45,8 @@ pub fn info_for(path: &std::path::Path) -> Option { } } - // SAFETY: the asset is alive while its tracks are walked. let tracks = unsafe { asset.tracks() }; for track in tracks.iter() { - // SAFETY: the track belongs to the array, still alive. let size = unsafe { track.naturalSize() }; if size.width >= 1.0 && size.height >= 1.0 { info.width = Some(size.width as u32); diff --git a/crates/cp-mac-sys/src/permissions.rs b/crates/cp-mac-sys/src/permissions.rs index db8116c..2a94930 100644 --- a/crates/cp-mac-sys/src/permissions.rs +++ b/crates/cp-mac-sys/src/permissions.rs @@ -1,4 +1,3 @@ -// SAFETY: C function declarations from the system frameworks; signatures match CoreGraphics and HIToolbox. unsafe extern "C" { fn CGPreflightPostEventAccess() -> bool; fn CGRequestPostEventAccess() -> bool; @@ -7,22 +6,18 @@ unsafe extern "C" { } pub fn can_post_events() -> bool { - // SAFETY: the function takes no arguments and returns no pointers. unsafe { CGPreflightPostEventAccess() } } pub fn request_post_events() -> bool { - // SAFETY: the function takes no arguments and returns no pointers. unsafe { CGRequestPostEventAccess() } } pub fn is_accessibility_trusted() -> bool { - // SAFETY: the function takes no arguments and returns no pointers. unsafe { AXIsProcessTrusted() } } pub fn is_secure_input_enabled() -> bool { - // SAFETY: the function takes no arguments and returns no pointers. unsafe { IsSecureEventInputEnabled() } } diff --git a/crates/cp-mac-sys/src/runloop.rs b/crates/cp-mac-sys/src/runloop.rs index fc72566..35ebe92 100644 --- a/crates/cp-mac-sys/src/runloop.rs +++ b/crates/cp-mac-sys/src/runloop.rs @@ -2,7 +2,6 @@ use std::ffi::c_void; type CFStringRef = *const c_void; -// SAFETY: CoreFoundation signatures as declared in its header. unsafe extern "C" { static kCFRunLoopDefaultMode: CFStringRef; fn CFRunLoopRunInMode( @@ -13,6 +12,5 @@ unsafe extern "C" { } pub fn pump(seconds: f64) { - // SAFETY: the mode is the system constant and the call only yields the thread to the run loop for the given time. unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, seconds, false) }; } diff --git a/crates/cp-mac/src/paste.rs b/crates/cp-mac/src/paste.rs index f69b735..1223d18 100644 --- a/crates/cp-mac/src/paste.rs +++ b/crates/cp-mac/src/paste.rs @@ -1,8 +1,9 @@ use cp_core::destination::Destination; -use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase}; +use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase, SETTLE}; use cp_mac_sys::frontmost; use cp_mac_sys::keyboard::{self, QWERTY_V}; use cp_mac_sys::keystroke::{self, Keystroke}; +use cp_mac_sys::permissions; use cp_mac_sys::runloop; #[derive(Debug, Clone, PartialEq, Eq)] @@ -30,6 +31,10 @@ impl Paster { let started = std::time::Instant::now(); let mut attempt = Attempt::default(); + if !permissions::can_post_events() { + return Outcome::Degraded(Failure::SendDenied); + } + hide_panel(); if !frontmost::is_alive(target.pid) { @@ -48,7 +53,7 @@ impl Paster { return Outcome::Degraded(Failure::NotForeground); } frontmost::bring_to_front(target.pid); - self.wait(0.060); + self.wait(SETTLE.as_secs_f64()); } let waiting = std::time::Instant::now(); diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 594d3a8..0741314 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -253,6 +253,11 @@ impl Store { } pub fn mark_deleted(&self, id: i64, at: i64) -> Result<()> { + self.erase(id, at)?; + self.checkpoint() + } + + fn erase(&self, id: i64, at: i64) -> Result<()> { self.db.execute( "UPDATE items SET deleted_at = ?2, updated_at = ?2, @@ -497,8 +502,9 @@ impl Store { rows.collect::>()? }; for id in &doomed { - self.mark_deleted(*id, cutoff)?; + self.erase(*id, cutoff)?; } + self.checkpoint()?; Ok(doomed.len()) } @@ -515,8 +521,9 @@ impl Store { rows.collect::>()? }; for id in &doomed { - self.mark_deleted(*id, at)?; + self.erase(*id, at)?; } + self.checkpoint()?; Ok(doomed.len()) } @@ -1352,6 +1359,29 @@ mod tests { ); } + #[test] + fn a_deleted_secret_is_not_left_lying_in_the_write_ahead_log() { + let dir = tempfile::tempdir().expect("carpeta"); + let path = dir.path().join("history.db"); + let secret = "hunter2-correo-del-banco"; + let store = Store::open(&path).expect("abre"); + let id = store + .insert_text("uuid-secreto", secret, 1) + .expect("insert"); + + store.mark_deleted(id, 2).expect("borra"); + + for file in ["history.db", "history.db-wal"] { + let bytes = std::fs::read(dir.path().join(file)).unwrap_or_default(); + assert!( + !bytes + .windows(secret.len()) + .any(|window| window == secret.as_bytes()), + "«{secret}» sigue legible en {file}" + ); + } + } + #[test] fn what_is_written_survives_closing_the_application() { let dir = tempfile::tempdir().expect("carpeta"); diff --git a/crates/cp-win-sys/Cargo.toml b/crates/cp-win-sys/Cargo.toml index b189917..f69f8f6 100644 --- a/crates/cp-win-sys/Cargo.toml +++ b/crates/cp-win-sys/Cargo.toml @@ -19,5 +19,4 @@ unsafe_code = "allow" [lints.clippy] all = { level = "deny", priority = -1 } -undocumented_unsafe_blocks = "deny" multiple_unsafe_ops_per_block = "deny" diff --git a/crates/cp-win-sys/src/clipboard.rs b/crates/cp-win-sys/src/clipboard.rs index 362ef21..85eeeea 100644 --- a/crates/cp-win-sys/src/clipboard.rs +++ b/crates/cp-win-sys/src/clipboard.rs @@ -14,7 +14,6 @@ pub struct Clipboard { impl Clipboard { pub fn open() -> Option { for wait in BACKOFF_MS { - // SAFETY: a null window makes the calling task the owner. if unsafe { OpenClipboard(Some(HWND::default())) }.is_ok() { return Some(Self { _private: () }); } @@ -27,7 +26,6 @@ impl Clipboard { let mut found = Vec::new(); let mut id = 0u32; loop { - // SAFETY: the clipboard is open for as long as `self` lives. id = unsafe { EnumClipboardFormats(id) }; if id == 0 { break; @@ -38,42 +36,39 @@ impl Clipboard { } pub fn owner(&self) -> Option { - // SAFETY: returns a borrowed handle that is not released here. let owner = unsafe { GetClipboardOwner() }.ok()?; (!owner.is_invalid()).then_some(owner) } pub fn bytes(&self, id: u32) -> Option> { - // SAFETY: returns a borrowed handle owned by whoever filled the clipboard. let handle = unsafe { GetClipboardData(id) }.ok()?; if handle.is_invalid() { return None; } let global = HGLOBAL(handle.0); - // SAFETY: the handle came from the clipboard and is still owned by it. + let size = unsafe { GlobalSize(global) }; if size == 0 { return None; } - // SAFETY: the handle is valid and the lock is released below. + let address = unsafe { GlobalLock(global) }; if address.is_null() { return None; } - // SAFETY: the system reports `size` readable bytes at `address`. + let bytes = unsafe { std::slice::from_raw_parts(address.cast::(), size) }.to_vec(); - // SAFETY: matches the lock above. + let _ = unsafe { GlobalUnlock(global) }; Some(bytes) } pub fn size_of(&self, id: u32) -> Option { - // SAFETY: returns a borrowed handle owned by the clipboard. let handle = unsafe { GetClipboardData(id) }.ok()?; if handle.is_invalid() { return None; } - // SAFETY: the handle came from the clipboard and is still owned by it. + let size = unsafe { GlobalSize(HGLOBAL(handle.0)) }; (size > 0).then_some(size) } @@ -81,20 +76,18 @@ impl Clipboard { impl Drop for Clipboard { fn drop(&mut self) { - // SAFETY: pairs with the successful open that produced this value. let _ = unsafe { CloseClipboard() }; } } pub fn sequence() -> Option { - // SAFETY: the call takes no arguments and returns a plain integer. let raw = unsafe { GetClipboardSequenceNumber() }; (raw != 0).then_some(i64::from(raw)) } pub fn register(name: &str) -> Option { let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); - // SAFETY: the string is null terminated and outlives the call. + let id = unsafe { windows::Win32::System::DataExchange::RegisterClipboardFormatW(windows::core::PCWSTR( wide.as_ptr(), diff --git a/crates/cp-win-sys/src/com.rs b/crates/cp-win-sys/src/com.rs index 7cb1746..1b7c48b 100644 --- a/crates/cp-win-sys/src/com.rs +++ b/crates/cp-win-sys/src/com.rs @@ -10,7 +10,6 @@ pub struct Apartment { impl Apartment { pub fn enter() -> Self { - // SAFETY: idempotent when the apartment matches; released in Drop when ours. let entered = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) }; Self { @@ -22,7 +21,6 @@ impl Apartment { impl Drop for Apartment { fn drop(&mut self) { if self.ours { - // SAFETY: pairs with the initialisation that this value owns. unsafe { CoUninitialize() }; } } diff --git a/crates/cp-win-sys/src/formats.rs b/crates/cp-win-sys/src/formats.rs index 89f0371..0f46159 100644 --- a/crates/cp-win-sys/src/formats.rs +++ b/crates/cp-win-sys/src/formats.rs @@ -55,7 +55,7 @@ pub fn name_of(id: u32) -> String { return known.to_owned(); } let mut buffer = [0u16; 256]; - // SAFETY: the buffer is live and its length is passed as declared. + let written = unsafe { GetClipboardFormatNameW(id, &mut buffer) }; if written > 0 { String::from_utf16_lossy(&buffer[..written as usize]) diff --git a/crates/cp-win-sys/src/frontmost.rs b/crates/cp-win-sys/src/frontmost.rs index 9d24b5b..bcd339c 100644 --- a/crates/cp-win-sys/src/frontmost.rs +++ b/crates/cp-win-sys/src/frontmost.rs @@ -5,7 +5,7 @@ use windows::Win32::Security::{ use windows::Win32::System::Threading::{ AttachThreadInput, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, }; -use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus; +use windows::Win32::UI::Input::KeyboardAndMouse::{GetFocus, SetFocus}; use windows::Win32::UI::WindowsAndMessaging::{ GUITHREADINFO, GetForegroundWindow, GetGUIThreadInfo, IsWindow, SMTO_ABORTIFHUNG, SMTO_BLOCK, SendMessageTimeoutW, SetForegroundWindow, WM_NULL, @@ -21,14 +21,13 @@ pub struct Target { } pub fn foreground() -> Option { - // SAFETY: returns a borrowed handle that is not released here. let window = unsafe { GetForegroundWindow() }; (!window.is_invalid()).then_some(window) } pub fn capture_target() -> Option { let window = foreground()?; - // SAFETY: the window came from the system and is still borrowed. + let thread = unsafe { windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(window, None) }; Some(Target { @@ -39,7 +38,6 @@ pub fn capture_target() -> Option { } pub fn target_for(window: HWND) -> Target { - // SAFETY: the window came from the caller and is only read. let thread = unsafe { windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(window, None) }; Target { @@ -54,24 +52,22 @@ fn inner_focus(thread: u32) -> Option { cbSize: u32::try_from(std::mem::size_of::()).ok()?, ..Default::default() }; - // SAFETY: the struct declares its own size as the call requires. + unsafe { GetGUIThreadInfo(thread, &mut info) }.ok()?; (!info.hwndFocus.is_invalid()).then_some(info.hwndFocus) } pub fn is_alive(window: HWND) -> bool { - // SAFETY: asking about a handle never dereferences it. unsafe { IsWindow(Some(window)) }.as_bool() } pub fn bring_forward(window: HWND) -> bool { - // SAFETY: the value is not to be trusted; callers check who is in front. unsafe { SetForegroundWindow(window) }.as_bool() } pub fn answers(window: HWND, patience_ms: u32) -> bool { let mut ignored = 0usize; - // SAFETY: a cross-thread send that gives up rather than hanging on a stuck target. + let replied = unsafe { SendMessageTimeoutW( window, @@ -93,12 +89,11 @@ pub struct Attached { impl Attached { pub fn to(thread: u32) -> Option { - // SAFETY: the call only reads the current thread id. let ours = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; if thread == 0 || thread == ours { return None; } - // SAFETY: detached in Drop, after the input has been sent. + unsafe { AttachThreadInput(ours, thread, true) } .as_bool() .then_some(Self { @@ -108,42 +103,45 @@ impl Attached { } pub fn focus_on(&self, window: HWND) -> bool { - // SAFETY: the queues are attached, so focus can cross. - unsafe { SetFocus(Some(window)) }.is_ok() + let _ = unsafe { SetFocus(Some(window)) }; + self.focused() == Some(window) + } + + pub fn focused(&self) -> Option { + let window = unsafe { GetFocus() }; + (!window.is_invalid()).then_some(window) } } impl Drop for Attached { fn drop(&mut self) { - // SAFETY: pairs with the attach that built this value. let _ = unsafe { AttachThreadInput(self.ours, self.theirs, false) }; } } pub fn integrity_of(pid: u32) -> Option { - // SAFETY: the handle is closed on every path below. let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?; let mut token = windows::Win32::Foundation::HANDLE::default(); - // SAFETY: the out parameter points at a live local. + let opened = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }; - // SAFETY: pairs with the OpenProcess above. + let _ = unsafe { CloseHandle(process) }; opened.ok()?; let level = level_of(token); - // SAFETY: pairs with the OpenProcessToken above. + let _ = unsafe { CloseHandle(token) }; level } fn level_of(token: windows::Win32::Foundation::HANDLE) -> Option { let mut size = 0u32; - // SAFETY: a null buffer asks only for the size it would need. + let _ = unsafe { GetTokenInformation(token, TokenIntegrityLevel, None, 0, &mut size) }; if size == 0 { return None; } let mut buffer = vec![0u8; size as usize]; - // SAFETY: the buffer holds the size the system just asked for. + unsafe { GetTokenInformation( token, @@ -155,18 +153,18 @@ fn level_of(token: windows::Win32::Foundation::HANDLE) -> Option { } .ok()?; let label = buffer.as_ptr().cast::(); - // SAFETY: the system filled the buffer with the label it declared. + let sid = unsafe { (*label).Label.Sid }; - // SAFETY: the sid came from the system and is read, never written. + let count = unsafe { windows::Win32::Security::GetSidSubAuthorityCount(sid) }; if count.is_null() { return None; } - // SAFETY: the pointer is not null and the last index is in range. + let last = unsafe { u32::from(*count) }.checked_sub(1)?; - // SAFETY: `last` is within the count the system reported. + let authority = unsafe { windows::Win32::Security::GetSidSubAuthority(sid, last) }; - // SAFETY: the pointer came from the sid and is read, never written. + Some(unsafe { *authority }) } @@ -211,7 +209,6 @@ mod tests { #[test] fn attaching_to_our_own_thread_is_refused() { - // SAFETY: the call only reads the current thread id. let ours = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; assert!(Attached::to(ours).is_none()); assert!(Attached::to(0).is_none()); diff --git a/crates/cp-win-sys/src/keystroke.rs b/crates/cp-win-sys/src/keystroke.rs index ebae0b5..3558bd3 100644 --- a/crates/cp-win-sys/src/keystroke.rs +++ b/crates/cp-win-sys/src/keystroke.rs @@ -24,7 +24,7 @@ fn key(code: VIRTUAL_KEY, up: bool) -> INPUT { if up { flags |= KEYEVENTF_KEYUP; } - // SAFETY: the union holds a keyboard event because the type says so. + let scan = unsafe { MapVirtualKeyW(u32::from(code.0), MAP_VIRTUAL_KEY_TYPE(0)) } as u16; INPUT { r#type: INPUT_KEYBOARD, @@ -41,7 +41,6 @@ fn key(code: VIRTUAL_KEY, up: bool) -> INPUT { } pub fn send(batch: &[INPUT]) -> bool { - // SAFETY: every entry is a keyboard event of the declared size. let sent = unsafe { SendInput(batch, std::mem::size_of::() as i32) }; sent as usize == batch.len() } @@ -53,7 +52,6 @@ pub fn modifiers_still_held() -> bool { } fn pressed(code: VIRTUAL_KEY) -> bool { - // SAFETY: the call only reads the asynchronous state of one key. let state = unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(i32::from(code.0)) }; state as u16 & 0x8000 != 0 @@ -70,7 +68,6 @@ mod tests { assert_eq!(batch.len(), 9); let ups: Vec = batch .iter() - // SAFETY: every entry was built as a keyboard event. .map(|one| unsafe { one.Anonymous.ki.dwFlags }.contains(KEYEVENTF_KEYUP)) .collect(); assert_eq!(ups[..5], [true; 5], "los cinco modificadores se sueltan"); @@ -80,7 +77,7 @@ mod tests { #[test] fn both_windows_keys_are_released_because_there_is_no_generic_one() { let batch = paste_batch(); - // SAFETY: every entry was built as a keyboard event. + let codes: Vec = batch .iter() .map(|one| unsafe { one.Anonymous.ki.wVk }.0) @@ -92,7 +89,7 @@ mod tests { #[test] fn the_windows_key_is_released_before_the_v_is_pressed() { let batch = paste_batch(); - // SAFETY: every entry was built as a keyboard event. + let codes: Vec = batch .iter() .map(|one| unsafe { one.Anonymous.ki.wVk }.0) @@ -111,7 +108,6 @@ mod tests { #[test] fn every_event_carries_a_scan_code() { for one in paste_batch() { - // SAFETY: the entry was built as a keyboard event. let scan = unsafe { one.Anonymous.ki.wScan }; assert_ne!(scan, 0, "hay destinos que leen el scancode y no el virtual"); } @@ -120,7 +116,6 @@ mod tests { #[test] fn every_event_is_marked_as_ours() { for one in paste_batch() { - // SAFETY: the entry was built as a keyboard event. assert_eq!(unsafe { one.Anonymous.ki.dwExtraInfo }, OURS); } } @@ -128,7 +123,7 @@ mod tests { #[test] fn the_control_that_presses_is_not_the_one_that_releases() { let batch = paste_batch(); - // SAFETY: every entry was built as a keyboard event. + let control: Vec = batch .iter() .filter(|one| unsafe { one.Anonymous.ki.wVk } == VK_CONTROL) @@ -137,7 +132,7 @@ mod tests { assert_eq!( control, [true, false, true], - "suelta, pulsa y vuelve a soltar" + "suelta, presiona y vuelve a soltar" ); } @@ -153,7 +148,6 @@ mod tests { #[test] fn nothing_is_flagged_as_a_bare_scan_code() { for one in paste_batch() { - // SAFETY: the entry was built as a keyboard event. let flags = unsafe { one.Anonymous.ki.dwFlags }; assert!( !flags.contains(KEYEVENTF_SCANCODE), diff --git a/crates/cp-win-sys/src/media.rs b/crates/cp-win-sys/src/media.rs index 3cf521b..08c21fd 100644 --- a/crates/cp-win-sys/src/media.rs +++ b/crates/cp-win-sys/src/media.rs @@ -68,7 +68,7 @@ pub fn info_for(path: &std::path::Path) -> Option { .encode_wide() .chain(std::iter::once(0)) .collect(); - // SAFETY: the string is null terminated and outlives the call. + let store: IPropertyStore = unsafe { SHGetPropertyStoreFromParsingName(PCWSTR(wide.as_ptr()), None, GPS_DEFAULT) } .ok()?; @@ -86,7 +86,6 @@ pub fn info_for(path: &std::path::Path) -> Option { } fn value_of(store: &IPropertyStore, key: PROPERTYKEY) -> Option { - // SAFETY: the key is a constant and the value is cleared by its own Drop. unsafe { store.GetValue(&key) }.ok() } @@ -110,14 +109,14 @@ fn number(store: &IPropertyStore, key: PROPERTYKEY) -> Option { fn text(store: &IPropertyStore, key: PROPERTYKEY) -> Option { let value = value_of(store, key)?; - // SAFETY: the allocation is handed back to the system below. + let raw = unsafe { PropVariantToStringAlloc(&value) }.ok()?; if raw.is_null() { return None; } - // SAFETY: the system returned a null terminated string. + let text = unsafe { raw.to_string() }.ok(); - // SAFETY: pairs with the allocation above. + unsafe { CoTaskMemFree(Some(raw.as_ptr().cast())) }; text.filter(|text| !text.trim().is_empty()) } diff --git a/crates/cp-win-sys/src/permissions.rs b/crates/cp-win-sys/src/permissions.rs index 400bc20..df62a92 100644 --- a/crates/cp-win-sys/src/permissions.rs +++ b/crates/cp-win-sys/src/permissions.rs @@ -38,17 +38,29 @@ mod tests { use super::*; #[test] - fn this_process_is_ready_to_watch() { - let ready = Readiness::probe(); - assert!(ready.can_watch()); - assert!(ready.integrity.is_some()); + fn what_cannot_reach_the_window_station_cannot_watch() { + let blind = Readiness { + reaches_window_station: false, + integrity: Some(MEDIUM), + }; + assert!(!blind.can_watch(), "sin estacion de ventanas no se vigila"); + let seeing = Readiness { + reaches_window_station: true, + integrity: None, + }; + assert!( + seeing.can_watch(), + "y con ella si, aunque el nivel se ignore" + ); } #[test] fn a_target_at_our_own_level_is_reachable() { - let ready = Readiness::probe(); - let ours = ready.integrity.expect("nivel propio"); - assert!(ready.can_paste_into(ours)); + let ready = Readiness { + reaches_window_station: true, + integrity: Some(MEDIUM), + }; + assert!(ready.can_paste_into(MEDIUM)); } #[test] diff --git a/crates/cp-win-sys/src/reading.rs b/crates/cp-win-sys/src/reading.rs index 025b347..16df3a0 100644 --- a/crates/cp-win-sys/src/reading.rs +++ b/crates/cp-win-sys/src/reading.rs @@ -29,15 +29,22 @@ pub fn within( patience: Duration, read: impl FnOnce() -> Option> + Send + 'static, ) -> Reading { + match anything_within(patience, read) { + Some(Some(bytes)) => Reading::Delivered(bytes), + Some(None) => Reading::Empty, + None => Reading::TooSlow, + } +} + +pub fn anything_within( + patience: Duration, + work: impl FnOnce() -> T + Send + 'static, +) -> Option { let (tell, hear) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let _ = tell.send(read()); + let _ = tell.send(work()); }); - match hear.recv_timeout(patience) { - Ok(Some(bytes)) => Reading::Delivered(bytes), - Ok(None) => Reading::Empty, - Err(_) => Reading::TooSlow, - } + hear.recv_timeout(patience).ok() } #[cfg(test)] diff --git a/crates/cp-win-sys/src/source.rs b/crates/cp-win-sys/src/source.rs index 525bffa..808dfc7 100644 --- a/crates/cp-win-sys/src/source.rs +++ b/crates/cp-win-sys/src/source.rs @@ -6,17 +6,16 @@ use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; pub fn process_of(window: HWND) -> Option { let mut pid = 0u32; - // SAFETY: the out parameter points at a live local. + let thread = unsafe { GetWindowThreadProcessId(window, Some(&mut pid)) }; (thread != 0 && pid != 0).then_some(pid) } pub fn name_of(pid: u32) -> Option { - // SAFETY: the handle is closed below on every path. let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?; let mut buffer = [0u16; MAX_PATH as usize]; let mut written = buffer.len() as u32; - // SAFETY: the buffer is live and its length is passed by reference as declared. + let queried = unsafe { QueryFullProcessImageNameW( process, @@ -25,7 +24,7 @@ pub fn name_of(pid: u32) -> Option { &mut written, ) }; - // SAFETY: pairs with the OpenProcess above. + let _ = unsafe { CloseHandle(process) }; queried.ok()?; let path = String::from_utf16_lossy(&buffer[..written as usize]); diff --git a/crates/cp-win-sys/src/thumbnail.rs b/crates/cp-win-sys/src/thumbnail.rs index d98a9ab..95869ac 100644 --- a/crates/cp-win-sys/src/thumbnail.rs +++ b/crates/cp-win-sys/src/thumbnail.rs @@ -24,20 +24,19 @@ pub fn dib_of_file(path: &std::path::Path, side: i32) -> Option> { .encode_wide() .chain(std::iter::once(0)) .collect(); - // SAFETY: the string is null terminated and outlives the call. + let factory: IShellItemImageFactory = unsafe { SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None) }.ok()?; let wanted = windows::Win32::Foundation::SIZE { cx: side, cy: side }; - // SAFETY: the factory is alive and the bitmap is released below. let cached = unsafe { factory.GetImage(wanted, SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY) }; let bitmap = match cached { Ok(bitmap) => bitmap, - // SAFETY: same factory, asking the shell to build the entry this time. + Err(_) => unsafe { factory.GetImage(wanted, SIIGBF_THUMBNAILONLY) }.ok()?, }; let dib = as_dib(bitmap, side); - // SAFETY: the bitmap came from GetImage and is released once. + let _ = unsafe { DeleteObject(bitmap.into()) }; dib } @@ -45,7 +44,7 @@ pub fn dib_of_file(path: &std::path::Path, side: i32) -> Option> { fn as_dib(bitmap: HBITMAP, side: i32) -> Option> { let mut shape = BITMAP::default(); let wrote = i32::try_from(std::mem::size_of::()).ok()?; - // SAFETY: the struct is the size declared and the bitmap is alive. + let read = unsafe { GetObjectW(bitmap.into(), wrote, Some((&raw mut shape).cast())) }; if read == 0 || is_an_icon(shape.bmWidth, shape.bmHeight, side) { return None; @@ -69,9 +68,9 @@ fn as_dib(bitmap: HBITMAP, side: i32) -> Option> { }; let mut dib = vec![0u8; std::mem::size_of::() + pixels]; - // SAFETY: a screen device context, released below. + let screen = unsafe { GetDC(None) }; - // SAFETY: the buffer holds the size the header declares. + let lines = unsafe { GetDIBits( screen, @@ -87,12 +86,12 @@ fn as_dib(bitmap: HBITMAP, side: i32) -> Option> { DIB_RGB_COLORS, ) }; - // SAFETY: pairs with the GetDC above. + unsafe { ReleaseDC(None, screen) }; if lines == 0 { return None; } - // SAFETY: the header is a plain struct of the size declared. + let header = unsafe { std::slice::from_raw_parts( (&raw const info.bmiHeader).cast::(), diff --git a/crates/cp-win-sys/src/window.rs b/crates/cp-win-sys/src/window.rs index e2d3a7a..0eacb6c 100644 --- a/crates/cp-win-sys/src/window.rs +++ b/crates/cp-win-sys/src/window.rs @@ -13,7 +13,7 @@ pub struct EditWindow { impl EditWindow { pub fn open(title: &str) -> Option { let wide: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); - // SAFETY: the class is a system one and both strings outlive the call. + let window = unsafe { CreateWindowExW( WINDOW_EX_STYLE(0), @@ -33,9 +33,9 @@ impl EditWindow { ) } .ok()?; - // SAFETY: the window was just created and is still ours. + let _ = unsafe { ShowWindow(window, SW_SHOW) }; - // SAFETY: the value is not to be trusted; callers check who is in front. + let _ = unsafe { SetForegroundWindow(window) }; Some(Self { window }) } @@ -48,11 +48,10 @@ impl EditWindow { let until = std::time::Instant::now() + how_long; while std::time::Instant::now() < until { let mut message = MSG::default(); - // SAFETY: the out parameter points at a live local. + while unsafe { PeekMessageW(&mut message, None, 0, 0, PM_REMOVE) }.as_bool() { - // SAFETY: the message was just filled by PeekMessage. let _ = unsafe { TranslateMessage(&message) }; - // SAFETY: same message, still live. + unsafe { DispatchMessageW(&message) }; } std::thread::sleep(std::time::Duration::from_millis(4)); @@ -60,7 +59,6 @@ impl EditWindow { } pub fn text(&self) -> String { - // SAFETY: the window is alive for as long as this value is. let length = unsafe { SendMessageW(self.window, WM_GETTEXTLENGTH, None, None) }.0; let Ok(length) = usize::try_from(length) else { return String::new(); @@ -69,7 +67,7 @@ impl EditWindow { return String::new(); } let mut buffer = vec![0u16; length + 1]; - // SAFETY: the buffer holds the length just reported plus the terminator. + let read = unsafe { SendMessageW( self.window, @@ -86,7 +84,6 @@ impl EditWindow { impl Drop for EditWindow { fn drop(&mut self) { - // SAFETY: the window was created here and is destroyed once. let _ = unsafe { DestroyWindow(self.window) }; } } diff --git a/crates/cp-win-sys/src/writing.rs b/crates/cp-win-sys/src/writing.rs index 0faab21..ea77fcc 100644 --- a/crates/cp-win-sys/src/writing.rs +++ b/crates/cp-win-sys/src/writing.rs @@ -15,14 +15,22 @@ impl Clipboard { if entries.is_empty() { return Written::Refused; } - // SAFETY: the clipboard is open and owned by this task for as long as `self` lives. + let Some(ready) = reserved(entries) else { + return Written::Refused; + }; + if unsafe { EmptyClipboard() }.is_err() { + release(&ready); return Written::Refused; } let mut placed = 0; - for (id, bytes) in entries { - if handed_over(*id, bytes) { - placed += 1; + for (id, block) in ready { + match unsafe { SetClipboardData(id, Some(HANDLE(block.0))) } { + Ok(_) => placed += 1, + + Err(_) => unsafe { + let _ = GlobalFree(Some(block)); + }, } } if placed == 0 { @@ -33,34 +41,37 @@ impl Clipboard { } } -fn handed_over(id: u32, bytes: &[u8]) -> bool { - let Some(block) = block_of(bytes) else { - return false; - }; - // SAFETY: on success the system takes ownership of the block. - match unsafe { SetClipboardData(id, Some(HANDLE(block.0))) } { - Ok(_) => true, - Err(_) => { - // SAFETY: ownership stayed here because the call failed. - let _ = unsafe { GlobalFree(Some(block)) }; - false +fn reserved(entries: &[(u32, &[u8])]) -> Option> { + let mut ready = Vec::with_capacity(entries.len()); + for (id, bytes) in entries { + match block_of(bytes) { + Some(block) => ready.push((*id, block)), + None => { + release(&ready); + return None; + } } } + Some(ready) +} + +fn release(blocks: &[(u32, HGLOBAL)]) { + for (_, block) in blocks { + let _ = unsafe { GlobalFree(Some(*block)) }; + } } fn block_of(bytes: &[u8]) -> Option { - // SAFETY: a moveable block of the requested size, released below on failure. let block = unsafe { GlobalAlloc(GMEM_MOVEABLE, bytes.len()) }.ok()?; - // SAFETY: the block was just allocated and is unlocked. + let address = unsafe { GlobalLock(block) }; if address.is_null() { - // SAFETY: nothing else holds the block. let _ = unsafe { GlobalFree(Some(block)) }; return None; } - // SAFETY: the block holds exactly `bytes.len()` writable bytes. + unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), address.cast::(), bytes.len()) }; - // SAFETY: matches the lock above. + let _ = unsafe { GlobalUnlock(block) }; Some(block) } @@ -86,6 +97,21 @@ pub fn text_of(bytes: &[u8]) -> Option { mod tests { use super::*; + #[test] + fn every_block_is_reserved_before_the_clipboard_is_emptied() { + let entries: Vec<(u32, &[u8])> = vec![(1, b"uno".as_slice()), (2, b"dos".as_slice())]; + let ready = reserved(&entries).expect("la memoria se consigue"); + assert_eq!(ready.len(), 2, "lo que se pidio, reservado y sin entregar"); + release(&ready); + } + + #[test] + fn nothing_to_write_reserves_nothing() { + let ready = reserved(&[]).expect("vacio no es fallo"); + assert!(ready.is_empty()); + release(&ready); + } + #[test] fn text_survives_the_round_trip() { for original in ["hola", "", "acentos: ñáéíóú", "emoji: 🦀", "日本語"] { diff --git a/crates/cp-win/examples/probe.rs b/crates/cp-win/examples/probe.rs index 04751e4..db97072 100644 --- a/crates/cp-win/examples/probe.rs +++ b/crates/cp-win/examples/probe.rs @@ -1,822 +1,5 @@ -#![cfg(target_os = "windows")] +#[cfg(target_os = "windows")] +include!("probe/battery.rs"); -use cp_core::formats::{Family, Take}; -use cp_core::watch::{Cadence, Seen, Watcher}; -use cp_win::capture::{Captured, capture}; -use cp_win::formats::CATALOG; -use cp_win::paste::{Outcome, paste_into}; -use cp_win::restore::{Restored, to_clipboard, to_clipboard_as_plain_text}; -use cp_win::transfer::{self, Transfer}; -use cp_win::watching::Watching; -use cp_win_sys::clipboard::{self, Clipboard}; -use cp_win_sys::formats::{CF_UNICODETEXT, name_of}; -use cp_win_sys::frontmost::{self, Target}; -use cp_win_sys::permissions::Readiness; -use cp_win_sys::reading::{self, PATIENCE, Reading}; -use cp_win_sys::window::EditWindow; -use cp_win_sys::writing::{Written, text_of, utf16_of}; -use cp_win_sys::{media, ocr, thumbnail}; - -struct Battery { - passed: u32, - failed: u32, - skipped: u32, -} - -impl Battery { - fn group(&self, name: &str) { - println!("\n {name}"); - } - - fn case(&mut self, id: &str, what: &str, run: impl FnOnce() -> Result<(), String>) { - match run() { - Ok(()) => { - self.passed += 1; - println!(" ok {id:<5} {what}"); - } - Err(why) => { - self.failed += 1; - println!(" FALLA {id:<5} {what}\n {why}"); - } - } - } - - fn skip(&mut self, id: &str, what: &str, why: &str) { - self.skipped += 1; - println!(" salta {id:<5} {what}\n {why}"); - } -} - -fn offered_names() -> Result, String> { - let clipboard = Clipboard::open().ok_or("no se pudo abrir el portapapeles")?; - Ok(clipboard.offered().into_iter().map(name_of).collect()) -} - -fn main() -> std::process::ExitCode { - println!("\nBatería del núcleo contra el portapapeles de Windows"); - - let mut b = Battery { - passed: 0, - failed: 0, - skipped: 0, - }; - - b.group("A · El portapapeles responde"); - - b.case("A1", "se abre y se cierra sin quedarse tomado", || { - { - let _first = Clipboard::open().ok_or("no abrió")?; - } - let _second = Clipboard::open().ok_or("no abrió la segunda vez")?; - Ok(()) - }); - - b.case("A2", "el contador de secuencia se lee", || { - clipboard::sequence() - .map(|_| ()) - .ok_or_else(|| "devolvió cero: no se alcanza la estación de ventanas".into()) - }); - - b.case("A3", "enumerar no pide un solo byte", || { - let names = offered_names()?; - println!(" {} formatos: {}", names.len(), names.join(", ")); - Ok(()) - }); - - b.group("B · El catálogo contra lo que hay de verdad"); - - b.case("B1", "todo lo ofrecido recibe una decisión", || { - let names = offered_names()?; - if names.is_empty() { - return Err("el portapapeles está vacío: copia algo y repite".into()); - } - for name in &names { - let take = CATALOG.decide(name); - let mark = match take { - Take::Payload => "copia", - Take::Presence => "anota", - Take::Never => "nunca", - }; - println!(" {mark} {name}"); - } - Ok(()) - }); - - b.case("B2", "lo que se copia se puede leer de verdad", || { - let clipboard = Clipboard::open().ok_or("no abrió")?; - let ids = clipboard.offered(); - let names: Vec = ids.iter().map(|id| name_of(*id)).collect(); - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - if CATALOG.refusal(&refs).is_some() { - return Err("la fuente pidió no registrar esto".into()); - } - let mut read = 0usize; - let mut bytes = 0usize; - for (id, name) in ids.iter().zip(&names) { - if CATALOG.decide(name) != Take::Payload || CATALOG.costlier_twin(name, &refs) { - continue; - } - match clipboard.size_of(*id) { - Some(size) => { - read += 1; - bytes += size; - println!(" {size:>9} B {name}"); - } - None => println!(" {:>9} {name}", "sin datos"), - } - } - if read == 0 { - return Err("nada de lo que el catálogo quiere entregó bytes".into()); - } - println!(" {read} formatos, {bytes} bytes"); - Ok(()) - }); - - b.case("B3", "la clase que sale es una de las tres", || { - let names = offered_names()?; - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - match CATALOG.classify(&refs) { - Some(family) => { - println!(" {family:?}"); - Ok(()) - } - None => Err(format!("ninguna clase para {names:?}")), - } - }); - - let names = offered_names().unwrap_or_default(); - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - let courtesy = refs.iter().any(|id| CATALOG.embeddable.contains(id)) - && CATALOG.preferred_image(&refs).is_some(); - if courtesy { - b.case( - "B4", - "una hoja de cálculo no se guarda como foto", - || match CATALOG.classify(&refs) { - Some(Family::Text) => Ok(()), - other => Err(format!("se clasificó como {other:?}")), - }, - ); - } else { - b.skip( - "B4", - "una hoja de cálculo no se guarda como foto", - "lo copiado no es un documento con imagen: copia un rango de Excel y repite", - ); - } - - b.group("F · Ida y vuelta, montada por nosotros"); - - b.case("F1", "lo que escribimos se vuelve a leer igual", || { - let written = "cp-f1-ida-y-vuelta ñ 🦀"; - { - let clipboard = Clipboard::open().ok_or("no abrió para escribir")?; - match clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]) { - Written::Placed { formats: 1 } => {} - other => return Err(format!("la escritura dio {other:?}")), - } - } - let clipboard = Clipboard::open().ok_or("no abrió para leer")?; - let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("no devolvió bytes")?; - match text_of(&bytes).as_deref() { - Some(back) if back == written => Ok(()), - other => Err(format!("volvió «{other:?}»")), - } - }); - - b.case("F2", "escribir mueve el contador y leer no", || { - let before = clipboard::sequence().ok_or("sin contador")?; - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f2"))]); - } - let after = clipboard::sequence().ok_or("sin contador")?; - if after == before { - return Err("el contador no se movió al escribir".into()); - } - let quiet = { - let clipboard = Clipboard::open().ok_or("no abrió")?; - let _ = clipboard.bytes(CF_UNICODETEXT); - clipboard::sequence().ok_or("sin contador")? - }; - if quiet != after { - return Err(format!("leer movió el contador de {after} a {quiet}")); - } - println!(" {before} → {after} por una escritura"); - Ok(()) - }); - - b.case( - "F3", - "el vigilante ve nuestra escritura como nuestra", - || { - let mut watcher = Watcher::new(Cadence::Opaque); - watcher.tick(clipboard::sequence().ok_or("sin contador")?); - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f3"))]); - } - let ours = clipboard::sequence().ok_or("sin contador")?; - watcher.wrote(ours); - match watcher.tick(ours) { - Seen::Ours => Ok(()), - other => Err(format!("se vio como {other:?}")), - } - }, - ); - - b.case("F4", "una copia ajena tras la nuestra no se traga", || { - let mut watcher = Watcher::new(Cadence::Opaque); - watcher.tick(clipboard::sequence().ok_or("sin contador")?); - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f4-nuestro"))]); - } - let ours = clipboard::sequence().ok_or("sin contador")?; - watcher.wrote(ours); - if watcher.tick(ours) != Seen::Ours { - return Err("la nuestra no se reconoció".into()); - } - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f4-ajeno"))]); - } - let theirs = clipboard::sequence().ok_or("sin contador")?; - match watcher.tick(theirs) { - Seen::Fresh { .. } => Ok(()), - other => Err(format!("la siguiente copia se vio como {other:?}")), - } - }); - - b.group("G · Lo que no entrega a tiempo se abandona"); - - b.case( - "G1", - "un formato con datos responde muy por debajo del techo", - || { - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-g1"))]); - } - let started = std::time::Instant::now(); - let seen = reading::within(PATIENCE, || { - let clipboard = Clipboard::open()?; - clipboard.bytes(CF_UNICODETEXT) - }); - let took = started.elapsed(); - if seen.is_too_slow() { - return Err(format!("no llegó en {PATIENCE:?}")); - } - println!(" {took:?} contra un techo de {PATIENCE:?}"); - Ok(()) - }, - ); - - b.case("G2", "lo que no contesta se abandona en el techo", || { - let started = std::time::Instant::now(); - let seen = reading::within(PATIENCE, || { - std::thread::sleep(std::time::Duration::from_secs(30)); - Some(Vec::new()) - }); - let took = started.elapsed(); - if seen != Reading::TooSlow { - return Err(format!("se esperó de más y dio {seen:?}")); - } - if took > PATIENCE * 3 { - return Err(format!("tardó {took:?} en rendirse")); - } - println!(" abandonado en {took:?}, no en los 30 s medidos"); - Ok(()) - }); - - b.group("H · La captura, de punta a punta"); - - b.case("H1", "un texto copiado se convierte en un ítem", || { - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("alguien@ejemplo.test"))]); - } - let clipboard = Clipboard::open().ok_or("no abrió")?; - match capture(&clipboard) { - Captured::Kept(item) => { - if item.kind != Some(cp_core::kind::Kind::Email) { - return Err(format!("la clase salió {:?}", item.kind)); - } - println!( - " {} formatos, {} bytes guardados, clase {:?}", - item.formats.len(), - item.stored_bytes(), - item.kind - ); - Ok(()) - } - other => Err(format!("no se capturó: {other:?}")), - } - }); - - b.case( - "H2", - "el conjunto entero se anota, no solo lo que se copia", - || { - let clipboard = Clipboard::open().ok_or("no abrió")?; - let offered = clipboard.offered().len(); - match capture(&clipboard) { - Captured::Kept(item) => { - if item.formats.len() < offered { - return Err(format!( - "se ofrecieron {offered} y solo se anotaron {}", - item.formats.len() - )); - } - Ok(()) - } - other => Err(format!("no se capturó: {other:?}")), - } - }, - ); - - b.case("H3", "dos copias iguales tienen la misma huella", || { - let write = |text: &str| { - let clipboard = Clipboard::open()?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(text))]); - Some(()) - }; - let taken = |()| { - let clipboard = Clipboard::open()?; - match capture(&clipboard) { - Captured::Kept(item) => Some(item.fingerprint()), - _ => None, - } - }; - write("cp-h3-mismo").ok_or("no escribió")?; - let first = taken(()).ok_or("no capturó")?; - write("cp-h3-mismo").ok_or("no escribió")?; - let again = taken(()).ok_or("no capturó")?; - write("cp-h3-distinto").ok_or("no escribió")?; - let other = taken(()).ok_or("no capturó")?; - if first != again { - return Err("lo mismo dio dos huellas".into()); - } - if first == other { - return Err("dos contenidos distintos dieron la misma huella".into()); - } - Ok(()) - }); - - b.case("H4", "un marcador de secreto detiene la captura", || { - let marker = cp_win_sys::formats::name_of( - cp_win_sys::clipboard::register("Clipboard Viewer Ignore").ok_or("no registró")?, - ); - if marker != "Clipboard Viewer Ignore" { - return Err(format!("el formato se registró como «{marker}»")); - } - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - let id = - cp_win_sys::clipboard::register("Clipboard Viewer Ignore").ok_or("no registró")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("una-contrasena")), (id, &[1u8])]); - } - let seen = { - let clipboard = Clipboard::open().ok_or("no abrió")?; - capture(&clipboard) - }; - { - let clipboard = Clipboard::open().ok_or("no abrió para limpiar")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-h4-limpio"))]); - } - match seen { - Captured::Refused(why) => { - println!(" rechazada por {why:?}, y el marcador se retiró"); - Ok(()) - } - other => Err(format!("se capturó igualmente: {other:?}")), - } - }); - - b.case("H5", "la batería no deja marcadores puestos", || { - let clipboard = Clipboard::open().ok_or("no abrió")?; - let names: Vec = clipboard.offered().into_iter().map(name_of).collect(); - let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - match CATALOG.refusal(&refs) { - None => Ok(()), - Some(left) => Err(format!("quedó {left:?} del caso anterior")), - } - }); - - b.group("I · Restaurar"); - - b.case( - "I1", - "un ítem vuelve al portapapeles con sus formatos", - || { - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i1-original"))]); - } - let item = { - let clipboard = Clipboard::open().ok_or("no abrió")?; - match capture(&clipboard) { - Captured::Kept(item) => item, - other => return Err(format!("no se capturó: {other:?}")), - } - }; - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i1-otra-cosa"))]); - } - let written = { - let clipboard = Clipboard::open().ok_or("no abrió")?; - to_clipboard(&clipboard, &item) - }; - match written { - Restored::Written { formats, .. } => { - println!(" {formats} formatos devueltos"); - } - other => return Err(format!("no se restauró: {other:?}")), - } - let clipboard = Clipboard::open().ok_or("no abrió")?; - let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("sin texto")?; - match text_of(&bytes).as_deref() { - Some("cp-i1-original") => Ok(()), - other => Err(format!("volvió «{other:?}»")), - } - }, - ); - - b.case("I2", "capturar lo restaurado da la misma huella", || { - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i2-ida-y-vuelta"))]); - } - let (first, item) = { - let clipboard = Clipboard::open().ok_or("no abrió")?; - match capture(&clipboard) { - Captured::Kept(item) => (item.fingerprint(), item), - other => return Err(format!("no se capturó: {other:?}")), - } - }; - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - to_clipboard(&clipboard, &item); - } - let clipboard = Clipboard::open().ok_or("no abrió")?; - match capture(&clipboard) { - Captured::Kept(again) => { - if again.fingerprint() == first { - Ok(()) - } else { - Err("la huella cambió al ir y volver".into()) - } - } - other => Err(format!("no se recapturó: {other:?}")), - } - }); - - b.case("I3", "pegar en plano no muda lo guardado", || { - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i3-con-estilos"))]); - } - let item = { - let clipboard = Clipboard::open().ok_or("no abrió")?; - match capture(&clipboard) { - Captured::Kept(item) => item, - other => return Err(format!("no se capturó: {other:?}")), - } - }; - let before = item.clone(); - let written = { - let clipboard = Clipboard::open().ok_or("no abrió")?; - to_clipboard_as_plain_text(&clipboard, &item) - }; - if !matches!( - written, - Restored::Written { - incomplete: false, - .. - } - ) { - return Err(format!("la escritura plana dio {written:?}")); - } - if item != before { - return Err("el ítem se mutiló al pegarlo en plano".into()); - } - Ok(()) - }); - - b.group("J · El vigilante en marcha"); - - b.case("J1", "una copia despierta al vigilante", || { - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - let seen = Arc::new(AtomicUsize::new(0)); - let counter = seen.clone(); - let watching = Watching::every(std::time::Duration::from_millis(10), move || { - counter.fetch_add(1, Ordering::Relaxed); - }); - std::thread::sleep(std::time::Duration::from_millis(60)); - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j1"))]); - } - std::thread::sleep(std::time::Duration::from_millis(200)); - drop(watching); - match seen.load(Ordering::Relaxed) { - 0 => Err("la copia no se vio".into()), - n => { - println!(" {n} aviso(s) por una copia"); - Ok(()) - } - } - }); - - b.case("J2", "un portapapeles quieto no despierta a nadie", || { - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j2-quieto"))]); - } - std::thread::sleep(std::time::Duration::from_millis(60)); - let seen = Arc::new(AtomicUsize::new(0)); - let counter = seen.clone(); - let watching = Watching::every(std::time::Duration::from_millis(10), move || { - counter.fetch_add(1, Ordering::Relaxed); - }); - std::thread::sleep(std::time::Duration::from_millis(250)); - drop(watching); - match seen.load(Ordering::Relaxed) { - 0 => Ok(()), - n => Err(format!("{n} avisos sin que nadie copiara")), - } - }); - - b.case("J3", "sondear el contador es casi gratis", || { - let rounds = 10_000; - let started = std::time::Instant::now(); - for _ in 0..rounds { - let _ = clipboard::sequence(); - } - let each = started.elapsed() / rounds; - println!(" {each:?} por sondeo"); - if each > std::time::Duration::from_micros(50) { - return Err(format!("{each:?} es demasiado para sondear seguido")); - } - Ok(()) - }); - - b.group("K · Permisos"); - - b.case("K1", "se sabe qué se puede hacer y qué no", || { - let ready = Readiness::probe(); - println!( - " estación: {} nivel: {:?} elevado: {}", - ready.can_watch(), - ready.integrity, - ready.is_elevated() - ); - if !ready.can_watch() { - return Err("no se alcanza la estación de ventanas".into()); - } - let ours = ready.integrity.ok_or("sin nivel propio")?; - if !ready.can_paste_into(ours) { - return Err("no se puede pegar en nuestro propio nivel".into()); - } - Ok(()) - }); - - b.group("L · Pegar de verdad"); - - let stage = EditWindow::open("destino de la bateria").and_then(|target| { - let until = std::time::Instant::now() + std::time::Duration::from_secs(2); - while frontmost::foreground() != Some(target.window()) { - if std::time::Instant::now() > until { - return None; - } - frontmost::bring_forward(target.window()); - target.pump(std::time::Duration::from_millis(50)); - } - Some(target) - }); - - match stage { - Some(target) => b.case("L1", "el texto llega a una ventana de destino", || { - let written = "cp-l1-pegado-real"; - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]); - } - let seen = frontmost::target_for(target.window()); - match paste_into(&seen, || {}) { - Outcome::Sent { took } => println!(" enviado en {took:?}"), - Outcome::Degraded(why) => return Err(format!("degradó a {why:?}")), - } - target.pump(std::time::Duration::from_millis(400)); - let arrived = target.text(); - if arrived.contains(written) { - Ok(()) - } else { - Err(format!("llegó «{arrived}» en vez de «{written}»")) - } - }), - None => b.skip( - "L1", - "el texto llega a una ventana de destino", - "Windows solo deja cambiar el primer plano a quien ya lo tiene: ejecuta la bateria desde una consola con el foco", - ), - } - - b.case( - "L2", - "el peor resultado sigue siendo pegarlo a mano", - || { - let written = "cp-l2-degradado"; - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]); - } - let gone = Target { - window: windows::Win32::Foundation::HWND(std::ptr::dangling_mut()), - focus: None, - thread: 0, - }; - match paste_into(&gone, || {}) { - Outcome::Degraded(cp_core::paste::Failure::TargetGone) => {} - other => return Err(format!("con un destino muerto dio {other:?}")), - } - let clipboard = Clipboard::open().ok_or("no abrió")?; - let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("sin texto")?; - match text_of(&bytes).as_deref() { - Some(back) if back == written => Ok(()), - other => Err(format!("el portapapeles quedó con «{other:?}»")), - } - }, - ); - - b.group("M · Texto dentro de una imagen"); - - b.case("M1", "el sistema ofrece un motor de lectura", || { - if ocr::is_available() { - Ok(()) - } else { - Err("no hay motor para los idiomas del perfil".into()) - } - }); - - b.case("M2", "se lee el texto de una imagen real", || { - let png = std::fs::read("fixtures/texto-en-imagen.png") - .map_err(|why| format!("no se pudo leer el fixture: {why}"))?; - let started = std::time::Instant::now(); - let text = ocr::text_in(&png).ok_or("no se reconoció nada")?; - println!( - " {:?} para leer «{}»", - started.elapsed(), - text.lines().next().unwrap_or("").trim() - ); - Ok(()) - }); - - b.case("M3", "una imagen en blanco no inventa texto", || { - let blank = image::RgbaImage::from_pixel(120, 60, image::Rgba([255, 255, 255, 255])); - let mut png = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(blank) - .write_to(&mut png, image::ImageFormat::Png) - .map_err(|why| why.to_string())?; - match ocr::text_in(&png.into_inner()) { - None => Ok(()), - Some(invented) => Err(format!("se inventó «{invented}»")), - } - }); - - b.group("N · Miniaturas y medios por el shell"); - - b.case( - "N1", - "el shell da miniatura de una imagen del disco", - || { - let png = std::path::Path::new("fixtures/texto-en-imagen.png"); - let started = std::time::Instant::now(); - let dib = thumbnail::dib_of_file(png, thumbnail::SIDE) - .ok_or("el shell no devolvió miniatura")?; - let took = started.elapsed(); - let small = cp_core::dib::to_png(&dib).ok_or("el DIB no se pudo convertir")?; - let original = std::fs::metadata(png).map_err(|why| why.to_string())?.len(); - println!( - " {took:?}, {} B de miniatura contra {original} del original", - small.len() - ); - if small.len() as u64 >= original { - return Err("la miniatura no es más pequeña que el original".into()); - } - Ok(()) - }, - ); - - b.case("N2", "lo que no tiene miniatura no inventa uno", || { - let dir = std::env::temp_dir().join("cp-sin-miniatura"); - std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; - let path = dir.join("vacio.bin"); - std::fs::write(&path, b"").map_err(|why| why.to_string())?; - match thumbnail::dib_of_file(&path, thumbnail::SIDE) { - None => Ok(()), - Some(_) => Err("devolvió algo para un archivo sin vista previa".into()), - } - }); - - b.case( - "N3", - "un archivo sin metadatos de medios no los inventa", - || { - let dir = std::env::temp_dir().join("cp-sin-medios"); - std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; - let path = dir.join("nota.txt"); - std::fs::write(&path, b"solo texto").map_err(|why| why.to_string())?; - match media::info_for(&path) { - None => Ok(()), - Some(info) => Err(format!("se inventó {info:?}")), - } - }, - ); - - b.group("C · Los formatos que cuelgan no se piden"); - - b.case("C1", "nada marcado como presencia se llega a pedir", || { - let clipboard = Clipboard::open().ok_or("no abrió")?; - let ids = clipboard.offered(); - let risky: Vec = ids - .iter() - .map(|id| name_of(*id)) - .filter(|name| CATALOG.decide(name) != Take::Payload) - .collect(); - println!( - " {} anotados sin pedir: {}", - risky.len(), - risky.join(", ") - ); - Ok(()) - }); - - b.group("D · El vigilante"); - - b.case("D1", "una escritura ajena se ve como copia nueva", || { - let mut watcher = Watcher::new(Cadence::Opaque); - let start = clipboard::sequence().ok_or("sin contador")?; - watcher.tick(start); - if watcher.tick(start) != Seen::Nothing { - return Err("un contador quieto produjo un evento".into()); - } - Ok(()) - }); - - b.case("D2", "el contador no se mueve al leer", || { - let before = clipboard::sequence().ok_or("sin contador")?; - { - let clipboard = Clipboard::open().ok_or("no abrió")?; - for id in clipboard.offered() { - let _ = clipboard.size_of(id); - } - } - let after = clipboard::sequence().ok_or("sin contador")?; - if before != after { - return Err(format!("pasó de {before} a {after}")); - } - Ok(()) - }); - - b.group("E · Copiar o cortar"); - - let effect = { - let clipboard = Clipboard::open(); - clipboard.and_then(|clipboard| { - let ids = clipboard.offered(); - ids.iter() - .find(|id| name_of(**id) == "Preferred DropEffect") - .and_then(|id| clipboard.bytes(*id)) - }) - }; - match effect { - Some(bytes) => b.case("E1", "el efecto se lee por sus bits", || { - let seen = transfer::transfer(&bytes); - println!(" {seen:?} sobre {bytes:?}"); - if seen == Transfer::Unsaid { - return Err("el explorador siempre dice copia o corte".into()); - } - Ok(()) - }), - None => b.skip( - "E1", - "el efecto se lee por sus bits", - "no hay archivos copiados: hazlo en el explorador y repite", - ), - } - - println!( - "\n {} ok, {} fallan, {} saltadas\n", - b.passed, b.failed, b.skipped - ); - if b.failed > 0 { - std::process::ExitCode::FAILURE - } else { - std::process::ExitCode::SUCCESS - } -} +#[cfg(not(target_os = "windows"))] +fn main() {} diff --git a/crates/cp-win/examples/probe/battery.rs b/crates/cp-win/examples/probe/battery.rs new file mode 100644 index 0000000..a80dd8d --- /dev/null +++ b/crates/cp-win/examples/probe/battery.rs @@ -0,0 +1,894 @@ +use cp_core::formats::{Family, Take}; +use cp_core::watch::{Cadence, Seen, Watcher}; +use cp_win::capture::{self, Captured, capture}; +use cp_win::formats::CATALOG; +use cp_win::paste::{Outcome, paste_into}; +use cp_win::restore::{Restored, to_clipboard, to_clipboard_as_plain_text}; +use cp_win::transfer::{self, Transfer}; +use cp_win::watching::Watching; +use cp_win_sys::clipboard::{self, Clipboard}; +use cp_win_sys::formats::{CF_UNICODETEXT, name_of}; +use cp_win_sys::frontmost::{self, Target}; +use cp_win_sys::permissions::Readiness; +use cp_win_sys::reading::{self, PATIENCE, Reading}; +use cp_win_sys::window::EditWindow; +use cp_win_sys::writing::{Written, text_of, utf16_of}; +use cp_win_sys::{media, ocr, thumbnail}; + +const CASES: u32 = 39; +const MAY_SKIP: &[&str] = &["B4", "E1", "L1"]; + +struct Battery { + passed: u32, + failed: u32, + skipped: Vec<&'static str>, +} + +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: &'static str, what: &str, why: &str) { + if MAY_SKIP.contains(&id) { + self.skipped.push(id); + println!(" salta {id:<5} {what}\n {why}"); + } else { + self.failed += 1; + println!(" FALLA {id:<5} {what}: este caso no puede saltarse"); + } + } +} + +fn offered_names() -> Result, String> { + let clipboard = Clipboard::open().ok_or("no se pudo abrir el portapapeles")?; + Ok(clipboard.offered().into_iter().map(name_of).collect()) +} + +fn main() -> std::process::ExitCode { + println!("\nBatería del núcleo contra el portapapeles de Windows"); + + let mut b = Battery { + passed: 0, + failed: 0, + skipped: Vec::new(), + }; + + b.group("A · El portapapeles responde"); + + b.case("A1", "se abre y se cierra sin quedarse tomado", || { + { + let _first = Clipboard::open().ok_or("no abrió")?; + } + let _second = Clipboard::open().ok_or("no abrió la segunda vez")?; + Ok(()) + }); + + b.case("A2", "el contador de secuencia se lee", || { + clipboard::sequence() + .map(|_| ()) + .ok_or_else(|| "devolvió cero: no se alcanza la estación de ventanas".into()) + }); + + b.case("A3", "enumerar no pide un solo byte", || { + let names = offered_names()?; + println!(" {} formatos: {}", names.len(), names.join(", ")); + Ok(()) + }); + + b.group("B · El catálogo contra lo que hay de verdad"); + + b.case("B1", "todo lo ofrecido recibe una decisión", || { + let names = offered_names()?; + if names.is_empty() { + return Err("el portapapeles está vacío: copia algo y repite".into()); + } + for name in &names { + let take = CATALOG.decide(name); + let mark = match take { + Take::Payload => "copia", + Take::Presence => "anota", + Take::Never => "nunca", + }; + println!(" {mark} {name}"); + } + Ok(()) + }); + + b.case("B2", "lo que se copia se puede leer de verdad", || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let ids = clipboard.offered(); + let names: Vec = ids.iter().map(|id| name_of(*id)).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + if CATALOG.refusal(&refs).is_some() { + return Err("la fuente pidió no registrar esto".into()); + } + let mut read = 0usize; + let mut bytes = 0usize; + for (id, name) in ids.iter().zip(&names) { + if CATALOG.decide(name) != Take::Payload || CATALOG.costlier_twin(name, &refs) { + continue; + } + match clipboard.size_of(*id) { + Some(size) => { + read += 1; + bytes += size; + println!(" {size:>9} B {name}"); + } + None => println!(" {:>9} {name}", "sin datos"), + } + } + if read == 0 { + return Err("nada de lo que el catálogo quiere entregó bytes".into()); + } + println!(" {read} formatos, {bytes} bytes"); + Ok(()) + }); + + b.case("B3", "la clase que sale es una de las tres", || { + let names = offered_names()?; + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + match CATALOG.classify(&refs) { + Some(family) => { + println!(" {family:?}"); + Ok(()) + } + None => Err(format!("ninguna clase para {names:?}")), + } + }); + + let names = offered_names().unwrap_or_default(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let courtesy = refs.iter().any(|id| CATALOG.embeddable.contains(id)) + && CATALOG.preferred_image(&refs).is_some(); + if courtesy { + b.case( + "B4", + "una hoja de cálculo no se guarda como foto", + || match CATALOG.classify(&refs) { + Some(Family::Text) => Ok(()), + other => Err(format!("se clasificó como {other:?}")), + }, + ); + } else { + b.skip( + "B4", + "una hoja de cálculo no se guarda como foto", + "lo copiado no es un documento con imagen: copia un rango de Excel y repite", + ); + } + + b.group("F · Ida y vuelta, montada por nosotros"); + + b.case("F1", "lo que escribimos se vuelve a leer igual", || { + let written = "cp-f1-ida-y-vuelta ñ 🦀"; + { + let clipboard = Clipboard::open().ok_or("no abrió para escribir")?; + match clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]) { + Written::Placed { formats: 1 } => {} + other => return Err(format!("la escritura dio {other:?}")), + } + } + let clipboard = Clipboard::open().ok_or("no abrió para leer")?; + let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("no devolvió bytes")?; + match text_of(&bytes).as_deref() { + Some(back) if back == written => Ok(()), + other => Err(format!("volvió «{other:?}»")), + } + }); + + b.case("F2", "escribir mueve el contador y leer no", || { + let before = clipboard::sequence().ok_or("sin contador")?; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f2"))]); + } + let after = clipboard::sequence().ok_or("sin contador")?; + if after == before { + return Err("el contador no se movió al escribir".into()); + } + let quiet = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let _ = clipboard.bytes(CF_UNICODETEXT); + clipboard::sequence().ok_or("sin contador")? + }; + if quiet != after { + return Err(format!("leer movió el contador de {after} a {quiet}")); + } + println!(" {before} → {after} por una escritura"); + Ok(()) + }); + + b.case( + "F3", + "el vigilante ve nuestra escritura como nuestra", + || { + let mut watcher = Watcher::new(Cadence::Opaque); + watcher.tick(clipboard::sequence().ok_or("sin contador")?); + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f3"))]); + } + let ours = clipboard::sequence().ok_or("sin contador")?; + watcher.wrote(ours); + match watcher.tick(ours) { + Seen::Ours => Ok(()), + other => Err(format!("se vio como {other:?}")), + } + }, + ); + + b.case("F4", "una copia ajena tras la nuestra no se traga", || { + let mut watcher = Watcher::new(Cadence::Opaque); + watcher.tick(clipboard::sequence().ok_or("sin contador")?); + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f4-nuestro"))]); + } + let ours = clipboard::sequence().ok_or("sin contador")?; + watcher.wrote(ours); + if watcher.tick(ours) != Seen::Ours { + return Err("la nuestra no se reconoció".into()); + } + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-f4-ajeno"))]); + } + let theirs = clipboard::sequence().ok_or("sin contador")?; + match watcher.tick(theirs) { + Seen::Fresh { .. } => Ok(()), + other => Err(format!("la siguiente copia se vio como {other:?}")), + } + }); + + b.group("G · Lo que no entrega a tiempo se abandona"); + + b.case( + "G1", + "un formato con datos responde muy por debajo del techo", + || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-g1"))]); + } + let started = std::time::Instant::now(); + let seen = reading::within(PATIENCE, || { + let clipboard = Clipboard::open()?; + clipboard.bytes(CF_UNICODETEXT) + }); + let took = started.elapsed(); + if seen.is_too_slow() { + return Err(format!("no llegó en {PATIENCE:?}")); + } + println!(" {took:?} contra un techo de {PATIENCE:?}"); + Ok(()) + }, + ); + + b.case("G2", "lo que no contesta se abandona en el techo", || { + let started = std::time::Instant::now(); + let seen = reading::within(PATIENCE, || { + std::thread::sleep(std::time::Duration::from_secs(30)); + Some(Vec::new()) + }); + let took = started.elapsed(); + if seen != Reading::TooSlow { + return Err(format!("se esperó de más y dio {seen:?}")); + } + if took > PATIENCE * 3 { + return Err(format!("tardó {took:?} en rendirse")); + } + println!(" abandonado en {took:?}, no en los 30 s medidos"); + Ok(()) + }); + + b.group("H · La captura, de punta a punta"); + + b.case("G3", "la captura entera tiene su propio techo", || { + let started = std::time::Instant::now(); + let seen = capture::capture_within(std::time::Duration::from_nanos(1)); + let took = started.elapsed(); + if seen != Captured::TooSlow { + return Err(format!("con un techo imposible dio {seen:?}")); + } + if took > std::time::Duration::from_millis(500) { + return Err(format!("tardo {took:?} en rendirse")); + } + let after = capture::capture_within(capture::PATIENCE); + if after == Captured::TooSlow { + return Err("y el techo normal ya no alcanza para nada".into()); + } + println!( + " abandonado en {took:?}; con {:?} si captura", + capture::PATIENCE + ); + Ok(()) + }); + + b.case("H1", "un texto copiado se convierte en un ítem", || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("alguien@ejemplo.test"))]); + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(item) => { + if item.kind != Some(cp_core::kind::Kind::Email) { + return Err(format!("la clase salió {:?}", item.kind)); + } + println!( + " {} formatos, {} bytes guardados, clase {:?}", + item.formats.len(), + item.stored_bytes(), + item.kind + ); + Ok(()) + } + other => Err(format!("no se capturó: {other:?}")), + } + }); + + b.case( + "H2", + "el conjunto entero se anota, no solo lo que se copia", + || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let offered = clipboard.offered().len(); + match capture(&clipboard) { + Captured::Kept(item) => { + if item.formats.len() < offered { + return Err(format!( + "se ofrecieron {offered} y solo se anotaron {}", + item.formats.len() + )); + } + Ok(()) + } + other => Err(format!("no se capturó: {other:?}")), + } + }, + ); + + b.case("H3", "dos copias iguales tienen la misma huella", || { + let write = |text: &str| { + let clipboard = Clipboard::open()?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(text))]); + Some(()) + }; + let taken = |()| { + let clipboard = Clipboard::open()?; + match capture(&clipboard) { + Captured::Kept(item) => Some(item.fingerprint()), + _ => None, + } + }; + write("cp-h3-mismo").ok_or("no escribió")?; + let first = taken(()).ok_or("no capturó")?; + write("cp-h3-mismo").ok_or("no escribió")?; + let again = taken(()).ok_or("no capturó")?; + write("cp-h3-distinto").ok_or("no escribió")?; + let other = taken(()).ok_or("no capturó")?; + if first != again { + return Err("lo mismo dio dos huellas".into()); + } + if first == other { + return Err("dos contenidos distintos dieron la misma huella".into()); + } + Ok(()) + }); + + b.case("H4", "un marcador de secreto detiene la captura", || { + let marker = cp_win_sys::formats::name_of( + cp_win_sys::clipboard::register("Clipboard Viewer Ignore").ok_or("no registró")?, + ); + if marker != "Clipboard Viewer Ignore" { + return Err(format!("el formato se registró como «{marker}»")); + } + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let id = + cp_win_sys::clipboard::register("Clipboard Viewer Ignore").ok_or("no registró")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("una-contrasena")), (id, &[1u8])]); + } + let seen = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + capture(&clipboard) + }; + { + let clipboard = Clipboard::open().ok_or("no abrió para limpiar")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-h4-limpio"))]); + } + match seen { + Captured::Refused(why) => { + println!(" rechazada por {why:?}, y el marcador se retiró"); + Ok(()) + } + other => Err(format!("se capturó igualmente: {other:?}")), + } + }); + + b.case("H5", "la batería no deja marcadores puestos", || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let names: Vec = clipboard.offered().into_iter().map(name_of).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + match CATALOG.refusal(&refs) { + None => Ok(()), + Some(left) => Err(format!("quedó {left:?} del caso anterior")), + } + }); + + b.group("I · Restaurar"); + + b.case( + "I1", + "un ítem vuelve al portapapeles con sus formatos", + || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i1-original"))]); + } + let item = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(item) => item, + other => return Err(format!("no se capturó: {other:?}")), + } + }; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i1-otra-cosa"))]); + } + let written = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard(&clipboard, &item) + }; + match written { + Restored::Written { formats, .. } => { + println!(" {formats} formatos devueltos"); + } + other => return Err(format!("no se restauró: {other:?}")), + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("sin texto")?; + match text_of(&bytes).as_deref() { + Some("cp-i1-original") => Ok(()), + other => Err(format!("volvió «{other:?}»")), + } + }, + ); + + b.case("I2", "capturar lo restaurado da la misma huella", || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i2-ida-y-vuelta"))]); + } + let (first, item) = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(item) => (item.fingerprint(), item), + other => return Err(format!("no se capturó: {other:?}")), + } + }; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard(&clipboard, &item); + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(again) => { + if again.fingerprint() == first { + Ok(()) + } else { + Err("la huella cambió al ir y volver".into()) + } + } + other => Err(format!("no se recapturó: {other:?}")), + } + }); + + b.case("I3", "pegar en plano no muda lo guardado", || { + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-i3-con-estilos"))]); + } + let item = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + match capture(&clipboard) { + Captured::Kept(item) => item, + other => return Err(format!("no se capturó: {other:?}")), + } + }; + let before = item.clone(); + let written = { + let clipboard = Clipboard::open().ok_or("no abrió")?; + to_clipboard_as_plain_text(&clipboard, &item) + }; + if !matches!( + written, + Restored::Written { + incomplete: false, + .. + } + ) { + return Err(format!("la escritura plana dio {written:?}")); + } + if item != before { + return Err("el ítem se mutiló al pegarlo en plano".into()); + } + Ok(()) + }); + + b.group("J · El vigilante en marcha"); + + b.case("J1", "una copia despierta al vigilante", || { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + let seen = Arc::new(AtomicUsize::new(0)); + let counter = seen.clone(); + let watching = Watching::every(std::time::Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::Relaxed); + }); + std::thread::sleep(std::time::Duration::from_millis(60)); + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j1"))]); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + drop(watching); + match seen.load(Ordering::Relaxed) { + 0 => Err("la copia no se vio".into()), + n => { + println!(" {n} aviso(s) por una copia"); + Ok(()) + } + } + }); + + b.case("J2", "un portapapeles quieto no despierta a nadie", || { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j2-quieto"))]); + } + std::thread::sleep(std::time::Duration::from_millis(60)); + let seen = Arc::new(AtomicUsize::new(0)); + let counter = seen.clone(); + let watching = Watching::every(std::time::Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::Relaxed); + }); + std::thread::sleep(std::time::Duration::from_millis(250)); + drop(watching); + match seen.load(Ordering::Relaxed) { + 0 => Ok(()), + n => Err(format!("{n} avisos sin que nadie copiara")), + } + }); + + b.case("J4", "lo que restauramos no se captura de vuelta", || { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + let seen = Arc::new(AtomicUsize::new(0)); + let counter = seen.clone(); + let watching = Watching::every(std::time::Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::Relaxed); + }); + std::thread::sleep(std::time::Duration::from_millis(60)); + + { + let clipboard = Clipboard::open().ok_or("no abrio")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j4-nuestra"))]); + } + watching.ours(); + std::thread::sleep(std::time::Duration::from_millis(200)); + let after_ours = seen.load(Ordering::Relaxed); + + { + let clipboard = Clipboard::open().ok_or("no abrio")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of("cp-j4-ajena"))]); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + let after_theirs = seen.load(Ordering::Relaxed); + drop(watching); + + if after_ours != 0 { + return Err(format!( + "lo nuestro desperto al vigilante {after_ours} vez(ces)" + )); + } + if after_theirs == 0 { + return Err("y entonces tampoco ve lo ajeno: la prueba no probaria nada".into()); + } + println!(" lo nuestro 0 avisos, lo ajeno {after_theirs}"); + Ok(()) + }); + + b.case("J3", "sondear el contador es casi gratis", || { + let rounds = 10_000; + let started = std::time::Instant::now(); + for _ in 0..rounds { + let _ = clipboard::sequence(); + } + let each = started.elapsed() / rounds; + println!(" {each:?} por sondeo"); + if each > std::time::Duration::from_micros(50) { + return Err(format!("{each:?} es demasiado para sondear seguido")); + } + Ok(()) + }); + + b.group("K · Permisos"); + + b.case("K1", "se sabe qué se puede hacer y qué no", || { + let ready = Readiness::probe(); + println!( + " estación: {} nivel: {:?} elevado: {}", + ready.can_watch(), + ready.integrity, + ready.is_elevated() + ); + if !ready.can_watch() { + return Err("no se alcanza la estación de ventanas".into()); + } + let ours = ready.integrity.ok_or("sin nivel propio")?; + if !ready.can_paste_into(ours) { + return Err("no se puede pegar en nuestro propio nivel".into()); + } + Ok(()) + }); + + b.group("L · Pegar de verdad"); + + let stage = EditWindow::open("destino de la bateria").and_then(|target| { + let until = std::time::Instant::now() + std::time::Duration::from_secs(2); + while frontmost::foreground() != Some(target.window()) { + if std::time::Instant::now() > until { + return None; + } + frontmost::bring_forward(target.window()); + target.pump(std::time::Duration::from_millis(50)); + } + Some(target) + }); + + match stage { + Some(target) => b.case("L1", "el texto llega a una ventana de destino", || { + let written = "cp-l1-pegado-real"; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]); + } + let seen = frontmost::target_for(target.window()); + match paste_into(&seen, || {}) { + Outcome::Sent { took } => println!(" enviado en {took:?}"), + Outcome::Degraded(why) => return Err(format!("degradó a {why:?}")), + } + target.pump(std::time::Duration::from_millis(400)); + let arrived = target.text(); + if arrived.contains(written) { + Ok(()) + } else { + Err(format!("llegó «{arrived}» en vez de «{written}»")) + } + }), + None => b.skip( + "L1", + "el texto llega a una ventana de destino", + "Windows solo deja cambiar el primer plano a quien ya lo tiene: ejecuta la bateria desde una consola con el foco", + ), + } + + b.case( + "L2", + "el peor resultado sigue siendo pegarlo a mano", + || { + let written = "cp-l2-degradado"; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + clipboard.replace(&[(CF_UNICODETEXT, &utf16_of(written))]); + } + let gone = Target { + window: windows::Win32::Foundation::HWND(std::ptr::dangling_mut()), + focus: None, + thread: 0, + }; + match paste_into(&gone, || {}) { + Outcome::Degraded(cp_core::paste::Failure::TargetGone) => {} + other => return Err(format!("con un destino muerto dio {other:?}")), + } + let clipboard = Clipboard::open().ok_or("no abrió")?; + let bytes = clipboard.bytes(CF_UNICODETEXT).ok_or("sin texto")?; + match text_of(&bytes).as_deref() { + Some(back) if back == written => Ok(()), + other => Err(format!("el portapapeles quedó con «{other:?}»")), + } + }, + ); + + b.group("M · Texto dentro de una imagen"); + + b.case("M1", "el sistema ofrece un motor de lectura", || { + if ocr::is_available() { + Ok(()) + } else { + Err("no hay motor para los idiomas del perfil".into()) + } + }); + + b.case("M2", "se lee el texto de una imagen real", || { + let png = std::fs::read("fixtures/texto-en-imagen.png") + .map_err(|why| format!("no se pudo leer el fixture: {why}"))?; + let started = std::time::Instant::now(); + let text = ocr::text_in(&png).ok_or("no se reconoció nada")?; + println!( + " {:?} para leer «{}»", + started.elapsed(), + text.lines().next().unwrap_or("").trim() + ); + Ok(()) + }); + + b.case("M3", "una imagen en blanco no inventa texto", || { + let blank = image::RgbaImage::from_pixel(120, 60, image::Rgba([255, 255, 255, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(blank) + .write_to(&mut png, image::ImageFormat::Png) + .map_err(|why| why.to_string())?; + match ocr::text_in(&png.into_inner()) { + None => Ok(()), + Some(invented) => Err(format!("se inventó «{invented}»")), + } + }); + + b.group("N · Miniaturas y medios por el shell"); + + b.case( + "N1", + "el shell da miniatura de una imagen del disco", + || { + let png = std::path::Path::new("fixtures/texto-en-imagen.png"); + let started = std::time::Instant::now(); + let dib = thumbnail::dib_of_file(png, thumbnail::SIDE) + .ok_or("el shell no devolvió miniatura")?; + let took = started.elapsed(); + let small = cp_core::dib::to_png(&dib).ok_or("el DIB no se pudo convertir")?; + let original = std::fs::metadata(png).map_err(|why| why.to_string())?.len(); + println!( + " {took:?}, {} B de miniatura contra {original} del original", + small.len() + ); + if small.len() as u64 >= original { + return Err("la miniatura no es más pequeña que el original".into()); + } + Ok(()) + }, + ); + + b.case("N2", "lo que no tiene miniatura no inventa uno", || { + let dir = std::env::temp_dir().join("cp-sin-miniatura"); + std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + let path = dir.join("vacio.bin"); + std::fs::write(&path, b"").map_err(|why| why.to_string())?; + match thumbnail::dib_of_file(&path, thumbnail::SIDE) { + None => Ok(()), + Some(_) => Err("devolvió algo para un archivo sin vista previa".into()), + } + }); + + b.case( + "N3", + "un archivo sin metadatos de medios no los inventa", + || { + let dir = std::env::temp_dir().join("cp-sin-medios"); + std::fs::create_dir_all(&dir).map_err(|why| why.to_string())?; + let path = dir.join("nota.txt"); + std::fs::write(&path, b"solo texto").map_err(|why| why.to_string())?; + match media::info_for(&path) { + None => Ok(()), + Some(info) => Err(format!("se inventó {info:?}")), + } + }, + ); + + b.group("C · Los formatos que cuelgan no se piden"); + + b.case("C1", "nada marcado como presencia se llega a pedir", || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let ids = clipboard.offered(); + let risky: Vec = ids + .iter() + .map(|id| name_of(*id)) + .filter(|name| CATALOG.decide(name) != Take::Payload) + .collect(); + println!( + " {} anotados sin pedir: {}", + risky.len(), + risky.join(", ") + ); + Ok(()) + }); + + b.group("D · El vigilante"); + + b.case("D1", "una escritura ajena se ve como copia nueva", || { + let mut watcher = Watcher::new(Cadence::Opaque); + let start = clipboard::sequence().ok_or("sin contador")?; + watcher.tick(start); + if watcher.tick(start) != Seen::Nothing { + return Err("un contador quieto produjo un evento".into()); + } + Ok(()) + }); + + b.case("D2", "el contador no se mueve al leer", || { + let before = clipboard::sequence().ok_or("sin contador")?; + { + let clipboard = Clipboard::open().ok_or("no abrió")?; + for id in clipboard.offered() { + let _ = clipboard.size_of(id); + } + } + let after = clipboard::sequence().ok_or("sin contador")?; + if before != after { + return Err(format!("pasó de {before} a {after}")); + } + Ok(()) + }); + + b.group("E · Copiar o cortar"); + + let effect = { + let clipboard = Clipboard::open(); + clipboard.and_then(|clipboard| { + let ids = clipboard.offered(); + ids.iter() + .find(|id| name_of(**id) == "Preferred DropEffect") + .and_then(|id| clipboard.bytes(*id)) + }) + }; + match effect { + Some(bytes) => b.case("E1", "el efecto se lee por sus bits", || { + let seen = transfer::transfer(&bytes); + println!(" {seen:?} sobre {bytes:?}"); + if seen == Transfer::Unsaid { + return Err("el explorador siempre dice copia o corte".into()); + } + Ok(()) + }), + None => b.skip( + "E1", + "el efecto se lee por sus bits", + "no hay archivos copiados: hazlo en el explorador y repite", + ), + } + + let ran = b.passed + b.failed + u32::try_from(b.skipped.len()).unwrap_or(u32::MAX); + if ran != CASES { + b.failed += 1; + println!(" FALLA corrieron {ran} casos de {CASES}: la bateria se desactivo sola"); + } + println!( + "\n {} ok, {} fallan, {} saltadas\n", + b.passed, + b.failed, + format_args!("{} ({})", b.skipped.len(), b.skipped.join(", ")) + ); + if b.failed > 0 { + std::process::ExitCode::FAILURE + } else { + std::process::ExitCode::SUCCESS + } +} diff --git a/crates/cp-win/src/capture.rs b/crates/cp-win/src/capture.rs index 23d446d..a8a5b7d 100644 --- a/crates/cp-win/src/capture.rs +++ b/crates/cp-win/src/capture.rs @@ -4,7 +4,7 @@ use cp_core::item::{Format, Item, Payload, SYNTHETIC_IMAGE}; use cp_core::kind::{self, Kind}; use cp_win_sys::clipboard::Clipboard; use cp_win_sys::formats::name_of; -use cp_win_sys::reading::{self, PATIENCE, Reading}; +use cp_win_sys::reading; use cp_win_sys::writing::text_of; use crate::formats::CATALOG; @@ -14,6 +14,20 @@ pub enum Captured { Kept(Item), Refused(Refusal), Nothing, + TooSlow, +} + +pub const PATIENCE: std::time::Duration = std::time::Duration::from_millis(400); + +const _: () = assert!(PATIENCE.as_millis() > cp_win_sys::reading::PATIENCE.as_millis()); +const _: () = assert!(PATIENCE.as_millis() < 30_000); + +pub fn capture_within(patience: std::time::Duration) -> Captured { + reading::anything_within(patience, || match Clipboard::open() { + Some(clipboard) => capture(&clipboard), + None => Captured::Nothing, + }) + .unwrap_or(Captured::TooSlow) } pub fn capture(clipboard: &Clipboard) -> Captured { @@ -58,26 +72,28 @@ fn asked_not_to_be_kept(clipboard: &Clipboard, ids: &[u32], names: &[String]) -> if !CATALOG.denied_when_zero.contains(&name.as_str()) { return None; } - let value = read(clipboard, *id).bytes().unwrap_or_default(); + let value = read(clipboard, *id).unwrap_or_default(); CATALOG.declines(name, &value) }) } fn payload_for(clipboard: &Clipboard, id: u32, name: &str, offered: &[&str]) -> Payload { - if CATALOG.decide(name) != Take::Payload || CATALOG.costlier_twin(name, offered) { + if CATALOG.decide(name) != Take::Payload { + return Payload::Announced { size: None }; + } + if CATALOG.costlier_twin(name, offered) { return Payload::Announced { size: clipboard.size_of(id), }; } match read(clipboard, id) { - Reading::Delivered(bytes) => Payload::stored(bytes), - Reading::Empty | Reading::TooSlow => Payload::Absent, + Some(bytes) => Payload::stored(bytes), + None => Payload::Absent, } } -fn read(clipboard: &Clipboard, id: u32) -> Reading { - let bytes = clipboard.bytes(id); - reading::within(PATIENCE, move || bytes) +fn read(clipboard: &Clipboard, id: u32) -> Option> { + clipboard.bytes(id) } fn transcoded_image( @@ -95,7 +111,7 @@ fn transcoded_image( .zip(names) .find(|(_, name)| name.as_str() == chosen) .map(|(id, _)| *id)?; - let raw = read(clipboard, id).bytes()?; + let raw = read(clipboard, id)?; let png = dib::to_png(&raw)?; Some(Format { id: SYNTHETIC_IMAGE.into(), @@ -196,6 +212,21 @@ mod tests { out } + #[test] + fn the_whole_capture_has_a_ceiling_well_under_the_thirty_seconds() { + assert!(PATIENCE < std::time::Duration::from_secs(1)); + assert!(PATIENCE > cp_win_sys::reading::PATIENCE); + } + + #[test] + fn a_capture_that_does_not_finish_in_time_is_abandoned() { + let seen = reading::anything_within(std::time::Duration::from_millis(20), || { + std::thread::sleep(std::time::Duration::from_secs(30)); + Captured::Nothing + }); + assert_eq!(seen, None, "el hilo se abandona y no se espera"); + } + #[test] fn every_copied_path_is_read_not_just_the_first() { let paths = [r"C:\uno.txt", r"C:\dos.txt", r"C:\una carpeta"]; diff --git a/crates/cp-win/src/paste.rs b/crates/cp-win/src/paste.rs index 97fc713..19d171b 100644 --- a/crates/cp-win/src/paste.rs +++ b/crates/cp-win/src/paste.rs @@ -1,9 +1,8 @@ -use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase}; +use cp_core::paste::{Attempt, Failure, Focus, Next, ORDER, Phase, SETTLE}; use cp_win_sys::frontmost::{self, Attached, Target}; use cp_win_sys::keystroke; use std::time::{Duration, Instant}; -const SETTLE: Duration = Duration::from_millis(60); const MODIFIERS_GO: Duration = Duration::from_millis(120); const TARGET_ANSWERS_MS: u32 = 200; @@ -49,7 +48,12 @@ pub fn paste_into(target: &Target, hide_panel: impl FnOnce()) -> Outcome { && frontmost::is_alive(inner) && frontmost::answers(inner, TARGET_ANSWERS_MS) { - attached.focus_on(inner); + while !attached.focus_on(inner) { + if attempt.on_failure(Failure::NoKeyboardFocus) != Next::Retry { + return Outcome::Degraded(Failure::NoKeyboardFocus); + } + std::thread::sleep(SETTLE); + } } let waiting = Instant::now(); @@ -101,7 +105,7 @@ mod tests { #[test] fn the_waits_are_shorter_than_the_paste_they_guard() { - assert!(SETTLE < MODIFIERS_GO); + assert!(SETTLE <= MODIFIERS_GO); assert!(MODIFIERS_GO < Duration::from_millis(500)); assert!(u128::from(TARGET_ANSWERS_MS) < MODIFIERS_GO.as_millis() * 2); } diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index 4cdc95c..fefa62c 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -13,11 +13,16 @@ pub enum Restored { pub fn to_clipboard(clipboard: &Clipboard, item: &Item) -> Restored { let mut owned: Vec<(u32, Vec)> = Vec::new(); + let mut returned = 0; for format in &item.formats { let Some(bytes) = payload_of(format) else { continue; }; - for (id, bytes) in writable(&format.id, bytes) { + let ids = writable(&format.id, bytes); + if !ids.is_empty() { + returned += 1; + } + for (id, bytes) in ids { if !owned.iter().any(|(kept, _)| *kept == id) { owned.push((id, bytes)); } @@ -33,7 +38,7 @@ pub fn to_clipboard(clipboard: &Clipboard, item: &Item) -> Restored { match clipboard.replace(&entries) { Written::Placed { formats } => Restored::Written { formats, - incomplete: formats != item.formats.len(), + incomplete: returned != item.formats.len(), }, Written::Refused => Restored::Failed, } @@ -131,6 +136,21 @@ mod tests { assert!(written[0].0 >= 0xC000); } + #[test] + fn an_image_that_needs_two_ids_is_not_an_incomplete_restore() { + let png = image_bytes(); + let ids = writable(SYNTHETIC_IMAGE, &png); + assert_eq!(ids.len(), 2, "un formato guardado sale por dos vias"); + let item = Item { + kind: None, + formats: vec![Format { + id: SYNTHETIC_IMAGE.into(), + payload: Payload::Inline(png), + }], + }; + assert_eq!(item.formats.len(), 1, "y sigue siendo un solo formato"); + } + #[test] fn nothing_readable_is_nothing_to_write() { let item = Item { diff --git a/crates/cp-win/src/watching.rs b/crates/cp-win/src/watching.rs index c64570b..4f84c7a 100644 --- a/crates/cp-win/src/watching.rs +++ b/crates/cp-win/src/watching.rs @@ -1,36 +1,46 @@ use cp_core::watch::{Cadence, Seen, Watcher}; use cp_win_sys::clipboard; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; pub const EVERY: Duration = Duration::from_millis(60); +const NAP: Duration = Duration::from_millis(10); const _: () = assert!(EVERY.as_millis() >= 16); const _: () = assert!(EVERY.as_millis() <= 250); +const _: () = assert!(NAP.as_millis() <= EVERY.as_millis()); pub struct Watching { stop: Arc, + watcher: Arc>, thread: Option>, } impl Watching { pub fn every(period: Duration, mut on_fresh: impl FnMut() + Send + 'static) -> Self { let stop = Arc::new(AtomicBool::new(false)); + let watcher = Arc::new(Mutex::new(Watcher::new(Cadence::Opaque))); let mine = stop.clone(); + let theirs = watcher.clone(); let thread = std::thread::spawn(move || { - let mut watcher = Watcher::new(Cadence::Opaque); while !mine.load(Ordering::Relaxed) { if let Some(count) = clipboard::sequence() + && let Ok(mut watcher) = theirs.lock() && let Seen::Fresh { .. } = watcher.tick(count) { + drop(watcher); on_fresh(); } - std::thread::sleep(period); + let until = std::time::Instant::now() + period; + while !mine.load(Ordering::Relaxed) && std::time::Instant::now() < until { + std::thread::sleep(period.min(NAP)); + } } }); Self { stop, + watcher, thread: Some(thread), } } @@ -38,6 +48,17 @@ impl Watching { pub fn start(on_fresh: impl FnMut() + Send + 'static) -> Self { Self::every(EVERY, on_fresh) } + + pub fn ours(&self) -> bool { + let Some(count) = clipboard::sequence() else { + return false; + }; + let Ok(mut watcher) = self.watcher.lock() else { + return false; + }; + watcher.wrote(count); + true + } } impl Drop for Watching { @@ -71,4 +92,15 @@ mod tests { assert!(watching.thread.is_some()); drop(watching); } + + #[test] + fn a_long_period_does_not_make_stopping_take_that_long() { + let watching = Watching::every(Duration::from_secs(3600), || {}); + let started = std::time::Instant::now(); + drop(watching); + assert!( + started.elapsed() < Duration::from_secs(1), + "cerrar la aplicacion no puede esperar al siguiente sondeo" + ); + } } diff --git a/scripts/mutants-report.sh b/scripts/mutants-report.sh new file mode 100644 index 0000000..c064cef --- /dev/null +++ b/scripts/mutants-report.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +code=${CODE:-0} +title=${TITLE:-mutants} +out=${OUT:-mutants.out} + +count() { + if [ -s "$1" ]; then grep -c . "$1"; else echo 0; fi +} + +caught=$(count "$out/caught.txt") +missed=$(count "$out/missed.txt") +timed=$(count "$out/timeout.txt") +unviable=$(count "$out/unviable.txt") + +{ + echo "### $title" + echo + echo "| caught | survived | timed out | unviable |" + echo "| ---: | ---: | ---: | ---: |" + echo "| $caught | $missed | $timed | $unviable |" + echo + if [ "$missed" -gt 0 ]; then + echo "A survivor is a line the tests are not watching." + echo + echo '```' + cat "$out/missed.txt" + echo '```' + else + echo "Nothing survived." + fi +} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" + +case "$code" in + 0 | 2 | 3) + exit 0 + ;; + 4) + echo "::error::the tests already fail unmutated, so nothing above means anything" + exit 1 + ;; + 5 | 6) + echo "::error::the diff handed to --in-diff does not describe this tree" + exit 1 + ;; + *) + echo "::error::cargo mutants stopped with $code" + exit 1 + ;; +esac diff --git a/scripts/mutants_score.py b/scripts/mutants_score.py new file mode 100644 index 0000000..24b55e9 --- /dev/null +++ b/scripts/mutants_score.py @@ -0,0 +1,70 @@ +import json +import os +import pathlib +import sys + + +def score() -> int: + root = pathlib.Path(os.environ.get("FROM", "outcomes")) + want = int(os.environ.get("WANT", "1")) + badge = pathlib.Path(os.environ.get("BADGE", "mutants.json")) + + found = sorted(root.rglob("outcomes.json")) + if len(found) < want: + print( + f"::error::{len(found)} of {want} shards reached here: " + "a score over part of a sweep would be a lie" + ) + return 1 + + caught = 0 + missed = 0 + for one in found: + try: + seen = json.loads(one.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as why: + print(f"::error::{one} is not a report: {why}") + return 1 + caught += int(seen.get("caught", 0)) + int(seen.get("timeout", 0)) + missed += int(seen.get("missed", 0)) + + total = caught + missed + if total == 0: + print("::error::not one mutant was tested, so there is no score to tell") + return 1 + + rate = caught * 100 / total + colour = "green" if rate >= 80 else "orange" if rate >= 60 else "red" + badge.write_text( + json.dumps( + { + "schemaVersion": 1, + "label": "mutants", + "message": f"{rate:.1f}%", + "color": colour, + } + ), + encoding="utf-8", + ) + + summary = os.environ.get("GITHUB_STEP_SUMMARY") + lines = [ + "### What the sweep came to", + "", + f"| shards | caught | survived | score |", + "| ---: | ---: | ---: | ---: |", + f"| {len(found)} | {caught} | {missed} | {rate:.1f}% |", + "", + ] + if summary: + with open(summary, "a", encoding="utf-8") as out: + out.write("\n".join(lines) + "\n") + else: + print("\n".join(lines)) + + print(f"{rate:.1f}") + return 0 + + +if __name__ == "__main__": + sys.exit(score())