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.
This commit is contained in:
parent
b0f94290df
commit
656045c4b4
@ -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
|
||||
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user