Merge pull request 'Fix screenshot paste and drag-out' (#15) from fix/image-paste-and-drag into main
This commit was merged in pull request #15.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user