Skip to main content

siglus_scene_vm/
desktop_icon.rs

1//! Game icons for native windows, including the Wayland toplevel icon protocol.
2
3use std::path::{Path, PathBuf};
4use winit::icon::RgbaIcon;
5use winit::window::WindowAttributes;
6
7/// Apply the game icon before creating its window. Winit sends the pixels
8/// directly to Wayland compositors that support xdg_toplevel_icon_v1.
9pub fn configure_window(attributes: WindowAttributes, project_dir: &Path) -> WindowAttributes {
10    let rgba = load_icon(project_dir);
11    let icon = RgbaIcon::new(rgba.as_raw().clone(), rgba.width(), rgba.height())
12        .ok()
13        .map(Into::into);
14    let attributes = attributes.with_window_icon(icon);
15    #[cfg(target_os = "linux")]
16    {
17        use winit::platform::wayland::WindowAttributesWayland;
18        let project_dir = project_dir
19            .canonicalize()
20            .unwrap_or_else(|_| project_dir.to_owned());
21        let app_id = application_id(&project_dir);
22        attributes.with_platform_attributes(Box::new(
23            WindowAttributesWayland::default().with_name(&app_id, "siglus_engine"),
24        ))
25    }
26    #[cfg(not(target_os = "linux"))]
27    {
28        attributes
29    }
30}
31
32fn load_icon(project_dir: &Path) -> image::RgbaImage {
33    // Prefer explicit icon names, then game-specific ICO files (e.g. train.ico).
34    // Resolve names case-insensitively, just like the other game resources.
35    let mut candidates: Vec<PathBuf> = ["icon.png", "icon.ico"]
36        .iter()
37        .filter_map(|name| {
38            crate::resource::resolve_game_file(&project_dir.join(name))
39                .ok()
40                .flatten()
41        })
42        .collect();
43    let mut ico_files: Vec<_> = std::fs::read_dir(project_dir)
44        .into_iter()
45        .flatten()
46        .filter_map(Result::ok)
47        .map(|entry| entry.path())
48        .filter(|path| {
49            path.extension()
50                .is_some_and(|ext| ext.eq_ignore_ascii_case("ico"))
51        })
52        .collect();
53    ico_files.sort();
54    candidates.extend(ico_files);
55    for path in candidates {
56        match image::open(&path) {
57            Ok(image) => return image.into_rgba8(),
58            Err(error) => log::warn!("Could not load window icon {}: {error}", path.display()),
59        }
60    }
61    image::load_from_memory_with_format(
62        include_bytes!("../../../icon/Icon.png"),
63        image::ImageFormat::Png,
64    )
65    .expect("bundled Siglus icon")
66    .into_rgba8()
67}
68
69#[cfg(target_os = "linux")]
70fn application_id(project_dir: &Path) -> String {
71    use std::os::unix::ffi::OsStrExt;
72    // Stable FNV-1a, unlike DefaultHasher whose algorithm may change between
73    // releases. Different games must not share taskbar grouping or icon caches.
74    let hash = project_dir
75        .as_os_str()
76        .as_bytes()
77        .iter()
78        .fold(0xcbf29ce484222325u64, |hash, byte| {
79            (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
80        });
81    format!("org.siglus_rs.game.g{hash:016x}")
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn game_ico_is_loaded_and_invalid_icons_fall_back() {
90        let root = std::env::temp_dir().join(format!("siglus-icon-test-{}", std::process::id()));
91        std::fs::create_dir_all(&root).unwrap();
92        std::fs::write(root.join("icon.png"), b"invalid").unwrap();
93        let expected = image::RgbaImage::from_pixel(32, 32, image::Rgba([20, 40, 60, 255]));
94        expected.save(root.join("train.ICO")).unwrap();
95        assert_eq!(load_icon(&root), expected);
96        std::fs::remove_file(root.join("train.ICO")).unwrap();
97        assert!(load_icon(&root).width() > 0);
98        std::fs::remove_dir_all(root).unwrap();
99    }
100}