Merge pull request #80 from NotAShelf/notashelf/push-yvkonkrnonvs

various: implement clipboard persistence
This commit is contained in:
raf 2026-04-01 08:46:30 +03:00 committed by GitHub
commit a2a609f07d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 680 additions and 199 deletions

10
Cargo.lock generated
View file

@ -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",

View file

@ -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

3
src/clipboard/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod persist;
pub use persist::{ClipboardData, get_serving_pid, persist_clipboard};

262
src/clipboard/persist.rs Normal file
View file

@ -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<i32> {
let pid = SERVING_PID.load(Ordering::SeqCst);
if pid != 0 { Some(pid) } else { None }
}
/// Result type for persistence operations.
pub type PersistenceResult<T> = Result<T, PersistenceError>;
/// 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<u8>,
/// All MIME types offered by the source. Preserves order.
pub mime_types: Vec<String>,
/// The MIME type that was selected for storage.
pub selected_mime: String,
}
impl ClipboardData {
/// Create new clipboard data.
pub fn new(
content: Vec<u8>,
mime_types: Vec<String>,
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<PreparedCopy> {
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");
}
}

View file

@ -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");
}

View file

@ -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<dyn Read>, String), wl_clipboard_rs::paste::Error> {
) -> Result<(Box<dyn Read>, String, Vec<String>), 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<dyn Read>, mime_str));
return Ok((Box::new(reader) as Box<dyn Read>, 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<dyn Read>, actual_mime))
Ok((Box::new(reader) as Box<dyn Read>, 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<String> = 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");
}
}

View file

@ -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<usize>,
max_size: usize,
content_hash: Option<i64>,
mime_types: Option<&[String]>,
) -> Result<i64, StashError>;
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<usize>,
max_size: usize,
content_hash: Option<i64>,
mime_types: Option<&[String]>,
) -> Result<i64, StashError> {
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<String> = 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<i64> = 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<i64> = 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");

View file

@ -17,6 +17,7 @@ impl AsyncClipboardDb {
Self { db_path }
}
#[expect(clippy::too_many_arguments)]
pub async fn store_entry(
&self,
data: Vec<u8>,
@ -26,6 +27,7 @@ impl AsyncClipboardDb {
min_size: Option<usize>,
max_size: usize,
content_hash: Option<i64>,
mime_types: Option<Vec<String>>,
) -> Result<i64, StashError> {
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
})
})

View file

@ -1,3 +1,9 @@
mod clipboard;
mod commands;
mod db;
mod mime;
mod multicall;
use std::{
env,
io::{self, IsTerminal},
@ -6,13 +12,14 @@ use std::{
};
use clap::{CommandFactory, Parser, Subcommand};
use color_eyre::eyre;
use humantime::parse_duration;
use inquire::Confirm;
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::{
@ -188,8 +195,20 @@ fn report_error<T>(
}
}
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)
@ -216,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)?;
@ -299,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.");
@ -360,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.");
}
@ -385,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.");
}
@ -397,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}");
@ -411,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}");
@ -434,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.");
}

View file

@ -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<Option<String>> = Mutex::new(None);
static FOCUSED_APP: ArcSwapOption<String> = ArcSwapOption::const_empty();
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
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<String> {
// 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<ZwlrForeignToplevelHandleV1, ()> 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())));
}
}
},