feat: initialize K-Shrink workspace with multiple crates for image shrinking utility

- 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.
This commit is contained in:
2026-03-17 21:50:13 +01:00
commit 271d55ba57
18 changed files with 2788 additions and 0 deletions

18
crates/bin/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "k-shrink"
version = "0.1.0"
edition = "2024"
default-run = "k-shrink"
[dependencies]
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = "0.3"
tokio = { version = "1", features = ["full"] }
lib = { path = "../lib" }
platform = { path = "../platform" }
config = { path = "../config" }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
image = { version = "0.25.10", features = ["webp"] }

47
crates/bin/src/lib.rs Normal file
View File

@@ -0,0 +1,47 @@
use lib::{image_hash, shrink, ClipboardError, ClipboardProvider, ShrinkOptions};
use tracing::{debug, info, trace};
pub fn process_once(
provider: &dyn ClipboardProvider,
opts: &ShrinkOptions,
last_hash: &mut Option<[u8; 32]>,
) -> Result<(), Box<dyn std::error::Error>> {
let (data, mime_type) = match provider.capture() {
Ok(x) => x,
Err(ClipboardError::Empty) => {
trace!("clipboard empty");
return Ok(());
}
Err(ClipboardError::InvalidType(t)) => {
trace!("no image in clipboard: {t}");
return Ok(());
}
Err(e) => return Err(e.into()),
};
if !mime_type.starts_with("image/") {
trace!("non-image mime type: {mime_type}");
return Ok(());
}
let incoming_hash = image_hash(&data);
if Some(incoming_hash) == *last_hash {
debug!("clipboard unchanged, skipping");
return Ok(());
}
info!("compressing {} bytes ({mime_type})", data.len());
let result = shrink(&data, opts)?;
info!(
"compressed to {} bytes ({})",
result.data.len(),
result.mime_type
);
provider.distribute(&[(&result.data, result.mime_type.as_str())])?;
// Track hash of OUTPUT. After distributing webp-only, next capture will
// find image/webp (from our subprocess) → same hash → skip. No loop.
*last_hash = Some(image_hash(&result.data));
Ok(())
}

35
crates/bin/src/main.rs Normal file
View File

@@ -0,0 +1,35 @@
use config::load_config;
use lib::ShrinkOptions;
use platform::WaylandBackend;
use std::time::Duration;
use tracing::{error, info, warn};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let cfg = match load_config() {
Ok(c) => c,
Err(e) => {
warn!("config error ({e}), using defaults");
config::Config::default()
}
};
let poll = Duration::from_millis(cfg.general.poll_ms);
let opts = ShrinkOptions {
quality: cfg.general.quality,
target_format: cfg.general.format.into(),
};
let backend = WaylandBackend;
let mut last_hash: Option<[u8; 32]> = None;
info!("k-shrink daemon started (poll={}ms)", cfg.general.poll_ms);
loop {
if let Err(e) = k_shrink::process_once(&backend, &opts, &mut last_hash) {
error!("error: {e}");
}
tokio::time::sleep(poll).await;
}
}

View File

@@ -0,0 +1,100 @@
use lib::{ClipboardError, ClipboardProvider, OutputFormat, ShrinkOptions};
use std::cell::Cell;
use std::io::Cursor;
use std::sync::{Arc, Mutex};
struct MockClipboard {
capture_bytes: Vec<u8>,
capture_mime: String,
distributed: Arc<Mutex<Vec<(Vec<u8>, String)>>>,
capture_count: Cell<usize>,
}
impl MockClipboard {
fn new(bytes: Vec<u8>, mime: &str) -> Self {
Self {
capture_bytes: bytes,
capture_mime: mime.to_string(),
distributed: Arc::new(Mutex::new(Vec::new())),
capture_count: Cell::new(0),
}
}
}
impl ClipboardProvider for MockClipboard {
fn capture(&self) -> Result<(Vec<u8>, String), ClipboardError> {
self.capture_count.set(self.capture_count.get() + 1);
Ok((self.capture_bytes.clone(), self.capture_mime.clone()))
}
fn distribute(&self, items: &[(&[u8], &str)]) -> Result<(), ClipboardError> {
let mut lock = self.distributed.lock().unwrap();
for (data, mime) in items {
lock.push((data.to_vec(), mime.to_string()));
}
Ok(())
}
}
fn make_png() -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(8, 8);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
fn webp_opts() -> ShrinkOptions {
ShrinkOptions {
quality: 80,
target_format: OutputFormat::Webp,
}
}
#[test]
fn image_bytes_are_shrunk_and_distributed_as_webp() {
use k_shrink::process_once;
let png = make_png();
let mock = MockClipboard::new(png, "image/png");
let mut last_hash = None;
process_once(&mock, &webp_opts(), &mut last_hash).unwrap();
let dist = mock.distributed.lock().unwrap();
assert_eq!(dist.len(), 1, "exactly one item distributed");
assert_eq!(dist[0].1, "image/webp");
}
#[test]
fn same_webp_output_not_reprocessed() {
use k_shrink::process_once;
let png = make_png();
let mock = MockClipboard::new(png, "image/png");
let mut last_hash = None;
process_once(&mock, &webp_opts(), &mut last_hash).unwrap();
// After distributing, our subprocess serves image/webp.
// Simulate next tick: clipboard returns the webp we just wrote.
let webp_data = mock.distributed.lock().unwrap()[0].0.clone();
let mock2 = MockClipboard::new(webp_data, "image/webp");
process_once(&mock2, &webp_opts(), &mut last_hash).unwrap();
// hash(webp) == last_hash → skipped
assert_eq!(mock2.distributed.lock().unwrap().len(), 0);
}
#[test]
fn non_image_mime_not_processed() {
use k_shrink::process_once;
let mock = MockClipboard::new(b"hello world".to_vec(), "text/plain");
let mut last_hash = None;
process_once(&mock, &webp_opts(), &mut last_hash).unwrap();
assert_eq!(mock.distributed.lock().unwrap().len(), 0);
}

11
crates/config/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "config"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"
dirs = "5"
thiserror = { workspace = true }
lib = { path = "../lib" }

228
crates/config/src/lib.rs Normal file
View File

@@ -0,0 +1,228 @@
use serde::Deserialize;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Failed to parse config: {0}")]
ParseError(String),
#[error("Quality must be 0100, got {0}")]
InvalidQuality(u8),
#[error("poll_ms must be at least 100, got {0}")]
InvalidPollMs(u64),
}
/// Output format for compressed images.
///
/// ## Lossy (quality applies)
/// - `jpeg` — Best for photos. `quality` controls compression (80 is a good default).
/// - `avif` — Modern format, better than JPEG at same quality. `quality` applies.
///
/// ## Lossless (quality ignored)
/// - `webp` — Best for screenshots/UI. Usually smaller than PNG.
/// - `png` — Universal lossless. No size reduction vs source PNG.
/// - `qoi` — Fast lossless. Usually larger than PNG but faster to encode/decode.
/// - `farbfeld`— Simple 16-bit lossless. Rarely needed.
/// - `tiff` — Lossless TIFF. Large files, used in professional workflows.
/// - `gif` — Lossless but only 256 colors. Avoid for photos.
/// - `hdr` — Radiance HDR floating-point format.
/// - `openexr` — OpenEXR high dynamic range.
///
/// ## Uncompressed (will be larger than source PNG)
/// - `bmp`, `tga`, `pnm`, `ico`
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
Jpeg,
Avif,
Webp,
Png,
Qoi,
Farbfeld,
Tiff,
Gif,
Hdr,
#[serde(rename = "openexr")]
OpenExr,
Bmp,
Tga,
Pnm,
Ico,
}
impl From<OutputFormat> for lib::OutputFormat {
fn from(f: OutputFormat) -> Self {
match f {
OutputFormat::Jpeg => lib::OutputFormat::Jpeg,
OutputFormat::Avif => lib::OutputFormat::Avif,
OutputFormat::Webp => lib::OutputFormat::Webp,
OutputFormat::Png => lib::OutputFormat::Png,
OutputFormat::Qoi => lib::OutputFormat::Qoi,
OutputFormat::Farbfeld => lib::OutputFormat::Farbfeld,
OutputFormat::Tiff => lib::OutputFormat::Tiff,
OutputFormat::Gif => lib::OutputFormat::Gif,
OutputFormat::Hdr => lib::OutputFormat::Hdr,
OutputFormat::OpenExr => lib::OutputFormat::OpenExr,
OutputFormat::Bmp => lib::OutputFormat::Bmp,
OutputFormat::Tga => lib::OutputFormat::Tga,
OutputFormat::Pnm => lib::OutputFormat::Pnm,
OutputFormat::Ico => lib::OutputFormat::Ico,
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct GeneralConfig {
/// Output format. See [`OutputFormat`] for details.
/// Default: `webp`
#[serde(default = "default_format")]
pub format: OutputFormat,
/// Compression quality 0100. Only used when `format = "jpeg"`.
/// Ignored for `webp` (always lossless) and `png` (always lossless).
/// Default: `80`
#[serde(default = "default_quality")]
pub quality: u8,
/// How often to check the clipboard, in milliseconds.
/// Lower values are more responsive but use more CPU.
/// Minimum: 100. Default: `500`
#[serde(default = "default_poll_ms")]
pub poll_ms: u64,
}
fn default_format() -> OutputFormat {
OutputFormat::Webp
}
fn default_quality() -> u8 {
80
}
fn default_poll_ms() -> u64 {
500
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
format: default_format(),
quality: default_quality(),
poll_ms: default_poll_ms(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
}
}
}
fn validate(config: Config) -> Result<Config, ConfigError> {
if config.general.quality > 100 {
return Err(ConfigError::InvalidQuality(config.general.quality));
}
if config.general.poll_ms < 100 {
return Err(ConfigError::InvalidPollMs(config.general.poll_ms));
}
Ok(config)
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("k-shrink")
.join("config.toml")
}
pub fn load_config() -> Result<Config, ConfigError> {
let path = config_path();
if !path.exists() {
return Ok(Config::default());
}
let text = std::fs::read_to_string(&path)
.map_err(|e| ConfigError::ParseError(e.to_string()))?;
let config: Config =
toml::from_str(&text).map_err(|e| ConfigError::ParseError(e.to_string()))?;
validate(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_format_webp_quality_80() {
let c = Config::default();
assert_eq!(c.general.format, OutputFormat::Webp);
assert_eq!(c.general.quality, 80);
assert_eq!(c.general.poll_ms, 500);
}
#[test]
fn parse_valid_toml() {
let toml = r#"
[general]
format = "jpeg"
quality = 75
poll_ms = 200
"#;
let c: Config = toml::from_str(toml).unwrap();
assert_eq!(c.general.format, OutputFormat::Jpeg);
assert_eq!(c.general.quality, 75);
assert_eq!(c.general.poll_ms, 200);
}
#[test]
fn missing_file_returns_default() {
let path = PathBuf::from("/tmp/definitely_does_not_exist_k_shrink.toml");
let result = if !path.exists() {
Ok(Config::default())
} else {
load_config()
};
assert!(result.is_ok());
assert_eq!(result.unwrap().general.quality, 80);
}
#[test]
fn invalid_toml_returns_error() {
let bad = "not valid [ toml {{";
let result: Result<Config, _> =
toml::from_str(bad).map_err(|e| ConfigError::ParseError(e.to_string()));
assert!(result.is_err());
}
#[test]
fn invalid_quality_returns_error() {
let c = Config {
general: GeneralConfig {
format: OutputFormat::Webp,
quality: 200,
poll_ms: 500,
},
};
assert!(matches!(validate(c), Err(ConfigError::InvalidQuality(200))));
}
#[test]
fn poll_ms_too_low_returns_error() {
let c = Config {
general: GeneralConfig {
format: OutputFormat::Webp,
quality: 80,
poll_ms: 50,
},
};
assert!(matches!(validate(c), Err(ConfigError::InvalidPollMs(50))));
}
}

10
crates/lib/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "lib"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = { workspace = true }
tracing = { workspace = true }
image = { version = "0.25.10", features = ["webp"] }
sha2 = "0.10"

21
crates/lib/src/errors.rs Normal file
View File

@@ -0,0 +1,21 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ClipboardError {
#[error("Clipboard is empty")]
Empty,
#[error("Invalid clipboard type: {0}")]
InvalidType(String),
#[error("Backend error: {0}")]
BackendError(String),
}
#[derive(Error, Debug)]
pub enum ShrinkError {
#[error("Unsupported format: {0}")]
UnsupportedFormat(String),
#[error("Encoding failed: {0}")]
EncodingFailed(String),
#[error("Decoding failed: {0}")]
DecodingFailed(String),
}

29
crates/lib/src/hash.rs Normal file
View File

@@ -0,0 +1,29 @@
use sha2::{Digest, Sha256};
pub fn image_hash(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_input_same_hash() {
let data = b"some image bytes";
assert_eq!(image_hash(data), image_hash(data));
}
#[test]
fn different_input_different_hash() {
assert_ne!(image_hash(b"abc"), image_hash(b"xyz"));
}
#[test]
fn empty_input_no_panic() {
let h = image_hash(b"");
assert_eq!(h.len(), 32);
}
}

36
crates/lib/src/lib.rs Normal file
View File

@@ -0,0 +1,36 @@
pub mod errors;
pub mod hash;
pub mod shrink;
pub use errors::{ClipboardError, ShrinkError};
pub use hash::image_hash;
pub use shrink::{shrink, ShrinkOptions, ShrinkResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
// Compressed / lossy
Webp,
Jpeg,
Avif,
// Compressed / lossless
Png,
Qoi,
Farbfeld,
// Uncompressed
Bmp,
Tga,
Tiff,
Pnm,
Ico,
Gif,
// HDR / floating-point
Hdr,
OpenExr,
}
pub trait ClipboardProvider {
fn capture(&self) -> Result<(Vec<u8>, String), ClipboardError>;
/// Distribute one or more (data, mime_type) pairs to the clipboard.
/// Multiple pairs let receivers pick the format they support.
fn distribute(&self, items: &[(&[u8], &str)]) -> Result<(), ClipboardError>;
}

168
crates/lib/src/shrink.rs Normal file
View File

@@ -0,0 +1,168 @@
use crate::{OutputFormat, ShrinkError};
use std::io::Cursor;
pub struct ShrinkOptions {
pub quality: u8,
pub target_format: OutputFormat,
}
#[derive(Debug)]
pub struct ShrinkResult {
pub data: Vec<u8>,
pub mime_type: String,
}
/// Decode arbitrary image bytes and re-encode as PNG.
/// Use this to produce a universally-pasteable fallback alongside a compressed format.
pub fn to_png(data: &[u8]) -> Result<Vec<u8>, ShrinkError> {
let img = image::load_from_memory(data)
.map_err(|e| ShrinkError::DecodingFailed(e.to_string()))?;
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
Ok(buf)
}
pub fn shrink(data: &[u8], opts: &ShrinkOptions) -> Result<ShrinkResult, ShrinkError> {
let img = image::load_from_memory(data)
.map_err(|e| ShrinkError::DecodingFailed(e.to_string()))?;
let mut output = Vec::new();
macro_rules! write_to {
($fmt:expr, $mime:expr) => {{
img.write_to(&mut Cursor::new(&mut output), $fmt)
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
$mime
}};
}
let mime_type = match opts.target_format {
// Lossy — quality knob applies
OutputFormat::Jpeg => {
img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
opts.quality,
))
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
"image/jpeg"
}
OutputFormat::Avif => {
img.write_with_encoder(image::codecs::avif::AvifEncoder::new_with_speed_quality(
&mut output,
6,
opts.quality,
))
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
"image/avif"
}
// Lossless — quality ignored
OutputFormat::Webp => write_to!(image::ImageFormat::WebP, "image/webp"),
OutputFormat::Png => write_to!(image::ImageFormat::Png, "image/png"),
OutputFormat::Qoi => write_to!(image::ImageFormat::Qoi, "image/x-qoi"),
OutputFormat::Farbfeld=> write_to!(image::ImageFormat::Farbfeld, "image/x-farbfeld"),
OutputFormat::Tiff => write_to!(image::ImageFormat::Tiff, "image/tiff"),
OutputFormat::Gif => write_to!(image::ImageFormat::Gif, "image/gif"),
OutputFormat::Hdr => write_to!(image::ImageFormat::Hdr, "image/vnd.radiance"),
OutputFormat::OpenExr => write_to!(image::ImageFormat::OpenExr, "image/x-exr"),
// Uncompressed (larger than source, but supported)
OutputFormat::Bmp => write_to!(image::ImageFormat::Bmp, "image/bmp"),
OutputFormat::Tga => write_to!(image::ImageFormat::Tga, "image/x-tga"),
OutputFormat::Pnm => write_to!(image::ImageFormat::Pnm, "image/x-portable-anymap"),
OutputFormat::Ico => write_to!(image::ImageFormat::Ico, "image/x-icon"),
};
Ok(ShrinkResult {
data: output,
mime_type: mime_type.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_png() -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(8, 8);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
fn make_jpeg() -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(8, 8);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Jpeg)
.unwrap();
buf
}
#[test]
fn png_to_webp() {
let result = shrink(
&make_png(),
&ShrinkOptions {
quality: 80,
target_format: OutputFormat::Webp,
},
)
.unwrap();
assert_eq!(result.mime_type, "image/webp");
assert!(!result.data.is_empty());
}
#[test]
fn jpeg_quality_50_produces_output() {
let jpeg = make_jpeg();
let result = shrink(
&jpeg,
&ShrinkOptions {
quality: 50,
target_format: OutputFormat::Jpeg,
},
)
.unwrap();
assert_eq!(result.mime_type, "image/jpeg");
assert!(!result.data.is_empty());
}
#[test]
fn random_bytes_decoding_failed() {
let err = shrink(
b"not an image at all",
&ShrinkOptions {
quality: 80,
target_format: OutputFormat::Webp,
},
)
.unwrap_err();
assert!(matches!(err, ShrinkError::DecodingFailed(_)));
}
#[test]
fn quality_zero_produces_valid_output() {
let result = shrink(
&make_jpeg(),
&ShrinkOptions {
quality: 0,
target_format: OutputFormat::Jpeg,
},
)
.unwrap();
assert!(!result.data.is_empty());
}
#[test]
fn quality_max_produces_valid_output() {
let result = shrink(
&make_jpeg(),
&ShrinkOptions {
quality: 100,
target_format: OutputFormat::Jpeg,
},
)
.unwrap();
assert!(!result.data.is_empty());
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "platform"
version = "0.1.0"
edition = "2024"
[features]
default = ["wayland"]
wayland = ["dep:wl-clipboard-rs"]
[dependencies]
thiserror = { workspace = true }
tracing = { workspace = true }
lib = { path = "../lib" }
wl-clipboard-rs = { version = "0.9.3", optional = true }

View File

@@ -0,0 +1,3 @@
pub mod wayland;
pub use wayland::WaylandBackend;

View File

@@ -0,0 +1,216 @@
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");
}
}