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>; /// 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, paused: Arc) -> 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, 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()); } }