maCopy/src-tauri/src/clipboard.rs
ilia 7bb3b5fc89 Fix paste creating duplicate entries in clipboard history
The clipboard polling thread was detecting programmatic writes (from
paste_and_refocus) as "new" clipboard content and re-inserting them.

Fix: share the last_hash between the polling thread and the paste
command via Arc<Mutex<String>>. When pasting, the Rust command now:
1. Computes the hash of the content being pasted
2. Updates the shared last_hash so the polling thread skips it
3. Writes to clipboard via arboard (moved from JS writeText)
4. Hides window and runs AppleScript paste

This eliminates the JS-side writeText call entirely — clipboard write
now happens in Rust where it can atomically update the hash before
the polling thread's next tick.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 14:33:33 -04:00

155 lines
5.0 KiB
Rust

use crate::db::Database;
use arboard::Clipboard;
use base64::Engine;
use sha2::{Digest, Sha256};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Hash arbitrary bytes for deduplication.
pub fn hash_content(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
/// 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>>;
/// Spawns a background thread that polls the system clipboard every 500ms.
/// When new content is detected (by comparing SHA-256 hashes), it is persisted
/// to SQLite. The `paused` flag lets the user freeze monitoring from the tray.
/// Returns the shared `LastHash` so other commands can update it.
pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
let initial = db.latest_hash().ok().flatten().unwrap_or_default();
let last_hash: LastHash = Arc::new(Mutex::new(initial));
let hash_for_thread = last_hash.clone();
std::thread::spawn(move || {
let mut clipboard = match Clipboard::new() {
Ok(c) => c,
Err(e) => {
log::error!("Failed to open clipboard: {}", e);
return;
}
};
loop {
std::thread::sleep(Duration::from_millis(500));
if paused.load(Ordering::Relaxed) {
continue;
}
let current_hash = hash_for_thread.lock().unwrap().clone();
if let Ok(text) = clipboard.get_text() {
if !text.trim().is_empty() {
let h = hash_content(text.as_bytes());
if h != current_hash {
*hash_for_thread.lock().unwrap() = h.clone();
let content_type = if text.lines().all(|l| {
let trimmed = l.trim();
!trimmed.is_empty() && std::path::Path::new(trimmed).exists()
}) {
"file"
} else {
"text"
};
if let Err(e) = db.insert_entry(&text, content_type, &h) {
log::error!("DB insert error: {}", e);
}
trim_if_needed(&db);
}
}
} else if let Ok(img) = clipboard.get_image() {
let h = hash_content(&img.bytes);
if h != current_hash {
*hash_for_thread.lock().unwrap() = h.clone();
if let Ok(png_data) = encode_rgba_to_png(
img.width as u32,
img.height as u32,
&img.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) {
log::error!("DB insert error (image): {}", e);
}
trim_if_needed(&db);
}
}
}
}
});
last_hash
}
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(rgba)?;
}
Ok(buf)
}
fn trim_if_needed(db: &Database) {
if let Ok(settings) = db.get_settings() {
let _ = db.trim_entries(settings.max_history);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_content_deterministic() {
let a = hash_content(b"hello world");
let b = hash_content(b"hello world");
assert_eq!(a, b);
}
#[test]
fn hash_content_differs_for_different_input() {
let a = hash_content(b"hello");
let b = hash_content(b"world");
assert_ne!(a, b);
}
#[test]
fn hash_content_is_hex_sha256() {
let h = hash_content(b"test");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn encode_rgba_to_png_produces_valid_png() {
let rgba = vec![
255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255,
];
let result = encode_rgba_to_png(2, 2, &rgba);
assert!(result.is_ok());
let png_data = result.unwrap();
assert!(png_data.len() > 8);
assert_eq!(&png_data[..4], b"\x89PNG");
}
#[test]
fn encode_rgba_empty_image() {
let result = encode_rgba_to_png(0, 0, &[]);
assert!(result.is_ok() || result.is_err());
}
}