From 656045c4b44ff4d73435e2773ee47564b458d038 Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 19 Jul 2026 11:14:19 -0400 Subject: [PATCH] Capture oversized screenshots instead of dropping them. Retina clipboard images over the RGBA size cap were silently skipped; downscale and store a thumbnail so they still appear in history, with regression tests. --- docs/TESTING.md | 13 +- src-tauri/src/clipboard.rs | 257 +++++++++++++++++++++----- src-tauri/src/db.rs | 32 ++++ src-tauri/src/image_util.rs | 63 +++++++ src/components/ClipboardList.test.tsx | 16 ++ 5 files changed, 334 insertions(+), 47 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 30c3177..fcbf97f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -16,14 +16,14 @@ maCopy has an automated suite. There is no separate “live” Storybook/UI docs | `npm run lint` | TypeScript `tsc --noEmit` | | `npm run check` | Lint + all tests (CI-style gate) | -Current counts (approx.): **~60 frontend** + **~52 Rust** unit tests. +Current counts (approx.): **~60 frontend** + **~65 Rust** unit tests. ## What is covered ### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`) - App: load entries, paste invoke, delete via keyboard -- ClipboardList: selection, multi-select modifiers, image stubs/thumbs, Alt=plain paste +- ClipboardList: selection, multi-select modifiers, image stubs/thumbs (including truncated thumb previews), Alt=plain paste - ContextMenu: Paste / Paste plain / transforms / Pin / Delete - SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable** - SearchBar basics @@ -34,18 +34,23 @@ Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard, ### Backend (`src-tauri/src/**` `#[cfg(test)]`) - SQLite CRUD, FTS5, trim + pin preservation, settings -- List previews: truncate text, omit image blobs, return thumbnails +- List previews: truncate text, omit image blobs, return thumbnails (including stub `[image WxH]` rows) +- Search excludes image rows (text-only FTS) - Sensitive redaction (card / password / banking) while `get_entry` stays full - Hotkey parser (`cmd+\`` etc.) -- Image thumbnail / PNG helpers +- Image thumbnail / PNG helpers + `fit_rgba_under_bytes` against production `MAX_IMAGE_BYTES` +- **Oversized / Retina screenshot prepare** — never skip; downscale + thumb + DB list preview - Clipboard content hashing fingerprints ## What is *not* covered (yet) - End-to-end UI on a real Mac (no Playwright against the Tauri window) +- Live pasteboard polling (requires macOS Accessibility / real `NSPasteboard`) - Accessibility permission / AppleScript paste in CI - Notarized release smoke tests - Performance benchmarks in CI +- Prefer-image-when-text-also-present (poller text-wins path) +- Exclude-apps / pause-monitoring integration ## Recommended workflow diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 408fdeb..71573de 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -1,5 +1,5 @@ use crate::db::Database; -use crate::image_util::{encode_rgba_to_png, make_thumbnail_data_uri}; +use crate::image_util::{encode_rgba_to_png, fit_rgba_under_bytes, make_thumbnail_data_uri}; use arboard::Clipboard; use base64::Engine; use sha2::{Digest, Sha256}; @@ -8,10 +8,23 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use tauri::{AppHandle, Emitter}; -/// Skip encoding RGBA buffers larger than this (keeps history DB usable). -const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024; +/// Max RGBA bytes kept for encode/storage (keeps history DB usable). +/// Larger pasteboard images are downscaled — never silently dropped. +pub(crate) const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024; /// Cap stored full PNG size; list uses a separate tiny thumbnail. -const MAX_STORED_PNG_BYTES: usize = 1_500_000; +pub(crate) const MAX_STORED_PNG_BYTES: usize = 1_500_000; + +/// Result of turning a pasteboard RGBA buffer into DB-ready fields. +#[derive(Debug, Clone)] +pub(crate) struct PreparedImage { + pub content: String, + pub thumbnail: Option, + pub width: u32, + pub height: u32, + pub downscaled: bool, + /// True when `content` is a stub and only the thumbnail is visual. + pub thumbnail_only: bool, +} /// Hash arbitrary bytes for deduplication. pub fn hash_content(data: &[u8]) -> String { @@ -21,7 +34,7 @@ pub fn hash_content(data: &[u8]) -> String { } /// Cheap image fingerprint: dimensions + length + edge samples (not full-buffer SHA). -fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String { +pub(crate) fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update((width as u64).to_le_bytes()); hasher.update((height as u64).to_le_bytes()); @@ -37,6 +50,51 @@ fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String { hex::encode(hasher.finalize()) } +/// Downscale if needed, build thumbnail + PNG (or stub). Never returns "skip". +/// This is the regression lock for Retina screenshots that used to vanish. +pub(crate) fn prepare_clipboard_image( + width: u32, + height: u32, + rgba: &[u8], + max_rgba_bytes: usize, + max_png_bytes: usize, +) -> Result { + let orig_w = width; + let orig_h = height; + let (w, h, fitted) = fit_rgba_under_bytes(width, height, rgba, max_rgba_bytes); + let downscaled = w < orig_w || h < orig_h; + let thumb = make_thumbnail_data_uri(w, h, &fitted).ok(); + + match encode_rgba_to_png(w, h, &fitted) { + Ok(png_data) if png_data.len() <= max_png_bytes => { + let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data); + Ok(PreparedImage { + content: format!("data:image/png;base64,{}", b64), + thumbnail: thumb, + width: w, + height: h, + downscaled, + thumbnail_only: false, + }) + } + Ok(_) => { + let stub = format!("[image {}x{}]", w, h); + if thumb.is_none() { + return Err("PNG too large and thumbnail failed".into()); + } + Ok(PreparedImage { + content: stub, + thumbnail: thumb, + width: w, + height: h, + downscaled, + thumbnail_only: true, + }) + } + Err(e) => Err(format!("PNG encode failed: {}", e)), + } +} + /// Shared state so paste_and_refocus can update the hash the polling /// thread compares against, preventing re-insertion of pasted content. pub type LastHash = Arc>; @@ -115,56 +173,52 @@ pub fn start_polling(app: AppHandle, db: Arc, paused: Arc) } _ => { if let Ok(img) = clipboard.get_image() { - if img.bytes.len() > MAX_IMAGE_BYTES { - log::warn!( - "Skipping oversized clipboard image ({} bytes)", - img.bytes.len() - ); - continue; - } - + // Fingerprint the original pasteboard image for dedup, then + // downscale if needed. Previously we skipped anything over + // MAX_IMAGE_BYTES — Retina screenshots never appeared. let h = fingerprint_image(img.width, img.height, &img.bytes); if h == current_hash { continue; } *hash_for_thread.lock().unwrap() = h.clone(); - let w = img.width as u32; - let hgt = img.height as u32; - let thumb = make_thumbnail_data_uri(w, hgt, &img.bytes).ok(); - - match encode_rgba_to_png(w, hgt, &img.bytes) { - Ok(png_data) if png_data.len() <= MAX_STORED_PNG_BYTES => { - let b64 = - base64::engine::general_purpose::STANDARD.encode(&png_data); - let data_uri = format!("data:image/png;base64,{}", b64); - if let Err(e) = - db.insert_entry(&data_uri, "image", &h, thumb.as_deref()) - { + match prepare_clipboard_image( + img.width as u32, + img.height as u32, + &img.bytes, + MAX_IMAGE_BYTES, + MAX_STORED_PNG_BYTES, + ) { + Ok(prepared) => { + if prepared.downscaled { + log::info!( + "Downscaled clipboard image {}x{} → {}x{} for storage", + img.width, + img.height, + prepared.width, + prepared.height + ); + } + if prepared.thumbnail_only { + log::warn!( + "Stored thumbnail only for large PNG ({}x{})", + prepared.width, + prepared.height + ); + } + if let Err(e) = db.insert_entry( + &prepared.content, + "image", + &h, + prepared.thumbnail.as_deref(), + ) { log::error!("DB insert error (image): {}", e); } else { let _ = app.emit("clipboard-changed", ()); } trim_if_needed(&db); } - Ok(png_data) => { - // Still store a thumbnail-only entry so the list can show something. - if let Some(ref t) = thumb { - let stub = format!("[image {}x{}]", w, hgt); - if db - .insert_entry(&stub, "image", &h, Some(t)) - .is_ok() - { - let _ = app.emit("clipboard-changed", ()); - trim_if_needed(&db); - } - } - log::warn!( - "Stored thumbnail only for large PNG ({} bytes encoded)", - png_data.len() - ); - } - Err(e) => log::error!("PNG encode failed: {}", e), + Err(e) => log::error!("Image prepare failed: {}", e), } } } @@ -184,6 +238,8 @@ fn trim_if_needed(db: &Database) { #[cfg(test)] mod tests { use super::*; + use crate::db::Database; + use crate::image_util::data_uri_to_rgba; #[test] fn hash_content_deterministic() { @@ -241,4 +297,119 @@ mod tests { let result = encode_rgba_to_png(0, 0, &[]); assert!(result.is_ok() || result.is_err()); } + + /// Regression: Retina-class screenshots used to be skipped entirely when + /// RGBA exceeded MAX_IMAGE_BYTES. They must still produce a listable entry. + #[test] + fn prepare_retina_screenshot_is_never_skipped() { + // ~14" MBP Retina full-screen-ish: far over the 16MB RGBA cap. + let w = 3024u32; + let h = 1964u32; + let rgba_len = (w as usize) * (h as usize) * 4; + assert!( + rgba_len > MAX_IMAGE_BYTES, + "fixture must exceed MAX_IMAGE_BYTES to exercise the bug" + ); + let rgba = vec![60u8; rgba_len]; + + let prepared = + prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES) + .expect("oversized screenshot must prepare, not skip"); + + assert!(prepared.downscaled); + assert!( + (prepared.width as usize) * (prepared.height as usize) * 4 <= MAX_IMAGE_BYTES + ); + assert!(prepared.thumbnail.is_some()); + let thumb = prepared.thumbnail.as_ref().unwrap(); + assert!( + thumb.starts_with("data:image/png;base64,"), + "list preview needs a real data URI thumb" + ); + assert!(!prepared.content.is_empty()); + } + + #[test] + fn prepare_small_image_keeps_full_png() { + let w = 32u32; + let h = 24u32; + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for px in rgba.chunks_exact_mut(4) { + px.copy_from_slice(&[10, 200, 80, 255]); + } + let prepared = + prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES).unwrap(); + assert!(!prepared.downscaled); + assert!(!prepared.thumbnail_only); + assert!(prepared.content.starts_with("data:image/png;base64,")); + let (rw, rh, _) = data_uri_to_rgba(&prepared.content).unwrap(); + assert_eq!((rw, rh), (w, h)); + } + + #[test] + fn prepare_thumbnail_only_when_png_cap_tiny() { + let w = 200u32; + let h = 200u32; + // High-entropy noise → PNG won't shrink under a tiny cap. + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for (i, px) in rgba.chunks_exact_mut(4).enumerate() { + let v = (i % 251) as u8; + px.copy_from_slice(&[v, v.wrapping_mul(3), v.wrapping_mul(7), 255]); + } + let prepared = prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, 200).unwrap(); + assert!(prepared.thumbnail_only); + assert!(prepared.content.starts_with("[image ")); + assert!(prepared + .thumbnail + .as_ref() + .unwrap() + .starts_with("data:image/png;base64,")); + } + + #[test] + fn prepare_then_insert_shows_thumb_in_list() { + let w = 2500u32; + let h = 1700u32; + let rgba = vec![90u8; (w * h * 4) as usize]; + let prepared = + prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES).unwrap(); + + let db = Database::in_memory().unwrap(); + let hash = fingerprint_image(w as usize, h as usize, &rgba); + let id = db + .insert_entry( + &prepared.content, + "image", + &hash, + prepared.thumbnail.as_deref(), + ) + .unwrap(); + + let list = db.get_entries(10).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, id); + assert_eq!(list[0].content_type, "image"); + assert!( + list[0].content.starts_with("data:image/png;base64,"), + "UI renders only when list content is a data URI; got {:?}", + &list[0].content[..list[0].content.len().min(40)] + ); + } + + #[test] + fn prepare_preserves_landscape_aspect_roughly() { + let w = 4800u32; + let h = 1600u32; // 29.3 MB RGBA → must downscale + let rgba = vec![1u8; (w * h * 4) as usize]; + assert!((w as usize) * (h as usize) * 4 > MAX_IMAGE_BYTES); + let prepared = + prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES).unwrap(); + assert!(prepared.downscaled); + let orig_aspect = w as f64 / h as f64; + let new_aspect = prepared.width as f64 / prepared.height as f64; + assert!( + (orig_aspect - new_aspect).abs() < 0.05, + "aspect {orig_aspect} vs {new_aspect}" + ); + } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 8c9585a..83d947a 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -754,6 +754,38 @@ mod tests { assert_eq!(full.content, blob); } + #[test] + fn list_shows_thumb_for_stub_image_entry() { + // Oversized-PNG path stores "[image WxH]" + thumbnail — list must still + // expose a data URI so the UI can render a preview. + let db = test_db(); + let thumb = "data:image/png;base64,stubthumb"; + db.insert_entry("[image 2800x1600]", "image", "hstub", Some(thumb)) + .unwrap(); + + let entries = db.get_entries(10).unwrap(); + assert_eq!(entries[0].content, thumb); + assert!(entries[0].truncated); + assert_eq!(entries[0].content_type, "image"); + } + + #[test] + fn search_entries_excludes_images() { + let db = test_db(); + db.insert_entry("findme text", "text", "ht1", None).unwrap(); + db.insert_entry( + "data:image/png;base64,findme", + "image", + "hi1", + Some("data:image/png;base64,thumb"), + ) + .unwrap(); + + let hits = db.search_entries("findme", 50).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].content_type, "text"); + } + #[test] fn list_entries_truncate_long_text() { let db = test_db(); diff --git a/src-tauri/src/image_util.rs b/src-tauri/src/image_util.rs index 305f611..107fa81 100644 --- a/src-tauri/src/image_util.rs +++ b/src-tauri/src/image_util.rs @@ -90,6 +90,27 @@ pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result (u32, u32, Vec) { + let need = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(4); + if need == 0 || need <= max_bytes { + return (width.max(1), height.max(1), rgba.to_vec()); + } + let max_pixels = (max_bytes / 4).max(1) as f64; + let pixels = (width as f64) * (height as f64); + let scale = (max_pixels / pixels).sqrt(); + let max_edge = ((width.max(height) as f64) * scale).floor().max(1.0) as u32; + downscale_rgba(width, height, rgba, max_edge) +} + pub fn data_uri_to_rgba(data_uri: &str) -> Result<(u32, u32, Vec), String> { let b64 = data_uri .split_once(',') @@ -139,4 +160,46 @@ mod tests { let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap(); assert!(uri.starts_with("data:image/png;base64,")); } + + #[test] + fn fit_rgba_leaves_small_images_alone() { + let rgba = vec![1u8; 10 * 10 * 4]; + let (w, h, out) = fit_rgba_under_bytes(10, 10, &rgba, 16 * 1024 * 1024); + assert_eq!((w, h), (10, 10)); + assert_eq!(out.len(), rgba.len()); + } + + #[test] + fn fit_rgba_shrinks_oversized_buffer() { + let w = 2500u32; + let h = 1700u32; + let rgba = vec![40u8; (w * h * 4) as usize]; + let max = 16 * 1024 * 1024; + assert!(rgba.len() > max); + let (tw, th, out) = fit_rgba_under_bytes(w, h, &rgba, max); + assert!(out.len() <= max); + assert!(tw < w || th < h); + assert_eq!(out.len(), (tw * th * 4) as usize); + } + + #[test] + fn fit_rgba_uses_production_max_image_bytes_cap() { + // Lock the constant the clipboard poller uses — if someone raises the + // skip threshold again without downscale, this fixture still exceeds it. + let max = crate::clipboard::MAX_IMAGE_BYTES; + let w = 3024u32; + let h = 1964u32; + let rgba = vec![0u8; (w * h * 4) as usize]; + assert!(rgba.len() > max); + let (tw, th, out) = fit_rgba_under_bytes(w, h, &rgba, max); + assert!(out.len() <= max); + assert!(tw > 0 && th > 0); + } + + #[test] + fn downscale_handles_zero_dimensions() { + let (w, h, out) = downscale_rgba(0, 0, &[], 96); + assert!(w >= 1 && h >= 1); + assert_eq!(out.len(), 4); // single transparent pixel path + } } diff --git a/src/components/ClipboardList.test.tsx b/src/components/ClipboardList.test.tsx index ed5e18b..cfd1154 100644 --- a/src/components/ClipboardList.test.tsx +++ b/src/components/ClipboardList.test.tsx @@ -105,6 +105,22 @@ describe("ClipboardList", () => { expect(img).toHaveAttribute("src", "data:image/png;base64,abc"); }); + it("renders thumbnail data URI for truncated oversized-image list rows", () => { + // Backend list preview swaps full PNG for thumbnail; truncated stays true. + const entries = [ + makeEntry({ + content: "data:image/png;base64,thumbpreview", + content_type: "image", + truncated: true, + }), + ]; + render(); + expect(screen.getByAltText("Clipboard image")).toHaveAttribute( + "src", + "data:image/png;base64,thumbpreview" + ); + }); + it("renders image stub when blob was omitted from list payload", () => { const entries = [ makeEntry({ content: "", content_type: "image", truncated: true }), -- 2.49.1