From 59423f9ae4595e78d6a634b2ec61c6b517f53d88 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 20 Jan 2026 10:14:32 +0300 Subject: [PATCH 1/7] list: add `content_hash` and `last_accessed` tracking with de-duplication Adds a `content_hash` column and index for deduplication, and a `last_accessed` column & index for time tracking. We now de-duplicate on copy by not copying if present, but instead bubbling up matching entry. Signed-off-by: NotAShelf Change-Id: Icbcdbd6ac28bbb21324785cae30911f96a6a6964 --- src/commands/list.rs | 90 ++++++++++++---------- src/db/mod.rs | 173 +++++++++++++++++++++++++++++++------------ src/wayland/mod.rs | 13 +--- 3 files changed, 179 insertions(+), 97 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index e501b45..c1e0164 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -55,7 +55,10 @@ impl SqliteClipboardDb { // Query entries from DB let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ + DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -242,8 +245,7 @@ impl SqliteClipboardDb { if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - if let Event::Key(key) = event::read() + && let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { match (key.code, key.modifiers) { @@ -275,50 +277,62 @@ impl SqliteClipboardDb { state.select(Some(i)); }, (KeyCode::Enter, _) => { - if let Some(idx) = state.selected() { - if let Some((id, ..)) = entries.get(idx) { - // Fetch full contents for the selected entry - let (contents, mime): (Vec, Option) = self - .conn - .query_row( - "SELECT contents, mime FROM clipboard WHERE id = ?1", - rusqlite::params![id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| { - StashError::ListDecode(e.to_string().into()) - })?; - // Copy to clipboard - let opts = Options::new(); - // Default clipboard is regular, seat is default - let mime_type = match mime { - Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.clone()), - None => MimeType::Text, - }; - let copy_result = opts - .copy(Source::Bytes(contents.clone().into()), mime_type); - match copy_result { - Ok(()) => { - let _ = Notification::new() - .summary("Stash") - .body("Copied entry to clipboard") - .show(); + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) { + match self.copy_entry(*id) { + Ok((new_id, contents, mime)) => { + if new_id != *id { + entries[idx] = ( + new_id, + entries[idx].1.clone(), + entries[idx].2.clone(), + ); + } + let opts = Options::new(); + let mime_type = match mime { + Some(ref m) if m == "text/plain" => MimeType::Text, + Some(ref m) => { + MimeType::Specific(m.clone().to_owned()) + }, + None => MimeType::Text, + }; + let copy_result = opts.copy( + Source::Bytes(contents.clone().into()), + mime_type, + ); + match copy_result { + Ok(()) => { + let _ = Notification::new() + .summary("Stash") + .body("Copied entry to clipboard") + .show(); + }, + Err(e) => { + log::error!( + "Failed to copy entry to clipboard: {e}" + ); + let _ = Notification::new() + .summary("Stash") + .body(&format!( + "Failed to copy to clipboard: {e}" + )) + .show(); + }, + } }, Err(e) => { - log::error!("Failed to copy entry to clipboard: {e}"); + log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") - .body(&format!("Failed to copy to clipboard: {e}")) + .body(&format!("Failed to fetch entry: {e}")) .show(); }, } } - } }, (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() { - if let Some((id, ..)) = entries.get(idx) { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) { // Delete entry from DB self .conn @@ -345,12 +359,10 @@ impl SqliteClipboardDb { .body("Deleted entry") .show(); } - } }, _ => {}, } } - } } Ok(()) })(); diff --git a/src/db/mod.rs b/src/db/mod.rs index 469ec2a..b5445a7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -89,6 +89,10 @@ pub trait ClipboardDb { ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; fn delete_entries(&self, input: impl Read) -> Result; + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError>; fn next_sequence(&self) -> i64; } @@ -149,17 +153,44 @@ impl SqliteClipboardDb { ) .map_err(|e| StashError::Store(e.to_string().into()))?; + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT, + content_hash INTEGER, + last_accessed INTEGER DEFAULT (CAST(strftime('%s', 'now') AS \ + INTEGER)) + );", + ) + .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", []); + // Add last_accessed column if it doesn't exist + let _ = conn.execute( + "ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER DEFAULT \ + (CAST(strftime('%s', 'now') AS 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)", [], ); + // Create index for last_accessed if it doesn't exist + let _ = conn.execute( + "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ + clipboard(last_accessed)", + [], + ); + // Initialize Wayland state in background thread. This will be used to track // focused window state. #[cfg(feature = "use-toplevel")] @@ -172,7 +203,10 @@ impl SqliteClipboardDb { pub fn list_json(&self) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -243,11 +277,11 @@ impl ClipboardDb for SqliteClipboardDb { let regex = load_sensitive_regex(); if let Some(re) = regex { // Only check text data - 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".into())); - } + if let Ok(s) = std::str::from_utf8(&buf) + && re.is_match(s) + { + warn!("Clipboard entry matches sensitive regex, skipping store."); + return Err(StashError::Store("Filtered by sensitive regex".into())); } } @@ -317,6 +351,8 @@ impl ClipboardDb for SqliteClipboardDb { let max_i64 = i64::try_from(max).unwrap_or(i64::MAX); if count > max_i64 { let to_delete = count - max_i64; + + #[allow(clippy::useless_conversion)] self .conn .execute( @@ -369,7 +405,10 @@ impl ClipboardDb for SqliteClipboardDb { ) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -476,6 +515,48 @@ impl ClipboardDb for SqliteClipboardDb { Ok(deleted) } + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError> { + let (contents, mime, content_hash): (Vec, Option, Option) = + self + .conn + .query_row( + "SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if let Some(hash) = content_hash { + let most_recent_id: Option = self + .conn + .query_row( + "SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \ + = (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \ + ?1)", + params![hash], + |row| row.get(0), + ) + .optional() + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if most_recent_id != Some(id) { + self + .conn + .execute( + "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ + AS INTEGER) WHERE id = ?1", + params![id], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + } + } + + Ok((id, contents, mime)) + } + fn next_sequence(&self) -> i64 { match self .conn @@ -693,11 +774,11 @@ fn get_focused_window_app() -> Option { } // Fallback: Check WAYLAND_CLIENT_NAME environment variable - if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") { - if !client.is_empty() { - debug!("Found WAYLAND_CLIENT_NAME: {client}"); - return Some(client); - } + if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") + && !client.is_empty() + { + debug!("Found WAYLAND_CLIENT_NAME: {client}"); + return Some(client); } debug!("No focused window detection method worked"); @@ -717,19 +798,17 @@ fn get_recently_active_excluded_app( 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")) { - let process_name = comm.trim(); + if let Ok(pid) = entry.file_name().to_string_lossy().parse::() + && let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) + { + let process_name = comm.trim(); - // Check process name against exclusion list - if app_matches_exclusion(process_name, excluded_apps) - && has_recent_activity(pid) - { - candidates.push(( - process_name.to_string(), - get_process_activity_score(pid), - )); - } + // Check process name against exclusion list + if app_matches_exclusion(process_name, excluded_apps) + && has_recent_activity(pid) + { + candidates + .push((process_name.to_string(), get_process_activity_score(pid))); } } } @@ -763,15 +842,13 @@ fn has_recent_activity(pid: u32) -> bool { // Check /proc/PID/io for recent I/O activity if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - if value > 1024 * 1024 { - // 1MB threshold - return true; - } - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + && value > 1024 * 1024 + { + // 1MB threshold + return true; } } } @@ -786,24 +863,22 @@ fn get_process_activity_score(pid: u32) -> u64 { // Add CPU time to score if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 14 { - if let (Ok(utime), Ok(stime)) = + if fields.len() > 14 + && let (Ok(utime), Ok(stime)) = (fields[13].parse::(), fields[14].parse::()) - { - score += utime + stime; - } + { + score += utime + stime; } } // Add I/O activity to score if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - score += value / 1024; // convert to KB - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + { + score += value / 1024; // convert to KB } } } @@ -834,11 +909,11 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { } else if excluded.contains('*') { // Simple wildcard matching 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}"); - return true; - } + if let Ok(regex) = regex::Regex::new(&pattern) + && regex.is_match(app_name) + { + debug!("Matched wildcard pattern: {app_name} matches {excluded}"); + return true; } } } diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 016d609..17e655f 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -33,12 +33,11 @@ pub fn init_wayland_state() { /// Get the currently focused window application name using Wayland protocols pub fn get_focused_window_app() -> Option { // Try Wayland protocol first - if let Ok(focused) = FOCUSED_APP.lock() { - if let Some(ref app) = *focused { + if let Ok(focused) = FOCUSED_APP.lock() + && let Some(ref app) = *focused { debug!("Found focused app via Wayland protocol: {app}"); return Some(app.clone()); } - } debug!("No focused window detection method worked"); None @@ -81,12 +80,10 @@ impl Dispatch for AppState { interface, version: _, } = event - { - if interface == "zwlr_foreign_toplevel_manager_v1" { + && interface == "zwlr_foreign_toplevel_manager_v1" { let _manager: ZwlrForeignToplevelManagerV1 = registry.bind(name, 1, qh, ()); } - } } fn event_created_child( @@ -155,12 +152,10 @@ impl Dispatch for AppState { // Update focused app to the `app_id` of this handle if let (Ok(apps), Ok(mut focused)) = (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) - { - if let Some(app_id) = apps.get(&handle_id) { + && let Some(app_id) = apps.get(&handle_id) { debug!("Setting focused app to: {app_id}"); *focused = Some(app_id.clone()); } - } } }, zwlr_foreign_toplevel_handle_v1::Event::Closed => { From dca7cca4550b21eef687c7db063327be60a6f59f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 11:27:32 +0300 Subject: [PATCH 2/7] nix: add cargo-nextest to devshell Signed-off-by: NotAShelf Change-Id: I2266c2f3fccff23fa3950f8fac3365f36a6a6964 --- nix/shell.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nix/shell.nix b/nix/shell.nix index 9df0432..273d74a 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -6,6 +6,7 @@ clippy, taplo, rust-analyzer-unwrapped, + cargo-nextest, rustPlatform, }: mkShell { @@ -20,6 +21,9 @@ mkShell { cargo taplo rust-analyzer-unwrapped + + # Additional Cargo Tooling + cargo-nextest ]; RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; From 20b6a12461443d7efe930577e66b18a7e1365b2c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 12:22:07 +0300 Subject: [PATCH 3/7] stash: make db module public for test visibility Signed-off-by: NotAShelf Change-Id: I5f75e6515114e7479a3fe63771a4e7fe6a6a6964 --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index d925c97..28f9fb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use clap::{CommandFactory, Parser, Subcommand}; use inquire::Confirm; mod commands; -mod db; +pub(crate) mod db; mod multicall; #[cfg(feature = "use-toplevel")] mod wayland; From 31655435804dd7ea67982a5ea4b08eb8f2e14932 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:28:43 +0300 Subject: [PATCH 4/7] commands: prevent usize underflow when navigating empty entry list Signed-off-by: NotAShelf Change-Id: I0432dcc88b22226772f6bb6e05cc64d36a6a6964 --- src/commands/list.rs | 172 ++++++++++++++++++++++--------------------- 1 file changed, 87 insertions(+), 85 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index c1e0164..1afab2a 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -247,10 +247,13 @@ impl SqliteClipboardDb { .map_err(|e| StashError::ListDecode(e.to_string().into()))? && let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - match (key.code, key.modifiers) { - (KeyCode::Char('q') | KeyCode::Esc, _) => break, - (KeyCode::Down | KeyCode::Char('j'), _) => { + { + match (key.code, key.modifiers) { + (KeyCode::Char('q') | KeyCode::Esc, _) => break, + (KeyCode::Down | KeyCode::Char('j'), _) => { + if entries.is_empty() { + state.select(None); + } else { let i = match state.selected() { Some(i) => { if i >= entries.len() - 1 { @@ -262,8 +265,12 @@ impl SqliteClipboardDb { None => 0, }; state.select(Some(i)); - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { + } + }, + (KeyCode::Up | KeyCode::Char('k'), _) => { + if entries.is_empty() { + state.select(None); + } else { let i = match state.selected() { Some(i) => { if i == 0 { @@ -275,94 +282,89 @@ impl SqliteClipboardDb { None => 0, }; state.select(Some(i)); - }, - (KeyCode::Enter, _) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) { - match self.copy_entry(*id) { - Ok((new_id, contents, mime)) => { - if new_id != *id { - entries[idx] = ( - new_id, - entries[idx].1.clone(), - entries[idx].2.clone(), - ); - } - let opts = Options::new(); - let mime_type = match mime { - Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => { - MimeType::Specific(m.clone().to_owned()) - }, - None => MimeType::Text, - }; - let copy_result = opts.copy( - Source::Bytes(contents.clone().into()), - mime_type, - ); - match copy_result { - Ok(()) => { - let _ = Notification::new() - .summary("Stash") - .body("Copied entry to clipboard") - .show(); - }, - Err(e) => { - log::error!( - "Failed to copy entry to clipboard: {e}" - ); - let _ = Notification::new() - .summary("Stash") - .body(&format!( - "Failed to copy to clipboard: {e}" - )) - .show(); - }, - } - }, - Err(e) => { - log::error!("Failed to fetch entry {id}: {e}"); + } + }, + (KeyCode::Enter, _) => { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) + { + match self.copy_entry(*id) { + Ok((new_id, contents, mime)) => { + if new_id != *id { + entries[idx] = ( + new_id, + entries[idx].1.clone(), + entries[idx].2.clone(), + ); + } + let opts = Options::new(); + let mime_type = match mime { + Some(ref m) if m == "text/plain" => MimeType::Text, + Some(ref m) => MimeType::Specific(m.clone().to_owned()), + None => MimeType::Text, + }; + let copy_result = opts + .copy(Source::Bytes(contents.clone().into()), mime_type); + match copy_result { + Ok(()) => { let _ = Notification::new() .summary("Stash") - .body(&format!("Failed to fetch entry: {e}")) + .body("Copied entry to clipboard") + .show(); + }, + Err(e) => { + log::error!("Failed to copy entry to clipboard: {e}"); + let _ = Notification::new() + .summary("Stash") + .body(&format!("Failed to copy to clipboard: {e}")) .show(); }, } - } - }, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) { - // Delete entry from DB - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| { - StashError::DeleteEntry(*id, e.to_string().into()) - })?; - // Remove from entries and update selection - entries.remove(idx); - let new_len = entries.len(); - if new_len == 0 { - state.select(None); - } else if idx >= new_len { - state.select(Some(new_len - 1)); - } else { - state.select(Some(idx)); - } - // Show notification + }, + Err(e) => { + log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") - .body("Deleted entry") + .body(&format!("Failed to fetch entry: {e}")) .show(); - } - }, - _ => {}, - } + }, + } + } + }, + (KeyCode::Char('D'), KeyModifiers::SHIFT) => { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) + { + // Delete entry from DB + self + .conn + .execute( + "DELETE FROM clipboard WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|e| { + StashError::DeleteEntry(*id, e.to_string().into()) + })?; + // Remove from entries and update selection + entries.remove(idx); + let new_len = entries.len(); + if new_len == 0 { + state.select(None); + } else if idx >= new_len { + state.select(Some(new_len - 1)); + } else { + state.select(Some(idx)); + } + // Show notification + let _ = Notification::new() + .summary("Stash") + .body("Deleted entry") + .show(); + } + }, + _ => {}, } + } } Ok(()) })(); From c65073e0d10fd0896346c019d22683caf21867d8 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:29:04 +0300 Subject: [PATCH 5/7] db: rewrite migration with transactional schema versioning This makes Stash's database handler a bit more robust. The changes started as me trying to add an entry expiry, but I've realized that the database system is a little fragile and it assumed the database does not change, ever. Well that's not true, it does change and when it does there's a chance that everything implodes. We now wrap migrations in transaction for atomicity and track version via PRAGMA user_version (0 -> 3). We also check column existence before ALTER TABLE and use `last_insert_rowid()` instead of `next_sequence()`. Last but not least, a bunch of regression tests have been added to the database system because I'd rather not discover regressions in production. Signed-off-by: NotAShelf Change-Id: Ifeab42b0816a5161d736767cb82065346a6a6964 --- src/db/mod.rs | 510 +++++++++++++++++++++++++++++++++++++++++---- src/wayland/mod.rs | 27 +-- 2 files changed, 487 insertions(+), 50 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index b5445a7..3d07a9e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -114,7 +114,7 @@ pub struct SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn new(conn: Connection) -> Result { + pub fn new(mut conn: Connection) -> Result { conn .pragma_update(None, "synchronous", "OFF") .map_err(|e| { @@ -143,53 +143,124 @@ impl SqliteClipboardDb { 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( + let tx = conn.transaction().map_err(|e| { + StashError::Store( + format!("Failed to begin migration transaction: {e}").into(), + ) + })?; + + let schema_version: i64 = tx + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(|e| { + StashError::Store(format!("Failed to read schema version: {e}").into()) + })?; + + if schema_version == 0 { + tx.execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT );", ) - .map_err(|e| StashError::Store(e.to_string().into()))?; + .map_err(|e| { + StashError::Store( + format!("Failed to create clipboard table: {e}").into(), + ) + })?; - conn - .execute_batch( - "CREATE TABLE IF NOT EXISTS clipboard ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contents BLOB NOT NULL, - mime TEXT, - content_hash INTEGER, - last_accessed INTEGER DEFAULT (CAST(strftime('%s', 'now') AS \ - INTEGER)) - );", - ) - .map_err(|e| StashError::Store(e.to_string().into()))?; + tx.execute("PRAGMA user_version = 1", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").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", []); + if schema_version < 2 { + let has_content_hash: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("content_hash")) + }, + ) + .unwrap_or(false); + + if !has_content_hash { + tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add content_hash column: {e}").into(), + ) + })?; + } + + // Create index for content_hash if it doesn't exist + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_content_hash ON \ + clipboard(content_hash)", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create content_hash index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 2", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } // Add last_accessed column if it doesn't exist - let _ = conn.execute( - "ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER DEFAULT \ - (CAST(strftime('%s', 'now') AS INTEGER))", - [], - ); + if schema_version < 3 { + let has_last_accessed: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("last_accessed")) + }, + ) + .unwrap_or(false); - // 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)", - [], - ); + if !has_last_accessed { + tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [ + ]) + .map_err(|e| { + StashError::Store( + format!("Failed to add last_accessed column: {e}").into(), + ) + })?; + } - // Create index for last_accessed if it doesn't exist - let _ = conn.execute( - "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ - clipboard(last_accessed)", - [], - ); + // Create index for last_accessed if it doesn't exist + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ + clipboard(last_accessed)", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create last_accessed index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 3", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + + tx.commit().map_err(|e| { + StashError::Store( + format!("Failed to commit migration transaction: {e}").into(), + ) + })?; // Initialize Wayland state in background thread. This will be used to track // focused window state. @@ -298,14 +369,27 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute( - "INSERT INTO clipboard (contents, mime, content_hash) VALUES (?1, ?2, \ - ?3)", - params![buf, mime, content_hash], + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + buf, + mime, + content_hash, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as i64 + ], ) .map_err(|e| StashError::Store(e.to_string().into()))?; + let id = self + .conn + .query_row("SELECT last_insert_rowid()", [], |row| row.get(0)) + .map_err(|e| StashError::Store(e.to_string().into()))?; + self.trim_db(max_items)?; - Ok(self.next_sequence()) + Ok(id) } fn deduplicate_by_hash( @@ -921,3 +1005,353 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { debug!("No match found for '{app_name}'"); false } + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + + use super::*; + + fn get_schema_version(conn: &Connection) -> rusqlite::Result { + conn.pragma_query_value(None, "user_version", |row| row.get(0)) + } + + fn table_column_exists(conn: &Connection, table: &str, column: &str) -> bool { + let query = format!( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='{}'", + table + ); + match conn.query_row(&query, [], |row| row.get::<_, String>(0)) { + Ok(sql) => sql.contains(column), + Err(_) => false, + } + } + + fn index_exists(conn: &Connection, index: &str) -> bool { + let query = "SELECT name FROM sqlite_master WHERE type='index' AND name=?1"; + conn + .query_row(query, [index], |row| row.get::<_, String>(0)) + .is_ok() + } + + #[test] + fn test_fresh_database_v3_schema() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_fresh.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn).expect("Failed to get schema version"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + + assert!(index_exists(&db.conn, "idx_content_hash")); + assert!(index_exists(&db.conn, "idx_last_accessed")); + + db.conn + .execute( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (x'010203', 'text/plain', 12345, 1704067200)", + [], + ) + .expect("Failed to insert test data"); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1); + } + + #[test] + fn test_migration_from_v0() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v0.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ + 'text/plain')", + ) + .expect("Failed to insert data"); + + assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_migration_from_v1() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v1.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + conn + .pragma_update(None, "user_version", 1i64) + .expect("Failed to set version"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ + 'text/plain')", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_migration_from_v2() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v2.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \ + INTEGER);", + ) + .expect("Failed to create table"); + + conn + .pragma_update(None, "user_version", 2i64) + .expect("Failed to set version"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime, content_hash) VALUES \ + (x'010203', 'text/plain', 12345)", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(index_exists(&db.conn, "idx_last_accessed")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_idempotent_migration() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_idempotent.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let version_after_first = + get_schema_version(&db.conn).expect("Failed to get version"); + + let db2 = + SqliteClipboardDb::new(db.conn).expect("Failed to create database again"); + let version_after_second = + get_schema_version(&db2.conn).expect("Failed to get version"); + + assert_eq!(version_after_first, version_after_second); + assert_eq!(version_after_first, 3); + } + + #[test] + fn test_store_and_retrieve_with_new_columns() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_store.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + let test_data = b"Hello, World!"; + let cursor = std::io::Cursor::new(test_data.to_vec()); + + let id = db + .store_entry(cursor, 100, 1000, None) + .expect("Failed to store entry"); + + let content_hash: Option = db + .conn + .query_row( + "SELECT content_hash FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .expect("Failed to get content_hash"); + + let last_accessed: Option = db + .conn + .query_row( + "SELECT last_accessed FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .expect("Failed to get last_accessed"); + + assert!(content_hash.is_some(), "content_hash should be set"); + assert!(last_accessed.is_some(), "last_accessed should be set"); + } + + #[test] + fn test_last_accessed_updated_on_copy() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_copy.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + let test_data = b"Test content for copy"; + let cursor = std::io::Cursor::new(test_data.to_vec()); + let id_a = db + .store_entry(cursor, 100, 1000, None) + .expect("Failed to store entry A"); + + let original_last_accessed: i64 = db + .conn + .query_row( + "SELECT last_accessed FROM clipboard WHERE id = ?1", + [id_a], + |row| row.get(0), + ) + .expect("Failed to get last_accessed"); + + std::thread::sleep(std::time::Duration::from_millis(1100)); + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + test_data.hash(&mut hasher); + let content_hash = hasher.finish() as i64; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as i64; + + db.conn + .execute( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (?1, ?2, ?3, ?4)", + params![test_data as &[u8], "text/plain", content_hash, now], + ) + .expect("Failed to insert entry B directly"); + + std::thread::sleep(std::time::Duration::from_millis(1100)); + + let (..) = db.copy_entry(id_a).expect("Failed to copy entry"); + + let new_last_accessed: i64 = db + .conn + .query_row( + "SELECT last_accessed FROM clipboard WHERE id = ?1", + [id_a], + |row| row.get(0), + ) + .expect("Failed to get updated last_accessed"); + + assert!( + new_last_accessed > original_last_accessed, + "last_accessed should be updated when copying an entry that is not the \ + most recent" + ); + } + + #[test] + fn test_migration_with_existing_columns_but_v0() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v0_with_cols.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \ + INTEGER, last_accessed INTEGER);", + ) + .expect("Failed to create table with all columns"); + + conn + .pragma_update(None, "user_version", 0i64) + .expect("Failed to set version to 0"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (x'010203', 'text/plain', 12345, 1704067200)", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn).expect("Failed to get version"), + 3 + ); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } +} diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 17e655f..9cfa765 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -34,10 +34,11 @@ pub fn init_wayland_state() { pub fn get_focused_window_app() -> Option { // Try Wayland protocol first if let Ok(focused) = FOCUSED_APP.lock() - && let Some(ref app) = *focused { - debug!("Found focused app via Wayland protocol: {app}"); - return Some(app.clone()); - } + && let Some(ref app) = *focused + { + debug!("Found focused app via Wayland protocol: {app}"); + return Some(app.clone()); + } debug!("No focused window detection method worked"); None @@ -80,10 +81,11 @@ impl Dispatch for AppState { interface, version: _, } = event - && interface == "zwlr_foreign_toplevel_manager_v1" { - let _manager: ZwlrForeignToplevelManagerV1 = - registry.bind(name, 1, qh, ()); - } + && interface == "zwlr_foreign_toplevel_manager_v1" + { + let _manager: ZwlrForeignToplevelManagerV1 = + registry.bind(name, 1, qh, ()); + } } fn event_created_child( @@ -152,10 +154,11 @@ impl Dispatch for AppState { // Update focused app to the `app_id` of this handle if let (Ok(apps), Ok(mut focused)) = (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) - && let Some(app_id) = apps.get(&handle_id) { - debug!("Setting focused app to: {app_id}"); - *focused = Some(app_id.clone()); - } + && let Some(app_id) = apps.get(&handle_id) + { + debug!("Setting focused app to: {app_id}"); + *focused = Some(app_id.clone()); + } } }, zwlr_foreign_toplevel_handle_v1::Event::Closed => { From 3d22a271bc6cd4e8c2c2d01f5bf199a4f3b26775 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:31:52 +0300 Subject: [PATCH 6/7] chore: add tempfile dependency for tests Signed-off-by: NotAShelf Change-Id: Ibf7a842a2a26f83e8adaf1123386306b6a6a6964 --- Cargo.lock | 1 + Cargo.toml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 57c420f..108d514 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1712,6 +1712,7 @@ dependencies = [ "serde", "serde_json", "smol", + "tempfile", "thiserror", "unicode-segmentation", "unicode-width 0.2.0", diff --git a/Cargo.toml b/Cargo.toml index f396825..725d84b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ wayland-client = { version = "0.31.11", features = ["log"], optional = true } wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true } notify-rust = { version = "4.11.7", optional = true } +[dev-dependencies] +tempfile = "3.18.0" + [profile.release] lto = true opt-level = "z" From 047445b14395003dd9c9b800831b1b20fd1566a0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:48:06 +0300 Subject: [PATCH 7/7] db: distinguish HEIC from HEIF in mime type detection Signed-off-by: NotAShelf Change-Id: I1a25c6d30fde6b4cc33c2a1666b2e1606a6a6964 --- src/db/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/db/mod.rs b/src/db/mod.rs index 3d07a9e..97e2bb3 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -725,6 +725,7 @@ pub fn detect_mime(data: &[u8]) -> Option { ImageType::Qoi => "image/qoi", ImageType::Tga => "image/x-tga", ImageType::Vtf => "image/x-vtf", + ImageType::Heif(imagesize::Compression::Hevc) => "image/heic", ImageType::Heif(_) => "image/heif", _ => "application/octet-stream", };