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:
18
crates/bin/Cargo.toml
Normal file
18
crates/bin/Cargo.toml
Normal 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
47
crates/bin/src/lib.rs
Normal 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
35
crates/bin/src/main.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
100
crates/bin/tests/integration_test.rs
Normal file
100
crates/bin/tests/integration_test.rs
Normal 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
11
crates/config/Cargo.toml
Normal 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
228
crates/config/src/lib.rs
Normal 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 0–100, 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 0–100. 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
10
crates/lib/Cargo.toml
Normal 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
21
crates/lib/src/errors.rs
Normal 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
29
crates/lib/src/hash.rs
Normal 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
36
crates/lib/src/lib.rs
Normal 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
168
crates/lib/src/shrink.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
14
crates/platform/Cargo.toml
Normal file
14
crates/platform/Cargo.toml
Normal 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 }
|
||||
3
crates/platform/src/lib.rs
Normal file
3
crates/platform/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod wayland;
|
||||
|
||||
pub use wayland::WaylandBackend;
|
||||
216
crates/platform/src/wayland.rs
Normal file
216
crates/platform/src/wayland.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user