Merge pull request 'Fix screenshot paste and drag-out' (#15) from fix/image-paste-and-drag into main
CI / skip-ci-check (push) Successful in 13s
CI / secret-scan (push) Successful in 13s
CI / node-ci (push) Successful in 29s

This commit was merged in pull request #15.
This commit is contained in:
2026-10-03 10:48:06 -05:00
9 changed files with 165 additions and 75 deletions
+4
View File
@@ -5,6 +5,10 @@ All notable changes to maCopy. Format loosely follows
## [Unreleased]
### Fixed
- Oversized screenshots pasted as the literal text `[image 2541x1650]` — history now always keeps a PNG, and older stub rows paste/drag their thumbnail instead of the stub.
- Drag-out died as soon as the picker hid (blur hide cancelled the macOS drag). The session now stays alive until drop.
### Added
- Capture finished files from `~/Downloads` into history (Settings toggle, on by default). Existing files are not imported; in-progress `.crdownload` / `.download` / `.part` files are skipped. PNGs are stored as images.
- Drag any history row onto another app (screenshot → Messages, PDF → Finder, …). Click still pastes; moving the pointer starts a native file drag.
+1
View File
@@ -146,6 +146,7 @@ two-finger / right-click, or the gear.)
| Hotkey does nothing | Settings → check it isn't a duplicate of a macOS shortcut (the old default `Cmd+\`` conflicted with window-cycling — current default is `` Ctrl+` ``) |
| Paste doesn't happen (item copies but old app doesn't receive it) | Grant **Accessibility** permission — System Settings → Privacy & Security → Accessibility → add/enable maCopy |
| Drag onto another app does nothing | Click-hold and move ~8px; drop after the picker hides. If the file was a download you already moved, the path is stale — copy/paste instead |
| Image pastes as `[image 1234x567]` | Update to a build after 2026-10-03. Recapture the screenshot if it was stored as a stub before that fix. |
| New Downloads files don't appear | Settings → **Capture Downloads folder** on; wait ~1s after the download finishes; tray **Pause monitoring** also pauses this |
| App won't open ("cannot verify developer") | Right-click → Open, or `xattr -cr /Applications/maCopy.app` — see [docs/SHARING.md](SHARING.md) |
| Launch at login doesn't stick after rebuild | You're running a dev/rebuilt binary at a new path — install the `.app` under `/Applications` and re-toggle the setting |
+1 -1
View File
@@ -34,7 +34,7 @@ Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard,
### Backend (`src-tauri/src/**` `#[cfg(test)]`)
- SQLite CRUD, FTS5 (incl. `/path` / `github.com` punctuation), trim + pin preservation, settings
- List previews: truncate text, omit image blobs, return thumbnails (including stub `[image WxH]` rows)
- List previews: truncate text, omit image blobs, return thumbnails (legacy `[image WxH]` stub rows still preview via thumbnail; paste resolves to a PNG)
- Search excludes image rows (text-only FTS); App clears the list if search IPC fails
- Sensitive redaction (card / password / banking) while `get_entry` stays full
- Hotkey parser (`cmd+\`` etc.)
+85 -40
View File
@@ -1,5 +1,7 @@
use crate::db::Database;
use crate::image_util::{encode_rgba_to_png, fit_rgba_under_bytes, make_thumbnail_data_uri};
use crate::image_util::{
downscale_rgba, encode_rgba_to_png, fit_rgba_under_bytes, make_thumbnail_data_uri,
};
use arboard::Clipboard;
use base64::Engine;
use sha2::{Digest, Sha256};
@@ -12,7 +14,9 @@ use tauri::{AppHandle, Emitter};
/// 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.
pub(crate) const MAX_STORED_PNG_BYTES: usize = 1_500_000;
/// 8MB covers Retina screenshots; we never fall back to a `[image WxH]` text stub.
pub(crate) const MAX_STORED_PNG_BYTES: usize = 8 * 1024 * 1024;
const MIN_PASTE_EDGE: u32 = 400;
/// Result of turning a pasteboard RGBA buffer into DB-ready fields.
#[derive(Debug, Clone)]
@@ -50,8 +54,8 @@ pub(crate) fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> St
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.
/// Downscale if needed, build thumbnail + a pasteable PNG data URI.
/// Never stores `[image WxH]` — that used to paste as literal text.
pub(crate) fn prepare_clipboard_image(
width: u32,
height: u32,
@@ -61,40 +65,67 @@ pub(crate) fn prepare_clipboard_image(
) -> 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 (mut w, mut h, mut fitted) = fit_rgba_under_bytes(width, height, rgba, max_rgba_bytes);
let mut 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());
loop {
match encode_rgba_to_png(w, h, &fitted) {
Ok(png_data) => {
let fits = png_data.len() <= max_png_bytes;
let at_floor = w.max(h) <= MIN_PASTE_EDGE;
if fits || at_floor {
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
return Ok(PreparedImage {
content: format!("data:image/png;base64,{}", b64),
thumbnail: thumb,
width: w,
height: h,
downscaled: downscaled || !fits,
thumbnail_only: false,
});
}
let next_edge = ((w.max(h) as f32) * 0.75).round().max(MIN_PASTE_EDGE as f32) as u32;
if next_edge >= w.max(h) {
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
return Ok(PreparedImage {
content: format!("data:image/png;base64,{}", b64),
thumbnail: thumb,
width: w,
height: h,
downscaled,
thumbnail_only: false,
});
}
let (nw, nh, nf) = downscale_rgba(w, h, &fitted, next_edge);
w = nw;
h = nh;
fitted = nf;
downscaled = true;
}
Ok(PreparedImage {
content: stub,
thumbnail: thumb,
width: w,
height: h,
downscaled,
thumbnail_only: true,
})
Err(e) => return Err(format!("PNG encode failed: {}", e)),
}
Err(e) => Err(format!("PNG encode failed: {}", e)),
}
}
/// True when an older row stored a text stub instead of a PNG.
pub fn is_image_stub(content: &str) -> bool {
let t = content.trim();
t.starts_with("[image ") && t.ends_with(']')
}
/// PNG data URI for paste/drag. Prefers full payload; falls back to thumbnail
/// for legacy stub rows so we never put `[image 2541x1650]` on the pasteboard.
pub fn pasteable_image_data_uri(content: &str, thumbnail: Option<&str>) -> Result<String, String> {
if content.starts_with("data:image/") {
return Ok(content.to_string());
}
if let Some(t) = thumbnail.map(str::trim).filter(|t| t.starts_with("data:image/")) {
return Ok(t.to_string());
}
Err("image entry has no PNG payload".into())
}
/// 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>>;
@@ -347,23 +378,37 @@ mod tests {
}
#[test]
fn prepare_thumbnail_only_when_png_cap_tiny() {
fn prepare_tiny_png_cap_still_stores_pasteable_data_uri() {
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,"));
assert!(
prepared.content.starts_with("data:image/png;base64,"),
"must paste as image, not a text stub; got {:?}",
&prepared.content[..prepared.content.len().min(40)]
);
assert!(!is_image_stub(&prepared.content));
assert!(prepared.thumbnail.is_some());
}
#[test]
fn pasteable_prefers_full_uri_and_falls_back_to_thumb() {
let full = "data:image/png;base64,FULL";
assert_eq!(
pasteable_image_data_uri(full, Some("data:image/png;base64,THUMB")).unwrap(),
full
);
assert_eq!(
pasteable_image_data_uri("[image 2541x1650]", Some("data:image/png;base64,THUMB"))
.unwrap(),
"data:image/png;base64,THUMB"
);
assert!(pasteable_image_data_uri("[image 10x10]", None).is_err());
}
#[test]
+41 -26
View File
@@ -8,6 +8,7 @@ pub struct DbState(pub Arc<Database>);
pub struct PausedState(pub Arc<AtomicBool>);
pub struct LastHashState(pub LastHash);
pub struct WatchDownloadsState(pub Arc<AtomicBool>);
pub struct DraggingState(pub Arc<AtomicBool>);
#[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
@@ -72,7 +73,12 @@ pub fn set_setting(
/// Native drag-out so the user can drop a history item onto Finder, Slack, etc.
#[tauri::command]
pub fn start_item_drag(app: tauri::AppHandle, db: State<'_, DbState>, id: i64) -> Result<(), String> {
pub fn start_item_drag(
app: tauri::AppHandle,
db: State<'_, DbState>,
dragging: State<'_, DraggingState>,
id: i64,
) -> Result<(), String> {
let entry = db
.0
.get_entry(id)
@@ -82,41 +88,45 @@ pub fn start_item_drag(app: tauri::AppHandle, db: State<'_, DbState>, id: i64) -
let window = app
.get_webview_window("main")
.ok_or_else(|| "main window missing".to_string())?;
let _ = window.hide();
dragging.0.store(true, Ordering::Relaxed);
#[cfg(target_os = "macos")]
{
let result = {
use drag::{start_drag, DragItem, Image, Options};
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let preview = crate::drag_out::drag_preview_or_placeholder(&path);
let drag_window = window.clone();
window
.run_on_main_thread(move || {
let result = start_drag(
&drag_window,
DragItem::Files(vec![path]),
Image::Raw(preview),
|_result, _cursor| {},
Options {
skip_animatation_on_cancel_or_failure: true,
..Default::default()
},
);
let _ = tx.send(result.map_err(|e| e.to_string()));
})
.map_err(|e| e.to_string())?;
rx.recv().map_err(|e| e.to_string())??;
}
let start = window.run_on_main_thread(move || {
let result = start_drag(
&drag_window,
DragItem::Files(vec![path]),
Image::Raw(preview),
|_result, _cursor| {},
Options {
skip_animatation_on_cancel_or_failure: true,
..Default::default()
},
);
let _ = tx.send(result.map_err(|e| e.to_string()));
});
match start {
Ok(()) => rx.recv().map_err(|e| e.to_string()).and_then(|r| r),
Err(e) => Err(e.to_string()),
}
};
#[cfg(not(target_os = "macos"))]
{
let result: Result<(), String> = {
let _ = path;
return Err("drag-out is only supported on macOS".into());
}
Err("drag-out is only supported on macOS".into())
};
Ok(())
dragging.0.store(false, Ordering::Relaxed);
let _ = window.hide();
result
}
#[tauri::command]
@@ -183,8 +193,13 @@ fn write_clipboard(
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("clipboard open failed: {}", e))?;
if kind == "image" && content.starts_with("data:image/") {
let (w, h, rgba) = crate::image_util::data_uri_to_rgba(content)?;
if kind == "image" {
let uri = if content.starts_with("data:image/") {
content.to_string()
} else {
crate::clipboard::pasteable_image_data_uri(content, None)?
};
let (w, h, rgba) = crate::image_util::data_uri_to_rgba(&uri)?;
let img = crate::image_util::to_arboard_image(w, h, rgba);
clipboard
.set_image(img)
+19 -3
View File
@@ -311,7 +311,7 @@ impl Database {
pub fn get_entry(&self, id: i64) -> SqlResult<Option<ClipboardEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, content, content_type, created_at, pinned, content_hash
"SELECT id, content, content_type, created_at, pinned, content_hash, thumbnail
FROM clipboard_entries WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], map_full_entry)?;
@@ -459,10 +459,20 @@ impl Database {
}
fn map_full_entry(row: &rusqlite::Row<'_>) -> SqlResult<ClipboardEntry> {
let mut content: String = row.get(1)?;
let content_type: String = row.get(2)?;
let thumbnail: Option<String> = row.get(6)?;
if content_type == "image" {
if let Ok(uri) =
crate::clipboard::pasteable_image_data_uri(&content, thumbnail.as_deref())
{
content = uri;
}
}
Ok(ClipboardEntry {
id: row.get(0)?,
content: row.get(1)?,
content_type: row.get(2)?,
content,
content_type,
created_at: row.get(3)?,
pinned: row.get::<_, i32>(4)? != 0,
content_hash: row.get(5)?,
@@ -854,6 +864,12 @@ mod tests {
assert_eq!(entries[0].content, thumb);
assert!(entries[0].truncated);
assert_eq!(entries[0].content_type, "image");
let full = db.get_entry(entries[0].id).unwrap().unwrap();
assert!(
full.content.starts_with("data:image/png;base64,"),
"paste/drag must get a PNG URI, not the stub; got {:?}",
&full.content[..full.content.len().min(48)]
);
}
#[test]
+2 -4
View File
@@ -30,10 +30,8 @@ fn existing_file_path(content: &str) -> Result<PathBuf, String> {
}
fn write_image_temp(id: i64, content: &str) -> Result<PathBuf, String> {
if !content.starts_with("data:image/") {
return Err("image entry has no PNG payload".into());
}
let (w, h, rgba) = data_uri_to_rgba(content)?;
let uri = crate::clipboard::pasteable_image_data_uri(content, None)?;
let (w, h, rgba) = data_uri_to_rgba(&uri)?;
let png = encode_rgba_to_png(w, h, &rgba).map_err(|e| e.to_string())?;
let path = temp_dir().join(format!("macopy-{id}.png"));
std::fs::write(&path, png).map_err(|e| e.to_string())?;
+11 -1
View File
@@ -6,7 +6,7 @@ mod drag_out;
mod image_util;
mod redact;
use commands::{DbState, LastHashState, PausedState, WatchDownloadsState};
use commands::{DbState, DraggingState, LastHashState, PausedState, WatchDownloadsState};
use db::Database;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
@@ -254,6 +254,7 @@ fn position_window(window: &tauri::WebviewWindow, position: &str) {
pub fn run() {
let db = Arc::new(Database::new().expect("Failed to initialize database"));
let paused = Arc::new(AtomicBool::new(false));
let dragging = Arc::new(AtomicBool::new(false));
let watch_downloads = Arc::new(AtomicBool::new(
db.get_settings()
.map(|s| s.watch_downloads)
@@ -285,6 +286,7 @@ pub fn run() {
.manage(DbState(db.clone()))
.manage(PausedState(paused.clone()))
.manage(WatchDownloadsState(watch_downloads.clone()))
.manage(DraggingState(dragging.clone()))
// LastHashState is added in .setup() after start_polling returns it
.invoke_handler(tauri::generate_handler![
commands::get_entries,
@@ -374,16 +376,24 @@ pub fn run() {
if let Some(window) = app.get_webview_window("main") {
let w = window.clone();
let ignore_blur_until = ignore_blur_until.clone();
let dragging = dragging.clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(false) = event {
if dragging.load(Ordering::Relaxed) {
return;
}
if now_millis() < ignore_blur_until.load(Ordering::Relaxed) {
return;
}
// Delay hide so left-clicks inside the list finish before
// macOS/webview focus churn can cancel them.
let w2 = w.clone();
let dragging2 = dragging.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(180));
if dragging2.load(Ordering::Relaxed) {
return;
}
if !w2.is_focused().unwrap_or(false) {
let _ = w2.hide();
}
+1
View File
@@ -175,6 +175,7 @@ const Row = memo(function Row({
if (!p || p.dragged) return;
if (isDragGesture(p.x, p.y, e.clientX, e.clientY)) {
p.dragged = true;
e.preventDefault();
onDragStart(entry);
}
}}