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>
155 lines
5.0 KiB
Rust
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());
|
|
}
|
|
}
|