cosmic_files/
clipboard.rs

1// Copyright 2024 System76 <info@system76.com>
2// SPDX-License-Identifier: GPL-3.0-only
3
4use cosmic::iced::clipboard::mime::{AllowedMimeTypes, AsMimeTypes};
5use std::borrow::Cow;
6use std::error::Error;
7use std::path::{Path, PathBuf};
8use std::str;
9use url::Url;
10
11#[derive(Clone, Copy, Debug)]
12pub enum ClipboardKind {
13    Copy,
14    Cut { is_dnd: bool },
15}
16
17#[derive(Clone, Debug)]
18pub struct ClipboardCopy {
19    pub available: Cow<'static, [String]>,
20    pub text_plain: Cow<'static, [u8]>,
21    pub text_uri_list: Cow<'static, [u8]>,
22    pub x_special_gnome_copied_files: Cow<'static, [u8]>,
23}
24
25impl ClipboardCopy {
26    pub fn new<P: AsRef<Path>>(kind: ClipboardKind, paths: impl IntoIterator<Item = P>) -> Self {
27        let available = vec![
28            "text/plain".to_string(),
29            "text/plain;charset=utf-8".to_string(),
30            "UTF8_STRING".to_string(),
31            "text/uri-list".to_string(),
32            "x-special/gnome-copied-files".to_string(),
33        ];
34        let mut text_plain = String::new();
35        let mut text_uri_list = String::new();
36        let mut x_special_gnome_copied_files = match kind {
37            ClipboardKind::Copy => "copy",
38            ClipboardKind::Cut { .. } => "cut",
39        }
40        .to_string();
41        //TODO: do we have to use \r\n?
42        let cr_nl = "\r\n";
43        for path in paths {
44            let path = path.as_ref();
45
46            match path.to_str() {
47                Some(path_str) => {
48                    if !text_plain.is_empty() {
49                        text_plain.push_str(cr_nl);
50                    }
51                    //TODO: what if the path contains CR or NL?
52                    text_plain.push_str(path_str);
53                }
54                None => {
55                    //TODO: allow non-UTF-8?
56                    log::warn!(
57                        "{} is not valid UTF-8, not adding to text/plain clipboard",
58                        path.display()
59                    );
60                }
61            }
62
63            match Url::from_file_path(path) {
64                Ok(url) => {
65                    let url_str = url.as_ref();
66
67                    text_uri_list.push_str(url_str);
68                    text_uri_list.push_str(cr_nl);
69
70                    x_special_gnome_copied_files.push('\n');
71                    x_special_gnome_copied_files.push_str(url_str);
72                }
73                Err(()) => {
74                    log::warn!(
75                        "{} cannot be turned into a URL, not adding to text/uri-list clipboard",
76                        path.display()
77                    );
78                }
79            }
80        }
81        Self {
82            available: Cow::from(available),
83            text_plain: Cow::from(text_plain.into_bytes()),
84            text_uri_list: Cow::from(text_uri_list.into_bytes()),
85            x_special_gnome_copied_files: Cow::from(x_special_gnome_copied_files.into_bytes()),
86        }
87    }
88}
89
90impl AsMimeTypes for ClipboardCopy {
91    fn available(&self) -> Cow<'static, [String]> {
92        self.available.clone()
93    }
94
95    fn as_bytes(&self, mime_type: &str) -> Option<Cow<'static, [u8]>> {
96        match mime_type {
97            "text/plain" | "text/plain;charset=utf-8" | "UTF8_STRING" => {
98                Some(self.text_plain.clone())
99            }
100            "text/uri-list" => Some(self.text_uri_list.clone()),
101            "x-special/gnome-copied-files" => Some(self.x_special_gnome_copied_files.clone()),
102            _ => None,
103        }
104    }
105}
106
107#[derive(Clone, Debug)]
108pub struct ClipboardPaste {
109    pub kind: ClipboardKind,
110    pub paths: Vec<PathBuf>,
111}
112
113impl AllowedMimeTypes for ClipboardPaste {
114    fn allowed() -> Cow<'static, [String]> {
115        Cow::from(vec![
116            "x-special/gnome-copied-files".to_string(),
117            "text/uri-list".to_string(),
118        ])
119    }
120}
121
122impl TryFrom<(Vec<u8>, String)> for ClipboardPaste {
123    type Error = Box<dyn Error>;
124    fn try_from(value: (Vec<u8>, String)) -> Result<Self, Self::Error> {
125        let (data, mime) = value;
126        // Assume the kind is Copy if not provided by the mime type
127        let mut kind = ClipboardKind::Copy;
128        let mut paths = Vec::new();
129
130        match mime.as_str() {
131            "text/uri-list" => {
132                let text = str::from_utf8(&data)?;
133                let _lines = text.lines();
134
135                for line in text.lines() {
136                    let url = Url::parse(line)?;
137                    match url.to_file_path() {
138                        Ok(path) => paths.push(path),
139                        Err(()) => Err(format!("invalid file URL {url:?}"))?,
140                    }
141                }
142            }
143            "x-special/gnome-copied-files" => {
144                let text = str::from_utf8(&data)?;
145                for (i, line) in text.lines().enumerate() {
146                    if i == 0 {
147                        kind = match line {
148                            "copy" => ClipboardKind::Copy,
149                            "cut" => ClipboardKind::Cut { is_dnd: false },
150                            _ => Err(format!("unsupported clipboard operation {line:?}"))?,
151                        };
152                    } else {
153                        let url = Url::parse(line)?;
154                        match url.to_file_path() {
155                            Ok(path) => paths.push(path),
156                            Err(()) => Err(format!("invalid file URL {url:?}"))?,
157                        }
158                    }
159                }
160            }
161            _ => Err(format!("unsupported mime type {mime:?}"))?,
162        }
163        Ok(Self { kind, paths })
164    }
165}
166
167/// Image data from clipboard for pasting as a new file.
168#[derive(Clone, Debug)]
169pub struct ClipboardPasteImage {
170    pub data: Vec<u8>,
171    pub mime_type: String,
172}
173
174impl AllowedMimeTypes for ClipboardPasteImage {
175    fn allowed() -> Cow<'static, [String]> {
176        Cow::from(vec![
177            "image/png".to_string(),
178            "image/jpeg".to_string(),
179            "image/gif".to_string(),
180            "image/bmp".to_string(),
181            "image/webp".to_string(),
182            "image/tiff".to_string(),
183            "image/x-tiff".to_string(),
184            "image/svg+xml".to_string(),
185            "image/x-icon".to_string(),
186            "image/vnd.microsoft.icon".to_string(),
187            "image/x-bmp".to_string(),
188            "image/x-ms-bmp".to_string(),
189            "image/pjpeg".to_string(),
190            "image/x-png".to_string(),
191            "image/avif".to_string(),
192            "image/heic".to_string(),
193            "image/heif".to_string(),
194            "image/jxl".to_string(),
195        ])
196    }
197}
198
199impl TryFrom<(Vec<u8>, String)> for ClipboardPasteImage {
200    type Error = Box<dyn Error>;
201    fn try_from(value: (Vec<u8>, String)) -> Result<Self, Self::Error> {
202        let (data, mime) = value;
203        if data.is_empty() {
204            return Err("Empty image data".into());
205        }
206        Ok(Self {
207            data,
208            mime_type: mime,
209        })
210    }
211}
212
213impl ClipboardPasteImage {
214    /// Get the file extension for the image based on MIME type.
215    /// Returns None if the MIME type is not recognized.
216    pub fn extension(&self) -> Option<&'static str> {
217        match self.mime_type.as_str() {
218            "image/png" | "image/x-png" => Some("png"),
219            "image/jpeg" | "image/pjpeg" => Some("jpg"),
220            "image/gif" => Some("gif"),
221            "image/bmp" | "image/x-bmp" | "image/x-ms-bmp" => Some("bmp"),
222            "image/webp" => Some("webp"),
223            "image/tiff" | "image/x-tiff" => Some("tiff"),
224            "image/svg+xml" => Some("svg"),
225            "image/x-icon" | "image/vnd.microsoft.icon" => Some("ico"),
226            "image/avif" => Some("avif"),
227            "image/heic" => Some("heic"),
228            "image/heif" => Some("heif"),
229            "image/jxl" => Some("jxl"),
230            _ => None,
231        }
232    }
233}
234
235/// Video data from clipboard for pasting as a new file.
236#[derive(Clone, Debug)]
237pub struct ClipboardPasteVideo {
238    pub data: Vec<u8>,
239    pub mime_type: String,
240}
241
242impl AllowedMimeTypes for ClipboardPasteVideo {
243    fn allowed() -> Cow<'static, [String]> {
244        Cow::from(vec![
245            "video/mp4".to_string(),
246            "video/webm".to_string(),
247            "video/ogg".to_string(),
248            "video/mpeg".to_string(),
249            "video/quicktime".to_string(),
250            "video/x-msvideo".to_string(),
251            "video/x-matroska".to_string(),
252            "video/x-flv".to_string(),
253            "video/3gpp".to_string(),
254            "video/3gpp2".to_string(),
255            "video/x-ms-wmv".to_string(),
256            "video/avi".to_string(),
257        ])
258    }
259}
260
261impl TryFrom<(Vec<u8>, String)> for ClipboardPasteVideo {
262    type Error = Box<dyn Error>;
263    fn try_from(value: (Vec<u8>, String)) -> Result<Self, Self::Error> {
264        let (data, mime) = value;
265        if data.is_empty() {
266            return Err("Empty video data".into());
267        }
268        Ok(Self {
269            data,
270            mime_type: mime,
271        })
272    }
273}
274
275impl ClipboardPasteVideo {
276    /// Get the file extension for the video based on MIME type.
277    /// Returns None if the MIME type is not recognized.
278    pub fn extension(&self) -> Option<&'static str> {
279        match self.mime_type.as_str() {
280            "video/mp4" => Some("mp4"),
281            "video/webm" => Some("webm"),
282            "video/ogg" => Some("ogv"),
283            "video/mpeg" => Some("mpeg"),
284            "video/quicktime" => Some("mov"),
285            "video/x-msvideo" | "video/avi" => Some("avi"),
286            "video/x-matroska" => Some("mkv"),
287            "video/x-flv" => Some("flv"),
288            "video/3gpp" => Some("3gp"),
289            "video/3gpp2" => Some("3g2"),
290            "video/x-ms-wmv" => Some("wmv"),
291            _ => None,
292        }
293    }
294}
295
296/// Text data from clipboard for pasting as a new text file.
297#[derive(Clone, Debug)]
298pub struct ClipboardPasteText {
299    pub data: String,
300}
301
302impl AllowedMimeTypes for ClipboardPasteText {
303    fn allowed() -> Cow<'static, [String]> {
304        Cow::from(vec![
305            "text/plain".to_string(),
306            "text/plain;charset=utf-8".to_string(),
307            "UTF8_STRING".to_string(),
308            "STRING".to_string(),
309            "TEXT".to_string(),
310        ])
311    }
312}
313
314impl TryFrom<(Vec<u8>, String)> for ClipboardPasteText {
315    type Error = Box<dyn Error>;
316    fn try_from(value: (Vec<u8>, String)) -> Result<Self, Self::Error> {
317        let (data, _mime) = value;
318        if data.is_empty() {
319            return Err("Empty text data".into());
320        }
321        // Use lossy conversion to handle clipboard data that may contain
322        // invalid UTF-8 (e.g., Latin-1 encoded special characters from browsers)
323        let text = String::from_utf8_lossy(&data);
324        Ok(Self {
325            data: text.into_owned(),
326        })
327    }
328}
329
330/// Cached clipboard content for paste operations.
331/// This is needed because Wayland restricts clipboard access from popup windows.
332#[derive(Clone, Debug)]
333pub enum ClipboardCache {
334    Files(ClipboardPaste),
335    Image(ClipboardPasteImage),
336    Video(ClipboardPasteVideo),
337    Text(ClipboardPasteText),
338    Empty,
339}