mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 06:23:47 +00:00
watch: respect source MIME type order in clipboard polling
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I3da2e187276611579f3686acb20aacf36a6a6964
This commit is contained in:
parent
b4dd704961
commit
3fd48896c1
1 changed files with 141 additions and 21 deletions
|
|
@ -8,7 +8,13 @@ use std::{
|
||||||
use smol::Timer;
|
use smol::Timer;
|
||||||
use wl_clipboard_rs::{
|
use wl_clipboard_rs::{
|
||||||
copy::{MimeType as CopyMimeType, Options, Source},
|
copy::{MimeType as CopyMimeType, Options, Source},
|
||||||
paste::{ClipboardType, Seat, get_contents},
|
paste::{
|
||||||
|
ClipboardType,
|
||||||
|
MimeType as PasteMimeType,
|
||||||
|
Seat,
|
||||||
|
get_contents,
|
||||||
|
get_mime_types_ordered,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||||
|
|
@ -93,6 +99,63 @@ impl ExpirationQueue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get clipboard contents using the source application's preferred MIME type.
|
||||||
|
///
|
||||||
|
/// See, `MimeType::Any` lets wl-clipboard-rs pick a type in arbitrary order,
|
||||||
|
/// which causes issues when applications offer multiple types (e.g. file
|
||||||
|
/// managers offering `text/uri-list` + `text/plain`, or Firefox offering
|
||||||
|
/// `text/html` + `image/png` + `text/plain`).
|
||||||
|
///
|
||||||
|
/// This queries the ordered types via [`get_mime_types_ordered`], which
|
||||||
|
/// preserves the Wayland protocol's offer order (source application's
|
||||||
|
/// preference) and then requests the first type with [`MimeType::Specific`].
|
||||||
|
///
|
||||||
|
/// The two-step approach has a theoretical race (clipboard could change between
|
||||||
|
/// the calls), but the wl-clipboard-rs API has no single-call variant that
|
||||||
|
/// respects source ordering. A race simply produces an error that the polling
|
||||||
|
/// loop handles like any other clipboard-empty/error case.
|
||||||
|
///
|
||||||
|
/// When `preference` is `"text"`, uses `MimeType::Text` directly (single call).
|
||||||
|
/// When `preference` is `"image"`, picks the first offered `image/*` type.
|
||||||
|
/// Otherwise picks the source's first offered type.
|
||||||
|
fn negotiate_mime_type(
|
||||||
|
preference: &str,
|
||||||
|
) -> Result<(Box<dyn Read>, String), wl_clipboard_rs::paste::Error> {
|
||||||
|
if preference == "text" {
|
||||||
|
let (reader, mime_str) = get_contents(
|
||||||
|
ClipboardType::Regular,
|
||||||
|
Seat::Unspecified,
|
||||||
|
PasteMimeType::Text,
|
||||||
|
)?;
|
||||||
|
return Ok((Box::new(reader) as Box<dyn Read>, mime_str));
|
||||||
|
}
|
||||||
|
|
||||||
|
let offered =
|
||||||
|
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?;
|
||||||
|
|
||||||
|
let chosen = if preference == "image" {
|
||||||
|
// Pick the first offered image type, fall back to first overall
|
||||||
|
offered
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.starts_with("image/"))
|
||||||
|
.or_else(|| offered.first())
|
||||||
|
} else {
|
||||||
|
offered.first()
|
||||||
|
};
|
||||||
|
|
||||||
|
match chosen {
|
||||||
|
Some(mime_str) => {
|
||||||
|
let (reader, actual_mime) = get_contents(
|
||||||
|
ClipboardType::Regular,
|
||||||
|
Seat::Unspecified,
|
||||||
|
PasteMimeType::Specific(mime_str),
|
||||||
|
)?;
|
||||||
|
Ok((Box::new(reader) as Box<dyn Read>, actual_mime))
|
||||||
|
},
|
||||||
|
None => Err(wl_clipboard_rs::paste::Error::NoSeats),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait WatchCommand {
|
pub trait WatchCommand {
|
||||||
fn watch(
|
fn watch(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -165,19 +228,8 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
hasher.finish()
|
hasher.finish()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert MIME type preference string to wl_clipboard_rs enum
|
// Initialize with current clipboard using smart MIME negotiation
|
||||||
let mime_type = match mime_type_preference {
|
if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) {
|
||||||
"text" => wl_clipboard_rs::paste::MimeType::Text,
|
|
||||||
"image" => {
|
|
||||||
wl_clipboard_rs::paste::MimeType::TextWithPriority("image/png")
|
|
||||||
},
|
|
||||||
_ => wl_clipboard_rs::paste::MimeType::Any,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize with current clipboard
|
|
||||||
if let Ok((mut reader, _)) =
|
|
||||||
get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type)
|
|
||||||
{
|
|
||||||
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));
|
||||||
|
|
@ -214,11 +266,9 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
log::info!("Entry {id} marked as expired");
|
log::info!("Entry {id} marked as expired");
|
||||||
|
|
||||||
// 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, _)) = get_contents(
|
if let Ok((mut reader, _)) =
|
||||||
ClipboardType::Regular,
|
negotiate_mime_type(mime_type_preference)
|
||||||
Seat::Unspecified,
|
{
|
||||||
mime_type,
|
|
||||||
) {
|
|
||||||
let mut current_buf = Vec::new();
|
let mut current_buf = Vec::new();
|
||||||
if reader.read_to_end(&mut current_buf).is_ok()
|
if reader.read_to_end(&mut current_buf).is_ok()
|
||||||
&& !current_buf.is_empty()
|
&& !current_buf.is_empty()
|
||||||
|
|
@ -262,8 +312,7 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal clipboard polling
|
// Normal clipboard polling
|
||||||
match get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type)
|
match negotiate_mime_type(mime_type_preference) {
|
||||||
{
|
|
||||||
Ok((mut reader, _mime_type)) => {
|
Ok((mut reader, _mime_type)) => {
|
||||||
buf.clear();
|
buf.clear();
|
||||||
if let Err(e) = reader.read_to_end(&mut buf) {
|
if let Err(e) = reader.read_to_end(&mut buf) {
|
||||||
|
|
@ -328,3 +377,74 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unit-testable helper: given ordered offers and a preference, return the
|
||||||
|
/// chosen MIME type. This mirrors the selection logic in
|
||||||
|
/// [`negotiate_mime_type`] without requiring a Wayland connection.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pick_mime<'a>(
|
||||||
|
offered: &'a [String],
|
||||||
|
preference: &str,
|
||||||
|
) -> Option<&'a String> {
|
||||||
|
if preference == "image" {
|
||||||
|
offered
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.starts_with("image/"))
|
||||||
|
.or_else(|| offered.first())
|
||||||
|
} else {
|
||||||
|
offered.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_first_offered() {
|
||||||
|
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
|
||||||
|
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_image_preference_finds_image() {
|
||||||
|
let offered = vec![
|
||||||
|
"text/html".to_string(),
|
||||||
|
"image/png".to_string(),
|
||||||
|
"text/plain".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_image_preference_falls_back() {
|
||||||
|
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
|
||||||
|
// No image types offered — falls back to first
|
||||||
|
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_empty_offered() {
|
||||||
|
let offered: Vec<String> = vec![];
|
||||||
|
assert!(pick_mime(&offered, "any").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_source_order_preserved() {
|
||||||
|
// Firefox typically offers html first, then image, then text
|
||||||
|
let offered = vec![
|
||||||
|
"text/html".to_string(),
|
||||||
|
"image/png".to_string(),
|
||||||
|
"text/plain".to_string(),
|
||||||
|
];
|
||||||
|
// With "any", we trust the source: first offered wins
|
||||||
|
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_file_manager_uri_list_first() {
|
||||||
|
// File managers typically offer uri-list first
|
||||||
|
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
|
||||||
|
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue