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>
This commit is contained in:
1 parent
bc3ca0f3b9
commit
cbea5a322f
204 files changed
+25056
-65
No files matched your search
@@ -0,0 +1,375 @@
|
||||
//! 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;
|
||||
Reference in new issue
Block a user