490 lines
18 KiB
Rust
490 lines
18 KiB
Rust
use cosmic::iced::Color;
|
|
use cosmic::widget::image;
|
|
use ferese_config::Document;
|
|
|
|
#[derive(Clone)]
|
|
pub struct Appearance {
|
|
pub appearance: ferese_config::theme::Appearance,
|
|
pub high_contrast: bool,
|
|
pub dim: f32,
|
|
pub show_clock: bool,
|
|
pub show_date: bool,
|
|
pub twelve_hour: bool,
|
|
pub panel: Color,
|
|
pub text: Color,
|
|
pub accent: Color,
|
|
pub on_accent: Color,
|
|
pub radius: f32,
|
|
pub font: cosmic::font::Font,
|
|
pub wallpaper: image::Handle,
|
|
pub wallpaper_path: std::path::PathBuf,
|
|
pub wallpaper_blur: f32,
|
|
pub wallpaper_revision: u64,
|
|
wallpaper_loaded: bool,
|
|
wallpaper_pending: Option<(std::path::PathBuf, bool)>,
|
|
pub reduce_transparency: bool,
|
|
pub avatar: Option<image::Handle>,
|
|
}
|
|
|
|
impl Appearance {
|
|
pub fn load(user: &str, path: Option<&std::path::Path>) -> Self {
|
|
let doc = path
|
|
.and_then(|path| std::fs::read_to_string(path).ok())
|
|
.and_then(|source| Document::parse(&source).ok());
|
|
let mut theme = ferese_theme_client::service::current().presented;
|
|
if let Some(path) =
|
|
path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
|
|
&& let Some(document) = &doc
|
|
&& let Ok(mut connection) = ferese_ipc::Connection::connect()
|
|
{
|
|
let args = serde_json::json!({
|
|
"source": document.to_string(),
|
|
"directory": path.parent(),
|
|
});
|
|
if let Ok(value) = connection.call("theme-preview", args)
|
|
&& let Ok(preview) = serde_json::from_value(value["theme"].clone())
|
|
{
|
|
theme = preview;
|
|
}
|
|
}
|
|
Self::from_document(user, doc, &theme)
|
|
}
|
|
|
|
fn from_document(
|
|
user: &str,
|
|
doc: Option<Document>,
|
|
theme: &ferese_config::theme::ResolvedTheme,
|
|
) -> Self {
|
|
let doc = doc.map(|doc| doc.with_theme(theme));
|
|
let string = |key: &str, fallback: &str| {
|
|
doc.as_ref()
|
|
.and_then(|d| d.get(key))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(fallback)
|
|
.to_owned()
|
|
};
|
|
let palette = ferese_theme::Palette::from_resolved(theme);
|
|
let font = string("theme.typography.font_family", "Inter");
|
|
let background = string("theme.background.path", ferese_config::default_wallpaper());
|
|
let path = string("theme.background.lock_path", &background);
|
|
let path = if let Some(tail) = path.strip_prefix("~/") {
|
|
std::env::var("HOME")
|
|
.map(|home| format!("{home}/{tail}"))
|
|
.unwrap_or(path)
|
|
} else {
|
|
path
|
|
};
|
|
let wallpaper_path = std::path::PathBuf::from(path);
|
|
let blur = doc
|
|
.as_ref()
|
|
.and_then(|doc| doc.get("lock_screen.background_blur"))
|
|
.and_then(|value| value.as_f64())
|
|
.unwrap_or(18.)
|
|
.clamp(0., 40.) as f32;
|
|
let reduce_transparency = theme.accessibility.reduce_transparency;
|
|
let loaded = load_wallpaper(&wallpaper_path, if reduce_transparency { 0. } else { blur });
|
|
let wallpaper_loaded = loaded.is_ok();
|
|
let wallpaper = loaded.unwrap_or_else(|_| {
|
|
image::Handle::from_bytes(
|
|
include_bytes!("../../../assets/wallpapers/ferese.png").as_slice(),
|
|
)
|
|
});
|
|
let boolean = |key, fallback| {
|
|
doc.as_ref()
|
|
.and_then(|d| d.get(key))
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(fallback)
|
|
};
|
|
Self {
|
|
appearance: theme.appearance,
|
|
high_contrast: theme.accessibility.increase_contrast,
|
|
dim: doc
|
|
.as_ref()
|
|
.and_then(|d| d.get("lock_screen.background_dim"))
|
|
.and_then(|v| v.as_f64())
|
|
.unwrap_or(0.48)
|
|
.clamp(0., 0.9) as f32,
|
|
show_clock: boolean("lock_screen.show_clock", true),
|
|
show_date: boolean("lock_screen.show_date", true),
|
|
twelve_hour: string("lock_screen.clock_format", "24h") == "12h",
|
|
panel: palette.sidebar,
|
|
text: palette.text,
|
|
accent: palette.accent,
|
|
on_accent: palette.on_accent,
|
|
radius: palette.radius,
|
|
font: ferese_theme::font(Some(&font)),
|
|
wallpaper,
|
|
wallpaper_path,
|
|
wallpaper_blur: blur,
|
|
wallpaper_revision: 0,
|
|
wallpaper_loaded,
|
|
wallpaper_pending: None,
|
|
reduce_transparency,
|
|
avatar: account_picture(user),
|
|
}
|
|
}
|
|
|
|
pub fn request_wallpaper(
|
|
&mut self,
|
|
theme: &ferese_config::theme::ResolvedTheme,
|
|
) -> Option<(u64, std::path::PathBuf, f32)> {
|
|
let path = theme
|
|
.tokens
|
|
.background
|
|
.lock_path
|
|
.as_ref()
|
|
.or(theme.tokens.background.path.as_ref())
|
|
.cloned()
|
|
.unwrap_or_else(|| ferese_config::default_wallpaper_for(theme.appearance).into());
|
|
let reduce = theme.accessibility.reduce_transparency;
|
|
let desired = (path.clone(), reduce);
|
|
if self.wallpaper_pending.as_ref() == Some(&desired) {
|
|
return None;
|
|
}
|
|
if self.wallpaper_loaded
|
|
&& path == self.wallpaper_path
|
|
&& reduce == self.reduce_transparency
|
|
{
|
|
if self.wallpaper_pending.take().is_some() {
|
|
self.wallpaper_revision = self.wallpaper_revision.wrapping_add(1);
|
|
}
|
|
return None;
|
|
}
|
|
self.wallpaper_pending = Some(desired);
|
|
self.wallpaper_revision = self.wallpaper_revision.wrapping_add(1);
|
|
Some((
|
|
self.wallpaper_revision,
|
|
path,
|
|
if reduce { 0.0 } else { self.wallpaper_blur },
|
|
))
|
|
}
|
|
|
|
pub fn finish_wallpaper(
|
|
&mut self,
|
|
revision: u64,
|
|
result: Result<image::Handle, String>,
|
|
) -> Result<(), String> {
|
|
if revision != self.wallpaper_revision {
|
|
return Ok(());
|
|
}
|
|
let Some((path, reduce)) = self.wallpaper_pending.take() else {
|
|
return Ok(());
|
|
};
|
|
let wallpaper = result?;
|
|
self.wallpaper = wallpaper;
|
|
self.wallpaper_path = path;
|
|
self.reduce_transparency = reduce;
|
|
self.wallpaper_loaded = true;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn clock_format(&self) -> &'static str {
|
|
if self.twelve_hour {
|
|
"%I:%M %p"
|
|
} else {
|
|
"%H:%M"
|
|
}
|
|
}
|
|
|
|
pub fn theme(&self) -> cosmic::Theme {
|
|
ferese_theme::Palette {
|
|
appearance: self.appearance,
|
|
high_contrast: self.high_contrast,
|
|
background: self.panel,
|
|
sidebar: self.panel,
|
|
card: self.panel,
|
|
text: self.text,
|
|
muted: self.text.scale_alpha(0.55),
|
|
accent: self.accent,
|
|
accent_gradient: None,
|
|
on_accent: self.on_accent,
|
|
radius: self.radius,
|
|
error: Color::from_rgb8(235, 98, 98),
|
|
}
|
|
.native_theme()
|
|
}
|
|
}
|
|
|
|
fn account_picture(user: &str) -> Option<image::Handle> {
|
|
let mut candidates =
|
|
vec![std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user)];
|
|
if let Some(home) = std::env::var_os("HOME") {
|
|
candidates.push(std::path::PathBuf::from(home).join(".face"));
|
|
}
|
|
candidates.into_iter().find_map(|path| {
|
|
let pixels = decode_avatar(&path).ok()?;
|
|
let side = pixels.width().min(pixels.height());
|
|
if side == 0 {
|
|
return None;
|
|
}
|
|
let square = ::image::imageops::crop_imm(
|
|
&pixels,
|
|
(pixels.width() - side) / 2,
|
|
(pixels.height() - side) / 2,
|
|
side,
|
|
side,
|
|
)
|
|
.to_image();
|
|
let mut pixels =
|
|
::image::imageops::resize(&square, 256, 256, ::image::imageops::FilterType::Lanczos3);
|
|
// Clip the decoded thumbnail, so the avatar stays circular even on
|
|
// renderers that do not support rounded image clipping.
|
|
for (x, y, pixel) in pixels.enumerate_pixels_mut() {
|
|
let distance =
|
|
((x as f32 + 0.5 - 128.).powi(2) + (y as f32 + 0.5 - 128.).powi(2)).sqrt();
|
|
let coverage = (128. - distance).clamp(0., 1.);
|
|
pixel[3] = (pixel[3] as f32 * coverage).round() as u8;
|
|
}
|
|
Some(image::Handle::from_rgba(
|
|
pixels.width(),
|
|
pixels.height(),
|
|
pixels.into_raw(),
|
|
))
|
|
})
|
|
}
|
|
|
|
fn decode_avatar(path: &std::path::Path) -> Result<::image::RgbaImage, String> {
|
|
use std::io::Read;
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
use ::image::ImageDecoder;
|
|
|
|
const ENCODED_LIMIT: u64 = 8 * 1024 * 1024;
|
|
const DECODED_LIMIT: u64 = 32 * 1024 * 1024;
|
|
let file = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.custom_flags(libc::O_NONBLOCK)
|
|
.open(path)
|
|
.map_err(|error| error.to_string())?;
|
|
if !file
|
|
.metadata()
|
|
.map_err(|error| error.to_string())?
|
|
.is_file()
|
|
{
|
|
return Err("Avatar is not a regular file".into());
|
|
}
|
|
|
|
let mut bytes = Vec::new();
|
|
file.take(ENCODED_LIMIT + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|error| error.to_string())?;
|
|
if bytes.len() as u64 > ENCODED_LIMIT {
|
|
return Err("Avatar exceeds the file limit".into());
|
|
}
|
|
|
|
let mut reader = ::image::ImageReader::new(std::io::Cursor::new(bytes))
|
|
.with_guessed_format()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut limits = ::image::Limits::default();
|
|
limits.max_image_width = Some(4096);
|
|
limits.max_image_height = Some(4096);
|
|
limits.max_alloc = Some(DECODED_LIMIT);
|
|
reader.limits(limits);
|
|
let decoder = reader.into_decoder().map_err(|error| error.to_string())?;
|
|
let (width, height) = decoder.dimensions();
|
|
if width == 0 || height == 0 || u64::from(width) * u64::from(height) * 4 > DECODED_LIMIT {
|
|
return Err("Avatar exceeds the decode limit".into());
|
|
}
|
|
|
|
::image::DynamicImage::from_decoder(decoder)
|
|
.map(|image| image.into_rgba8())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
|
|
let result = prepare_wallpaper(path, blur);
|
|
// Decode and resize scratch buffers have been dropped. Return their freed
|
|
// pages even when glibc would otherwise retain them in an arena.
|
|
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
|
// SAFETY: glibc malloc_trim is thread-safe and does not invalidate any live allocation.
|
|
unsafe {
|
|
libc::malloc_trim(0);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn prepare_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
|
|
use ::image::ImageDecoder;
|
|
let mut reader = ::image::ImageReader::open(path)
|
|
.map_err(|e| e.to_string())?
|
|
.with_guessed_format()
|
|
.map_err(|e| e.to_string())?;
|
|
let mut limits = ::image::Limits::default();
|
|
limits.max_alloc = Some(256 * 1024 * 1024);
|
|
reader.limits(limits);
|
|
let decoder = reader.into_decoder().map_err(|e| e.to_string())?;
|
|
let (width, height) = decoder.dimensions();
|
|
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > (256 * 1024 * 1024) / 4 {
|
|
return Err("Lock wallpaper exceeds the decode limit".into());
|
|
}
|
|
if blur <= 0. {
|
|
// Iced decodes the path and releases its host buffer after GPU upload.
|
|
// An RGBA handle would retain those pixels for the handle's lifetime.
|
|
return Ok(image::Handle::from_path(path));
|
|
}
|
|
let pixels = {
|
|
let decoded = ::image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?;
|
|
let thumbnail = decoded.thumbnail(640, 640).into_rgba8();
|
|
::image::imageops::blur(&thumbnail, blur)
|
|
};
|
|
Ok(image::Handle::from_rgba(
|
|
pixels.width(),
|
|
pixels.height(),
|
|
pixels.into_raw(),
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn wallpaper_handles_keep_only_paths_or_blurred_thumbnails() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
::image::RgbaImage::from_pixel(1280, 720, ::image::Rgba([32, 80, 160, 255]))
|
|
.save_with_format(file.path(), ::image::ImageFormat::Png)
|
|
.unwrap();
|
|
match load_wallpaper(file.path(), 0.).unwrap() {
|
|
image::Handle::Path(_, path) => assert_eq!(path, file.path()),
|
|
_ => panic!("unblurred wallpaper must not retain RGBA pixels"),
|
|
}
|
|
match load_wallpaper(file.path(), 2.).unwrap() {
|
|
image::Handle::Rgba {
|
|
width,
|
|
height,
|
|
pixels,
|
|
..
|
|
} => {
|
|
assert_eq!((width, height), (640, 360));
|
|
assert_eq!(pixels.len(), 640 * 360 * 4);
|
|
}
|
|
_ => panic!("blurred wallpaper must retain only the processed thumbnail"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wallpaper_path_handles_require_a_valid_image_header() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
assert!(load_wallpaper(file.path(), 0.).is_err());
|
|
std::fs::write(file.path(), b"not an image").unwrap();
|
|
assert!(load_wallpaper(file.path(), 0.).is_err());
|
|
assert!(load_wallpaper(&file.path().with_extension("missing"), 0.).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn wallpaper_requests_retry_failures_and_ignore_superseded_results() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let initial = directory.path().join("initial.png");
|
|
::image::RgbaImage::new(2, 2).save(&initial).unwrap();
|
|
let mut theme = ferese_config::theme::default_theme();
|
|
theme.tokens.background.path = Some(initial.clone());
|
|
theme.tokens.background.lock_path = None;
|
|
let document = Document::parse("").unwrap();
|
|
let mut appearance = Appearance::from_document("no-such-test-user", Some(document), &theme);
|
|
assert!(appearance.request_wallpaper(&theme).is_none());
|
|
let next = directory.path().join("next.png");
|
|
theme.tokens.background.path = Some(next.clone());
|
|
let (failed, _, _) = appearance.request_wallpaper(&theme).unwrap();
|
|
assert!(appearance.request_wallpaper(&theme).is_none());
|
|
assert!(
|
|
appearance
|
|
.finish_wallpaper(failed, Err("decode failed".into()))
|
|
.is_err()
|
|
);
|
|
assert_eq!(appearance.wallpaper_path, initial);
|
|
let (retry, path, _) = appearance.request_wallpaper(&theme).unwrap();
|
|
assert_eq!(path, next);
|
|
let last = directory.path().join("last.png");
|
|
theme.tokens.background.path = Some(last.clone());
|
|
let (latest, _, _) = appearance.request_wallpaper(&theme).unwrap();
|
|
appearance
|
|
.finish_wallpaper(retry, Ok(image::Handle::from_rgba(1, 1, vec![255; 4])))
|
|
.unwrap();
|
|
assert_eq!(appearance.wallpaper_path, initial);
|
|
appearance
|
|
.finish_wallpaper(latest, Ok(image::Handle::from_rgba(1, 1, vec![255; 4])))
|
|
.unwrap();
|
|
assert_eq!(appearance.wallpaper_path, last);
|
|
assert!(appearance.request_wallpaper(&theme).is_none());
|
|
theme.tokens.background.lock_path = Some(initial.clone());
|
|
let (_, path, _) = appearance.request_wallpaper(&theme).unwrap();
|
|
assert_eq!(
|
|
path, initial,
|
|
"an explicit lock image overrides the desktop wallpaper"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn avatar_decoding_bounds_dimensions_and_encoded_size() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
::image::RgbaImage::new(3, 2)
|
|
.save_with_format(file.path(), ::image::ImageFormat::Png)
|
|
.unwrap();
|
|
assert_eq!(decode_avatar(file.path()).unwrap().dimensions(), (3, 2));
|
|
|
|
::image::RgbaImage::new(4097, 1)
|
|
.save_with_format(file.path(), ::image::ImageFormat::Png)
|
|
.unwrap();
|
|
assert!(decode_avatar(file.path()).is_err());
|
|
|
|
file.as_file().set_len(8 * 1024 * 1024 + 1).unwrap();
|
|
assert!(
|
|
decode_avatar(file.path())
|
|
.unwrap_err()
|
|
.contains("file limit")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn portal_wallpaper_loads_without_a_filename_extension() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
::image::RgbaImage::from_pixel(3, 2, ::image::Rgba([32, 80, 160, 255]))
|
|
.save_with_format(file.path(), ::image::ImageFormat::Png)
|
|
.unwrap();
|
|
assert!(load_wallpaper(file.path(), 0.).is_ok());
|
|
assert!(load_wallpaper(file.path(), 2.).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn lock_options_share_shell_style_without_changing_security() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let config = directory.path().join("config.kdl");
|
|
std::fs::write(
|
|
&config,
|
|
r##"
|
|
theme {
|
|
geometry { shell-radius 0; }
|
|
colors { accent "#FF5500"; }
|
|
}
|
|
lock-screen {
|
|
show-clock #false
|
|
show-date #false
|
|
clock-format "12h"
|
|
background-dim 0.7
|
|
}
|
|
"##,
|
|
)
|
|
.unwrap();
|
|
let document = Document::parse(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
|
let theme = ferese_config::theme::resolve(
|
|
&document,
|
|
directory.path(),
|
|
jiff::Timestamp::now(),
|
|
|_| Err("unexpected file".into()),
|
|
)
|
|
.unwrap()
|
|
.theme;
|
|
let appearance = Appearance::from_document("no-such-test-user", Some(document), &theme);
|
|
assert_eq!(appearance.radius, 0.);
|
|
assert_eq!(theme.requested_accent, "#FF5500");
|
|
assert_eq!(
|
|
appearance.accent,
|
|
ferese_theme::parse_color(&theme.tokens.colors.accent).unwrap()
|
|
);
|
|
assert!(ferese_theme::contrast(appearance.accent, appearance.on_accent) >= 4.5);
|
|
assert!(!appearance.show_clock && !appearance.show_date);
|
|
assert_eq!(appearance.clock_format(), "%I:%M %p");
|
|
assert_eq!(appearance.dim, 0.7);
|
|
assert_eq!(appearance.theme().cosmic().corner_radii.radius_m, [0.; 4]);
|
|
}
|
|
}
|