use std::path::Path; use base64::Engine; use serde::Serialize; pub const IMAGE_MIMES: &[&str] = &[ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", "image/bmp", "image/tiff", ]; pub const INGEST_JS: &str = include_str!("../../../ui/inject/ingest.js"); #[derive(Debug, Clone, Serialize)] pub struct ClipboardFile { pub name: String, #[serde(rename = "type")] pub mime: String, pub b64: String, } pub fn is_image_mime(mime: &str) -> bool { let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase(); let mime = if mime == "image/jpg" { "image/jpeg" } else { mime.as_str() }; IMAGE_MIMES.contains(&mime) || mime == "image/jpeg" } pub fn filename_for_mime(mime: &str, index: usize) -> String { let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase(); let ext = match mime.as_str() { "image/png" => "png", "image/jpeg" | "image/jpg" => "jpg", "image/webp" => "webp", "image/gif" => "gif", "image/bmp" => "bmp", "image/tiff" => "tiff", _ => "png", }; if index == 0 { format!("clipboard.{ext}") } else { format!("clipboard-{}.{ext}", index + 1) } } pub fn build_ingest_call(files: &[ClipboardFile]) -> String { let payload = serde_json::to_string(files).unwrap_or_else(|_| "[]".into()); format!("window.__dshDesktopPasteFiles && window.__dshDesktopPasteFiles({payload});") } pub fn parse_uri_list(payload: &str) -> Vec { let mut paths = Vec::new(); for raw in payload.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some(rest) = line.strip_prefix("file:") { if let Ok(url) = url_parse_file(rest) { paths.push(url); } } else if line.starts_with('/') { paths.push(line.to_string()); } } paths } fn url_parse_file(rest: &str) -> Result { // file:///home/me/Pictures/shot.png or file://localhost/home/... let uri = if rest.starts_with("//") { format!("file:{rest}") } else { format!("file:{rest}") }; let decoded = percent_decode(&uri); if let Some(idx) = decoded.find("://") { let after = &decoded[idx + 3..]; let path = after .strip_prefix("localhost") .unwrap_or(after); return Ok(path.to_string()); } if let Some(path) = decoded.strip_prefix("file:") { return Ok(path.to_string()); } Ok(decoded) } fn percent_decode(input: &str) -> String { let bytes = input.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(v) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) { out.push(v); i += 3; continue; } } out.push(bytes[i]); i += 1; } String::from_utf8_lossy(&out).into_owned() } pub fn files_from_paths(paths: I) -> Vec where I: IntoIterator, S: AsRef, { let mut out = Vec::new(); for (index, raw) in paths.into_iter().enumerate() { let path = Path::new(raw.as_ref()); if !path.is_file() { continue; } let suffix = path .extension() .and_then(|s| s.to_str()) .unwrap_or("") .to_ascii_lowercase(); let mime = match suffix.as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "webp" => "image/webp", "gif" => "image/gif", "bmp" => "image/bmp", "tif" | "tiff" => "image/tiff", _ => continue, }; let Ok(data) = std::fs::read(path) else { continue; }; if data.is_empty() { continue; } let name = path .file_name() .and_then(|s| s.to_str()) .map(|s| s.to_string()) .unwrap_or_else(|| filename_for_mime(mime, index)); out.push(ClipboardFile { name, mime: mime.to_string(), b64: base64::engine::general_purpose::STANDARD.encode(data), }); } out } pub fn file_from_bytes(name: String, mime: String, data: Vec) -> Option { if data.is_empty() || !is_image_mime(&mime) { return None; } Some(ClipboardFile { name, mime: mime.split(';').next().unwrap_or(&mime).trim().to_string(), b64: base64::engine::general_purpose::STANDARD.encode(data), }) } #[cfg(test)] mod tests { use super::*; #[test] fn mime_and_filename() { assert!(is_image_mime("image/png")); assert!(is_image_mime("image/jpeg; charset=binary")); assert!(is_image_mime("image/jpg")); assert!(!is_image_mime("text/plain")); assert_eq!(filename_for_mime("image/png", 0), "clipboard.png"); assert_eq!(filename_for_mime("image/jpeg", 1), "clipboard-2.jpg"); } #[test] fn ingest_js_defines_helper() { assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles")); assert!(INGEST_JS.contains("DragEvent")); assert!(INGEST_JS.contains("new File")); assert!(INGEST_JS.contains("text/uri-list")); } #[test] fn build_ingest_call_embeds_payload() { let files = [ClipboardFile { name: "shot.png".into(), mime: "image/png".into(), b64: base64::engine::general_purpose::STANDARD.encode(b"\x89PNG"), }]; let script = build_ingest_call(&files); assert!(script.contains("__dshDesktopPasteFiles")); assert!(script.contains("shot.png")); assert!(script.contains("image/png")); assert!(!script.contains(", \"hi\"")); } #[test] fn parse_file_uri() { let payload = "# comment\nfile:///home/me/Pictures/shot.png\n"; assert_eq!( parse_uri_list(payload), vec!["/home/me/Pictures/shot.png".to_string()] ); } #[test] fn parse_plain_path() { assert_eq!(parse_uri_list("/tmp/a.jpg"), vec!["/tmp/a.jpg".to_string()]); } #[test] fn skips_non_images() { assert!(files_from_paths(["/no/such/file.txt"]).is_empty()); } }