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);
}