Add Downloads ingest and drag-out from the picker.
CI / skip-ci-check (pull_request) Successful in 13s
CI / secret-scan (pull_request) Successful in 13s
CI / node-ci (pull_request) Successful in 29s

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:
2026-10-03 11:18:02 -04:00
parent f62c1d6ba7
commit f891a7724b
24 changed files with 852 additions and 48 deletions
+132 -13
View File
@@ -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",
+1
View File
@@ -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"]
+61 -2
View File
@@ -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]
+32
View File
@@ -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]
+249
View File
@@ -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);
}
}
+153
View File
@@ -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
View File
@@ -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!())