doom-eternal 1.4.0

Rust CLI for the Xylex DOOM Eternal texture and install workflow
Documentation
use std::{
    fs,
    io::BufWriter,
    path::{Path, PathBuf},
    process::Command,
};

use image::{
    imageops::{overlay, resize, FilterType},
    ImageFormat, Rgba, RgbaImage,
};
use tempfile::NamedTempFile;

use crate::{
    context::AppContext,
    error::{DoomError, Result},
};

const DEFAULT_SOURCE_ICON: &str = "assets/source/logos/XYLEX_GROUP_ICON_TRANSPARENT_WHITE.png";
const DEFAULT_FAVICON_OUTPUT: &str = "desktop/public/favicon.png";
const DEFAULT_ICON_OUTPUT_DIR: &str = "desktop/src-tauri/icons";
const DEFAULT_DESKTOP_ROOT: &str = "desktop";
const DEFAULT_ICON_SIZE: u32 = 1024;
const DEFAULT_ICON_PADDING: u32 = 96;

#[derive(Debug, Clone)]
pub struct BrandIconsRequest {
    pub source_icon: PathBuf,
    pub favicon_output: PathBuf,
    pub icon_output_dir: PathBuf,
    pub size: u32,
    pub padding: u32,
    pub color: String,
    pub skip_tauri: bool,
}

impl Default for BrandIconsRequest {
    fn default() -> Self {
        Self {
            source_icon: PathBuf::from(DEFAULT_SOURCE_ICON),
            favicon_output: PathBuf::from(DEFAULT_FAVICON_OUTPUT),
            icon_output_dir: PathBuf::from(DEFAULT_ICON_OUTPUT_DIR),
            size: DEFAULT_ICON_SIZE,
            padding: DEFAULT_ICON_PADDING,
            color: "#d72638".to_string(),
            skip_tauri: false,
        }
    }
}

pub fn brand_icons(ctx: &AppContext, request: BrandIconsRequest) -> Result<()> {
    let source_icon = ctx.repo().repo_path(request.source_icon);
    let favicon_output = ctx.repo().repo_path(request.favicon_output);
    let icon_output_dir = ctx.repo().repo_path(request.icon_output_dir);
    let desktop_root = ctx.repo().repo_path(DEFAULT_DESKTOP_ROOT);
    let color = parse_hex_color(&request.color)?;

    if !source_icon.is_file() {
        return Err(DoomError::message(format!(
            "Missing source icon: {}",
            ctx.console().shorten_path(&source_icon)
        )));
    }

    let favicon = build_tinted_icon(&source_icon, request.size, request.padding, color)?;
    write_png_atomic(&favicon, &favicon_output)?;
    ctx.console().log_success(format!(
        "Wrote desktop favicon source: {}",
        ctx.console().format_path(&favicon_output)
    ));

    if request.skip_tauri {
        return Ok(());
    }

    regenerate_tauri_icons(ctx, &desktop_root, &favicon_output, &icon_output_dir)?;
    ctx.console().log_success(format!(
        "Updated Tauri icon set: {}",
        ctx.console().format_path(&icon_output_dir)
    ));
    Ok(())
}

fn parse_hex_color(value: &str) -> Result<Rgba<u8>> {
    let normalized = value.trim().trim_start_matches('#');
    if normalized.len() != 6 {
        return Err(DoomError::message(format!(
            "Expected a 6-digit hex color, got: {value}"
        )));
    }

    let red = u8::from_str_radix(&normalized[0..2], 16)
        .map_err(|_| DoomError::message(format!("Invalid hex color: {value}")))?;
    let green = u8::from_str_radix(&normalized[2..4], 16)
        .map_err(|_| DoomError::message(format!("Invalid hex color: {value}")))?;
    let blue = u8::from_str_radix(&normalized[4..6], 16)
        .map_err(|_| DoomError::message(format!("Invalid hex color: {value}")))?;
    Ok(Rgba([red, green, blue, 255]))
}

fn build_tinted_icon(
    input_path: &Path,
    size: u32,
    padding: u32,
    color: Rgba<u8>,
) -> Result<RgbaImage> {
    let source = image::open(input_path)?.into_rgba8();
    let mut canvas = RgbaImage::from_pixel(size, size, Rgba([0, 0, 0, 0]));
    let usable_size = size.saturating_sub(padding.saturating_mul(2)).max(1);
    let scale = (usable_size as f32 / source.width() as f32)
        .min(usable_size as f32 / source.height() as f32);
    let draw_width = ((source.width() as f32 * scale).round() as u32).max(1);
    let draw_height = ((source.height() as f32 * scale).round() as u32).max(1);
    let resized = resize(&source, draw_width, draw_height, FilterType::Lanczos3);
    let mut tinted = RgbaImage::from_pixel(draw_width, draw_height, Rgba([0, 0, 0, 0]));

    for (x, y, pixel) in resized.enumerate_pixels() {
        tinted.put_pixel(x, y, Rgba([color[0], color[1], color[2], pixel[3]]));
    }

    let draw_x = (size.saturating_sub(draw_width) / 2) as i64;
    let draw_y = (size.saturating_sub(draw_height) / 2) as i64;
    overlay(&mut canvas, &tinted, draw_x, draw_y);
    Ok(canvas)
}

fn write_png_atomic(image: &RgbaImage, output_path: &Path) -> Result<()> {
    let parent = output_path.parent().ok_or_else(|| {
        DoomError::message(format!(
            "Output path has no parent directory: {}",
            output_path.display()
        ))
    })?;
    fs::create_dir_all(parent)?;
    let mut temp_file = NamedTempFile::new_in(parent)?;
    {
        let mut writer = BufWriter::new(temp_file.as_file_mut());
        image::DynamicImage::ImageRgba8(image.clone()).write_to(&mut writer, ImageFormat::Png)?;
    }
    temp_file
        .persist(output_path)
        .map_err(|error| DoomError::Io(error.error))?;
    Ok(())
}

fn regenerate_tauri_icons(
    ctx: &AppContext,
    desktop_root: &Path,
    source_icon_path: &Path,
    output_dir: &Path,
) -> Result<()> {
    ctx.console().log_info(format!(
        "Generating native icons from {}",
        ctx.console().format_path(source_icon_path)
    ));

    let mut command = if cfg!(windows) {
        let mut command = Command::new("cmd");
        command.arg("/c").arg("pnpm");
        command
    } else {
        Command::new("pnpm")
    };

    let status = command
        .arg("exec")
        .arg("tauri")
        .arg("icon")
        .arg("--output")
        .arg(output_dir)
        .arg(source_icon_path)
        .current_dir(desktop_root)
        .status()?;

    if !status.success() {
        return Err(DoomError::message(format!(
            "`pnpm exec tauri icon` failed with exit code {}",
            status.code().unwrap_or(-1)
        )));
    }

    remove_unused_generated_assets(output_dir)?;
    Ok(())
}

fn remove_unused_generated_assets(output_dir: &Path) -> Result<()> {
    for candidate in [
        output_dir.join("64x64.png"),
        output_dir.join("android"),
        output_dir.join("ios"),
    ] {
        if candidate.is_dir() {
            fs::remove_dir_all(candidate)?;
        } else if candidate.is_file() {
            fs::remove_file(candidate)?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::{build_tinted_icon, parse_hex_color};

    #[test]
    fn parses_hex_color() {
        let color = parse_hex_color("#d72638").expect("valid color");
        assert_eq!(color.0, [0xd7, 0x26, 0x38, 0xff]);
    }

    #[test]
    fn builds_a_png_sized_icon() {
        let temp_dir = TempDir::new().expect("tempdir");
        let source_path = temp_dir.path().join("source.png");
        let source = image::RgbaImage::from_pixel(64, 32, image::Rgba([255, 255, 255, 200]));
        image::DynamicImage::ImageRgba8(source)
            .save(&source_path)
            .expect("save source");

        let icon = build_tinted_icon(
            &source_path,
            128,
            16,
            parse_hex_color("#d72638").expect("valid color"),
        )
        .expect("build icon");

        assert_eq!(icon.width(), 128);
        assert_eq!(icon.height(), 128);
        assert!(icon.pixels().any(|pixel| pixel[3] > 0));
    }
}