Merge pull request 'Capture oversized screenshots instead of dropping them' (#6) from fix/oversized-screenshot-capture into main
All checks were successful
CI / skip-ci-check (push) Successful in 9s
CI / secret-scan (push) Successful in 9s
CI / node-ci (push) Successful in 26s

This commit is contained in:
ilia 2026-07-19 10:16:47 -05:00
commit 82c04da445
5 changed files with 334 additions and 47 deletions

View File

@ -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

View File

@ -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<String>,
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<PreparedImage, String> {
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<Mutex<String>>;
@ -115,56 +173,52 @@ pub fn start_polling(app: AppHandle, db: Arc<Database>, paused: Arc<AtomicBool>)
}
_ => {
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 <img> 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}"
);
}
}

View File

@ -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();

View File

@ -90,6 +90,27 @@ pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<S
rgba_to_data_uri(tw, th, &thumb)
}
/// Shrink RGBA so `width * height * 4 <= max_bytes`, preserving aspect ratio.
/// Returns the original buffer unchanged when already under the limit.
pub fn fit_rgba_under_bytes(
width: u32,
height: u32,
rgba: &[u8],
max_bytes: usize,
) -> (u32, u32, Vec<u8>) {
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<u8>), 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
}
}

View File

@ -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(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
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 }),