From 489eb2a066ee7a882e9368994488ca58f0f76be1 Mon Sep 17 00:00:00 2001 From: Jason Rodney Hansen Date: Sat, 5 Jul 2025 09:34:03 -0600 Subject: [PATCH] Add caching for thumbnails based and freedesktop.org spec --- Cargo.lock | 12 ++ Cargo.toml | 2 + src/lib.rs | 1 + src/tab.rs | 179 ++++++++++++++++----- src/thumbnail_cacher.rs | 347 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 505 insertions(+), 36 deletions(-) create mode 100644 src/thumbnail_cacher.rs diff --git a/Cargo.lock b/Cargo.lock index 3edacc1..8fd6a64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1507,6 +1507,7 @@ dependencies = [ "libc", "libcosmic", "log", + "md-5", "mime_guess", "notify-debouncer-full", "notify-rust", @@ -1514,6 +1515,7 @@ dependencies = [ "open", "ordermap", "paste", + "png", "procfs", "recently-used-xbel", "regex", @@ -4570,6 +4572,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.5" diff --git a/Cargo.toml b/Cargo.toml index 051bbab..79e44e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ slotmap = "1.0.7" recently-used-xbel = "1.1.0" zip = "2.2.2" uzers = "0.12.1" +md-5 = "0.10.6" +png = "0.17.16" # Completion-based IO runtime to enable io_uring / IOCP file IO support. [dependencies.compio] diff --git a/src/lib.rs b/src/lib.rs index 556fbef..f9749f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ use tab::Location; use crate::config::State; pub mod tab; +mod thumbnail_cacher; mod thumbnailer; pub(crate) fn err_str(err: T) -> String { diff --git a/src/tab.rs b/src/tab.rs index da6a546..805ed0f 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -62,8 +62,9 @@ use std::{ sync::{atomic, Arc, LazyLock, Mutex, RwLock}, time::{Duration, Instant, SystemTime}, }; +use tempfile::NamedTempFile; use tokio::sync::mpsc; -use trash::{TrashItemMetadata, TrashItemSize}; +use trash::TrashItemSize; use walkdir::WalkDir; use crate::{ @@ -78,6 +79,7 @@ use crate::{ mounter::MOUNTERS, mouse_area, operation::Controller, + thumbnail_cacher::{CachedThumbnail, ThumbnailCacher, ThumbnailSize}, thumbnailer::thumbnailer, }; use uzers::{get_group_by_gid, get_user_by_uid}; @@ -1705,7 +1707,34 @@ impl Clone for ItemThumbnail { } impl ItemThumbnail { - pub fn new(path: &Path, metadata: ItemMetadata, mime: mime::Mime, thumbnail_size: u32) -> Self { + pub fn new( + path: &Path, + metadata: ItemMetadata, + mime: mime::Mime, + mut thumbnail_size: u32, + ) -> Self { + let thumbnail_cacher = + ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size)); + match thumbnail_cacher.as_ref() { + Ok(cache) => match cache.get_cached_thumbnail() { + CachedThumbnail::Valid((path, size)) => { + return ItemThumbnail::Image( + widget::image::Handle::from_path(path), + size.map(|s| (s.pixel_size(), s.pixel_size())), + ); + } + CachedThumbnail::Failed => { + return ItemThumbnail::NotImage; + } + CachedThumbnail::RequiresUpdate(size) => { + thumbnail_size = size.pixel_size(); + } + }, + Err(err) => { + log::warn!("failed to create ThumbnailCache for {:?}: {}", path, err); + } + } + let size = metadata.file_size().unwrap_or_default(); let check_size = |thumbnailer: &str, max_size| { if size <= max_size { @@ -1721,11 +1750,73 @@ impl ItemThumbnail { false } }; + + let mut tried_supported_file = false; + + // First try built-in image thumbnailer + if mime.type_() == mime::IMAGE && check_size("image", 64 * 1000 * 1000) { + tried_supported_file = true; + match image::ImageReader::open(path).and_then(|img| img.with_guessed_format()) { + Ok(reader) => match reader.decode() { + Ok(image) => { + if let Ok(cacher) = thumbnail_cacher.as_ref() { + match cacher.update_with_image(image) { + Ok(path) => { + return ItemThumbnail::Image( + widget::image::Handle::from_path(path), + None, + ); + } + Err(err) => { + log::warn!("failed to decode {:?}: {}", path, err); + } + } + } else { + // Fallback for when thumbnail cacher isn't available. + let thumbnail = + image.thumbnail(thumbnail_size, thumbnail_size).into_rgba8(); + return ItemThumbnail::Image( + widget::image::Handle::from_rgba( + thumbnail.width(), + thumbnail.height(), + thumbnail.into_raw(), + ), + Some((image.width(), image.height())), + ); + } + } + Err(err) => { + log::warn!("failed to decode {:?}: {}", path, err); + } + }, + Err(err) => { + log::warn!("failed to read {:?}: {}", path, err); + } + } + } + + // Try external thumbnailers. + let thumbnail_dir = thumbnail_cacher.as_ref().ok().map(|c| c.thumbnail_dir()); + if let Some((item_thumbnail, temp_file)) = + Self::generate_thumbnail_external(path, &mime, thumbnail_size, thumbnail_dir) + { + if let Ok(cache) = thumbnail_cacher { + if let Err(err) = cache.update_with_temp_file(temp_file) { + log::warn!("failed to update cache for {:?}: {}", path, err); + } + } + return item_thumbnail; + } + + tried_supported_file = tried_supported_file || !thumbnailer(&mime).is_empty(); + + // Try internal thumbnailers that don't get cached. //TODO: adjust limits for internal thumbnailers as desired if mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG && check_size("svg", 8 * 1000 * 1000) { + tried_supported_file = true; // Try built-in svg thumbnailer match fs::read(path) { Ok(data) => { @@ -1736,30 +1827,6 @@ impl ItemThumbnail { log::warn!("failed to read {:?}: {}", path, err); } } - } else if mime.type_() == mime::IMAGE && check_size("image", 64 * 1000 * 1000) { - // Try built-in image thumbnailer - match image::ImageReader::open(path).and_then(|img| img.with_guessed_format()) { - Ok(reader) => match reader.decode() { - Ok(image) => { - let thumbnail = - image.thumbnail(thumbnail_size, thumbnail_size).into_rgba8(); - return ItemThumbnail::Image( - widget::image::Handle::from_rgba( - thumbnail.width(), - thumbnail.height(), - thumbnail.into_raw(), - ), - Some((image.width(), image.height())), - ); - } - Err(err) => { - log::warn!("failed to decode {:?}: {}", path, err); - } - }, - Err(err) => { - log::warn!("failed to read {:?}: {}", path, err); - } - } } else if mime.type_() == mime::TEXT && check_size("text", 8 * 1000 * 1000) { /*TODO: fix performance issues, widget::text_editr::Content::with_text forces all text to shape, which blocks rendering match fs::read_to_string(&path) { @@ -1773,15 +1840,52 @@ impl ItemThumbnail { */ } + // If we weren't able to create a thumbnail, but we should have + // been able to, create a fail marker so that it isn't tried the + // next time. + if let Ok(cacher) = thumbnail_cacher { + if tried_supported_file { + if let Err(err) = cacher.create_fail_marker() { + log::warn!( + "failed to create thumbnail fail marker for {:?}: {}", + path, + err + ); + } + } + } + + ItemThumbnail::NotImage + } + + fn generate_thumbnail_external( + path: &Path, + mime: &mime::Mime, + thumbnail_size: u32, + thumbnail_dir: Option<&Path>, + ) -> Option<(ItemThumbnail, NamedTempFile)> { // Try external thumbnailers for thumbnailer in thumbnailer(&mime) { - let prefix = if thumbnailer.exec.starts_with("evince-thumbnailer ") { + let is_evince = thumbnailer.exec.starts_with("evince-thumbnailer "); + let prefix = if is_evince { //TODO: apparmor config for evince-thumbnailer does not allow /tmp/cosmic-files* "gnome-desktop-" } else { "cosmic-files-" }; - let file = match tempfile::NamedTempFile::with_prefix(prefix) { + + // It's preferable to create the tempfile in the same directory as the final cached + // thumbnail to ensure that no copies accross filesytems need to be made. However, + // the apparmor config for evince-thumbnailer does not allow this, so we need to + // fallback to the system tempdir. + let file = if thumbnail_dir.is_none() || is_evince { + tempfile::Builder::new().prefix(prefix).tempfile() + } else { + tempfile::Builder::new() + .prefix(prefix) + .tempfile_in(thumbnail_dir.unwrap()) + }; + let file = match file { Ok(ok) => ok, Err(err) => { log::warn!( @@ -1804,14 +1908,17 @@ impl ItemThumbnail { { Ok(reader) => match reader.decode().map(|image| image.into_rgba8()) { Ok(image) => { - return ItemThumbnail::Image( - widget::image::Handle::from_rgba( - image.width(), - image.height(), - image.into_raw(), + return Some(( + ItemThumbnail::Image( + widget::image::Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + ), + None, ), - None, - ); + file, + )); } Err(err) => { log::warn!("failed to decode {:?}: {}", path, err); @@ -1831,7 +1938,7 @@ impl ItemThumbnail { } } - ItemThumbnail::NotImage + None } } diff --git a/src/thumbnail_cacher.rs b/src/thumbnail_cacher.rs new file mode 100644 index 0000000..61e71eb --- /dev/null +++ b/src/thumbnail_cacher.rs @@ -0,0 +1,347 @@ +use image::DynamicImage; +use md5::{Digest, Md5}; +use once_cell::sync::Lazy; +use std::{ + collections::HashMap, + error::Error, + fs::{self, File}, + io::{self, BufReader, BufWriter}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + time::UNIX_EPOCH, +}; +use tempfile::NamedTempFile; +use url::Url; + +/// Implements thumbnail caching based on the freedesktop.org Thumbnail Managing Standard. +/// https://specifications.freedesktop.org/thumbnail-spec/latest/ +pub struct ThumbnailCacher { + file_path: PathBuf, + file_uri: String, + thumbnail_dir: PathBuf, + thumbnail_path: PathBuf, + thumbnail_size: ThumbnailSize, + thumbnail_fail_marker_path: PathBuf, +} + +impl ThumbnailCacher { + pub fn new(file_path: &Path, thumbnail_size: ThumbnailSize) -> Result { + let file_uri = thumbnail_uri(file_path) + .map_err(|err| format!("failed to create URI for {:?}: {}", file_path, err))?; + let cache_base_dir = THUMBNAIL_CACHE_BASE_DIR + .as_ref() + .ok_or(format!("failed to get thumbnail cache directory"))?; + let thumbnail_filename = thumbnail_cache_filename(&file_uri); + let thumbnail_dir = cache_base_dir.join(thumbnail_size.subdirectory_name()); + let thumbnail_path = thumbnail_dir.join(&thumbnail_filename); + let thumbnail_fail_marker_path = cache_base_dir + .join("fail") + .join(format!("cosmic-files-{}", env!("CARGO_PKG_VERSION"))) + .join(&thumbnail_filename); + + Ok(Self { + file_path: file_path.to_path_buf(), + file_uri, + thumbnail_dir, + thumbnail_path, + thumbnail_size, + thumbnail_fail_marker_path, + }) + } + + pub fn get_cached_thumbnail(&self) -> CachedThumbnail { + // If the file is already a thumbnail, just use it so we don't generate + // cached thumbnails of thumbnails. + if let (Some(cache_base_dir), Ok(metadata)) = ( + THUMBNAIL_CACHE_BASE_DIR.as_ref(), + std::fs::metadata(&self.file_path), + ) { + if metadata.is_file() && self.file_path.starts_with(cache_base_dir) { + return CachedThumbnail::Valid((self.file_path.to_path_buf(), None)); + } + } + + // Use cached thumbnail if it is valid. + if self.is_thumbnail_valid(&self.thumbnail_path) { + return CachedThumbnail::Valid(( + self.thumbnail_path.clone(), + Some(self.thumbnail_size), + )); + } + + // Check if there is a fail marker from an earlier failure. + if self.is_thumbnail_valid(&self.thumbnail_fail_marker_path) { + return CachedThumbnail::Failed; + } + + CachedThumbnail::RequiresUpdate(self.thumbnail_size) + } + + pub fn thumbnail_dir(&self) -> &Path { + &self.thumbnail_dir + } + + pub fn update_with_temp_file(&self, temp_file: NamedTempFile) -> Result<&Path, Box> { + fs::set_permissions(temp_file.path(), fs::Permissions::from_mode(0o600))?; + self.update_thumbnail_text_metadata(temp_file.path())?; + fs::rename(temp_file.path(), &self.thumbnail_path)?; + + Ok(&self.thumbnail_path) + } + + pub fn update_with_image(&self, image: DynamicImage) -> Result<&Path, Box> { + let temp_file = tempfile::Builder::new() + .prefix("cosmic-files-") + .tempfile_in(&self.thumbnail_dir)?; + { + let file = File::create(temp_file.path())?; + let image = image + .thumbnail( + self.thumbnail_size.pixel_size(), + self.thumbnail_size.pixel_size(), + ) + .into_rgba8(); + let writer = BufWriter::new(file); + let mut encoder = png::Encoder::new(writer, image.width(), image.height()); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header()? + .write_image_data(&image.into_raw())?; + } + + self.update_with_temp_file(temp_file).into() + } + + pub fn create_fail_marker(&self) -> Result<(), Box> { + if let Some(dir) = self.thumbnail_fail_marker_path.parent() { + fs::create_dir_all(dir)?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + + let file = File::create(&self.thumbnail_fail_marker_path)?; + let writer = BufWriter::new(file); + let mut encoder = png::Encoder::new(writer, 1, 1); + encoder.set_color(png::ColorType::Grayscale); + encoder.set_depth(png::BitDepth::One); + encoder.write_header()?.write_image_data(&[0])?; + self.update_thumbnail_text_metadata(&self.thumbnail_fail_marker_path) + .into() + } + + fn update_thumbnail_text_metadata(&self, path: &Path) -> Result<(), Box> { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let decoder = png::Decoder::new(reader); + let mut reader = decoder.read_info()?; + let (width, height, color_type, bit_depth, mut text_chunks) = { + let info = reader.info(); + let text_chunks: HashMap = info + .uncompressed_latin1_text + .clone() + .into_iter() + .map(|chunk| (chunk.keyword, chunk.text)) + .collect(); + ( + info.width, + info.height, + info.color_type, + info.bit_depth, + text_chunks, + ) + }; + + let mut image_data = vec![0; reader.output_buffer_size()]; + reader.next_frame(&mut image_data)?; + + let file = File::create(path)?; + let writer = BufWriter::new(file); + + let mut encoder = png::Encoder::new(writer, width, height); + encoder.set_color(color_type); + encoder.set_depth(bit_depth); + + text_chunks.insert("Software".to_string(), "COSMIC Files".to_string()); + text_chunks.insert("Thumb::URI".to_string(), self.file_uri.clone()); + let metadata = std::fs::metadata(&self.file_path)?; + let size = metadata.len(); + text_chunks.insert("Thumb::Size".to_string(), size.to_string()); + let mtime = metadata + .modified()? + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + text_chunks.insert("Thumb::MTime".to_string(), mtime.to_string()); + + for (keyword, text) in text_chunks { + encoder.add_text_chunk(keyword, text)?; + } + + let mut writer = encoder.write_header()?; + writer.write_image_data(&image_data)?; + + Ok(()) + } + + fn is_thumbnail_valid(&self, thumbnail_path: &Path) -> bool { + let thumbnail_file = match File::open(thumbnail_path) { + Ok(file) => file, + Err(_) => return false, + }; + let decoder = png::Decoder::new(BufReader::new(thumbnail_file)); + let reader = match decoder.read_info() { + Ok(reader) => reader, + Err(err) => { + log::warn!("failed to decode {:?} as PNG: {}", thumbnail_path, err); + return false; + } + }; + + let texts = &reader.info().uncompressed_latin1_text; + + // Thumb::URI is required and must match. + let thumb_uri = texts + .iter() + .find(|text| text.keyword == "Thumb::URI") + .map(|t| &t.text); + if let Some(thumb_uri) = thumb_uri { + if *thumb_uri != self.file_uri { + return false; + } + } else { + return false; + } + + let metadata = match std::fs::metadata(&self.file_path) { + Ok(m) => m, + Err(err) => { + log::warn!("failed to get metatdata of {:?}: {}", self.file_path, err); + return false; + } + }; + + // Thumb::MTime is required and must match. + let thumb_mtime = texts + .iter() + .find(|text| text.keyword == "Thumb::MTime") + .map(|t| &t.text); + if let Some(thumb_mtime) = thumb_mtime { + let modified = match metadata.modified() { + Ok(m) => m, + Err(err) => { + log::warn!( + "failed to get modified from metatdata of {:?}, {}", + self.file_path, + err + ); + return false; + } + }; + let mtime = modified + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + if *thumb_mtime != mtime { + return false; + } + } else { + return false; + } + + // Thumb::Size isn't required, but it should be verified if present. + let thumb_size = texts + .iter() + .find(|text| text.keyword == "Thumb::Size") + .map(|t| &t.text); + if let Some(thumb_size) = thumb_size { + let size = metadata.len(); + if *thumb_size != size.to_string() { + return false; + } + } + + true + } +} + +fn thumbnail_uri(path: &Path) -> io::Result { + let absolute_path = fs::canonicalize(&path)?; + let url = Url::from_file_path(&absolute_path).map_err(|_| { + io::Error::other(format!( + "failed to create URI for thumbnail_file: {:?}", + absolute_path + )) + })?; + // Technically square brackets don't need to be percent encoded, + // and they aren't by the url crate, but the thumbnailer used by + // Gnome Files does. In order to share thumbnails and not get duplicates + // we should do the same. + let url = url.to_string().replace("[", "%5B").replace("]", "%5D"); + Ok(url) +} + +fn thumbnail_cache_filename(file_uri: &str) -> String { + let hash = Md5::digest(file_uri); + format!("{:x}.png", hash) +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum ThumbnailSize { + Normal = 128, + Large = 256, + XLarge = 512, + XXLarge = 1024, +} + +impl ThumbnailSize { + pub fn from_pixel_size(pixel_size: u32) -> Self { + if pixel_size <= Self::Normal.pixel_size() { + Self::Normal + } else if pixel_size <= Self::Large.pixel_size() { + Self::Large + } else if pixel_size <= Self::XLarge.pixel_size() { + Self::XLarge + } else { + Self::XXLarge + } + } + + pub fn pixel_size(self) -> u32 { + self as u32 + } + + pub fn subdirectory_name(self) -> String { + match self { + Self::Normal => "normal".to_string(), + Self::Large => "large".to_string(), + Self::XLarge => "x-large".to_string(), + Self::XXLarge => "xx-large".to_string(), + } + } +} + +pub enum CachedThumbnail { + /// The cached thumbnail is valid and should be used with size if known. + Valid((PathBuf, Option)), + /// The cached thumbnail doesn't exist or it's invalid and + /// needs to be recreated with the pixel size. + RequiresUpdate(ThumbnailSize), + // The cached thumbnail is in a failed state. + // This means it failed to create by cosmic-files in the past + // and shouldn't be tried again. + Failed, +} + +static THUMBNAIL_CACHE_BASE_DIR: Lazy> = Lazy::new(|| { + if let Ok(base_dirs) = xdg::BaseDirectories::new() { + let base_dir = base_dirs.get_cache_home().join("thumbnails"); + return Some(base_dir); + } + + log::warn!("failed to get thumbnail cache directory, thumbnails will not be cached"); + + None +});