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));
}
}