flatland3-gfx 0.2.126

Flatland3 Macroquad + egui graphical play client
//! Flatland3 window / taskbar icon for miniquad.
//!
//! GNOME (especially on Wayland) ignores `_NET_WM_ICON` and looks up a
//! `.desktop` file whose ID matches the window `WM_CLASS` / app_id.

use miniquad::conf::Icon;

const SMALL: [u8; 16 * 16 * 4] = *include_bytes!("../icon/icon-16.rgba");
const MEDIUM: [u8; 32 * 32 * 4] = *include_bytes!("../icon/icon-32.rgba");
const BIG: [u8; 64 * 64 * 4] = *include_bytes!("../icon/icon-64.rgba");

/// Must match [`miniquad::conf::Platform::linux_wm_class`] and the `.desktop` file ID.
pub const LINUX_WM_CLASS: &str = "Flatland3";

pub fn window_icon() -> Icon {
    Icon {
        small: SMALL,
        medium: MEDIUM,
        big: BIG,
    }
}

/// Install a user `.desktop` + hicolor icons so GNOME/KDE can show the F3 mark.
///
/// Call this from `window_conf` **before** the window is created so the shell
/// can match the new surface to the desktop file.
#[cfg(target_os = "linux")]
pub fn install_linux_desktop() {
    let Some(data) = dirs::data_local_dir() else {
        return;
    };
    let apps = data.join("applications");
    let icons_root = data.join("icons").join("hicolor");
    if std::fs::create_dir_all(&apps).is_err() {
        return;
    }

    const PNGS: &[(u32, &[u8])] = &[
        (16, include_bytes!("../icon/icon-16.png")),
        (32, include_bytes!("../icon/icon-32.png")),
        (48, include_bytes!("../icon/icon-48.png")),
        (64, include_bytes!("../icon/icon-64.png")),
        (128, include_bytes!("../icon/icon-128.png")),
        (256, include_bytes!("../icon/icon-256.png")),
    ];

    let mut icon_256 = None;
    for &(size, bytes) in PNGS {
        let dir = icons_root.join(format!("{size}x{size}")).join("apps");
        if std::fs::create_dir_all(&dir).is_err() {
            continue;
        }
        for name in [LINUX_WM_CLASS, "flatland3"] {
            let path = dir.join(format!("{name}.png"));
            let _ = write_if_changed(&path, bytes);
            if size == 256 && name == LINUX_WM_CLASS {
                icon_256 = Some(path);
            }
        }
    }

    let exec = desktop_exec();
    let icon_key = icon_256
        .as_ref()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|| LINUX_WM_CLASS.to_string());
    let desktop = desktop_entry(&exec, &icon_key);
    let desktop_path = apps.join(format!("{LINUX_WM_CLASS}.desktop"));
    let _ = write_if_changed(&desktop_path, desktop.as_bytes());
    // Avoid a second app-menu row from the older lowercase filename.
    let _ = std::fs::remove_file(apps.join("flatland3.desktop"));
}

#[cfg(target_os = "linux")]
fn write_if_changed(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
    if std::fs::read(path).ok().as_deref() == Some(contents) {
        return Ok(());
    }
    std::fs::write(path, contents)
}

#[cfg(target_os = "linux")]
fn desktop_exec() -> String {
    std::env::current_exe()
        .ok()
        .and_then(|p| p.canonicalize().ok())
        .map(|p| quote_desktop_exec(&p.to_string_lossy()))
        .unwrap_or_else(|| "flatland3-gfx".to_string())
}

fn quote_desktop_exec(path: &str) -> String {
    const SPECIAL: &str = " \"'\\><~|&;$*?#()";
    if path.chars().any(|c| SPECIAL.contains(c)) {
        let escaped = path.replace('\\', r"\\").replace('"', "\\\"");
        format!("\"{escaped}\"")
    } else {
        path.to_string()
    }
}

fn desktop_entry(exec: &str, icon: &str) -> String {
    let exec_line = format!("Exec={exec}");
    let icon_line = format!("Icon={icon}");
    let class_line = format!("StartupWMClass={LINUX_WM_CLASS}");
    [
        "[Desktop Entry]",
        "Type=Application",
        "Name=Flatland3",
        "Comment=Flatland3 graphical play client",
        exec_line.as_str(),
        icon_line.as_str(),
        "Terminal=false",
        "Categories=Game;",
        "Keywords=MMO;MMORPG;Flatland;",
        class_line.as_str(),
        "StartupNotify=true",
        "",
    ]
    .join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn icon_rgba_sizes() {
        assert_eq!(SMALL.len(), 1024);
        assert_eq!(MEDIUM.len(), 4096);
        assert_eq!(BIG.len(), 16384);
        assert_eq!(SMALL[3], 0, "top-left 16px pixel should be transparent");
        assert_eq!(window_icon().big.len(), BIG.len());
    }

    #[test]
    fn desktop_entry_matches_wm_class() {
        let text = desktop_entry("/tmp/flatland3-gfx", "/tmp/Flatland3.png");
        assert!(text.contains("StartupWMClass=Flatland3"));
        assert!(text.contains("Name=Flatland3"));
        assert!(text.contains("Exec=/tmp/flatland3-gfx"));
        assert_eq!(quote_desktop_exec("/tmp/my game/x"), "\"/tmp/my game/x\"");
    }
}