//! Installed-app artwork is discovered and decoded on its own worker. The UI //! requests visible cards only; it uploads finished pixels on the SDL thread. use crate::model::valid_app_id; use image::{ImageReader, Limits, RgbaImage}; use serde::Serialize; use serde_json::Value; use std::{ cell::RefCell, collections::HashMap, fs::File, io::{Cursor, Read}, path::{Component, Path, PathBuf}, sync::{Arc, mpsc}, thread, }; const ROOTS: [&str; 3] = [ "/media/developer/apps/usr/palm/applications", "/media/cryptofs/apps/usr/palm/applications", "/usr/palm/applications", ]; pub const MAX_FILE: usize = 512 * 1024; const CACHE_BYTES: usize = 16 * 1024 * 1024; const CACHE_ENTRIES: usize = 256; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] pub struct Source { pub id: String, directory: Option, large_icon: Option, icon: Option, // Part of the cache key: an app update changes it, so its icon is read // again, while unchanged apps keep their decoded artwork across refreshes. version: Option, } impl Source { pub fn new(id: &str, metadata: &Value) -> Self { let text = |key| { metadata .get(key) .and_then(Value::as_str) .filter(|s| !s.is_empty() && s.len() <= 4096 && !s.chars().any(char::is_control)) }; Self { id: id.into(), directory: text("folderPath").map(PathBuf::from).filter(|p| { p.components().all(|c| !matches!(c, Component::ParentDir)) && ROOTS.iter().any(|root| p.parent() == Some(Path::new(root))) }), large_icon: text("largeIcon").map(str::to_owned), icon: text("icon").map(str::to_owned), version: text("version").map(str::to_owned), } } fn directories(&self) -> Vec { let mut directories: Vec<_> = self.directory.iter().cloned().collect(); for root in ROOTS { let directory = Path::new(root).join(&self.id); if !directories.contains(&directory) { directories.push(directory); } } directories } } pub struct Artwork { pub key: String, pub pixels: RgbaImage, pub color: [u8; 3], } fn read_bounded(path: &Path, limit: usize) -> Option> { let file = File::open(path).ok()?; let metadata = file.metadata().ok()?; if !metadata.is_file() || metadata.len() > limit as u64 { return None; } let mut bytes = Vec::new(); file.take(limit as u64 + 1).read_to_end(&mut bytes).ok()?; (bytes.len() <= limit).then_some(bytes) } fn icon_path(directory: &Path, value: &str) -> Option { if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) { return None; } let value = value.strip_prefix("file://").unwrap_or(value); if value.contains("://") { return None; } let path = Path::new(value); if path .components() .any(|part| matches!(part, Component::ParentDir)) { return None; } let path = if path.is_absolute() { path.to_owned() } else { directory.join(path) }; ROOTS .iter() .any(|root| path.starts_with(root)) .then_some(path) } fn decode(bytes: &[u8]) -> Option { let mut reader = ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .ok()?; let mut limits = Limits::default(); limits.max_image_width = Some(2048); limits.max_image_height = Some(2048); limits.max_alloc = Some(32 * 1024 * 1024); reader.limits(limits); let image = reader.decode().ok()?; let pixels = image.thumbnail(256, 256).into_rgba8(); let color = background(&pixels); Some(Artwork { key: String::new(), pixels, color, }) } fn background(image: &RgbaImage) -> [u8; 3] { // Match the icon's opaque edges rather than letting its central logo choose // the card colour. Transparent artwork gets a neutral dark background, even // when its logo reaches into the edge band: those few opaque pixels would // otherwise fill the card with the logo's own colour and hide it. let sample = image::imageops::resize(image, 48, 48, image::imageops::FilterType::Triangle); let mut bins = HashMap::<[u8; 3], (usize, [usize; 3])>::new(); let (mut edge, mut opaque) = (0, 0); for (x, y, pixel) in sample.enumerate_pixels() { if (6..42).contains(&x) && (6..42).contains(&y) { continue; } edge += 1; if pixel[3] < 230 { continue; } opaque += 1; let bin = bins .entry([pixel[0] >> 4, pixel[1] >> 4, pixel[2] >> 4]) .or_default(); bin.0 += 1; for channel in 0..3 { bin.1[channel] += pixel[channel] as usize; } } if opaque * 2 < edge { return [20; 3]; } bins.into_iter() .max_by_key(|(key, (count, _))| (*count, *key)) .map_or([20; 3], |(_, (count, sum))| { sum.map(|s| ((s + count / 2) / count) as u8) }) } fn load(source: &Source) -> Option { if !valid_app_id(&source.id) { return None; } if let Some(image) = load_local(source, read_bounded) { return Some(image); } // Some jailed installations cannot read other applications' files. Use the // same native-authenticated elevated service as inventory, on this worker. match crate::platform::read_app_icons(source) { Ok(icons) => icons.iter().find_map(|bytes| decode(bytes)), Err(error) => { eprintln!("Luma installed icon {}: {error}", source.id); None } } } fn load_local( source: &Source, mut read: impl FnMut(&Path, usize) -> Option>, ) -> Option { for directory in source.directories() { let manifest = read(&directory.join("appinfo.json"), 64 * 1024) .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .filter(|value| value.get("id").and_then(Value::as_str) == Some(&source.id)); let manifest_icon = |key| { manifest .as_ref() .and_then(|m| m.get(key)) .and_then(Value::as_str) }; for icon in [ source.large_icon.as_deref(), manifest_icon("largeIcon"), source.icon.as_deref(), manifest_icon("icon"), Some("icon.png"), ] .into_iter() .flatten() { if let Some(artwork) = icon_path(&directory, icon) .and_then(|path| read(&path, MAX_FILE)) .and_then(|bytes| decode(&bytes)) { return Some(artwork); } } } None } struct Request { token: u64, source: Source, } struct Reply { request: Request, image: Option>, } struct Entry { token: u64, used: u64, // None: pending; Some(None): unavailable until the next inventory refresh. image: Option>>, } #[derive(Default)] struct Cache { clock: u64, entries: HashMap, } pub struct Service { sender: Option>, receiver: mpsc::Receiver, cache: RefCell, } impl Service { pub fn new(enabled: bool) -> Self { Self::with_loader(enabled, load) } fn with_loader( enabled: bool, loader: impl Fn(&Source) -> Option + Send + 'static, ) -> Self { let (sender, requests) = mpsc::sync_channel::(16); // Room for a full request queue: an idle UI only polls on its next // wake, and a one-slot reply channel held the worker to one icon each. let (responses, receiver) = mpsc::sync_channel(16); if enabled { thread::spawn(move || { while let Ok(request) = requests.recv() { let image = loader(&request.source).map(|mut image| { image.key = format!("installed-icon:{}", request.token); Arc::new(image) }); if responses.send(Reply { request, image }).is_err() { break; } } }); } Self { sender: enabled.then_some(sender), receiver, cache: RefCell::default(), } } pub fn get(&self, source: &Source) -> Option> { let sender = self.sender.as_ref()?; let mut cache = self.cache.borrow_mut(); cache.clock += 1; let clock = cache.clock; if let Some(entry) = cache.entries.get_mut(source) { entry.used = clock; return entry.image.clone().flatten(); } let request = Request { token: clock, source: source.clone(), }; // A full queue is retried when the next completed request redraws the // scene. Neither scrolling nor a slow Homebrew service can block input. if sender.try_send(request).is_ok() { cache.entries.insert( source.clone(), Entry { token: clock, used: clock, image: None, }, ); } None } /// Whether a requested icon has not come back yet, so the event loop /// should wake soon to poll for it. pub fn pending(&self) -> bool { self.cache .borrow() .entries .values() .any(|entry| entry.image.is_none()) } /// Refresh apps: every icon is read again. Replies to earlier requests /// no longer match an entry's token and are dropped. pub fn reset(&self) { self.cache.borrow_mut().entries.clear(); } /// After an automatic inventory refresh: icons that were unavailable are /// tried again. Decoded and pending icons stay, because a `Source` names /// the app's version and icon paths, so an updated app has a new one. pub fn refresh(&self) { self.cache .borrow_mut() .entries .retain(|_, entry| !matches!(entry.image, Some(None))); } pub fn poll(&self) -> bool { let mut changed = false; let mut cache = self.cache.borrow_mut(); while let Ok(reply) = self.receiver.try_recv() { changed = true; if let Some(entry) = cache.entries.get_mut(&reply.request.source) && entry.token == reply.request.token { entry.image = Some(reply.image); } } if !changed { return false; } let mut bytes: usize = cache .entries .values() .filter_map(|e| e.image.as_ref()?.as_ref()) .map(|i| i.pixels.as_raw().len()) .sum(); let mut old: Vec<_> = cache .entries .iter() .filter(|(_, e)| e.image.is_some()) .map(|(s, e)| (s.clone(), e.used)) .collect(); old.sort_by_key(|(_, used)| *used); for (source, _) in old { if bytes <= CACHE_BYTES && cache.entries.len() <= CACHE_ENTRIES { break; } if let Some(entry) = cache.entries.remove(&source) && let Some(Some(image)) = entry.image { bytes -= image.pixels.as_raw().len(); } } changed } } #[cfg(test)] #[path = "app-artwork-tests.rs"] mod tests;