diff --git a/src/commands/decode.rs b/src/commands/decode.rs index 9dc9116..8f414a1 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -26,7 +26,7 @@ impl DecodeCommand for SqliteClipboardDb { let mut buf = String::new(); in_ .read_to_string(&mut buf) - .map_err(|e| StashError::DecodeRead(e.to_string()))?; + .map_err(|e| StashError::DecodeRead(e.to_string().into()))?; buf }; @@ -38,18 +38,18 @@ impl DecodeCommand for SqliteClipboardDb { { let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|e| { - StashError::DecodeRead(format!( - "Failed to read clipboard for relay: {e}" - )) + StashError::DecodeRead( + format!("Failed to read clipboard for relay: {e}").into(), + ) })?; out.write_all(&buf).map_err(|e| { - StashError::DecodeWrite(format!( - "Failed to write clipboard relay: {e}" - )) + StashError::DecodeWrite( + format!("Failed to write clipboard relay: {e}").into(), + ) })?; } else { return Err(StashError::DecodeGet( - "Failed to get clipboard contents for relay".to_string(), + "Failed to get clipboard contents for relay".into(), )); } return Ok(()); @@ -69,14 +69,14 @@ impl DecodeCommand for SqliteClipboardDb { { let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|err| { - StashError::DecodeRead(format!( - "Failed to read clipboard for relay: {err}" - )) + StashError::DecodeRead( + format!("Failed to read clipboard for relay: {err}").into(), + ) })?; out.write_all(&buf).map_err(|err| { - StashError::DecodeWrite(format!( - "Failed to write clipboard relay: {err}" - )) + StashError::DecodeWrite( + format!("Failed to write clipboard relay: {err}").into(), + ) })?; Ok(()) } else { diff --git a/src/commands/import.rs b/src/commands/import.rs index 05833d7..a5b4e55 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -27,19 +27,19 @@ impl ImportCommand for SqliteClipboardDb { let mut imported = 0; for (lineno, line) in reader.lines().enumerate() { let line = line.map_err(|e| { - StashError::Store(format!("Failed to read line {lineno}: {e}")) + StashError::Store(format!("Failed to read line {lineno}: {e}").into()) })?; let mut parts = line.splitn(2, '\t'); let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { - return Err(StashError::Store(format!( - "Malformed TSV line {lineno}: {line:?}" - ))); + return Err(StashError::Store( + format!("Malformed TSV line {lineno}: {line:?}").into(), + )); }; let Ok(_id) = id_str.parse::() else { - return Err(StashError::Store(format!( - "Failed to parse id from line {lineno}: {id_str}" - ))); + return Err(StashError::Store( + format!("Failed to parse id from line {lineno}: {id_str}").into(), + )); }; let entry = Entry { @@ -54,9 +54,9 @@ impl ImportCommand for SqliteClipboardDb { rusqlite::params![entry.contents, entry.mime], ) .map_err(|e| { - StashError::Store(format!( - "Failed to insert entry at line {lineno}: {e}" - )) + StashError::Store( + format!("Failed to insert entry at line {lineno}: {e}").into(), + ) })?; imported += 1; } diff --git a/src/commands/list.rs b/src/commands/list.rs index 75c1ce5..68a5b39 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -47,27 +47,27 @@ impl SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut entries: Vec<(u64, String, String)> = Vec::new(); let mut max_id_width = 2; let mut max_mime_width = 8; while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let preview = crate::db::preview_entry(&contents, mime.as_deref(), preview_width); let mime_str = mime.as_deref().unwrap_or("").to_string(); @@ -77,13 +77,14 @@ impl SqliteClipboardDb { entries.push((id, preview, mime_str)); } - enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; + enable_raw_mode() + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut state = ListState::default(); if !entries.is_empty() { @@ -225,13 +226,13 @@ impl SqliteClipboardDb { f.render_stateful_widget(list, area, &mut state); }) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; if event::poll(std::time::Duration::from_millis(250)) - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - if let Event::Key(key) = - event::read().map_err(|e| StashError::ListDecode(e.to_string()))? + if let Event::Key(key) = event::read() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { match key.code { KeyCode::Char('q') | KeyCode::Esc => break, diff --git a/src/commands/watch.rs b/src/commands/watch.rs index a3d863d..ce2acf7 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,9 +1,14 @@ -use std::{io::Read, time::Duration}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io::Read, + time::Duration, +}; use smol::Timer; use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; -use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; +use crate::db::{ClipboardDb, SqliteClipboardDb}; pub trait WatchCommand { fn watch( @@ -24,11 +29,18 @@ impl WatchCommand for SqliteClipboardDb { smol::block_on(async { log::info!("Starting clipboard watch daemon"); - // Preallocate buffer for clipboard contents - let mut last_contents: Option> = None; - let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully + // We use hashes for comparison instead of storing full contents + let mut last_hash: Option = None; + let mut buf = Vec::with_capacity(4096); - // Initialize with current clipboard to avoid duplicating on startup + // Helper to hash clipboard contents + let hash_contents = |data: &[u8]| -> u64 { + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + hasher.finish() + }; + + // Initialize with current clipboard if let Ok((mut reader, _)) = get_contents( ClipboardType::Regular, Seat::Unspecified, @@ -36,7 +48,7 @@ impl WatchCommand for SqliteClipboardDb { ) { buf.clear(); if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_contents = Some(buf.clone()); + last_hash = Some(hash_contents(&buf)); } } @@ -46,7 +58,7 @@ impl WatchCommand for SqliteClipboardDb { Seat::Unspecified, wl_clipboard_rs::paste::MimeType::Any, ) { - Ok((mut reader, mime_type)) => { + Ok((mut reader, _mime_type)) => { buf.clear(); if let Err(e) = reader.read_to_end(&mut buf) { log::error!("Failed to read clipboard contents: {e}"); @@ -55,38 +67,35 @@ impl WatchCommand for SqliteClipboardDb { } // Only store if changed and not empty - if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { - let new_contents = std::mem::take(&mut buf); - let mime = Some(mime_type.to_string()); - let entry = Entry { - contents: new_contents.clone(), - mime, - }; - let id = self.next_sequence(); - match self.store_entry( - &entry.contents[..], - max_dedupe_search, - max_items, - Some(excluded_apps), - ) { - Ok(_) => { - log::info!("Stored new clipboard entry (id: {id})"); - last_contents = Some(new_contents); - }, - Err(crate::db::StashError::ExcludedByApp(_)) => { - log::info!("Clipboard entry excluded by app filter"); - last_contents = Some(new_contents); - }, - Err(crate::db::StashError::Store(ref msg)) - if msg.contains("Excluded by app filter") => - { - log::info!("Clipboard entry excluded by app filter"); - last_contents = Some(new_contents); - }, - Err(e) => { - log::error!("Failed to store clipboard entry: {e}"); - last_contents = Some(new_contents); - }, + if !buf.is_empty() { + let current_hash = hash_contents(&buf); + if last_hash != Some(current_hash) { + let id = self.next_sequence(); + match self.store_entry( + &buf[..], + max_dedupe_search, + max_items, + Some(excluded_apps), + ) { + Ok(_) => { + log::info!("Stored new clipboard entry (id: {id})"); + last_hash = Some(current_hash); + }, + Err(crate::db::StashError::ExcludedByApp(_)) => { + log::info!("Clipboard entry excluded by app filter"); + last_hash = Some(current_hash); + }, + Err(crate::db::StashError::Store(ref msg)) + if msg.contains("Excluded by app filter") => + { + log::info!("Clipboard entry excluded by app filter"); + last_hash = Some(current_hash); + }, + Err(e) => { + log::error!("Failed to store clipboard entry: {e}"); + last_hash = Some(current_hash); + }, + } } } }, diff --git a/src/db/mod.rs b/src/db/mod.rs index 02984a8..fa27cce 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,18 +1,20 @@ use std::{ + collections::hash_map::DefaultHasher, env, fmt, fs, + hash::{Hash, Hasher}, io::{BufRead, BufReader, Read, Write}, str, + sync::OnceLock, }; -use base64::{Engine, engine::general_purpose::STANDARD}; -use imagesize::{ImageSize, ImageType}; -use log::{debug, error, info, warn}; +use base64::prelude::*; +use imagesize::ImageType; +use log::{debug, error, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; -use serde_json::json; use thiserror::Error; #[derive(Error, Debug)] @@ -23,38 +25,38 @@ pub enum StashError { AllWhitespace, #[error("Failed to store entry: {0}")] - Store(String), + Store(Box), #[error("Entry excluded by app filter: {0}")] - ExcludedByApp(String), + ExcludedByApp(Box), #[error("Error reading entry during deduplication: {0}")] - DeduplicationRead(String), + DeduplicationRead(Box), #[error("Error decoding entry during deduplication: {0}")] - DeduplicationDecode(String), + DeduplicationDecode(Box), #[error("Failed to remove entry during deduplication: {0}")] - DeduplicationRemove(String), + DeduplicationRemove(Box), #[error("Failed to trim entry: {0}")] - Trim(String), + Trim(Box), #[error("No entries to delete")] NoEntriesToDelete, #[error("Failed to delete last entry: {0}")] - DeleteLast(String), + DeleteLast(Box), #[error("Failed to wipe database: {0}")] - Wipe(String), + Wipe(Box), #[error("Failed to decode entry during list: {0}")] - ListDecode(String), + ListDecode(Box), #[error("Failed to read input for decode: {0}")] - DecodeRead(String), + DecodeRead(Box), #[error("Failed to extract id for decode: {0}")] - DecodeExtractId(String), + DecodeExtractId(Box), #[error("Failed to get entry for decode: {0}")] - DecodeGet(String), + DecodeGet(Box), #[error("Failed to write decoded entry: {0}")] - DecodeWrite(String), + DecodeWrite(Box), #[error("Failed to delete entry during query delete: {0}")] - QueryDelete(String), + QueryDelete(Box), #[error("Failed to delete entry with id {0}: {1}")] - DeleteEntry(u64, String), + DeleteEntry(u64, Box), } pub trait ClipboardDb { @@ -65,8 +67,13 @@ pub trait ClipboardDb { max_items: u64, excluded_apps: Option<&[String]>, ) -> Result; - fn deduplicate(&self, buf: &[u8], max: u64) -> Result; - fn trim_db(&self, max: u64) -> Result<(), StashError>; + + fn deduplicate_by_hash( + &self, + content_hash: i64, + max: u64, + ) -> Result; + fn trim_db(&self, max_items: u64) -> Result<(), StashError>; fn delete_last(&self) -> Result<(), StashError>; fn wipe_db(&self) -> Result<(), StashError>; fn list_entries( @@ -76,12 +83,12 @@ pub trait ClipboardDb { ) -> Result; fn decode_entry( &self, - in_: impl Read, + input: impl Read, out: impl Write, - input: Option, + id_hint: Option, ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; - fn delete_entries(&self, in_: impl Read) -> Result; + fn delete_entries(&self, input: impl Read) -> Result; fn next_sequence(&self) -> u64; } @@ -104,6 +111,34 @@ pub struct SqliteClipboardDb { impl SqliteClipboardDb { pub fn new(conn: Connection) -> Result { + conn + .pragma_update(None, "synchronous", "OFF") + .map_err(|e| { + StashError::Store( + format!("Failed to set synchronous pragma: {e}").into(), + ) + })?; + conn + .pragma_update(None, "journal_mode", "MEMORY") + .map_err(|e| { + StashError::Store( + format!("Failed to set journal_mode pragma: {e}").into(), + ) + })?; + conn.pragma_update(None, "cache_size", "-256") // 256KB cache + .map_err(|e| StashError::Store(format!("Failed to set cache_size pragma: {e}").into()))?; + conn + .pragma_update(None, "temp_store", "memory") + .map_err(|e| { + StashError::Store( + format!("Failed to set temp_store pragma: {e}").into(), + ) + })?; + conn.pragma_update(None, "mmap_size", "0") // disable mmap + .map_err(|e| StashError::Store(format!("Failed to set mmap_size pragma: {e}").into()))?; + conn.pragma_update(None, "page_size", "512") // small(er) pages + .map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?; + conn .execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( @@ -112,8 +147,21 @@ impl SqliteClipboardDb { mime TEXT );", ) - .map_err(|e| StashError::Store(e.to_string()))?; - // Initialize Wayland state in background thread + .map_err(|e| StashError::Store(e.to_string().into()))?; + + // Add content_hash column if it doesn't exist + // Migration MUST be done to avoid breaking existing installations. + let _ = + conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []); + + // Create index for content_hash if it doesn't exist + let _ = conn.execute( + "CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)", + [], + ); + + // Initialize Wayland state in background thread. This will be used to track + // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); Ok(Self { conn }) @@ -125,33 +173,34 @@ impl SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut entries = Vec::new(); while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents_str = match mime.as_deref() { Some(m) if m.starts_with("text/") || m == "application/json" => { - String::from_utf8_lossy(&contents).to_string() + String::from_utf8_lossy(&contents).into_owned() }, - _ => STANDARD.encode(&contents), + _ => base64::prelude::BASE64_STANDARD.encode(&contents), }; - entries.push(json!({ + entries.push(serde_json::json!({ "id": id, "contents": contents_str, "mime": mime, @@ -159,7 +208,7 @@ impl SqliteClipboardDb { } serde_json::to_string_pretty(&entries) - .map_err(|e| StashError::ListDecode(e.to_string())) + .map_err(|e| StashError::ListDecode(e.to_string().into())) } } @@ -182,17 +231,13 @@ impl ClipboardDb for SqliteClipboardDb { return Err(StashError::AllWhitespace); } - let mime = match detect_mime(&buf) { - None => { - // If valid UTF-8, treat as text/plain - if std::str::from_utf8(&buf).is_ok() { - Some("text/plain".to_string()) - } else { - None - } - }, - other => other, - }; + // Calculate content hash for deduplication + let mut hasher = DefaultHasher::new(); + buf.hash(&mut hasher); + #[allow(clippy::cast_possible_wrap)] + let content_hash = hasher.finish() as i64; + + let mime = detect_mime_optimized(&buf); // Try to load regex from systemd credential file, then env var let regex = load_sensitive_regex(); @@ -201,9 +246,7 @@ impl ClipboardDb for SqliteClipboardDb { if let Ok(s) = std::str::from_utf8(&buf) { if re.is_match(s) { warn!("Clipboard entry matches sensitive regex, skipping store."); - return Err(StashError::Store( - "Filtered by sensitive regex".to_string(), - )); + return Err(StashError::Store("Filtered by sensitive regex".into())); } } } @@ -212,50 +255,56 @@ impl ClipboardDb for SqliteClipboardDb { if should_exclude_by_app(excluded_apps) { warn!("Clipboard entry excluded by app filter"); return Err(StashError::ExcludedByApp( - "Clipboard entry from excluded app".to_string(), + "Clipboard entry from excluded app".into(), )); } - self.deduplicate(&buf, max_dedupe_search)?; + self.deduplicate_by_hash(content_hash, max_dedupe_search)?; self .conn .execute( - "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", - params![buf, mime], + "INSERT INTO clipboard (contents, mime, content_hash) VALUES (?1, ?2, \ + ?3)", + params![buf, mime.map(|s| s.to_string()), content_hash], ) - .map_err(|e| StashError::Store(e.to_string()))?; + .map_err(|e| StashError::Store(e.to_string().into()))?; self.trim_db(max_items)?; Ok(self.next_sequence()) } - fn deduplicate(&self, buf: &[u8], max: u64) -> Result { + fn deduplicate_by_hash( + &self, + content_hash: i64, + max: u64, + ) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + .prepare( + "SELECT id FROM clipboard WHERE content_hash = ?1 ORDER BY id DESC \ + LIMIT ?2", + ) + .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; let mut rows = stmt - .query(params![i64::try_from(max).unwrap_or(i64::MAX)]) - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + .query(params![ + content_hash, + i64::try_from(max).unwrap_or(i64::MAX) + ]) + .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; let mut deduped = 0; while let Some(row) = rows .next() - .map_err(|e| StashError::DeduplicationRead(e.to_string()))? + .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - if contents == buf { - self - .conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; - deduped += 1; - } + .map_err(|e| StashError::DeduplicationDecode(e.to_string().into()))?; + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeduplicationRemove(e.to_string().into()))?; + deduped += 1; } Ok(deduped) } @@ -264,7 +313,7 @@ impl ClipboardDb for SqliteClipboardDb { let count: u64 = self .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .map_err(|e| StashError::Trim(e.to_string()))?; + .map_err(|e| StashError::Trim(e.to_string().into()))?; if count > max { let to_delete = count - max; self @@ -274,7 +323,7 @@ impl ClipboardDb for SqliteClipboardDb { BY id ASC LIMIT ?1)", params![i64::try_from(to_delete).unwrap_or(i64::MAX)], ) - .map_err(|e| StashError::Trim(e.to_string()))?; + .map_err(|e| StashError::Trim(e.to_string().into()))?; } Ok(()) } @@ -288,12 +337,12 @@ impl ClipboardDb for SqliteClipboardDb { |row| row.get(0), ) .optional() - .map_err(|e| StashError::DeleteLast(e.to_string()))?; + .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; if let Some(id) = id { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteLast(e.to_string()))?; + .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; Ok(()) } else { Err(StashError::NoEntriesToDelete) @@ -304,11 +353,11 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute("DELETE FROM clipboard", []) - .map_err(|e| StashError::Wipe(e.to_string()))?; + .map_err(|e| StashError::Wipe(e.to_string().into()))?; self .conn .execute("DELETE FROM sqlite_sequence WHERE name = 'clipboard'", []) - .map_err(|e| StashError::Wipe(e.to_string()))?; + .map_err(|e| StashError::Wipe(e.to_string().into()))?; Ok(()) } @@ -320,24 +369,26 @@ impl ClipboardDb for SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut listed = 0; + while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let preview = preview_entry(&contents, mime.as_deref(), preview_width); if writeln!(out, "{id}\t{preview}").is_ok() { listed += 1; @@ -348,21 +399,22 @@ impl ClipboardDb for SqliteClipboardDb { fn decode_entry( &self, - mut in_: impl Read, + input: impl Read, mut out: impl Write, - input: Option, + id_hint: Option, ) -> Result<(), StashError> { - let s = if let Some(input) = input { - input + let input_str = if let Some(s) = id_hint { + s } else { + let mut input = BufReader::new(input); let mut buf = String::new(); - in_ + input .read_to_string(&mut buf) - .map_err(|e| StashError::DecodeRead(e.to_string()))?; + .map_err(|e| StashError::DecodeExtractId(e.to_string().into()))?; buf }; - let id = - extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; + let id = extract_id(&input_str) + .map_err(|e| StashError::DecodeExtractId(e.into()))?; let (contents, _mime): (Vec, Option) = self .conn .query_row( @@ -370,11 +422,11 @@ impl ClipboardDb for SqliteClipboardDb { params![id], |row| Ok((row.get(0)?, row.get(1)?)), ) - .map_err(|e| StashError::DecodeGet(e.to_string()))?; + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; out .write_all(&contents) - .map_err(|e| StashError::DecodeWrite(e.to_string()))?; - info!("Decoded entry with id {id}"); + .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; + log::info!("Decoded entry with id {id}"); Ok(()) } @@ -382,26 +434,26 @@ impl ClipboardDb for SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents FROM clipboard") - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let mut deleted = 0; while let Some(row) = rows .next() - .map_err(|e| StashError::QueryDelete(e.to_string()))? + .map_err(|e| StashError::QueryDelete(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; if contents.windows(query.len()).any(|w| w == query.as_bytes()) { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; deleted += 1; } } @@ -416,7 +468,7 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; + .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; deleted += 1; } } @@ -435,30 +487,36 @@ impl ClipboardDb for SqliteClipboardDb { } } -// Helper functions - /// Try to load a sensitive regex from systemd credential or env. /// /// # Returns +/// /// `Some(Regex)` if present and valid, `None` otherwise. fn load_sensitive_regex() -> Option { - if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { - let file = format!("{regex_path}/clipboard_filter"); - if let Ok(contents) = fs::read_to_string(&file) { - if let Ok(re) = Regex::new(contents.trim()) { - return Some(re); + static REGEX_CACHE: OnceLock> = OnceLock::new(); + static CHECKED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + + if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) { + CHECKED.store(true, std::sync::atomic::Ordering::Relaxed); + + let regex = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{regex_path}/clipboard_filter"); + if let Ok(contents) = fs::read_to_string(&file) { + Regex::new(contents.trim()).ok() + } else { + None } - } + } else if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { + Regex::new(&pattern).ok() + } else { + None + }; + + let _ = REGEX_CACHE.set(regex); } - // Fallback to an environment variable - if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { - if let Ok(re) = Regex::new(&pattern) { - return Some(re); - } - } - - None + REGEX_CACHE.get().and_then(std::clone::Clone::clone) } pub fn extract_id(input: &str) -> Result { @@ -466,35 +524,45 @@ pub fn extract_id(input: &str) -> Result { id_str.parse().map_err(|_| "invalid id") } +pub fn detect_mime_optimized(data: &[u8]) -> Option { + // Check if it's valid UTF-8 first, which most clipboard content are. + // This will be used to return early without unnecessary mimetype detection + // overhead. + if std::str::from_utf8(data).is_ok() { + return Some("text/plain".to_string()); + } + + // Only run image detection on binary data + detect_mime(data) +} + pub fn detect_mime(data: &[u8]) -> Option { if let Ok(img_type) = imagesize::image_type(data) { - Some( - match img_type { - ImageType::Png => "image/png", - ImageType::Jpeg => "image/jpeg", - ImageType::Gif => "image/gif", - ImageType::Bmp => "image/bmp", - ImageType::Tiff => "image/tiff", - ImageType::Webp => "image/webp", - ImageType::Aseprite => "image/x-aseprite", - ImageType::Dds => "image/vnd.ms-dds", - ImageType::Exr => "image/aces", - ImageType::Farbfeld => "image/farbfeld", - ImageType::Hdr => "image/vnd.radiance", - ImageType::Ico => "image/x-icon", - ImageType::Ilbm => "image/ilbm", - ImageType::Jxl => "image/jxl", - ImageType::Ktx2 => "image/ktx2", - ImageType::Pnm => "image/x-portable-anymap", - ImageType::Psd => "image/vnd.adobe.photoshop", - ImageType::Qoi => "image/qoi", - ImageType::Tga => "image/x-tga", - ImageType::Vtf => "image/x-vtf", - ImageType::Heif(_) => "image/heif", - _ => "application/octet-stream", - } - .to_string(), - ) + let mime_str = match img_type { + ImageType::Png => "image/png", + ImageType::Jpeg => "image/jpeg", + ImageType::Gif => "image/gif", + ImageType::Bmp => "image/bmp", + ImageType::Tiff => "image/tiff", + ImageType::Webp => "image/webp", + ImageType::Aseprite => "image/x-aseprite", + ImageType::Dds => "image/vnd.ms-dds", + ImageType::Exr => "image/aces", + ImageType::Farbfeld => "image/farbfeld", + ImageType::Hdr => "image/vnd.radiance", + ImageType::Ico => "image/x-icon", + ImageType::Ilbm => "image/ilbm", + ImageType::Jxl => "image/jxl", + ImageType::Ktx2 => "image/ktx2", + ImageType::Pnm => "image/x-portable-anymap", + ImageType::Psd => "image/vnd.adobe.photoshop", + ImageType::Qoi => "image/qoi", + ImageType::Tga => "image/x-tga", + ImageType::Vtf => "image/x-vtf", + ImageType::Heif(_) => "image/heif", + _ => "application/octet-stream", + }; + Some(mime_str.to_string()) } else { None } @@ -503,38 +571,54 @@ pub fn detect_mime(data: &[u8]) -> Option { pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { if let Some(mime) = mime { if mime.starts_with("image/") { - if let Ok(ImageSize { - width: img_width, - height: img_height, - }) = imagesize::blob_size(data) - { - return format!( - "[[ binary data {} {} {}x{} ]]", - size_str(data.len()), - mime, - img_width, - img_height - ); - } + return format!("[[ binary data {} {} ]]", size_str(data.len()), mime); } else if mime == "application/json" || mime.starts_with("text/") { - let s = match str::from_utf8(data) { - Ok(s) => s, - Err(e) => { - error!("Failed to decode UTF-8 clipboard data: {e}"); - "" - }, + let Ok(s) = str::from_utf8(data) else { + return format!("[[ invalid UTF-8 {} ]]", size_str(data.len())); }; - let s = s.trim().replace(|c: char| c.is_whitespace(), " "); - return truncate(&s, width as usize, "…"); + + let trimmed = s.trim(); + if trimmed.len() <= width as usize + && !trimmed.chars().any(|c| c.is_whitespace() && c != ' ') + { + return trimmed.to_string(); + } + + // Only allocate new string if we need to replace whitespace + let mut result = String::with_capacity(width as usize + 1); + for (char_count, c) in trimmed.chars().enumerate() { + if char_count >= width as usize { + result.push('…'); + break; + } + + if c.is_whitespace() { + result.push(' '); + } else { + result.push(c); + } + } + return result; } } + + // For non-text data, use lossy conversion let s = String::from_utf8_lossy(data); truncate(s.trim(), width as usize, "…") } pub fn truncate(s: &str, max: usize, ellip: &str) -> String { - if s.chars().count() > max { - s.chars().take(max).collect::() + ellip + let char_count = s.chars().count(); + if char_count > max { + let mut result = String::with_capacity(max * 4 + ellip.len()); // UTF-8 worst case + let mut char_iter = s.chars(); + for _ in 0..max { + if let Some(c) = char_iter.next() { + result.push(c); + } + } + result.push_str(ellip); + result } else { s.to_string() } @@ -630,7 +714,7 @@ fn get_recently_active_excluded_app( let mut candidates = Vec::new(); - if let Ok(entries) = fs::read_dir(proc_dir) { + if let Ok(entries) = std::fs::read_dir(proc_dir) { for entry in entries.flatten() { if let Ok(pid) = entry.file_name().to_string_lossy().parse::() { if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { @@ -729,9 +813,7 @@ fn get_process_activity_score(pid: u32) -> u64 { /// Check if an app name matches any in the exclusion list. /// Supports basic string matching and simple regex patterns. fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { - debug!( - "Checking if '{app_name}' matches exclusion list: {excluded_apps:?}" - ); + debug!("Checking if '{app_name}' matches exclusion list: {excluded_apps:?}"); for excluded in excluded_apps { // Basic string matching (case-insensitive) @@ -753,9 +835,7 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { let pattern = excluded.replace('*', ".*"); if let Ok(regex) = regex::Regex::new(&pattern) { if regex.is_match(app_name) { - debug!( - "Matched wildcard pattern: {app_name} matches {excluded}" - ); + debug!("Matched wildcard pattern: {app_name} matches {excluded}"); return true; } } diff --git a/src/main.rs b/src/main.rs index 2c12a80..cbeedd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,7 @@ struct Cli { /// Number of recent entries to check for duplicates when storing new /// clipboard data. - #[arg(long, default_value_t = 100)] + #[arg(long, default_value_t = 20)] max_dedupe_search: u64, /// Maximum width (in characters) for clipboard entry previews in list diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 425d5ab..016d609 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -25,7 +25,7 @@ static TOPLEVEL_APPS: LazyLock>> = pub fn init_wayland_state() { std::thread::spawn(|| { if let Err(e) = run_wayland_event_loop() { - debug!("Wayland event loop error: {}", e); + debug!("Wayland event loop error: {e}"); } }); } @@ -35,7 +35,7 @@ pub fn get_focused_window_app() -> Option { // Try Wayland protocol first if let Ok(focused) = FOCUSED_APP.lock() { if let Some(ref app) = *focused { - debug!("Found focused app via Wayland protocol: {}", app); + debug!("Found focused app via Wayland protocol: {app}"); return Some(app.clone()); } } @@ -49,7 +49,7 @@ fn run_wayland_event_loop() -> Result<(), Box> { let conn = match WaylandConnection::connect_to_env() { Ok(conn) => conn, Err(e) => { - debug!("Failed to connect to Wayland: {}", e); + debug!("Failed to connect to Wayland: {e}"); return Ok(()); }, }; @@ -111,7 +111,7 @@ impl Dispatch for AppState { { // New toplevel created // We'll track it for focus events - let _handle: ZwlrForeignToplevelHandleV1 = toplevel; + let _: ZwlrForeignToplevelHandleV1 = toplevel; } } @@ -136,7 +136,7 @@ impl Dispatch for AppState { match event { zwlr_foreign_toplevel_handle_v1::Event::AppId { app_id } => { - debug!("Toplevel app_id: {}", app_id); + debug!("Toplevel app_id: {app_id}"); // Store the app_id for this handle if let Ok(mut apps) = TOPLEVEL_APPS.lock() { apps.insert(handle_id, app_id); @@ -157,7 +157,7 @@ impl Dispatch for AppState { (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) { if let Some(app_id) = apps.get(&handle_id) { - debug!("Setting focused app to: {}", app_id); + debug!("Setting focused app to: {app_id}"); *focused = Some(app_id.clone()); } }