diff --git a/src/lib.rs b/src/lib.rs index 0f201d7..37183ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ mod operation; mod spawn_detached; use tab::Location; pub mod tab; +mod thumbnailer; pub(crate) fn err_str(err: T) -> String { err.to_string() diff --git a/src/tab.rs b/src/tab.rs index cf32420..e1c088a 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -68,6 +68,7 @@ use crate::{ mime_icon::{mime_for_path, mime_icon}, mounter::Mounters, mouse_area, + thumbnailer::thumbnailer, }; use unix_permissions_ext::UNIXPermissionsExt; use uzers::{get_group_by_gid, get_user_by_uid}; @@ -937,100 +938,90 @@ pub enum ItemThumbnail { impl ItemThumbnail { pub fn new(path: &Path, mime: mime::Mime, thumbnail_size: u32) -> Self { if mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG { + // Try built-in svg thumbnailer //TODO: have a reasonable limit on SVG size? match fs::read(&path) { Ok(data) => { //TODO: validate SVG data - ItemThumbnail::Svg(data.into()) + return ItemThumbnail::Svg(data.into()); } Err(err) => { log::warn!("failed to read {:?}: {}", path, err); - ItemThumbnail::NotImage } } } else if mime.type_() == mime::IMAGE { + // Try built-in image thumbnailer match image::io::Reader::open(&path).and_then(|img| img.with_guessed_format()) { Ok(reader) => match reader.decode() { Ok(image) => { let thumbnail = image.thumbnail(thumbnail_size, thumbnail_size); - ItemThumbnail::Rgba( + return ItemThumbnail::Rgba( thumbnail.to_rgba8(), Some((image.width(), image.height())), - ) + ); } Err(err) => { log::warn!("failed to decode {:?}: {}", path, err); - ItemThumbnail::NotImage } }, Err(err) => { log::warn!("failed to read {:?}: {}", path, err); - ItemThumbnail::NotImage } } - } else { - //TODO: also support other external thumbnailers, using /usr/share/thumbnailers? - let (thumbnailer, prefix) = if mime.type_() == mime::VIDEO { - ("totem-video-thumbnailer", "cosmic-files-") - } else if mime == mime::APPLICATION_PDF { + } + + // Try external thumbnailers + for thumbnailer in thumbnailer(&mime) { + let prefix = if thumbnailer.exec.starts_with("evince-thumbnailer ") { //TODO: apparmor config for evince-thumbnailer does not allow /tmp/cosmic-files* - ("evince-thumbnailer", "gnome-desktop-") + "gnome-desktop-" } else { - return ItemThumbnail::NotImage; + "cosmic-files-" }; - match tempfile::NamedTempFile::with_prefix(prefix) { - Ok(file) => { - match process::Command::new(thumbnailer) - .arg("-l") - .arg("-s") - .arg(format!("{}", thumbnail_size)) - .arg(&path) - .arg(file.path()) - .status() - { - Ok(status) => { - if status.success() { - match image::io::Reader::open(file.path()) - .and_then(|img| img.with_guessed_format()) - { - Ok(reader) => match reader.decode() { - Ok(image) => ItemThumbnail::Rgba(image.to_rgba8(), None), - Err(err) => { - log::warn!("failed to decode {:?}: {}", path, err); - ItemThumbnail::NotImage - } - }, - Err(err) => { - log::warn!("failed to read {:?}: {}", path, err); - ItemThumbnail::NotImage - } - } - } else { - log::warn!( - "failed to run {} for {:?}: {}", - thumbnailer, - path, - status - ); - ItemThumbnail::NotImage - } - } - Err(err) => { - log::warn!("failed to run {} for {:?}: {}", thumbnailer, path, err); - ItemThumbnail::NotImage - } - } - } + let file = match tempfile::NamedTempFile::with_prefix(prefix) { + Ok(ok) => ok, Err(err) => { log::warn!( "failed to create temporary file for thumbnail of {:?}: {}", path, err ); - ItemThumbnail::NotImage + continue; + } + }; + + let Some(mut command) = thumbnailer.command(&path, file.path(), thumbnail_size) else { + continue; + }; + match command.status() { + Ok(status) => { + if status.success() { + match image::io::Reader::open(file.path()) + .and_then(|img| img.with_guessed_format()) + { + Ok(reader) => match reader.decode() { + Ok(image) => { + return ItemThumbnail::Rgba(image.to_rgba8(), None); + } + Err(err) => { + log::warn!("failed to decode {:?}: {}", path, err); + } + }, + Err(err) => { + log::warn!("failed to read {:?}: {}", path, err); + } + } + } else { + log::warn!("failed to run {:?} for {:?}: {}", thumbnailer, path, status); + } + } + Err(err) => { + log::warn!("failed to run {:?} for {:?}: {}", thumbnailer, path, err); } } } + + ItemThumbnail::NotImage } } diff --git a/src/thumbnailer.rs b/src/thumbnailer.rs new file mode 100644 index 0000000..ec0ab17 --- /dev/null +++ b/src/thumbnailer.rs @@ -0,0 +1,165 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use mime_guess::Mime; +use once_cell::sync::Lazy; +use std::{ + cmp::Ordering, + collections::HashMap, + env, fs, + path::{Path, PathBuf}, + process, + sync::Mutex, + time::Instant, +}; + +#[derive(Clone, Debug)] +pub struct Thumbnailer { + pub exec: String, +} + +impl Thumbnailer { + pub fn command( + &self, + input: &Path, + output: &Path, + thumbnail_size: u32, + ) -> Option { + let args_vec: Vec = shlex::split(&self.exec)?; + let mut args = args_vec.iter(); + let mut command = process::Command::new(args.next()?); + for arg in args { + if arg.starts_with('%') { + match arg.as_str() { + "%f" | "%F" | "%u" | "%U" => { + command.arg(input); + } + "%o" => { + command.arg(output); + } + "%s" => { + command.arg(format!("{}", thumbnail_size)); + } + _ => { + log::warn!( + "unsupported thumbnailer Exec code {:?} in {:?}", + arg, + self.exec + ); + return None; + } + } + } else { + command.arg(arg); + } + } + Some(command) + } +} + +pub struct ThumbnailerCache { + cache: HashMap>, +} + +impl ThumbnailerCache { + pub fn new() -> Self { + let mut thumbnailer_cache = Self { + cache: HashMap::new(), + }; + thumbnailer_cache.reload(); + thumbnailer_cache + } + + pub fn reload(&mut self) { + use crate::localize::LANGUAGE_SORTER; + + let start = Instant::now(); + + self.cache.clear(); + + let mut search_dirs = Vec::new(); + match xdg::BaseDirectories::new() { + Ok(xdg_dirs) => { + search_dirs.push(xdg_dirs.get_data_home().join("thumbnailers")); + for data_dir in xdg_dirs.get_data_dirs() { + search_dirs.push(data_dir.join("thumbnailers")); + } + } + Err(err) => { + log::warn!("failed to get xdg base directories: {}", err); + } + } + + let mut thumbnailer_paths = Vec::new(); + for dir in search_dirs { + log::trace!("looking for thumbnailers in {:?}", dir); + match fs::read_dir(&dir) { + Ok(entries) => { + for entry_res in entries { + match entry_res { + Ok(entry) => thumbnailer_paths.push(entry.path()), + Err(err) => { + log::warn!("failed to read entry in directory {:?}: {}", dir, err); + } + } + } + } + Err(err) => { + log::warn!("failed to read directory {:?}: {}", dir, err); + } + } + } + + //TODO: handle directory specific behavior + for path in thumbnailer_paths { + let entry = match freedesktop_entry_parser::parse_entry(&path) { + Ok(ok) => ok, + Err(err) => { + log::warn!("failed to parse {:?}: {}", path, err); + continue; + } + }; + + //TODO: use TryExec? + let section = entry.section("Thumbnailer Entry"); + let Some(exec) = section.attr("Exec") else { + log::warn!("missing Exec attribute for thumbnailer {:?}", path); + continue; + }; + let Some(mime_types) = section.attr("MimeType") else { + log::warn!("missing MimeType attribute for thumbnailer {:?}", path); + continue; + }; + + for mime_type in mime_types.split_terminator(';') { + if let Ok(mime) = mime_type.parse::() { + log::trace!("thumbnailer {}={:?}", mime, path); + let apps = self + .cache + .entry(mime.clone()) + .or_insert_with(|| Vec::with_capacity(1)); + apps.push(Thumbnailer { + exec: exec.to_string(), + }); + } + } + } + + let elapsed = start.elapsed(); + log::info!("loaded thumbnailer cache in {:?}", elapsed); + } + + pub fn get(&self, key: &Mime) -> Vec { + self.cache + .get(&key) + .map_or_else(|| Vec::new(), |x| x.clone()) + } +} + +static THUMBNAILER_CACHE: Lazy> = + Lazy::new(|| Mutex::new(ThumbnailerCache::new())); + +pub fn thumbnailer(mime: &Mime) -> Vec { + let thumbnailer_cache = THUMBNAILER_CACHE.lock().unwrap(); + thumbnailer_cache.get(mime) +}