Merge Luma launcher fork into monorepo
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

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:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 11:52:55 +03:00
1 parent bc3ca0f3b9
commit cbea5a322f
204 files changed
+25056 -65

No files matched your search

+401
View File
@@ -0,0 +1,401 @@
use super::*;
use image::{DynamicImage, ImageFormat, Rgba};
use serde_json::json;
use std::{
sync::atomic::{AtomicUsize, Ordering},
time::{Duration, Instant},
};
fn encoded(format: ImageFormat, width: u32, height: u32, color: [u8; 4]) -> Vec<u8> {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(width, height, Rgba(color)));
let image = if format == ImageFormat::Jpeg {
DynamicImage::ImageRgb8(image.into_rgb8())
} else {
image
};
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, format).unwrap();
bytes.into_inner()
}
fn png(image: RgbaImage) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image)
.write_to(&mut bytes, ImageFormat::Png)
.unwrap();
bytes.into_inner()
}
fn poll_until(service: &Service, mut done: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(3);
loop {
service.poll();
if done() {
break;
}
assert!(Instant::now() < deadline, "artwork worker did not finish");
thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn inventory_metadata_resolves_absolute_relative_and_manifest_fallback_paths() {
let metadata = json!({"id":"sample", "title":"Regional App", "icon":"regional.png",
"largeIcon":"large.png", "folderPath":format!("{}/sample", ROOTS[1]), "version":"3.1.0"});
let apps = crate::platform::normalize_apps(&json!([metadata.clone()])).unwrap();
assert_eq!(apps[0].icon, Some(Source::new("sample", &metadata)));
assert_eq!(
apps[0].icon.as_ref().unwrap().version.as_deref(),
Some("3.1.0")
);
let source = Source::new(
"sample",
&json!({
"folderPath": format!("{}/actual-package-directory", ROOTS[1]),
"largeIcon": "resources/hu/large.png", "icon": "icon.png"
}),
);
let dirs = source.directories();
assert_eq!(
dirs[0],
Path::new(ROOTS[1]).join("actual-package-directory")
);
assert_eq!(
icon_path(&dirs[0], source.large_icon.as_deref().unwrap()),
Some(dirs[0].join("resources/hu/large.png"))
);
let absolute = format!("file://{}/sample/icon.png", ROOTS[2]);
assert_eq!(
icon_path(&dirs[0], &absolute),
Some(Path::new(ROOTS[2]).join("sample/icon.png"))
);
for bad in [
"../../etc/passwd",
"/etc/passwd",
"https://example.com/icon.png",
"icon\0.png",
] {
assert!(icon_path(&dirs[0], bad).is_none());
}
let source = Source::new("sample", &json!({"folderPath":"/etc"}));
assert_eq!(source.directories().len(), 3);
assert_eq!(source.directories()[0], Path::new(ROOTS[0]).join("sample"));
}
#[test]
fn supported_icons_decode_to_bounded_pixels_and_runtime_card_colours() {
for format in [ImageFormat::Png, ImageFormat::Jpeg, ImageFormat::WebP] {
let artwork = decode(&encoded(format, 400, 200, [48, 80, 112, 255])).unwrap();
assert_eq!(artwork.pixels.dimensions(), (256, 128));
for (actual, expected) in artwork.color.into_iter().zip([48u8, 80, 112]) {
assert!(actual.abs_diff(expected) <= 2);
}
}
let transparent = decode(&encoded(ImageFormat::Png, 32, 32, [255, 0, 0, 0])).unwrap();
assert_eq!(transparent.color, [20; 3]);
assert!(decode(b"not an image").is_none());
assert!(decode(&encoded(ImageFormat::Png, 2049, 1, [0; 4])).is_none());
}
#[test]
fn transparent_logos_reaching_the_edges_keep_the_neutral_card() {
// Like Litefin's 130px icon: a ring logo on a transparent background whose
// stroke reaches into the sampled edge band.
let ring = RgbaImage::from_fn(130, 130, |x, y| {
let distance = (x as f32 - 64.5).hypot(y as f32 - 64.5);
if (52.0..62.0).contains(&distance) {
Rgba([150, 100, 210, 255])
} else {
Rgba([0; 4])
}
});
assert_eq!(decode(&png(ring)).unwrap().color, [20; 3]);
// Opaque artwork with transparent rounded corners still takes its edge colour.
let rounded = RgbaImage::from_fn(130, 130, |x, y| {
let inset = |v: u32| v.min(129 - v) as f32;
let (dx, dy) = (24. - inset(x), 24. - inset(y));
if dx > 0. && dy > 0. && dx.hypot(dy) > 24. {
Rgba([0; 4])
} else {
Rgba([40, 90, 160, 255])
}
});
let color = decode(&png(rounded)).unwrap().color;
for (actual, expected) in color.into_iter().zip([40u8, 90, 160]) {
assert!(actual.abs_diff(expected) <= 2);
}
}
#[test]
fn local_reads_fall_back_from_a_corrupt_large_icon_to_manifest_artwork() {
let directory = Path::new(ROOTS[1]).join("sample");
let manifest = json!({"id":"sample", "largeIcon":"corrupt.png", "icon":"local/icon.webp"});
let files = HashMap::from([
(
directory.join("appinfo.json"),
serde_json::to_vec(&manifest).unwrap(),
),
(directory.join("corrupt.png"), b"broken".to_vec()),
(
directory.join("local/icon.webp"),
encoded(ImageFormat::WebP, 32, 32, [8, 16, 24, 255]),
),
]);
let source = Source::new("sample", &json!({"folderPath": directory}));
let mut reads = Vec::new();
let image = load_local(&source, |path, limit| {
reads.push(path.to_owned());
files
.get(path)
.filter(|bytes| bytes.len() <= limit)
.cloned()
})
.unwrap();
assert_eq!(image.pixels.get_pixel(0, 0).0, [8, 16, 24, 255]);
assert_eq!(reads[0], directory.join("appinfo.json"));
assert!(reads.contains(&directory.join("corrupt.png")));
}
#[test]
fn refresh_discards_old_worker_results_and_reloads_changed_artwork() {
let calls = Arc::new(AtomicUsize::new(0));
let worker_calls = calls.clone();
let (started, received) = mpsc::channel();
let ui_thread = thread::current().id();
let service = Service::with_loader(true, move |_| {
assert_ne!(thread::current().id(), ui_thread);
let number = worker_calls.fetch_add(1, Ordering::SeqCst) + 1;
started.send(number).unwrap();
decode(&encoded(ImageFormat::Png, 2, 2, [number as u8, 0, 0, 255]))
});
let source = Source::new("sample", &json!({}));
assert!(service.get(&source).is_none());
assert_eq!(received.recv_timeout(Duration::from_secs(3)).unwrap(), 1);
service.reset();
assert!(service.get(&source).is_none());
poll_until(&service, || service.get(&source).is_some());
let image = service.get(&source).unwrap();
assert_eq!(image.pixels.get_pixel(0, 0)[0], 2);
assert!(Arc::ptr_eq(&image, &service.get(&source).unwrap()));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn the_worker_keeps_decoding_while_the_ui_is_idle() {
let calls = Arc::new(AtomicUsize::new(0));
let worker_calls = calls.clone();
let service = Service::with_loader(true, move |_| {
worker_calls.fetch_add(1, Ordering::SeqCst);
decode(&encoded(ImageFormat::Png, 2, 2, [1, 2, 3, 255]))
});
let sources: Vec<_> = (0..5)
.map(|number| Source::new(&format!("app-{number}"), &json!({})))
.collect();
for source in &sources {
assert!(service.get(source).is_none());
}
assert!(service.pending());
// No poll yet: an idle UI must not hold the worker to one finished icon.
let deadline = Instant::now() + Duration::from_secs(3);
while calls.load(Ordering::SeqCst) < sources.len() {
assert!(
Instant::now() < deadline,
"worker stalled until the UI polled"
);
thread::sleep(Duration::from_millis(1));
}
poll_until(&service, || {
sources.iter().all(|source| service.get(source).is_some())
});
assert!(!service.pending());
}
#[test]
fn a_refresh_keeps_unchanged_icons_and_reads_updated_apps_again() {
let calls = Arc::new(AtomicUsize::new(0));
let worker_calls = calls.clone();
let ui_thread = thread::current().id();
let service = Service::with_loader(true, move |_| {
assert_ne!(thread::current().id(), ui_thread);
let number = worker_calls.fetch_add(1, Ordering::SeqCst) + 1;
decode(&encoded(ImageFormat::Png, 2, 2, [number as u8, 0, 0, 255]))
});
let installed = Source::new("sample", &json!({"version": "1.0.0"}));
assert!(service.get(&installed).is_none());
poll_until(&service, || service.get(&installed).is_some());
let image = service.get(&installed).unwrap();
service.refresh();
// Unchanged after the refresh: the same decoded artwork, not read again.
assert!(Arc::ptr_eq(&image, &service.get(&installed).unwrap()));
assert_eq!(calls.load(Ordering::SeqCst), 1);
// An update changes the version, so its icon is read again.
let updated = Source::new("sample", &json!({"version": "1.1.0"}));
assert_ne!(installed, updated);
assert!(service.get(&updated).is_none());
poll_until(&service, || service.get(&updated).is_some());
assert_eq!(service.get(&updated).unwrap().pixels.get_pixel(0, 0)[0], 2);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn a_refresh_during_a_load_keeps_its_result() {
let calls = Arc::new(AtomicUsize::new(0));
let worker_calls = calls.clone();
let (release, hold) = mpsc::channel::<()>();
let service = Service::with_loader(true, move |_| {
worker_calls.fetch_add(1, Ordering::SeqCst);
hold.recv().unwrap();
decode(&encoded(ImageFormat::Png, 2, 2, [1, 0, 0, 255]))
});
let source = Source::new("sample", &json!({}));
assert!(service.get(&source).is_none());
service.refresh();
release.send(()).unwrap();
poll_until(&service, || service.get(&source).is_some());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn unavailable_icons_are_not_retried_on_every_redraw() {
let calls = Arc::new(AtomicUsize::new(0));
let worker_calls = calls.clone();
let service = Service::with_loader(true, move |_| {
worker_calls.fetch_add(1, Ordering::SeqCst);
None
});
let source = Source::new("sample", &json!({}));
assert!(service.get(&source).is_none());
poll_until(&service, || {
service.cache.borrow().entries[&source].image.is_some()
});
for _ in 0..10 {
assert!(service.get(&source).is_none());
}
assert_eq!(calls.load(Ordering::SeqCst), 1);
// Unavailable icons are tried again after a refresh.
service.refresh();
assert!(service.get(&source).is_none());
poll_until(&service, || {
service.cache.borrow().entries[&source].image.is_some()
});
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn hundreds_of_apps_stay_within_the_cache_bounds_across_refreshes() {
let service = Service::with_loader(true, |_| {
Some(Artwork {
key: String::new(),
pixels: RgbaImage::new(256, 256),
color: [20; 3],
})
});
for number in 0..300 {
if number % 100 == 0 {
service.refresh();
}
let source = Source::new(&format!("app-{number}"), &json!({"version": "1"}));
service.get(&source);
poll_until(&service, || {
service.cache.borrow().entries[&source].image.is_some()
});
}
let cache = service.cache.borrow();
let bytes: usize = cache
.entries
.values()
.filter_map(|entry| entry.image.as_ref()?.as_ref())
.map(|image| image.pixels.as_raw().len())
.sum();
assert!(bytes <= CACHE_BYTES);
assert!(cache.entries.len() <= CACHE_ENTRIES);
}
#[test]
fn decoded_cache_evicts_old_icons_when_scrolling_through_many_apps() {
let service = Service::with_loader(true, |_| {
Some(Artwork {
key: String::new(),
pixels: RgbaImage::new(256, 256),
color: [20; 3],
})
});
for number in 0..80 {
let source = Source::new(&format!("app-{number}"), &json!({}));
service.get(&source);
poll_until(&service, || {
service.cache.borrow().entries[&source].image.is_some()
});
}
let cache = service.cache.borrow();
let bytes: usize = cache
.entries
.values()
.filter_map(|entry| entry.image.as_ref()?.as_ref())
.map(|image| image.pixels.as_raw().len())
.sum();
assert!(bytes <= CACHE_BYTES);
assert!(
!cache
.entries
.contains_key(&Source::new("app-0", &json!({})))
);
assert!(
cache
.entries
.contains_key(&Source::new("app-79", &json!({})))
);
}
#[test]
fn visible_cards_switch_from_fallback_to_installed_artwork_without_an_icon_map() {
let assets = crate::view::Assets {
root: PathBuf::new(),
version: "test".into(),
metrics: Default::default(),
artwork: Service::with_loader(true, |_| {
decode(&encoded(ImageFormat::Png, 32, 32, [24, 40, 56, 255]))
}),
};
let apps =
crate::platform::normalize_apps(&json!([{"id":"regional.app", "title":"Regional App"}]))
.unwrap();
let mut state = crate::state::State::new(Default::default());
state.set_inventory(
crate::model::Inventory {
apps,
inputs: vec![],
},
None,
);
let scene = || {
crate::view::scene_with_pill(
&state,
&assets,
800.,
100.,
time::OffsetDateTime::UNIX_EPOCH,
)
};
assert!(
!scene()
.draw
.iter()
.any(|draw| matches!(draw, crate::renderer::Draw::AppImage(..)))
);
poll_until(&assets.artwork, || {
scene()
.draw
.iter()
.any(|draw| matches!(draw, crate::renderer::Draw::AppImage(..)))
});
let image = scene()
.draw
.into_iter()
.find_map(|draw| match draw {
crate::renderer::Draw::AppImage(image, _, _) => Some(image),
_ => None,
})
.unwrap();
assert_eq!(image.color, [24, 40, 56]);
}
+375
View File
@@ -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;
+220
View File
@@ -0,0 +1,220 @@
//! Curated artwork is an exact application-ID allowlist. Inventory titles and
//! partial ID matches must never replace another application's installed icon.
#[derive(Debug)]
pub struct Brand {
pub key: &'static str,
pub title: &'static str,
pub ids: &'static [&'static str],
pub category: &'static str,
}
impl Brand {
/// Optical bounds, rather than a shared bounding box: heavy wordmarks need
/// more breathing room than wide, fine-lettered logos such as YouTube.
pub fn artwork_size(&self) -> (f32, f32) {
match self.key {
"netflix" => (0.76, 88.),
"youtube" => (0.74, 88.),
"apple-tv" => (0.43, 88.),
"disney-plus" => (0.72, 105.6),
"prime-video" => (0.72, 88.),
"jellyfin" => (0.83, 88.),
"arte" => (0.58, 88.),
"zdf" => (0.50, 88.),
"das-erste" => (0.70, 88.),
"hulu" | "joyn" | "itvx" => (0.62, 48.),
"nrk-tv" => (0.62, 56.),
"tv2-play" => (0.56, 80.),
"plex" => (0.62, 52.),
"filmin" => (0.62, 46.),
"hbo-max" | "bbc-iplayer" | "crunchyroll" | "twitch" | "dazn" | "paramount-plus"
| "channel-4" | "wavve" => (0.70, 64.),
"now-tv" => (0.70, 52.),
"live-tv" | "browser" => (0.62, 68.),
_ => (0.70, 56.),
}
}
}
const fn video(key: &'static str, title: &'static str, ids: &'static [&'static str]) -> Brand {
Brand {
key,
title,
ids,
category: "Streaming",
}
}
const fn music(key: &'static str, title: &'static str, ids: &'static [&'static str]) -> Brand {
Brand {
key,
title,
ids,
category: "Music",
}
}
pub const BRANDS: &[Brand] = &[
video("live-tv", "Live TV", &["com.webos.app.livetv"]),
Brand {
key: "browser",
title: "Web Browser",
ids: &["com.webos.app.browser"],
category: "Utilities",
},
video("netflix", "Netflix", &["netflix"]),
video("youtube", "YouTube", &["youtube.leanback.v4"]),
video(
"apple-tv",
"Apple TV",
&["com.apple.appletv", "com.apple.appletv.web"],
),
video("disney-plus", "Disney+", &["com.disney.disneyplus-prod"]),
video("prime-video", "Prime Video", &["amazon"]),
video("jellyfin", "Jellyfin", &["org.jellyfin.webos"]),
video("arte", "ARTE", &["com.arte.tv", "com.3827031.168353"]),
video("zdf", "ZDF", &["de.zdf.app.zdfm3"]),
video("das-erste", "ARD Mediathek", &["ard.mediathek"]),
music(
"spotify",
"Spotify",
&["spotify-beehive", "com.spotify.tv.webos"],
),
video("plex", "Plex", &["cdp-30", "plex"]),
video(
"hbo-max",
"HBO Max",
&["com.wbd.stream", "com.hbo.hbomax", "hbo-go-2"],
),
video(
"paramount-plus",
"Paramount+",
&["com.pplus.webapp.prod-intl", "cbs"],
),
video("peacock", "Peacock", &["com.peacocktv.peacock"]),
video("hulu", "Hulu", &["hulu"]),
video(
"crunchyroll",
"Crunchyroll",
&["crunchyroll", "com.crunchyroll.webos"],
),
video("twitch", "Twitch", &["tv.twitch.tv.starshot.lg", "twitch"]),
video("dazn", "DAZN", &["dazn"]),
video("rakuten-tv", "Rakuten TV", &["ui30"]),
music(
"apple-music",
"Apple Music",
&["com.apple.music", "com.apple.ampl"],
),
music(
"amazon-music",
"Amazon Music",
&["com.theadelab.amclient-lg"],
),
video(
"bbc-iplayer",
"BBC iPlayer",
&["bbc.iplayer.3.0", "bbc.iplayer"],
),
video("itvx", "ITVX", &["com.fvp.itv", "itvx"]),
video(
"channel-4",
"Channel 4",
&["com.fvp.ch4", "channel4", "all4.app"],
),
video("now-tv", "NOW / Now TV", &["now.tv", "nowtv"]),
video("rtl-plus", "RTL+", &["tvnow", "com.tvnow.webos"]),
video("joyn", "Joyn", &["joyn"]),
video("waipu-tv", "waipu.tv", &["tv.waipu.app.waipu-lg"]),
video("zattoo", "Zattoo", &["zattoo", "com.zattoo.webapp"]),
video(
"skyshowtime",
"SkyShowtime",
&["com.skyshowtime.tv", "com.skyshowtime.skyshowtime"],
),
video("coupang-play", "Coupang Play", &["coupangplay"]),
video("tving", "TVING", &["cj.eandm"]),
video("wavve", "Wavve", &["pooq"]),
video("watcha", "Watcha Play", &["com.frograms.watchaplay.webos"]),
video("movistar-plus", "Movistar+", &["telefonica"]),
video("filmin", "Filmin", &["filmin"]),
video("nrk-tv", "NRK TV", &["no.nrk.webos.smarttv"]),
video("tv2-play", "TV 2 Play", &["tv2sumo"]),
];
pub fn for_app(id: &str) -> Option<&'static Brand> {
BRANDS.iter().find(|brand| brand.ids.contains(&id))
}
pub fn for_key(key: &str) -> Option<&'static Brand> {
BRANDS.iter().find(|brand| brand.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn every_curated_id_is_unique_and_selects_its_brand_without_title_matching() {
let mut ids = HashSet::new();
for brand in BRANDS {
assert!(!brand.ids.is_empty());
assert!(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("assets/logos/{}.svg", brand.key))
.is_file(),
"{} has no vector source",
brand.key
);
for id in brand.ids {
assert!(ids.insert(id), "Duplicate curated app ID: {id}");
let app = crate::model::App::new(id, "A localized application title");
assert_eq!(app.brand, brand.key);
assert_eq!(app.category, brand.category);
assert!(for_app(&format!("{id}.unrelated")).is_none());
}
}
}
#[test]
fn now_tv_aliases_select_now_without_rebranding_other_sky_or_now_apps() {
for id in ["now.tv", "nowtv"] {
assert_eq!(for_app(id).unwrap().key, "now-tv");
}
for id in [
"de.sky.skyonline",
"com.skygo.app.de.q",
"now.tv.news",
"com.nowtv.hk",
] {
assert!(for_app(id).is_none());
}
}
#[test]
fn desktop_demo_contains_every_bundled_logo_and_no_duplicate_apps() {
let inventory = crate::model::demo_inventory();
let ids: HashSet<_> = inventory.apps.iter().map(|app| &app.id).collect();
assert_eq!(ids.len(), inventory.apps.len());
for brand in BRANDS {
assert!(
inventory.apps.iter().any(|app| app.brand == brand.key),
"The desktop demo is missing {}",
brand.title
);
}
for entry in
std::fs::read_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/logos"))
.unwrap()
{
let path = entry.unwrap().path();
if path.extension().is_some_and(|ext| ext == "svg") {
assert!(
for_key(path.file_stem().unwrap().to_str().unwrap()).is_some(),
"Bundled logo has no demo card: {}",
path.display()
);
}
}
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Presentation pacing is measured only in forced-redraw profiling mode. Idle
//! wakeups are not frames, and CPU submission time is not a GPU timestamp.
use serde::Serialize;
use std::time::Instant;
const WARMUP: usize = 10;
#[derive(Default)]
pub struct Profile {
presented: usize,
previous: Option<Instant>,
intervals: Vec<f64>,
cpu: Vec<f64>,
}
#[derive(Serialize)]
pub struct Timing {
pub samples: usize,
pub mean_ms: f64,
pub p95_ms: f64,
pub max_ms: f64,
}
#[derive(Serialize)]
pub struct Summary {
pub presented: usize,
pub warmup_frames: usize,
pub pacing: Option<Timing>,
pub cpu_submission: Option<Timing>,
}
impl Profile {
pub fn record(&mut self, now: Instant, cpu_ms: f64, exclude: bool) {
self.presented += 1;
if self.presented > WARMUP
&& !exclude
&& let Some(previous) = self.previous
{
self.intervals
.push(now.duration_since(previous).as_secs_f64() * 1000.);
self.cpu.push(cpu_ms);
}
self.previous = Some(now);
}
pub fn pause(&mut self) {
self.previous = None;
}
pub fn summary(&self) -> Summary {
Summary {
presented: self.presented,
warmup_frames: WARMUP,
pacing: summarize(&self.intervals),
cpu_submission: summarize(&self.cpu),
}
}
}
fn summarize(samples: &[f64]) -> Option<Timing> {
if samples.is_empty() {
return None;
}
let mut sorted = samples.to_vec();
sorted.sort_by(f64::total_cmp);
Some(Timing {
samples: sorted.len(),
mean_ms: sorted.iter().sum::<f64>() / sorted.len() as f64,
p95_ms: sorted[(sorted.len() * 95).div_ceil(100) - 1],
max_ms: *sorted.last()?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn profiling_excludes_warmup_and_capture_stalls() {
let mut profile = Profile::default();
let start = Instant::now();
for index in 0..20 {
profile.record(start + Duration::from_millis(index * 16), 2., index == 19);
}
let summary = profile.summary();
let pacing = summary.pacing.unwrap();
assert_eq!(pacing.samples, 9);
assert_eq!(pacing.mean_ms, 16.);
assert_eq!(pacing.p95_ms, 16.);
assert_eq!(summary.cpu_submission.unwrap().mean_ms, 2.);
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Lazy TTF metrics and individual outlines; never preprocess an entire font.
use geometry::lyon_tessellation::{
FillRule,
math::point,
path::{Path, path::Builder},
};
use luma_vector_geometry::{self as geometry, Vertex};
use owned_ttf_parser::{AsFaceRef, GlyphId, OutlineBuilder, OwnedFace};
pub struct Font {
face: OwnedFace,
}
pub struct LineMetrics {
pub ascent: f32,
pub descent: f32,
}
pub struct Metrics {
pub advance_width: f32,
}
impl Font {
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, String> {
OwnedFace::from_vec(bytes, 0)
.map(|face| Self { face })
.map_err(|e| e.to_string())
}
pub fn lookup_glyph_index(&self, character: char) -> u16 {
self.face
.as_face_ref()
.glyph_index(character)
.map_or(0, |id| id.0)
}
pub fn horizontal_line_metrics(&self, size: f32) -> Option<LineMetrics> {
let face = self.face.as_face_ref();
let scale = size / f32::from(face.units_per_em());
Some(LineMetrics {
ascent: f32::from(face.ascender()) * scale,
descent: f32::from(face.descender()) * scale,
})
}
pub fn metrics(&self, character: char, size: f32) -> Metrics {
let face = self.face.as_face_ref();
let glyph = GlyphId(self.lookup_glyph_index(character));
Metrics {
advance_width: f32::from(face.glyph_hor_advance(glyph).unwrap_or(0)) * size
/ f32::from(face.units_per_em()),
}
}
pub fn horizontal_kern(&self, left: char, right: char, size: f32) -> Option<f32> {
let face = self.face.as_face_ref();
let left = face.glyph_index(left)?;
let right = face.glyph_index(right)?;
for table in face.tables().kern?.subtables {
if table.horizontal
&& !table.has_cross_stream
&& let Some(value) = table.glyphs_kerning(left, right)
{
return Some(f32::from(value) * size / f32::from(face.units_per_em()));
}
}
None
}
pub fn geometry(&self, character: char) -> Result<Vec<Vertex>, String> {
let face = self.face.as_face_ref();
let mut outline = GlyphOutline {
builder: Path::builder(),
scale: 1. / f32::from(face.units_per_em()),
open: false,
};
let glyph = GlyphId(self.lookup_glyph_index(character));
if face.outline_glyph(glyph, &mut outline).is_none() {
return Ok(Vec::new());
}
if outline.open {
outline.builder.end(true);
}
// Flatten at subpixel precision for glyphs up to 256 physical px/em,
// covering the current 1080p and 4K typography with the same geometry.
geometry::tessellate(&outline.builder.build(), 0.15 / 256., FillRule::NonZero)
}
}
struct GlyphOutline {
builder: Builder,
scale: f32,
open: bool,
}
impl OutlineBuilder for GlyphOutline {
fn move_to(&mut self, x: f32, y: f32) {
if self.open {
self.builder.end(true);
}
self.builder.begin(point(x * self.scale, -y * self.scale));
self.open = true;
}
fn line_to(&mut self, x: f32, y: f32) {
self.builder.line_to(point(x * self.scale, -y * self.scale));
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.builder.quadratic_bezier_to(
point(x1 * self.scale, -y1 * self.scale),
point(x * self.scale, -y * self.scale),
);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.builder.cubic_bezier_to(
point(x1 * self.scale, -y1 * self.scale),
point(x2 * self.scale, -y2 * self.scale),
point(x * self.scale, -y * self.scale),
);
}
fn close(&mut self) {
if self.open {
self.builder.end(true);
self.open = false;
}
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Bundled English, German, Spanish, Chinese, Brazilian Portuguese and Italian message catalog.
use serde::{Deserialize, Deserializer, Serialize};
use std::{collections::BTreeMap, sync::OnceLock};
use time::OffsetDateTime;
type Messages = BTreeMap<String, BTreeMap<String, String>>;
fn messages() -> &'static Messages {
static CATALOG: OnceLock<Messages> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str(include_str!("../assets/messages.json"))
.expect("Bundled translations must be valid")
})
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
#[default]
En,
De,
Zh,
Es,
Pt,
It,
}
impl Language {
pub const ALL: [Self; 6] = [Self::En, Self::De, Self::Zh, Self::Es, Self::Pt, Self::It];
pub fn code(self) -> &'static str {
match self {
Self::En => "en",
Self::De => "de",
Self::Zh => "zh",
Self::Es => "es",
Self::Pt => "pt",
Self::It => "it",
}
}
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::De => "Deutsch",
Self::Zh => "简体中文",
Self::Es => "Español",
Self::Pt => "Português (Brasil)",
Self::It => "Italiano",
}
}
pub fn index(self) -> usize {
match self {
Self::En => 0,
Self::De => 1,
Self::Zh => 2,
Self::Es => 3,
Self::Pt => 4,
Self::It => 5,
}
}
pub fn t(self, key: &str) -> &str {
messages()
.get(key)
.and_then(|entry| entry.get(self.code()).or_else(|| entry.get("en")))
.map(String::as_str)
.unwrap_or(key)
}
pub fn format(self, key: &str, values: &[(&str, &str)]) -> String {
let mut value = self.t(key).to_owned();
for (name, replacement) in values {
value = value.replace(&format!("{{{name}}}"), replacement);
}
value
}
pub fn category(self, value: &str) -> &str {
match value {
"Streaming" => self.t("categoryStreaming"),
"Music" => self.t("categoryMusic"),
"Gaming" => self.t("categoryGaming"),
"Utilities" => self.t("categoryUtilities"),
_ => self.t("categoryUtilities"),
}
}
pub fn wallpaper(self, id: &str) -> &str {
self.t(match id {
"dunes" => "monumentValley",
"san-francisco" => "sanFrancisco",
"northern-lights" => "northernLights",
_ => id,
})
}
pub fn greeting(self, hour: u8, name: &str) -> String {
let key = match hour {
0..=4 => "greetingNight",
5..=11 => "morning",
12..=17 => "afternoon",
_ => "evening",
};
let name = name.trim();
if name.is_empty() {
self.t(key).to_owned()
} else {
self.format(&format!("{key}Named"), &[("name", name)])
}
}
pub fn date(self, now: OffsetDateTime) -> String {
let weekday = now.weekday().number_days_from_monday() as usize;
let month = now.month() as usize - 1;
match self {
Self::En => format!(
"{}, {} {}",
[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
][weekday],
[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
][month],
now.day()
)
.to_uppercase(),
Self::De => format!(
"{}, {}. {}",
[
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
"Sonntag"
][weekday],
now.day(),
[
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember"
][month]
)
.to_uppercase(),
Self::Zh => format!(
"{}月{}日星期{}",
now.month() as u8,
now.day(),
["一", "二", "三", "四", "五", "六", "日"][weekday]
),
Self::Es => format!(
"{}, {} de {}",
[
"lunes",
"martes",
"miércoles",
"jueves",
"viernes",
"sábado",
"domingo"
][weekday],
now.day(),
[
"enero",
"febrero",
"marzo",
"abril",
"mayo",
"junio",
"julio",
"agosto",
"septiembre",
"octubre",
"noviembre",
"diciembre"
][month]
)
.to_uppercase(),
Self::Pt => format!(
"{}, {} de {}",
[
"segunda-feira",
"terça-feira",
"quarta-feira",
"quinta-feira",
"sexta-feira",
"sábado",
"domingo"
][weekday],
now.day(),
[
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro"
][month]
)
.to_uppercase(),
Self::It => format!(
"{} {} {}",
[
"lunedì",
"martedì",
"mercoledì",
"giovedì",
"venerdì",
"sabato",
"domenica"
][weekday],
now.day(),
[
"gennaio",
"febbraio",
"marzo",
"aprile",
"maggio",
"giugno",
"luglio",
"agosto",
"settembre",
"ottobre",
"novembre",
"dicembre"
][month]
)
.to_uppercase(),
}
}
pub fn clock(self, now: OffsetDateTime, hour24: bool, seconds: bool) -> String {
let base = self.clock_base(now, hour24);
if !seconds {
return base;
}
let tail = format!(":{:02}", now.second());
match base.find(' ') {
Some(space) => format!("{}{tail}{}", &base[..space], &base[space..]),
None => format!("{base}{tail}"),
}
}
fn clock_base(self, now: OffsetDateTime, hour24: bool) -> String {
if hour24 {
return if self == Self::En {
format!("{:02}:{:02}", now.hour(), now.minute())
} else {
format!("{}:{:02}", now.hour(), now.minute())
};
}
let hour = match now.hour() % 12 {
0 => 12,
value => value,
};
if self == Self::Zh {
format!(
"{}{hour}:{:02}",
if now.hour() >= 12 { "下午" } else { "上午" },
now.minute()
)
} else {
format!(
"{hour}:{:02} {}",
now.minute(),
if now.hour() >= 12 { "PM" } else { "AM" }
)
}
}
}
pub fn deserialize_language<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Language, D::Error> {
Ok(
match serde_json::Value::deserialize(deserializer)?.as_str() {
Some("de") => Language::De,
Some("zh") => Language::Zh,
Some("es") => Language::Es,
Some("pt") => Language::Pt,
Some("it") => Language::It,
_ => Language::En,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_message_has_all_languages_and_no_empty_values() {
for (key, entry) in messages() {
for language in Language::ALL {
assert!(
!entry
.get(language.code())
.unwrap_or_else(|| panic!("Missing {key} / {}", language.code()))
.is_empty()
);
}
}
}
#[test]
fn every_translation_preserves_the_english_message_placeholders() {
fn placeholders(message: &str) -> std::collections::BTreeSet<&str> {
message
.split('{')
.skip(1)
.map(|part| {
part.split_once('}')
.expect("Unclosed message placeholder")
.0
})
.collect()
}
for (key, entry) in messages() {
let expected = placeholders(&entry["en"]);
for language in Language::ALL {
assert_eq!(
placeholders(&entry[language.code()]),
expected,
"Mismatched placeholders in {key} / {}",
language.code()
);
}
}
}
#[test]
fn localization_keeps_user_provided_names_intact() {
assert_eq!(
Language::De.format("favoriteAdded", &[("app", "Netflix")]),
"Netflix zu Favoriten hinzugefügt."
);
assert_eq!(
Language::Es.format("favoriteAdded", &[("app", "Netflix")]),
"Netflix añadida a favoritos."
);
assert_eq!(
Language::Pt.format("favoriteAdded", &[("app", "Netflix")]),
"Netflix adicionado aos favoritos."
);
assert_eq!(
Language::Zh.format("hidden", &[("count", "3")]),
"已隐藏(3)"
);
assert_eq!(Language::De.category("Gaming"), "Spiele");
assert_eq!(Language::En.greeting(15, ""), "Good afternoon.");
assert_eq!(Language::En.greeting(15, " Ana "), "Good afternoon, Ana.");
assert_eq!(Language::Es.greeting(2, "Ana"), "¿Aún despiertos, Ana?");
assert_eq!(Language::Zh.greeting(20, "小明"), "小明,晚上好。");
}
#[test]
fn clock_and_date_follow_the_selected_locale() {
let now = OffsetDateTime::parse(
"2026-10-21T20:41:00+02:00",
&time::format_description::well_known::Rfc3339,
)
.unwrap();
assert_eq!(Language::De.date(now), "MITTWOCH, 21. OKTOBER");
assert_eq!(Language::Es.date(now), "MIÉRCOLES, 21 DE OCTUBRE");
assert_eq!(Language::Pt.date(now), "QUARTA-FEIRA, 21 DE OUTUBRO");
assert_eq!(Language::Zh.date(now), "10月21日星期三");
assert_eq!(Language::Zh.clock(now, false, false), "下午8:41");
assert_eq!(Language::Zh.clock(now, false, true), "下午8:41:00");
assert_eq!(Language::En.clock(now, true, false), "20:41");
assert_eq!(Language::En.clock(now, true, true), "20:41:00");
assert_eq!(Language::En.clock(now, false, true), "8:41:00 PM");
}
#[test]
fn portuguese_dates_include_accented_names_and_handle_leap_days() {
for (date, expected) in [
("2024-02-29T00:05:00-03:00", "QUINTA-FEIRA, 29 DE FEVEREIRO"),
("2026-03-03T12:00:00-03:00", "TERÇA-FEIRA, 3 DE MARÇO"),
("2026-10-24T20:41:00-03:00", "SÁBADO, 24 DE OUTUBRO"),
] {
let now = OffsetDateTime::parse(date, &time::format_description::well_known::Rfc3339)
.unwrap();
assert_eq!(Language::Pt.date(now), expected);
}
}
}
+210
View File
@@ -0,0 +1,210 @@
//! One bounded LS2 round trip on the platform worker's own GLib context.
//! The hub sees the installed native executable. LSCallFromApplication (the
//! luna-send-pub -a path) instead requires an impersonation privilege we lack.
use libc::{c_char, c_int, c_ulong, c_void};
use serde_json::Value;
use std::{
ffi::{CStr, CString},
ptr, thread,
time::{Duration, Instant},
};
const MAX_REPLY: usize = 2 * 1024 * 1024;
// Layout from the NDK's public luna-service2/lunaservice.h.
#[repr(C)]
struct Error {
code: c_int,
message: *mut c_char,
file: *const c_char,
line: c_int,
function: *const c_char,
padding: *mut c_void,
magic: c_ulong,
}
#[link(name = "luna-service2")]
unsafe extern "C" {
fn LSErrorInit(error: *mut Error) -> bool;
fn LSErrorFree(error: *mut Error);
fn LSRegister(name: *const c_char, handle: *mut *mut c_void, error: *mut Error) -> bool;
fn LSUnregister(handle: *mut c_void, error: *mut Error) -> bool;
fn LSGmainContextAttach(handle: *mut c_void, context: *mut c_void, error: *mut Error) -> bool;
fn LSCallOneReply(
handle: *mut c_void,
uri: *const c_char,
payload: *const c_char,
callback: extern "C" fn(*mut c_void, *mut c_void, *mut c_void) -> bool,
userdata: *mut c_void,
token: *mut c_ulong,
error: *mut Error,
) -> bool;
fn LSCallCancel(handle: *mut c_void, token: c_ulong, error: *mut Error) -> bool;
fn LSMessageGetPayload(message: *mut c_void) -> *const c_char;
}
#[link(name = "glib-2.0")]
unsafe extern "C" {
fn g_main_context_new() -> *mut c_void;
fn g_main_context_iteration(context: *mut c_void, may_block: c_int) -> c_int;
fn g_main_context_unref(context: *mut c_void);
}
impl Error {
fn new() -> Self {
// SAFETY: the public error structure is zero-initializable; LS2 then
// initializes its cookie before any other error API uses it.
unsafe {
let mut error = std::mem::zeroed();
LSErrorInit(&mut error);
error
}
}
fn describe(&self, stage: &str) -> String {
let detail = if self.message.is_null() {
String::new()
} else {
// SAFETY: LS2 owns this NUL-terminated string until LSErrorFree.
unsafe { CStr::from_ptr(self.message) }
.to_string_lossy()
.chars()
.take(240)
.collect()
};
format!("LS2 {stage} failed ({}): {detail}", self.code)
}
}
impl Drop for Error {
fn drop(&mut self) {
// SAFETY: every Error was initialized and is freed exactly once.
unsafe { LSErrorFree(self) };
}
}
#[derive(Default)]
struct Reply {
result: Option<Result<Value, String>>,
}
extern "C" fn receive(_handle: *mut c_void, message: *mut c_void, userdata: *mut c_void) -> bool {
if message.is_null() || userdata.is_null() {
return true;
}
// SAFETY: LS2 invokes this only while this connection's context is iterated
// on the worker. The heap slot outlives cancellation/unregistration.
unsafe {
let reply = &mut *userdata.cast::<Reply>();
let payload = LSMessageGetPayload(message);
reply.result = Some(if payload.is_null() {
Err("Luna reply has no payload".into())
} else {
let size = libc::strnlen(payload, MAX_REPLY + 1);
if size > MAX_REPLY {
Err("TV response is too large".into())
} else {
let bytes = std::slice::from_raw_parts(payload.cast::<u8>(), size);
serde_json::from_slice(bytes).map_err(|error| format!("Invalid Luna JSON: {error}"))
}
});
}
true
}
struct Connection {
handle: *mut c_void,
context: *mut c_void,
reply: Option<Box<Reply>>,
}
impl Connection {
fn open() -> Result<Self, String> {
let mut error = Error::new();
let mut connection = Self {
handle: ptr::null_mut(),
// SAFETY: GLib returns a new context owned by this connection.
context: unsafe { g_main_context_new() },
reply: None,
};
if connection.context.is_null() {
return Err("Could not allocate a Luna event context".into());
}
// Anonymous registration still authenticates the executable and its LS2
// role, and avoids colliding with SDL's own app-named registration.
// SAFETY: output/error pointers are valid; no fake application ID is sent.
if !unsafe { LSRegister(ptr::null(), &mut connection.handle, &mut error) } {
return Err(error.describe("registration"));
}
// SAFETY: the registered handle and private context stay alive together.
if !unsafe { LSGmainContextAttach(connection.handle, connection.context, &mut error) } {
return Err(error.describe("context attachment"));
}
Ok(connection)
}
fn request(&mut self, uri: &str, payload: &Value) -> Result<Value, String> {
let uri = CString::new(uri).map_err(|_| "Invalid Luna URI")?;
let payload = CString::new(payload.to_string()).map_err(|_| "Invalid Luna payload")?;
let mut error = Error::new();
let mut reply = Box::<Reply>::default();
let userdata = ptr::from_mut(reply.as_mut()).cast();
self.reply = Some(reply);
let mut token = 0;
// SAFETY: strings and the stable reply allocation outlive this request.
if !unsafe {
LSCallOneReply(
self.handle,
uri.as_ptr(),
payload.as_ptr(),
receive,
userdata,
&mut token,
&mut error,
)
} {
return Err(error.describe("call submission"));
}
let deadline = Instant::now() + Duration::from_millis(6500);
while Instant::now() < deadline {
// SAFETY: only this worker drives this private context. Iteration is
// nonblocking, so a missing service cannot hold the UI or this loop.
unsafe { g_main_context_iteration(self.context, 0) };
if let Some(result) = self.reply.as_mut().and_then(|reply| reply.result.take()) {
return result;
}
thread::sleep(Duration::from_millis(2));
}
let mut cancel_error = Error::new();
// SAFETY: handle/token belong to this live request. Even a refused cancel
// leaves its callback allocation alive until unregistering on drop.
if !unsafe { LSCallCancel(self.handle, token, &mut cancel_error) } {
eprintln!("{}", cancel_error.describe("timeout cancellation"));
}
Err("The TV service timed out".into())
}
}
impl Drop for Connection {
fn drop(&mut self) {
if !self.handle.is_null() {
let mut error = Error::new();
// SAFETY: unregister before freeing the context or callback storage.
if !unsafe { LSUnregister(self.handle, &mut error) } {
eprintln!("{}", error.describe("unregistration"));
// A refused unregister can retain the callback. Preserve its
// context and heap slot rather than leave dangling pointers.
std::mem::forget(self.reply.take());
return;
}
}
if !self.context.is_null() {
// SAFETY: no live LS2 connection can now dispatch on this context.
unsafe { g_main_context_unref(self.context) };
}
}
}
pub fn call(uri: &str, payload: &Value) -> Result<Value, String> {
Connection::open()?.request(uri, payload)
}
+1129
View File
File diff suppressed because it is too large. Load diff
+295
View File
@@ -0,0 +1,295 @@
use crate::i18n::Language;
use crate::weather::{Location, Unit};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
pub const CATEGORIES: [&str; 4] = ["Streaming", "Music", "Gaming", "Utilities"];
pub const BACKGROUNDS: [(&str, &str); 9] = [
("slate", "Slate"),
("midnight", "Midnight"),
("prism", "Prism"),
("dunes", "Monument Valley"),
("san-francisco", "San Francisco"),
("summit", "Summit"),
("forest", "Forest"),
("yosemite", "Yosemite"),
("northern-lights", "Northern lights"),
];
// Generic fallbacks use delimited words, so "game" does not match "endgame".
// Non-ASCII bytes count as boundaries for localized titles. Curated vector
// branding is selected separately by exact ID in branding.rs.
fn contains_word(haystack: &str, needle: &str) -> bool {
let bytes = haystack.as_bytes();
haystack.match_indices(needle).any(|(start, matched)| {
let end = start + matched.len();
let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric();
before_ok && after_ok
})
}
#[derive(Clone, Debug, Serialize)]
pub struct App {
pub id: String,
pub title: String,
pub brand: String,
pub category: String,
#[serde(skip)]
pub icon: Option<crate::app_artwork::Source>,
}
impl App {
pub fn new(id: &str, title: &str) -> Self {
let identity = format!("{id} {title}").to_lowercase();
let generic = [
("kodi", "kodi", "Streaming"),
("moonlight", "moonlight", "Gaming"),
("limelight", "moonlight", "Gaming"),
("game", "gaming", "Gaming"),
("hbchannel", "homebrew", "Utilities"),
]
.into_iter()
.find_map(|(needle, brand, category)| {
contains_word(&identity, needle).then_some((brand, category))
})
.unwrap_or(("generic", "Utilities"));
let (brand, category) =
crate::branding::for_app(id).map_or(generic, |brand| (brand.key, brand.category));
Self {
id: id.into(),
title: title.into(),
brand: brand.into(),
category: category.into(),
icon: None,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Input {
pub id: String,
pub title: String,
pub port: String,
pub connected: Option<bool>,
}
#[derive(Clone, Debug, Default)]
pub struct Inventory {
pub apps: Vec<App>,
pub inputs: Vec<Input>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
pub enum InputMode {
#[default]
Auto,
On,
Off,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct Preferences {
pub favorites: Option<Vec<String>>,
pub hidden: Vec<String>,
pub background: String,
pub show_home_names: bool,
pub show_app_names: bool,
pub hour_24: bool,
pub reduced_motion: bool,
pub show_seconds: bool,
/// Optional name appended to the Home greeting ("Good evening, Ana."). Empty = plain greeting.
pub greeting_name: String,
pub input_modes: BTreeMap<String, InputMode>,
/// Custom names by input app ID; the TV's port and label stay visible as detail.
pub input_names: BTreeMap<String, String>,
#[serde(deserialize_with = "crate::weather::deserialize_enabled")]
pub show_weather: bool,
#[serde(deserialize_with = "crate::weather::deserialize_location")]
pub weather_location: Option<Location>,
#[serde(deserialize_with = "crate::weather::deserialize_unit")]
pub temperature_unit: Unit,
#[serde(deserialize_with = "crate::i18n::deserialize_language")]
pub language: Language,
}
impl Default for Preferences {
fn default() -> Self {
Self {
favorites: None,
hidden: vec![],
background: "dunes".into(),
show_home_names: false,
show_app_names: true,
hour_24: false,
reduced_motion: false,
show_seconds: false,
greeting_name: String::new(),
input_modes: BTreeMap::new(),
input_names: BTreeMap::new(),
show_weather: true,
weather_location: None,
temperature_unit: Unit::Celsius,
language: Language::En,
}
}
}
impl Preferences {
pub fn favorites(&self, apps: &[App]) -> Vec<String> {
self.favorites.clone().unwrap_or_else(|| {
let mut ids = Vec::new();
for brand in [
"netflix",
"youtube",
"apple-tv",
"disney-plus",
"prime-video",
"live-tv",
] {
if let Some(app) = apps.iter().find(|app| app.brand == brand) {
ids.push(app.id.clone());
}
}
for app in apps {
if ids.len() >= 6 {
break;
}
if !ids.contains(&app.id) {
ids.push(app.id.clone());
}
}
ids
})
}
pub fn input_visible(&self, input: &Input) -> bool {
match self.input_modes.get(&input.id).copied().unwrap_or_default() {
InputMode::On => true,
InputMode::Off => false,
InputMode::Auto => input.connected == Some(true),
}
}
pub fn input_name(&self, input: &Input) -> Option<&str> {
self.input_names.get(&input.id).map(String::as_str)
}
/// The custom name if one is saved, otherwise the port ("HDMI 2").
pub fn input_title<'a>(&'a self, input: &'a Input) -> &'a str {
self.input_name(input).unwrap_or(&input.port)
}
/// Saves a cleaned name; an empty name restores the port title.
pub fn set_input_name(&mut self, id: &str, name: &str) -> bool {
if !valid_app_id(id) {
return false;
}
let name = clean_input_name(name);
if name.is_empty() {
self.input_names.remove(id).is_some()
} else {
self.input_names.insert(id.into(), name.clone()) != Some(name)
}
}
pub fn sanitize(&mut self) {
if !BACKGROUNDS.iter().any(|(id, _)| *id == self.background) {
self.background = "dunes".into();
}
fn clean(ids: &mut Vec<String>) {
let mut seen = HashSet::new();
ids.retain(|id| valid_app_id(id) && seen.insert(id.clone()));
ids.truncate(1000);
}
if let Some(ids) = &mut self.favorites {
clean(ids);
}
clean(&mut self.hidden);
self.input_modes.retain(|id, _| valid_app_id(id));
self.input_names = std::mem::take(&mut self.input_names)
.into_iter()
.filter(|(id, _)| valid_app_id(id))
.map(|(id, name)| (id, clean_input_name(&name)))
.filter(|(_, name)| !name.is_empty())
.take(128)
.collect();
}
}
pub const INPUT_NAME_LIMIT: usize = 40;
pub fn clean_input_name(name: &str) -> String {
let clean: String = name.chars().filter(|c| !c.is_control()).collect();
let clean: String = clean.trim().chars().take(INPUT_NAME_LIMIT).collect();
clean.trim_end().into()
}
pub fn valid_app_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.split(['.', '_', '-'])
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric()))
}
pub fn demo_inventory() -> Inventory {
let mut inventory = Inventory {
apps: [
("com.webos.app.livetv", "Live TV"),
("netflix", "Netflix"),
("youtube.leanback.v4", "YouTube"),
("com.apple.appletv", "Apple TV"),
("com.disney.disneyplus-prod", "Disney+"),
("amazon", "Prime Video"),
("plex", "Plex"),
("spotify-beehive", "Spotify"),
("org.jellyfin.webos", "Jellyfin"),
("org.xbmc.kodi", "Kodi"),
("com.limelight.webos", "Moonlight"),
("org.webosbrew.hbchannel", "Homebrew Channel"),
("com.webos.app.browser", "Web Browser"),
("com.arte.tv", "ARTE"),
("de.zdf.app.zdfm3", "ZDF"),
("ard.mediathek", "Das Erste"),
("coupangplay", "Coupang Play"),
("com.frograms.watchaplay.webos", "Watcha Play"),
("pooq", "Wavve"),
("com.cmb.rainbowtv", "Rainbow TV"),
("com.lgtvod.kr.app", "LG TV VOD"),
("com.webos.app.igallery", "올레드 갤러리"),
("com.webos.app.roomconnect", "룸투룸 셰어"),
]
.into_iter()
.map(|(id, title)| App::new(id, title))
.collect(),
inputs: vec![
Input {
id: "com.webos.app.hdmi1".into(),
title: "PlayStation 5".into(),
port: "HDMI 1".into(),
connected: Some(true),
},
Input {
id: "com.webos.app.hdmi2".into(),
title: "Apple TV".into(),
port: "HDMI 2".into(),
connected: Some(true),
},
Input {
id: "com.webos.app.hdmi3".into(),
title: "HDMI 3".into(),
port: "HDMI 3".into(),
connected: Some(false),
},
],
};
// Desktop-only artwork gallery. TV inventory is always read from services.
for brand in crate::branding::BRANDS {
if !inventory.apps.iter().any(|app| app.brand == brand.key) {
inventory.apps.push(App::new(brand.ids[0], brand.title));
}
}
inventory
}
+425
View File
@@ -0,0 +1,425 @@
//! Blocking platform work runs on one worker, never in the SDL/UI thread.
//! TV calls originate from this native executable's LS2 connection, not a CLI
//! process trying to impersonate its application identity.
use crate::{
app_artwork,
model::{App, Input, Inventory, demo_inventory, valid_app_id},
};
use serde_json::{Value, json};
use std::{collections::HashSet, sync::mpsc, thread};
pub const APP_ID: &str = "com.ecklf.luma";
pub const HOME_ID: &str = "com.webos.app.home";
pub fn redirect_to_home() -> Result<bool, String> {
// The root-only integration mounts this marker with its native Home
// manifest. A normal tile launch then opens that same instance instead of
// starting a second renderer writing the same preferences.
if std::fs::read_to_string("/usr/palm/applications/com.webos.app.home/.luma-home")
.is_ok_and(|marker| marker.trim() == APP_ID)
{
thread::spawn(|| {
call(
"luna://com.webos.applicationManager/launch",
json!({"id": HOME_ID}),
)
})
.join()
.map_err(|_| "Home launch worker failed".to_string())??;
return Ok(true);
}
Ok(false)
}
pub enum Request {
Refresh,
Launch(String),
SyncSplash(String),
}
pub enum Response {
Inventory(Result<(Inventory, Option<String>), String>),
Launched(Result<(), String>),
}
pub struct Platform {
sender: mpsc::Sender<Request>,
pub receiver: mpsc::Receiver<Response>,
pub webos: bool,
splash_enabled: bool,
}
impl Platform {
pub fn new(webos: bool, splash_enabled: bool) -> Self {
let (sender, requests) = mpsc::channel();
let (responses, receiver) = mpsc::channel();
thread::spawn(move || {
let mut splash_background = None;
while let Ok(request) = requests.recv() {
let response = match request {
Request::Refresh => Response::Inventory(if webos {
inventory()
} else {
Ok((demo_inventory(), None))
}),
Request::Launch(id) => Response::Launched(if webos {
if crate::scroll_preview::is_placeholder(&id) {
Err("Scroll preview cards cannot be launched".into())
} else if !valid_app_id(&id) {
Err("Invalid application identifier".into())
} else {
call(
"luna://com.webos.applicationManager/launch",
json!({"id": id}),
)
.map(|_| ())
}
} else {
Ok(())
}),
Request::SyncSplash(background) => {
if webos
&& splash_enabled
&& splash_background.as_ref() != Some(&background)
{
match sync_splash(&background) {
Ok(()) => splash_background = Some(background),
Err(error) => eprintln!("Luma splash: {error}"),
}
}
continue;
}
};
if responses.send(response).is_err() {
break;
}
}
});
Self {
sender,
receiver,
webos,
splash_enabled,
}
}
pub fn send(&self, request: Request) {
let _ = self.sender.send(request);
}
pub fn sync_splash(&self, background: &str) {
if self.webos && self.splash_enabled {
self.send(Request::SyncSplash(background.to_owned()));
}
}
}
fn sync_splash(background: &str) -> Result<(), String> {
let executable = std::env::current_exe().map_err(|e| e.to_string())?;
let directory = executable
.parent()
.ok_or("Missing installation directory")?;
let quote = |value: &str| format!("'{}'", value.replace('\'', "'\\''"));
let command = format!(
"/usr/bin/node -e {} {}",
quote(include_str!("../tv/sync-splash.cjs")),
quote(&json!({"directory": directory, "background": background}).to_string())
);
let envelope = call(
"luna://org.webosbrew.hbchannel.service/exec",
json!({"command": command}),
)?;
let saved = inventory_payload(&envelope, "background")?;
if saved.as_str() != Some(background) {
return Err("Splash background was not updated".into());
}
Ok(())
}
fn call(uri: &str, payload: Value) -> Result<Value, String> {
#[cfg(all(feature = "webos", target_os = "linux", target_arch = "arm"))]
let response = crate::luna::call(uri, &payload);
#[cfg(not(all(feature = "webos", target_os = "linux", target_arch = "arm")))]
let response: Result<Value, String> = {
let _ = payload;
Err("Native Luna services are only available in an ARM webOS build".into())
};
response.and_then(checked_response).map_err(|error| {
let error = format!("{uri}: {error}");
eprintln!("Luma TV service: {error}");
error
})
}
fn checked_response(value: Value) -> Result<Value, String> {
if value.get("returnValue").and_then(Value::as_bool) != Some(true) {
return Err(value
.get("errorText")
.and_then(Value::as_str)
.unwrap_or("The TV denied this action")
.chars()
.take(240)
.collect());
}
Ok(value)
}
fn inventory_read(uri: &str, field: &str) -> Result<Value, String> {
// Only these fixed inventory commands use Homebrew's elevated service.
// Use the same one-reply invocation as the web launcher, without guessing
// CLI behavior from a firmware-version string.
let command = format!(
"if [ \"$(id -u)\" = 0 ]; then /usr/bin/luna-send -n 1 -w 4000 {uri} '{{}}'; else printf '%s\\n' '{{\"returnValue\":false,\"errorText\":\"Homebrew Channel needs root access\"}}'; fi"
);
let envelope = call(
"luna://org.webosbrew.hbchannel.service/exec",
json!({"command": command}),
)?;
inventory_payload(&envelope, field).map_err(|error| {
let error = format!("{uri}: {error}");
eprintln!("Luma TV inventory: {error}");
error
})
}
pub fn read_app_icons(source: &app_artwork::Source) -> Result<Vec<Vec<u8>>, String> {
if !cfg!(feature = "webos") {
return Err("Installed artwork is only read on TV builds".into());
}
let quote = |value: &str| format!("'{}'", value.replace('\'', "'\\''"));
let command = format!(
"/usr/bin/node -e {} {}",
quote(include_str!("../tv/read-app-icon.cjs")),
quote(&serde_json::to_string(source).map_err(|e| e.to_string())?)
);
let envelope = call(
"luna://org.webosbrew.hbchannel.service/exec",
json!({"command": command}),
)?;
let icons = inventory_payload(&envelope, "icons")?;
let mut remaining = app_artwork::MAX_FILE;
icons
.as_array()
.ok_or("Invalid installed icon reply")?
.iter()
.map(|value| {
let hex = value.as_str().ok_or("Invalid installed icon data")?;
if hex.len() % 2 != 0 || hex.len() / 2 > remaining {
return Err("Installed icon reply exceeds its size limit".into());
}
remaining -= hex.len() / 2;
hex.as_bytes()
.chunks_exact(2)
.map(|pair| {
let digit = |b: u8| {
(b as char)
.to_digit(16)
.ok_or("Invalid installed icon encoding")
};
Ok((digit(pair[0])? * 16 + digit(pair[1])?) as u8)
})
.collect()
})
.collect()
}
fn output_excerpt(output: &str) -> String {
output
.chars()
.map(|c| if c.is_whitespace() { ' ' } else { c })
.filter(|c| !c.is_control())
.take(320)
.collect()
}
fn inventory_payload(envelope: &Value, field: &str) -> Result<Value, String> {
let stdout = envelope
.get("stdoutString")
.and_then(Value::as_str)
.unwrap_or("");
let stderr = envelope
.get("stderrString")
.and_then(Value::as_str)
.unwrap_or("");
let failure = |reason: &str| {
format!(
"{reason}; stdout: {}; stderr: {}",
output_excerpt(stdout),
output_excerpt(stderr),
)
};
for key in ["returnCode", "exitCode"] {
if let Some(code) = envelope.get(key)
&& code != 0
&& code != "0"
{
return Err(failure(&format!(
"Homebrew inventory command failed ({key}={code})"
)));
}
}
if stdout.trim().is_empty() {
return Err(failure("Homebrew returned no inventory"));
}
if stdout.len() > 2 * 1024 * 1024 {
return Err("Homebrew inventory exceeds the 2 MiB limit".into());
}
// Parse an entire JSON object, including pretty-printed multi-line replies.
// A stream parser permits CLI timing text after that object; a preamble can
// precede its opening brace. Never mistake a nested one-line app for a reply.
let start = stdout
.find('{')
.ok_or_else(|| failure("Homebrew inventory contains no JSON object"))?;
let response = serde_json::Deserializer::from_str(&stdout[start..])
.into_iter::<Value>()
.next()
.ok_or_else(|| failure("Homebrew inventory contains no JSON reply"))?
.map_err(|error| {
failure(&format!(
"Homebrew inventory JSON could not be parsed: {error}"
))
})?;
if !response.is_object() || !response.get("returnValue").is_some_and(Value::is_boolean) {
return Err(failure("Homebrew inventory JSON is not a Luna response"));
}
let response = checked_response(response)?;
response
.get(field)
.cloned()
.ok_or_else(|| format!("Missing TV inventory field: {field}"))
}
fn inventory() -> Result<(Inventory, Option<String>), String> {
let apps = normalize_apps(&inventory_read(
"luna://com.webos.applicationManager/listApps",
"apps",
)?)?;
let inputs = inventory_read("luna://com.webos.service.eim/getAllInputStatus", "devices")
.and_then(|raw| normalize_inputs(&raw));
match inputs {
Ok(inputs) => {
eprintln!(
"Luma TV inventory: {} apps, {} inputs",
apps.len(),
inputs.len()
);
Ok((Inventory { apps, inputs }, None))
}
Err(error) => Ok((
Inventory {
apps,
inputs: vec![],
},
Some(format!("Inputs could not be refreshed: {error}")),
)),
}
}
fn title(value: Option<&str>, fallback: &str) -> String {
let clean: String = value
.unwrap_or("")
.chars()
.filter(|c| !c.is_control())
.take(120)
.collect();
if clean.trim().is_empty() {
fallback.into()
} else {
clean.trim().into()
}
}
pub fn normalize_apps(raw: &Value) -> Result<Vec<App>, String> {
let entries = raw
.as_array()
.filter(|a| a.len() <= 1000)
.ok_or("Invalid app inventory")?;
let mut seen = HashSet::new();
let mut apps = Vec::new();
for entry in entries {
let Some(id) = entry
.get("id")
.and_then(Value::as_str)
.filter(|id| valid_app_id(id))
else {
continue;
};
let live = id == "com.webos.app.livetv";
let visible = entry.get("visible");
if id == APP_ID
|| id == "com.luma.native"
|| id == "com.luma.launcher"
|| id == "com.luma.launcher.native"
|| id == "com.webos.app.home"
|| id.starts_with("com.webos.app.hdmi")
|| id.starts_with("com.webos.app.externalinput.")
|| (!live && (visible == Some(&json!(false)) || visible == Some(&json!("false"))))
|| (!live
&& ["com.webos.", "com.palm.", "com.lge."]
.iter()
.any(|prefix| id.starts_with(prefix))
&& visible != Some(&json!(true)))
|| !seen.insert(id)
{
continue;
}
let mut app = App::new(id, &title(entry.get("title").and_then(Value::as_str), id));
app.icon = Some(app_artwork::Source::new(id, entry));
apps.push(app);
}
apps.sort_by_key(|a| a.title.to_lowercase());
Ok(apps)
}
pub fn normalize_inputs(raw: &Value) -> Result<Vec<Input>, String> {
let entries = raw
.as_array()
.filter(|a| a.len() <= 128)
.ok_or("Invalid input inventory")?;
let mut inputs = Vec::new();
for entry in entries {
let Some(id) = entry.get("appId").and_then(Value::as_str) else {
continue;
};
let Some(number) = id
.strip_prefix("com.webos.app.hdmi")
.and_then(|s| s.parse::<u8>().ok())
.filter(|n| *n > 0 && *n < 100)
else {
continue;
};
if id != format!("com.webos.app.hdmi{number}")
|| entries
.iter()
.filter(|e| e.get("appId").and_then(Value::as_str) == Some(id))
.count()
!= 1
{
continue;
}
if entry
.get("id")
.is_some_and(|v| v != &format!("HDMI_{number}"))
{
continue;
}
if entry
.get("port")
.is_some_and(|v| v != &json!(number) && v != &number.to_string())
{
continue;
}
let port = format!("HDMI {number}");
inputs.push(Input {
id: id.into(),
title: title(entry.get("label").and_then(Value::as_str), &port),
port,
// Some firmware reports `connected: true` for every existing port;
// `hdmiPlugIn` is the actual cable state where it is provided.
connected: entry
.get("hdmiPlugIn")
.and_then(Value::as_bool)
.or_else(|| entry.get("connected").and_then(Value::as_bool)),
});
}
Ok(inputs)
}
+97
View File
@@ -0,0 +1,97 @@
//! Logical layout is independent of drawable density. Vector coverage and
//! glyph-origin snapping use the drawable's physical pixel scale in shaders.
use serde::Serialize;
use std::str::FromStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct Size {
pub width: u32,
pub height: u32,
}
impl Size {
pub const HD: Self = Self {
width: 1920,
height: 1080,
};
pub const UHD: Self = Self {
width: 3840,
height: 2160,
};
pub const fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
pub fn scale(self) -> f32 {
(self.width as f32 / 1920.).min(self.height as f32 / 1080.)
}
pub fn is_4k(self) -> bool {
self.width >= 3840 && self.height >= 2160
}
}
impl FromStr for Size {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"1080p" => return Ok(Self::HD),
"4k" => return Ok(Self::UHD),
_ => {}
}
let (w, h) = value
.split_once('x')
.ok_or("Resolution must be 1080p, 4k, or WIDTHxHEIGHT")?;
let width = w.parse::<u32>().map_err(|e| e.to_string())?;
let height = h.parse::<u32>().map_err(|e| e.to_string())?;
if !(640..=7680).contains(&width) || !(360..=4320).contains(&height) {
return Err("Requested window must be between 640x360 and 7680x4320".into());
}
Ok(Self { width, height })
}
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct Viewport {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub scale: f32,
}
impl Viewport {
pub fn new(size: Size) -> Self {
let scale = size.scale();
let width = (1920. * scale).round() as i32;
let height = (1080. * scale).round() as i32;
Self {
x: (size.width as i32 - width) / 2,
y: (size.height as i32 - height) / 2,
width,
height,
scale,
}
}
pub fn text_scale(self) -> f32 {
self.height as f32 / 1080.
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_letterboxed_drawable_uses_its_fitted_density() {
let viewport = Viewport::new(Size::new(3840, 2400));
assert_eq!(viewport.scale, 2.);
assert_eq!(viewport.y, 120);
assert_eq!(viewport.width, 3840);
assert_eq!(viewport.height, 2160);
}
#[test]
fn requested_resolution_is_validated_separately_from_the_actual_drawable() {
assert_eq!("4k".parse::<Size>().unwrap(), Size::UHD);
assert_eq!("1280x720".parse::<Size>().unwrap(), Size::new(1280, 720));
for invalid in ["0x0", "100000x2160", "3840", "-1x1080"] {
assert!(invalid.parse::<Size>().is_err());
}
assert!(!Size::HD.is_4k());
assert!(Size::UHD.is_4k());
}
}
+1370
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
//! Explicit manual scroll preview. Never used as a TV inventory fallback.
use crate::{i18n::Language, model::Inventory};
const PREFIX: &str = "com.luma.scroll-preview.";
pub fn is_placeholder(id: &str) -> bool {
id.starts_with(PREFIX)
}
pub fn append(inventory: &mut Inventory, count: usize, language: Language) {
if count == 0 {
return;
}
let templates = inventory.apps.clone();
if templates.is_empty() {
eprintln!("Luma scroll preview: no real apps to use as artwork templates");
return;
}
let mut added = 0;
for index in 0..count {
let id = format!("{PREFIX}{index:03}");
if inventory.apps.iter().any(|app| app.id == id) {
continue;
}
let mut app = templates[index % templates.len()].clone();
app.id = id;
app.title = format!(
"{} {:03} · {}",
language.t("scrollTestApp"),
index + 1,
app.title
);
inventory.apps.push(app);
added += 1;
}
eprintln!(
"Luma scroll preview: {added} placeholder apps appended to {} real apps",
templates.len()
);
}
+32
View File
@@ -0,0 +1,32 @@
uniform sampler2D u_texture;
uniform float u_aa;
uniform vec4 u_edge_fade;
varying PAINT_PRECISION vec2 v_uv;
varying PAINT_PRECISION vec4 v_color;
varying PAINT_PRECISION vec4 v_shape;
varying PAINT_PRECISION vec2 v_params;
varying PAINT_PRECISION float v_logical_y;
float edge_coverage() {
if (u_edge_fade.y <= u_edge_fade.x) return 1.0;
return smoothstep(u_edge_fade.x, u_edge_fade.y, v_logical_y) *
(1.0 - smoothstep(u_edge_fade.z, u_edge_fade.w, v_logical_y));
}
void main() {
if (v_params.x < 0.0) {
gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, edge_coverage());
return;
}
float blur = max(-v_params.y, 0.0);
vec2 half_size = v_shape.zw - vec2(blur);
float radius = min(v_params.x, min(half_size.x, half_size.y));
vec2 q = abs(v_shape.xy) - half_size + vec2(radius);
float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius;
float alpha = 1.0 - smoothstep(-u_aa, 0.0, d);
if (blur > 0.0) {
alpha = 1.0 - smoothstep(-blur, blur, d);
}
if (v_params.y > 0.0) {
alpha *= smoothstep(-v_params.y - u_aa, -v_params.y, d);
}
gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, alpha * edge_coverage());
}
+18
View File
@@ -0,0 +1,18 @@
attribute vec2 a_position;
attribute vec2 a_uv;
attribute vec4 a_color;
attribute vec4 a_shape;
attribute vec2 a_params;
varying PAINT_PRECISION vec2 v_uv;
varying PAINT_PRECISION vec4 v_color;
varying PAINT_PRECISION vec4 v_shape;
varying PAINT_PRECISION vec2 v_params;
varying PAINT_PRECISION float v_logical_y;
void main() {
gl_Position = vec4(a_position.x / 960.0 - 1.0, 1.0 - a_position.y / 540.0, 0.0, 1.0);
v_uv = a_uv;
v_color = a_color;
v_shape = a_shape;
v_params = a_params;
v_logical_y = a_position.y;
}
+24
View File
@@ -0,0 +1,24 @@
attribute vec2 a_position;
attribute vec2 a_offset;
attribute vec4 a_origin;
attribute float a_coverage;
uniform vec4 u_transform;
uniform vec2 u_pixel_scale;
uniform vec4 u_tint;
uniform float u_fringe;
varying PAINT_PRECISION vec4 v_color;
varying PAINT_PRECISION float v_coverage;
varying PAINT_PRECISION float v_logical_y;
void main() {
// Snap each glyph's insertion origin/baseline, not individual outline
// vertices. Curves retain their shape while scrolling and changing density.
vec2 origin = a_origin.xy + u_transform.zw;
origin = floor(origin * u_pixel_scale + vec2(0.5)) / u_pixel_scale;
// With MSAA, use the original outline; inward offsets can fold thin stems.
// Single-sample targets retain a narrower analytic boundary fringe.
vec2 position = origin + a_position + a_offset * u_fringe / u_pixel_scale;
gl_Position = vec4(position.x / 960.0 - 1.0, 1.0 - position.y / 540.0, 0.0, 1.0);
v_color = u_tint;
v_coverage = mix(1.0, a_coverage, step(0.001, u_fringe));
v_logical_y = position.y;
}
+12
View File
@@ -0,0 +1,12 @@
varying PAINT_PRECISION vec4 v_color;
varying PAINT_PRECISION float v_coverage;
varying PAINT_PRECISION float v_logical_y;
uniform vec4 u_edge_fade;
void main() {
float edge = 1.0;
if (u_edge_fade.y > u_edge_fade.x) {
edge = smoothstep(u_edge_fade.x, u_edge_fade.y, v_logical_y) *
(1.0 - smoothstep(u_edge_fade.z, u_edge_fade.w, v_logical_y));
}
gl_FragColor = vec4(v_color.rgb, v_color.a * clamp(v_coverage, 0.0, 1.0) * edge);
}
+22
View File
@@ -0,0 +1,22 @@
attribute vec2 a_position;
attribute vec2 a_offset;
attribute vec4 a_color;
attribute float a_coverage;
uniform vec4 u_transform;
uniform vec2 u_pixel_scale;
uniform vec4 u_tint;
varying PAINT_PRECISION vec4 v_color;
varying PAINT_PRECISION float v_coverage;
varying PAINT_PRECISION float v_logical_y;
void main() {
vec2 position = a_position * u_transform.xy + u_transform.zw;
// Offsets are centered on the path boundary and stay half a physical pixel
// wide at every drawable density. Interior triangle edges have no fringe.
vec2 direction = a_offset / max(abs(u_transform.xy), vec2(0.000001));
vec2 offset = direction / max(length(direction), 0.000001) * length(a_offset);
position += offset / u_pixel_scale;
gl_Position = vec4(position.x / 960.0 - 1.0, 1.0 - position.y / 540.0, 0.0, 1.0);
v_color = a_color * u_tint;
v_coverage = a_coverage;
v_logical_y = position.y;
}
+1561
View File
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
use crate::model::Preferences;
use std::{
fs,
path::{Path, PathBuf},
};
pub fn default_path(webos: bool) -> Result<PathBuf, String> {
if webos {
#[cfg(all(feature = "webos", unix))]
return tv_path();
#[cfg(not(all(feature = "webos", unix)))]
return Err("TV storage requires a webOS build".into());
}
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".config")))
.ok_or("Set HOME, XDG_CONFIG_HOME or --preferences for native preferences")?;
Ok(base.join("luma-native/preferences.json"))
}
#[cfg(all(feature = "webos", unix))]
fn tv_path() -> Result<PathBuf, String> {
use std::{
io::Write,
os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
};
let executable = std::env::current_exe().map_err(|e| e.to_string())?;
let legacy = executable.with_file_name("preferences.json");
let id = crate::platform::APP_ID;
// These volumes persist across boots. The app-specific directory also
// survives replacement of the installed payload on an ordinary update.
// Production native jails expose internal storage instead of developer.
let developer = PathBuf::from("/media/developer")
.join(id)
.join("preferences.json");
let shared_developer = PathBuf::from("/media/developer/temp")
.join(id)
.join("preferences.json");
let internal = PathBuf::from("/media/internal")
.join(format!(".{id}"))
.join("preferences.json");
let mut candidates = if executable.starts_with("/media/cryptofs") {
[internal, developer, shared_developer]
} else {
[developer, shared_developer, internal]
};
// Keep a previously used destination even if another candidate becomes
// writable after a reboot; do not accidentally restart with fresh settings.
candidates.sort_by_key(|path| !path.exists());
let mut failures = Vec::new();
for path in candidates {
let directory = path.parent().ok_or("Invalid TV storage directory")?;
// Create only this app's leaf, not a missing system mount hierarchy.
let created = fs::DirBuilder::new().mode(0o700).create(directory);
if let Err(error) = created
&& error.kind() != std::io::ErrorKind::AlreadyExists
{
failures.push(format!("{}: {error}", directory.display()));
continue;
}
let metadata = match fs::symlink_metadata(directory) {
Ok(metadata) => metadata,
Err(error) => {
failures.push(format!("{}: {error}", directory.display()));
continue;
}
};
// Do not silently reuse a symlink or root-created debug directory as
// another process's private app storage.
if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } {
failures.push(format!(
"{} is not owned by the app user",
directory.display()
));
continue;
}
let probe = directory.join(format!(".write-probe-{}", std::process::id()));
let writable = (|| {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&probe)?;
let result = file
.write_all(b"Luma storage probe")
.and_then(|_| file.sync_all());
drop(file);
// Only remove a probe that this process created successfully.
let cleanup = fs::remove_file(&probe);
result.and(cleanup)
})();
if let Err(error) = writable {
failures.push(format!("{}: {error}", directory.display()));
continue;
}
if !path.exists() {
// Import JSON only from the old path in THIS installation, never
// browser storage or another app ID's preferences.
match fs::read(&legacy) {
Ok(bytes) => {
let mut preferences: Preferences =
serde_json::from_slice(&bytes).map_err(|e| {
format!(
"Existing preferences {} could not be read: {e}",
legacy.display()
)
})?;
preferences.sanitize();
save(&path, &preferences)?;
eprintln!(
"Luma preferences migrated from {} to {}",
legacy.display(),
path.display()
);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"Existing preferences {} could not be read: {error}",
legacy.display()
));
}
}
let old_cache = crate::weather_service::cache_path(&legacy);
let cache = crate::weather_service::cache_path(&path);
if !cache.exists()
&& let Some(reading) = crate::weather_service::load_cache(&old_cache)
&& let Err(error) = crate::weather_service::save_cache(&cache, &reading)
{
eprintln!("Luma weather cache migration: {error}");
}
}
eprintln!("Luma persistent storage: {}", directory.display());
return Ok(path);
}
Err(format!(
"No writable persistent TV storage found: {}",
failures.join("; ")
))
}
pub fn load(path: &Path) -> Result<Preferences, String> {
match fs::read(path) {
Ok(bytes) => {
let mut preferences: Preferences = serde_json::from_slice(&bytes)
.map_err(|e| format!("Preferences could not be read: {e}"))?;
preferences.sanitize();
Ok(preferences)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Preferences::default()),
Err(e) => Err(format!("Preferences could not be read: {e}")),
}
}
pub fn save(path: &Path, preferences: &Preferences) -> Result<(), String> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Could not prepare preferences directory {}: {e}",
parent.display()
)
})?;
}
let temporary = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(preferences)
.map_err(|e| format!("Could not encode preferences: {e}"))?;
fs::write(&temporary, bytes).map_err(|e| {
format!(
"Could not write preferences temporary file {}: {e}",
temporary.display()
)
})?;
fs::rename(&temporary, path)
.map_err(|e| format!("Could not replace preferences file {}: {e}", path.display()))
}
+1238
View File
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
//! Precompiled SVG triangle geometry, uploaded once without image textures.
use gl::types::GLuint;
use std::{fs, path::Path};
pub struct Mesh {
pub buffer: GLuint,
pub width: f32,
pub height: f32,
pub vertices: i32,
pub bytes: usize,
}
impl Mesh {
pub fn load(path: &Path) -> Result<Self, String> {
let bytes =
fs::read(path).map_err(|e| format!("Vector artwork {}: {e}", path.display()))?;
if bytes.len() < 20 || &bytes[..8] != b"LUMAVEC1" {
return Err(format!("Invalid vector artwork header: {}", path.display()));
}
let float = |offset| f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
let width = float(8);
let height = float(12);
let count = u32::from_le_bytes(bytes[16..20].try_into().unwrap()) as usize;
if !width.is_finite()
|| !height.is_finite()
|| width <= 0.
|| height <= 0.
|| count == 0
|| count > 1_000_000
|| !count.is_multiple_of(3)
|| bytes.len() != 20 + count * 36
{
return Err(format!(
"Invalid vector artwork dimensions: {}",
path.display()
));
}
let vertices: Vec<f32> = bytes[20..]
.chunks_exact(4)
.map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap()))
.collect();
if vertices.iter().any(|value| !value.is_finite()) {
return Err(format!(
"Invalid vector artwork coordinates: {}",
path.display()
));
}
Self::upload(width, height, &vertices)
}
pub fn upload(width: f32, height: f32, vertices: &[f32]) -> Result<Self, String> {
let count = vertices.len() / 9;
if count == 0 || !count.is_multiple_of(3) || vertices.len() != count * 9 {
return Err("Invalid vector buffer vertex count".into());
}
if vertices.iter().any(|value| !value.is_finite()) {
return Err("Invalid vector buffer coordinates".into());
}
let mut buffer = 0;
unsafe {
gl::GenBuffers(1, &mut buffer);
gl::BindBuffer(gl::ARRAY_BUFFER, buffer);
gl::BufferData(
gl::ARRAY_BUFFER,
std::mem::size_of_val(vertices) as isize,
vertices.as_ptr().cast(),
gl::STATIC_DRAW,
);
}
Ok(Self {
buffer,
width,
height,
vertices: count as i32,
bytes: count * 36,
})
}
}
impl Drop for Mesh {
fn drop(&mut self) {
unsafe { gl::DeleteBuffers(1, &self.buffer) };
}
}
+2635
View File
File diff suppressed because it is too large. Load diff
+354
View File
@@ -0,0 +1,354 @@
//! Dedicated latest-job HTTP workers and an ordered cache writer. No request,
//! DNS/TLS work or cache write blocks the SDL thread.
use crate::i18n::Language;
use crate::weather::{Cache, Command, Location, Reading, parse_locations, parse_reading};
use serde_json::Value;
use std::{
fs,
io::Read,
net::SocketAddr,
path::{Path, PathBuf},
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicU64, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::Duration,
};
const LIMIT: usize = 256 * 1024;
#[derive(Clone)]
pub struct Client {
agent: ureq::Agent,
geocoding: String,
forecast: String,
}
impl Client {
pub fn new(test_server: Option<&str>) -> Result<Self, String> {
let (geocoding, forecast) = if let Some(server) = test_server {
let uri: ureq::http::Uri = server
.parse()
.map_err(|e| format!("Invalid weather test server: {e}"))?;
let address: SocketAddr = uri
.authority()
.ok_or("Weather test server needs a loopback address and port")?
.as_str()
.parse()
.map_err(|_| "Weather tests require an IP-literal loopback address and port")?;
if uri.scheme_str() != Some("http")
|| !address.ip().is_loopback()
|| address.port() == 0
|| uri.path() != "/"
|| uri.query().is_some()
{
return Err("Weather test server must be an HTTP loopback origin".into());
}
(
format!("http://{address}/v1/search"),
format!("http://{address}/v1/forecast"),
)
} else {
(
"https://geocoding-api.open-meteo.com/v1/search".into(),
"https://api.open-meteo.com/v1/forecast".into(),
)
};
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(8)))
.max_redirects(0)
.proxy(None)
.build()
.new_agent();
Ok(Self {
agent,
geocoding,
forecast,
})
}
fn request(&self, endpoint: &str, query: &[(&str, &str)]) -> Result<Value, String> {
let mut request = self
.agent
.get(endpoint)
.header("Accept", "application/json")
.header("User-Agent", "LumaNative");
for (key, value) in query {
request = request.query(key, value);
}
let mut response = request
.call()
.map_err(|_| "Open-Meteo is unavailable. Try again shortly.")?;
if !response.status().is_success() {
return Err("Open-Meteo is unavailable. Try again shortly.".into());
}
let mut bytes = Vec::new();
response
.body_mut()
.as_reader()
.take(LIMIT as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| "The weather response could not be read")?;
if bytes.len() > LIMIT {
return Err("The weather response is too large".into());
}
serde_json::from_slice(&bytes)
.map_err(|_| "The weather service returned unreadable data".into())
}
pub fn search(&self, query: &str, language: Language) -> Result<Vec<Location>, String> {
let query = query.trim();
if query.chars().count() < 2 {
return Err("Enter at least two characters.".into());
}
parse_locations(&self.request(
&self.geocoding,
&[
("name", query),
("count", "6"),
("language", language.code()),
("format", "json"),
],
)?)
}
pub fn forecast(&self, location: &Location) -> Result<Reading, String> {
let latitude = location.latitude.to_string();
let longitude = location.longitude.to_string();
parse_reading(&self.request(
&self.forecast,
&[
("latitude", &latitude),
("longitude", &longitude),
("current", "temperature_2m,is_day,weather_code"),
("temperature_unit", "celsius"),
("timezone", "auto"),
],
)?)
}
}
pub enum Response {
Search {
id: u64,
result: Result<Vec<Location>, String>,
},
Forecast {
id: u64,
key: String,
result: Result<Reading, String>,
},
}
#[derive(Default)]
struct Pending {
job: Option<Command>,
closed: bool,
}
#[derive(Default)]
struct Queue {
pending: Mutex<Pending>,
ready: Condvar,
generation: AtomicU64,
}
impl Queue {
fn submit(&self, id: u64, job: Command) {
self.generation.store(id, Ordering::SeqCst);
self.pending.lock().unwrap().job = Some(job);
self.ready.notify_one();
}
fn close(&self) {
self.generation.store(u64::MAX, Ordering::SeqCst);
let mut pending = self.pending.lock().unwrap();
pending.closed = true;
pending.job = None;
self.ready.notify_one();
}
}
pub struct Service {
search: Arc<Queue>,
forecast: Arc<Queue>,
pub receiver: mpsc::Receiver<Response>,
cache: Option<mpsc::Sender<Option<Cache>>>,
cache_worker: Option<JoinHandle<()>>,
}
impl Service {
pub fn new(client: Client, path: PathBuf) -> Self {
let (sender, receiver) = mpsc::channel();
let spawn = |client: Client, sender: mpsc::Sender<Response>| {
let queue = Arc::new(Queue::default());
let worker = queue.clone();
thread::spawn(move || {
loop {
let job = {
let mut pending = worker.pending.lock().unwrap();
while pending.job.is_none() && !pending.closed {
pending = worker.ready.wait(pending).unwrap();
}
if pending.closed {
break;
}
pending.job.take().unwrap()
};
let id = match &job {
Command::Search { id, .. } | Command::Forecast { id, .. } => *id,
_ => continue,
};
if worker.generation.load(Ordering::SeqCst) != id {
continue;
}
let response = match job {
Command::Search {
id,
query,
language,
} => Response::Search {
id,
result: client.search(&query, language),
},
Command::Forecast { id, location } => Response::Forecast {
id,
key: location.key(),
result: client.forecast(&location),
},
_ => continue,
};
if worker.generation.load(Ordering::SeqCst) == id
&& sender.send(response).is_err()
{
break;
}
}
});
queue
};
let search = spawn(client.clone(), sender.clone());
let forecast = spawn(client, sender);
let (cache, writes) = mpsc::channel::<Option<Cache>>();
let cache_worker = thread::spawn(move || {
for cache in writes {
if let Some(cache) = cache {
let _ = save_cache(&path, &cache);
} else {
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("json.tmp"));
}
}
});
Self {
search,
forecast,
receiver,
cache: Some(cache),
cache_worker: Some(cache_worker),
}
}
pub fn generations(&self, (search, forecast): (u64, u64)) {
self.search.generation.store(search, Ordering::SeqCst);
self.forecast.generation.store(forecast, Ordering::SeqCst);
}
pub fn send(&self, command: Command) {
match command {
job @ Command::Search { id, .. } => self.search.submit(id, job),
job @ Command::Forecast { id, .. } => self.forecast.submit(id, job),
Command::Persist(cache) => {
if let Some(sender) = &self.cache {
let _ = sender.send(Some(cache));
}
}
Command::ClearCache => {
if let Some(sender) = &self.cache {
let _ = sender.send(None);
}
}
}
}
}
impl Drop for Service {
fn drop(&mut self) {
self.search.close();
self.forecast.close();
self.cache.take();
if let Some(worker) = self.cache_worker.take() {
let _ = worker.join();
}
}
}
pub fn cache_path(preferences: &Path) -> PathBuf {
preferences.with_extension("weather.json")
}
pub fn load_cache(path: &Path) -> Option<Cache> {
let mut bytes = Vec::new();
fs::File::open(path)
.ok()?
.take(64 * 1024 + 1)
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() > 64 * 1024 {
return None;
}
serde_json::from_slice(&bytes).ok()
}
pub(crate) fn save_cache(path: &Path, cache: &Cache) -> Result<(), String> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let temporary = path.with_extension("json.tmp");
fs::write(
&temporary,
serde_json::to_vec(cache).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
fs::rename(temporary, path).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_origins_are_loopback_only_and_never_follow_redirects() {
assert!(Client::new(Some("http://127.0.0.1:1234")).is_ok());
assert!(Client::new(Some("http://[::1]:1234")).is_ok());
for invalid in [
"http://example.com:1234",
"https://127.0.0.1:1234",
"http://127.0.0.1:0",
"http://127.0.0.1:1234/path",
"http://user@127.0.0.1:1234",
"http://127.0.0.1:1234?query=yes",
] {
assert!(Client::new(Some(invalid)).is_err(), "{invalid}");
}
}
#[test]
fn cache_writes_are_ordered_and_flushed_before_shutdown() {
let directory =
std::env::temp_dir().join(format!("luma-weather-cache-{}", std::process::id()));
let path = directory.join("preferences.weather.json");
let cache = Cache {
key: "48.2085,16.3721".into(),
updated_at: 1000,
reading: Reading {
temperature: 14.2,
code: 2,
is_day: true,
},
};
{
let service = Service::new(
Client::new(Some("http://127.0.0.1:1234")).unwrap(),
path.clone(),
);
service.send(Command::Persist(cache.clone()));
service.send(Command::ClearCache);
service.send(Command::Persist(cache.clone()));
}
assert_eq!(load_cache(&path), Some(cache));
{
let service = Service::new(
Client::new(Some("http://127.0.0.1:1234")).unwrap(),
path.clone(),
);
service.send(Command::ClearCache);
}
assert!(!path.exists());
fs::remove_dir_all(directory).unwrap();
}
}
+236
View File
@@ -0,0 +1,236 @@
use super::*;
use serde_json::json;
fn place() -> Location {
Location {
name: "Vienna".into(),
region: "Vienna".into(),
country: "Austria".into(),
latitude: 48.2085,
longitude: 16.3721,
}
}
fn reading() -> Reading {
Reading {
temperature: 14.2,
code: 2,
is_day: true,
}
}
fn preferences() -> Preferences {
Preferences {
weather_location: Some(place()),
..Preferences::default()
}
}
fn cache(updated_at: i64) -> Cache {
Cache {
key: place().key(),
updated_at,
reading: reading(),
}
}
#[test]
fn search_results_are_validated_normalized_and_deduplicated() {
let result = parse_locations(&json!({"results":[
{"name":" Vienna\n", "admin1":"Vienna", "country":"Austria", "latitude":48.2085,"longitude":16.3721},
{"name":"Duplicate", "latitude":48.2085,"longitude":16.3721},
{"name":"Bad", "latitude":91,"longitude":16}, {"name":"", "latitude":0,"longitude":0}
]})).unwrap();
assert_eq!(result, vec![place()]);
assert_eq!(result[0].detail(), "Austria");
assert!(parse_locations(&json!({})).unwrap().is_empty());
assert!(parse_locations(&json!({"results":"wrong"})).is_err());
assert!(parse_locations(&json!({"error":true})).is_err());
}
#[test]
fn temperatures_convert_the_unrounded_reading_and_use_web_rounding() {
assert_eq!(format_temperature(14.2, Unit::Fahrenheit), "58°F");
assert_eq!(format_temperature(14.2, Unit::Celsius), "14°C");
assert_eq!(format_temperature(-0.5, Unit::Celsius), "0°C");
assert_eq!(format_temperature(-5.5, Unit::Celsius), "-5°C");
}
#[test]
fn api_readings_and_conditions_cover_invalid_data_and_night() {
let good = json!({"current":{"temperature_2m":14.2,"weather_code":2.0,"is_day":1.0}});
assert_eq!(parse_reading(&good).unwrap(), reading());
for value in [
json!({}),
json!({"current":{"temperature_2m":90,"weather_code":2,"is_day":1}}),
json!({"current":{"temperature_2m":14,"weather_code":2.5,"is_day":1}}),
json!({"current":{"temperature_2m":14,"weather_code":2,"is_day":true}}),
] {
assert!(parse_reading(&value).is_err());
}
for (code, day, icon) in [
(0, false, "moon"),
(0, true, "sun"),
(45, true, "fog"),
(56, true, "drizzle"),
(81, true, "rain"),
(85, true, "snow"),
(99, true, "storm"),
] {
assert_eq!(
condition(Reading {
code,
is_day: day,
..reading()
})
.icon,
icon
);
}
}
#[test]
fn cache_reuse_and_units_do_not_trigger_another_request() {
let mut weather = Weather {
cache: Some(cache(1000)),
..Weather::default()
};
let mut preferences = preferences();
assert!(weather.sync(&preferences, true, 1100).0.is_none());
preferences.temperature_unit = Unit::Fahrenheit;
assert!(weather.sync(&preferences, true, 1200).0.is_none());
assert_eq!(weather.reading(&place()).unwrap().temperature, 14.2);
assert!(matches!(
weather.sync(&preferences, true, 1900).0,
Some(Command::Forecast { .. })
));
}
#[test]
fn no_requests_without_a_location_enabled_widget_and_visible_home() {
let mut weather = Weather::default();
let mut preferences = preferences();
preferences.show_weather = false;
assert!(weather.sync(&preferences, true, 1000).0.is_none());
assert!(preferences.weather_location.is_some());
preferences.show_weather = true;
assert!(weather.sync(&preferences, false, 1000).0.is_none());
preferences.weather_location = None;
assert!(weather.sync(&preferences, true, 1000).0.is_none());
}
#[test]
fn weather_failure_keeps_valid_cache_labels_it_saved_and_retries_later() {
let mut weather = Weather {
cache: Some(cache(1000)),
..Weather::default()
};
let preferences = preferences();
let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 2000).0 else {
panic!("Expected stale-cache refresh");
};
weather.accept_forecast(id, &location.key(), Err("offline".into()), 2000);
assert!(weather.failed);
assert!(weather.reading(&place()).is_some());
assert!(weather.sync(&preferences, true, 2299).0.is_none());
assert!(weather.sync(&preferences, true, 2300).0.is_some());
assert!(cache(1000).age(999).is_none());
assert!(cache(1000).age(1001 + MAX_CACHE_AGE).is_none());
weather.sync(&preferences, true, 1001 + MAX_CACHE_AGE);
assert!(weather.cache.is_none());
}
#[test]
fn removed_disabled_or_changed_locations_reject_in_flight_results() {
let mut weather = Weather::default();
let mut preferences = preferences();
let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 1000).0 else {
panic!();
};
weather.remove_location();
preferences.weather_location = None;
assert_eq!(
weather.accept_forecast(id, &location.key(), Ok(reading()), 1001),
(false, None)
);
assert!(weather.cache.is_none());
preferences.weather_location = Some(place());
let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 1002).0 else {
panic!();
};
preferences.show_weather = false;
weather.sync(&preferences, true, 1003);
assert_eq!(
weather.accept_forecast(id, &location.key(), Ok(reading()), 1004),
(false, None)
);
preferences.show_weather = true;
let Some(Command::Forecast {
id: old,
location: old_location,
}) = weather.sync(&preferences, true, 1005).0
else {
panic!();
};
preferences.weather_location.as_mut().unwrap().latitude += 1.;
let Some(Command::Forecast {
id: new,
location: new_location,
}) = weather.sync(&preferences, true, 1006).0
else {
panic!();
};
assert_eq!(
weather.accept_forecast(old, &old_location.key(), Ok(reading()), 1007),
(false, None)
);
assert!(
weather
.accept_forecast(new, &new_location.key(), Ok(reading()), 1007)
.0
);
assert_eq!(weather.cache.as_ref().unwrap().key, new_location.key());
}
#[test]
fn returning_home_retries_stale_weather_without_waiting_for_the_old_retry_timer() {
let mut weather = Weather {
cache: Some(cache(1000)),
..Weather::default()
};
let preferences = preferences();
let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 2000).0 else {
panic!();
};
weather.accept_forecast(id, &location.key(), Err("offline".into()), 2000);
assert!(weather.sync(&preferences, false, 2010).0.is_none());
assert!(weather.sync(&preferences, true, 2020).0.is_some());
}
#[test]
fn editing_query_invalidates_old_results_and_short_queries_do_not_search() {
let mut weather = Weather::default();
weather.edit("V".into());
assert!(weather.search(Language::En).is_none());
assert!(weather.message.is_some());
weather.edit("Vienna".into());
let Some(Command::Search { id, .. }) = weather.search(Language::En) else {
panic!();
};
assert!(weather.search(Language::En).is_none());
weather.edit("Paris".into());
assert!(!weather.accept_search(id, Ok(vec![place()])));
assert!(weather.results.is_empty());
}
#[test]
fn malformed_weather_preferences_do_not_lose_existing_favorites() {
let preferences: Preferences = serde_json::from_value(json!({
"favorites":["netflix"], "weather_location":{"name":"Invalid","latitude":"oops","longitude":0},
"temperature_unit":"kelvin", "show_weather":"off"
})).unwrap();
assert_eq!(preferences.favorites, Some(vec!["netflix".into()]));
assert!(preferences.weather_location.is_none());
assert_eq!(preferences.temperature_unit, Unit::Celsius);
assert!(preferences.show_weather);
let old: Preferences = serde_json::from_value(json!({"background":"forest"})).unwrap();
assert!(old.show_weather);
assert!(old.weather_location.is_none());
}
+433
View File
@@ -0,0 +1,433 @@
//! Open-Meteo data and request scheduling. API/cache temperatures remain Celsius;
//! presentation units never change the request or invalidate a cached reading.
use crate::i18n::Language;
use crate::model::Preferences;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::collections::HashSet;
pub const REFRESH_SECONDS: i64 = 15 * 60;
pub const MAX_CACHE_AGE: i64 = 6 * 60 * 60;
const RETRY_SECONDS: i64 = 5 * 60;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct Location {
pub name: String,
pub region: String,
pub country: String,
pub latitude: f64,
pub longitude: f64,
}
impl Location {
pub fn parse(value: &Value) -> Option<Self> {
let latitude = value.get("latitude")?.as_f64()?;
let longitude = value.get("longitude")?.as_f64()?;
let name = label(value.get("name"));
if name.is_empty()
|| !latitude.is_finite()
|| !longitude.is_finite()
|| latitude.abs() > 90.
|| longitude.abs() > 180.
{
return None;
}
Some(Self {
name,
region: label(value.get("region").or_else(|| value.get("admin1"))),
country: label(value.get("country")),
latitude,
longitude,
})
}
pub fn key(&self) -> String {
format!(
"{:.4},{:.4}",
if self.latitude == 0. {
0.
} else {
self.latitude
},
if self.longitude == 0. {
0.
} else {
self.longitude
}
)
}
pub fn detail(&self) -> String {
let mut parts = Vec::new();
for part in [&self.region, &self.country] {
if !part.is_empty() && part != &self.name && !parts.contains(&part.as_str()) {
parts.push(part.as_str());
}
}
parts.join(", ")
}
}
fn label(value: Option<&Value>) -> String {
value
.and_then(Value::as_str)
.unwrap_or("")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.filter(|c| !c.is_control())
.take(120)
.collect()
}
pub fn deserialize_location<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Location>, D::Error> {
Ok(Location::parse(&Value::deserialize(deserializer)?))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Unit {
#[default]
Celsius,
Fahrenheit,
}
impl Unit {
pub fn symbol(self) -> &'static str {
match self {
Self::Celsius => "°C",
Self::Fahrenheit => "°F",
}
}
}
pub fn deserialize_unit<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Unit, D::Error> {
Ok(
if Value::deserialize(deserializer)?.as_str() == Some("fahrenheit") {
Unit::Fahrenheit
} else {
Unit::Celsius
},
)
}
pub fn deserialize_enabled<'de, D: Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
Ok(Value::deserialize(deserializer)? != Value::Bool(false))
}
pub fn format_temperature(celsius: f64, unit: Unit) -> String {
let value = if unit == Unit::Fahrenheit {
celsius * 9. / 5. + 32.
} else {
celsius
};
// Match JavaScript Math.round, including negative half-degree ties.
format!("{}{}", (value + 0.5).floor() as i32, unit.symbol())
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
pub struct Reading {
pub temperature: f64,
pub code: u8,
pub is_day: bool,
}
impl Reading {
pub fn valid(self) -> bool {
self.temperature.is_finite()
&& (-100.0..=70.0).contains(&self.temperature)
&& self.code <= 99
}
}
pub fn parse_reading(value: &Value) -> Result<Reading, String> {
let current = &value["current"];
let temperature = current["temperature_2m"].as_f64();
let code = current["weather_code"]
.as_f64()
.filter(|n| n.fract() == 0. && (0.0..=99.0).contains(n));
let day = current["is_day"].as_f64().filter(|n| *n == 0. || *n == 1.);
if let (Some(temperature), Some(code), Some(day)) = (temperature, code, day) {
let reading = Reading {
temperature,
code: code as u8,
is_day: day == 1.,
};
if reading.valid() {
return Ok(reading);
}
}
Err("The weather service returned an unreadable reading.".into())
}
pub fn parse_locations(value: &Value) -> Result<Vec<Location>, String> {
if !value.is_object()
|| value["error"] == true
|| value.get("results").is_some_and(|v| !v.is_array())
{
return Err("The location service returned an unreadable response.".into());
}
let mut seen = HashSet::new();
Ok(value
.get("results")
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(20)
.filter_map(Location::parse)
.filter(|place| seen.insert(place.key()))
.take(6)
.collect())
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct Cache {
pub key: String,
pub updated_at: i64,
pub reading: Reading,
}
impl Cache {
pub fn age(&self, now: i64) -> Option<i64> {
now.checked_sub(self.updated_at)
.filter(|age| (0..=MAX_CACHE_AGE).contains(age) && self.reading.valid())
}
}
pub struct Condition {
pub key: &'static str,
pub icon: &'static str,
}
pub fn condition(reading: Reading) -> Condition {
let (key, icon) = match reading.code {
0 if reading.is_day => ("clearSkies", "sun"),
0 => ("clearNight", "moon"),
1 => ("mostlyClear", "partly-cloudy"),
2 => ("partlyCloudy", "partly-cloudy"),
3 => ("overcast", "cloud"),
45 | 48 => ("fog", "fog"),
51 | 53 | 55 | 56 | 57 => ("drizzle", "drizzle"),
61 | 63 | 65 | 66 | 67 | 80 | 81 | 82 => ("rain", "rain"),
71 | 73 | 75 | 77 | 85 | 86 => ("snow", "snow"),
95 | 96 | 99 => ("thunderstorms", "storm"),
_ => ("currentWeather", "cloud"),
};
Condition { key, icon }
}
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Search {
id: u64,
query: String,
language: Language,
},
Forecast {
id: u64,
location: Location,
},
Persist(Cache),
ClearCache,
}
#[derive(Default, Serialize)]
pub struct Weather {
pub query: String,
pub results: Vec<Location>,
pub searching: bool,
pub message: Option<String>,
pub cache: Option<Cache>,
pub failed: bool,
pub loading: bool,
#[serde(skip)]
search_id: u64,
#[serde(skip)]
forecast_id: u64,
#[serde(skip)]
active_key: Option<String>,
#[serde(skip)]
pending: Option<(u64, String)>,
#[serde(skip)]
next_fetch: i64,
#[serde(skip)]
active_home: bool,
}
impl Weather {
pub fn generations(&self) -> (u64, u64) {
(self.search_id, self.forecast_id)
}
pub fn fetching(&self) -> bool {
self.pending.is_some()
}
pub fn edit(&mut self, query: String) {
self.query = query
.chars()
.filter(|c| !c.is_control())
.take(120)
.collect();
self.cancel_search();
}
pub fn cancel_search(&mut self) {
self.search_id += 1;
self.results.clear();
self.searching = false;
self.message = None;
}
pub fn search(&mut self, language: Language) -> Option<Command> {
if self.searching {
return None;
}
self.cancel_search();
let query = self.query.trim().to_owned();
if query.chars().count() < 2 {
self.message = Some("enterCity".into());
return None;
}
self.searching = true;
Some(Command::Search {
id: self.search_id,
query,
language,
})
}
pub fn accept_search(&mut self, id: u64, result: Result<Vec<Location>, String>) -> bool {
if id != self.search_id || !self.searching {
return false;
}
self.searching = false;
match result {
Ok(places) => {
self.results = places;
if self.results.is_empty() {
self.message = Some("noLocations".into());
}
}
Err(_) => self.message = Some("searchUnavailable".into()),
}
true
}
pub fn invalidate_forecast(&mut self) {
self.forecast_id += 1;
self.pending = None;
self.active_key = None;
self.next_fetch = 0;
self.loading = false;
self.failed = false;
}
pub fn remove_location(&mut self) {
self.cancel_search();
self.invalidate_forecast();
self.cache = None;
}
pub fn reading(&self, location: &Location) -> Option<Reading> {
self.cache
.as_ref()
.filter(|cache| cache.key == location.key())
.map(|cache| cache.reading)
}
pub fn sync(
&mut self,
preferences: &Preferences,
home_visible: bool,
now: i64,
) -> (Option<Command>, bool) {
let before = (
self.loading,
self.failed,
self.cache.as_ref().map(|c| (c.updated_at, c.reading)),
);
if self
.cache
.as_ref()
.is_some_and(|cache| cache.age(now).is_none())
{
self.cache = None;
}
let key = preferences.weather_location.as_ref().map(Location::key);
if self.active_key != key {
self.invalidate_forecast();
self.active_key = key.clone();
}
let active = preferences.show_weather && home_visible && key.is_some();
if active && !self.active_home {
self.next_fetch = 0;
}
self.active_home = active;
let mut command = None;
if !preferences.show_weather || !home_visible || key.is_none() {
if self.pending.take().is_some() {
self.forecast_id += 1;
}
self.loading = false;
} else if let Some(cache) = self.cache.as_ref().filter(|c| {
Some(&c.key) == key.as_ref() && c.age(now).is_some_and(|age| age < REFRESH_SECONDS)
}) {
self.loading = false;
self.failed = false;
self.next_fetch = cache.updated_at + REFRESH_SECONDS;
} else if self.pending.is_none() && now >= self.next_fetch {
self.forecast_id += 1;
self.pending = key.clone().map(|key| (self.forecast_id, key));
self.loading = self
.cache
.as_ref()
.is_none_or(|cache| Some(&cache.key) != key.as_ref());
command = preferences
.weather_location
.clone()
.map(|location| Command::Forecast {
id: self.forecast_id,
location,
});
}
if active && self.pending.is_some() {
self.loading = self
.cache
.as_ref()
.is_none_or(|cache| Some(&cache.key) != key.as_ref());
}
let changed = before
!= (
self.loading,
self.failed,
self.cache.as_ref().map(|c| (c.updated_at, c.reading)),
);
(command, changed)
}
pub fn accept_forecast(
&mut self,
id: u64,
key: &str,
result: Result<Reading, String>,
now: i64,
) -> (bool, Option<Command>) {
if self
.pending
.as_ref()
.is_none_or(|(pending, location)| *pending != id || location != key)
{
return (false, None);
}
self.pending = None;
self.loading = false;
match result {
Ok(reading) if reading.valid() => {
let cache = Cache {
key: key.into(),
updated_at: now,
reading,
};
self.cache = Some(cache.clone());
self.failed = false;
self.next_fetch = now + REFRESH_SECONDS;
(true, Some(Command::Persist(cache)))
}
_ => {
self.failed = true;
self.next_fetch = now + RETRY_SECONDS;
if self
.cache
.as_ref()
.is_some_and(|cache| cache.age(now).is_none())
{
self.cache = None;
}
(true, None)
}
}
}
}
#[cfg(test)]
#[path = "weather-tests.rs"]
mod tests;
+125
View File
@@ -0,0 +1,125 @@
//! The webOS SDK's libc lacks getauxval, used by Rust's startup and ring.
//! Read real kernel auxiliary values rather than inventing CPU capabilities.
fn read_auxv(fd: libc::c_int, kind: libc::c_ulong) -> Option<libc::c_ulong> {
loop {
let mut entry = [0 as libc::c_ulong; 2];
// SAFETY: the byte slice covers exactly this initialized, writable entry.
let bytes = unsafe {
std::slice::from_raw_parts_mut(
entry.as_mut_ptr().cast::<u8>(),
std::mem::size_of_val(&entry),
)
};
let mut offset = 0;
while offset < bytes.len() {
// SAFETY: read receives a valid writable slice and never owns fd.
let count = unsafe {
libc::read(
fd,
bytes[offset..].as_mut_ptr().cast(),
bytes.len() - offset,
)
};
if count < 0 {
if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
continue;
}
return None;
}
if count == 0 {
return None;
}
offset += count as usize;
}
if entry[0] == 0 {
return None;
}
if entry[0] == kind {
return Some(entry[1]);
}
}
}
#[cfg(target_os = "linux")]
#[unsafe(no_mangle)]
extern "C" fn getauxval(kind: libc::c_ulong) -> libc::c_ulong {
// SAFETY: errno is thread-local; open uses a static NUL-terminated pathname.
// This path needs no allocations, SDL, or initialized Rust application state.
unsafe {
let errno = libc::__errno_location();
let saved_errno = *errno;
let fd = libc::open(
c"/proc/self/auxv".as_ptr(),
libc::O_RDONLY | libc::O_CLOEXEC,
);
let value = if fd >= 0 {
let value = read_auxv(fd, kind);
libc::close(fd);
value
} else {
None
};
*errno = if value.is_some() {
saved_errno
} else {
libc::ENOENT
};
value.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{io::Write, os::fd::AsRawFd, os::unix::net::UnixStream};
fn lookup(
entries: &[[libc::c_ulong; 2]],
truncate: usize,
key: libc::c_ulong,
) -> Option<libc::c_ulong> {
let (reader, mut writer) = UnixStream::pair().unwrap();
let bytes: Vec<u8> = entries
.iter()
.flatten()
.flat_map(|n| n.to_ne_bytes())
.collect();
writer.write_all(&bytes[..bytes.len() - truncate]).unwrap();
writer.shutdown(std::net::Shutdown::Write).unwrap();
read_auxv(reader.as_raw_fd(), key)
}
#[test]
fn preserves_kernel_values_including_zero() {
let entries = [[6, 4096], [16, 0], [26, 17], [0, 0]];
assert_eq!(lookup(&entries, 0, 6), Some(4096));
assert_eq!(lookup(&entries, 0, 16), Some(0));
assert_eq!(lookup(&entries, 0, 26), Some(17));
assert_eq!(lookup(&entries, 0, 42), None);
}
#[test]
fn rejects_terminator_and_incomplete_records() {
assert_eq!(lookup(&[[0, 0], [6, 4096]], 0, 6), None);
assert_eq!(lookup(&[[0, 0]], 0, 0), None);
assert_eq!(lookup(&[[6, 4096]], 1, 6), None);
assert_eq!(lookup(&[], 0, 6), None);
assert_eq!(read_auxv(-1, 6), None);
}
#[cfg(target_os = "linux")]
#[test]
fn exposes_real_page_size_and_libc_errno_contract() {
unsafe {
*libc::__errno_location() = libc::EACCES;
assert_eq!(
getauxval(libc::AT_PAGESZ),
libc::sysconf(libc::_SC_PAGESIZE) as libc::c_ulong
);
assert_eq!(*libc::__errno_location(), libc::EACCES);
assert_eq!(getauxval(libc::c_ulong::MAX), 0);
assert_eq!(*libc::__errno_location(), libc::ENOENT);
}
}
}