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
15pub 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 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 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 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 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 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 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 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 Valid((PathBuf, Option<ThumbnailSize>)),
366 RequiresUpdate(ThumbnailSize),
369 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});