cosmic_files/
thumbnail_cacher.rs

1use image::DynamicImage;
2use md5::{Digest, Md5};
3use rustc_hash::FxHashMap;
4use std::error::Error;
5use std::fs::{self, File};
6use std::io::{self, BufReader, BufWriter};
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9use std::path::{Path, PathBuf};
10use std::sync::LazyLock;
11use std::time::UNIX_EPOCH;
12use tempfile::NamedTempFile;
13use url::Url;
14
15/// Implements thumbnail caching based on the freedesktop.org Thumbnail Managing Standard.
16/// <https://specifications.freedesktop.org/thumbnail-spec/latest>/
17pub struct ThumbnailCacher {
18    file_path: PathBuf,
19    file_uri: String,
20    thumbnail_dir: PathBuf,
21    thumbnail_path: PathBuf,
22    thumbnail_size: ThumbnailSize,
23    thumbnail_fail_marker_path: PathBuf,
24}
25
26impl ThumbnailCacher {
27    pub fn new(file_path: &Path, thumbnail_size: ThumbnailSize) -> Result<Self, String> {
28        let file_uri = thumbnail_uri(file_path)
29            .map_err(|err| format!("failed to create URI for {}: {}", file_path.display(), err))?;
30        let cache_base_dir = THUMBNAIL_CACHE_BASE_DIR
31            .as_ref()
32            .ok_or("failed to get thumbnail cache directory".to_string())?;
33        let thumbnail_filename = thumbnail_cache_filename(&file_uri);
34        let thumbnail_dir = cache_base_dir.join(thumbnail_size.subdirectory_name());
35        if !thumbnail_dir.is_dir() {
36            log::warn!(
37                "{} is not a directory, creating one now",
38                thumbnail_dir.display()
39            );
40            let _: () = log::error!(
41                "{} failed to create directory, this error can be expected on first run",
42                thumbnail_dir.display()
43            );
44            fs::create_dir_all(&thumbnail_dir).unwrap_or(());
45        }
46        let thumbnail_path = thumbnail_dir.join(&thumbnail_filename);
47        let thumbnail_fail_marker_path = cache_base_dir
48            .join("fail")
49            .join(format!("cosmic-files-{}", env!("CARGO_PKG_VERSION")))
50            .join(&thumbnail_filename);
51
52        Ok(Self {
53            file_path: file_path.to_path_buf(),
54            file_uri,
55            thumbnail_dir,
56            thumbnail_path,
57            thumbnail_size,
58            thumbnail_fail_marker_path,
59        })
60    }
61
62    pub fn get_cached_thumbnail(&self) -> CachedThumbnail {
63        // If the file is already a thumbnail, just use it so we don't generate
64        // cached thumbnails of thumbnails.
65        if let (Some(cache_base_dir), Ok(metadata)) = (
66            THUMBNAIL_CACHE_BASE_DIR.as_ref(),
67            std::fs::metadata(&self.file_path),
68        ) && metadata.is_file()
69            && self.file_path.starts_with(cache_base_dir)
70        {
71            return CachedThumbnail::Valid((self.file_path.clone(), None));
72        }
73
74        // Use cached thumbnail if it is valid.
75        if self.is_thumbnail_valid(&self.thumbnail_path) {
76            return CachedThumbnail::Valid((
77                self.thumbnail_path.clone(),
78                Some(self.thumbnail_size),
79            ));
80        }
81
82        // Check if there is a fail marker from an earlier failure.
83        if self.is_thumbnail_valid(&self.thumbnail_fail_marker_path) {
84            return CachedThumbnail::Failed;
85        }
86
87        CachedThumbnail::RequiresUpdate(self.thumbnail_size)
88    }
89
90    pub fn thumbnail_dir(&self) -> &Path {
91        &self.thumbnail_dir
92    }
93
94    pub fn update_with_temp_file(&self, temp_file: NamedTempFile) -> Result<&Path, Box<dyn Error>> {
95        #[cfg(unix)]
96        fs::set_permissions(temp_file.path(), fs::Permissions::from_mode(0o600))?;
97        #[cfg(not(unix))]
98        {
99            let mut permissions = fs::metadata(temp_file.path())?.permissions();
100            #[allow(clippy::permissions_set_readonly_false)]
101            permissions.set_readonly(false);
102            fs::set_permissions(temp_file.path(), permissions)?;
103        }
104        self.update_thumbnail_text_metadata(temp_file.path())?;
105        fs::rename(temp_file.path(), &self.thumbnail_path)?;
106
107        Ok(&self.thumbnail_path)
108    }
109
110    pub fn update_with_image(&self, image: DynamicImage) -> Result<&Path, Box<dyn Error>> {
111        let temp_file = tempfile::Builder::new()
112            .prefix("cosmic-files-")
113            .tempfile_in(&self.thumbnail_dir)?;
114        {
115            let file = File::create(temp_file.path())?;
116            let image = image
117                .thumbnail(
118                    self.thumbnail_size.pixel_size(),
119                    self.thumbnail_size.pixel_size(),
120                )
121                .into_rgba8();
122            let writer = BufWriter::new(file);
123            let mut encoder = png::Encoder::new(writer, image.width(), image.height());
124            encoder.set_color(png::ColorType::Rgba);
125            encoder.set_depth(png::BitDepth::Eight);
126            encoder
127                .write_header()?
128                .write_image_data(&image.into_raw())?;
129        }
130
131        self.update_with_temp_file(temp_file)
132    }
133
134    pub fn create_fail_marker(&self) -> Result<(), Box<dyn Error>> {
135        if let Some(dir) = self.thumbnail_fail_marker_path.parent() {
136            fs::create_dir_all(dir)?;
137            #[cfg(unix)]
138            fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
139            #[cfg(windows)]
140            {
141                let mut perms = fs::metadata(dir)?.permissions();
142                #[allow(clippy::permissions_set_readonly_false)]
143                perms.set_readonly(false);
144                fs::set_permissions(dir, perms)?;
145            }
146        }
147
148        let file = File::create(&self.thumbnail_fail_marker_path)?;
149        let writer = BufWriter::new(file);
150        let mut encoder = png::Encoder::new(writer, 1, 1);
151        encoder.set_color(png::ColorType::Grayscale);
152        encoder.set_depth(png::BitDepth::One);
153        encoder.write_header()?.write_image_data(&[0])?;
154        self.update_thumbnail_text_metadata(&self.thumbnail_fail_marker_path)
155    }
156
157    fn update_thumbnail_text_metadata(&self, path: &Path) -> Result<(), Box<dyn Error>> {
158        let file = File::open(path)?;
159        let reader = BufReader::new(file);
160
161        let decoder = png::Decoder::new(reader);
162        let mut reader = decoder.read_info()?;
163        let (width, height, color_type, bit_depth, mut text_chunks) = {
164            let info = reader.info();
165            let text_chunks: FxHashMap<String, String> = info
166                .uncompressed_latin1_text
167                .iter()
168                .map(|chunk| (chunk.keyword.clone(), chunk.text.clone()))
169                .collect();
170            (
171                info.width,
172                info.height,
173                info.color_type,
174                info.bit_depth,
175                text_chunks,
176            )
177        };
178
179        let mut image_data = vec![
180            0;
181            reader
182                .output_buffer_size()
183                .ok_or("The required image buffer size is too large.")?
184        ];
185        reader.next_frame(&mut image_data)?;
186
187        let file = File::create(path)?;
188        let writer = BufWriter::new(file);
189
190        let mut encoder = png::Encoder::new(writer, width, height);
191        encoder.set_color(color_type);
192        encoder.set_depth(bit_depth);
193
194        text_chunks.insert("Software".to_string(), "COSMIC Files".to_string());
195        text_chunks.insert("Thumb::URI".to_string(), self.file_uri.clone());
196        let metadata = std::fs::metadata(&self.file_path)?;
197        let size = metadata.len();
198        text_chunks.insert("Thumb::Size".to_string(), size.to_string());
199        let mtime = metadata
200            .modified()?
201            .duration_since(UNIX_EPOCH)
202            .unwrap_or_default()
203            .as_secs();
204        text_chunks.insert("Thumb::MTime".to_string(), mtime.to_string());
205
206        for (keyword, text) in text_chunks {
207            encoder.add_text_chunk(keyword, text)?;
208        }
209
210        let mut writer = encoder.write_header()?;
211        writer.write_image_data(&image_data)?;
212
213        Ok(())
214    }
215
216    fn is_thumbnail_valid(&self, thumbnail_path: &Path) -> bool {
217        let thumbnail_file = match File::open(thumbnail_path) {
218            Ok(file) => file,
219            Err(_) => return false,
220        };
221        let decoder = png::Decoder::new(BufReader::new(thumbnail_file));
222        let reader = match decoder.read_info() {
223            Ok(reader) => reader,
224            Err(err) => {
225                log::warn!(
226                    "failed to decode {} as PNG: {}",
227                    thumbnail_path.display(),
228                    err
229                );
230                return false;
231            }
232        };
233
234        let texts = &reader.info().uncompressed_latin1_text;
235
236        // Thumb::URI is required and must match.
237        let thumb_uri = texts
238            .iter()
239            .find(|&text| text.keyword == "Thumb::URI")
240            .map(|t| &t.text);
241        if let Some(thumb_uri) = thumb_uri {
242            if *thumb_uri != self.file_uri {
243                return false;
244            }
245        } else {
246            return false;
247        }
248
249        let metadata = match std::fs::metadata(&self.file_path) {
250            Ok(m) => m,
251            Err(err) => {
252                log::warn!(
253                    "failed to get metatdata of {}: {}",
254                    self.file_path.display(),
255                    err
256                );
257                return false;
258            }
259        };
260
261        // Thumb::MTime is required and must match.
262        let thumb_mtime = texts
263            .iter()
264            .find(|&text| text.keyword == "Thumb::MTime")
265            .map(|t| &t.text);
266        if let Some(thumb_mtime) = thumb_mtime {
267            let modified = match metadata.modified() {
268                Ok(m) => m,
269                Err(err) => {
270                    log::warn!(
271                        "failed to get modified from metatdata of {}, {}",
272                        self.file_path.display(),
273                        err
274                    );
275                    return false;
276                }
277            };
278            let mtime = modified
279                .duration_since(UNIX_EPOCH)
280                .unwrap_or_default()
281                .as_secs()
282                .to_string();
283            if *thumb_mtime != mtime {
284                return false;
285            }
286        } else {
287            return false;
288        }
289
290        // Thumb::Size isn't required, but it should be verified if present.
291        let thumb_size = texts
292            .iter()
293            .find(|&text| text.keyword == "Thumb::Size")
294            .map(|t| &t.text);
295        if let Some(thumb_size) = thumb_size {
296            let size = metadata.len();
297            if *thumb_size != size.to_string() {
298                return false;
299            }
300        }
301
302        true
303    }
304}
305
306fn thumbnail_uri(path: &Path) -> io::Result<String> {
307    let absolute_path = fs::canonicalize(path)?;
308    let url = Url::from_file_path(&absolute_path).map_err(|()| {
309        io::Error::other(format!(
310            "failed to create URI for thumbnail_file: {}",
311            absolute_path.display()
312        ))
313    })?;
314    // Technically square brackets don't need to be percent encoded,
315    // and they aren't by the url crate, but the thumbnailer used by
316    // Gnome Files does. In order to share thumbnails and not get duplicates
317    // we should do the same.
318    let url = url.as_str().replace('[', "%5B").replace(']', "%5D");
319    Ok(url)
320}
321
322fn thumbnail_cache_filename(file_uri: &str) -> String {
323    let hash = Md5::digest(file_uri);
324    format!("{hash:x}.png")
325}
326
327#[derive(Copy, Clone, Debug, PartialEq, Eq)]
328#[repr(u32)]
329pub enum ThumbnailSize {
330    Normal = 128,
331    Large = 256,
332    XLarge = 512,
333    XXLarge = 1024,
334}
335
336impl ThumbnailSize {
337    pub fn from_pixel_size(pixel_size: u32) -> Self {
338        if pixel_size <= Self::Normal.pixel_size() {
339            Self::Normal
340        } else if pixel_size <= Self::Large.pixel_size() {
341            Self::Large
342        } else if pixel_size <= Self::XLarge.pixel_size() {
343            Self::XLarge
344        } else {
345            Self::XXLarge
346        }
347    }
348
349    pub const fn pixel_size(self) -> u32 {
350        self as u32
351    }
352
353    pub const fn subdirectory_name(self) -> &'static str {
354        match self {
355            Self::Normal => "normal",
356            Self::Large => "large",
357            Self::XLarge => "x-large",
358            Self::XXLarge => "xx-large",
359        }
360    }
361}
362
363pub enum CachedThumbnail {
364    /// The cached thumbnail is valid and should be used with size if known.
365    Valid((PathBuf, Option<ThumbnailSize>)),
366    /// The cached thumbnail doesn't exist or it's invalid and
367    /// needs to be recreated with the pixel size.
368    RequiresUpdate(ThumbnailSize),
369    // The cached thumbnail is in a failed state.
370    // This means it failed to create by cosmic-files in the past
371    // and shouldn't be tried again.
372    Failed,
373}
374
375static THUMBNAIL_CACHE_BASE_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
376    if let Some(cache_dir) = dirs::cache_dir() {
377        return Some(cache_dir.join("thumbnails"));
378    }
379
380    log::warn!("failed to get thumbnail cache directory, thumbnails will not be cached");
381
382    None
383});