Files
luma-home/src/app_artwork.rs
T
Kaloyan DanchevandClaude Opus 4.6 cbea5a322f
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Merge Luma launcher fork into monorepo
Move trampoline files (boot-hook, watcher, overlay) into trampoline/
subdirectory. Add upstream Luma v2.3.1 source at repo root with our
additions:

- Page::Ha (4th nav tab) with placeholder empty state
- HA tab config schema and default entity mapping (ha-home/)
- Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings
- All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 11:52:55 +03:00

376 lines
12 KiB
Rust

//! 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<PathBuf>,
large_icon: Option<String>,
icon: Option<String>,
// 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<String>,
}
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<PathBuf> {
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<Vec<u8>> {
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<PathBuf> {
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<Artwork> {
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<Artwork> {
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<Vec<u8>>,
) -> Option<Artwork> {
for directory in source.directories() {
let manifest = read(&directory.join("appinfo.json"), 64 * 1024)
.and_then(|bytes| serde_json::from_slice::<Value>(&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<Arc<Artwork>>,
}
struct Entry {
token: u64,
used: u64,
// None: pending; Some(None): unavailable until the next inventory refresh.
image: Option<Option<Arc<Artwork>>>,
}
#[derive(Default)]
struct Cache {
clock: u64,
entries: HashMap<Source, Entry>,
}
pub struct Service {
sender: Option<mpsc::SyncSender<Request>>,
receiver: mpsc::Receiver<Reply>,
cache: RefCell<Cache>,
}
impl Service {
pub fn new(enabled: bool) -> Self {
Self::with_loader(enabled, load)
}
fn with_loader(
enabled: bool,
loader: impl Fn(&Source) -> Option<Artwork> + Send + 'static,
) -> Self {
let (sender, requests) = mpsc::sync_channel::<Request>(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<Arc<Artwork>> {
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;