Add Downloads ingest and drag-out from the picker.
New finished files in ~/Downloads join history, and a drag gesture on a row starts a native file drop so screenshots and downloads can be placed into other apps without a Dock stack.
This commit is contained in:
Generated
+132
-13
@@ -767,6 +767,27 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drag"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c"
|
||||
dependencies = [
|
||||
"core-graphics 0.24.0",
|
||||
"dunce",
|
||||
"gdk",
|
||||
"gdkx11",
|
||||
"gtk",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.52.0",
|
||||
"windows-core 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
@@ -1963,6 +1984,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"core-graphics 0.24.0",
|
||||
"dirs 5.0.1",
|
||||
"drag",
|
||||
"hex",
|
||||
"log",
|
||||
"objc2",
|
||||
@@ -3351,7 +3373,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -3423,7 +3445,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3570,7 +3592,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3595,7 +3617,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -4478,10 +4500,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4502,7 +4524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4558,6 +4580,18 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
||||
dependencies = [
|
||||
"windows-core 0.52.0",
|
||||
"windows-implement 0.52.0",
|
||||
"windows-interface 0.52.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -4580,14 +4614,36 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement 0.58.0",
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -4599,8 +4655,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -4617,6 +4673,28 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -4628,6 +4706,28 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -4661,6 +4761,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4679,6 +4788,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
@@ -5202,7 +5321,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
|
||||
@@ -34,6 +34,7 @@ core-graphics = "0.24"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSPasteboard"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||
drag = "2.1"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -7,6 +7,7 @@ use tauri::{Manager, State};
|
||||
pub struct DbState(pub Arc<Database>);
|
||||
pub struct PausedState(pub Arc<AtomicBool>);
|
||||
pub struct LastHashState(pub LastHash);
|
||||
pub struct WatchDownloadsState(pub Arc<AtomicBool>);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
||||
@@ -56,8 +57,66 @@ pub fn get_settings(db: State<'_, DbState>) -> Result<Settings, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_setting(db: State<'_, DbState>, key: String, value: String) -> Result<(), String> {
|
||||
db.0.set_setting(&key, &value).map_err(|e| e.to_string())
|
||||
pub fn set_setting(
|
||||
db: State<'_, DbState>,
|
||||
watch: State<'_, WatchDownloadsState>,
|
||||
key: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
db.0.set_setting(&key, &value).map_err(|e| e.to_string())?;
|
||||
if key == "watch_downloads" {
|
||||
watch.0.store(value == "true", Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let entry = db
|
||||
.0
|
||||
.get_entry(id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("entry {} not found", id))?;
|
||||
let path = crate::drag_out::materialize_drag_path(&entry)?;
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "main window missing".to_string())?;
|
||||
let _ = window.hide();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
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())??;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = path;
|
||||
return Err("drag-out is only supported on macOS".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -34,6 +34,7 @@ pub struct Settings {
|
||||
pub window_width: i64,
|
||||
pub window_height: i64,
|
||||
pub hotkey: String,
|
||||
pub watch_downloads: bool,
|
||||
}
|
||||
|
||||
pub const DEFAULT_HOTKEY: &str = "ctrl+`";
|
||||
@@ -50,6 +51,7 @@ impl Default for Settings {
|
||||
window_width: 420,
|
||||
window_height: 560,
|
||||
hotkey: DEFAULT_HOTKEY.to_string(),
|
||||
watch_downloads: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +203,10 @@ impl Database {
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["hotkey", &defaults.hotkey],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["watch_downloads", defaults.watch_downloads.to_string()],
|
||||
)?;
|
||||
|
||||
// Migrate installs that still use Cmd+` (steals macOS window-cycle).
|
||||
let current_hotkey: String = conn
|
||||
@@ -237,6 +243,16 @@ impl Database {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn has_hash(&self, content_hash: &str) -> SqlResult<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard_entries WHERE content_hash = ?1",
|
||||
params![content_hash],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Build missing image thumbnails from stored full PNGs (best-effort, capped).
|
||||
pub fn backfill_thumbnails(&self, max: usize) -> usize {
|
||||
let rows: Vec<(i64, String)> = {
|
||||
@@ -428,6 +444,7 @@ impl Database {
|
||||
window_width: get("window_width", "420").parse().unwrap_or(420),
|
||||
window_height: get("window_height", "560").parse().unwrap_or(560),
|
||||
hotkey: get("hotkey", DEFAULT_HOTKEY),
|
||||
watch_downloads: get("watch_downloads", "true") == "true",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -498,6 +515,7 @@ mod tests {
|
||||
assert_eq!(s.window_width, 420);
|
||||
assert_eq!(s.window_height, 560);
|
||||
assert_eq!(s.hotkey, DEFAULT_HOTKEY);
|
||||
assert!(s.watch_downloads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -537,6 +555,15 @@ mod tests {
|
||||
assert!(!entries[0].pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_hash_detects_inserted_row() {
|
||||
let db = test_db();
|
||||
assert!(!db.has_hash("hash1").unwrap());
|
||||
db.insert_entry("hello world", "text", "hash1", None).unwrap();
|
||||
assert!(db.has_hash("hash1").unwrap());
|
||||
assert!(!db.has_hash("hash2").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_ordered_newest_first() {
|
||||
let db = test_db();
|
||||
@@ -770,6 +797,11 @@ mod tests {
|
||||
db.set_setting("launch_at_login", "true").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(s.launch_at_login);
|
||||
|
||||
assert!(s.watch_downloads);
|
||||
db.set_setting("watch_downloads", "false").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(!s.watch_downloads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Watch ~/Downloads and insert finished files into clipboard history.
|
||||
//!
|
||||
//! Only files that appear *after* the watcher starts are ingested — existing
|
||||
//! clutter in Downloads is left alone.
|
||||
|
||||
use crate::clipboard::{hash_content, prepare_clipboard_image, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES};
|
||||
use crate::db::Database;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
const STABLE_FOR: Duration = Duration::from_millis(900);
|
||||
const POLL_EVERY: Duration = Duration::from_millis(1000);
|
||||
const MAX_READ_BYTES: u64 = 40 * 1024 * 1024;
|
||||
|
||||
const INCOMPLETE_SUFFIXES: &[&str] = &[
|
||||
".download",
|
||||
".crdownload",
|
||||
".part",
|
||||
".partial",
|
||||
".tmp",
|
||||
".temp",
|
||||
];
|
||||
|
||||
/// True for in-progress browser downloads, junk, and hidden files.
|
||||
pub fn skip_download_name(name: &str) -> bool {
|
||||
if name.is_empty() || name.starts_with('.') {
|
||||
return true;
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower == "desktop.ini" || lower == "thumbs.db" {
|
||||
return true;
|
||||
}
|
||||
INCOMPLETE_SUFFIXES.iter().any(|s| lower.ends_with(s))
|
||||
}
|
||||
|
||||
pub fn is_png_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("png"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn downloads_dir() -> Option<PathBuf> {
|
||||
dirs::download_dir().or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
|
||||
}
|
||||
|
||||
/// Content hash for a finished download (path + size + mtime).
|
||||
pub fn download_hash(path: &Path, size: u64, mtime_secs: u64) -> String {
|
||||
hash_content(format!("{}:{size}:{mtime_secs}", path.display()).as_bytes())
|
||||
}
|
||||
|
||||
pub fn start_polling(
|
||||
app: AppHandle,
|
||||
db: Arc<Database>,
|
||||
paused: Arc<AtomicBool>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let Some(dir) = downloads_dir() else {
|
||||
log::warn!("Downloads folder not found; watcher idle");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut known = snapshot_paths(&dir);
|
||||
let mut pending: HashMap<PathBuf, (u64, Instant)> = HashMap::new();
|
||||
|
||||
loop {
|
||||
std::thread::sleep(POLL_EVERY);
|
||||
if paused.load(Ordering::Relaxed) || !enabled.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut alive: HashSet<PathBuf> = HashSet::new();
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for ent in entries.flatten() {
|
||||
let path = ent.path();
|
||||
alive.insert(path.clone());
|
||||
let name = ent.file_name().to_string_lossy().into_owned();
|
||||
if skip_download_name(&name) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = ent.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let size = meta.len();
|
||||
if size == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if known.contains(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match pending.get(&path) {
|
||||
Some((old_size, since)) if *old_size == size && since.elapsed() >= STABLE_FOR => {
|
||||
pending.remove(&path);
|
||||
known.insert(path.clone());
|
||||
match ingest_download(&db, &path, size, mtime_secs(&meta)) {
|
||||
Ok(true) => {
|
||||
let _ = app.emit("clipboard-changed", ());
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => log::warn!("Downloads ingest {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
Some((old_size, _)) if *old_size == size => {}
|
||||
_ => {
|
||||
pending.insert(path, (size, Instant::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
known.retain(|p| alive.contains(p));
|
||||
pending.retain(|p, _| alive.contains(p) && !known.contains(p));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn snapshot_paths(dir: &Path) -> HashSet<PathBuf> {
|
||||
let mut out = HashSet::new();
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for ent in entries.flatten() {
|
||||
out.insert(ent.path());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mtime_secs(meta: &std::fs::Metadata) -> u64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn ingest_download(db: &Database, path: &Path, size: u64, mtime: u64) -> Result<bool, String> {
|
||||
let hash = download_hash(path, size, mtime);
|
||||
if db.has_hash(&hash).unwrap_or(false) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if is_png_path(path) && size <= MAX_READ_BYTES {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
if let Ok((w, h, rgba)) = crate::image_util::decode_png_rgba(&bytes) {
|
||||
let prepared = prepare_clipboard_image(
|
||||
w,
|
||||
h,
|
||||
&rgba,
|
||||
MAX_IMAGE_BYTES,
|
||||
MAX_STORED_PNG_BYTES,
|
||||
)?;
|
||||
db.insert_entry(
|
||||
&prepared.content,
|
||||
"image",
|
||||
&hash,
|
||||
prepared.thumbnail.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let abs = path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
db.insert_entry(&abs, "file", &hash, None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
#[test]
|
||||
fn skips_hidden_and_incomplete() {
|
||||
assert!(skip_download_name(".DS_Store"));
|
||||
assert!(skip_download_name("foo.crdownload"));
|
||||
assert!(skip_download_name("Safari.dmg.download"));
|
||||
assert!(skip_download_name("x.part"));
|
||||
assert!(skip_download_name("x.tmp"));
|
||||
assert!(skip_download_name("desktop.ini"));
|
||||
assert!(!skip_download_name("photo.png"));
|
||||
assert!(!skip_download_name("Report.pdf"));
|
||||
assert!(!skip_download_name("Screenshot 2026-10-03.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_extension_is_case_insensitive() {
|
||||
assert!(is_png_path(Path::new("/tmp/a.PNG")));
|
||||
assert!(is_png_path(Path::new("/tmp/a.png")));
|
||||
assert!(!is_png_path(Path::new("/tmp/a.jpg")));
|
||||
assert!(!is_png_path(Path::new("/tmp/a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_hash_changes_with_mtime() {
|
||||
let p = Path::new("/tmp/a.png");
|
||||
assert_ne!(download_hash(p, 10, 1), download_hash(p, 10, 2));
|
||||
assert_eq!(download_hash(p, 10, 1), download_hash(p, 10, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_png_as_image_and_other_as_file() {
|
||||
let db = Database::in_memory().unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("macopy-dl-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let png_path = dir.join("shot.png");
|
||||
let rgba = vec![255u8, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255];
|
||||
let png = crate::image_util::encode_rgba_to_png(2, 2, &rgba).unwrap();
|
||||
std::fs::write(&png_path, &png).unwrap();
|
||||
|
||||
let pdf_path = dir.join("notes.pdf");
|
||||
std::fs::write(&pdf_path, b"%PDF-fake").unwrap();
|
||||
|
||||
assert!(ingest_download(&db, &png_path, png.len() as u64, 1).unwrap());
|
||||
assert!(ingest_download(&db, &pdf_path, 9, 1).unwrap());
|
||||
// Same file+mtime is a no-op.
|
||||
assert!(!ingest_download(&db, &pdf_path, 9, 1).unwrap());
|
||||
|
||||
let rows = db.get_entries(10).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
let img = rows.iter().find(|e| e.content_type == "image").unwrap();
|
||||
assert!(img.content.starts_with("data:image/png;base64,") || img.content.is_empty());
|
||||
let file = rows.iter().find(|e| e.content_type == "file").unwrap();
|
||||
assert!(file.content.contains("notes.pdf"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Turn a history entry into a real file path so macOS can drag it to other apps.
|
||||
|
||||
use crate::db::ClipboardEntry;
|
||||
use crate::image_util::{data_uri_to_rgba, encode_rgba_to_png};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TEXT_DRAG_MAX: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// Resolve a file we can hand to the OS drag session.
|
||||
pub fn materialize_drag_path(entry: &ClipboardEntry) -> Result<PathBuf, String> {
|
||||
match entry.content_type.as_str() {
|
||||
"file" => existing_file_path(&entry.content),
|
||||
"image" => write_image_temp(entry.id, &entry.content),
|
||||
_ => write_text_temp(entry.id, &entry.content),
|
||||
}
|
||||
}
|
||||
|
||||
fn existing_file_path(content: &str) -> Result<PathBuf, String> {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let p = PathBuf::from(trimmed);
|
||||
if p.is_file() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
Err("file no longer exists".into())
|
||||
}
|
||||
|
||||
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 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())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_text_temp(id: i64, content: &str) -> Result<PathBuf, String> {
|
||||
if content.len() > TEXT_DRAG_MAX {
|
||||
return Err("text too large to drag as a file".into());
|
||||
}
|
||||
let path = temp_dir().join(format!("macopy-{id}.txt"));
|
||||
std::fs::write(&path, content).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join("macopy-drag");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Small PNG for the drag cursor. Never reads huge downloads into memory.
|
||||
pub fn drag_preview_bytes(path: &Path) -> Vec<u8> {
|
||||
const MAX: u64 = 1_500_000;
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp") {
|
||||
return Vec::new();
|
||||
}
|
||||
match std::fs::metadata(path) {
|
||||
Ok(m) if m.len() > 0 && m.len() <= MAX => std::fs::read(path).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// NSImage rejects empty buffers — 1×1 PNG when we have no thumbnail.
|
||||
pub fn drag_preview_or_placeholder(path: &Path) -> Vec<u8> {
|
||||
let bytes = drag_preview_bytes(path);
|
||||
if !bytes.is_empty() {
|
||||
return bytes;
|
||||
}
|
||||
crate::image_util::encode_rgba_to_png(1, 1, &[160, 160, 160, 255]).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::image_util::encode_rgba_to_png;
|
||||
use base64::Engine;
|
||||
|
||||
fn entry(kind: &str, content: &str) -> ClipboardEntry {
|
||||
ClipboardEntry {
|
||||
id: 42,
|
||||
content: content.to_string(),
|
||||
content_type: kind.to_string(),
|
||||
created_at: "now".into(),
|
||||
pinned: false,
|
||||
content_hash: "h".into(),
|
||||
truncated: false,
|
||||
sensitive: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_entry_uses_existing_path() {
|
||||
let dir = std::env::temp_dir().join(format!("macopy-drag-src-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let src = dir.join("hello.pdf");
|
||||
std::fs::write(&src, b"pdf").unwrap();
|
||||
let path = materialize_drag_path(&entry("file", src.to_str().unwrap())).unwrap();
|
||||
assert_eq!(path, src);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_errors() {
|
||||
let err = materialize_drag_path(&entry("file", "/tmp/macopy-no-such-file-xyz")).unwrap_err();
|
||||
assert!(err.contains("no longer exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_entry_writes_png() {
|
||||
let rgba = vec![10u8, 20, 30, 255];
|
||||
let png = encode_rgba_to_png(1, 1, &rgba).unwrap();
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
|
||||
let uri = format!("data:image/png;base64,{b64}");
|
||||
let path = materialize_drag_path(&entry("image", &uri)).unwrap();
|
||||
assert!(path.ends_with("macopy-42.png"));
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(&bytes[..4], b"\x89PNG");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_entry_writes_txt() {
|
||||
let path = materialize_drag_path(&entry("text", "hello drag")).unwrap();
|
||||
assert!(path.ends_with("macopy-42.txt"));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello drag");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_skips_non_images_and_placeholder_is_png() {
|
||||
let dir = std::env::temp_dir().join(format!("macopy-prev-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pdf = dir.join("a.pdf");
|
||||
std::fs::write(&pdf, b"%PDF").unwrap();
|
||||
assert!(drag_preview_bytes(&pdf).is_empty());
|
||||
let ph = drag_preview_or_placeholder(&pdf);
|
||||
assert!(ph.len() > 8);
|
||||
assert_eq!(&ph[..4], b"\x89PNG");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -1,10 +1,12 @@
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod downloads;
|
||||
mod drag_out;
|
||||
mod image_util;
|
||||
mod redact;
|
||||
|
||||
use commands::{DbState, LastHashState, PausedState};
|
||||
use commands::{DbState, LastHashState, PausedState, WatchDownloadsState};
|
||||
use db::Database;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -252,9 +254,17 @@ 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 watch_downloads = Arc::new(AtomicBool::new(
|
||||
db.get_settings()
|
||||
.map(|s| s.watch_downloads)
|
||||
.unwrap_or(true),
|
||||
));
|
||||
|
||||
let db_for_polling = db.clone();
|
||||
let paused_for_polling = paused.clone();
|
||||
let db_for_downloads = db.clone();
|
||||
let paused_for_downloads = paused.clone();
|
||||
let watch_for_downloads = watch_downloads.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
@@ -274,6 +284,7 @@ pub fn run() {
|
||||
)
|
||||
.manage(DbState(db.clone()))
|
||||
.manage(PausedState(paused.clone()))
|
||||
.manage(WatchDownloadsState(watch_downloads.clone()))
|
||||
// LastHashState is added in .setup() after start_polling returns it
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_entries,
|
||||
@@ -291,6 +302,7 @@ pub fn run() {
|
||||
commands::paste_and_refocus,
|
||||
commands::copy_to_clipboard,
|
||||
commands::set_hotkey,
|
||||
commands::start_item_drag,
|
||||
])
|
||||
.setup(move |app| {
|
||||
let ignore_blur_until = Arc::new(AtomicU64::new(0));
|
||||
@@ -393,6 +405,13 @@ pub fn run() {
|
||||
);
|
||||
app.manage(LastHashState(last_hash));
|
||||
|
||||
downloads::start_polling(
|
||||
app.handle().clone(),
|
||||
db_for_downloads,
|
||||
paused_for_downloads,
|
||||
watch_for_downloads,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
Reference in New Issue
Block a user