Files
dsh-easy-desktop/crates/dsh-core/src/clipboard.rs
Tommy f8a3c2dc6f Add README, plugin citations, and multi-platform release CI.
Ship screenshots and third-party notices for the desktop shell, and
package Windows, macOS, deb, rpm, and Flatpak from GitHub Releases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:02:13 +08:00

228 lines
6.4 KiB
Rust

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<String> {
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<String, ()> {
// 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<I, S>(paths: I) -> Vec<ClipboardFile>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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<u8>) -> Option<ClipboardFile> {
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());
}
}