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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
1788
Cargo.lock
generated
Normal file
1788
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["crates/bin", "crates/config", "crates/lib", "crates/platform"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
tracing = "0.1.44"
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# K-Shrink
|
||||||
|
|
||||||
|
Utility tool for shrinking/compressing images from the clipboard seamlessly. Built in Rust.
|
||||||
|
|
||||||
|
# Why?
|
||||||
|
|
||||||
|
Recently, I found myself sharing tons of memes and screenshots with friends, and it has come to my attention that many of those images are HUGE and it makes my blood boil. So, I decided to build a tool that will automatically take the image I have copied, whether from the web/facebook/discord/whatever or local files, or ftp, and shrink using user provided configuration (which format, quality, etc) and then put the shrunk image back to the clipboard, so that when I paste it, it's already shrunk and ready to be shared.
|
||||||
|
|
||||||
|
# How does it work?
|
||||||
|
|
||||||
|
It is basically a daemon that runs in the background and listens for clipboard changes. When it detects a change, it checks if the clipboard contains an image. If it does, it processes the image according to the user configuration and then puts the shrunk image back to the clipboard. We need some sort of lock or signature to prevent infinite loops.
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
The configuration is stored in a file called `config.toml` in ~/.config/k-shrink/ directory. The configuration file is in TOML format and contains the following fields:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[general]
|
||||||
|
# The format to shrink the image to. Supported formats are: png, jpeg, webp
|
||||||
|
format = "webp"
|
||||||
|
# The quality of the shrunk image. Supported values are: 0-100
|
||||||
|
quality = 80
|
||||||
|
```
|
||||||
|
|
||||||
|
# Portability
|
||||||
|
|
||||||
|
I personally use Arch Linux with Wayland, which is why for now it supports only wayland. But because I value portability and future-proofing, I have designed architecture in a way that it should be easy to add support for x11, windows, macos, what-have-you in the future.
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
That being said, let's talk about crates and general architecture.
|
||||||
|
|
||||||
|
All crates live in the `crates` directory. There are three main crates: `lib`, `platform`, and `bin`.
|
||||||
|
|
||||||
|
- `lib` crate is pure business logic, it doesn't care or know about the platform, it just provides the functionality to shrink images. It has no dependencies on platform-specific libraries, and it is tested with unit tests and integration tests. It is also the most stable crate, and it should not be changed frequently.
|
||||||
|
- `platform` crate is the crate that provides the platform-specific implementation of the clipboard provider. It depends on the `lib` crate, and it is tested with integration tests. It is also the most volatile crate, and it may change frequently as we add support for more platforms.
|
||||||
|
- `bin` crate is the crate that provides the binary executable. It depends on both `lib` and `platform` crates, and it is tested with integration tests. It is just an orchestrator that ties everything together, and it should not contain any business logic.
|
||||||
|
|
||||||
|
Configuration should be handled by new crate called `config`, which will take care of toml, reading and writing config. Also in `platform` crate we should provide a way for `config` crate to properly work across platforms. (this crate could be debated whether it should be separate or not, I am not convinced that separating it is the best idea, but we will see)
|
||||||
|
|
||||||
|
# Critical things I care about
|
||||||
|
|
||||||
|
- Performance: Entire point of this tool is to be invisible to the user, which means it's gotta be fast. it can't get in the way because then it defeats the purpose.
|
||||||
|
- Reliability: It should work consistently and not crash or cause any issues with the clipboard. It should also handle edge cases gracefully, such as unsupported formats, large images, etc.
|
||||||
|
- Portability: It should work on multiple platforms, and it should be easy to add support for new platforms in the future.
|
||||||
|
- User experience: It should be easy to use and configure, and it should provide feedback to the user when something goes wrong (e.g. unsupported format, etc). It should also have a good default configuration that works well for most users.
|
||||||
|
- Feature flags: We should use feature flags to enable or disable certain features, such as support for specific platforms, or support for specific image formats. This will allow us to keep the binary size small and avoid unnecessary dependencies for users who don't need those features.
|
||||||
|
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! If you want to contribute, please fork the repository and create a pull request. Please make sure to follow the coding style and conventions used in the project. Also, please make sure to write tests for your changes.
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
MIT. I seriously don't care what you do with this code or binaries, do whatever you want with it. If somehow it breaks something, it's your problem, not mine. #works-on-my-machine
|
||||||
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