- Add Cargo.toml for workspace configuration with dependencies. - Create README.md with project description, usage, and architecture details. - Implement `bin` crate for the main executable, including clipboard processing logic. - Add `config` crate for handling configuration in TOML format. - Develop `lib` crate containing core image processing logic and error handling. - Introduce `platform` crate for platform-specific clipboard interactions, starting with Wayland. - Implement tests for image shrinking functionality and clipboard interactions.
217 lines
7.0 KiB
Rust
217 lines
7.0 KiB
Rust
use lib::{ClipboardError, ClipboardProvider};
|
|
use std::io::Read;
|
|
use tracing::debug;
|
|
|
|
pub struct WaylandBackend;
|
|
|
|
const IMAGE_MIMES: &[&str] = &[
|
|
// Compressed — try our own likely output first
|
|
"image/webp",
|
|
"image/avif",
|
|
// Common lossless/lossy
|
|
"image/png",
|
|
"image/jpeg",
|
|
// Other standard types
|
|
"image/gif",
|
|
"image/bmp",
|
|
"image/tiff",
|
|
// Less common but image-crate-supported
|
|
"image/x-qoi",
|
|
"image/x-tga",
|
|
"image/x-icon",
|
|
"image/vnd.radiance",
|
|
"image/x-exr",
|
|
"image/x-portable-anymap",
|
|
"image/x-farbfeld",
|
|
];
|
|
|
|
impl ClipboardProvider for WaylandBackend {
|
|
fn capture(&self) -> Result<(Vec<u8>, String), ClipboardError> {
|
|
use wl_clipboard_rs::paste::{ClipboardType, Error, MimeType, Seat, get_contents};
|
|
|
|
// 1. Try raw image MIME types (webp first so our own output is matched first).
|
|
for &mime in IMAGE_MIMES {
|
|
match get_contents(
|
|
ClipboardType::Regular,
|
|
Seat::Unspecified,
|
|
MimeType::Specific(mime),
|
|
) {
|
|
Ok((mut pipe, mime_type)) => {
|
|
let mut data = Vec::new();
|
|
pipe.read_to_end(&mut data)
|
|
.map_err(|e| ClipboardError::BackendError(e.to_string()))?;
|
|
if data.is_empty() {
|
|
debug!("{mime} offered but data was empty");
|
|
continue;
|
|
}
|
|
debug!("captured {} bytes as {mime_type}", data.len());
|
|
return Ok((data, mime_type));
|
|
}
|
|
Err(Error::NoMimeType) => {
|
|
debug!("{mime} not offered");
|
|
continue;
|
|
}
|
|
Err(Error::NoSeats) | Err(Error::ClipboardEmpty) => {
|
|
return Err(ClipboardError::Empty);
|
|
}
|
|
Err(e) => return Err(ClipboardError::BackendError(e.to_string())),
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: file manager copies put file URIs in text/uri-list.
|
|
match get_contents(
|
|
ClipboardType::Regular,
|
|
Seat::Unspecified,
|
|
MimeType::Specific("text/uri-list"),
|
|
) {
|
|
Ok((pipe, _)) => read_image_from_uri_list(pipe),
|
|
Err(Error::NoMimeType) | Err(Error::NoSeats) | Err(Error::ClipboardEmpty) => {
|
|
Err(ClipboardError::Empty)
|
|
}
|
|
Err(e) => Err(ClipboardError::BackendError(e.to_string())),
|
|
}
|
|
}
|
|
|
|
fn distribute(&self, items: &[(&[u8], &str)]) -> Result<(), ClipboardError> {
|
|
use wl_clipboard_rs::copy::{MimeSource, MimeType as CopyMime, Options, Source};
|
|
|
|
let sources: Vec<MimeSource> = items
|
|
.iter()
|
|
.map(|(data, mime)| MimeSource {
|
|
source: Source::Bytes(data.to_vec().into_boxed_slice()),
|
|
mime_type: CopyMime::Specific(mime.to_string()),
|
|
})
|
|
.collect();
|
|
|
|
Options::new()
|
|
.copy_multi(sources)
|
|
.map_err(|e| ClipboardError::BackendError(e.to_string()))
|
|
}
|
|
}
|
|
|
|
fn read_image_from_uri_list(mut pipe: impl Read) -> Result<(Vec<u8>, String), ClipboardError> {
|
|
let mut content = String::new();
|
|
pipe.read_to_string(&mut content)
|
|
.map_err(|e| ClipboardError::BackendError(e.to_string()))?;
|
|
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line.starts_with('#') || line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let path_str = match line.strip_prefix("file://") {
|
|
Some(p) => percent_decode(p),
|
|
None => {
|
|
debug!("skipping non-file URI: {line}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let path = std::path::Path::new(&path_str);
|
|
let mime = match path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| e.to_lowercase())
|
|
.as_deref()
|
|
{
|
|
Some("png") => "image/png",
|
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
|
Some("webp") => "image/webp",
|
|
Some("avif") => "image/avif",
|
|
Some("gif") => "image/gif",
|
|
Some("bmp") => "image/bmp",
|
|
Some("tiff") | Some("tif") => "image/tiff",
|
|
Some("qoi") => "image/x-qoi",
|
|
Some("tga") => "image/x-tga",
|
|
Some("ico") => "image/x-icon",
|
|
Some("hdr") => "image/vnd.radiance",
|
|
Some("exr") => "image/x-exr",
|
|
Some("ppm") | Some("pgm") | Some("pbm") | Some("pam") => "image/x-portable-anymap",
|
|
Some("ff") => "image/x-farbfeld",
|
|
_ => {
|
|
debug!("skipping non-image file: {path_str}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
debug!("reading image file: {path_str}");
|
|
let data = std::fs::read(path)
|
|
.map_err(|e| ClipboardError::BackendError(format!("{path_str}: {e}")))?;
|
|
|
|
return Ok((data, mime.to_string()));
|
|
}
|
|
|
|
Err(ClipboardError::InvalidType(
|
|
"no image files in uri-list".to_string(),
|
|
))
|
|
}
|
|
|
|
fn percent_decode(s: &str) -> String {
|
|
let bytes = s.as_bytes();
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
|
if let Ok(b) =
|
|
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
|
{
|
|
out.push(b);
|
|
i += 3;
|
|
continue;
|
|
}
|
|
}
|
|
out.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
String::from_utf8_lossy(&out).into_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn percent_decode_spaces() {
|
|
assert_eq!(
|
|
percent_decode("/home/user/my%20file.png"),
|
|
"/home/user/my file.png"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn percent_decode_no_encoding() {
|
|
assert_eq!(
|
|
percent_decode("/home/user/photo.jpg"),
|
|
"/home/user/photo.jpg"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uri_list_skips_comments_and_non_image() {
|
|
let input = b"# comment\nfile:///home/user/doc.pdf\nfile:///home/user/photo.png\n";
|
|
let result = read_image_from_uri_list(input.as_slice());
|
|
// doc.pdf skipped, photo.png read → would fail because file doesn't exist,
|
|
// but we get a BackendError (not InvalidType), meaning we got past the extension check.
|
|
assert!(matches!(result, Err(ClipboardError::BackendError(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn uri_list_no_images_returns_invalid_type() {
|
|
let input = b"file:///home/user/doc.pdf\nfile:///home/user/notes.txt\n";
|
|
let result = read_image_from_uri_list(input.as_slice());
|
|
assert!(matches!(result, Err(ClipboardError::InvalidType(_))));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn round_trip() {
|
|
let backend = WaylandBackend;
|
|
let data = b"fake image bytes";
|
|
backend.distribute(&[(data, "image/webp")]).unwrap();
|
|
let (got, mime) = backend.capture().unwrap();
|
|
assert_eq!(got, data);
|
|
assert_eq!(mime, "image/webp");
|
|
}
|
|
}
|