From 71fc1ff40f5e71dd5f946e8c51e0a8b032d04bcf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 14:32:00 +0300 Subject: [PATCH 01/60] db: add `expires_at column and expiration management methods Schema v4: add expires_at REAL column with partial index for NULL values Other relevant methods that were added: - `now()` for Unix timestamp with sub-second precision - `cleanup_expired()` to remove all expired entries - `get_expired_entries()` for for diagnostic output (`stash list --expired`) - `get_next_expiration()` for heap initialization - `set_expiration()` to update expiration timestamp This feature has proven larger than I had anticipated (and hoped) but that's the reality of dealing with databases. Some of the methods are slightly redundant but it helps keep tracing the code manageable and semantically correct. We'll probably not regret those later. Probably. Signed-off-by: NotAShelf Change-Id: Ie9e5b0767673e74389b8e59c466afd946a6a6964 --- Cargo.lock | 35 ++++++++------ Cargo.toml | 3 +- src/db/mod.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3b5980..26321a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "ident_case" version = "1.0.1" @@ -1121,9 +1127,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -2128,6 +2134,7 @@ dependencies = [ "ctrlc", "dirs", "env_logger", + "humantime", "imagesize", "inquire", "libc", @@ -2561,18 +2568,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -2583,9 +2590,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2593,9 +2600,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -2606,9 +2613,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -2924,9 +2931,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wl-clipboard-rs" diff --git a/Cargo.toml b/Cargo.toml index 94ec687..cd21891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.90" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] @@ -22,6 +22,7 @@ crossterm = "0.29.0" ctrlc = "3.5.1" dirs = "6.0.0" env_logger = "0.11.8" +humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.2", default-features = false, features = [ "crossterm", diff --git a/src/db/mod.rs b/src/db/mod.rs index 97e2bb3..8c98ddd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -256,6 +256,46 @@ impl SqliteClipboardDb { })?; } + // Add expires_at column if it doesn't exist (v4) + if schema_version < 4 { + let has_expires_at: 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("expires_at")) + }, + ) + .unwrap_or(false); + + if !has_expires_at { + tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add expires_at column: {e}").into(), + ) + })?; + } + + // Create partial index for expires_at (only index non-NULL values) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \ + WHERE expires_at IS NOT NULL", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create expires_at index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 4", []).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(), @@ -653,6 +693,93 @@ impl ClipboardDb for SqliteClipboardDb { } } +impl SqliteClipboardDb { + /// Get current Unix timestamp with sub-second precision + pub fn now() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64() + } + + /// Clean up all expired entries. Returns count deleted. + pub fn cleanup_expired(&self) -> Result { + let now = Self::now(); + self + .conn + .execute( + "DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \ + ?1", + [now], + ) + .map_err(|e| StashError::Trim(e.to_string().into())) + } + + /// Get expired entries (for --expired flag diagnostic output) + pub fn get_expired_entries( + &self, + ) -> Result, Option)>, StashError> { + let now = Self::now(); + let mut stmt = self + .conn + .prepare( + "SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \ + NULL AND expires_at <= ?1 ORDER BY expires_at ASC", + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut rows = stmt + .query([now]) + .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().into()))? + { + let id: i64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + entries.push((id, contents, mime)); + } + Ok(entries) + } + + /// Get the earliest expiration (timestamp, id) for heap initialization + pub fn get_next_expiration(&self) -> Result, StashError> { + match self.conn.query_row( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ + ORDER BY expires_at ASC LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) { + Ok(result) => Ok(Some(result)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(StashError::Store(e.to_string().into())), + } + } + + /// Set expiration timestamp for an entry + pub fn set_expiration( + &self, + id: i64, + expires_at: f64, + ) -> Result<(), StashError> { + self + .conn + .execute( + "UPDATE clipboard SET expires_at = ?2 WHERE id = ?1", + params![id, expires_at], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + } +} + /// Try to load a sensitive regex from systemd credential or env. /// /// # Returns From dd7a55c7604f037ace290ed204e704eff097127e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 15:12:33 +0300 Subject: [PATCH 02/60] watch: implement expiration queue w/ sub-second precision This adds a Neg wrapper struct for min-heap behaviour on BinaryHeap which has proven *really* valuable. Also modify `watch()` to take the `expire_after` argument for various new features. See my previous commit for what is actually new. Signed-off-by: NotAShelf Change-Id: I8705d404eae5d93ad48f738a24f698196a6a6964 --- src/commands/watch.rs | 171 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 4 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ce2acf7..e7ac13e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,5 +1,5 @@ use std::{ - collections::hash_map::DefaultHasher, + collections::{BinaryHeap, hash_map::DefaultHasher}, hash::{Hash, Hasher}, io::Read, time::Duration, @@ -10,12 +10,89 @@ use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; use crate::db::{ClipboardDb, SqliteClipboardDb}; +/// Wrapper to provide Ord implementation for f64 by negating values. +/// This allows BinaryHeap (which is a max-heap) to function as a min-heap. +#[derive(Debug, Clone, Copy)] +struct Neg(f64); + +impl Neg { + fn inner(&self) -> f64 { + self.0 + } +} + +impl std::cmp::PartialEq for Neg { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl std::cmp::Eq for Neg {} + +impl std::cmp::PartialOrd for Neg { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for Neg { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reverse ordering for min-heap behavior + other + .0 + .partial_cmp(&self.0) + .unwrap_or(std::cmp::Ordering::Equal) + } +} + +/// Min-heap for tracking entry expirations with sub-second precision. +/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior. +#[derive(Debug, Default)] +struct ExpirationQueue { + heap: BinaryHeap<(Neg, i64)>, +} + +impl ExpirationQueue { + /// Create a new empty expiration queue + fn new() -> Self { + Self { + heap: BinaryHeap::new(), + } + } + + /// Push a new expiration into the queue + fn push(&mut self, expires_at: f64, id: i64) { + self.heap.push((Neg(expires_at), id)); + } + + /// Peek at the next expiration timestamp without removing it + fn peek_next(&self) -> Option { + self.heap.peek().map(|(neg, _)| neg.inner()) + } + + /// Remove and return all entries that have expired by `now` + fn pop_expired(&mut self, now: f64) -> Vec { + let mut expired = Vec::new(); + while let Some((neg_exp, id)) = self.heap.peek() { + let expires_at = neg_exp.inner(); + if expires_at <= now { + expired.push(*id); + self.heap.pop(); + } else { + break; + } + } + expired + } +} + pub trait WatchCommand { fn watch( &self, max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ); } @@ -25,10 +102,52 @@ impl WatchCommand for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ) { smol::block_on(async { log::info!("Starting clipboard watch daemon"); + // Cleanup any already-expired entries on startup + if let Ok(count) = self.cleanup_expired() { + if count > 0 { + log::info!("Cleaned up {} expired entries on startup", count); + } + } + + // Build expiration queue from existing entries + let mut exp_queue = ExpirationQueue::new(); + if let Ok(Some((expires_at, id))) = self.get_next_expiration() { + exp_queue.push(expires_at, id); + // Load remaining expirations + let mut stmt = self + .conn + .prepare( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ + NULL ORDER BY expires_at ASC", + ) + .ok(); + if let Some(ref mut stmt) = stmt { + let mut rows = stmt.query([]).ok(); + if let Some(ref mut rows) = rows { + while let Ok(Some(row)) = rows.next() { + if let (Ok(exp), Ok(row_id)) = + (row.get::<_, f64>(0), row.get::<_, i64>(1)) + { + // Skip first entry which is already added + if exp_queue + .heap + .iter() + .any(|(_, existing_id)| *existing_id == row_id) + { + continue; + } + exp_queue.push(exp, row_id); + } + } + } + } + } + // We use hashes for comparison instead of storing full contents let mut last_hash: Option = None; let mut buf = Vec::with_capacity(4096); @@ -53,6 +172,39 @@ impl WatchCommand for SqliteClipboardDb { } loop { + // Process any pending expirations + if let Some(next_exp) = exp_queue.peek_next() { + let now = SqliteClipboardDb::now(); + if next_exp <= now { + // Expired entries to process + let expired_ids = exp_queue.pop_expired(now); + for id in expired_ids { + // Verify entry still exists (handles stale heap entries) + let exists = self + .conn + .query_row( + "SELECT 1 FROM clipboard WHERE id = ?1", + [id], + |_| Ok(()), + ) + .is_ok(); + if exists { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", [id]) + .ok(); + log::info!("Entry {id} expired and removed"); + } + } + } else { + // Sleep precisely until next expiration (sub-second precision) + let sleep_duration = next_exp - now; + Timer::after(Duration::from_secs_f64(sleep_duration)).await; + continue; // Skip normal poll, process expirations first + } + } + + // Normal clipboard polling match get_contents( ClipboardType::Regular, Seat::Unspecified, @@ -70,16 +222,23 @@ impl WatchCommand for SqliteClipboardDb { 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(_) => { + Ok(id) => { log::info!("Stored new clipboard entry (id: {id})"); last_hash = Some(current_hash); + + // Set expiration if configured + if let Some(duration) = expire_after { + let expires_at = + SqliteClipboardDb::now() + duration.as_secs_f64(); + self.set_expiration(id, expires_at).ok(); + exp_queue.push(expires_at, id); + } }, Err(crate::db::StashError::ExcludedByApp(_)) => { log::info!("Clipboard entry excluded by app filter"); @@ -106,7 +265,11 @@ impl WatchCommand for SqliteClipboardDb { } }, } - Timer::after(Duration::from_millis(500)).await; + + // Normal poll interval (only if no expirations pending) + if exp_queue.peek_next().is_none() { + Timer::after(Duration::from_millis(500)).await; + } } }); } From f4936e56ffc311ca4fe22d986d1304c678282c28 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 15:14:16 +0300 Subject: [PATCH 03/60] cli: add `--expire-after` flag to watch and `--expired` flag to list Signed-off-by: NotAShelf Change-Id: I833e7bfaecb5e3254d2ea16f2b880e246a6a6964 --- src/main.rs | 100 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/src/main.rs b/src/main.rs index 28f9fb0..1bf87ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,11 @@ use std::{ env, io::{self, IsTerminal}, path::PathBuf, + time::Duration, }; use clap::{CommandFactory, Parser, Subcommand}; +use humantime::parse_duration; use inquire::Confirm; mod commands; @@ -71,6 +73,10 @@ enum Command { /// Output format: "tsv" (default) or "json" #[arg(long, value_parser = ["tsv", "json"])] format: Option, + + /// Show only expired entries (diagnostic, does not remove them) + #[arg(long)] + expired: bool, }, /// Decode and output clipboard entry by id @@ -111,7 +117,11 @@ enum Command { }, /// Start a process to watch clipboard for changes and store automatically. - Watch, + Watch { + /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). + #[arg(long, value_parser = parse_duration)] + expire_after: Option, + }, } fn report_error( @@ -186,40 +196,67 @@ fn main() -> color_eyre::eyre::Result<()> { "failed to store entry", ); }, - Some(Command::List { format }) => { - match format.as_deref() { - Some("tsv") => { - report_error( - db.list(io::stdout(), cli.preview_width), - "failed to list entries", - ); - }, - Some("json") => { - match db.list_json() { - Ok(json) => { - println!("{json}"); - }, - Err(e) => { - log::error!("failed to list entries as JSON: {e}"); - }, + Some(Command::List { format, expired }) => { + if expired { + // Diagnostic mode: show expired entries only (does not cleanup) + match db.get_expired_entries() { + Ok(entries) => { + for (id, contents, mime) in entries { + let preview = db::preview_entry( + &contents, + mime.as_deref(), + cli.preview_width, + ); + println!("{id}\t{preview}"); + } + }, + Err(e) => { + log::error!("failed to list expired entries: {e}"); + }, + } + } else { + // Normal list mode + // Cleanup expired entries when daemon is not running + if let Ok(count) = db.cleanup_expired() { + if count > 0 { + log::info!("Cleaned up {} expired entries", count); } - }, - Some(other) => { - log::error!("unsupported format: {other}"); - }, - None => { - if std::io::stdout().is_terminal() { - report_error( - db.list_tui(cli.preview_width), - "failed to list entries in TUI", - ); - } else { + } + + match format.as_deref() { + Some("tsv") => { report_error( db.list(io::stdout(), cli.preview_width), "failed to list entries", ); - } - }, + }, + Some("json") => { + match db.list_json() { + Ok(json) => { + println!("{json}"); + }, + Err(e) => { + log::error!("failed to list entries as JSON: {e}"); + }, + } + }, + Some(other) => { + log::error!("unsupported format: {other}"); + }, + None => { + if std::io::stdout().is_terminal() { + report_error( + db.list_tui(cli.preview_width), + "failed to list entries in TUI", + ); + } else { + report_error( + db.list(io::stdout(), cli.preview_width), + "failed to list entries", + ); + } + }, + } } }, Some(Command::Decode { input }) => { @@ -334,7 +371,7 @@ fn main() -> color_eyre::eyre::Result<()> { } } }, - Some(Command::Watch) => { + Some(Command::Watch { expire_after }) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -342,6 +379,7 @@ fn main() -> color_eyre::eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], + expire_after, ); }, From d40b547c07ddee30b4b8b058a88f2602f6a24940 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:07:30 +0300 Subject: [PATCH 04/60] db: add is_expired column and implement vacuum/stats commands Migrates schema to v5; `is_expired` column is added with partial index and `include_expired` parameter to `list_entries()` and `list_json()` methods. Also adds `vacuum()` and `stats()` methods for SQlite "administration", and removes `next_sequence()` from trait and impl. This has been a valuable addition to stash, as the database is now *less abstract* in the sense that user is made aware of its presence (stash wipe -> stash db wipe) and can modify it. Signed-off-by: NotAShelf Change-Id: Icfab67753d7f18e3798c0a930b16d05e6a6a6964 --- src/db/mod.rs | 177 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 122 insertions(+), 55 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 8c98ddd..f2048cd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -80,6 +80,7 @@ pub trait ClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result; fn decode_entry( &self, @@ -93,7 +94,6 @@ pub trait ClipboardDb { &self, id: i64, ) -> Result<(i64, Vec, Option), StashError>; - fn next_sequence(&self) -> i64; } #[derive(Serialize, Deserialize)] @@ -296,6 +296,49 @@ impl SqliteClipboardDb { })?; } + // Add is_expired column if it doesn't exist (v5) + if schema_version < 5 { + let has_is_expired: 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("is_expired")) + }, + ) + .unwrap_or(false); + + if !has_is_expired { + tx.execute( + "ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to add is_expired column: {e}").into(), + ) + })?; + } + + // Create index for is_expired (for filtering) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \ + WHERE is_expired = 1", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create is_expired index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 5", []).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(), @@ -311,13 +354,17 @@ impl SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn list_json(&self) -> Result { + pub fn list_json(&self, include_expired: bool) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -526,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb { &self, mut out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -680,17 +732,6 @@ impl ClipboardDb for SqliteClipboardDb { Ok((id, contents, mime)) } - - fn next_sequence(&self) -> i64 { - match self - .conn - .query_row("SELECT MAX(id) FROM clipboard", [], |row| { - row.get::<_, Option>(0) - }) { - Ok(Some(max_id)) => max_id + 1, - Ok(None) | Err(_) => 1, - } - } } impl SqliteClipboardDb { @@ -715,40 +756,6 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Trim(e.to_string().into())) } - /// Get expired entries (for --expired flag diagnostic output) - pub fn get_expired_entries( - &self, - ) -> Result, Option)>, StashError> { - let now = Self::now(); - let mut stmt = self - .conn - .prepare( - "SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \ - NULL AND expires_at <= ?1 ORDER BY expires_at ASC", - ) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query([now]) - .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().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - entries.push((id, contents, mime)); - } - Ok(entries) - } - /// Get the earliest expiration (timestamp, id) for heap initialization pub fn get_next_expiration(&self) -> Result, StashError> { match self.conn.query_row( @@ -778,6 +785,66 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Store(e.to_string().into()))?; Ok(()) } + + /// Optimize database using VACUUM + pub fn vacuum(&self) -> Result<(), StashError> { + self + .conn + .execute("VACUUM", []) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + } + + /// Get database statistics + pub fn stats(&self) -> Result { + let total: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let expired: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE is_expired = 1", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let active = total - expired; + + let with_expiration: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \ + (is_expired IS NULL OR is_expired = 0)", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + // Get database file size + let page_count: i64 = self + .conn + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let page_size: i64 = self + .conn + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let size_bytes = page_count * page_size; + let size_mb = size_bytes as f64 / 1024.0 / 1024.0; + + Ok(format!( + "Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \ + {active}\nExpired: {expired}\nWith TTL: \ + {with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \ + ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ + {page_size} bytes" + )) + } } /// Try to load a sensitive regex from systemd credential or env. From b070d4d93d158c7a9a50ed44f4300e66f1dd86bb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:52:29 +0300 Subject: [PATCH 05/60] watch: implement soft-delete behaviour for expired entries The previous `--expire-after` flag behave more like *delete* after rather than *expire*. This fixes that, and changes the behaviour to excluding expired entries from list commands and already-marked expired entries from expiration queue. Updates log messages accordingly. Signed-off-by: NotAShelf Change-Id: Ib162dff3a76e23edcdfbd1af13b01b916a6a6964 --- src/commands/watch.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index e7ac13e..018eeca 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -107,23 +107,17 @@ impl WatchCommand for SqliteClipboardDb { smol::block_on(async { log::info!("Starting clipboard watch daemon"); - // Cleanup any already-expired entries on startup - if let Ok(count) = self.cleanup_expired() { - if count > 0 { - log::info!("Cleaned up {} expired entries on startup", count); - } - } - // Build expiration queue from existing entries let mut exp_queue = ExpirationQueue::new(); if let Ok(Some((expires_at, id))) = self.get_next_expiration() { exp_queue.push(expires_at, id); - // Load remaining expirations + // Load remaining expirations (exclude already-marked expired entries) let mut stmt = self .conn .prepare( "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ - NULL ORDER BY expires_at ASC", + NULL AND (is_expired IS NULL OR is_expired = 0) ORDER BY \ + expires_at ASC", ) .ok(); if let Some(ref mut stmt) = stmt { @@ -189,11 +183,15 @@ impl WatchCommand for SqliteClipboardDb { ) .is_ok(); if exists { + // Mark as expired instead of deleting self .conn - .execute("DELETE FROM clipboard WHERE id = ?1", [id]) + .execute( + "UPDATE clipboard SET is_expired = 1 WHERE id = ?1", + [id], + ) .ok(); - log::info!("Entry {id} expired and removed"); + log::info!("Entry {id} marked as expired"); } } } else { From 2e555ee043366bd1cdc7aa059508c46dc6ebc746 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:53:44 +0300 Subject: [PATCH 06/60] commands/list: add include_expired parameter for filtering Signed-off-by: NotAShelf Change-Id: Ia1ab13345cfa5e2cf9a92f8b32a6a9826a6a6964 --- src/commands/list.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 1afab2a..25903f3 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -6,8 +6,12 @@ use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { - fn list(&self, out: impl Write, preview_width: u32) - -> Result<(), StashError>; + fn list( + &self, + out: impl Write, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { @@ -15,14 +19,21 @@ impl ListCommand for SqliteClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result<(), StashError> { - self.list_entries(out, preview_width).map(|_| ()) + self + .list_entries(out, preview_width, include_expired) + .map(|_| ()) } } impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] - pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { + pub fn list_tui( + &self, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ @@ -53,12 +64,16 @@ impl SqliteClipboardDb { use wl_clipboard_rs::copy::{MimeType, Options, Source}; // Query entries from DB + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \ + id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY last_accessed DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ - DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) From 5731fb08a523a3b2f210ea17d0d18bb8dfec8750 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:54:22 +0300 Subject: [PATCH 07/60] cli: add db subcommand Adds a `db` subcommand with `DbAction` for three new database operations: wipe, vacuum and stats. We can extend this database later, but this is a very good start for now and plays nicely with the `--expired` flag. This soft-deprecates `stash wipe` in favor of a `stash db wipe` with the addition of a new `--expired` flag that wipes the expired entries only. The `list` subcommand has also been refactored to allow for a similar `--expired` flag that lists only expired entries. Signed-off-by: NotAShelf Change-Id: I34107880185d231d207b0dab7782d5d96a6a6964 --- src/main.rs | 174 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 118 insertions(+), 56 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1bf87ba..aca9838 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,12 +99,21 @@ enum Command { }, /// Wipe all clipboard history + /// + /// DEPRECATED: Use `stash db wipe` instead + #[command(hide = true)] Wipe { /// Ask for confirmation before wiping #[arg(long)] ask: bool, }, + /// Database management operations + Db { + #[command(subcommand)] + action: DbAction, + }, + /// Import clipboard data from stdin (default: TSV format) Import { /// Explicitly specify format: "tsv" (default) @@ -124,6 +133,26 @@ enum Command { }, } +#[derive(Subcommand)] +enum DbAction { + /// Wipe database entries + Wipe { + /// Only wipe expired entries instead of all entries + #[arg(long)] + expired: bool, + + /// Ask for confirmation before wiping + #[arg(long)] + ask: bool, + }, + + /// Optimize database using VACUUM + Vacuum, + + /// Show database statistics + Stats, +} + fn report_error( result: Result, context: &str, @@ -197,66 +226,39 @@ fn main() -> color_eyre::eyre::Result<()> { ); }, Some(Command::List { format, expired }) => { - if expired { - // Diagnostic mode: show expired entries only (does not cleanup) - match db.get_expired_entries() { - Ok(entries) => { - for (id, contents, mime) in entries { - let preview = db::preview_entry( - &contents, - mime.as_deref(), - cli.preview_width, - ); - println!("{id}\t{preview}"); - } - }, - Err(e) => { - log::error!("failed to list expired entries: {e}"); - }, - } - } else { - // Normal list mode - // Cleanup expired entries when daemon is not running - if let Ok(count) = db.cleanup_expired() { - if count > 0 { - log::info!("Cleaned up {} expired entries", count); + match format.as_deref() { + Some("tsv") => { + report_error( + db.list(io::stdout(), cli.preview_width, expired), + "failed to list entries", + ); + }, + Some("json") => { + match db.list_json(expired) { + Ok(json) => { + println!("{json}"); + }, + Err(e) => { + log::error!("failed to list entries as JSON: {e}"); + }, } - } - - match format.as_deref() { - Some("tsv") => { + }, + Some(other) => { + log::error!("unsupported format: {other}"); + }, + None => { + if std::io::stdout().is_terminal() { report_error( - db.list(io::stdout(), cli.preview_width), + db.list_tui(cli.preview_width, expired), + "failed to list entries in TUI", + ); + } else { + report_error( + db.list(io::stdout(), cli.preview_width, expired), "failed to list entries", ); - }, - Some("json") => { - match db.list_json() { - Ok(json) => { - println!("{json}"); - }, - Err(e) => { - log::error!("failed to list entries as JSON: {e}"); - }, - } - }, - Some(other) => { - log::error!("unsupported format: {other}"); - }, - None => { - if std::io::stdout().is_terminal() { - report_error( - db.list_tui(cli.preview_width), - "failed to list entries in TUI", - ); - } else { - report_error( - db.list(io::stdout(), cli.preview_width), - "failed to list entries", - ); - } - }, - } + } + }, } }, Some(Command::Decode { input }) => { @@ -324,6 +326,10 @@ fn main() -> color_eyre::eyre::Result<()> { } }, Some(Command::Wipe { ask }) => { + eprintln!( + "Warning: The 'stash wipe' command is deprecated. Use 'stash db \ + wipe' instead." + ); let mut should_proceed = true; if ask { should_proceed = Confirm::new( @@ -341,6 +347,62 @@ fn main() -> color_eyre::eyre::Result<()> { } }, + Some(Command::Db { action }) => { + match action { + DbAction::Wipe { expired, ask } => { + let mut should_proceed = true; + if ask { + let message = if expired { + "Are you sure you want to wipe all expired clipboard entries?" + } else { + "Are you sure you want to wipe ALL clipboard history?" + }; + should_proceed = Confirm::new(message) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("db wipe command aborted by user."); + } + } + if should_proceed { + if expired { + match db.cleanup_expired() { + Ok(count) => { + log::info!("Wiped {} expired entries", count); + }, + Err(e) => { + log::error!("failed to wipe expired entries: {e}"); + }, + } + } else { + report_error(db.wipe(), "failed to wipe database"); + } + } + }, + DbAction::Vacuum => { + match db.vacuum() { + Ok(()) => { + log::info!("Database optimized successfully"); + }, + Err(e) => { + log::error!("failed to vacuum database: {e}"); + }, + } + }, + DbAction::Stats => { + match db.stats() { + Ok(stats) => { + println!("{}", stats); + }, + Err(e) => { + log::error!("failed to get database stats: {e}"); + }, + } + }, + } + }, + Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { From b00e9b5f3a8e4135b81820dbd1407aa0123b7822 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 18:02:38 +0300 Subject: [PATCH 08/60] watch: clear clipboard when expired entry content matches current clipboard Signed-off-by: NotAShelf Change-Id: I4bede5db16cea993ed8e8591e8d198d56a6a6964 --- src/commands/watch.rs | 71 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 018eeca..54706bb 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -6,12 +6,19 @@ use std::{ }; use smol::Timer; -use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; +use wl_clipboard_rs::{ + copy::{MimeType as CopyMimeType, Options, Source}, + paste::{ClipboardType, Seat, get_contents}, +}; use crate::db::{ClipboardDb, SqliteClipboardDb}; -/// Wrapper to provide Ord implementation for f64 by negating values. -/// This allows BinaryHeap (which is a max-heap) to function as a min-heap. +/// Wrapper to provide [`Ord`] implementation for `f64` by negating values. +/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. +/// Also see: +/// - +/// - +/// - #[derive(Debug, Clone, Copy)] struct Neg(f64); @@ -173,17 +180,18 @@ impl WatchCommand for SqliteClipboardDb { // Expired entries to process let expired_ids = exp_queue.pop_expired(now); for id in expired_ids { - // Verify entry still exists (handles stale heap entries) - let exists = self + // Verify entry still exists and get its content_hash + let expired_hash: Option = self .conn .query_row( - "SELECT 1 FROM clipboard WHERE id = ?1", + "SELECT content_hash FROM clipboard WHERE id = ?1", [id], - |_| Ok(()), + |row| row.get(0), ) - .is_ok(); - if exists { - // Mark as expired instead of deleting + .ok(); + + if let Some(stored_hash) = expired_hash { + // Mark as expired self .conn .execute( @@ -192,13 +200,52 @@ impl WatchCommand for SqliteClipboardDb { ) .ok(); log::info!("Entry {id} marked as expired"); + + // Check if this expired entry is currently in the clipboard + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + let mut current_buf = Vec::new(); + if reader.read_to_end(&mut current_buf).is_ok() + && !current_buf.is_empty() + { + let current_hash = hash_contents(¤t_buf); + // Compare as i64 (database stores as i64) + if current_hash as i64 == stored_hash { + // Clear the clipboard since expired content is still + // there + let mut opts = Options::new(); + opts.clipboard( + wl_clipboard_rs::copy::ClipboardType::Regular, + ); + if opts + .copy( + Source::Bytes(Vec::new().into()), + CopyMimeType::Autodetect, + ) + .is_ok() + { + log::info!( + "Cleared clipboard containing expired entry {id}" + ); + last_hash = None; // reset tracked hash + } else { + log::warn!( + "Failed to clear clipboard for expired entry {id}" + ); + } + } + } + } } } } else { - // Sleep precisely until next expiration (sub-second precision) + // Sleep *precisely* until next expiration let sleep_duration = next_exp - now; Timer::after(Duration::from_secs_f64(sleep_duration)).await; - continue; // Skip normal poll, process expirations first + continue; // skip normal poll, process expirations first } } From e185ecd32a7bf9151fccdb8bb66bfba27edabeda Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 18:43:17 +0300 Subject: [PATCH 09/60] docs: document entry expiry features for `stash watch` & db cmds Signed-off-by: NotAShelf Change-Id: I60fe5afdb6e903b96023ca420bb7902d6a6a6964 --- README.md | 132 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c3fd56c..72ea591 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ with many features such as but not necessarily limited to: - Import clipboard history from TSV (e.g., from `cliphist list`) - Image preview (shows dimensions and format) - Text previews with customizable width -- Deduplication and entry limit control +- De-duplication, whitespace prevention and entry limit control - Automatic clipboard monitoring with `stash watch` + - Configurable auto-expiry of old entries in watch mode as a safety buffer - Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) - Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering by application (see below) @@ -141,7 +142,7 @@ Commands: list List clipboard history decode Decode and output clipboard entry by id delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly - wipe Wipe all clipboard history + db Database management operations import Import clipboard data from stdin (default: TSV format) watch Start a process to watch clipboard for changes and store automatically help Print this message or the help of the given subcommand(s) @@ -154,7 +155,7 @@ Options: --preview-width Maximum width (in characters) for clipboard entry previews in list output [default: 100] --db-path - Path to the `SQLite` clipboard database file + Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=] --excluded-apps Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=] --ask @@ -188,6 +189,11 @@ and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will display entries in Cliphist-compatible TSV format in Bash scripts. You may also enforce the output format with `stash list --format `. +You may also view your clipboard _with the addition of expired entries_, i.e., +entries that have reached their TTL and are marked as expired, using the +`--expired` flag as `stash list --expired`. Expired entries are not cleaned up +when using this flag, allowing you to inspect them before running cleanup. + ### Decode an entry by ID ```bash @@ -219,10 +225,33 @@ stash delete --type id < ids.txt ### Wipe all entries +> [!WARNING] +> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe` +> instead. + ```bash stash wipe ``` +### Database management + +Stash provides a `db` subcommand for database maintenance operations: + +```bash +stash db wipe [--expired] [--ask] +stash db vacuum +stash db stats +``` + +- `stash db wipe`: Remove all entries from the database. Use `--expired` to only + wipe expired entries instead of all entries. Requires `--ask` confirmation by + default. +- `stash db vacuum`: Optimize the database using SQLite's VACUUM command, + reclaiming space and improving performance. +- `stash db stats`: Display database statistics including total/active/expired + entry counts, storage size, and page information. This is provided purely for + convenience and the rule of the cool. + ### Watch clipboard for changes and store automatically ```bash @@ -235,13 +264,16 @@ automatically. This is designed as an alternative to shelling out to premade Systemd service in `contrib/`. Packagers are encouraged to vendor the service unless adding their own. -> [!TIP] -> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility -> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you -> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs` -> wrappers on your system. In other words, you can use -> `wl-paste --watch stash store` as an alternative to `stash watch` if -> preferred. +#### Automatic Clipboard Clearing on Expiration + +When `stash watch` is running and a clipboard entry expires, Stash will detect +if the current clipboard still contains that expired content and automatically +clear it. This prevents stale data from remaining in your clipboard after an +entry has expired from history. + +> [!NOTE] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. ### Options @@ -406,6 +438,86 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! the packagers. While building from source, you may link `target/release/stash` manually. +### Entry Expiration + +Stash supports time-to-live (TTL) for clipboard entries. When an entry's +expiration time is reached, it is marked as expired rather than immediately +deleted. This allows for inspection of expired entries and automatic clipboard +cleanup. + +#### How Expiration Works + +When `stash watch` is running with `--expire-after`, it monitors the clipboard +and processes expired entries periodically. Upon expiration: + +1. The entry's `is_expired` flag is set to `1` in the database +2. If the current clipboard content matches the expired entry, Stash clears the + clipboard to prevent pasting stale data +3. Expired entries are excluded from normal list operations unless `--expired` + is specified + +> [!NOTE] +> By default, entries do not expire. Use `stash watch --expire-after DURATION` +> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL). + +#### Viewing Expired Entries + +Use `stash list --expired` to include expired entries in the output. This is +useful for: + +- Inspecting what has expired from your clipboard history +- Verifying that sensitive data has been properly expired +- Debugging expiration behavior + +```bash +# View all entries including expired ones +stash list --expired + +# View expired entries in JSON format +stash list --expired --format json +``` + +#### Cleaning Up Expired Entries + +The watch daemon automatically cleans up expired entries when it processes them. +For manual cleanup, use: + +```bash +# Remove all expired entries from the database +stash db wipe --expired +``` + +> [!NOTE] +> If you have a large number of expired entries, consider running +> `stash db vacuum` afterward to reclaim disk space. + +#### Automatic Clipboard Clearing + +When `stash watch` is running and an entry expires, Stash checks if the current +clipboard still contains that expired content. If it matches, Stash clears the +clipboard automatically. This prevents accidentally pasting outdated content. + +> [!TIP] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. + +#### Database Maintenance + +Stash uses SQLite for persistent storage. Over time, deleted entries and +fragmentation can affect performance. Use the `stash db` command to maintain +your database: + +- **Check statistics**: `stash db stats` shows entry counts and storage usage. + Use this to monitor growth and decide when to clean up. +- **Remove expired entries**: `stash db wipe --expired` removes entries that + have reached their TTL. The daemon normally handles this, but this is useful + for manual cleanup. +- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to + reclaim space and defragment the database. This is safe to run periodically. + +It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep +the database compact, especially after deleting many entries. + ## Attributions My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the From ff2f272055e5dca1fa3a101478467c4cbdef784a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 23 Jan 2026 22:38:21 +0300 Subject: [PATCH 10/60] mime: refactor mime detection to separate module; streamline Signed-off-by: NotAShelf Change-Id: I489054d2537a4c0de32d79f793478c206a6a6964 --- src/commands/import.rs | 10 +-- src/commands/watch.rs | 33 +++++---- src/db/mod.rs | 60 ++--------------- src/main.rs | 11 ++- src/mime.rs | 149 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 74 deletions(-) create mode 100644 src/mime.rs diff --git a/src/commands/import.rs b/src/commands/import.rs index a5b4e55..933cf88 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -1,12 +1,6 @@ use std::io::{self, BufRead}; -use crate::db::{ - ClipboardDb, - Entry, - SqliteClipboardDb, - StashError, - detect_mime, -}; +use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError}; pub trait ImportCommand { /// Import clipboard entries from TSV format. @@ -44,7 +38,7 @@ impl ImportCommand for SqliteClipboardDb { let entry = Entry { contents: val.as_bytes().to_vec(), - mime: detect_mime(val.as_bytes()), + mime: crate::mime::detect_mime(val.as_bytes()), }; self diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 54706bb..ce04495 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -100,6 +100,7 @@ pub trait WatchCommand { max_items: u64, excluded_apps: &[String], expire_after: Option, + mime_type_preference: &str, ); } @@ -110,9 +111,13 @@ impl WatchCommand for SqliteClipboardDb { max_items: u64, excluded_apps: &[String], expire_after: Option, + mime_type_preference: &str, ) { smol::block_on(async { - log::info!("Starting clipboard watch daemon"); + log::info!( + "Starting clipboard watch daemon with MIME type preference: \ + {mime_type_preference}" + ); // Build expiration queue from existing entries let mut exp_queue = ExpirationQueue::new(); @@ -160,12 +165,19 @@ impl WatchCommand for SqliteClipboardDb { hasher.finish() }; + // Convert MIME type preference string to wl_clipboard_rs enum + let mime_type = match mime_type_preference { + "text" => wl_clipboard_rs::paste::MimeType::Text, + "image" => { + wl_clipboard_rs::paste::MimeType::TextWithPriority("image/png") + }, + _ => wl_clipboard_rs::paste::MimeType::Any, + }; + // Initialize with current clipboard - if let Ok((mut reader, _)) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { + if let Ok((mut reader, _)) = + get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) + { buf.clear(); if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { last_hash = Some(hash_contents(&buf)); @@ -205,7 +217,7 @@ impl WatchCommand for SqliteClipboardDb { if let Ok((mut reader, _)) = get_contents( ClipboardType::Regular, Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, + mime_type, ) { let mut current_buf = Vec::new(); if reader.read_to_end(&mut current_buf).is_ok() @@ -250,11 +262,8 @@ impl WatchCommand for SqliteClipboardDb { } // Normal clipboard polling - match get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { + match get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) + { Ok((mut reader, _mime_type)) => { buf.clear(); if let Err(e) = reader.read_to_end(&mut buf) { diff --git a/src/db/mod.rs b/src/db/mod.rs index f2048cd..8999bd5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -10,7 +10,6 @@ use std::{ }; use base64::prelude::*; -use imagesize::ImageType; use log::{debug, error, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; @@ -429,7 +428,7 @@ impl ClipboardDb for SqliteClipboardDb { #[allow(clippy::cast_possible_wrap)] let content_hash = hasher.finish() as i64; - let mime = detect_mime_optimized(&buf); + let mime = crate::mime::detect_mime(&buf); // Try to load regex from systemd credential file, then env var let regex = load_sensitive_regex(); @@ -884,51 +883,6 @@ 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) { - 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(imagesize::Compression::Hevc) => "image/heic", - ImageType::Heif(_) => "image/heif", - _ => "application/octet-stream", - }; - Some(mime_str.to_string()) - } else { - None - } -} - pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { if let Some(mime) = mime { if mime.starts_with("image/") { @@ -1239,7 +1193,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); @@ -1290,7 +1244,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); @@ -1332,7 +1286,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); @@ -1375,7 +1329,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); @@ -1411,7 +1365,7 @@ mod tests { get_schema_version(&db2.conn).expect("Failed to get version"); assert_eq!(version_after_first, version_after_second); - assert_eq!(version_after_first, 3); + assert_eq!(version_after_first, 5); } #[test] @@ -1540,7 +1494,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn).expect("Failed to get version"), - 3 + 5 ); let count: i64 = db diff --git a/src/main.rs b/src/main.rs index aca9838..56c2170 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use inquire::Confirm; mod commands; pub(crate) mod db; +pub(crate) mod mime; mod multicall; #[cfg(feature = "use-toplevel")] mod wayland; @@ -130,6 +131,10 @@ enum Command { /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). #[arg(long, value_parser = parse_duration)] expire_after: Option, + + /// MIME type preference for clipboard reading. + #[arg(short = 't', long, default_value = "any")] + mime_type: String, }, } @@ -433,7 +438,10 @@ fn main() -> color_eyre::eyre::Result<()> { } } }, - Some(Command::Watch { expire_after }) => { + Some(Command::Watch { + expire_after, + mime_type, + }) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -442,6 +450,7 @@ fn main() -> color_eyre::eyre::Result<()> { #[cfg(not(feature = "use-toplevel"))] &[], expire_after, + &mime_type, ); }, diff --git a/src/mime.rs b/src/mime.rs new file mode 100644 index 0000000..fd9c448 --- /dev/null +++ b/src/mime.rs @@ -0,0 +1,149 @@ +use imagesize::ImageType; + +/// Detect MIME type of clipboard data. We try binary detection first using +/// [`imagesize`] followed by a check for text/uri-list for file manager copies +/// and finally fall back to text/plain for UTF-8 or [`None`] for binary. +pub fn detect_mime(data: &[u8]) -> Option { + if data.is_empty() { + return None; + } + + // Try image detection first + if let Ok(img_type) = imagesize::image_type(data) { + return Some(image_type_to_mime(img_type)); + } + + // Check if it's UTF-8 text + if let Ok(text) = std::str::from_utf8(data) { + let trimmed = text.trim(); + + // Check for text/uri-list format (file paths from file managers) + if is_uri_list(trimmed) { + return Some("text/uri-list".to_string()); + } + + // Default to plain text + return Some("text/plain".to_string()); + } + + // Unknown binary data + None +} + +/// Convert [`imagesize`] [`ImageType`] to MIME type string +fn image_type_to_mime(img_type: ImageType) -> String { + let mime = 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(imagesize::Compression::Hevc) => "image/heic", + ImageType::Heif(_) => "image/heif", + _ => "application/octet-stream", + }; + mime.to_string() +} + +/// Check if text is a URI list per RFC 2483. +/// +/// Used when copying files from file managers - they provide file paths +/// as text/uri-list format (`file://` URIs, one per line, `#` for comments). +fn is_uri_list(text: &str) -> bool { + if text.is_empty() { + return false; + } + + // Must start with a URI scheme to even consider it + if !text.starts_with("file://") + && !text.starts_with("http://") + && !text.starts_with("https://") + && !text.starts_with("ftp://") + && !text.starts_with('#') + { + return false; + } + + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + + // Check first non-comment line is a URI + let first_content = + lines.iter().find(|l| !l.is_empty() && !l.starts_with('#')); + + if let Some(line) = first_content { + line.starts_with("file://") + || line.starts_with("http://") + || line.starts_with("https://") + || line.starts_with("ftp://") + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_data() { + assert_eq!(detect_mime(b""), None); + } + + #[test] + fn test_plain_text() { + let data = b"Hello, world!"; + assert_eq!(detect_mime(data), Some("text/plain".to_string())); + } + + #[test] + fn test_uri_list_single_file() { + let data = b"file:///home/user/document.pdf"; + assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); + } + + #[test] + fn test_uri_list_multiple_files() { + let data = b"file:///home/user/file1.txt\nfile:///home/user/file2.txt"; + assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); + } + + #[test] + fn test_uri_list_with_comments() { + let data = b"# Comment\nfile:///home/user/file.txt"; + assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); + } + + #[test] + fn test_uri_list_http() { + let data = b"https://example.com/image.png"; + assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); + } + + #[test] + fn test_not_uri_list() { + let data = b"This is just text with file:// in the middle"; + assert_eq!(detect_mime(data), Some("text/plain".to_string())); + } + + #[test] + fn test_unknown_binary() { + // Binary data that's not UTF-8 and not a known format + let data = b"\x80\x81\x82\x83\x84\x85\x86\x87"; + assert_eq!(detect_mime(data), None); + } +} From 5c8591b2e53eea200eb7a5c2668609292e757105 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 23 Jan 2026 23:10:58 +0300 Subject: [PATCH 11/60] docs: mention MIME preference usage in README Signed-off-by: NotAShelf Change-Id: I3bda3397f0350f27523b419bd079f8756a6a6964 --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 72ea591..faabc1c 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,30 @@ entry has expired from history. > This behavior only applies when the watch daemon is actively running. Manual > expiration or deletion of entries will not clear the clipboard. +### MIME Type Preference for Watch + +`stash watch` supports a `--mime-type` (short `-t`) option that lets you +prioritise which MIME type the daemon should request from the clipboard when +multiple representations are available. + +- `any` (default): Request any available representation (current behaviour). +- `text`: Prefer text representations (e.g. `text/plain`, `text/html`). +- `image`: Prefer image representations (e.g. `image/png`, `image/jpeg`) so that + image copies from browsers or file managers are stored as images rather than + HTML fragments. + +Example: prefer images when running the watch daemon + +```bash +stash watch --mime-type image +``` + +This is useful when copying images from browsers or file managers where the +clipboard may offer both HTML and image representations; selecting `image` will +ask the compositor for image data first. Most users will be fine using the +default value (`any`) but in the case your browser (or other applications!) +regularly misrepresent data, you might wish to prioritize a different type. + ### Options Some commands take additional flags to modify Stash's behavior. See each From bb8e88256581d56d9fccfd43268cbfe354dfbff1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 12:12:19 +0300 Subject: [PATCH 12/60] mime: expand test coverage Signed-off-by: NotAShelf Change-Id: I3f17b98ad68f17ebcf9554e5e88f62676a6a6964 --- src/mime.rs | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/mime.rs b/src/mime.rs index fd9c448..3761ab3 100644 --- a/src/mime.rs +++ b/src/mime.rs @@ -146,4 +146,128 @@ mod tests { let data = b"\x80\x81\x82\x83\x84\x85\x86\x87"; assert_eq!(detect_mime(data), None); } + + #[test] + fn test_uri_list_trailing_newline() { + let data = b"file:///foo\n"; + assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); + } + + #[test] + fn test_uri_list_ftp() { + let data = b"ftp://host/path"; + assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); + } + + #[test] + fn test_uri_list_mixed_schemes() { + let data = b"file:///home/user/doc.pdf\nhttps://example.com/file.zip"; + assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); + } + + #[test] + fn test_plain_url_in_text() { + let data = b"visit http://example.com for info"; + assert_eq!(detect_mime(data), Some("text/plain".to_string())); + } + + #[test] + fn test_png_magic_bytes() { + // Real PNG header: 8-byte signature + minimal IHDR chunk + let data: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // width: 1 + 0x00, 0x00, 0x00, 0x01, // height: 1 + 0x08, 0x02, // bit depth: 8, color type: 2 (RGB) + 0x00, 0x00, 0x00, // compression, filter, interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + ]; + assert_eq!(detect_mime(data), Some("image/png".to_string())); + } + + #[test] + fn test_jpeg_magic_bytes() { + // JPEG SOI marker + APP0 (JFIF) marker + let data: &[u8] = &[ + 0xFF, 0xD8, 0xFF, 0xE0, // SOI + APP0 + 0x00, 0x10, // Length + 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" + 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + ]; + assert_eq!(detect_mime(data), Some("image/jpeg".to_string())); + } + + #[test] + fn test_gif_magic_bytes() { + // GIF89a header + let data: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a" + 0x01, 0x00, 0x01, 0x00, // 1x1 + 0x80, 0x00, 0x00, // GCT flag, bg, aspect + ]; + assert_eq!(detect_mime(data), Some("image/gif".to_string())); + } + + #[test] + fn test_webp_magic_bytes() { + // RIFF....WEBP header + let data: &[u8] = &[ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x24, 0x00, 0x00, 0x00, // file size + 0x57, 0x45, 0x42, 0x50, // "WEBP" + 0x56, 0x50, 0x38, 0x20, // "VP8 " + 0x18, 0x00, 0x00, 0x00, // chunk size + 0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A, // VP8 bitstream + 0x01, 0x00, 0x01, 0x00, // width/height + ]; + assert_eq!(detect_mime(data), Some("image/webp".to_string())); + } + + #[test] + fn test_whitespace_only() { + let data = b" \n\t "; + // Valid UTF-8 text, even if only whitespace. [`detect_mime`] doesn't reject + // it (store_entry rejects it separately). As text it's text/plain. + assert_eq!(detect_mime(data), Some("text/plain".to_string())); + } + + #[test] + fn test_image_type_to_mime_coverage() { + assert_eq!(image_type_to_mime(ImageType::Png), "image/png"); + assert_eq!(image_type_to_mime(ImageType::Jpeg), "image/jpeg"); + assert_eq!(image_type_to_mime(ImageType::Gif), "image/gif"); + assert_eq!(image_type_to_mime(ImageType::Bmp), "image/bmp"); + assert_eq!(image_type_to_mime(ImageType::Tiff), "image/tiff"); + assert_eq!(image_type_to_mime(ImageType::Webp), "image/webp"); + assert_eq!(image_type_to_mime(ImageType::Aseprite), "image/x-aseprite"); + assert_eq!(image_type_to_mime(ImageType::Dds), "image/vnd.ms-dds"); + assert_eq!(image_type_to_mime(ImageType::Exr), "image/aces"); + assert_eq!(image_type_to_mime(ImageType::Farbfeld), "image/farbfeld"); + assert_eq!(image_type_to_mime(ImageType::Hdr), "image/vnd.radiance"); + assert_eq!(image_type_to_mime(ImageType::Ico), "image/x-icon"); + assert_eq!(image_type_to_mime(ImageType::Ilbm), "image/ilbm"); + assert_eq!(image_type_to_mime(ImageType::Jxl), "image/jxl"); + assert_eq!(image_type_to_mime(ImageType::Ktx2), "image/ktx2"); + assert_eq!( + image_type_to_mime(ImageType::Pnm), + "image/x-portable-anymap" + ); + assert_eq!( + image_type_to_mime(ImageType::Psd), + "image/vnd.adobe.photoshop" + ); + assert_eq!(image_type_to_mime(ImageType::Qoi), "image/qoi"); + assert_eq!(image_type_to_mime(ImageType::Tga), "image/x-tga"); + assert_eq!(image_type_to_mime(ImageType::Vtf), "image/x-vtf"); + assert_eq!( + image_type_to_mime(ImageType::Heif(imagesize::Compression::Hevc)), + "image/heic" + ); + assert_eq!( + image_type_to_mime(ImageType::Heif(imagesize::Compression::Av1)), + "image/heif" + ); + } } From b4dd70496157c5d01049d2d56ef6e68ab9313968 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 12:47:26 +0300 Subject: [PATCH 13/60] db: add an in-memory test helper Signed-off-by: NotAShelf Change-Id: I22cc10df47265fa4d08d5c03cadbe9c56a6a6964 --- src/db/mod.rs | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/src/db/mod.rs b/src/db/mod.rs index 8999bd5..4b57ae5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1161,6 +1161,13 @@ mod tests { use super::*; + /// Create an in-memory test database with full schema. + fn test_db() -> SqliteClipboardDb { + let conn = + Connection::open_in_memory().expect("Failed to open in-memory db"); + SqliteClipboardDb::new(conn).expect("Failed to create test database") + } + fn get_schema_version(conn: &Connection) -> rusqlite::Result { conn.pragma_query_value(None, "user_version", |row| row.get(0)) } @@ -1503,4 +1510,260 @@ mod tests { .expect("Failed to count"); assert_eq!(count, 1, "Existing data should be preserved"); } + + #[test] + fn test_store_uri_list_content() { + let db = test_db(); + let data = b"file:///home/user/document.pdf\nfile:///home/user/image.png"; + let id = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store URI list"); + + let mime: Option = db + .conn + .query_row("SELECT mime FROM clipboard WHERE id = ?1", [id], |row| { + row.get(0) + }) + .expect("Failed to get mime"); + assert_eq!(mime, Some("text/uri-list".to_string())); + } + + #[test] + fn test_store_binary_image() { + let db = test_db(); + // Minimal PNG header + let data: Vec = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // width: 1 + 0x00, 0x00, 0x00, 0x01, // height: 1 + 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color, etc. + 0x90, 0x77, 0x53, 0xDE, // CRC + ]; + let id = db + .store_entry(std::io::Cursor::new(data.clone()), 100, 1000, None) + .expect("Failed to store image"); + + let (contents, mime): (Vec, Option) = db + .conn + .query_row( + "SELECT contents, mime FROM clipboard WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("Failed to get stored entry"); + assert_eq!(contents, data); + assert_eq!(mime, Some("image/png".to_string())); + } + + #[test] + fn test_deduplication() { + let db = test_db(); + let data = b"duplicate content"; + + let id1 = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store first"); + let _id2 = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store second"); + + // First entry should have been removed by deduplication + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Deduplication should keep only one copy"); + + // The original id should be gone + let exists: bool = db + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE id = ?1", + [id1], + |row| row.get::<_, i64>(0), + ) + .map(|c| c > 0) + .unwrap_or(false); + assert!(!exists, "Old entry should be removed"); + } + + #[test] + fn test_trim_excess_entries() { + let db = test_db(); + for i in 0..5 { + let data = format!("entry {i}"); + db.store_entry( + std::io::Cursor::new(data.into_bytes()), + 100, + 3, // max 3 items + None, + ) + .expect("Failed to store"); + } + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert!(count <= 3, "Trim should keep at most max_items entries"); + } + + #[test] + fn test_reject_empty_input() { + let db = test_db(); + let result = + db.store_entry(std::io::Cursor::new(Vec::new()), 100, 1000, None); + assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); + } + + #[test] + fn test_reject_whitespace_input() { + let db = test_db(); + let result = db.store_entry( + std::io::Cursor::new(b" \n\t ".to_vec()), + 100, + 1000, + None, + ); + assert!(matches!(result, Err(StashError::AllWhitespace))); + } + + #[test] + fn test_reject_oversized_input() { + let db = test_db(); + // 5MB + 1 byte + let data = vec![b'a'; 5 * 1_000_000 + 1]; + let result = db.store_entry(std::io::Cursor::new(data), 100, 1000, None); + assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); + } + + #[test] + fn test_delete_entries_by_id() { + let db = test_db(); + let id = db + .store_entry(std::io::Cursor::new(b"to delete".to_vec()), 100, 1000, None) + .expect("Failed to store"); + + let input = format!("{id}\tpreview text\n"); + let deleted = db + .delete_entries(std::io::Cursor::new(input.into_bytes())) + .expect("Failed to delete"); + assert_eq!(deleted, 1); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 0); + } + + #[test] + fn test_delete_query_matching() { + let db = test_db(); + db.store_entry( + std::io::Cursor::new(b"secret password 123".to_vec()), + 100, + 1000, + None, + ) + .expect("Failed to store"); + db.store_entry( + std::io::Cursor::new(b"normal text".to_vec()), + 100, + 1000, + None, + ) + .expect("Failed to store"); + + let deleted = db + .delete_query("secret password") + .expect("Failed to delete query"); + assert_eq!(deleted, 1); + + 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_wipe_db() { + let db = test_db(); + for i in 0..3 { + let data = format!("entry {i}"); + db.store_entry(std::io::Cursor::new(data.into_bytes()), 100, 1000, None) + .expect("Failed to store"); + } + + db.wipe_db().expect("Failed to wipe"); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 0); + } + + #[test] + fn test_extract_id_valid() { + assert_eq!(extract_id("42\tsome preview"), Ok(42)); + assert_eq!(extract_id("1"), Ok(1)); + assert_eq!(extract_id("999\t"), Ok(999)); + } + + #[test] + fn test_extract_id_invalid() { + assert!(extract_id("abc\tpreview").is_err()); + assert!(extract_id("").is_err()); + assert!(extract_id("\tpreview").is_err()); + } + + #[test] + fn test_preview_entry_text() { + let data = b"Hello, world!"; + let preview = preview_entry(data, Some("text/plain"), 100); + assert_eq!(preview, "Hello, world!"); + } + + #[test] + fn test_preview_entry_image() { + let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG-ish bytes + let preview = preview_entry(&data, Some("image/png"), 100); + assert!(preview.contains("binary data")); + assert!(preview.contains("image/png")); + } + + #[test] + fn test_preview_entry_truncation() { + let data = b"This is a rather long piece of text that should be truncated"; + let preview = preview_entry(data, Some("text/plain"), 10); + assert!(preview.len() <= 15); // 10 chars + ellipsis (multi-byte) + assert!(preview.ends_with('…')); + } + + #[test] + fn test_size_str_formatting() { + assert_eq!(size_str(0), "0 B"); + assert_eq!(size_str(512), "512 B"); + assert_eq!(size_str(1024), "1 KiB"); + assert_eq!(size_str(1024 * 1024), "1 MiB"); + } + + #[test] + fn test_copy_entry_returns_data() { + let db = test_db(); + let data = b"copy me"; + let id = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store"); + + let (returned_id, contents, mime) = + db.copy_entry(id).expect("Failed to copy"); + assert_eq!(returned_id, id); + assert_eq!(contents, data.to_vec()); + assert_eq!(mime, Some("text/plain".to_string())); + } } From 3fd48896c122ba7872dbf4a7bbadaf62b053a7cd Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 12:47:56 +0300 Subject: [PATCH 14/60] watch: respect source MIME type order in clipboard polling Signed-off-by: NotAShelf Change-Id: I3da2e187276611579f3686acb20aacf36a6a6964 --- src/commands/watch.rs | 162 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 21 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ce04495..3a7b7b2 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -8,7 +8,13 @@ use std::{ use smol::Timer; use wl_clipboard_rs::{ copy::{MimeType as CopyMimeType, Options, Source}, - paste::{ClipboardType, Seat, get_contents}, + paste::{ + ClipboardType, + MimeType as PasteMimeType, + Seat, + get_contents, + get_mime_types_ordered, + }, }; use crate::db::{ClipboardDb, SqliteClipboardDb}; @@ -93,6 +99,63 @@ impl ExpirationQueue { } } +/// Get clipboard contents using the source application's preferred MIME type. +/// +/// See, `MimeType::Any` lets wl-clipboard-rs pick a type in arbitrary order, +/// which causes issues when applications offer multiple types (e.g. file +/// managers offering `text/uri-list` + `text/plain`, or Firefox offering +/// `text/html` + `image/png` + `text/plain`). +/// +/// This queries the ordered types via [`get_mime_types_ordered`], which +/// preserves the Wayland protocol's offer order (source application's +/// preference) and then requests the first type with [`MimeType::Specific`]. +/// +/// The two-step approach has a theoretical race (clipboard could change between +/// the calls), but the wl-clipboard-rs API has no single-call variant that +/// respects source ordering. A race simply produces an error that the polling +/// loop handles like any other clipboard-empty/error case. +/// +/// When `preference` is `"text"`, uses `MimeType::Text` directly (single call). +/// When `preference` is `"image"`, picks the first offered `image/*` type. +/// Otherwise picks the source's first offered type. +fn negotiate_mime_type( + preference: &str, +) -> Result<(Box, String), wl_clipboard_rs::paste::Error> { + if preference == "text" { + let (reader, mime_str) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + PasteMimeType::Text, + )?; + return Ok((Box::new(reader) as Box, mime_str)); + } + + let offered = + get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?; + + let chosen = if preference == "image" { + // Pick the first offered image type, fall back to first overall + offered + .iter() + .find(|m| m.starts_with("image/")) + .or_else(|| offered.first()) + } else { + offered.first() + }; + + match chosen { + Some(mime_str) => { + let (reader, actual_mime) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + PasteMimeType::Specific(mime_str), + )?; + Ok((Box::new(reader) as Box, actual_mime)) + }, + None => Err(wl_clipboard_rs::paste::Error::NoSeats), + } +} + pub trait WatchCommand { fn watch( &self, @@ -165,19 +228,8 @@ impl WatchCommand for SqliteClipboardDb { hasher.finish() }; - // Convert MIME type preference string to wl_clipboard_rs enum - let mime_type = match mime_type_preference { - "text" => wl_clipboard_rs::paste::MimeType::Text, - "image" => { - wl_clipboard_rs::paste::MimeType::TextWithPriority("image/png") - }, - _ => wl_clipboard_rs::paste::MimeType::Any, - }; - - // Initialize with current clipboard - if let Ok((mut reader, _)) = - get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) - { + // Initialize with current clipboard using smart MIME negotiation + if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) { buf.clear(); if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { last_hash = Some(hash_contents(&buf)); @@ -214,11 +266,9 @@ impl WatchCommand for SqliteClipboardDb { log::info!("Entry {id} marked as expired"); // Check if this expired entry is currently in the clipboard - if let Ok((mut reader, _)) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - mime_type, - ) { + if let Ok((mut reader, _)) = + negotiate_mime_type(mime_type_preference) + { let mut current_buf = Vec::new(); if reader.read_to_end(&mut current_buf).is_ok() && !current_buf.is_empty() @@ -262,8 +312,7 @@ impl WatchCommand for SqliteClipboardDb { } // Normal clipboard polling - match get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) - { + match negotiate_mime_type(mime_type_preference) { Ok((mut reader, _mime_type)) => { buf.clear(); if let Err(e) = reader.read_to_end(&mut buf) { @@ -328,3 +377,74 @@ impl WatchCommand for SqliteClipboardDb { }); } } + +/// Unit-testable helper: given ordered offers and a preference, return the +/// chosen MIME type. This mirrors the selection logic in +/// [`negotiate_mime_type`] without requiring a Wayland connection. +#[cfg(test)] +fn pick_mime<'a>( + offered: &'a [String], + preference: &str, +) -> Option<&'a String> { + if preference == "image" { + offered + .iter() + .find(|m| m.starts_with("image/")) + .or_else(|| offered.first()) + } else { + offered.first() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pick_first_offered() { + let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list"); + } + + #[test] + fn test_pick_image_preference_finds_image() { + let offered = vec![ + "text/html".to_string(), + "image/png".to_string(), + "text/plain".to_string(), + ]; + assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png"); + } + + #[test] + fn test_pick_image_preference_falls_back() { + let offered = vec!["text/html".to_string(), "text/plain".to_string()]; + // No image types offered — falls back to first + assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); + } + + #[test] + fn test_pick_empty_offered() { + let offered: Vec = vec![]; + assert!(pick_mime(&offered, "any").is_none()); + } + + #[test] + fn test_pick_source_order_preserved() { + // Firefox typically offers html first, then image, then text + let offered = vec![ + "text/html".to_string(), + "image/png".to_string(), + "text/plain".to_string(), + ]; + // With "any", we trust the source: first offered wins + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); + } + + #[test] + fn test_pick_file_manager_uri_list_first() { + // File managers typically offer uri-list first + let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list"); + } +} From 9afbe9ceca9d2830fbff20f34be8f2d20e344ab1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 12:25:17 +0300 Subject: [PATCH 15/60] watch: deprioritize text/html in MIME negotiation Firefox and Electron apps offer `text/html` first when copying images, which causes stash to store the HTML wrapper (``) instead of the actual image data, which is what we want. We handicap, i.e., deprioritize `text/html` in the "any" preference mode and prefer `image/*` types first, then any non-html type. This sounds a little illogical, but in user will almost always prefer the image itself rather than the text representation. So it's intuitive. Signed-off-by: NotAShelf Change-Id: I6bd5969344893e15226c27071442475f6a6a6964 --- src/commands/watch.rs | 63 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 3a7b7b2..54dc803 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -140,7 +140,26 @@ fn negotiate_mime_type( .find(|m| m.starts_with("image/")) .or_else(|| offered.first()) } else { - offered.first() + // XXX: When preference is "any", deprioritize text/html if a more + // concrete type is available. Browsers and Electron apps put + // text/html first even for "Copy Image", but the HTML is just + // a wrapper (), i.e., never what the user wants in a + // clipboard manager. Prefer image/* first, then any non-html + // type, and fall back to text/html only as a last resort. + let has_image = offered.iter().any(|m| m.starts_with("image/")); + if has_image { + offered + .iter() + .find(|m| m.starts_with("image/")) + .or_else(|| offered.first()) + } else if offered.first().is_some_and(|m| m == "text/html") { + offered + .iter() + .find(|m| *m != "text/html") + .or_else(|| offered.first()) + } else { + offered.first() + } }; match chosen { @@ -392,7 +411,20 @@ fn pick_mime<'a>( .find(|m| m.starts_with("image/")) .or_else(|| offered.first()) } else { - offered.first() + let has_image = offered.iter().any(|m| m.starts_with("image/")); + if has_image { + offered + .iter() + .find(|m| m.starts_with("image/")) + .or_else(|| offered.first()) + } else if offered.first().is_some_and(|m| m == "text/html") { + offered + .iter() + .find(|m| *m != "text/html") + .or_else(|| offered.first()) + } else { + offered.first() + } } } @@ -430,17 +462,38 @@ mod tests { } #[test] - fn test_pick_source_order_preserved() { - // Firefox typically offers html first, then image, then text + fn test_pick_image_over_html_firefox_copy_image() { + // Firefox "Copy Image" offers html first, then image, then text. + // We should pick the image, not the html wrapper. let offered = vec![ "text/html".to_string(), "image/png".to_string(), "text/plain".to_string(), ]; - // With "any", we trust the source: first offered wins + assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); + } + + #[test] + fn test_pick_image_over_html_electron() { + // Electron apps also put text/html before image types + let offered = vec!["text/html".to_string(), "image/jpeg".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "image/jpeg"); + } + + #[test] + fn test_pick_html_fallback_when_only_html() { + // When text/html is the only type, pick it + let offered = vec!["text/html".to_string()]; assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); } + #[test] + fn test_pick_text_over_html_when_no_image() { + // Rich text copy: html + plain, no image — prefer plain text + let offered = vec!["text/html".to_string(), "text/plain".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain"); + } + #[test] fn test_pick_file_manager_uri_list_first() { // File managers typically offer uri-list first From cff9f7bbbac538a010208edca82c4c214363aa7c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 18:06:43 +0300 Subject: [PATCH 16/60] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: Ib0445df9b8e5f0d4aabfcd4ff1bc27f16a6a6964 --- Cargo.lock | 12 ++++++------ Cargo.toml | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26321a7..0164c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.54" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" dependencies = [ "clap_builder", "clap_derive", @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" dependencies = [ "anstream", "anstyle", @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index cd21891..7e6dade 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" -clap = { version = "4.5.54", features = ["derive", "env"] } +clap = { version = "4.5.56", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" crossterm = "0.29.0" @@ -44,7 +44,7 @@ wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional wl-clipboard-rs = "0.9.3" [dev-dependencies] -tempfile = "3.18.0" +tempfile = "3.24.0" [features] default = ["notifications", "use-toplevel"] From 2e086800d0f4fd078a0bc3b455982cf0cec32bd5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 18:06:57 +0300 Subject: [PATCH 17/60] chore: format TOML with Taplo Signed-off-by: NotAShelf Change-Id: I2ecc8923946ace5288a1c45ca202cb956a6a6964 --- .taplo.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .taplo.toml diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..b19e6b9 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,14 @@ +[formatting] +align_entries = true +column_width = 110 +compact_arrays = false +reorder_inline_tables = false +reorder_keys = true + +[[rule]] +include = [ "**/Cargo.toml" ] +keys = [ "package" ] + +[rule.formatting] +reorder_keys = false + From 2227ef7e8992943fcaa00199473fbdb4b4119356 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 18:08:14 +0300 Subject: [PATCH 18/60] chore: format Cargo manifest with Taplo; v0.3.5 Signed-off-by: NotAShelf Change-Id: Id35b12bba16b0e181bb4536154259b5a6a6a6964 --- Cargo.lock | 2 +- Cargo.toml | 78 ++++++++++++++++++++++++++---------------------------- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0164c7c..c2882f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,7 +2124,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.4" +version = "0.3.5" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7e6dade..d2bfe15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "stash-clipboard" -description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.4" -edition = "2024" -authors = ["NotAShelf "] -license = "MPL-2.0" -readme = true -repository = "https://github.com/notashelf/stash" +name = "stash-clipboard" +description = "Wayland clipboard manager with fast persistent history and multi-media support" +version = "0.3.5" +edition = "2024" +authors = [ "NotAShelf " ] +license = "MPL-2.0" +readme = true +repository = "https://github.com/notashelf/stash" rust-version = "1.90" [[bin]] @@ -14,44 +14,42 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] -base64 = "0.22.1" -clap = { version = "4.5.56", features = ["derive", "env"] } -clap-verbosity-flag = "3.0.4" -color-eyre = "0.6.5" -crossterm = "0.29.0" -ctrlc = "3.5.1" -dirs = "6.0.0" -env_logger = "0.11.8" -humantime = "2.3.0" -imagesize = "0.14.0" -inquire = { version = "0.9.2", default-features = false, features = [ - "crossterm", -] } -libc = "0.2.180" -log = "0.4.29" -notify-rust = { version = "4.11.7", optional = true } -ratatui = "0.30.0" -regex = "1.12.2" -rusqlite = { version = "0.38.0", features = ["bundled"] } -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -smol = "2.0.2" -thiserror = "2.0.18" -unicode-segmentation = "1.12.0" -unicode-width = "0.2.2" -wayland-client = { version = "0.31.12", features = ["log"], optional = true } +base64 = "0.22.1" +clap = { version = "4.5.56", features = [ "derive", "env" ] } +clap-verbosity-flag = "3.0.4" +color-eyre = "0.6.5" +crossterm = "0.29.0" +ctrlc = "3.5.1" +dirs = "6.0.0" +env_logger = "0.11.8" +humantime = "2.3.0" +imagesize = "0.14.0" +inquire = { version = "0.9.2", default-features = false, features = [ "crossterm" ] } +libc = "0.2.180" +log = "0.4.29" +notify-rust = { version = "4.11.7", optional = true } +ratatui = "0.30.0" +regex = "1.12.2" +rusqlite = { version = "0.38.0", features = [ "bundled" ] } +serde = { version = "1.0.228", features = [ "derive" ] } +serde_json = "1.0.149" +smol = "2.0.2" +thiserror = "2.0.18" +unicode-segmentation = "1.12.0" +unicode-width = "0.2.2" +wayland-client = { version = "0.31.12", features = [ "log" ], optional = true } wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true } -wl-clipboard-rs = "0.9.3" +wl-clipboard-rs = "0.9.3" [dev-dependencies] tempfile = "3.24.0" [features] -default = ["notifications", "use-toplevel"] -notifications = ["dep:notify-rust"] -use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] +default = [ "notifications", "use-toplevel" ] +notifications = [ "dep:notify-rust" ] +use-toplevel = [ "dep:wayland-client", "dep:wayland-protocols-wlr" ] [profile.release] +lto = true opt-level = "z" -strip = true -lto = true +strip = true From 134da06fd0e4a0713f51d0b8ecfe7987cdb205d9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 07:53:51 +0300 Subject: [PATCH 19/60] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: I53a5279d1c3e74ae54e2f32a800f83766a6a6964 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 12 ++++++------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2882f3..3c67fbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.56" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.56" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -430,9 +430,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "color-eyre" @@ -1056,9 +1056,9 @@ dependencies = [ [[package]] name = "inquire" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ "bitflags 2.10.0", "crossterm", @@ -1160,9 +1160,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" @@ -1196,9 +1196,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litrs" @@ -1346,9 +1346,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.11.7" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" dependencies = [ "futures-lite", "log", @@ -1860,9 +1860,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1929,9 +1929,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", @@ -2224,9 +2224,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index d2bfe15..8ef6a3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" -clap = { version = "4.5.56", features = [ "derive", "env" ] } +clap = { version = "4.5.60", features = [ "derive", "env" ] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" crossterm = "0.29.0" @@ -24,12 +24,12 @@ dirs = "6.0.0" env_logger = "0.11.8" humantime = "2.3.0" imagesize = "0.14.0" -inquire = { version = "0.9.2", default-features = false, features = [ "crossterm" ] } -libc = "0.2.180" +inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } +libc = "0.2.182" log = "0.4.29" -notify-rust = { version = "4.11.7", optional = true } +notify-rust = { version = "4.12.0", optional = true } ratatui = "0.30.0" -regex = "1.12.2" +regex = "1.12.3" rusqlite = { version = "0.38.0", features = [ "bundled" ] } serde = { version = "1.0.228", features = [ "derive" ] } serde_json = "1.0.149" @@ -42,7 +42,7 @@ wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional wl-clipboard-rs = "0.9.3" [dev-dependencies] -tempfile = "3.24.0" +tempfile = "3.26.0" [features] default = [ "notifications", "use-toplevel" ] From 2edecf4c17a77c93bb858fba5d00f9a78ee9021c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 07:54:18 +0300 Subject: [PATCH 20/60] chore: format with taplo Signed-off-by: NotAShelf Change-Id: I942883a08eccc5decd38a6865b3451496a6a6964 --- .rustfmt.toml | 46 +++++++++++++++++++++++----------------------- .taplo.toml | 1 - 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.rustfmt.toml b/.rustfmt.toml index 324bf8b..9d5c77e 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,26 +1,26 @@ -condense_wildcard_suffixes = true +condense_wildcard_suffixes = true doc_comment_code_block_width = 80 -edition = "2024" # Keep in sync with Cargo.toml. +edition = "2024" # Keep in sync with Cargo.toml. enum_discrim_align_threshold = 60 -force_explicit_abi = false -force_multiline_blocks = true -format_code_in_doc_comments = true -format_macro_matchers = true -format_strings = true -group_imports = "StdExternalCrate" -hex_literal_case = "Upper" -imports_granularity = "Crate" -imports_layout = "HorizontalVertical" -inline_attribute_width = 60 -match_block_trailing_comma = true -max_width = 80 -newline_style = "Unix" -normalize_comments = true -normalize_doc_attributes = true -overflow_delimited_expr = true +force_explicit_abi = false +force_multiline_blocks = true +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Upper" +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" +inline_attribute_width = 60 +match_block_trailing_comma = true +max_width = 80 +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true struct_field_align_threshold = 60 -tab_spaces = 2 -unstable_features = true -use_field_init_shorthand = true -use_try_shorthand = true -wrap_comments = true +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true diff --git a/.taplo.toml b/.taplo.toml index b19e6b9..fae0c57 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -11,4 +11,3 @@ keys = [ "package" ] [rule.formatting] reorder_keys = false - From d367728b39206fb298bc8b7ef4c6c36d84c59c39 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 07:54:51 +0300 Subject: [PATCH 21/60] chore: set MSRV to 1.91.0 Signed-off-by: NotAShelf Change-Id: Iadde6dfe7e79a365edf4d664b941c0776a6a6964 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8ef6a3f..51e12ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = [ "NotAShelf " ] license = "MPL-2.0" readme = true repository = "https://github.com/notashelf/stash" -rust-version = "1.90" +rust-version = "1.91.0" [[bin]] name = "stash" # actual binary name for Nix, Cargo, etc. From 2e3c73957a27e544669472e4c6c7d191b2554ca1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 08:10:52 +0300 Subject: [PATCH 22/60] meta: allow disabling symlinks in build script via env vars Signed-off-by: NotAShelf Change-Id: I07f5d565d26ca527d413edf69857539e6a6a6964 --- build.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/build.rs b/build.rs index f777a7c..b511acb 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,9 @@ use std::{env, fs, path::Path}; const MULTICALL_LINKS: &[&str] = &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; +/// Wayland-specific symlinks that can be disabled separately +const WAYLAND_LINKS: &[&str] = &["wl-copy", "wl-paste"]; + fn main() { // OUT_DIR is something like .../target/debug/build//out // We want .../target/debug or .../target/release @@ -16,8 +19,24 @@ fn main() { // Path to the main stash binary let stash_bin = bin_dir.join("stash"); + // Check for environment variables to disable symlinking + let disable_all_symlinks = env::var("STASH_NO_SYMLINKS").is_ok(); + let disable_wayland_symlinks = env::var("STASH_NO_WL_SYMLINKS").is_ok(); + // Create symlinks for each multicall binary for link in MULTICALL_LINKS { + if disable_all_symlinks { + println!("cargo:warning=Skipping symlink {link} (all symlinks disabled)"); + continue; + } + + if disable_wayland_symlinks && WAYLAND_LINKS.contains(link) { + println!( + "cargo:warning=Skipping symlink {link} (wayland symlinks disabled)" + ); + continue; + } + let link_path = bin_dir.join(link); // Remove existing symlink or file if present let _ = fs::remove_file(&link_path); From 4d58cae50db95c0f5a316cfe64a17f4bd06102cb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 09:11:43 +0300 Subject: [PATCH 23/60] nix: add platforms to meta; allow overriding symlink behaviour Signed-off-by: NotAShelf Change-Id: Ib6e44abd86bd0e58f290b456680a97236a6a6964 --- nix/package.nix | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 336926a..b068d4a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,9 +4,10 @@ stdenv, mold, versionCheckHook, + createSymlinks ? true, }: let pname = "stash"; - version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version; + version = (lib.importTOML ../Cargo.toml).package.version; src = let fs = lib.fileset; s = ../.; @@ -36,7 +37,7 @@ in # generated by the build wrapper are correctly linked, we should link # them *manually*. The postInstallCheck phase that follows will check # to verify if all of those links are in place. - postInstall = '' + postInstall = lib.optionalString createSymlinks '' mkdir -p $out for bin in stash-copy stash-paste wl-copy wl-paste; do ln -sf $out/bin/stash $out/bin/$bin @@ -48,7 +49,7 @@ in # After the version check, let's see if all binaries are linked correctly. # We could probably add a check phase to get the versions of each. - postInstallCheck = '' + postInstallCheck = lib.optionalString createSymlinks '' for bin in stash stash-copy stash-paste wl-copy wl-paste; do [ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; } done @@ -65,5 +66,6 @@ in license = lib.licenses.mpl20; maintainers = [lib.maintainers.NotAShelf]; mainProgram = "stash"; + platforms = lib.platforms.linux; }; } From 0215ebeb6ce6f56da2597186eccb9948e27d8109 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 11:09:39 +0300 Subject: [PATCH 24/60] chore: recursively bump `time` dep Signed-off-by: NotAShelf Change-Id: I57471a3c88a4cfe2d267f0fa8ceb59946a6a6964 --- Cargo.lock | 76 +++++++++++++++++++++++++++--------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c67fbf..d3c1ace 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,7 +203,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -238,7 +238,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -425,7 +425,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -592,7 +592,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -603,7 +603,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -614,9 +614,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -640,7 +640,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -737,7 +737,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1077,7 +1077,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1122,7 +1122,7 @@ checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1360,9 +1360,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -1372,7 +1372,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1553,7 +1553,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1617,7 +1617,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1725,9 +1725,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -1991,7 +1991,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2015,7 +2015,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2185,7 +2185,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2201,9 +2201,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2324,7 +2324,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2335,7 +2335,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2349,9 +2349,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "libc", @@ -2364,9 +2364,9 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "toml_datetime" @@ -2417,7 +2417,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2487,9 +2487,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -2607,7 +2607,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2839,7 +2839,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2850,7 +2850,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2997,7 +2997,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -3043,7 +3043,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -3056,6 +3056,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.117", "winnow", ] From 88c1f0f1586ac6f45c76775f17bd1566ca8a114a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 10:28:53 +0300 Subject: [PATCH 25/60] commands/list: full TUI rewrite for better perf Signed-off-by: NotAShelf Change-Id: I49009a89542fdeeea31d3755108b53d06a6a6964 --- src/commands/list.rs | 513 +++++++++++++++++++++++++++++-------------- src/db/mod.rs | 70 ++++++ 2 files changed, 418 insertions(+), 165 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 25903f3..2651370 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -27,6 +27,178 @@ impl ListCommand for SqliteClipboardDb { } } +/// All mutable state for the TUI list view. +struct TuiState { + /// Total number of entries matching the current filter in the DB. + total: usize, + + /// Global cursor position: index into the full ordered result set. + cursor: usize, + + /// DB offset of `window[0]`, i.e., the first row currently loaded. + viewport_offset: usize, + + /// The loaded slice of entries: `(id, preview, mime)`. + window: Vec<(i64, String, String)>, + + /// How many rows the window holds (== visible list height). + window_size: usize, + + /// Whether the window needs to be re-fetched from the DB. + dirty: bool, +} + +impl TuiState { + /// Create initial state: count total rows, load the first window. + fn new( + db: &SqliteClipboardDb, + include_expired: bool, + window_size: usize, + preview_width: u32, + ) -> Result { + let total = db.count_entries(include_expired)?; + let window = if total > 0 { + db.fetch_entries_window(include_expired, 0, window_size, preview_width)? + } else { + Vec::new() + }; + Ok(Self { + total, + cursor: 0, + viewport_offset: 0, + window, + window_size, + dirty: false, + }) + } + + /// Return the cursor position relative to the current window + /// (`window[local_cursor]` == the selected entry). + #[inline] + fn local_cursor(&self) -> usize { + self.cursor.saturating_sub(self.viewport_offset) + } + + /// Return the selected `(id, preview, mime)` if any entry is selected. + fn selected_entry(&self) -> Option<&(i64, String, String)> { + if self.total == 0 { + return None; + } + self.window.get(self.local_cursor()) + } + + /// Move the cursor down by one, wrapping to 0 at the bottom. + fn move_down(&mut self) { + if self.total == 0 { + return; + } + self.cursor = if self.cursor + 1 >= self.total { + 0 + } else { + self.cursor + 1 + }; + self.dirty = true; + } + + /// Move the cursor up by one, wrapping to `total - 1` at the top. + fn move_up(&mut self) { + if self.total == 0 { + return; + } + self.cursor = if self.cursor == 0 { + self.total - 1 + } else { + self.cursor - 1 + }; + self.dirty = true; + } + + /// Resize the window (e.g. terminal resized). Marks dirty so the + /// viewport is reloaded on the next frame. + fn resize(&mut self, new_size: usize) { + if new_size != self.window_size { + self.window_size = new_size; + self.dirty = true; + } + } + + /// After a delete the total shrinks by one and the cursor may need + /// clamping. The caller is responsible for the DB deletion itself. + fn on_delete(&mut self) { + if self.total == 0 { + return; + } + self.total -= 1; + if self.total == 0 { + self.cursor = 0; + } else if self.cursor >= self.total { + self.cursor = self.total - 1; + } + self.dirty = true; + } + + /// Reload the window from the DB if `dirty` is set or if the cursor + /// has drifted outside the currently loaded range. + fn sync( + &mut self, + db: &SqliteClipboardDb, + include_expired: bool, + preview_width: u32, + ) -> Result<(), StashError> { + let cursor_out_of_window = self.cursor < self.viewport_offset + || self.cursor >= self.viewport_offset + self.window.len().max(1); + + if !self.dirty && !cursor_out_of_window { + return Ok(()); + } + + // Re-anchor the viewport so the cursor sits in the upper half when + // scrolling downward, or at a sensible position when wrapping. + let half = self.window_size / 2; + self.viewport_offset = if self.cursor >= half { + (self.cursor - half).min(self.total.saturating_sub(self.window_size)) + } else { + 0 + }; + + self.window = if self.total > 0 { + db.fetch_entries_window( + include_expired, + self.viewport_offset, + self.window_size, + preview_width, + )? + } else { + Vec::new() + }; + self.dirty = false; + Ok(()) + } +} + +/// Query the maximum id digit-width and maximum mime byte-length across +/// all entries. This is pretty damn fast as it touches only index/metadata, +/// not blobs. +fn global_column_widths( + db: &SqliteClipboardDb, + include_expired: bool, +) -> Result<(usize, usize), StashError> { + let filter = if include_expired { + "" + } else { + "WHERE (is_expired IS NULL OR is_expired = 0)" + }; + let query = format!( + "SELECT COALESCE(MAX(LENGTH(CAST(id AS TEXT))), 2), \ + COALESCE(MAX(LENGTH(mime)), 8) FROM clipboard {filter}" + ); + let (id_w, mime_w): (i64, i64) = db + .conn + .query_row(&query, [], |r| Ok((r.get(0)?, r.get(1)?))) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok((id_w.max(2) as usize, mime_w.max(8) as usize)) +} + impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] pub fn list_tui( @@ -63,46 +235,9 @@ impl SqliteClipboardDb { }; use wl_clipboard_rs::copy::{MimeType, Options, Source}; - // Query entries from DB - let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \ - id DESC" - } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY last_accessed DESC, id DESC" - }; - let mut stmt = self - .conn - .prepare(query) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let mut entries: Vec<(i64, 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().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .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(); - let id_str = id.to_string(); - max_id_width = max_id_width.max(id_str.width()); - max_mime_width = max_mime_width.max(mime_str.width()); - entries.push((id, preview, mime_str)); - } + // One-time column-width metadata (no blob reads). + let (max_id_width, max_mime_width) = + global_column_widths(self, include_expired)?; enable_raw_mode() .map_err(|e| StashError::ListDecode(e.to_string().into()))?; @@ -113,13 +248,91 @@ impl SqliteClipboardDb { let mut terminal = Terminal::new(backend) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut state = ListState::default(); - if !entries.is_empty() { - state.select(Some(0)); + // Derive initial window size from current terminal height. + let initial_height = terminal + .size() + .map(|r| r.height.saturating_sub(2) as usize) + .unwrap_or(24); + let initial_height = initial_height.max(1); + + let mut tui = + TuiState::new(self, include_expired, initial_height, preview_width)?; + + // ratatui ListState; only tracks selection within the *window* slice. + let mut list_state = ListState::default(); + if tui.total > 0 { + list_state.select(Some(0)); } - let res = (|| -> Result<(), StashError> { - loop { + /// Accumulated actions from draining the event queue. + struct EventActions { + quit: bool, + net_down: i64, // positive=down, negative=up, 0=none + copy: bool, + delete: bool, + } + + /// Drain all pending key events and return what actions to perform. + /// Navigation is capped to ±1 per frame to prevent jumpy scrolling when + /// the key-repeat rate exceeds the render frame rate. + fn drain_events() -> Result { + let mut actions = EventActions { + quit: false, + net_down: 0, + copy: false, + delete: false, + }; + + while event::poll(std::time::Duration::from_millis(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + if 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, _) => actions.quit = true, + (KeyCode::Down | KeyCode::Char('j'), _) => { + // Cap at +1 per frame for smooth scrolling + if actions.net_down < 1 { + actions.net_down += 1; + } + }, + (KeyCode::Up | KeyCode::Char('k'), _) => { + // Cap at -1 per frame for smooth scrolling + if actions.net_down > -1 { + actions.net_down -= 1; + } + }, + (KeyCode::Enter, _) => actions.copy = true, + (KeyCode::Char('D'), KeyModifiers::SHIFT) => actions.delete = true, + _ => {}, + } + } + } + Ok(actions) + } + + let draw_frame = + |terminal: &mut Terminal>, + tui: &mut TuiState, + list_state: &mut ListState, + max_id_width: usize, + max_mime_width: usize| + -> Result<(), StashError> { + let term_height = terminal + .size() + .map(|r| r.height.saturating_sub(2) as usize) + .unwrap_or(24) + .max(1); + tui.resize(term_height); + tui.sync(self, include_expired, preview_width)?; + + if tui.total == 0 { + list_state.select(None); + } else { + list_state.select(Some(tui.local_cursor())); + } + terminal .draw(|f| { let area = f.area(); @@ -135,13 +348,11 @@ impl SqliteClipboardDb { let highlight_width = 1; let content_width = area.width as usize - border_width; - // Minimum widths for columns let min_id_width = 2; let min_mime_width = 6; let min_preview_width = 4; - let spaces = 3; // [id][ ][preview][ ][mime] + let spaces = 3; - // Dynamically allocate widths let mut id_col = max_id_width.max(min_id_width); let mut mime_col = max_mime_width.max(min_mime_width); let mut preview_col = content_width @@ -150,7 +361,6 @@ impl SqliteClipboardDb { .saturating_sub(mime_col) .saturating_sub(spaces); - // If not enough space, shrink columns if preview_col < min_preview_width { let needed = min_preview_width - preview_col; if mime_col > min_mime_width { @@ -173,13 +383,13 @@ impl SqliteClipboardDb { preview_col = min_preview_width; } - let selected = state.selected(); + let selected = list_state.selected(); - let list_items: Vec = entries + let list_items: Vec = tui + .window .iter() .enumerate() .map(|(i, entry)| { - // Truncate preview by grapheme clusters and display width let mut preview = String::new(); let mut width = 0; for g in entry.1.graphemes(true) { @@ -191,7 +401,6 @@ impl SqliteClipboardDb { preview.push_str(g); width += g_width; } - // Truncate and pad mimetype let mut mime = String::new(); let mut mwidth = 0; for g in entry.2.graphemes(true) { @@ -204,8 +413,6 @@ impl SqliteClipboardDb { mwidth += g_width; } - // Compose the row as highlight + id + space + preview + space + - // mimetype let mut spans = Vec::new(); let (id, preview, mime) = entry; if Some(i) == selected { @@ -252,133 +459,109 @@ impl SqliteClipboardDb { .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(""); // handled manually + .highlight_symbol(""); - f.render_stateful_widget(list, area, &mut state); + f.render_stateful_widget(list, area, list_state); }) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok(()) + }; + // Initial draw. + draw_frame( + &mut terminal, + &mut tui, + &mut list_state, + max_id_width, + max_mime_width, + )?; + + let res = (|| -> Result<(), StashError> { + loop { + // Block waiting for events, then drain and process all queued input. if event::poll(std::time::Duration::from_millis(250)) .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'), _) => { - if entries.is_empty() { - state.select(None); - } else { - let i = match state.selected() { - Some(i) => { - if i >= entries.len() - 1 { - 0 - } else { - i + 1 - } - }, - None => 0, + let actions = drain_events()?; + + if actions.quit { + break; + } + + // Apply navigation (capped at ±1 per frame for smooth scrolling). + if actions.net_down > 0 { + tui.move_down(); + } else if actions.net_down < 0 { + tui.move_up(); + } + + if actions.delete + && let Some(&(id, ..)) = tui.selected_entry() + { + self + .conn + .execute( + "DELETE FROM clipboard WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; + tui.on_delete(); + let _ = Notification::new() + .summary("Stash") + .body("Deleted entry") + .show(); + } + + if actions.copy + && let Some(&(id, ..)) = tui.selected_entry() + { + match self.copy_entry(id) { + Ok((new_id, contents, mime)) => { + if new_id != id { + tui.dirty = true; + } + 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, }; - state.select(Some(i)); - } - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { - if entries.is_empty() { - state.select(None); - } else { - let i = match state.selected() { - Some(i) => { - if i == 0 { - entries.len() - 1 - } else { - i - 1 - } - }, - 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}"); + 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(); - } - }, - _ => {}, + }, + } } + + // Redraw once after processing all accumulated input. + draw_frame( + &mut terminal, + &mut tui, + &mut list_state, + max_id_width, + max_mime_width, + )?; } } Ok(()) diff --git a/src/db/mod.rs b/src/db/mod.rs index 4b57ae5..23e622e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -734,6 +734,76 @@ impl ClipboardDb for SqliteClipboardDb { } impl SqliteClipboardDb { + /// Count visible clipboard entries (respects include_expired filter). + pub fn count_entries( + &self, + include_expired: bool, + ) -> Result { + let count: i64 = if include_expired { + self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + } else { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0)", + [], + |r| r.get(0), + ) + } + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok(count.max(0) as usize) + } + + /// Fetch a window of entries for TUI virtual scrolling. + /// + /// Returns `(id, preview_string, mime_string)` tuples for at most + /// `limit` rows starting at `offset` (0-indexed) in the canonical + /// display order (most-recently-accessed first, then id DESC). + pub fn fetch_entries_window( + &self, + include_expired: bool, + offset: usize, + limit: usize, + preview_width: u32, + ) -> Result, StashError> { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ + ?1 OFFSET ?2" + }; + let mut stmt = self + .conn + .prepare(query) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut rows = stmt + .query(rusqlite::params![limit as i64, offset as i64]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let mut window = Vec::with_capacity(limit); + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + let id: i64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let preview = preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.unwrap_or_default(); + window.push((id, preview, mime_str)); + } + Ok(window) + } + /// Get current Unix timestamp with sub-second precision pub fn now() -> f64 { std::time::SystemTime::now() From b850a54f7be40fea8826d6dad3ad86665c05b668 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 11:24:55 +0300 Subject: [PATCH 26/60] commands/list: implement clipboard history search Signed-off-by: NotAShelf Change-Id: I57f00cbd9d02b1981cf3ea5dc908e72c6a6a6964 --- src/commands/list.rs | 343 +++++++++++++++++++++++++++++++------------ src/db/mod.rs | 102 ++++++++++--- 2 files changed, 326 insertions(+), 119 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 2651370..03309aa 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -46,6 +46,12 @@ struct TuiState { /// Whether the window needs to be re-fetched from the DB. dirty: bool, + + /// Current search query. Empty string means no filter. + search_query: String, + + /// Whether we're currently in search input mode. + search_mode: bool, } impl TuiState { @@ -56,9 +62,15 @@ impl TuiState { window_size: usize, preview_width: u32, ) -> Result { - let total = db.count_entries(include_expired)?; + let total = db.count_entries(include_expired, None)?; let window = if total > 0 { - db.fetch_entries_window(include_expired, 0, window_size, preview_width)? + db.fetch_entries_window( + include_expired, + 0, + window_size, + preview_width, + None, + )? } else { Vec::new() }; @@ -69,9 +81,56 @@ impl TuiState { window, window_size, dirty: false, + search_query: String::new(), + search_mode: false, }) } + /// Return the current search filter (`None` if empty). + fn search_filter(&self) -> Option<&str> { + if self.search_query.is_empty() { + None + } else { + Some(&self.search_query) + } + } + + /// Update search query and reset cursor. Returns true if search changed. + fn set_search(&mut self, query: String) -> bool { + let changed = self.search_query != query; + if changed { + self.search_query = query; + self.cursor = 0; + self.viewport_offset = 0; + self.dirty = true; + } + changed + } + + /// Clear search and reset state. Returns true if was searching. + fn clear_search(&mut self) -> bool { + let had_search = !self.search_query.is_empty(); + self.search_query.clear(); + self.search_mode = false; + if had_search { + self.cursor = 0; + self.viewport_offset = 0; + self.dirty = true; + } + had_search + } + + /// Toggle search mode. + fn toggle_search_mode(&mut self) { + self.search_mode = !self.search_mode; + if self.search_mode { + // When entering search mode, clear query if there was one + // or start fresh + self.search_query.clear(); + self.dirty = true; + } + } + /// Return the cursor position relative to the current window /// (`window[local_cursor]` == the selected entry). #[inline] @@ -161,12 +220,14 @@ impl TuiState { 0 }; + let search = self.search_filter(); self.window = if self.total > 0 { db.fetch_entries_window( include_expired, self.viewport_offset, self.window_size, preview_width, + search, )? } else { Vec::new() @@ -177,7 +238,7 @@ impl TuiState { } /// Query the maximum id digit-width and maximum mime byte-length across -/// all entries. This is pretty damn fast as it touches only index/metadata, +/// all entries. This is pretty damn fast as it touches only index/metadata, /// not blobs. fn global_column_widths( db: &SqliteClipboardDb, @@ -266,21 +327,29 @@ impl SqliteClipboardDb { /// Accumulated actions from draining the event queue. struct EventActions { - quit: bool, - net_down: i64, // positive=down, negative=up, 0=none - copy: bool, - delete: bool, + quit: bool, + net_down: i64, // positive=down, negative=up, 0=none + copy: bool, + delete: bool, + toggle_search: bool, // enter/exit search mode + search_input: Option, // character typed in search mode + search_backspace: bool, // backspace in search mode + clear_search: bool, // clear search query (ESC in search mode) } /// Drain all pending key events and return what actions to perform. - /// Navigation is capped to ±1 per frame to prevent jumpy scrolling when + /// Navigation is capped to +-1 per frame to prevent jumpy scrolling when /// the key-repeat rate exceeds the render frame rate. - fn drain_events() -> Result { + fn drain_events(tui: &TuiState) -> Result { let mut actions = EventActions { - quit: false, - net_down: 0, - copy: false, - delete: false, + quit: false, + net_down: 0, + copy: false, + delete: false, + toggle_search: false, + search_input: None, + search_backspace: false, + clear_search: false, }; while event::poll(std::time::Duration::from_millis(0)) @@ -289,23 +358,46 @@ impl SqliteClipboardDb { if 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, _) => actions.quit = true, - (KeyCode::Down | KeyCode::Char('j'), _) => { - // Cap at +1 per frame for smooth scrolling - if actions.net_down < 1 { - actions.net_down += 1; - } - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { - // Cap at -1 per frame for smooth scrolling - if actions.net_down > -1 { - actions.net_down -= 1; - } - }, - (KeyCode::Enter, _) => actions.copy = true, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => actions.delete = true, - _ => {}, + if tui.search_mode { + // In search mode, handle text input + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + actions.clear_search = true; + }, + (KeyCode::Enter, _) => { + actions.toggle_search = true; // exit search mode + }, + (KeyCode::Backspace, _) => { + actions.search_backspace = true; + }, + (KeyCode::Char(c), _) => { + actions.search_input = Some(c); + }, + _ => {}, + } + } else { + // Normal mode navigation commands + match (key.code, key.modifiers) { + (KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true, + (KeyCode::Down | KeyCode::Char('j'), _) => { + // Cap at +1 per frame for smooth scrolling + if actions.net_down < 1 { + actions.net_down += 1; + } + }, + (KeyCode::Up | KeyCode::Char('k'), _) => { + // Cap at -1 per frame for smooth scrolling + if actions.net_down > -1 { + actions.net_down -= 1; + } + }, + (KeyCode::Enter, _) => actions.copy = true, + (KeyCode::Char('D'), KeyModifiers::SHIFT) => { + actions.delete = true + }, + (KeyCode::Char('/'), _) => actions.toggle_search = true, + _ => {}, + } } } } @@ -319,9 +411,11 @@ impl SqliteClipboardDb { max_id_width: usize, max_mime_width: usize| -> Result<(), StashError> { + // Reserve 2 rows for search bar when in search mode + let search_bar_height = if tui.search_mode { 2 } else { 0 }; let term_height = terminal .size() - .map(|r| r.height.saturating_sub(2) as usize) + .map(|r| r.height.saturating_sub(2 + search_bar_height) as usize) .unwrap_or(24) .max(1); tui.resize(term_height); @@ -336,12 +430,23 @@ impl SqliteClipboardDb { terminal .draw(|f| { let area = f.area(); - let block = Block::default() - .title( - "Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ - to delete, q/ESC to quit)", + + // Build title based on search state + let title = if tui.search_mode { + format!("Search: {}", tui.search_query) + } else if tui.search_query.is_empty() { + "Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \ + Shift+D to delete, q/ESC to quit)" + .to_string() + } else { + format!( + "Clipboard Entries (filtered: '{}' - {} results, / to search, \ + ESC to clear, q to quit)", + tui.search_query, tui.total ) - .borders(Borders::ALL); + }; + + let block = Block::default().title(title).borders(Borders::ALL); let border_width = 2; let highlight_symbol = ">"; @@ -482,75 +587,119 @@ impl SqliteClipboardDb { if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let actions = drain_events()?; + let actions = drain_events(&tui)?; if actions.quit { break; } + // Handle search mode actions + if actions.toggle_search { + tui.toggle_search_mode(); + } + + if actions.clear_search && tui.clear_search() { + // Search was cleared, refresh count + tui.total = + self.count_entries(include_expired, tui.search_filter())?; + } + + if let Some(c) = actions.search_input { + let new_query = format!("{}{}", tui.search_query, c); + if tui.set_search(new_query) { + // Search changed, refresh count and reset + tui.total = + self.count_entries(include_expired, tui.search_filter())?; + } + } + + if actions.search_backspace { + let new_query = tui + .search_query + .chars() + .next_back() + .map(|_| { + tui + .search_query + .chars() + .take(tui.search_query.len() - 1) + .collect::() + }) + .unwrap_or_default(); + if tui.set_search(new_query) { + // Search changed, refresh count and reset + tui.total = + self.count_entries(include_expired, tui.search_filter())?; + } + } + // Apply navigation (capped at ±1 per frame for smooth scrolling). - if actions.net_down > 0 { - tui.move_down(); - } else if actions.net_down < 0 { - tui.move_up(); - } + if !tui.search_mode { + if actions.net_down > 0 { + tui.move_down(); + } else if actions.net_down < 0 { + tui.move_up(); + } - if actions.delete - && let Some(&(id, ..)) = tui.selected_entry() - { - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; - tui.on_delete(); - let _ = Notification::new() - .summary("Stash") - .body("Deleted entry") - .show(); - } + if actions.delete + && let Some(&(id, ..)) = tui.selected_entry() + { + self + .conn + .execute( + "DELETE FROM clipboard WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|e| { + StashError::DeleteEntry(id, e.to_string().into()) + })?; + tui.on_delete(); + let _ = Notification::new() + .summary("Stash") + .body("Deleted entry") + .show(); + } - if actions.copy - && let Some(&(id, ..)) = tui.selected_entry() - { - match self.copy_entry(id) { - Ok((new_id, contents, mime)) => { - if new_id != id { - tui.dirty = true; - } - 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}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to fetch entry: {e}")) - .show(); - }, + if actions.copy + && let Some(&(id, ..)) = tui.selected_entry() + { + match self.copy_entry(id) { + Ok((new_id, contents, mime)) => { + if new_id != id { + tui.dirty = true; + } + 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}"); + let _ = Notification::new() + .summary("Stash") + .body(&format!("Failed to fetch entry: {e}")) + .show(); + }, + } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 23e622e..ca8ed37 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -734,22 +734,50 @@ impl ClipboardDb for SqliteClipboardDb { } impl SqliteClipboardDb { - /// Count visible clipboard entries (respects include_expired filter). + /// Count visible clipboard entries, with respect to `include_expired` and + /// optional search filter. pub fn count_entries( &self, include_expired: bool, + search: Option<&str>, ) -> Result { - let count: i64 = if include_expired { - self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) - } else { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0)", - [], - |r| r.get(0), - ) + let search_pattern = search.map(|s| { + // Avoid backslash escaping issues + let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + format!("%{escaped}%") + }); + + let count: i64 = match (include_expired, search_pattern.as_deref()) { + (true, None) => { + self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + }, + (true, Some(pattern)) => { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (LOWER(CAST(contents AS \ + TEXT)) LIKE LOWER(?1) ESCAPE '!')", + [pattern], + |r| r.get(0), + ) + }, + (false, None) => { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0)", + [], + |r| r.get(0), + ) + }, + (false, Some(pattern)) => { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) \ + ESCAPE '!')", + [pattern], + |r| r.get(0), + ) + }, } .map_err(|e| StashError::ListDecode(e.to_string().into()))?; Ok(count.max(0) as usize) @@ -760,28 +788,58 @@ impl SqliteClipboardDb { /// Returns `(id, preview_string, mime_string)` tuples for at most /// `limit` rows starting at `offset` (0-indexed) in the canonical /// display order (most-recently-accessed first, then id DESC). + /// Optionally filters by search query in a case-insensitive nabber on text + /// content. pub fn fetch_entries_window( &self, include_expired: bool, offset: usize, limit: usize, preview_width: u32, + search: Option<&str>, ) -> Result, StashError> { - let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" - } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ - ?1 OFFSET ?2" + let search_pattern = search.map(|s| { + let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + format!("%{escaped}%") + }); + + let query = match (include_expired, search_pattern.as_deref()) { + (true, None) => { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + }, + (true, Some(_)) => { + "SELECT id, contents, mime FROM clipboard WHERE (LOWER(CAST(contents \ + AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, \ + 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + }, + (false, None) => { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC \ + LIMIT ?1 OFFSET ?2" + }, + (false, Some(_)) => { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) \ + ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ + ?1 OFFSET ?2" + }, }; + let mut stmt = self .conn .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query(rusqlite::params![limit as i64, offset as i64]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let mut rows = if let Some(pattern) = search_pattern.as_deref() { + stmt + .query(rusqlite::params![limit as i64, offset as i64, pattern]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + } else { + stmt + .query(rusqlite::params![limit as i64, offset as i64]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + }; let mut window = Vec::with_capacity(limit); while let Some(row) = rows From 117e9d11efb60d6eb5cbfdcab2115e156cb6a1c7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 16:23:50 +0300 Subject: [PATCH 27/60] docs: add cliphist to attributions section; add motivation section Signed-off-by: NotAShelf Change-Id: Ia3da5b4dc3aeeb98eafc77173ae592596a6a6964 --- README.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index faabc1c..775e223 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@
- Lightweight Wayland clipboard "manager" with fast persistent history and + Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and robust multi-media support. Stores and previews clipboard entries (text, images) on the clipboard with a neat TUI and advanced scripting capabilities.
@@ -28,7 +28,7 @@ @@ -375,6 +375,20 @@ be only copied to the clipboard. > > `stash --excluded-apps Bitwarden watch` +## Motivation + +I've been a long-time user of Cliphist. You can probably tell by the number of +times it has been mentioned in the README, if not for the attributions section, +that Stash is _clearly_ inspired and adapted from it. It's actually a great +clipboard manager if your needs are simple, but mine aren't. I need an +**all-in-one** solution, that I can freely hack on, with simple solutions to +complex problems that I've had with managing my clipboard. I wanted it to be +scriptable _and_ interactive, I wanted it to be performant, I wanted it to be... + +You get the point. Perhaps you also share similar needs, or just like Rust +software in general on your desktop. In either case, Stash hopes to serve as an +excellent clipboard manager for your needs, with _excellent_ performance. + ## Tips & Tricks ### Migrating from Cliphist @@ -549,8 +563,14 @@ My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the powered by [several crates](./Cargo.toml), but none of them were as detrimental in Stash's design process. -Additional thanks to my testers, who have tested earlier versions of Stash and -provided feedback. Thank you :) +Secondly, but by no means less importantly, I would like to thank +[cliphist](https://github.com/sentriz/cliphist) for the excellent reference it +has provided to me as a "solid clipboard manager." The interface of Stash is +inspired by Cliphist, and it has served me very well for a very long time. + +Additional and definitely heartfelt thanks to my testers, who have tested +earlier versions of Stash, helped with packaging and provided feedback. Thank +you :) ## License From 469fccbef6fce5db5cce185a80525229e2658e5d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 16:24:08 +0300 Subject: [PATCH 28/60] chore: release v0.3.6 Signed-off-by: NotAShelf Change-Id: I2adaf9944a4572dcd15157f32b52eec26a6a6964 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3c1ace..98e77f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,7 +2124,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.5" +version = "0.3.6" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index 51e12ee..a828573 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.5" +version = "0.3.6" edition = "2024" authors = [ "NotAShelf " ] license = "MPL-2.0" From 02ba05dc955d0b4f535394764ae781eb5f39638c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 17:00:07 +0300 Subject: [PATCH 29/60] db: add new error variants for entries below minimum and above maximum sizes Signed-off-by: NotAShelf Change-Id: Icba2920cfef0ffb0ce6435ab6d7809166a6a6964 --- src/db/mod.rs | 122 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index ca8ed37..e55f426 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -22,6 +22,10 @@ pub enum StashError { EmptyOrTooLarge, #[error("Input is all whitespace, skipping store.")] AllWhitespace, + #[error("Entry too small (min size: {0} bytes), skipping store.")] + TooSmall(usize), + #[error("Entry too large (max size: {0} bytes), skipping store.")] + TooLarge(usize), #[error("Failed to store entry: {0}")] Store(Box), @@ -65,6 +69,8 @@ pub trait ClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, + min_size: Option, + max_size: Option, ) -> Result; fn deduplicate_by_hash( @@ -410,14 +416,30 @@ impl ClipboardDb for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, + min_size: Option, + max_size: Option, ) -> Result { let mut buf = Vec::new(); - if input.read_to_end(&mut buf).is_err() - || buf.is_empty() - || buf.len() > 5 * 1_000_000 - { + if input.read_to_end(&mut buf).is_err() || buf.is_empty() { return Err(StashError::EmptyOrTooLarge); } + + let size = buf.len(); + + if let Some(min) = min_size + && size < min + { + return Err(StashError::TooSmall(min)); + } + + if let Some(max) = max_size { + if size > max { + return Err(StashError::TooLarge(max)); + } + } else if size > 5 * 1_000_000 { + return Err(StashError::TooLarge(5 * 1_000_000)); + } + if buf.iter().all(u8::is_ascii_whitespace) { return Err(StashError::AllWhitespace); } @@ -1514,7 +1536,7 @@ mod tests { let cursor = std::io::Cursor::new(test_data.to_vec()); let id = db - .store_entry(cursor, 100, 1000, None) + .store_entry(cursor, 100, 1000, None, None, None) .expect("Failed to store entry"); let content_hash: Option = db @@ -1549,7 +1571,7 @@ mod tests { 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) + .store_entry(cursor, 100, 1000, None, None, None) .expect("Failed to store entry A"); let original_last_accessed: i64 = db @@ -1644,7 +1666,14 @@ mod tests { let db = test_db(); let data = b"file:///home/user/document.pdf\nfile:///home/user/image.png"; let id = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store URI list"); let mime: Option = db @@ -1670,7 +1699,14 @@ mod tests { 0x90, 0x77, 0x53, 0xDE, // CRC ]; let id = db - .store_entry(std::io::Cursor::new(data.clone()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.clone()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store image"); let (contents, mime): (Vec, Option) = db @@ -1691,10 +1727,24 @@ mod tests { let data = b"duplicate content"; let id1 = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store first"); let _id2 = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store second"); // First entry should have been removed by deduplication @@ -1727,6 +1777,8 @@ mod tests { 100, 3, // max 3 items None, + None, + None, ) .expect("Failed to store"); } @@ -1741,8 +1793,14 @@ mod tests { #[test] fn test_reject_empty_input() { let db = test_db(); - let result = - db.store_entry(std::io::Cursor::new(Vec::new()), 100, 1000, None); + let result = db.store_entry( + std::io::Cursor::new(Vec::new()), + 100, + 1000, + None, + None, + None, + ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1754,6 +1812,8 @@ mod tests { 100, 1000, None, + None, + None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1763,15 +1823,23 @@ mod tests { let db = test_db(); // 5MB + 1 byte let data = vec![b'a'; 5 * 1_000_000 + 1]; - let result = db.store_entry(std::io::Cursor::new(data), 100, 1000, None); - assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); + let result = + db.store_entry(std::io::Cursor::new(data), 100, 1000, None, None, None); + assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } #[test] fn test_delete_entries_by_id() { let db = test_db(); let id = db - .store_entry(std::io::Cursor::new(b"to delete".to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(b"to delete".to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store"); let input = format!("{id}\tpreview text\n"); @@ -1795,6 +1863,8 @@ mod tests { 100, 1000, None, + None, + None, ) .expect("Failed to store"); db.store_entry( @@ -1802,6 +1872,8 @@ mod tests { 100, 1000, None, + None, + None, ) .expect("Failed to store"); @@ -1822,8 +1894,15 @@ mod tests { let db = test_db(); for i in 0..3 { let data = format!("entry {i}"); - db.store_entry(std::io::Cursor::new(data.into_bytes()), 100, 1000, None) - .expect("Failed to store"); + db.store_entry( + std::io::Cursor::new(data.into_bytes()), + 100, + 1000, + None, + None, + None, + ) + .expect("Failed to store"); } db.wipe_db().expect("Failed to wipe"); @@ -1885,7 +1964,14 @@ mod tests { let db = test_db(); let data = b"copy me"; let id = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store"); let (returned_id, contents, mime) = From 3a14860ae18475362d9d2b67a8ddca50be79da80 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 17:02:45 +0300 Subject: [PATCH 30/60] various: validate lower and upper boundaries before storing; add CLI flags Signed-off-by: NotAShelf Change-Id: I6484f9579a8799d952b15adcb47c8eec6a6a6964 --- src/commands/store.rs | 7 ++++++ src/commands/watch.rs | 7 ++++++ src/db/mod.rs | 52 +++++++++++++++++++++++-------------------- src/main.rs | 35 +++++++++++++++++++++-------- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/commands/store.rs b/src/commands/store.rs index 9e5a6c6..3854b16 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -2,6 +2,7 @@ use std::io::Read; use crate::db::{ClipboardDb, SqliteClipboardDb}; +#[allow(clippy::too_many_arguments)] pub trait StoreCommand { fn store( &self, @@ -10,6 +11,8 @@ pub trait StoreCommand { max_items: u64, state: Option, excluded_apps: &[String], + min_size: Option, + max_size: usize, ) -> Result<(), crate::db::StashError>; } @@ -21,6 +24,8 @@ impl StoreCommand for SqliteClipboardDb { max_items: u64, state: Option, excluded_apps: &[String], + min_size: Option, + max_size: usize, ) -> Result<(), crate::db::StashError> { if let Some("sensitive" | "clear") = state.as_deref() { self.delete_last()?; @@ -31,6 +36,8 @@ impl StoreCommand for SqliteClipboardDb { max_dedupe_search, max_items, Some(excluded_apps), + min_size, + max_size, )?; log::info!("Entry stored"); } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 54dc803..fbc7239 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -175,6 +175,7 @@ fn negotiate_mime_type( } } +#[allow(clippy::too_many_arguments)] pub trait WatchCommand { fn watch( &self, @@ -183,6 +184,8 @@ pub trait WatchCommand { excluded_apps: &[String], expire_after: Option, mime_type_preference: &str, + min_size: Option, + max_size: usize, ); } @@ -194,6 +197,8 @@ impl WatchCommand for SqliteClipboardDb { excluded_apps: &[String], expire_after: Option, mime_type_preference: &str, + min_size: Option, + max_size: usize, ) { smol::block_on(async { log::info!( @@ -349,6 +354,8 @@ impl WatchCommand for SqliteClipboardDb { max_dedupe_search, max_items, Some(excluded_apps), + min_size, + max_size, ) { Ok(id) => { log::info!("Stored new clipboard entry (id: {id})"); diff --git a/src/db/mod.rs b/src/db/mod.rs index e55f426..ae8d814 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -16,6 +16,8 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; use thiserror::Error; +pub const DEFAULT_MAX_ENTRY_SIZE: usize = 5_000_000; + #[derive(Error, Debug)] pub enum StashError { #[error("Input is empty or too large, skipping store.")] @@ -70,7 +72,7 @@ pub trait ClipboardDb { max_items: u64, excluded_apps: Option<&[String]>, min_size: Option, - max_size: Option, + max_size: usize, ) -> Result; fn deduplicate_by_hash( @@ -417,7 +419,7 @@ impl ClipboardDb for SqliteClipboardDb { max_items: u64, excluded_apps: Option<&[String]>, min_size: Option, - max_size: Option, + max_size: usize, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() { @@ -432,12 +434,8 @@ impl ClipboardDb for SqliteClipboardDb { return Err(StashError::TooSmall(min)); } - if let Some(max) = max_size { - if size > max { - return Err(StashError::TooLarge(max)); - } - } else if size > 5 * 1_000_000 { - return Err(StashError::TooLarge(5 * 1_000_000)); + if size > max_size { + return Err(StashError::TooLarge(max_size)); } if buf.iter().all(u8::is_ascii_whitespace) { @@ -1536,7 +1534,7 @@ mod tests { let cursor = std::io::Cursor::new(test_data.to_vec()); let id = db - .store_entry(cursor, 100, 1000, None, None, None) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE) .expect("Failed to store entry"); let content_hash: Option = db @@ -1571,7 +1569,7 @@ mod tests { 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, None, None) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE) .expect("Failed to store entry A"); let original_last_accessed: i64 = db @@ -1672,7 +1670,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store URI list"); @@ -1705,7 +1703,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store image"); @@ -1733,7 +1731,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store first"); let _id2 = db @@ -1743,7 +1741,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store second"); @@ -1778,7 +1776,7 @@ mod tests { 3, // max 3 items None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); } @@ -1799,7 +1797,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1813,7 +1811,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1823,8 +1821,14 @@ mod tests { let db = test_db(); // 5MB + 1 byte let data = vec![b'a'; 5 * 1_000_000 + 1]; - let result = - db.store_entry(std::io::Cursor::new(data), 100, 1000, None, None, None); + let result = db.store_entry( + std::io::Cursor::new(data), + 100, + 1000, + None, + None, + DEFAULT_MAX_ENTRY_SIZE, + ); assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } @@ -1838,7 +1842,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); @@ -1864,7 +1868,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); db.store_entry( @@ -1873,7 +1877,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); @@ -1900,7 +1904,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); } @@ -1970,7 +1974,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); diff --git a/src/main.rs b/src/main.rs index 56c2170..ef12ed1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,15 +15,18 @@ pub(crate) mod mime; mod multicall; #[cfg(feature = "use-toplevel")] mod wayland; -use crate::commands::{ - decode::DecodeCommand, - delete::DeleteCommand, - import::ImportCommand, - list::ListCommand, - query::QueryCommand, - store::StoreCommand, - watch::WatchCommand, - wipe::WipeCommand, +use crate::{ + commands::{ + decode::DecodeCommand, + delete::DeleteCommand, + import::ImportCommand, + list::ListCommand, + query::QueryCommand, + store::StoreCommand, + watch::WatchCommand, + wipe::WipeCommand, + }, + db::DEFAULT_MAX_ENTRY_SIZE, }; #[derive(Parser)] @@ -42,6 +45,16 @@ struct Cli { #[arg(long, default_value_t = 20)] max_dedupe_search: u64, + /// Minimum size (in bytes) for clipboard entries. Entries smaller than this + /// will not be stored. + #[arg(long, env = "STASH_MIN_SIZE")] + min_size: Option, + + /// Maximum size (in bytes) for clipboard entries. Entries larger than this + /// will not be stored. Defaults to 5MB. + #[arg(long, default_value_t = DEFAULT_MAX_ENTRY_SIZE, env = "STASH_MAX_SIZE")] + max_size: usize, + /// Maximum width (in characters) for clipboard entry previews in list /// output. #[arg(long, default_value_t = 100)] @@ -226,6 +239,8 @@ fn main() -> color_eyre::eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], + cli.min_size, + cli.max_size, ), "failed to store entry", ); @@ -451,6 +466,8 @@ fn main() -> color_eyre::eyre::Result<()> { &[], expire_after, &mime_type, + cli.min_size, + cli.max_size, ); }, From ba2e29d5b76a33050cf79d9c03ee2affc06e261c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 08:09:18 +0300 Subject: [PATCH 31/60] docs: fix HTML formatting; mention Cliphist's features Signed-off-by: NotAShelf Change-Id: I92716daef01c00bbe8e75426c3662fbb6a6a6964 --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 775e223..ba3cf9e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ @@ -52,7 +52,19 @@ with many features such as but not necessarily limited to: - Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering by application (see below) -See [usage section](#usage) for more details. +on top of the existing features of Cliphist, which are as follows: + +- Write clipboard changes to a history file. +- Recall history with dmenu, rofi, wofi (or whatever other picker you like). +- Both text and images are supported. +- Clipboard is preserved byte-for-byte. + - Leading/trailing whitespace, no whitespace, or newlines are preserved. + - Won’t break fancy editor selections like Vim wordwise, linewise, or block + mode. + +Most of Stash's usage is documented in the [usage section](#usage) for more +details. Refer to the [Tips & Tricks section](#tips--tricks) for more "advanced" +features, or conveniences provided by Stash. ## Installation @@ -554,7 +566,8 @@ your database: reclaim space and defragment the database. This is safe to run periodically. It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep -the database compact, especially after deleting many entries. +the database compact, especially after deleting many entries. You can, of +course, wipe the database entirely if it has grown too large. ## Attributions From ebf46de99d8ce895410ce2e814ac064d589238d3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 10:26:45 +0300 Subject: [PATCH 32/60] docs: add installation instructions for crates.io Signed-off-by: NotAShelf Change-Id: Ib9a3fc7ee21324707d046d52a24b50596a6a6964 --- README.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ba3cf9e..42dd542 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ with many features such as but not necessarily limited to: - Image preview (shows dimensions and format) - Text previews with customizable width - De-duplication, whitespace prevention and entry limit control -- Automatic clipboard monitoring with `stash watch` +- Automatic clipboard monitoring with + [`stash watch`](#watch-clipboard-for-changes-and-store-automatically) - Configurable auto-expiry of old entries in watch mode as a safety buffer - Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) - Sensitive clipboard filtering via regex (see below) @@ -70,9 +71,9 @@ features, or conveniences provided by Stash. ### With Nix -Nix is the recommended way of downloading Stash. You can install it using Nix -flakes using `nix profile add` if on non-nixos or add Stash as a flake input if -you are on NixOS. +Nix is the recommended way of downloading (and developing!) Stash. You can +install it using Nix flakes using `nix profile add` if on non-nixos or add Stash +as a flake input if you are on NixOS. ```nix { @@ -103,7 +104,8 @@ If you want to give Stash a try before you switch to it, you may also run it one time with `nix run`. ```sh -nix run github:NotAShelf/stash -- watch # start the watch daemon +# Run directly from the git repository; will be garbage collected +$ nix run github:NotAShelf/stash -- watch # start the watch daemon ``` ### Without Nix @@ -122,16 +124,23 @@ releases are made when a version gets tagged, and are available under - Build and install from source with Cargo: ```bash - cargo install --git https://github.com/notashelf/stash + cargo install stash --locked ``` +Additionally, you may get Stash from source via `cargo install` using +`cargo install --git https://github.com/notashelf/stash --locked` or you may +check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0 +or above. Most distributions should package this version already. You may, of +course, prefer to package the built releases if you'd like. + ## Usage -> [!NOTE] +> [!IMPORTANT] > It is not a priority to provide 1:1 backwards compatibility with Cliphist. -> While the interface is _almost_ identical, Stash chooses to build upon +> While the interface is generally similar, Stash chooses to build upon > Cliphist's design and extend existing design choices. See -> [Migrating from Cliphist](#migrating-from-cliphist) for more details. +> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to +> help text if confused. The command interface of Stash is _only slightly_ different from Cliphist. In most cases, you may simply replace `cliphist` with `stash` and your commands, @@ -287,7 +296,7 @@ entry has expired from history. > This behavior only applies when the watch daemon is actively running. Manual > expiration or deletion of entries will not clear the clipboard. -### MIME Type Preference for Watch +#### MIME Type Preference for Watch `stash watch` supports a `--mime-type` (short `-t`) option that lets you prioritise which MIME type the daemon should request from the clipboard when From 181edcefb1fb38bbd1ca306e91ba493bcf4014d9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 10:34:32 +0300 Subject: [PATCH 33/60] db: add MIME sniffing for binary clipboard previews Signed-off-by: NotAShelf Change-Id: I70416269dd40496758b6e5431e77a9456a6a6964 --- Cargo.lock | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/db/mod.rs | 38 ++++--- 3 files changed, 307 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 98e77f7..f18e409 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,6 +686,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "document-features" version = "0.2.12" @@ -900,6 +911,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -1017,12 +1037,114 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "imagesize" version = "0.14.0" @@ -1200,6 +1322,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "litrs" version = "1.0.0" @@ -1273,6 +1401,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime-sniffer" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b8b2a64cd735f1d5f17ff6701ced3cc3c54851f9448caf454cd9c923d812408" +dependencies = [ + "mime", + "url", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1523,6 +1667,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.5" @@ -1681,6 +1831,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2122,6 +2281,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "stash-clipboard" version = "0.3.6" @@ -2139,6 +2304,7 @@ dependencies = [ "inquire", "libc", "log", + "mime-sniffer", "notify-rust", "ratatui", "regex", @@ -2210,6 +2376,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tauri-winrt-notification" version = "0.7.2" @@ -2368,6 +2545,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -2514,6 +2701,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2953,6 +3158,35 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zbus" version = "5.13.2" @@ -3014,6 +3248,60 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.16" diff --git a/Cargo.toml b/Cargo.toml index a828573..0a6abd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } libc = "0.2.182" log = "0.4.29" +mime-sniffer = "0.1.3" notify-rust = { version = "4.12.0", optional = true } ratatui = "0.30.0" regex = "1.12.3" diff --git a/src/db/mod.rs b/src/db/mod.rs index ae8d814..5bbfffb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -10,7 +10,8 @@ use std::{ }; use base64::prelude::*; -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; +use mime_sniffer::MimeTypeSniffer; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; @@ -1065,26 +1066,14 @@ pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { } } - // 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 { - 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() + // For non-text/non-image data, try to sniff the MIME type + if let Some(sniffed) = data.sniff_mime_type() { + return format!("[[ binary data {} {} ]]", size_str(data.len()), sniffed); } + + // Shouldn't reach here if MIME is properly set, but just in case + info!("Mimetype sniffing failed, omitting"); + format!("[[ binary data {} ]]", size_str(data.len())) } pub fn size_str(size: usize) -> String { @@ -1963,6 +1952,15 @@ mod tests { assert_eq!(size_str(1024 * 1024), "1 MiB"); } + #[test] + fn test_preview_entry_binary_sniffed() { + // PDF magic bytes + let data = b"%PDF-1.4 fake pdf content here for testing"; + let preview = preview_entry(data, None, 100); + assert!(preview.contains("binary data")); + assert!(preview.contains("application/pdf")); + } + #[test] fn test_copy_entry_returns_data() { let db = test_db(); From 5e0599dc715f5d7ec5cfed8664a722aac9fb73d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:28:36 +0000 Subject: [PATCH 34/60] build(deps): bump ctrlc from 3.5.1 to 3.5.2 Bumps [ctrlc](https://github.com/Detegr/rust-ctrlc) from 3.5.1 to 3.5.2. - [Release notes](https://github.com/Detegr/rust-ctrlc/releases) - [Commits](https://github.com/Detegr/rust-ctrlc/compare/3.5.1...3.5.2) --- updated-dependencies: - dependency-name: ctrlc dependency-version: 3.5.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f18e409..2563e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,12 +563,12 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.30.1", + "nix 0.31.2", "windows-sys", ] @@ -1459,9 +1459,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.10.0", "cfg-if", diff --git a/Cargo.toml b/Cargo.toml index 0a6abd5..f1033ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ clap = { version = "4.5.60", features = [ "derive", "env" ] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" crossterm = "0.29.0" -ctrlc = "3.5.1" +ctrlc = "3.5.2" dirs = "6.0.0" env_logger = "0.11.8" humantime = "2.3.0" From ffdc13e8f574c8ef25dcf1766faa396bcc4fd8dc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 14:53:25 +0300 Subject: [PATCH 35/60] commands/list: allow printing in reversed order with `--reverse` Signed-off-by: NotAShelf Change-Id: I305cfdc68d877dc5d5083a76dccc62db6a6a6964 --- src/commands/list.rs | 21 ++++++++++-- src/db/mod.rs | 76 ++++++++++++++++++++++++++++++-------------- src/main.rs | 18 ++++++++--- 3 files changed, 83 insertions(+), 32 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 03309aa..3f1fd62 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -11,6 +11,7 @@ pub trait ListCommand { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError>; } @@ -20,9 +21,10 @@ impl ListCommand for SqliteClipboardDb { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError> { self - .list_entries(out, preview_width, include_expired) + .list_entries(out, preview_width, include_expired, reverse) .map(|_| ()) } } @@ -52,6 +54,9 @@ struct TuiState { /// Whether we're currently in search input mode. search_mode: bool, + + /// Whether to show entries in reverse order (oldest first). + reverse: bool, } impl TuiState { @@ -61,6 +66,7 @@ impl TuiState { include_expired: bool, window_size: usize, preview_width: u32, + reverse: bool, ) -> Result { let total = db.count_entries(include_expired, None)?; let window = if total > 0 { @@ -70,6 +76,7 @@ impl TuiState { window_size, preview_width, None, + reverse, )? } else { Vec::new() @@ -83,6 +90,7 @@ impl TuiState { dirty: false, search_query: String::new(), search_mode: false, + reverse, }) } @@ -228,6 +236,7 @@ impl TuiState { self.window_size, preview_width, search, + self.reverse, )? } else { Vec::new() @@ -266,6 +275,7 @@ impl SqliteClipboardDb { &self, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError> { use std::io::stdout; @@ -316,8 +326,13 @@ impl SqliteClipboardDb { .unwrap_or(24); let initial_height = initial_height.max(1); - let mut tui = - TuiState::new(self, include_expired, initial_height, preview_width)?; + let mut tui = TuiState::new( + self, + include_expired, + initial_height, + preview_width, + reverse, + )?; // ratatui ListState; only tracks selection within the *window* slice. let mut list_state = ListState::default(); diff --git a/src/db/mod.rs b/src/db/mod.rs index 5bbfffb..2c3921f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -89,6 +89,7 @@ pub trait ClipboardDb { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result; fn decode_entry( &self, @@ -362,17 +363,27 @@ impl SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn list_json(&self, include_expired: bool) -> Result { + pub fn list_json( + &self, + include_expired: bool, + reverse: bool, + ) -> Result { + let order = if reverse { "ASC" } else { "DESC" }; let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order}" + ) } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order}" + ) }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -594,17 +605,24 @@ impl ClipboardDb for SqliteClipboardDb { mut out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result { + let order = if reverse { "ASC" } else { "DESC" }; let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order}" + ) } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order}" + ) }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -818,38 +836,48 @@ impl SqliteClipboardDb { limit: usize, preview_width: u32, search: Option<&str>, + reverse: bool, ) -> Result, StashError> { let search_pattern = search.map(|s| { let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); format!("%{escaped}%") }); + let order = if reverse { "ASC" } else { "DESC" }; let query = match (include_expired, search_pattern.as_deref()) { (true, None) => { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" + ) }, (true, Some(_)) => { - "SELECT id, contents, mime FROM clipboard WHERE (LOWER(CAST(contents \ - AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, \ - 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard WHERE \ + (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" + ) }, (false, None) => { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC \ - LIMIT ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order} LIMIT ?1 OFFSET ?2" + ) }, (false, Some(_)) => { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) \ - ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ - ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE \ + LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) {order}, \ + id {order} LIMIT ?1 OFFSET ?2" + ) }, }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = if let Some(pattern) = search_pattern.as_deref() { diff --git a/src/main.rs b/src/main.rs index ef12ed1..fd74b1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,10 @@ enum Command { /// Show only expired entries (diagnostic, does not remove them) #[arg(long)] expired: bool, + + /// Reverse the order of entries (oldest first instead of newest first) + #[arg(long)] + reverse: bool, }, /// Decode and output clipboard entry by id @@ -245,16 +249,20 @@ fn main() -> color_eyre::eyre::Result<()> { "failed to store entry", ); }, - Some(Command::List { format, expired }) => { + Some(Command::List { + format, + expired, + reverse, + }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width, expired), + db.list(io::stdout(), cli.preview_width, expired, reverse), "failed to list entries", ); }, Some("json") => { - match db.list_json(expired) { + match db.list_json(expired, reverse) { Ok(json) => { println!("{json}"); }, @@ -269,12 +277,12 @@ fn main() -> color_eyre::eyre::Result<()> { None => { if std::io::stdout().is_terminal() { report_error( - db.list_tui(cli.preview_width, expired), + db.list_tui(cli.preview_width, expired, reverse), "failed to list entries in TUI", ); } else { report_error( - db.list(io::stdout(), cli.preview_width, expired), + db.list(io::stdout(), cli.preview_width, expired, reverse), "failed to list entries", ); } From 7184c8b68281e0a19828a7fbd3ac8c0191a78960 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 10:03:26 +0300 Subject: [PATCH 36/60] db: consolidate duplicated SQL queries Signed-off-by: NotAShelf Change-Id: I8b6889d1e420865d0a8d3b8da916d8086a6a6964 --- src/db/mod.rs | 210 ++++++++++++++++++++++++++------------------------ 1 file changed, 109 insertions(+), 101 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 2c3921f..61d3351 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -19,6 +19,97 @@ use thiserror::Error; pub const DEFAULT_MAX_ENTRY_SIZE: usize = 5_000_000; +/// Query builder helper for list operations. +/// Centralizes WHERE clause and ORDER BY generation to avoid duplication. +struct ListQueryBuilder { + include_expired: bool, + reverse: bool, + search_pattern: Option, + limit: Option, + offset: Option, +} + +impl ListQueryBuilder { + fn new(include_expired: bool, reverse: bool) -> Self { + Self { + include_expired, + reverse, + search_pattern: None, + limit: None, + offset: None, + } + } + + fn with_search(mut self, pattern: Option<&str>) -> Self { + self.search_pattern = pattern.map(|s| { + let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + format!("%{escaped}%") + }); + self + } + + fn with_pagination(mut self, offset: usize, limit: usize) -> Self { + self.offset = Some(offset); + self.limit = Some(limit); + self + } + + fn where_clause(&self) -> String { + let mut conditions = Vec::new(); + + if !self.include_expired { + conditions.push("(is_expired IS NULL OR is_expired = 0)"); + } + + if self.search_pattern.is_some() { + conditions + .push("(LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) ESCAPE '!')"); + } + + if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + } + } + + fn order_clause(&self) -> String { + let order = if self.reverse { "ASC" } else { "DESC" }; + format!("ORDER BY COALESCE(last_accessed, 0) {order}, id {order}") + } + + fn pagination_clause(&self) -> String { + match (self.limit, self.offset) { + (Some(limit), Some(offset)) => format!("LIMIT {limit} OFFSET {offset}"), + _ => String::new(), + } + } + + fn select_star_query(&self) -> String { + let where_clause = self.where_clause(); + let order_clause = self.order_clause(); + let pagination = self.pagination_clause(); + + format!( + "SELECT id, contents, mime FROM clipboard {where_clause} {order_clause} \ + {pagination}" + ) + .trim() + .to_string() + } + + fn count_query(&self) -> String { + let where_clause = self.where_clause(); + format!("SELECT COUNT(*) FROM clipboard {where_clause}") + .trim() + .to_string() + } + + fn search_param(&self) -> Option<&str> { + self.search_pattern.as_deref() + } +} + #[derive(Error, Debug)] pub enum StashError { #[error("Input is empty or too large, skipping store.")] @@ -368,19 +459,8 @@ impl SqliteClipboardDb { include_expired: bool, reverse: bool, ) -> Result { - let order = if reverse { "ASC" } else { "DESC" }; - let query = if include_expired { - format!( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order}" - ) - } else { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ - {order}" - ) - }; + let builder = ListQueryBuilder::new(include_expired, reverse); + let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) @@ -607,19 +687,8 @@ impl ClipboardDb for SqliteClipboardDb { include_expired: bool, reverse: bool, ) -> Result { - let order = if reverse { "ASC" } else { "DESC" }; - let query = if include_expired { - format!( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order}" - ) - } else { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ - {order}" - ) - }; + let builder = ListQueryBuilder::new(include_expired, reverse); + let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) @@ -780,43 +849,14 @@ impl SqliteClipboardDb { include_expired: bool, search: Option<&str>, ) -> Result { - let search_pattern = search.map(|s| { - // Avoid backslash escaping issues - let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); - format!("%{escaped}%") - }); + let builder = + ListQueryBuilder::new(include_expired, false).with_search(search); + let query = builder.count_query(); - let count: i64 = match (include_expired, search_pattern.as_deref()) { - (true, None) => { - self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) - }, - (true, Some(pattern)) => { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (LOWER(CAST(contents AS \ - TEXT)) LIKE LOWER(?1) ESCAPE '!')", - [pattern], - |r| r.get(0), - ) - }, - (false, None) => { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0)", - [], - |r| r.get(0), - ) - }, - (false, Some(pattern)) => { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) \ - ESCAPE '!')", - [pattern], - |r| r.get(0), - ) - }, + let count: i64 = if let Some(pattern) = builder.search_param() { + self.conn.query_row(&query, [pattern], |r| r.get(0)) + } else { + self.conn.query_row(&query, [], |r| r.get(0)) } .map_err(|e| StashError::ListDecode(e.to_string().into()))?; Ok(count.max(0) as usize) @@ -838,55 +878,23 @@ impl SqliteClipboardDb { search: Option<&str>, reverse: bool, ) -> Result, StashError> { - let search_pattern = search.map(|s| { - let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); - format!("%{escaped}%") - }); - - let order = if reverse { "ASC" } else { "DESC" }; - let query = match (include_expired, search_pattern.as_deref()) { - (true, None) => { - format!( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" - ) - }, - (true, Some(_)) => { - format!( - "SELECT id, contents, mime FROM clipboard WHERE \ - (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" - ) - }, - (false, None) => { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ - {order} LIMIT ?1 OFFSET ?2" - ) - }, - (false, Some(_)) => { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE \ - LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) {order}, \ - id {order} LIMIT ?1 OFFSET ?2" - ) - }, - }; + let builder = ListQueryBuilder::new(include_expired, reverse) + .with_search(search) + .with_pagination(offset, limit); + let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = if let Some(pattern) = search_pattern.as_deref() { + let mut rows = if let Some(pattern) = builder.search_param() { stmt - .query(rusqlite::params![limit as i64, offset as i64, pattern]) + .query(rusqlite::params![pattern]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? } else { stmt - .query(rusqlite::params![limit as i64, offset as i64]) + .query([]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? }; From 95bf1766cef9424ea753238cc2b824e95b53a4b5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 11:13:53 +0300 Subject: [PATCH 37/60] stash: async db operations; make hashes deterministic Signed-off-by: NotAShelf Change-Id: Iccc9980fa13a752e0e6c9fb630c28ba96a6a6964 --- Cargo.lock | 541 +++++++++++++++++++++++++++++++----------- Cargo.toml | 2 + src/commands/watch.rs | 391 ++++++++++++++++-------------- src/db/mod.rs | 97 +++++--- src/db/nonblocking.rs | 141 +++++++++++ src/main.rs | 5 +- 6 files changed, 815 insertions(+), 362 deletions(-) create mode 100644 src/db/nonblocking.rs diff --git a/Cargo.lock b/Cargo.lock index f18e409..30d0945 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-broadcast" @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -306,9 +306,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -343,15 +343,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "castaway" @@ -364,9 +364,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.53" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -520,7 +520,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", @@ -563,12 +563,12 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.30.1", + "nix 0.31.2", "windows-sys", ] @@ -676,11 +676,11 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -753,9 +753,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -763,9 +763,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -871,9 +871,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "finl_unicode" @@ -921,16 +921,52 @@ dependencies = [ ] [[package]] -name = "futures-core" -version = "0.3.31" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -945,6 +981,46 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -974,10 +1050,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1118,6 +1207,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1165,6 +1260,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1182,7 +1279,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm", "dyn-clone", "unicode-segmentation", @@ -1225,9 +1322,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -1238,9 +1335,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", @@ -1249,9 +1346,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1280,6 +1377,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" @@ -1288,11 +1391,10 @@ checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.10.0", "libc", ] @@ -1313,7 +1415,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -1382,9 +1484,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmem" @@ -1450,7 +1552,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1459,11 +1561,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1539,9 +1641,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1552,7 +1654,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -1569,7 +1671,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -1634,9 +1736,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "parking" @@ -1675,9 +1777,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -1685,9 +1787,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -1695,9 +1797,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", @@ -1708,9 +1810,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", @@ -1781,15 +1883,15 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -1818,15 +1920,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -1847,10 +1949,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "proc-macro-crate" -version = "3.4.0" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -1875,18 +1987,18 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1897,6 +2009,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1932,7 +2050,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "compact_str", "hashbrown 0.16.1", "indoc", @@ -1984,7 +2102,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.16.1", "indoc", "instability", @@ -2003,7 +2121,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2031,9 +2149,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2042,9 +2160,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rsqlite-vfs" @@ -2062,7 +2180,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2092,7 +2210,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -2107,9 +2225,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -2236,15 +2354,15 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2292,6 +2410,7 @@ name = "stash-clipboard" version = "0.3.6" dependencies = [ "base64", + "blocking", "clap", "clap-verbosity-flag", "color-eyre", @@ -2299,6 +2418,7 @@ dependencies = [ "ctrlc", "dirs", "env_logger", + "futures", "humantime", "imagesize", "inquire", @@ -2406,7 +2526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys", @@ -2441,7 +2561,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.11.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2557,18 +2677,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", "toml_datetime", @@ -2578,9 +2698,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -2663,13 +2783,13 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys", ] [[package]] @@ -2701,6 +2821,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.8" @@ -2727,12 +2853,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "atomic", - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -2773,18 +2899,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -2795,9 +2930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2805,9 +2940,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -2818,18 +2953,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] [[package]] -name = "wayland-backend" -version = "0.3.12" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa6143502b9a87f759cb6a649ca801a226f77740eb54f3951cba2227790afeb" dependencies = [ "cc", "downcast-rs", @@ -2840,11 +3009,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "log", "rustix", "wayland-backend", @@ -2853,11 +3022,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2865,11 +3034,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2878,20 +3047,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" dependencies = [ "proc-macro2", - "quick-xml 0.38.4", + "quick-xml 0.39.2", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "81d2bd69b1dadd601d0e98ef2fc9339a1b1e00cec5ee7545a77b5a0f52a90394" dependencies = [ "pkg-config", ] @@ -3136,9 +3305,91 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wl-clipboard-rs" @@ -3189,9 +3440,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -3224,9 +3475,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3304,15 +3555,15 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", @@ -3324,9 +3575,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 0a6abd5..709673f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" +blocking = "1.6.2" clap = { version = "4.5.60", features = [ "derive", "env" ] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" @@ -43,6 +44,7 @@ wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional wl-clipboard-rs = "0.9.3" [dev-dependencies] +futures = "0.3.32" tempfile = "3.26.0" [features] diff --git a/src/commands/watch.rs b/src/commands/watch.rs index fbc7239..9ac82cc 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,9 +1,32 @@ -use std::{ - collections::{BinaryHeap, hash_map::DefaultHasher}, - hash::{Hash, Hasher}, - io::Read, - time::Duration, -}; +use std::{collections::BinaryHeap, io::Read, time::Duration}; + +/// FNV-1a hasher for deterministic hashing across process runs. +/// Unlike DefaultHasher (SipHash), this produces stable hashes. +struct Fnv1aHasher { + state: u64, +} + +impl Fnv1aHasher { + const FNV_OFFSET: u64 = 0xCBF29CE484222325; + const FNV_PRIME: u64 = 0x100000001B3; + + fn new() -> Self { + Self { + state: Self::FNV_OFFSET, + } + } + + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.state ^= *byte as u64; + self.state = self.state.wrapping_mul(Self::FNV_PRIME); + } + } + + fn finish(&self) -> u64 { + self.state + } +} use smol::Timer; use wl_clipboard_rs::{ @@ -17,7 +40,7 @@ use wl_clipboard_rs::{ }, }; -use crate::db::{ClipboardDb, SqliteClipboardDb}; +use crate::db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}; /// Wrapper to provide [`Ord`] implementation for `f64` by negating values. /// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. @@ -97,6 +120,16 @@ impl ExpirationQueue { } expired } + + /// Check if the queue is empty + fn is_empty(&self) -> bool { + self.heap.is_empty() + } + + /// Get the number of entries in the queue + fn len(&self) -> usize { + self.heap.len() + } } /// Get clipboard contents using the source application's preferred MIME type. @@ -177,7 +210,7 @@ fn negotiate_mime_type( #[allow(clippy::too_many_arguments)] pub trait WatchCommand { - fn watch( + async fn watch( &self, max_dedupe_search: u64, max_items: u64, @@ -190,7 +223,7 @@ pub trait WatchCommand { } impl WatchCommand for SqliteClipboardDb { - fn watch( + async fn watch( &self, max_dedupe_search: u64, max_items: u64, @@ -200,207 +233,203 @@ impl WatchCommand for SqliteClipboardDb { min_size: Option, max_size: usize, ) { - smol::block_on(async { - log::info!( - "Starting clipboard watch daemon with MIME type preference: \ - {mime_type_preference}" - ); + let async_db = AsyncClipboardDb::new(self.db_path.clone()); + log::info!( + "Starting clipboard watch daemon with MIME type preference: \ + {mime_type_preference}" + ); - // Build expiration queue from existing entries - let mut exp_queue = ExpirationQueue::new(); - if let Ok(Some((expires_at, id))) = self.get_next_expiration() { - exp_queue.push(expires_at, id); - // Load remaining expirations (exclude already-marked expired entries) - let mut stmt = self - .conn - .prepare( - "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ - NULL AND (is_expired IS NULL OR is_expired = 0) ORDER BY \ - expires_at ASC", - ) - .ok(); - if let Some(ref mut stmt) = stmt { - let mut rows = stmt.query([]).ok(); - if let Some(ref mut rows) = rows { - while let Ok(Some(row)) = rows.next() { - if let (Ok(exp), Ok(row_id)) = - (row.get::<_, f64>(0), row.get::<_, i64>(1)) - { - // Skip first entry which is already added - if exp_queue - .heap - .iter() - .any(|(_, existing_id)| *existing_id == row_id) - { - continue; - } - exp_queue.push(exp, row_id); - } - } - } + // Build expiration queue from existing entries + let mut exp_queue = ExpirationQueue::new(); + + // Load all expirations from database asynchronously + match async_db.load_all_expirations().await { + Ok(expirations) => { + for (expires_at, id) in expirations { + exp_queue.push(expires_at, id); } - } - - // We use hashes for comparison instead of storing full contents - let mut last_hash: Option = None; - let mut buf = Vec::with_capacity(4096); - - // 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 using smart MIME negotiation - if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) { - buf.clear(); - if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_hash = Some(hash_contents(&buf)); + if !exp_queue.is_empty() { + log::info!("Loaded {} expirations from database", exp_queue.len()); } + }, + Err(e) => { + log::warn!("Failed to load expirations: {e}"); + }, + } + + // We use hashes for comparison instead of storing full contents + let mut last_hash: Option = None; + let mut buf = Vec::with_capacity(4096); + + // Helper to hash clipboard contents using FNV-1a (deterministic across + // runs) + let hash_contents = |data: &[u8]| -> u64 { + let mut hasher = Fnv1aHasher::new(); + hasher.write(data); + hasher.finish() + }; + + // Initialize with current clipboard using smart MIME negotiation + if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) { + buf.clear(); + if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + last_hash = Some(hash_contents(&buf)); } + } - loop { - // Process any pending expirations - if let Some(next_exp) = exp_queue.peek_next() { - let now = SqliteClipboardDb::now(); - if next_exp <= now { - // Expired entries to process - let expired_ids = exp_queue.pop_expired(now); - for id in expired_ids { - // Verify entry still exists and get its content_hash - let expired_hash: Option = self - .conn - .query_row( - "SELECT content_hash FROM clipboard WHERE id = ?1", - [id], - |row| row.get(0), - ) - .ok(); + let poll_interval = Duration::from_millis(500); - if let Some(stored_hash) = expired_hash { - // Mark as expired - self - .conn - .execute( - "UPDATE clipboard SET is_expired = 1 WHERE id = ?1", - [id], - ) - .ok(); + loop { + // Process any pending expirations that are due now + if let Some(next_exp) = exp_queue.peek_next() { + let now = SqliteClipboardDb::now(); + if next_exp <= now { + // Expired entries to process + let expired_ids = exp_queue.pop_expired(now); + for id in expired_ids { + // Verify entry still exists and get its content_hash + let expired_hash: Option = + match async_db.get_content_hash(id).await { + Ok(hash) => hash, + Err(e) => { + log::warn!("Failed to get content hash for entry {id}: {e}"); + None + }, + }; + + if let Some(stored_hash) = expired_hash { + // Mark as expired + if let Err(e) = async_db.mark_expired(id).await { + log::warn!("Failed to mark entry {id} as expired: {e}"); + } else { log::info!("Entry {id} marked as expired"); + } - // Check if this expired entry is currently in the clipboard - if let Ok((mut reader, _)) = - negotiate_mime_type(mime_type_preference) + // Check if this expired entry is currently in the clipboard + if let Ok((mut reader, _)) = + negotiate_mime_type(mime_type_preference) + { + let mut current_buf = Vec::new(); + if reader.read_to_end(&mut current_buf).is_ok() + && !current_buf.is_empty() { - let mut current_buf = Vec::new(); - if reader.read_to_end(&mut current_buf).is_ok() - && !current_buf.is_empty() - { - let current_hash = hash_contents(¤t_buf); - // Compare as i64 (database stores as i64) - if current_hash as i64 == stored_hash { - // Clear the clipboard since expired content is still - // there - let mut opts = Options::new(); - opts.clipboard( - wl_clipboard_rs::copy::ClipboardType::Regular, + let current_hash = hash_contents(¤t_buf); + // Convert stored i64 to u64 for comparison (preserves bit + // pattern) + if current_hash == stored_hash as u64 { + // Clear the clipboard since expired content is still + // there + let mut opts = Options::new(); + opts + .clipboard(wl_clipboard_rs::copy::ClipboardType::Regular); + if opts + .copy( + Source::Bytes(Vec::new().into()), + CopyMimeType::Autodetect, + ) + .is_ok() + { + log::info!( + "Cleared clipboard containing expired entry {id}" + ); + last_hash = None; // reset tracked hash + } else { + log::warn!( + "Failed to clear clipboard for expired entry {id}" ); - if opts - .copy( - Source::Bytes(Vec::new().into()), - CopyMimeType::Autodetect, - ) - .is_ok() - { - log::info!( - "Cleared clipboard containing expired entry {id}" - ); - last_hash = None; // reset tracked hash - } else { - log::warn!( - "Failed to clear clipboard for expired entry {id}" - ); - } } } } } } - } else { - // Sleep *precisely* until next expiration - let sleep_duration = next_exp - now; - Timer::after(Duration::from_secs_f64(sleep_duration)).await; - continue; // skip normal poll, process expirations first } } + } - // Normal clipboard polling - match negotiate_mime_type(mime_type_preference) { - 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}"); - Timer::after(Duration::from_millis(500)).await; - continue; - } + // Normal clipboard polling (always run, even when expirations are + // pending) + match negotiate_mime_type(mime_type_preference) { + 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}"); + Timer::after(Duration::from_millis(500)).await; + continue; + } - // Only store if changed and not empty - if !buf.is_empty() { - let current_hash = hash_contents(&buf); - if last_hash != Some(current_hash) { - match self.store_entry( - &buf[..], + // Only store if changed and not empty + if !buf.is_empty() { + let current_hash = hash_contents(&buf); + if last_hash != Some(current_hash) { + // Clone buf for the async operation since it needs 'static + let buf_clone = buf.clone(); + match async_db + .store_entry( + buf_clone, max_dedupe_search, max_items, - Some(excluded_apps), + Some(excluded_apps.to_vec()), min_size, max_size, - ) { - Ok(id) => { - log::info!("Stored new clipboard entry (id: {id})"); - last_hash = Some(current_hash); + ) + .await + { + Ok(id) => { + log::info!("Stored new clipboard entry (id: {id})"); + last_hash = Some(current_hash); - // Set expiration if configured - if let Some(duration) = expire_after { - let expires_at = - SqliteClipboardDb::now() + duration.as_secs_f64(); - self.set_expiration(id, expires_at).ok(); + // Set expiration if configured + if let Some(duration) = expire_after { + let expires_at = + SqliteClipboardDb::now() + duration.as_secs_f64(); + if let Err(e) = + async_db.set_expiration(id, expires_at).await + { + log::warn!( + "Failed to set expiration for entry {id}: {e}" + ); + } else { exp_queue.push(expires_at, id); } - }, - 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); - }, - } + } + }, + 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); + }, } } - }, - Err(e) => { - let error_msg = e.to_string(); - if !error_msg.contains("empty") { - log::error!("Failed to get clipboard contents: {e}"); - } - }, - } - - // Normal poll interval (only if no expirations pending) - if exp_queue.peek_next().is_none() { - Timer::after(Duration::from_millis(500)).await; - } + } + }, + Err(e) => { + let error_msg = e.to_string(); + if !error_msg.contains("empty") { + log::error!("Failed to get clipboard contents: {e}"); + } + }, } - }); + + // Calculate sleep time: min of poll interval and time until next + // expiration + let sleep_duration = if let Some(next_exp) = exp_queue.peek_next() { + let now = SqliteClipboardDb::now(); + let time_to_exp = (next_exp - now).max(0.0); + poll_interval.min(Duration::from_secs_f64(time_to_exp)) + } else { + poll_interval + }; + Timer::after(sleep_duration).await; + } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 61d3351..1f58cdf 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,14 +1,44 @@ use std::{ - collections::hash_map::DefaultHasher, env, fmt, fs, - hash::{Hash, Hasher}, io::{BufRead, BufReader, Read, Write}, + path::PathBuf, str, sync::OnceLock, }; +pub mod nonblocking; + +/// FNV-1a hasher for deterministic hashing across process runs. +/// Unlike DefaultHasher (SipHash with random seed), this produces stable +/// hashes. +pub struct Fnv1aHasher { + state: u64, +} + +impl Fnv1aHasher { + const FNV_OFFSET: u64 = 0xCBF29CE484222325; + const FNV_PRIME: u64 = 0x100000001B3; + + pub fn new() -> Self { + Self { + state: Self::FNV_OFFSET, + } + } + + pub fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.state ^= *byte as u64; + self.state = self.state.wrapping_mul(Self::FNV_PRIME); + } + } + + pub fn finish(&self) -> u64 { + self.state + } +} + use base64::prelude::*; use log::{debug, error, info, warn}; use mime_sniffer::MimeTypeSniffer; @@ -210,11 +240,15 @@ impl fmt::Display for Entry { } pub struct SqliteClipboardDb { - pub conn: Connection, + pub conn: Connection, + pub db_path: PathBuf, } impl SqliteClipboardDb { - pub fn new(mut conn: Connection) -> Result { + pub fn new( + mut conn: Connection, + db_path: PathBuf, + ) -> Result { conn .pragma_update(None, "synchronous", "OFF") .map_err(|e| { @@ -449,7 +483,7 @@ impl SqliteClipboardDb { // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); - Ok(Self { conn }) + Ok(Self { conn, db_path }) } } @@ -535,8 +569,8 @@ impl ClipboardDb for SqliteClipboardDb { } // Calculate content hash for deduplication - let mut hasher = DefaultHasher::new(); - buf.hash(&mut hasher); + let mut hasher = Fnv1aHasher::new(); + hasher.write(&buf); #[allow(clippy::cast_possible_wrap)] let content_hash = hasher.finish() as i64; @@ -940,20 +974,6 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Trim(e.to_string().into())) } - /// Get the earliest expiration (timestamp, id) for heap initialization - pub fn get_next_expiration(&self) -> Result, StashError> { - match self.conn.query_row( - "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ - ORDER BY expires_at ASC LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) { - Ok(result) => Ok(Some(result)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(StashError::Store(e.to_string().into())), - } - } - /// Set expiration timestamp for an entry pub fn set_expiration( &self, @@ -1338,7 +1358,8 @@ mod tests { fn test_db() -> SqliteClipboardDb { let conn = Connection::open_in_memory().expect("Failed to open in-memory db"); - SqliteClipboardDb::new(conn).expect("Failed to create test database") + SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create test database") } fn get_schema_version(conn: &Connection) -> rusqlite::Result { @@ -1369,7 +1390,8 @@ mod tests { 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"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), @@ -1419,7 +1441,8 @@ mod tests { assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) @@ -1461,7 +1484,8 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) @@ -1504,7 +1528,8 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) @@ -1535,12 +1560,13 @@ mod tests { ) .expect("Failed to create table"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .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 db2 = SqliteClipboardDb::new(db.conn, db.db_path) + .expect("Failed to create database again"); let version_after_second = get_schema_version(&db2.conn).expect("Failed to get version"); @@ -1553,7 +1579,8 @@ mod tests { 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 db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); let test_data = b"Hello, World!"; let cursor = std::io::Cursor::new(test_data.to_vec()); @@ -1589,7 +1616,8 @@ mod tests { 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 db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); let test_data = b"Test content for copy"; let cursor = std::io::Cursor::new(test_data.to_vec()); @@ -1608,8 +1636,8 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(1100)); - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - test_data.hash(&mut hasher); + let mut hasher = Fnv1aHasher::new(); + hasher.write(test_data); let content_hash = hasher.finish() as i64; let now = std::time::SystemTime::now() @@ -1670,7 +1698,8 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn).expect("Failed to get version"), diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs new file mode 100644 index 0000000..9640e26 --- /dev/null +++ b/src/db/nonblocking.rs @@ -0,0 +1,141 @@ +use std::path::PathBuf; + +use rusqlite::OptionalExtension; + +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; + +/// Async wrapper for database operations that runs blocking operations +/// on a thread pool to avoid blocking the async runtime. +/// +/// Since rusqlite::Connection is not Send, we store the database path +/// and open a new connection for each operation. +pub struct AsyncClipboardDb { + db_path: PathBuf, +} + +impl AsyncClipboardDb { + pub fn new(db_path: PathBuf) -> Self { + Self { db_path } + } + + pub async fn store_entry( + &self, + data: Vec, + max_dedupe_search: u64, + max_items: u64, + excluded_apps: Option>, + min_size: Option, + max_size: usize, + ) -> Result { + let path = self.db_path.clone(); + blocking::unblock(move || { + let db = Self::open_db_internal(&path)?; + db.store_entry( + std::io::Cursor::new(data), + max_dedupe_search, + max_items, + excluded_apps.as_deref(), + min_size, + max_size, + ) + }) + .await + } + + pub async fn set_expiration( + &self, + id: i64, + expires_at: f64, + ) -> Result<(), StashError> { + let path = self.db_path.clone(); + blocking::unblock(move || { + let db = Self::open_db_internal(&path)?; + db.set_expiration(id, expires_at) + }) + .await + } + + pub async fn load_all_expirations( + &self, + ) -> Result, StashError> { + let path = self.db_path.clone(); + blocking::unblock(move || { + let db = Self::open_db_internal(&path)?; + let mut stmt = db + .conn + .prepare( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ + AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC", + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut expirations = Vec::new(); + + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + let exp = row + .get::<_, f64>(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let id = row + .get::<_, i64>(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + expirations.push((exp, id)); + } + Ok(expirations) + }) + .await + } + + pub async fn get_content_hash( + &self, + id: i64, + ) -> Result, StashError> { + let path = self.db_path.clone(); + blocking::unblock(move || { + let db = Self::open_db_internal(&path)?; + let result: Option = db + .conn + .query_row( + "SELECT content_hash FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .optional() + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok(result) + }) + .await + } + + pub async fn mark_expired(&self, id: i64) -> Result<(), StashError> { + let path = self.db_path.clone(); + blocking::unblock(move || { + let db = Self::open_db_internal(&path)?; + db.conn + .execute("UPDATE clipboard SET is_expired = 1 WHERE id = ?1", [id]) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + }) + .await + } + + fn open_db_internal(path: &PathBuf) -> Result { + let conn = rusqlite::Connection::open(path).map_err(|e| { + StashError::Store(format!("Failed to open database: {e}").into()) + })?; + SqliteClipboardDb::new(conn, path.clone()) + } +} + +impl Clone for AsyncClipboardDb { + fn clone(&self) -> Self { + Self { + db_path: self.db_path.clone(), + } + } +} diff --git a/src/main.rs b/src/main.rs index fd74b1f..2c2f6e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -228,7 +228,7 @@ fn main() -> color_eyre::eyre::Result<()> { } let conn = rusqlite::Connection::open(&db_path)?; - let db = db::SqliteClipboardDb::new(conn)?; + let db = db::SqliteClipboardDb::new(conn, db_path)?; match cli.command { Some(Command::Store) => { @@ -476,7 +476,8 @@ fn main() -> color_eyre::eyre::Result<()> { &mime_type, cli.min_size, cli.max_size, - ); + ) + .await; }, None => { From cf5b1e82055d1f58d673a04e98a110adb04cfe1c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 13:04:31 +0300 Subject: [PATCH 38/60] db: tests for determinism & async ops Signed-off-by: NotAShelf Change-Id: I2591e607a945c0aaa28a75247fc638436a6a6964 --- src/db/mod.rs | 106 +++++++++++++++++++++++ src/db/nonblocking.rs | 190 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 292 insertions(+), 4 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 1f58cdf..62c2756 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2047,4 +2047,110 @@ mod tests { assert_eq!(contents, data.to_vec()); assert_eq!(mime, Some("text/plain".to_string())); } + + #[test] + fn test_fnv1a_hasher_deterministic() { + // Same input should produce same hash + let data = b"test data"; + + let mut hasher1 = Fnv1aHasher::new(); + hasher1.write(data); + let hash1 = hasher1.finish(); + + let mut hasher2 = Fnv1aHasher::new(); + hasher2.write(data); + let hash2 = hasher2.finish(); + + assert_eq!(hash1, hash2, "FNV-1a should produce deterministic hashes"); + } + + #[test] + fn test_fnv1a_hasher_different_input() { + // Different inputs should (almost certainly) produce different hashes + let data1 = b"test data 1"; + let data2 = b"test data 2"; + + let mut hasher1 = Fnv1aHasher::new(); + hasher1.write(data1); + let hash1 = hasher1.finish(); + + let mut hasher2 = Fnv1aHasher::new(); + hasher2.write(data2); + let hash2 = hasher2.finish(); + + assert_ne!( + hash1, hash2, + "Different data should produce different hashes" + ); + } + + #[test] + fn test_fnv1a_hasher_known_values() { + // Test against known FNV-1a hash values + let mut hasher = Fnv1aHasher::new(); + hasher.write(b""); + assert_eq!( + hasher.finish(), + 0xCBF29CE484222325, + "Empty string hash mismatch" + ); + + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"a"); + assert_eq!( + hasher.finish(), + 0xAF63DC4C8601EC8C, + "Single byte hash mismatch" + ); + + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"hello"); + assert_eq!(hasher.finish(), 0xA430D84680AABD0B, "Hello hash mismatch"); + } + + #[test] + fn test_fnv1a_hash_stored_in_db() { + // Verify hash is stored correctly and can be retrieved + let db = test_db(); + let data = b"test content for hashing"; + + let id = db + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + DEFAULT_MAX_ENTRY_SIZE, + ) + .expect("Failed to store"); + + // Retrieve the stored hash + let stored_hash: i64 = db + .conn + .query_row( + "SELECT content_hash FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .expect("Failed to get hash"); + + // Calculate hash independently + let mut hasher = Fnv1aHasher::new(); + hasher.write(data); + let calculated_hash = hasher.finish() as i64; + + assert_eq!( + stored_hash, calculated_hash, + "Stored hash should match calculated hash" + ); + + // Verify round-trip: convert back to u64 and compare + let stored_hash_u64 = stored_hash as u64; + let calculated_hash_u64 = hasher.finish(); + assert_eq!( + stored_hash_u64, calculated_hash_u64, + "Bit pattern should be preserved in i64/u64 conversion" + ); + } } diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index 9640e26..bdcc596 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -5,10 +5,9 @@ use rusqlite::OptionalExtension; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; /// Async wrapper for database operations that runs blocking operations -/// on a thread pool to avoid blocking the async runtime. -/// -/// Since rusqlite::Connection is not Send, we store the database path -/// and open a new connection for each operation. +/// on a thread pool to avoid blocking the async runtime. Since +/// [`rusqlite::Connection`] is not Send, we store the database path and open a +/// new connection for each operation. pub struct AsyncClipboardDb { db_path: PathBuf, } @@ -139,3 +138,186 @@ impl Clone for AsyncClipboardDb { } } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use tempfile::tempdir; + + use super::*; + + fn setup_test_db() -> (AsyncClipboardDb, tempfile::TempDir) { + let temp_dir = tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test.db"); + + // Create initial database + { + let conn = + rusqlite::Connection::open(&db_path).expect("Failed to open database"); + crate::db::SqliteClipboardDb::new(conn, db_path.clone()) + .expect("Failed to create database"); + } + + let async_db = AsyncClipboardDb::new(db_path); + (async_db, temp_dir) + } + + #[test] + fn test_async_store_entry() { + smol::block_on(async { + let (async_db, _temp_dir) = setup_test_db(); + let data = b"async test data"; + + let id = async_db + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .await + .expect("Failed to store entry"); + + assert!(id > 0, "Should return positive id"); + + // Verify it was stored by checking content hash + let hash = async_db + .get_content_hash(id) + .await + .expect("Failed to get hash") + .expect("Hash should exist"); + + // Calculate expected hash + let mut hasher = crate::db::Fnv1aHasher::new(); + hasher.write(data); + let expected_hash = hasher.finish() as i64; + + assert_eq!(hash, expected_hash, "Stored hash should match"); + }); + } + + #[test] + fn test_async_set_expiration_and_load() { + smol::block_on(async { + let (async_db, _temp_dir) = setup_test_db(); + let data = b"expiring entry"; + + let id = async_db + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .await + .expect("Failed to store entry"); + + let expires_at = 1234567890.5; + async_db + .set_expiration(id, expires_at) + .await + .expect("Failed to set expiration"); + + // Load all expirations + let expirations = async_db + .load_all_expirations() + .await + .expect("Failed to load expirations"); + + assert_eq!(expirations.len(), 1, "Should have one expiration"); + assert!( + (expirations[0].0 - expires_at).abs() < 0.001, + "Expiration time should match" + ); + assert_eq!(expirations[0].1, id, "Expiration id should match"); + }); + } + + #[test] + fn test_async_mark_expired() { + smol::block_on(async { + let (async_db, _temp_dir) = setup_test_db(); + let data = b"entry to expire"; + + let id = async_db + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .await + .expect("Failed to store entry"); + + async_db + .mark_expired(id) + .await + .expect("Failed to mark as expired"); + + // Load expirations, this should be empty since entry is now marked + // expired + let expirations = async_db + .load_all_expirations() + .await + .expect("Failed to load expirations"); + + assert!( + expirations.is_empty(), + "Expired entries should not be loaded" + ); + }); + } + + #[test] + fn test_async_get_content_hash_not_found() { + smol::block_on(async { + let (async_db, _temp_dir) = setup_test_db(); + + let hash = async_db + .get_content_hash(999999) + .await + .expect("Should not fail on non-existent entry"); + + assert!(hash.is_none(), "Hash should be None for non-existent entry"); + }); + } + + #[test] + fn test_async_clone() { + let (async_db, _temp_dir) = setup_test_db(); + let cloned = async_db.clone(); + + smol::block_on(async { + // Both should work independently + let data = b"clone test"; + + let id1 = async_db + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .await + .expect("Failed with original"); + + let id2 = cloned + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .await + .expect("Failed with clone"); + + assert_ne!(id1, id2, "Should store as separate entries"); + }); + } + + #[test] + fn test_async_concurrent_operations() { + smol::block_on(async { + let (async_db, _temp_dir) = setup_test_db(); + + // Spawn multiple concurrent store operations + let futures: Vec<_> = (0..5) + .map(|i| { + let db = async_db.clone(); + let data = format!("concurrent test {}", i).into_bytes(); + smol::spawn(async move { + db.store_entry(data, 100, 1000, None, None, 5_000_000).await + }) + }) + .collect(); + + let results: Result, _> = futures::future::join_all(futures) + .await + .into_iter() + .collect(); + + let ids = results.expect("All stores should succeed"); + assert_eq!(ids.len(), 5, "Should have 5 entries"); + + // All IDs should be unique + let unique_ids: HashSet<_> = ids.iter().collect(); + assert_eq!(unique_ids.len(), 5, "All IDs should be unique"); + }); + } +} From 0865a1f1393f14eb6676e89d86e5552367f78d27 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 14:06:12 +0300 Subject: [PATCH 39/60] commands/list: debounce for rapid copy operations Tracks the entry ID currently being copied in `TuiState` to prevent concurrent `copy_entry()` calls on the same entity. Otherwise we hit a race condition. Fun! Track the entry ID currently being copied in TuiState to prevent concurrent copy_entry() calls on the same entry. Fixes database race conditions when users trigger copy commands in rapid succession. Signed-off-by: NotAShelf Change-Id: If8e8fe56bf6dc35960e47decf59636116a6a6964 --- src/commands/list.rs | 85 +++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 3f1fd62..e9da836 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -57,6 +57,9 @@ struct TuiState { /// Whether to show entries in reverse order (oldest first). reverse: bool, + + /// ID of entry currently being copied. + copying_entry: Option, } impl TuiState { @@ -91,6 +94,7 @@ impl TuiState { search_query: String::new(), search_mode: false, reverse, + copying_entry: None, }) } @@ -678,42 +682,51 @@ impl SqliteClipboardDb { if actions.copy && let Some(&(id, ..)) = tui.selected_entry() { - match self.copy_entry(id) { - Ok((new_id, contents, mime)) => { - if new_id != id { - tui.dirty = true; - } - 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}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to fetch entry: {e}")) - .show(); - }, + if tui.copying_entry == Some(id) { + log::debug!( + "Skipping duplicate copy for entry {id} (already in \ + progress)" + ); + } else { + tui.copying_entry = Some(id); + match self.copy_entry(id) { + Ok((new_id, contents, mime)) => { + if new_id != id { + tui.dirty = true; + } + 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}"); + let _ = Notification::new() + .summary("Stash") + .body(&format!("Failed to fetch entry: {e}")) + .show(); + }, + } + tui.copying_entry = None; } } } From 373affabee8ca562a14cdc3634ea7bf52923fa56 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 14:27:10 +0300 Subject: [PATCH 40/60] db: improve content hashing; cache only positive scan result Signed-off-by: NotAShelf Change-Id: If8035bf1dcd598a992762b9c714253406a6a6964 --- src/commands/store.rs | 1 + src/commands/watch.rs | 5 +- src/db/mod.rs | 113 +++++++++++++++++++++++++++++++++++++----- src/db/nonblocking.rs | 15 +++--- 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/src/commands/store.rs b/src/commands/store.rs index 3854b16..af683d7 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -38,6 +38,7 @@ impl StoreCommand for SqliteClipboardDb { Some(excluded_apps), min_size, max_size, + None, // no pre-computed hash for CLI store )?; log::info!("Entry stored"); } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 9ac82cc..133cf68 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -363,6 +363,8 @@ impl WatchCommand for SqliteClipboardDb { if last_hash != Some(current_hash) { // Clone buf for the async operation since it needs 'static let buf_clone = buf.clone(); + #[allow(clippy::cast_possible_wrap)] + let content_hash = Some(current_hash as i64); match async_db .store_entry( buf_clone, @@ -371,6 +373,7 @@ impl WatchCommand for SqliteClipboardDb { Some(excluded_apps.to_vec()), min_size, max_size, + content_hash, ) .await { @@ -433,7 +436,7 @@ impl WatchCommand for SqliteClipboardDb { } } -/// Unit-testable helper: given ordered offers and a preference, return the +/// Given ordered offers and a preference, return the /// chosen MIME type. This mirrors the selection logic in /// [`negotiate_mime_type`] without requiring a Wayland connection. #[cfg(test)] diff --git a/src/db/mod.rs b/src/db/mod.rs index 62c2756..facaa99 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -5,11 +5,67 @@ use std::{ io::{BufRead, BufReader, Read, Write}, path::PathBuf, str, - sync::OnceLock, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, }; pub mod nonblocking; +/// Cache for process scanning results to avoid expensive `/proc` reads on every +/// store operation. TTL of 5 seconds balances freshness with performance. +struct ProcessCache { + last_scan: Instant, + excluded_app: Option, +} + +impl ProcessCache { + const TTL: Duration = Duration::from_secs(5); + + /// Check cache for recently active excluded app. + /// Only caches positive results (when an excluded app IS found). + /// Negative results (no excluded apps) are never cached to ensure + /// we don't miss exclusions when users switch apps. + fn get(excluded_apps: &[String]) -> Option { + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| { + Mutex::new(ProcessCache { + last_scan: Instant::now() - Self::TTL, /* Expire immediately on + * first use */ + excluded_app: None, + }) + }); + + if let Ok(mut cache) = cache.lock() { + // Check if we have a valid cached positive result + if cache.last_scan.elapsed() < Self::TTL + && let Some(ref app) = cache.excluded_app + { + // Verify the cached app is still in the exclusion list + if app_matches_exclusion(app, excluded_apps) { + return Some(app.clone()); + } + } + + // No valid cache, scan and only cache positive results + let result = get_recently_active_excluded_app_uncached(excluded_apps); + if result.is_some() { + cache.last_scan = Instant::now(); + cache.excluded_app = result.clone(); + } else { + // Don't cache negative results. We expire cache immediately so next + // call will rescan. This ensures we don't miss exclusions when user + // switches from non-excluded to excluded app. + cache.last_scan = Instant::now() - Self::TTL; + cache.excluded_app = None; + } + result + } else { + // Lock poisoned - fall back to uncached + get_recently_active_excluded_app_uncached(excluded_apps) + } + } +} + /// FNV-1a hasher for deterministic hashing across process runs. /// Unlike DefaultHasher (SipHash with random seed), this produces stable /// hashes. @@ -187,6 +243,18 @@ pub enum StashError { } pub trait ClipboardDb { + /// Store a new clipboard entry. + /// + /// # Arguments + /// * `input` - Reader for the clipboard content + /// * `max_dedupe_search` - Maximum number of recent entries to check for + /// duplicates + /// * `max_items` - Maximum total entries to keep in database + /// * `excluded_apps` - List of app names to exclude + /// * `min_size` - Minimum content size (None for no minimum) + /// * `max_size` - Maximum content size + /// * `content_hash` - Optional pre-computed content hash (avoids re-hashing) + #[allow(clippy::too_many_arguments)] fn store_entry( &self, input: impl Read, @@ -195,6 +263,7 @@ pub trait ClipboardDb { excluded_apps: Option<&[String]>, min_size: Option, max_size: usize, + content_hash: Option, ) -> Result; fn deduplicate_by_hash( @@ -308,8 +377,8 @@ impl SqliteClipboardDb { })?; } - // Add content_hash column if it doesn't exist - // Migration MUST be done to avoid breaking existing installations. + // Add content_hash column if it doesn't exist. Migration MUST be done to + // avoid breaking existing installations. if schema_version < 2 { let has_content_hash: bool = tx .query_row( @@ -546,6 +615,7 @@ impl ClipboardDb for SqliteClipboardDb { excluded_apps: Option<&[String]>, min_size: Option, max_size: usize, + content_hash: Option, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() { @@ -568,11 +638,14 @@ impl ClipboardDb for SqliteClipboardDb { return Err(StashError::AllWhitespace); } - // Calculate content hash for deduplication - let mut hasher = Fnv1aHasher::new(); - hasher.write(&buf); - #[allow(clippy::cast_possible_wrap)] - let content_hash = hasher.finish() as i64; + // Use pre-computed hash if provided, otherwise calculate it + let content_hash = content_hash.unwrap_or_else(|| { + let mut hasher = Fnv1aHasher::new(); + hasher.write(&buf); + #[allow(clippy::cast_possible_wrap)] + let hash = hasher.finish() as i64; + hash + }); let mime = crate::mime::detect_mime(&buf); @@ -1181,7 +1254,8 @@ fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool { } // Strategy 2: Check recently active processes (timing correlation) - if let Some(active_app) = get_recently_active_excluded_app(excluded_apps) { + // Use cached results to avoid expensive /proc scanning + if let Some(active_app) = ProcessCache::get(excluded_apps) { debug!("Clipboard excluded: recent activity from {active_app}"); return true; } @@ -1212,7 +1286,8 @@ fn get_focused_window_app() -> Option { } /// Check for recently active excluded apps using CPU and I/O activity. -fn get_recently_active_excluded_app( +/// This is the uncached version - use `ProcessCache::get()` for cached access. +fn get_recently_active_excluded_app_uncached( excluded_apps: &[String], ) -> Option { let proc_dir = std::path::Path::new("/proc"); @@ -1586,7 +1661,7 @@ mod tests { let cursor = std::io::Cursor::new(test_data.to_vec()); let id = db - .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None) .expect("Failed to store entry"); let content_hash: Option = db @@ -1622,7 +1697,7 @@ mod tests { 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, None, DEFAULT_MAX_ENTRY_SIZE) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None) .expect("Failed to store entry A"); let original_last_accessed: i64 = db @@ -1725,6 +1800,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store URI list"); @@ -1758,6 +1834,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store image"); @@ -1786,6 +1863,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store first"); let _id2 = db @@ -1796,6 +1874,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store second"); @@ -1831,6 +1910,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); } @@ -1852,6 +1932,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1866,6 +1947,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1882,6 +1964,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ); assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } @@ -1897,6 +1980,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); @@ -1923,6 +2007,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); db.store_entry( @@ -1932,6 +2017,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); @@ -1959,6 +2045,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); } @@ -2038,6 +2125,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); @@ -2122,6 +2210,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index bdcc596..d45d905 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -25,6 +25,7 @@ impl AsyncClipboardDb { excluded_apps: Option>, min_size: Option, max_size: usize, + content_hash: Option, ) -> Result { let path = self.db_path.clone(); blocking::unblock(move || { @@ -36,6 +37,7 @@ impl AsyncClipboardDb { excluded_apps.as_deref(), min_size, max_size, + content_hash, ) }) .await @@ -170,7 +172,7 @@ mod tests { let data = b"async test data"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed to store entry"); @@ -199,7 +201,7 @@ mod tests { let data = b"expiring entry"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed to store entry"); @@ -231,7 +233,7 @@ mod tests { let data = b"entry to expire"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed to store entry"); @@ -278,12 +280,12 @@ mod tests { let data = b"clone test"; let id1 = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed with original"); let id2 = cloned - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed with clone"); @@ -302,7 +304,8 @@ mod tests { let db = async_db.clone(); let data = format!("concurrent test {}", i).into_bytes(); smol::spawn(async move { - db.store_entry(data, 100, 1000, None, None, 5_000_000).await + db.store_entry(data, 100, 1000, None, None, 5_000_000, None) + .await }) }) .collect(); From b1f43bdf7fd348d1cde18accc6ffa01cb432831d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 15:14:02 +0300 Subject: [PATCH 41/60] db: replace \`CHECKED\` atomic flag with pattern-keyed regex cache Signed-off-by: NotAShelf Change-Id: I9d5fa5212c5418ce6bca02d05149e1356a6a6964 --- src/commands/list.rs | 4 +- src/commands/watch.rs | 6 +- src/db/mod.rs | 115 ++++++++++++++++++++++++++++++-------- src/main.rs | 4 +- src/multicall/wl_paste.rs | 12 ++-- 5 files changed, 104 insertions(+), 37 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index e9da836..7d289ad 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -412,7 +412,7 @@ impl SqliteClipboardDb { }, (KeyCode::Enter, _) => actions.copy = true, (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - actions.delete = true + actions.delete = true; }, (KeyCode::Char('/'), _) => actions.toggle_search = true, _ => {}, @@ -697,7 +697,7 @@ impl SqliteClipboardDb { 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()), + Some(ref m) => MimeType::Specific(m.clone().clone()), None => MimeType::Text, }; let copy_result = opts diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 133cf68..c5ae423 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,7 +1,7 @@ use std::{collections::BinaryHeap, io::Read, time::Duration}; /// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike DefaultHasher (SipHash), this produces stable hashes. +/// Unlike `DefaultHasher` (`SipHash`), this produces stable hashes. struct Fnv1aHasher { state: u64, } @@ -18,7 +18,7 @@ impl Fnv1aHasher { fn write(&mut self, bytes: &[u8]) { for byte in bytes { - self.state ^= *byte as u64; + self.state ^= u64::from(*byte); self.state = self.state.wrapping_mul(Self::FNV_PRIME); } } @@ -82,7 +82,7 @@ impl std::cmp::Ord for Neg { } /// Min-heap for tracking entry expirations with sub-second precision. -/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior. +/// Uses Neg wrapper to turn `BinaryHeap` (max-heap) into min-heap behavior. #[derive(Debug, Default)] struct ExpirationQueue { heap: BinaryHeap<(Neg, i64)>, diff --git a/src/db/mod.rs b/src/db/mod.rs index facaa99..6e32381 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -29,7 +29,7 @@ impl ProcessCache { static CACHE: OnceLock> = OnceLock::new(); let cache = CACHE.get_or_init(|| { Mutex::new(ProcessCache { - last_scan: Instant::now() - Self::TTL, /* Expire immediately on + last_scan: Instant::now().checked_sub(Self::TTL).unwrap(), /* Expire immediately on * first use */ excluded_app: None, }) @@ -55,7 +55,7 @@ impl ProcessCache { // Don't cache negative results. We expire cache immediately so next // call will rescan. This ensures we don't miss exclusions when user // switches from non-excluded to excluded app. - cache.last_scan = Instant::now() - Self::TTL; + cache.last_scan = Instant::now().checked_sub(Self::TTL).unwrap(); cache.excluded_app = None; } result @@ -67,7 +67,7 @@ impl ProcessCache { } /// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike DefaultHasher (SipHash with random seed), this produces stable +/// Unlike `DefaultHasher` (`SipHash` with random seed), this produces stable /// hashes. pub struct Fnv1aHasher { state: u64, @@ -85,7 +85,7 @@ impl Fnv1aHasher { pub fn write(&mut self, bytes: &[u8]) { for byte in bytes { - self.state ^= *byte as u64; + self.state ^= u64::from(*byte); self.state = self.state.wrapping_mul(Self::FNV_PRIME); } } @@ -1129,31 +1129,41 @@ impl SqliteClipboardDb { /// # Returns /// /// `Some(Regex)` if present and valid, `None` otherwise. +/// +/// # Note +/// +/// This function checks environment variables on every call to pick up +/// changes made after daemon startup. Regex compilation is cached by +/// pattern to avoid recompilation. fn load_sensitive_regex() -> Option { - static REGEX_CACHE: OnceLock> = OnceLock::new(); - static CHECKED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); + // Get the current pattern from env vars + let pattern = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{regex_path}/clipboard_filter"); + fs::read_to_string(&file).ok().map(|s| s.trim().to_string()) + } else { + env::var("STASH_SENSITIVE_REGEX").ok() + }?; - if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) { - CHECKED.store(true, std::sync::atomic::Ordering::Relaxed); + // Cache compiled regexes by pattern to avoid recompilation + static REGEX_CACHE: OnceLock< + Mutex>, + > = OnceLock::new(); + let cache = + REGEX_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())); - 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); + // Check cache first + if let Ok(cache) = cache.lock() + && let Some(regex) = cache.get(&pattern) + { + return Some(regex.clone()); } - REGEX_CACHE.get().and_then(std::clone::Clone::clone) + // Compile and cache + Regex::new(&pattern).ok().inspect(|regex| { + if let Ok(mut cache) = cache.lock() { + cache.insert(pattern.clone(), regex.clone()); + } + }) } pub fn extract_id(input: &str) -> Result { @@ -2242,4 +2252,61 @@ mod tests { "Bit pattern should be preserved in i64/u64 conversion" ); } + + /// Verify that regex loading picks up env var changes. This was broken + /// because CHECKED flag prevented re-checking after first call + #[test] + fn test_sensitive_regex_env_var_change_detection() { + // XXX: This test manipulates environment variables which affects + // parallel tests. We use a unique pattern to avoid conflicts. + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); + let test_id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + + // Test 1: No env var set initially + let var_name = format!("STASH_SENSITIVE_REGEX_TEST_{}", test_id); + unsafe { + env::remove_var(&var_name); + } + + // Temporarily override the function to use our test var + // Since we can't easily mock env::var, we test the logic indirectly + // by verifying the new implementation checks every time + + // Call multiple times, ensure no panic and behavior is + // consistent + let _ = load_sensitive_regex(); + let _ = load_sensitive_regex(); + let _ = load_sensitive_regex(); + + // If we got here without deadlocks or panics, the caching logic works + // The actual env var change detection is verified by the implementation: + // - Preivously CHECKED atomic prevented re-checking + // - Now we check env vars every call, only caches compiled Regex objects + } + + /// Test that regex compilation is cached by pattern + #[test] + fn test_sensitive_regex_caching_by_pattern() { + // This test verifies that the regex cache works correctly + // by ensuring multiple calls don't cause issues. + + // Call multiple times, should use cache after first compilation + let result1 = load_sensitive_regex(); + let result2 = load_sensitive_regex(); + let result3 = load_sensitive_regex(); + + // All results should be consistent + assert_eq!( + result1.is_some(), + result2.is_some(), + "Regex loading should be deterministic" + ); + assert_eq!( + result2.is_some(), + result3.is_some(), + "Regex loading should be deterministic" + ); + } } diff --git a/src/main.rs b/src/main.rs index 2c2f6e0..e2602aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -397,7 +397,7 @@ fn main() -> color_eyre::eyre::Result<()> { if expired { match db.cleanup_expired() { Ok(count) => { - log::info!("Wiped {} expired entries", count); + log::info!("Wiped {count} expired entries"); }, Err(e) => { log::error!("failed to wipe expired entries: {e}"); @@ -421,7 +421,7 @@ fn main() -> color_eyre::eyre::Result<()> { DbAction::Stats => { match db.stats() { Ok(stats) => { - println!("{}", stats); + println!("{stats}"); }, Err(e) => { log::error!("failed to get database stats: {e}"); diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index af686c4..4b828b5 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -360,7 +360,7 @@ fn execute_watch_command( /// Select the best MIME type from available types when none is specified. /// Prefers specific content types (image/*, application/*) over generic -/// text representations (TEXT, STRING, UTF8_STRING). +/// text representations (TEXT, STRING, `UTF8_STRING`). fn select_best_mime_type( types: &std::collections::HashSet, ) -> Option { @@ -421,7 +421,7 @@ fn handle_regular_paste( let selected_type = available_types.as_ref().and_then(select_best_mime_type); let mime_type = if let Some(ref best) = selected_type { - log::debug!("Auto-selecting MIME type: {}", best); + log::debug!("Auto-selecting MIME type: {best}"); PasteMimeType::Specific(best) } else { get_paste_mime_type(args.mime_type.as_deref()) @@ -461,14 +461,14 @@ fn handle_regular_paste( // Only add newline for text content, not binary data // Check if the MIME type indicates text content - let is_text_content = if !types.is_empty() { + let is_text_content = if types.is_empty() { + // If no MIME type, check if content is valid UTF-8 + std::str::from_utf8(&buf).is_ok() + } else { types.starts_with("text/") || types == "application/json" || types == "application/xml" || types == "application/x-sh" - } else { - // If no MIME type, check if content is valid UTF-8 - std::str::from_utf8(&buf).is_ok() }; if !args.no_newline From 3faadd709f15829aab09daf88be020cbd75be0f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:36:04 +0000 Subject: [PATCH 42/60] build(deps): bump libc from 0.2.182 to 0.2.183 Bumps [libc](https://github.com/rust-lang/libc) from 0.2.182 to 0.2.183. - [Release notes](https://github.com/rust-lang/libc/releases) - [Changelog](https://github.com/rust-lang/libc/blob/0.2.183/CHANGELOG.md) - [Commits](https://github.com/rust-lang/libc/compare/0.2.182...0.2.183) --- updated-dependencies: - dependency-name: libc dependency-version: 0.2.183 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30d0945..b28140e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1385,9 +1385,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" diff --git a/Cargo.toml b/Cargo.toml index dfc08e7..5167fd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ env_logger = "0.11.8" humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } -libc = "0.2.182" +libc = "0.2.183" log = "0.4.29" mime-sniffer = "0.1.3" notify-rust = { version = "4.12.0", optional = true } From 909bb53afaa680155baf4a7784d12612aa30ee74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:55:19 +0000 Subject: [PATCH 43/60] build(deps): bump cachix/cachix-action from 16 to 17 Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 16 to 17. - [Release notes](https://github.com/cachix/cachix-action/releases) - [Commits](https://github.com/cachix/cachix-action/compare/v16...v17) --- updated-dependencies: - dependency-name: cachix/cachix-action dependency-version: '17' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/nix-cache.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 9a9b4dc..8936c67 100644 --- a/.github/workflows/nix-cache.yaml +++ b/.github/workflows/nix-cache.yaml @@ -20,7 +20,7 @@ jobs: with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v16 + - uses: cachix/cachix-action@v17 with: name: nyx authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' From aabf40ac6ec3e7022374b4789566ec7422955eaf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 31 Mar 2026 09:00:05 +0300 Subject: [PATCH 44/60] build: bump dependencies Signed-off-by: NotAShelf Change-Id: I7a974572e4e36c9013e5c1c808677eaf6a6a6964 --- Cargo.lock | 197 ++++++++++++++++++++++++++++------------------------- Cargo.toml | 14 ++-- 2 files changed, 110 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b28140e..fe1039d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -49,15 +49,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -364,9 +364,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -386,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -430,9 +430,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "color-eyre" @@ -463,9 +463,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" @@ -753,9 +753,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -763,9 +763,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -792,9 +792,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -1288,9 +1288,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", @@ -1316,9 +1316,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" @@ -1346,9 +1346,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ "once_cell", "wasm-bindgen", @@ -1356,9 +1356,9 @@ dependencies = [ [[package]] name = "kasuari" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", @@ -1391,18 +1391,18 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -1411,9 +1411,9 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ "bitflags 2.11.0", ] @@ -1462,9 +1462,9 @@ dependencies = [ [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", "objc2", @@ -1536,9 +1536,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1606,9 +1606,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -1689,9 +1689,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1926,9 +1926,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -2176,9 +2176,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags 2.11.0", "fallible-iterator", @@ -2521,9 +2521,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -2677,32 +2677,32 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.1", ] [[package]] @@ -2749,9 +2749,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "sharded-slab", "thread_local", @@ -2783,9 +2783,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -2800,9 +2800,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -2853,9 +2853,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "atomic", "getrandom 0.4.2", @@ -2917,9 +2917,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -2930,9 +2930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2940,9 +2940,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -2953,9 +2953,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] @@ -2996,9 +2996,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa6143502b9a87f759cb6a649ca801a226f77740eb54f3951cba2227790afeb" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -3009,9 +3009,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.11.0", "log", @@ -3022,9 +3022,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.11" +version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -3034,9 +3034,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -3047,9 +3047,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.9" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", "quick-xml 0.39.2", @@ -3058,9 +3058,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.9" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81d2bd69b1dadd601d0e98ef2fc9339a1b1e00cec5ee7545a77b5a0f52a90394" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "pkg-config", ] @@ -3296,9 +3296,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -3467,7 +3476,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -3495,7 +3504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] @@ -3568,7 +3577,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -3596,5 +3605,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index 5167fd4..bfc3800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,13 +16,13 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" blocking = "1.6.2" -clap = { version = "4.5.60", features = [ "derive", "env" ] } +clap = { version = "4.6.0", features = [ "derive", "env" ] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" crossterm = "0.29.0" ctrlc = "3.5.2" dirs = "6.0.0" -env_logger = "0.11.8" +env_logger = "0.11.10" humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } @@ -32,20 +32,20 @@ mime-sniffer = "0.1.3" notify-rust = { version = "4.12.0", optional = true } ratatui = "0.30.0" regex = "1.12.3" -rusqlite = { version = "0.38.0", features = [ "bundled" ] } +rusqlite = { version = "0.39.0", features = [ "bundled" ] } serde = { version = "1.0.228", features = [ "derive" ] } serde_json = "1.0.149" smol = "2.0.2" thiserror = "2.0.18" -unicode-segmentation = "1.12.0" +unicode-segmentation = "1.13.2" unicode-width = "0.2.2" -wayland-client = { version = "0.31.12", features = [ "log" ], optional = true } -wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true } +wayland-client = { version = "0.31.14", features = [ "log" ], optional = true } +wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true } wl-clipboard-rs = "0.9.3" [dev-dependencies] futures = "0.3.32" -tempfile = "3.26.0" +tempfile = "3.27.0" [features] default = [ "notifications", "use-toplevel" ] From fe86356399138973f6d85e900f729e4709343310 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Mar 2026 08:39:49 +0300 Subject: [PATCH 45/60] wayland: use arc-swap over Mutex for `FOCUSED_APP` for better concurrency Signed-off-by: NotAShelf Change-Id: Id6b40d5c533c35dda5bce7b852b836f26a6a6964 --- Cargo.lock | 10 ++++++++++ Cargo.toml | 3 ++- src/wayland/mod.rs | 19 +++++++++---------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe1039d..8ea168d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -2409,6 +2418,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stash-clipboard" version = "0.3.6" dependencies = [ + "arc-swap", "base64", "blocking", "clap", diff --git a/Cargo.toml b/Cargo.toml index bfc3800..bae39c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] +arc-swap = { version = "1.9.0", optional = true } base64 = "0.22.1" blocking = "1.6.2" clap = { version = "4.6.0", features = [ "derive", "env" ] } @@ -50,7 +51,7 @@ tempfile = "3.27.0" [features] default = [ "notifications", "use-toplevel" ] notifications = [ "dep:notify-rust" ] -use-toplevel = [ "dep:wayland-client", "dep:wayland-protocols-wlr" ] +use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ] [profile.release] lto = true diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 9cfa765..38f6ff5 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -1,8 +1,9 @@ use std::{ collections::HashMap, - sync::{LazyLock, Mutex}, + sync::{Arc, LazyLock, Mutex}, }; +use arc_swap::ArcSwapOption; use log::debug; use wayland_client::{ Connection as WaylandConnection, @@ -17,7 +18,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, }; -static FOCUSED_APP: Mutex> = Mutex::new(None); +static FOCUSED_APP: ArcSwapOption = ArcSwapOption::const_empty(); static TOPLEVEL_APPS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -32,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() - && let Some(ref app) = *focused - { + // Load the focused app using lock-free arc-swap + let focused = FOCUSED_APP.load(); + if let Some(app) = focused.as_ref() { debug!("Found focused app via Wayland protocol: {app}"); - return Some(app.clone()); + return Some(app.to_string()); } debug!("No focused window detection method worked"); @@ -152,12 +152,11 @@ impl Dispatch for AppState { }) { debug!("Toplevel activated"); // 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 Ok(apps) = TOPLEVEL_APPS.lock() && let Some(app_id) = apps.get(&handle_id) { debug!("Setting focused app to: {app_id}"); - *focused = Some(app_id.clone()); + FOCUSED_APP.store(Some(Arc::new(app_id.clone()))); } } }, From 030be21ea5f3e6f36a944cd7cd38fadb2160db08 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Mar 2026 09:20:54 +0300 Subject: [PATCH 46/60] clipboard: persist clipboard contents after source application closes When the source application closes, the forked child continues serving clipboard data so it remains available for paste operations. Signed-off-by: NotAShelf Change-Id: I14fbcf8cbc47c40bfa1da7f8b09245936a6a6964 --- src/clipboard/mod.rs | 3 + src/clipboard/persist.rs | 262 +++++++++++++++++++++++++++++++++++++++ src/commands/store.rs | 1 + src/commands/watch.rs | 237 +++++++++++++++++++++++++++++++---- src/db/mod.rs | 208 ++++++++++++------------------- src/db/nonblocking.rs | 60 ++++++++- src/main.rs | 1 + 7 files changed, 616 insertions(+), 156 deletions(-) create mode 100644 src/clipboard/mod.rs create mode 100644 src/clipboard/persist.rs diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs new file mode 100644 index 0000000..2648ce5 --- /dev/null +++ b/src/clipboard/mod.rs @@ -0,0 +1,3 @@ +pub mod persist; + +pub use persist::{ClipboardData, get_serving_pid, persist_clipboard}; diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs new file mode 100644 index 0000000..df73fc8 --- /dev/null +++ b/src/clipboard/persist.rs @@ -0,0 +1,262 @@ +use std::{ + process::exit, + sync::atomic::{AtomicI32, Ordering}, +}; + +use wl_clipboard_rs::copy::{ + ClipboardType, + MimeType as CopyMimeType, + Options, + PreparedCopy, + ServeRequests, + Source, +}; + +/// Maximum number of paste requests to serve before exiting. This (hopefully) +/// prevents runaway processes while still providing persistence. +const MAX_SERVE_REQUESTS: usize = 1000; + +/// PID of the current clipboard persistence child process. Used to detect when +/// clipboard content is from our own serve process. +static SERVING_PID: AtomicI32 = AtomicI32::new(0); + +/// Get the current serving PID if any. Used by the watch loop to avoid +/// duplicate persistence processes. +pub fn get_serving_pid() -> Option { + let pid = SERVING_PID.load(Ordering::SeqCst); + if pid != 0 { Some(pid) } else { None } +} + +/// Result type for persistence operations. +pub type PersistenceResult = Result; + +/// Errors that can occur during clipboard persistence. +#[derive(Debug, thiserror::Error)] +pub enum PersistenceError { + #[error("Failed to prepare copy: {0}")] + PrepareFailed(String), + + #[error("Failed to fork: {0}")] + ForkFailed(String), + + #[error("Clipboard data too large: {0} bytes")] + DataTooLarge(usize), + + #[error("Clipboard content is empty")] + EmptyContent, + + #[error("No MIME types to offer")] + NoMimeTypes, +} + +/// Clipboard data with all MIME types for persistence. +#[derive(Debug, Clone)] +pub struct ClipboardData { + /// The actual clipboard content. + pub content: Vec, + + /// All MIME types offered by the source. Preserves order. + pub mime_types: Vec, + + /// The MIME type that was selected for storage. + pub selected_mime: String, +} + +impl ClipboardData { + /// Create new clipboard data. + pub fn new( + content: Vec, + mime_types: Vec, + selected_mime: String, + ) -> Self { + Self { + content, + mime_types, + selected_mime, + } + } + + /// Check if data is valid for persistence. + pub fn is_valid(&self) -> Result<(), PersistenceError> { + const MAX_SIZE: usize = 100 * 1024 * 1024; // 100MB + + if self.content.is_empty() { + return Err(PersistenceError::EmptyContent); + } + + if self.content.len() > MAX_SIZE { + return Err(PersistenceError::DataTooLarge(self.content.len())); + } + + if self.mime_types.is_empty() { + return Err(PersistenceError::NoMimeTypes); + } + + Ok(()) + } +} + +/// Persist clipboard data by forking a background process that serves it. +/// +/// 1. Prepares a clipboard copy operation with all MIME types +/// 2. Forks a child process +/// 3. The child serves clipboard data indefinitely (until MAX_SERVE_REQUESTS) +/// 4. The parent returns immediately +/// +/// # Safety +/// +/// This function uses `libc::fork()` which is unsafe. The child process +/// must not modify any shared state or file descriptors. +pub unsafe fn persist_clipboard(data: ClipboardData) -> PersistenceResult<()> { + // Validate data + data.is_valid()?; + + // Prepare the copy operation + let prepared = prepare_clipboard_copy(&data)?; + + // Fork and serve + unsafe { fork_and_serve(prepared) } +} + +/// Prepare a clipboard copy operation with all MIME types. +fn prepare_clipboard_copy( + data: &ClipboardData, +) -> PersistenceResult { + let mut opts = Options::new(); + opts.clipboard(ClipboardType::Regular); + opts.serve_requests(ServeRequests::Only(MAX_SERVE_REQUESTS)); + opts.foreground(true); // we'll fork manually for better control + + // Determine MIME type for the primary offer + let mime_type = if data.selected_mime.starts_with("text/") { + CopyMimeType::Text + } else { + CopyMimeType::Specific(data.selected_mime.clone()) + }; + + // Prepare the copy + let prepared = opts + .prepare_copy(Source::Bytes(data.content.clone().into()), mime_type) + .map_err(|e| PersistenceError::PrepareFailed(e.to_string()))?; + + Ok(prepared) +} + +/// Fork a child process to serve clipboard data. +/// +/// The child process will: +/// +/// 1. Register its process ID with the self-detection module +/// 2. Serve clipboard requests until MAX_SERVE_REQUESTS +/// 3. Exit cleanly +/// +/// The parent stores the child `PID` in `SERVING_PID` and returns immediately. +unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { + // Enable automatic child reaping to prevent zombie processes + unsafe { + libc::signal(libc::SIGCHLD, libc::SIG_IGN); + } + + match unsafe { libc::fork() } { + 0 => { + // Child process - clear serving PID + // Look at me. I'm the server now. + SERVING_PID.store(0, Ordering::SeqCst); + serve_clipboard_child(prepared); + exit(0); + }, + + -1 => { + // Oops. + Err(PersistenceError::ForkFailed( + "libc::fork() returned -1".to_string(), + )) + }, + + pid => { + // Parent process, store child PID for loop detection + log::debug!("Forked clipboard persistence process (pid: {pid})"); + SERVING_PID.store(pid, Ordering::SeqCst); + Ok(()) + }, + } +} + +/// Child process entry point for serving clipboard data. +fn serve_clipboard_child(prepared: PreparedCopy) { + let pid = std::process::id() as i32; + log::debug!("Clipboard persistence child process started (pid: {pid})"); + + // Serve clipboard requests. The PreparedCopy::serve() method blocks and + // handles all the Wayland protocol interactions internally via + // wl-clipboard-rs + match prepared.serve() { + Ok(()) => { + log::debug!("Clipboard persistence: serve completed normally"); + }, + + Err(e) => { + log::error!("Clipboard persistence: serve failed: {e}"); + exit(1); + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_data_validation() { + // Valid data + let valid = ClipboardData::new( + b"hello".to_vec(), + vec!["text/plain".to_string()], + "text/plain".to_string(), + ); + assert!(valid.is_valid().is_ok()); + + // Empty content + let empty = ClipboardData::new( + vec![], + vec!["text/plain".to_string()], + "text/plain".to_string(), + ); + assert!(matches!( + empty.is_valid(), + Err(PersistenceError::EmptyContent) + )); + + // No MIME types + let no_mimes = + ClipboardData::new(b"hello".to_vec(), vec![], "text/plain".to_string()); + assert!(matches!( + no_mimes.is_valid(), + Err(PersistenceError::NoMimeTypes) + )); + + // Too large + let huge = ClipboardData::new( + vec![0u8; 101 * 1024 * 1024], // 101MB + vec!["text/plain".to_string()], + "text/plain".to_string(), + ); + assert!(matches!( + huge.is_valid(), + Err(PersistenceError::DataTooLarge(_)) + )); + } + + #[test] + fn test_clipboard_data_creation() { + let data = ClipboardData::new( + b"test content".to_vec(), + vec!["text/plain".to_string(), "text/html".to_string()], + "text/plain".to_string(), + ); + + assert_eq!(data.content, b"test content"); + assert_eq!(data.mime_types.len(), 2); + assert_eq!(data.selected_mime, "text/plain"); + } +} diff --git a/src/commands/store.rs b/src/commands/store.rs index af683d7..0b7e23c 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -39,6 +39,7 @@ impl StoreCommand for SqliteClipboardDb { min_size, max_size, None, // no pre-computed hash for CLI store + None, // no mime types for CLI store )?; log::info!("Entry stored"); } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index c5ae423..ddfdbea 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,5 +1,22 @@ use std::{collections::BinaryHeap, io::Read, time::Duration}; +use smol::Timer; +use wl_clipboard_rs::{ + copy::{MimeType as CopyMimeType, Options, Source}, + paste::{ + ClipboardType, + MimeType as PasteMimeType, + Seat, + get_contents, + get_mime_types_ordered, + }, +}; + +use crate::{ + clipboard::{self, ClipboardData, get_serving_pid}, + db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}, +}; + /// FNV-1a hasher for deterministic hashing across process runs. /// Unlike `DefaultHasher` (`SipHash`), this produces stable hashes. struct Fnv1aHasher { @@ -28,20 +45,6 @@ impl Fnv1aHasher { } } -use smol::Timer; -use wl_clipboard_rs::{ - copy::{MimeType as CopyMimeType, Options, Source}, - paste::{ - ClipboardType, - MimeType as PasteMimeType, - Seat, - get_contents, - get_mime_types_ordered, - }, -}; - -use crate::db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}; - /// Wrapper to provide [`Ord`] implementation for `f64` by negating values. /// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. /// Also see: @@ -151,21 +154,29 @@ impl ExpirationQueue { /// When `preference` is `"text"`, uses `MimeType::Text` directly (single call). /// When `preference` is `"image"`, picks the first offered `image/*` type. /// Otherwise picks the source's first offered type. +/// +/// # Returns +/// +/// The content reader, the selected MIME type, and ALL offered MIME +/// types. +#[expect(clippy::type_complexity)] fn negotiate_mime_type( preference: &str, -) -> Result<(Box, String), wl_clipboard_rs::paste::Error> { +) -> Result<(Box, String, Vec), wl_clipboard_rs::paste::Error> +{ + // Get all offered MIME types first (needed for persistence) + let offered = + get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?; + if preference == "text" { let (reader, mime_str) = get_contents( ClipboardType::Regular, Seat::Unspecified, PasteMimeType::Text, )?; - return Ok((Box::new(reader) as Box, mime_str)); + return Ok((Box::new(reader) as Box, mime_str, offered)); } - let offered = - get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?; - let chosen = if preference == "image" { // Pick the first offered image type, fall back to first overall offered @@ -202,7 +213,8 @@ fn negotiate_mime_type( Seat::Unspecified, PasteMimeType::Specific(mime_str), )?; - Ok((Box::new(reader) as Box, actual_mime)) + + Ok((Box::new(reader) as Box, actual_mime, offered)) }, None => Err(wl_clipboard_rs::paste::Error::NoSeats), } @@ -270,7 +282,7 @@ impl WatchCommand for SqliteClipboardDb { }; // Initialize with current clipboard using smart MIME negotiation - if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) { + if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) { buf.clear(); if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { last_hash = Some(hash_contents(&buf)); @@ -306,7 +318,7 @@ impl WatchCommand for SqliteClipboardDb { } // Check if this expired entry is currently in the clipboard - if let Ok((mut reader, _)) = + if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) { let mut current_buf = Vec::new(); @@ -349,7 +361,7 @@ impl WatchCommand for SqliteClipboardDb { // Normal clipboard polling (always run, even when expirations are // pending) match negotiate_mime_type(mime_type_preference) { - Ok((mut reader, _mime_type)) => { + Ok((mut reader, _mime_type, _all_mimes)) => { buf.clear(); if let Err(e) = reader.read_to_end(&mut buf) { log::error!("Failed to read clipboard contents: {e}"); @@ -365,6 +377,12 @@ impl WatchCommand for SqliteClipboardDb { let buf_clone = buf.clone(); #[allow(clippy::cast_possible_wrap)] let content_hash = Some(current_hash as i64); + + // Clone data for persistence after successful store + let buf_for_persist = buf.clone(); + let mime_types_for_persist = _all_mimes.clone(); + let selected_mime = _mime_type.clone(); + match async_db .store_entry( buf_clone, @@ -374,6 +392,7 @@ impl WatchCommand for SqliteClipboardDb { min_size, max_size, content_hash, + Some(mime_types_for_persist.clone()), ) .await { @@ -381,6 +400,37 @@ impl WatchCommand for SqliteClipboardDb { log::info!("Stored new clipboard entry (id: {id})"); last_hash = Some(current_hash); + // Persist clipboard: fork child to serve data + // This keeps the clipboard alive when source app closes + // Check if we're already serving to avoid duplicate processes + if get_serving_pid().is_none() { + let clipboard_data = ClipboardData::new( + buf_for_persist, + mime_types_for_persist, + selected_mime, + ); + + // Validate and persist in blocking task + if clipboard_data.is_valid().is_ok() { + smol::spawn(async move { + // Use blocking task for fork operation + let result = smol::unblock(move || unsafe { + clipboard::persist_clipboard(clipboard_data) + }) + .await; + + if let Err(e) = result { + log::debug!("Clipboard persistence failed: {e}"); + } + }) + .detach(); + } + } else { + log::trace!( + "Already serving clipboard, skipping persistence fork" + ); + } + // Set expiration if configured if let Some(duration) = expire_after { let expires_at = @@ -539,4 +589,145 @@ mod tests { let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()]; assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list"); } + + /// Test that "text" preference is handled separately from pick_mime logic. + /// Documents that "text" preference uses PasteMimeType::Text directly + /// without querying MIME type ordering. This is functionally a regression + /// test for `negotiate_mime_type()`, which is load bearing, to ensure that + /// we don't mess it up. + #[test] + fn test_text_preference_behavior() { + // When preference is "text", negotiate_mime_type() should: + // 1. Use PasteMimeType::Text directly (no ordering query via + // get_mime_types_ordered) + // 2. Return content with text/plain MIME type + // + // Note: "text" is NOT passed to pick_mime() - it's handled separately + // in negotiate_mime_type() before the pick_mime logic. + // This test documents the separation of concerns. + let offered = vec![ + "text/html".to_string(), + "image/png".to_string(), + "text/plain".to_string(), + ]; + // pick_mime is only called for "image" and "any" preferences + // "text" goes through a different code path + assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); + } + + /// Test MIME type selection priority for "any" preference with multiple + /// types. Documents that: + /// 1. Image types are preferred over text/html + /// 2. Non-html text types are preferred over text/html + /// 3. First offered type is used when no special cases match + #[test] + fn test_any_preference_selection_priority() { + // Priority 1: Image over HTML + let offered = vec!["text/html".to_string(), "image/png".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); + + // Priority 2: Plain text over HTML + let offered = vec!["text/html".to_string(), "text/plain".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain"); + + // Priority 3: First type when no special handling + let offered = + vec!["application/json".to_string(), "text/plain".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "application/json"); + } + + /// Test "image" preference behavior. + /// Documents that: + /// 1. First image/* type is selected + /// 2. Falls back to first type if no images + #[test] + fn test_image_preference_selection_behavior() { + // Multiple images - pick first one + let offered = vec![ + "image/jpeg".to_string(), + "image/png".to_string(), + "text/plain".to_string(), + ]; + assert_eq!(pick_mime(&offered, "image").unwrap(), "image/jpeg"); + + // No images - fall back to first + let offered = vec!["text/html".to_string(), "text/plain".to_string()]; + assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); + } + + /// Test edge case: text/html as only option. + /// Documents that text/html is used when it's the only type available. + #[test] + fn test_html_fallback_as_only_option() { + let offered = vec!["text/html".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); + assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); + } + + /// Test complex Firefox scenario with all MIME types. + /// Documents expected behavior when source offers many types. + #[test] + fn test_firefox_copy_image_all_types() { + // Firefox "Copy Image" offers: + // text/html, text/_moz_htmlcontext, text/_moz_htmlinfo, + // image/png, image/bmp, image/x-bmp, image/x-ico, + // text/ico, application/ico, image/ico, image/icon, + // text/icon, image/x-win-bitmap, image/x-win-bmp, + // image/x-icon, text/plain + let offered = vec![ + "text/html".to_string(), + "text/_moz_htmlcontext".to_string(), + "image/png".to_string(), + "image/bmp".to_string(), + "text/plain".to_string(), + ]; + + // "any" should pick image/png (first image, skipping HTML) + assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); + + // "image" should pick image/png + assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png"); + } + + /// Test complex Electron app scenario. + #[test] + fn test_electron_app_mime_types() { + // Electron apps often offer: text/html, image/png, text/plain + let offered = vec![ + "text/html".to_string(), + "image/png".to_string(), + "text/plain".to_string(), + ]; + + assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); + assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png"); + } + + /// Test that the function handles empty offers correctly. + /// Documents that empty offers result in an error (NoSeats equivalent). + #[test] + fn test_empty_offers_behavior() { + let offered: Vec = vec![]; + assert!(pick_mime(&offered, "any").is_none()); + assert!(pick_mime(&offered, "image").is_none()); + assert!(pick_mime(&offered, "text").is_none()); + } + + /// Test file manager behavior with URI lists. + #[test] + fn test_file_manager_uri_list_behavior() { + // File managers typically offer: text/uri-list, text/plain, + // x-special/gnome-copied-files + let offered = vec![ + "text/uri-list".to_string(), + "text/plain".to_string(), + "x-special/gnome-copied-files".to_string(), + ]; + + // "any" should pick text/uri-list (first) + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list"); + + // "image" should fall back to text/uri-list + assert_eq!(pick_mime(&offered, "image").unwrap(), "text/uri-list"); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6e32381..441495f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -254,6 +254,7 @@ pub trait ClipboardDb { /// * `min_size` - Minimum content size (None for no minimum) /// * `max_size` - Maximum content size /// * `content_hash` - Optional pre-computed content hash (avoids re-hashing) + /// * `mime_types` - Optional list of all MIME types offered (for persistence) #[allow(clippy::too_many_arguments)] fn store_entry( &self, @@ -264,6 +265,7 @@ pub trait ClipboardDb { min_size: Option, max_size: usize, content_hash: Option, + mime_types: Option<&[String]>, ) -> Result; fn deduplicate_by_hash( @@ -542,6 +544,36 @@ impl SqliteClipboardDb { })?; } + // Add mime_types column if it doesn't exist (v6) + // Stores all MIME types offered by the source application as JSON array. + // Needed for clipboard persistence to re-offer the same types. + if schema_version < 6 { + let has_mime_types: 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("mime_types")) + }, + ) + .unwrap_or(false); + + if !has_mime_types { + tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add mime_types column: {e}").into(), + ) + })?; + } + + tx.execute("PRAGMA user_version = 6", []).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(), @@ -616,6 +648,7 @@ impl ClipboardDb for SqliteClipboardDb { min_size: Option, max_size: usize, content_hash: Option, + mime_types: Option<&[String]>, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() { @@ -671,11 +704,21 @@ impl ClipboardDb for SqliteClipboardDb { self.deduplicate_by_hash(content_hash, max_dedupe_search)?; + let mime_types_json: Option = match mime_types { + Some(types) => { + Some( + serde_json::to_string(&types) + .map_err(|e| StashError::Store(e.to_string().into()))?, + ) + }, + None => None, + }; + self .conn .execute( - "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ - VALUES (?1, ?2, ?3, ?4)", + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \ + mime_types) VALUES (?1, ?2, ?3, ?4, ?5)", params![ buf, mime, @@ -683,7 +726,8 @@ impl ClipboardDb for SqliteClipboardDb { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("Time went backwards") - .as_secs() as i64 + .as_secs() as i64, + mime_types_json ], ) .map_err(|e| StashError::Store(e.to_string().into()))?; @@ -1480,11 +1524,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); assert!(index_exists(&db.conn, "idx_content_hash")); assert!(index_exists(&db.conn, "idx_last_accessed")); @@ -1532,11 +1577,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn @@ -1575,11 +1621,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn @@ -1619,11 +1666,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); assert!(index_exists(&db.conn, "idx_last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn @@ -1656,7 +1704,7 @@ mod tests { get_schema_version(&db2.conn).expect("Failed to get version"); assert_eq!(version_after_first, version_after_second); - assert_eq!(version_after_first, 5); + assert_eq!(version_after_first, 6); } #[test] @@ -1670,127 +1718,19 @@ mod tests { 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, None, DEFAULT_MAX_ENTRY_SIZE, None) + let _id = db + .store_entry( + cursor, + 100, + 1000, + None, + None, + DEFAULT_MAX_ENTRY_SIZE, + None, + 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, PathBuf::from(":memory:")) - .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, None, DEFAULT_MAX_ENTRY_SIZE, 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 = Fnv1aHasher::new(); - hasher.write(test_data); - 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, PathBuf::from(":memory:")) - .expect("Failed to create database"); - - assert_eq!( - get_schema_version(&db.conn).expect("Failed to get version"), - 5 - ); - let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) @@ -1811,6 +1751,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store URI list"); @@ -1845,6 +1786,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store image"); @@ -1874,6 +1816,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store first"); let _id2 = db @@ -1885,6 +1828,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store second"); @@ -1921,6 +1865,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); } @@ -1943,6 +1888,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1958,6 +1904,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1975,6 +1922,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ); assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } @@ -1991,6 +1939,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); @@ -2018,6 +1967,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); db.store_entry( @@ -2028,6 +1978,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); @@ -2056,6 +2007,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); } @@ -2136,6 +2088,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); @@ -2221,6 +2174,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index d45d905..c1e57cd 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -17,6 +17,7 @@ impl AsyncClipboardDb { Self { db_path } } + #[expect(clippy::too_many_arguments)] pub async fn store_entry( &self, data: Vec, @@ -26,6 +27,7 @@ impl AsyncClipboardDb { min_size: Option, max_size: usize, content_hash: Option, + mime_types: Option>, ) -> Result { let path = self.db_path.clone(); blocking::unblock(move || { @@ -38,6 +40,7 @@ impl AsyncClipboardDb { min_size, max_size, content_hash, + mime_types.as_deref(), ) }) .await @@ -172,7 +175,16 @@ mod tests { let data = b"async test data"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed to store entry"); @@ -201,7 +213,16 @@ mod tests { let data = b"expiring entry"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed to store entry"); @@ -233,7 +254,16 @@ mod tests { let data = b"entry to expire"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed to store entry"); @@ -280,12 +310,30 @@ mod tests { let data = b"clone test"; let id1 = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed with original"); let id2 = cloned - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed with clone"); @@ -304,7 +352,7 @@ mod tests { let db = async_db.clone(); let data = format!("concurrent test {}", i).into_bytes(); smol::spawn(async move { - db.store_entry(data, 100, 1000, None, None, 5_000_000, None) + db.store_entry(data, 100, 1000, None, None, 5_000_000, None, None) .await }) }) diff --git a/src/main.rs b/src/main.rs index e2602aa..fd8c8cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use clap::{CommandFactory, Parser, Subcommand}; use humantime::parse_duration; use inquire::Confirm; +mod clipboard; mod commands; pub(crate) mod db; pub(crate) mod mime; From d9bee33aba7a6cdd289717edf28aa952b032c38b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 31 Mar 2026 12:47:31 +0300 Subject: [PATCH 47/60] stash: consolidate confirmation prompts; install color_eyre hook Signed-off-by: NotAShelf Change-Id: I7fb4ba67098f897849fc9b317c7fde646a6a6964 --- src/main.rs | 77 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/main.rs b/src/main.rs index fd8c8cc..53ed1c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,9 @@ +mod clipboard; +mod commands; +mod db; +mod mime; +mod multicall; + use std::{ env, io::{self, IsTerminal}, @@ -6,14 +12,14 @@ use std::{ }; use clap::{CommandFactory, Parser, Subcommand}; +use color_eyre::eyre; use humantime::parse_duration; use inquire::Confirm; -mod clipboard; -mod commands; -pub(crate) mod db; -pub(crate) mod mime; -mod multicall; +// While the module is named "wayland", the Wayland module is *strictly* for the +// use-toplevel feature as it requires some low-level wayland crates that are +// not required *by default*. The module is named that way because "toplevel" +// sounded too silly. Stash is strictly a Wayland clipboard manager. #[cfg(feature = "use-toplevel")] mod wayland; use crate::{ @@ -189,8 +195,20 @@ fn report_error( } } +fn confirm(prompt: &str) -> bool { + Confirm::new(prompt) + .with_default(false) + .prompt() + .unwrap_or_else(|e| { + log::error!("Confirmation prompt failed: {e}"); + false + }) +} + #[allow(clippy::too_many_lines)] // whatever -fn main() -> color_eyre::eyre::Result<()> { +fn main() -> eyre::Result<()> { + color_eyre::install()?; + // Check if we're being called as a multicall binary let program_name = env::args().next().map(|s| { PathBuf::from(s) @@ -217,12 +235,18 @@ fn main() -> color_eyre::eyre::Result<()> { .filter_level(cli.verbosity.into()) .init(); - let db_path = cli.db_path.unwrap_or_else(|| { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("stash") - .join("db") - }); + let db_path = match cli.db_path { + Some(path) => path, + None => { + let cache_dir = dirs::cache_dir().ok_or_else(|| { + eyre::eyre!( + "Could not determine cache directory. Set --db-path or \ + $STASH_DB_PATH explicitly." + ) + })?; + cache_dir.join("stash").join("db") + }, + }; if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent)?; @@ -300,10 +324,7 @@ fn main() -> color_eyre::eyre::Result<()> { let mut should_proceed = true; if ask { should_proceed = - Confirm::new("Are you sure you want to delete clipboard entries?") - .with_default(false) - .prompt() - .unwrap_or(false); + confirm("Are you sure you want to delete clipboard entries?"); if !should_proceed { log::info!("aborted by user."); @@ -361,12 +382,8 @@ fn main() -> color_eyre::eyre::Result<()> { ); let mut should_proceed = true; if ask { - should_proceed = Confirm::new( - "Are you sure you want to wipe all clipboard history?", - ) - .with_default(false) - .prompt() - .unwrap_or(false); + should_proceed = + confirm("Are you sure you want to wipe all clipboard history?"); if !should_proceed { log::info!("wipe command aborted by user."); } @@ -386,10 +403,7 @@ fn main() -> color_eyre::eyre::Result<()> { } else { "Are you sure you want to wipe ALL clipboard history?" }; - should_proceed = Confirm::new(message) - .with_default(false) - .prompt() - .unwrap_or(false); + should_proceed = confirm(message); if !should_proceed { log::info!("db wipe command aborted by user."); } @@ -398,7 +412,7 @@ fn main() -> color_eyre::eyre::Result<()> { if expired { match db.cleanup_expired() { Ok(count) => { - log::info!("Wiped {count} expired entries"); + log::info!("wiped {count} expired entries"); }, Err(e) => { log::error!("failed to wipe expired entries: {e}"); @@ -412,7 +426,7 @@ fn main() -> color_eyre::eyre::Result<()> { DbAction::Vacuum => { match db.vacuum() { Ok(()) => { - log::info!("Database optimized successfully"); + log::info!("database optimized successfully"); }, Err(e) => { log::error!("failed to vacuum database: {e}"); @@ -435,13 +449,10 @@ fn main() -> color_eyre::eyre::Result<()> { Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { - should_proceed = Confirm::new( + should_proceed = confirm( "Are you sure you want to import clipboard data? This may \ overwrite existing entries.", - ) - .with_default(false) - .prompt() - .unwrap_or(false); + ); if !should_proceed { log::info!("import command aborted by user."); } From d643376cd7ca4cb30ffbf2a90fa13d11d0c9bcd0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 1 Apr 2026 14:38:47 +0300 Subject: [PATCH 48/60] stash: deduplicate Fnv1aHasher; add derive for u64 wrapper Signed-off-by: NotAShelf Change-Id: Ic2886815721f6eefc66a8ddacd44fb286a6a6964 --- src/commands/watch.rs | 31 +------------ src/db/mod.rs | 33 ++------------ src/hash.rs | 101 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 4 files changed, 108 insertions(+), 58 deletions(-) create mode 100644 src/hash.rs diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ddfdbea..542937d 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,4 +1,4 @@ -use std::{collections::BinaryHeap, io::Read, time::Duration}; +use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration}; use smol::Timer; use wl_clipboard_rs::{ @@ -15,36 +15,9 @@ use wl_clipboard_rs::{ use crate::{ clipboard::{self, ClipboardData, get_serving_pid}, db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}, + hash::Fnv1aHasher, }; -/// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike `DefaultHasher` (`SipHash`), this produces stable hashes. -struct Fnv1aHasher { - state: u64, -} - -impl Fnv1aHasher { - const FNV_OFFSET: u64 = 0xCBF29CE484222325; - const FNV_PRIME: u64 = 0x100000001B3; - - fn new() -> Self { - Self { - state: Self::FNV_OFFSET, - } - } - - fn write(&mut self, bytes: &[u8]) { - for byte in bytes { - self.state ^= u64::from(*byte); - self.state = self.state.wrapping_mul(Self::FNV_PRIME); - } - } - - fn finish(&self) -> u64 { - self.state - } -} - /// Wrapper to provide [`Ord`] implementation for `f64` by negating values. /// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. /// Also see: diff --git a/src/db/mod.rs b/src/db/mod.rs index 441495f..f907b3b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -11,6 +11,10 @@ use std::{ pub mod nonblocking; +use std::hash::Hasher; + +use crate::hash::Fnv1aHasher; + /// Cache for process scanning results to avoid expensive `/proc` reads on every /// store operation. TTL of 5 seconds balances freshness with performance. struct ProcessCache { @@ -66,35 +70,6 @@ impl ProcessCache { } } -/// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike `DefaultHasher` (`SipHash` with random seed), this produces stable -/// hashes. -pub struct Fnv1aHasher { - state: u64, -} - -impl Fnv1aHasher { - const FNV_OFFSET: u64 = 0xCBF29CE484222325; - const FNV_PRIME: u64 = 0x100000001B3; - - pub fn new() -> Self { - Self { - state: Self::FNV_OFFSET, - } - } - - pub fn write(&mut self, bytes: &[u8]) { - for byte in bytes { - self.state ^= u64::from(*byte); - self.state = self.state.wrapping_mul(Self::FNV_PRIME); - } - } - - pub fn finish(&self) -> u64 { - self.state - } -} - use base64::prelude::*; use log::{debug, error, info, warn}; use mime_sniffer::MimeTypeSniffer; diff --git a/src/hash.rs b/src/hash.rs new file mode 100644 index 0000000..f017a51 --- /dev/null +++ b/src/hash.rs @@ -0,0 +1,101 @@ +/// FNV-1a hasher for deterministic hashing across process runs. +/// +/// Unlike `std::collections::hash_map::DefaultHasher` (which uses SipHash +/// with a random seed), this produces stable hashes suitable for persistent +/// storage and cross-process comparison. +/// +/// # Example +/// +/// ``` +/// use std::hash::Hasher; +/// +/// use stash::hash::Fnv1aHasher; +/// +/// let mut hasher = Fnv1aHasher::new(); +/// hasher.write(b"hello"); +/// let hash = hasher.finish(); +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct Fnv1aHasher { + state: u64, +} + +impl Fnv1aHasher { + const FNV_OFFSET: u64 = 0xCBF29CE484222325; + const FNV_PRIME: u64 = 0x100000001B3; + + /// Creates a new hasher initialized with the FNV-1a offset basis. + #[must_use] + pub fn new() -> Self { + Self { + state: Self::FNV_OFFSET, + } + } +} + +impl Default for Fnv1aHasher { + fn default() -> Self { + Self::new() + } +} + +impl std::hash::Hasher for Fnv1aHasher { + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.state ^= u64::from(*byte); + self.state = self.state.wrapping_mul(Self::FNV_PRIME); + } + } + + fn finish(&self) -> u64 { + self.state + } +} + +#[cfg(test)] +mod tests { + use std::hash::Hasher; + + use super::*; + + #[test] + fn test_fnv1a_basic() { + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"hello"); + // FNV-1a hash for "hello" (little-endian u64) + assert_eq!(hasher.finish(), 0xA430D84680AABD0B); + } + + #[test] + fn test_fnv1a_empty() { + let hasher = Fnv1aHasher::new(); + // Empty input should return offset basis + assert_eq!(hasher.finish(), Fnv1aHasher::FNV_OFFSET); + } + + #[test] + fn test_fnv1a_deterministic() { + // Same input must produce same hash + let mut h1 = Fnv1aHasher::new(); + let mut h2 = Fnv1aHasher::new(); + h1.write(b"test data"); + h2.write(b"test data"); + assert_eq!(h1.finish(), h2.finish()); + } + + #[test] + fn test_default_trait() { + let h1 = Fnv1aHasher::new(); + let h2 = Fnv1aHasher::default(); + assert_eq!(h1.finish(), h2.finish()); + } + + #[test] + fn test_copy_trait() { + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"data"); + let copied = hasher; + // Both should have same state after copy + assert_eq!(hasher.finish(), copied.finish()); + } +} diff --git a/src/main.rs b/src/main.rs index 53ed1c9..32c271d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod clipboard; mod commands; mod db; +mod hash; mod mime; mod multicall; From 77ac70f0d354bdfedf1898aeb8ba550f3ea95853 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 1 Apr 2026 16:23:08 +0300 Subject: [PATCH 49/60] db/nonblocking: add test-only imports for the `Fnv1aHasher` Signed-off-by: NotAShelf Change-Id: I66effd259c6654bd4efac2f4e6bc4e176a6a6964 --- src/db/nonblocking.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index c1e57cd..d62e0dd 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -146,11 +146,12 @@ impl Clone for AsyncClipboardDb { #[cfg(test)] mod tests { - use std::collections::HashSet; + use std::{collections::HashSet, hash::Hasher}; use tempfile::tempdir; use super::*; + use crate::hash::Fnv1aHasher; fn setup_test_db() -> (AsyncClipboardDb, tempfile::TempDir) { let temp_dir = tempdir().expect("Failed to create temp dir"); @@ -198,7 +199,7 @@ mod tests { .expect("Hash should exist"); // Calculate expected hash - let mut hasher = crate::db::Fnv1aHasher::new(); + let mut hasher = Fnv1aHasher::new(); hasher.write(data); let expected_hash = hasher.finish() as i64; From 9702e67599cdebb9ef0cdb03e80c27e89cdd8f4f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 13:55:58 +0300 Subject: [PATCH 50/60] build: get rid of the overzealous build script; leave symlinking to packagers Signed-off-by: NotAShelf Change-Id: I39c590f0a703ab71d3cb5a8df9b095a46a6a6964 --- build.rs | 65 ------------------------------------------------- nix/package.nix | 3 ++- 2 files changed, 2 insertions(+), 66 deletions(-) delete mode 100644 build.rs diff --git a/build.rs b/build.rs deleted file mode 100644 index b511acb..0000000 --- a/build.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{env, fs, path::Path}; - -/// List of multicall symlinks to create (name, target) -const MULTICALL_LINKS: &[&str] = - &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; - -/// Wayland-specific symlinks that can be disabled separately -const WAYLAND_LINKS: &[&str] = &["wl-copy", "wl-paste"]; - -fn main() { - // OUT_DIR is something like .../target/debug/build//out - // We want .../target/debug or .../target/release - let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); - let bin_dir = Path::new(&out_dir) - .ancestors() - .nth(3) - .expect("Failed to find binary dir"); - - // Path to the main stash binary - let stash_bin = bin_dir.join("stash"); - - // Check for environment variables to disable symlinking - let disable_all_symlinks = env::var("STASH_NO_SYMLINKS").is_ok(); - let disable_wayland_symlinks = env::var("STASH_NO_WL_SYMLINKS").is_ok(); - - // Create symlinks for each multicall binary - for link in MULTICALL_LINKS { - if disable_all_symlinks { - println!("cargo:warning=Skipping symlink {link} (all symlinks disabled)"); - continue; - } - - if disable_wayland_symlinks && WAYLAND_LINKS.contains(link) { - println!( - "cargo:warning=Skipping symlink {link} (wayland symlinks disabled)" - ); - continue; - } - - let link_path = bin_dir.join(link); - // Remove existing symlink or file if present - let _ = fs::remove_file(&link_path); - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - match symlink(&stash_bin, &link_path) { - Ok(()) => { - println!( - "cargo:warning=Created symlink: {} -> {}", - link_path.display(), - stash_bin.display() - ); - }, - Err(e) => { - println!( - "cargo:warning=Failed to create symlink {} -> {}: {}", - link_path.display(), - stash_bin.display(), - e - ); - }, - } - } - } -} diff --git a/nix/package.nix b/nix/package.nix index b068d4a..ba9573d 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,6 +4,7 @@ stdenv, mold, versionCheckHook, + useMold ? stdenv.isLinux, createSymlinks ? true, }: let pname = "stash"; @@ -55,7 +56,7 @@ in done ''; - env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) { + env = lib.optionalAttrs useMold { CARGO_LINKER = "clang"; CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold"; }; From da9bf5ea3e3c9d08a52ef32a8a8f52bbcd0c3da5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 13:59:09 +0300 Subject: [PATCH 51/60] treewide: make logging format more consistent; make clipboard persistence opt-in Signed-off-by: NotAShelf Change-Id: I9092f93c29fcbe99c90483875f4acd0c6a6a6964 --- src/clipboard/persist.rs | 8 ++++---- src/commands/decode.rs | 2 +- src/commands/delete.rs | 2 +- src/commands/import.rs | 4 ++-- src/commands/list.rs | 4 ++-- src/commands/store.rs | 4 ++-- src/commands/watch.rs | 38 ++++++++++++++++++++++---------------- src/commands/wipe.rs | 2 +- src/db/mod.rs | 2 +- src/main.rs | 8 +++++++- src/multicall/wl_paste.rs | 2 +- 11 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs index df73fc8..a677f50 100644 --- a/src/clipboard/persist.rs +++ b/src/clipboard/persist.rs @@ -175,7 +175,7 @@ unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { pid => { // Parent process, store child PID for loop detection - log::debug!("Forked clipboard persistence process (pid: {pid})"); + log::debug!("forked clipboard persistence process (pid: {pid})"); SERVING_PID.store(pid, Ordering::SeqCst); Ok(()) }, @@ -185,18 +185,18 @@ unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { /// Child process entry point for serving clipboard data. fn serve_clipboard_child(prepared: PreparedCopy) { let pid = std::process::id() as i32; - log::debug!("Clipboard persistence child process started (pid: {pid})"); + log::debug!("clipboard persistence child process started (pid: {pid})"); // Serve clipboard requests. The PreparedCopy::serve() method blocks and // handles all the Wayland protocol interactions internally via // wl-clipboard-rs match prepared.serve() { Ok(()) => { - log::debug!("Clipboard persistence: serve completed normally"); + log::debug!("clipboard persistence: serve completed normally"); }, Err(e) => { - log::error!("Clipboard persistence: serve failed: {e}"); + log::error!("clipboard persistence: serve failed: {e}"); exit(1); }, } diff --git a/src/commands/decode.rs b/src/commands/decode.rs index 8f414a1..f989a18 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -32,7 +32,7 @@ impl DecodeCommand for SqliteClipboardDb { // If input is empty or whitespace, treat as error and trigger fallback if input_str.trim().is_empty() { - log::debug!("No input provided to decode; relaying clipboard to stdout"); + log::debug!("no input provided to decode; relaying clipboard to stdout"); if let Ok((mut reader, _mime)) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) { diff --git a/src/commands/delete.rs b/src/commands/delete.rs index dd84989..ba358ad 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -9,7 +9,7 @@ pub trait DeleteCommand { impl DeleteCommand for SqliteClipboardDb { fn delete(&self, input: impl Read) -> Result { let deleted = self.delete_entries(input)?; - log::info!("Deleted {deleted} entries"); + log::info!("deleted {deleted} entries"); Ok(deleted) } } diff --git a/src/commands/import.rs b/src/commands/import.rs index 933cf88..4a3a2a7 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -55,11 +55,11 @@ impl ImportCommand for SqliteClipboardDb { imported += 1; } - log::info!("Imported {imported} records from TSV into SQLite database."); + log::info!("imported {imported} records from TSV into SQLite database."); // Trim database to max_items after import self.trim_db(max_items)?; - log::info!("Trimmed clipboard database to max_items = {max_items}"); + log::info!("trimmed clipboard database to max_items = {max_items}"); Ok(()) } diff --git a/src/commands/list.rs b/src/commands/list.rs index 7d289ad..b3041e5 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -710,7 +710,7 @@ impl SqliteClipboardDb { .show(); }, Err(e) => { - log::error!("Failed to copy entry to clipboard: {e}"); + log::error!("failed to copy entry to clipboard: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to copy to clipboard: {e}")) @@ -719,7 +719,7 @@ impl SqliteClipboardDb { } }, Err(e) => { - log::error!("Failed to fetch entry {id}: {e}"); + log::error!("failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to fetch entry: {e}")) diff --git a/src/commands/store.rs b/src/commands/store.rs index 0b7e23c..4495754 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -29,7 +29,7 @@ impl StoreCommand for SqliteClipboardDb { ) -> Result<(), crate::db::StashError> { if let Some("sensitive" | "clear") = state.as_deref() { self.delete_last()?; - log::info!("Entry deleted"); + log::info!("entry deleted"); } else { self.store_entry( input, @@ -41,7 +41,7 @@ impl StoreCommand for SqliteClipboardDb { None, // no pre-computed hash for CLI store None, // no mime types for CLI store )?; - log::info!("Entry stored"); + log::info!("entry stored"); } Ok(()) } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 542937d..71cdc17 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -204,6 +204,7 @@ pub trait WatchCommand { mime_type_preference: &str, min_size: Option, max_size: usize, + persist: bool, ); } @@ -217,6 +218,7 @@ impl WatchCommand for SqliteClipboardDb { mime_type_preference: &str, min_size: Option, max_size: usize, + persist: bool, ) { let async_db = AsyncClipboardDb::new(self.db_path.clone()); log::info!( @@ -224,6 +226,10 @@ impl WatchCommand for SqliteClipboardDb { {mime_type_preference}" ); + if persist { + log::info!("clipboard persistence enabled"); + } + // Build expiration queue from existing entries let mut exp_queue = ExpirationQueue::new(); @@ -234,11 +240,11 @@ impl WatchCommand for SqliteClipboardDb { exp_queue.push(expires_at, id); } if !exp_queue.is_empty() { - log::info!("Loaded {} expirations from database", exp_queue.len()); + log::info!("loaded {} expirations from database", exp_queue.len()); } }, Err(e) => { - log::warn!("Failed to load expirations: {e}"); + log::warn!("failed to load expirations: {e}"); }, } @@ -277,7 +283,7 @@ impl WatchCommand for SqliteClipboardDb { match async_db.get_content_hash(id).await { Ok(hash) => hash, Err(e) => { - log::warn!("Failed to get content hash for entry {id}: {e}"); + log::warn!("failed to get content hash for entry {id}: {e}"); None }, }; @@ -285,9 +291,9 @@ impl WatchCommand for SqliteClipboardDb { if let Some(stored_hash) = expired_hash { // Mark as expired if let Err(e) = async_db.mark_expired(id).await { - log::warn!("Failed to mark entry {id} as expired: {e}"); + log::warn!("failed to mark entry {id} as expired: {e}"); } else { - log::info!("Entry {id} marked as expired"); + log::info!("entry {id} marked as expired"); } // Check if this expired entry is currently in the clipboard @@ -315,12 +321,12 @@ impl WatchCommand for SqliteClipboardDb { .is_ok() { log::info!( - "Cleared clipboard containing expired entry {id}" + "cleared clipboard containing expired entry {id}" ); last_hash = None; // reset tracked hash } else { log::warn!( - "Failed to clear clipboard for expired entry {id}" + "failed to clear clipboard for expired entry {id}" ); } } @@ -337,7 +343,7 @@ impl WatchCommand for SqliteClipboardDb { Ok((mut reader, _mime_type, _all_mimes)) => { buf.clear(); if let Err(e) = reader.read_to_end(&mut buf) { - log::error!("Failed to read clipboard contents: {e}"); + log::error!("failed to read clipboard contents: {e}"); Timer::after(Duration::from_millis(500)).await; continue; } @@ -370,13 +376,13 @@ impl WatchCommand for SqliteClipboardDb { .await { Ok(id) => { - log::info!("Stored new clipboard entry (id: {id})"); + log::info!("stored new clipboard entry (id: {id})"); last_hash = Some(current_hash); // Persist clipboard: fork child to serve data // This keeps the clipboard alive when source app closes // Check if we're already serving to avoid duplicate processes - if get_serving_pid().is_none() { + if persist && get_serving_pid().is_none() { let clipboard_data = ClipboardData::new( buf_for_persist, mime_types_for_persist, @@ -393,12 +399,12 @@ impl WatchCommand for SqliteClipboardDb { .await; if let Err(e) = result { - log::debug!("Clipboard persistence failed: {e}"); + log::debug!("clipboard persistence failed: {e}"); } }) .detach(); } - } else { + } else if persist { log::trace!( "Already serving clipboard, skipping persistence fork" ); @@ -420,17 +426,17 @@ impl WatchCommand for SqliteClipboardDb { } }, Err(crate::db::StashError::ExcludedByApp(_)) => { - log::info!("Clipboard entry excluded by app filter"); + 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"); + log::info!("clipboard entry excluded by app filter"); last_hash = Some(current_hash); }, Err(e) => { - log::error!("Failed to store clipboard entry: {e}"); + log::error!("failed to store clipboard entry: {e}"); last_hash = Some(current_hash); }, } @@ -440,7 +446,7 @@ impl WatchCommand for SqliteClipboardDb { Err(e) => { let error_msg = e.to_string(); if !error_msg.contains("empty") { - log::error!("Failed to get clipboard contents: {e}"); + log::error!("failed to get clipboard contents: {e}"); } }, } diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs index c0bb9ee..2126347 100644 --- a/src/commands/wipe.rs +++ b/src/commands/wipe.rs @@ -7,7 +7,7 @@ pub trait WipeCommand { impl WipeCommand for SqliteClipboardDb { fn wipe(&self) -> Result<(), StashError> { self.wipe_db()?; - log::info!("Database wiped"); + log::info!("database wiped"); Ok(()) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index f907b3b..65eb097 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -875,7 +875,7 @@ impl ClipboardDb for SqliteClipboardDb { out .write_all(&contents) .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; - log::info!("Decoded entry with id {id}"); + log::info!("decoded entry with id {id}"); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 32c271d..3075e20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,6 +160,10 @@ enum Command { /// MIME type preference for clipboard reading. #[arg(short = 't', long, default_value = "any")] mime_type: String, + + /// Persist clipboard contents after the source application closes. + #[arg(long)] + persist: bool, }, } @@ -201,7 +205,7 @@ fn confirm(prompt: &str) -> bool { .with_default(false) .prompt() .unwrap_or_else(|e| { - log::error!("Confirmation prompt failed: {e}"); + log::error!("confirmation prompt failed: {e}"); false }) } @@ -477,6 +481,7 @@ fn main() -> eyre::Result<()> { Some(Command::Watch { expire_after, mime_type, + persist, }) => { db.watch( cli.max_dedupe_search, @@ -489,6 +494,7 @@ fn main() -> eyre::Result<()> { &mime_type, cli.min_size, cli.max_size, + persist, ) .await; }, diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 4b828b5..5daa1fd 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -421,7 +421,7 @@ fn handle_regular_paste( let selected_type = available_types.as_ref().and_then(select_best_mime_type); let mime_type = if let Some(ref best) = selected_type { - log::debug!("Auto-selecting MIME type: {best}"); + log::debug!("auto-selecting MIME type: {best}"); PasteMimeType::Specific(best) } else { get_paste_mime_type(args.mime_type.as_deref()) From 5cb6c84f0897ad9e836b25a885723fd9aa4a166e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 14:07:45 +0300 Subject: [PATCH 52/60] docs: document clipboard persistence opt-in behaviour Signed-off-by: NotAShelf Change-Id: Ie0830d547ba0e4fcbd620290b3d314b16a6a6964 --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 42dd542..d29b4f4 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,25 @@ ask the compositor for image data first. Most users will be fine using the default value (`any`) but in the case your browser (or other applications!) regularly misrepresent data, you might wish to prioritize a different type. +#### Clipboard Persistence + +By default, when you copy something and close the source application, Wayland +clears the clipboard. Stash can optionally keep the clipboard contents available +after the source closes using the `--persist` flag. + +```bash +stash watch --persist +``` + +When enabled, Stash will fork a background process to serve the clipboard +contents, keeping them available even after the original application exits. + +> [!NOTE] +> This feature is **opt-in** and disabled by default, as it may not be desirable +> for all users and can leave clipboard data in memory longer than expected. You +> must start the `stash watch` daemon with `--persist` for clipboard +> persistence. + ### Options Some commands take additional flags to modify Stash's behavior. See each From 75ca501e29fe74d5e0b35782b15c50d4e15ff31b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 14:09:39 +0300 Subject: [PATCH 53/60] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: Ibecde757e509c21ad612fc9b8e0fb5876a6a6964 --- Cargo.lock | 113 +++++++++++++++++++++++++++-------------------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ea168d..3bc9a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1137,12 +1137,13 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1150,9 +1151,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1163,9 +1164,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1177,15 +1178,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1197,15 +1198,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1263,9 +1264,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1355,9 +1356,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.93" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", @@ -1394,9 +1395,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" @@ -1435,9 +1436,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -1944,9 +1945,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2677,9 +2678,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2687,18 +2688,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", "toml_datetime", @@ -2708,9 +2709,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.1", ] @@ -2927,9 +2928,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -2940,9 +2941,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2950,9 +2951,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -2963,9 +2964,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -3430,15 +3431,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3447,9 +3448,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3520,18 +3521,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3541,9 +3542,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3552,9 +3553,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3563,9 +3564,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", From b0ee7f59a3804ae7672e622c1b924a9cfe091df1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 14:44:44 +0300 Subject: [PATCH 54/60] commands: deprecate plain `wipe` command in favor of `db wipe` Signed-off-by: NotAShelf Change-Id: I62dbcc00b6b79f160318f9704fab001b6a6a6964 --- src/commands/mod.rs | 1 - src/commands/wipe.rs | 13 ------------- src/main.rs | 32 ++------------------------------ 3 files changed, 2 insertions(+), 44 deletions(-) delete mode 100644 src/commands/wipe.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 67e9950..86b8c99 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,4 +5,3 @@ pub mod list; pub mod query; pub mod store; pub mod watch; -pub mod wipe; diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs deleted file mode 100644 index 2126347..0000000 --- a/src/commands/wipe.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; - -pub trait WipeCommand { - fn wipe(&self) -> Result<(), StashError>; -} - -impl WipeCommand for SqliteClipboardDb { - fn wipe(&self) -> Result<(), StashError> { - self.wipe_db()?; - log::info!("database wiped"); - Ok(()) - } -} diff --git a/src/main.rs b/src/main.rs index 3075e20..f6359b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,9 +32,8 @@ use crate::{ query::QueryCommand, store::StoreCommand, watch::WatchCommand, - wipe::WipeCommand, }, - db::DEFAULT_MAX_ENTRY_SIZE, + db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE}, }; #[derive(Parser)] @@ -124,16 +123,6 @@ enum Command { ask: bool, }, - /// Wipe all clipboard history - /// - /// DEPRECATED: Use `stash db wipe` instead - #[command(hide = true)] - Wipe { - /// Ask for confirmation before wiping - #[arg(long)] - ask: bool, - }, - /// Database management operations Db { #[command(subcommand)] @@ -380,23 +369,6 @@ fn main() -> eyre::Result<()> { } } }, - Some(Command::Wipe { ask }) => { - eprintln!( - "Warning: The 'stash wipe' command is deprecated. Use 'stash db \ - wipe' instead." - ); - let mut should_proceed = true; - if ask { - should_proceed = - confirm("Are you sure you want to wipe all clipboard history?"); - if !should_proceed { - log::info!("wipe command aborted by user."); - } - } - if should_proceed { - report_error(db.wipe(), "failed to wipe database"); - } - }, Some(Command::Db { action }) => { match action { @@ -424,7 +396,7 @@ fn main() -> eyre::Result<()> { }, } } else { - report_error(db.wipe(), "failed to wipe database"); + report_error(db.wipe_db(), "failed to wipe database"); } } }, From 32cf1936b6a2e148ec66a4330e7f11a199fe9784 Mon Sep 17 00:00:00 2001 From: Fazzi Date: Fri, 3 Apr 2026 20:08:31 +0100 Subject: [PATCH 55/60] nix: don't source old build script --- nix/package.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index ba9573d..b27a730 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -19,7 +19,6 @@ (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src)) (s + /Cargo.lock) (s + /Cargo.toml) - (s + /build.rs) ]; }; From 20504a6e8ba2766fd9eeaba1798c3d584517da8a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:42:19 +0300 Subject: [PATCH 56/60] ci: update flake inputs with dependabot; add cooldown to Rust deps Signed-off-by: NotAShelf Change-Id: Iac735278f32f323106314eb9d94159f06a6a6964 --- .github/dependabot.yaml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index aa30540..4bbfe7c 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,13 +1,23 @@ version: 2 updates: - # Update Cargo deps - - package-ecosystem: cargo - directory: "/" - schedule: - interval: "weekly" - # Update used workflows - package-ecosystem: github-actions directory: "/" schedule: interval: daily + + # Update Cargo deps + - package-ecosystem: cargo + directory: "/" + cooldown: + default-days: 7 + schedule: + interval: "weekly" + + # Update Nixpkgs & Crane + - package-ecosystem: nix + directory: "/" + cooldown: + default-days: 7 + schedule: + interval: daily From 81683ded038add7374cb67d66b935cbc39421015 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:44:53 +0300 Subject: [PATCH 57/60] nix: bump inputs Signed-off-by: NotAShelf Change-Id: I4ae530fc33a1d4033600801193a2566d6a6a6964 --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 62a0021..e50ffba 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1766194365, - "narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=", + "lastModified": 1775839657, + "narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=", "owner": "ipetkov", "repo": "crane", - "rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379", + "rev": "7cf72d978629469c4bd4206b95c402514c1f6000", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1766309749, - "narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=", + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", "type": "github" }, "original": { From 84cf1b46adc94d66b4309f19b209d6247572cc07 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:45:02 +0300 Subject: [PATCH 58/60] stash: add a note about Clap's multicall handling Signed-off-by: NotAShelf Change-Id: I4aec7f38ab24a6cd6310630f2169690c6a6a6964 --- src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.rs b/src/main.rs index f6359b1..f006d36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -204,6 +204,12 @@ fn main() -> eyre::Result<()> { color_eyre::install()?; // Check if we're being called as a multicall binary + // + // NOTE: We cannot use clap's multicall here because it requires the main + // command to have no arguments (only subcommands), but our Cli has global + // arguments like --max-items, --db-path, etc. Instead, we manually detect + // the invocation name and route appropriately. While this is ugly, it's + // seemingly the only option. let program_name = env::args().next().map(|s| { PathBuf::from(s) .file_name() From ac7fbe293bbf9f80f494729584f965e012af0921 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:55:56 +0300 Subject: [PATCH 59/60] build: bump dependencies Signed-off-by: NotAShelf Change-Id: If7985aa26f98a6aac1a994118df886046a6a6964 --- Cargo.lock | 68 +++++++++++++++++++++++++++++------------------------- Cargo.toml | 6 ++--- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc9a63..3c753bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,9 +90,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -217,9 +217,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -373,9 +373,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "shlex", @@ -863,9 +863,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filedescriptor" @@ -1102,6 +1102,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.11.0" @@ -1264,12 +1270,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1356,9 +1362,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "once_cell", "wasm-bindgen", @@ -1401,9 +1407,9 @@ checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -1602,9 +1608,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.12.0" +version = "4.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" dependencies = [ "futures-lite", "log", @@ -1910,9 +1916,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polling" @@ -2247,9 +2253,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2697,9 +2703,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -2928,9 +2934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -2941,9 +2947,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2951,9 +2957,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -2964,9 +2970,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index bae39c5..e3467ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] -arc-swap = { version = "1.9.0", optional = true } +arc-swap = { version = "1.9.1", optional = true } base64 = "0.22.1" blocking = "1.6.2" clap = { version = "4.6.0", features = [ "derive", "env" ] } @@ -27,10 +27,10 @@ env_logger = "0.11.10" humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } -libc = "0.2.183" +libc = "0.2.184" log = "0.4.29" mime-sniffer = "0.1.3" -notify-rust = { version = "4.12.0", optional = true } +notify-rust = { version = "4.14.0", optional = true } ratatui = "0.30.0" regex = "1.12.3" rusqlite = { version = "0.39.0", features = [ "bundled" ] } From cd692ba00247cfebc1686a202ffd1505dfb95faf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:00:34 +0000 Subject: [PATCH 60/60] build(deps): bump softprops/action-gh-release from 2 to 3 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 62bfdd3..62cfe82 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,7 +40,7 @@ jobs: steps: - name: Create Release id: create_release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: draft: false prerelease: false @@ -98,7 +98,7 @@ jobs: cp target/${{ matrix.target }}/release/stash ${{ matrix.name }} - name: Upload Release Asset - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: ${{ matrix.name }} @@ -120,7 +120,7 @@ jobs: sha256sum stash-* > SHA256SUMS - name: Upload Checksums - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: token: ${{ secrets.GITHUB_TOKEN }} files: SHA256SUMS