Merge Luma launcher fork into monorepo
Move trampoline files (boot-hook, watcher, overlay) into trampoline/ subdirectory. Add upstream Luma v2.3.1 source at repo root with our additions: - Page::Ha (4th nav tab) with placeholder empty state - HA tab config schema and default entity mapping (ha-home/) - Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings - All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc3ca0f3b9
commit
cbea5a322f
204 files changed
+25056
-65
No files matched your search
@@ -0,0 +1,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]);
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+295
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+176
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user