From fe86356399138973f6d85e900f729e4709343310 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Mar 2026 08:39:49 +0300 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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."); }