mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
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>
228 lines
6.4 KiB
Rust
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());
|
|
}
|
|
}
|