mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-12 14:07:42 +00:00
Merge pull request #80 from NotAShelf/notashelf/push-yvkonkrnonvs
various: implement clipboard persistence
This commit is contained in:
commit
a2a609f07d
10 changed files with 680 additions and 199 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -88,6 +88,15 @@ version = "1.0.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -2409,6 +2418,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
name = "stash-clipboard"
|
name = "stash-clipboard"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
"base64",
|
"base64",
|
||||||
"blocking",
|
"blocking",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ name = "stash" # actual binary name for Nix, Cargo, etc.
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
arc-swap = { version = "1.9.0", optional = true }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
blocking = "1.6.2"
|
blocking = "1.6.2"
|
||||||
clap = { version = "4.6.0", features = [ "derive", "env" ] }
|
clap = { version = "4.6.0", features = [ "derive", "env" ] }
|
||||||
|
|
@ -50,7 +51,7 @@ tempfile = "3.27.0"
|
||||||
[features]
|
[features]
|
||||||
default = [ "notifications", "use-toplevel" ]
|
default = [ "notifications", "use-toplevel" ]
|
||||||
notifications = [ "dep:notify-rust" ]
|
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]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
3
src/clipboard/mod.rs
Normal file
3
src/clipboard/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod persist;
|
||||||
|
|
||||||
|
pub use persist::{ClipboardData, get_serving_pid, persist_clipboard};
|
||||||
262
src/clipboard/persist.rs
Normal file
262
src/clipboard/persist.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,7 @@ impl StoreCommand for SqliteClipboardDb {
|
||||||
min_size,
|
min_size,
|
||||||
max_size,
|
max_size,
|
||||||
None, // no pre-computed hash for CLI store
|
None, // no pre-computed hash for CLI store
|
||||||
|
None, // no mime types for CLI store
|
||||||
)?;
|
)?;
|
||||||
log::info!("Entry stored");
|
log::info!("Entry stored");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
use std::{collections::BinaryHeap, io::Read, time::Duration};
|
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.
|
/// 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 {
|
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.
|
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
|
||||||
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
|
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
|
||||||
/// Also see:
|
/// Also see:
|
||||||
|
|
@ -151,21 +154,29 @@ impl ExpirationQueue {
|
||||||
/// When `preference` is `"text"`, uses `MimeType::Text` directly (single call).
|
/// When `preference` is `"text"`, uses `MimeType::Text` directly (single call).
|
||||||
/// When `preference` is `"image"`, picks the first offered `image/*` type.
|
/// When `preference` is `"image"`, picks the first offered `image/*` type.
|
||||||
/// Otherwise picks the source's first offered 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(
|
fn negotiate_mime_type(
|
||||||
preference: &str,
|
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" {
|
if preference == "text" {
|
||||||
let (reader, mime_str) = get_contents(
|
let (reader, mime_str) = get_contents(
|
||||||
ClipboardType::Regular,
|
ClipboardType::Regular,
|
||||||
Seat::Unspecified,
|
Seat::Unspecified,
|
||||||
PasteMimeType::Text,
|
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" {
|
let chosen = if preference == "image" {
|
||||||
// Pick the first offered image type, fall back to first overall
|
// Pick the first offered image type, fall back to first overall
|
||||||
offered
|
offered
|
||||||
|
|
@ -202,7 +213,8 @@ fn negotiate_mime_type(
|
||||||
Seat::Unspecified,
|
Seat::Unspecified,
|
||||||
PasteMimeType::Specific(mime_str),
|
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),
|
None => Err(wl_clipboard_rs::paste::Error::NoSeats),
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +282,7 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize with current clipboard using smart MIME negotiation
|
// 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();
|
buf.clear();
|
||||||
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
||||||
last_hash = Some(hash_contents(&buf));
|
last_hash = Some(hash_contents(&buf));
|
||||||
|
|
@ -306,7 +318,7 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this expired entry is currently in the clipboard
|
// 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)
|
negotiate_mime_type(mime_type_preference)
|
||||||
{
|
{
|
||||||
let mut current_buf = Vec::new();
|
let mut current_buf = Vec::new();
|
||||||
|
|
@ -349,7 +361,7 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
// Normal clipboard polling (always run, even when expirations are
|
// Normal clipboard polling (always run, even when expirations are
|
||||||
// pending)
|
// pending)
|
||||||
match negotiate_mime_type(mime_type_preference) {
|
match negotiate_mime_type(mime_type_preference) {
|
||||||
Ok((mut reader, _mime_type)) => {
|
Ok((mut reader, _mime_type, _all_mimes)) => {
|
||||||
buf.clear();
|
buf.clear();
|
||||||
if let Err(e) = reader.read_to_end(&mut buf) {
|
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}");
|
||||||
|
|
@ -365,6 +377,12 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
let buf_clone = buf.clone();
|
let buf_clone = buf.clone();
|
||||||
#[allow(clippy::cast_possible_wrap)]
|
#[allow(clippy::cast_possible_wrap)]
|
||||||
let content_hash = Some(current_hash as i64);
|
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
|
match async_db
|
||||||
.store_entry(
|
.store_entry(
|
||||||
buf_clone,
|
buf_clone,
|
||||||
|
|
@ -374,6 +392,7 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
min_size,
|
min_size,
|
||||||
max_size,
|
max_size,
|
||||||
content_hash,
|
content_hash,
|
||||||
|
Some(mime_types_for_persist.clone()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -381,6 +400,37 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
log::info!("Stored new clipboard entry (id: {id})");
|
log::info!("Stored new clipboard entry (id: {id})");
|
||||||
last_hash = Some(current_hash);
|
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
|
// Set expiration if configured
|
||||||
if let Some(duration) = expire_after {
|
if let Some(duration) = expire_after {
|
||||||
let expires_at =
|
let expires_at =
|
||||||
|
|
@ -539,4 +589,145 @@ mod tests {
|
||||||
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
|
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
|
||||||
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
208
src/db/mod.rs
208
src/db/mod.rs
|
|
@ -254,6 +254,7 @@ pub trait ClipboardDb {
|
||||||
/// * `min_size` - Minimum content size (None for no minimum)
|
/// * `min_size` - Minimum content size (None for no minimum)
|
||||||
/// * `max_size` - Maximum content size
|
/// * `max_size` - Maximum content size
|
||||||
/// * `content_hash` - Optional pre-computed content hash (avoids re-hashing)
|
/// * `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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn store_entry(
|
fn store_entry(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -264,6 +265,7 @@ pub trait ClipboardDb {
|
||||||
min_size: Option<usize>,
|
min_size: Option<usize>,
|
||||||
max_size: usize,
|
max_size: usize,
|
||||||
content_hash: Option<i64>,
|
content_hash: Option<i64>,
|
||||||
|
mime_types: Option<&[String]>,
|
||||||
) -> Result<i64, StashError>;
|
) -> Result<i64, StashError>;
|
||||||
|
|
||||||
fn deduplicate_by_hash(
|
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| {
|
tx.commit().map_err(|e| {
|
||||||
StashError::Store(
|
StashError::Store(
|
||||||
format!("Failed to commit migration transaction: {e}").into(),
|
format!("Failed to commit migration transaction: {e}").into(),
|
||||||
|
|
@ -616,6 +648,7 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
min_size: Option<usize>,
|
min_size: Option<usize>,
|
||||||
max_size: usize,
|
max_size: usize,
|
||||||
content_hash: Option<i64>,
|
content_hash: Option<i64>,
|
||||||
|
mime_types: Option<&[String]>,
|
||||||
) -> Result<i64, StashError> {
|
) -> Result<i64, StashError> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
if input.read_to_end(&mut buf).is_err() || buf.is_empty() {
|
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)?;
|
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
|
self
|
||||||
.conn
|
.conn
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \
|
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
mime_types) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![
|
params![
|
||||||
buf,
|
buf,
|
||||||
mime,
|
mime,
|
||||||
|
|
@ -683,7 +726,8 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.expect("Time went backwards")
|
.expect("Time went backwards")
|
||||||
.as_secs() as i64
|
.as_secs() as i64,
|
||||||
|
mime_types_json
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||||
|
|
@ -1480,11 +1524,12 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
get_schema_version(&db.conn).expect("Failed to get schema version"),
|
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", "content_hash"));
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
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_content_hash"));
|
||||||
assert!(index_exists(&db.conn, "idx_last_accessed"));
|
assert!(index_exists(&db.conn, "idx_last_accessed"));
|
||||||
|
|
@ -1532,11 +1577,12 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
get_schema_version(&db.conn)
|
get_schema_version(&db.conn)
|
||||||
.expect("Failed to get version after migration"),
|
.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", "content_hash"));
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "mime_types"));
|
||||||
|
|
||||||
let count: i64 = db
|
let count: i64 = db
|
||||||
.conn
|
.conn
|
||||||
|
|
@ -1575,11 +1621,12 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
get_schema_version(&db.conn)
|
get_schema_version(&db.conn)
|
||||||
.expect("Failed to get version after migration"),
|
.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", "content_hash"));
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "mime_types"));
|
||||||
|
|
||||||
let count: i64 = db
|
let count: i64 = db
|
||||||
.conn
|
.conn
|
||||||
|
|
@ -1619,11 +1666,12 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
get_schema_version(&db.conn)
|
get_schema_version(&db.conn)
|
||||||
.expect("Failed to get version after migration"),
|
.expect("Failed to get version after migration"),
|
||||||
5
|
6
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
||||||
assert!(index_exists(&db.conn, "idx_last_accessed"));
|
assert!(index_exists(&db.conn, "idx_last_accessed"));
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "mime_types"));
|
||||||
|
|
||||||
let count: i64 = db
|
let count: i64 = db
|
||||||
.conn
|
.conn
|
||||||
|
|
@ -1656,7 +1704,7 @@ mod tests {
|
||||||
get_schema_version(&db2.conn).expect("Failed to get version");
|
get_schema_version(&db2.conn).expect("Failed to get version");
|
||||||
|
|
||||||
assert_eq!(version_after_first, version_after_second);
|
assert_eq!(version_after_first, version_after_second);
|
||||||
assert_eq!(version_after_first, 5);
|
assert_eq!(version_after_first, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1670,127 +1718,19 @@ mod tests {
|
||||||
let test_data = b"Hello, World!";
|
let test_data = b"Hello, World!";
|
||||||
let cursor = std::io::Cursor::new(test_data.to_vec());
|
let cursor = std::io::Cursor::new(test_data.to_vec());
|
||||||
|
|
||||||
let id = db
|
let _id = db
|
||||||
.store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None)
|
.store_entry(
|
||||||
|
cursor,
|
||||||
|
100,
|
||||||
|
1000,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.expect("Failed to store entry");
|
.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
|
let count: i64 = db
|
||||||
.conn
|
.conn
|
||||||
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||||
|
|
@ -1811,6 +1751,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store URI list");
|
.expect("Failed to store URI list");
|
||||||
|
|
||||||
|
|
@ -1845,6 +1786,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store image");
|
.expect("Failed to store image");
|
||||||
|
|
||||||
|
|
@ -1874,6 +1816,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store first");
|
.expect("Failed to store first");
|
||||||
let _id2 = db
|
let _id2 = db
|
||||||
|
|
@ -1885,6 +1828,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store second");
|
.expect("Failed to store second");
|
||||||
|
|
||||||
|
|
@ -1921,6 +1865,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store");
|
.expect("Failed to store");
|
||||||
}
|
}
|
||||||
|
|
@ -1943,6 +1888,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Err(StashError::EmptyOrTooLarge)));
|
assert!(matches!(result, Err(StashError::EmptyOrTooLarge)));
|
||||||
}
|
}
|
||||||
|
|
@ -1958,6 +1904,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Err(StashError::AllWhitespace)));
|
assert!(matches!(result, Err(StashError::AllWhitespace)));
|
||||||
}
|
}
|
||||||
|
|
@ -1975,6 +1922,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Err(StashError::TooLarge(5000000))));
|
assert!(matches!(result, Err(StashError::TooLarge(5000000))));
|
||||||
}
|
}
|
||||||
|
|
@ -1991,6 +1939,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store");
|
.expect("Failed to store");
|
||||||
|
|
||||||
|
|
@ -2018,6 +1967,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store");
|
.expect("Failed to store");
|
||||||
db.store_entry(
|
db.store_entry(
|
||||||
|
|
@ -2028,6 +1978,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store");
|
.expect("Failed to store");
|
||||||
|
|
||||||
|
|
@ -2056,6 +2007,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store");
|
.expect("Failed to store");
|
||||||
}
|
}
|
||||||
|
|
@ -2136,6 +2088,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store");
|
.expect("Failed to store");
|
||||||
|
|
||||||
|
|
@ -2221,6 +2174,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
DEFAULT_MAX_ENTRY_SIZE,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("Failed to store");
|
.expect("Failed to store");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ impl AsyncClipboardDb {
|
||||||
Self { db_path }
|
Self { db_path }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::too_many_arguments)]
|
||||||
pub async fn store_entry(
|
pub async fn store_entry(
|
||||||
&self,
|
&self,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
|
|
@ -26,6 +27,7 @@ impl AsyncClipboardDb {
|
||||||
min_size: Option<usize>,
|
min_size: Option<usize>,
|
||||||
max_size: usize,
|
max_size: usize,
|
||||||
content_hash: Option<i64>,
|
content_hash: Option<i64>,
|
||||||
|
mime_types: Option<Vec<String>>,
|
||||||
) -> Result<i64, StashError> {
|
) -> Result<i64, StashError> {
|
||||||
let path = self.db_path.clone();
|
let path = self.db_path.clone();
|
||||||
blocking::unblock(move || {
|
blocking::unblock(move || {
|
||||||
|
|
@ -38,6 +40,7 @@ impl AsyncClipboardDb {
|
||||||
min_size,
|
min_size,
|
||||||
max_size,
|
max_size,
|
||||||
content_hash,
|
content_hash,
|
||||||
|
mime_types.as_deref(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -172,7 +175,16 @@ mod tests {
|
||||||
let data = b"async test data";
|
let data = b"async test data";
|
||||||
|
|
||||||
let id = async_db
|
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
|
.await
|
||||||
.expect("Failed to store entry");
|
.expect("Failed to store entry");
|
||||||
|
|
||||||
|
|
@ -201,7 +213,16 @@ mod tests {
|
||||||
let data = b"expiring entry";
|
let data = b"expiring entry";
|
||||||
|
|
||||||
let id = async_db
|
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
|
.await
|
||||||
.expect("Failed to store entry");
|
.expect("Failed to store entry");
|
||||||
|
|
||||||
|
|
@ -233,7 +254,16 @@ mod tests {
|
||||||
let data = b"entry to expire";
|
let data = b"entry to expire";
|
||||||
|
|
||||||
let id = async_db
|
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
|
.await
|
||||||
.expect("Failed to store entry");
|
.expect("Failed to store entry");
|
||||||
|
|
||||||
|
|
@ -280,12 +310,30 @@ mod tests {
|
||||||
let data = b"clone test";
|
let data = b"clone test";
|
||||||
|
|
||||||
let id1 = async_db
|
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
|
.await
|
||||||
.expect("Failed with original");
|
.expect("Failed with original");
|
||||||
|
|
||||||
let id2 = cloned
|
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
|
.await
|
||||||
.expect("Failed with clone");
|
.expect("Failed with clone");
|
||||||
|
|
||||||
|
|
@ -304,7 +352,7 @@ mod tests {
|
||||||
let db = async_db.clone();
|
let db = async_db.clone();
|
||||||
let data = format!("concurrent test {}", i).into_bytes();
|
let data = format!("concurrent test {}", i).into_bytes();
|
||||||
smol::spawn(async move {
|
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
|
.await
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
76
src/main.rs
76
src/main.rs
|
|
@ -1,3 +1,9 @@
|
||||||
|
mod clipboard;
|
||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod mime;
|
||||||
|
mod multicall;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
io::{self, IsTerminal},
|
io::{self, IsTerminal},
|
||||||
|
|
@ -6,13 +12,14 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use clap::{CommandFactory, Parser, Subcommand};
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
|
use color_eyre::eyre;
|
||||||
use humantime::parse_duration;
|
use humantime::parse_duration;
|
||||||
use inquire::Confirm;
|
use inquire::Confirm;
|
||||||
|
|
||||||
mod commands;
|
// While the module is named "wayland", the Wayland module is *strictly* for the
|
||||||
pub(crate) mod db;
|
// use-toplevel feature as it requires some low-level wayland crates that are
|
||||||
pub(crate) mod mime;
|
// not required *by default*. The module is named that way because "toplevel"
|
||||||
mod multicall;
|
// sounded too silly. Stash is strictly a Wayland clipboard manager.
|
||||||
#[cfg(feature = "use-toplevel")] mod wayland;
|
#[cfg(feature = "use-toplevel")] mod wayland;
|
||||||
|
|
||||||
use crate::{
|
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
|
#[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
|
// Check if we're being called as a multicall binary
|
||||||
let program_name = env::args().next().map(|s| {
|
let program_name = env::args().next().map(|s| {
|
||||||
PathBuf::from(s)
|
PathBuf::from(s)
|
||||||
|
|
@ -216,12 +235,18 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
.filter_level(cli.verbosity.into())
|
.filter_level(cli.verbosity.into())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let db_path = cli.db_path.unwrap_or_else(|| {
|
let db_path = match cli.db_path {
|
||||||
dirs::cache_dir()
|
Some(path) => path,
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
None => {
|
||||||
.join("stash")
|
let cache_dir = dirs::cache_dir().ok_or_else(|| {
|
||||||
.join("db")
|
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() {
|
if let Some(parent) = db_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
|
|
@ -299,10 +324,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
should_proceed =
|
should_proceed =
|
||||||
Confirm::new("Are you sure you want to delete clipboard entries?")
|
confirm("Are you sure you want to delete clipboard entries?");
|
||||||
.with_default(false)
|
|
||||||
.prompt()
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !should_proceed {
|
if !should_proceed {
|
||||||
log::info!("aborted by user.");
|
log::info!("aborted by user.");
|
||||||
|
|
@ -360,12 +382,8 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
);
|
);
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
should_proceed = Confirm::new(
|
should_proceed =
|
||||||
"Are you sure you want to wipe all clipboard history?",
|
confirm("Are you sure you want to wipe all clipboard history?");
|
||||||
)
|
|
||||||
.with_default(false)
|
|
||||||
.prompt()
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !should_proceed {
|
if !should_proceed {
|
||||||
log::info!("wipe command aborted by user.");
|
log::info!("wipe command aborted by user.");
|
||||||
}
|
}
|
||||||
|
|
@ -385,10 +403,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
} else {
|
} else {
|
||||||
"Are you sure you want to wipe ALL clipboard history?"
|
"Are you sure you want to wipe ALL clipboard history?"
|
||||||
};
|
};
|
||||||
should_proceed = Confirm::new(message)
|
should_proceed = confirm(message);
|
||||||
.with_default(false)
|
|
||||||
.prompt()
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !should_proceed {
|
if !should_proceed {
|
||||||
log::info!("db wipe command aborted by user.");
|
log::info!("db wipe command aborted by user.");
|
||||||
}
|
}
|
||||||
|
|
@ -397,7 +412,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
if expired {
|
if expired {
|
||||||
match db.cleanup_expired() {
|
match db.cleanup_expired() {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
log::info!("Wiped {count} expired entries");
|
log::info!("wiped {count} expired entries");
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("failed to wipe expired entries: {e}");
|
log::error!("failed to wipe expired entries: {e}");
|
||||||
|
|
@ -411,7 +426,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
DbAction::Vacuum => {
|
DbAction::Vacuum => {
|
||||||
match db.vacuum() {
|
match db.vacuum() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
log::info!("Database optimized successfully");
|
log::info!("database optimized successfully");
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("failed to vacuum database: {e}");
|
log::error!("failed to vacuum database: {e}");
|
||||||
|
|
@ -434,13 +449,10 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
Some(Command::Import { r#type, ask }) => {
|
Some(Command::Import { r#type, ask }) => {
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
should_proceed = Confirm::new(
|
should_proceed = confirm(
|
||||||
"Are you sure you want to import clipboard data? This may \
|
"Are you sure you want to import clipboard data? This may \
|
||||||
overwrite existing entries.",
|
overwrite existing entries.",
|
||||||
)
|
);
|
||||||
.with_default(false)
|
|
||||||
.prompt()
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !should_proceed {
|
if !should_proceed {
|
||||||
log::info!("import command aborted by user.");
|
log::info!("import command aborted by user.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{LazyLock, Mutex},
|
sync::{Arc, LazyLock, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use arc_swap::ArcSwapOption;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use wayland_client::{
|
use wayland_client::{
|
||||||
Connection as WaylandConnection,
|
Connection as WaylandConnection,
|
||||||
|
|
@ -17,7 +18,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{
|
||||||
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
|
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>>> =
|
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
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
|
/// Get the currently focused window application name using Wayland protocols
|
||||||
pub fn get_focused_window_app() -> Option<String> {
|
pub fn get_focused_window_app() -> Option<String> {
|
||||||
// Try Wayland protocol first
|
// Load the focused app using lock-free arc-swap
|
||||||
if let Ok(focused) = FOCUSED_APP.lock()
|
let focused = FOCUSED_APP.load();
|
||||||
&& let Some(ref app) = *focused
|
if let Some(app) = focused.as_ref() {
|
||||||
{
|
|
||||||
debug!("Found focused app via Wayland protocol: {app}");
|
debug!("Found focused app via Wayland protocol: {app}");
|
||||||
return Some(app.clone());
|
return Some(app.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("No focused window detection method worked");
|
debug!("No focused window detection method worked");
|
||||||
|
|
@ -152,12 +152,11 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
|
||||||
}) {
|
}) {
|
||||||
debug!("Toplevel activated");
|
debug!("Toplevel activated");
|
||||||
// Update focused app to the `app_id` of this handle
|
// Update focused app to the `app_id` of this handle
|
||||||
if let (Ok(apps), Ok(mut focused)) =
|
if let Ok(apps) = TOPLEVEL_APPS.lock()
|
||||||
(TOPLEVEL_APPS.lock(), FOCUSED_APP.lock())
|
|
||||||
&& let Some(app_id) = apps.get(&handle_id)
|
&& let Some(app_id) = apps.get(&handle_id)
|
||||||
{
|
{
|
||||||
debug!("Setting focused app to: {app_id}");
|
debug!("Setting focused app to: {app_id}");
|
||||||
*focused = Some(app_id.clone());
|
FOCUSED_APP.store(Some(Arc::new(app_id.clone())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue