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, pub reduce_transparency: bool, pub avatar: Option, } 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::theme::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, 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 wallpaper = load_wallpaper(&wallpaper_path, if reduce_transparency { 0. } else { blur }).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, reduce_transparency, avatar: account_picture(user), } } 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 { 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 { 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 u64::from(width) * u64::from(height) * 4 > 256 * 1024 * 1024 { return Err("Lock wallpaper exceeds the decode limit".into()); } let pixels = ::image::DynamicImage::from_decoder(decoder) .map_err(|e| e.to_string())? .into_rgba8(); let pixels = if blur > 0. { ::image::imageops::blur(&::image::imageops::thumbnail(&pixels, 640, 640), blur) } else { pixels }; Ok(image::Handle::from_rgba( pixels.width(), pixels.height(), pixels.into_raw(), )) } #[cfg(test)] mod tests { use super::*; #[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]); } }