use crate::bundle::Settings;
use crate::utils::{self, fs_utils, CommandExt};
use std::{
cmp::min,
ffi::OsStr,
fs::{self, File},
io::{self, BufWriter},
path::{Path, PathBuf},
process::Command,
};
use image::GenericImageView;
pub fn create_icns_file(out_dir: &Path, settings: &Settings) -> crate::Result<Option<PathBuf>> {
if settings.icon_files().count() == 0 {
return Ok(None);
}
for icon_path in settings.icon_files() {
let icon_path = icon_path?;
if icon_path.extension() == Some(OsStr::new("icns")) {
let mut dest_path = out_dir.to_path_buf();
dest_path.push(icon_path.file_name().expect("Could not get icon filename"));
fs_utils::copy_file(&icon_path, &dest_path)?;
return Ok(Some(dest_path));
}
}
let mut family = icns::IconFamily::new();
fn add_icon_to_family(
icon: image::DynamicImage,
density: u32,
family: &mut icns::IconFamily,
) -> io::Result<()> {
match icns::IconType::from_pixel_size_and_density(icon.width(), icon.height(), density) {
Some(icon_type) => {
if !family.has_icon_with_type(icon_type) {
let icon = make_icns_image(icon)?;
family.add_icon_with_type(&icon, icon_type)?;
}
Ok(())
}
None => Err(io::Error::new(
io::ErrorKind::InvalidData,
"No matching IconType",
)),
}
}
let mut images_to_resize: Vec<(image::DynamicImage, u32, u32)> = vec![];
for icon_path in settings.icon_files() {
let icon_path = icon_path?;
if icon_path.extension().is_some_and(|ext| ext == "car") {
continue;
}
let icon = image::open(&icon_path)?;
let density = if utils::is_retina(&icon_path) { 2 } else { 1 };
let (w, h) = icon.dimensions();
let orig_size = min(w, h);
let next_size_down = 2f32.powf((orig_size as f32).log2().floor()) as u32;
if orig_size > next_size_down {
images_to_resize.push((icon, next_size_down, density));
} else {
add_icon_to_family(icon, density, &mut family)?;
}
}
for (icon, next_size_down, density) in images_to_resize {
let icon = icon.resize_exact(
next_size_down,
next_size_down,
image::imageops::FilterType::Lanczos3,
);
add_icon_to_family(icon, density, &mut family)?;
}
if !family.is_empty() {
fs::create_dir_all(out_dir)?;
let mut dest_path = out_dir.to_path_buf();
dest_path.push(settings.product_name());
dest_path.set_extension("icns");
let icns_file = BufWriter::new(File::create(&dest_path)?);
family.write(icns_file)?;
Ok(Some(dest_path))
} else {
Err(crate::Error::GenericError(
"No usable Icon files found".to_owned(),
))
}
}
fn make_icns_image(img: image::DynamicImage) -> io::Result<icns::Image> {
let pixel_format = match img.color() {
image::ColorType::Rgba8 => icns::PixelFormat::RGBA,
image::ColorType::Rgb8 => icns::PixelFormat::RGB,
image::ColorType::La8 => icns::PixelFormat::GrayAlpha,
image::ColorType::L8 => icns::PixelFormat::Gray,
_ => {
let msg = format!("unsupported ColorType: {:?}", img.color());
return Err(io::Error::new(io::ErrorKind::InvalidData, msg));
}
};
icns::Image::from_data(pixel_format, img.width(), img.height(), img.into_bytes())
}
pub fn create_assets_car_file(
out_dir: &Path,
settings: &Settings,
) -> crate::Result<Option<PathBuf>> {
let Some(icons) = settings.icons() else {
return Ok(None);
};
let mut icon_composer_icon_path = None;
for icon in icons {
let icon_path = Path::new(&icon).to_path_buf();
if icon_path.extension() == Some(OsStr::new("car")) {
let dest_path = out_dir.join("Assets.car");
fs_utils::copy_file(&icon_path, &dest_path)?;
return Ok(Some(dest_path));
}
if icon_path.extension() == Some(OsStr::new("icon")) {
icon_composer_icon_path.replace(icon_path);
}
}
let Some(icon_composer_icon_path) = icon_composer_icon_path else {
return Ok(None);
};
if let Some(version) = get_actool_version() {
let major_version: Option<u32> = version.split('.').next().and_then(|s| s.parse().ok());
if let Some(major) = major_version {
if major < 26 {
log::error!("actool version is less than 26, skipping Assets.car file creation. Please update Xcode to 26 or above and try again.");
return Ok(None);
}
} else {
log::error!("failed to parse actool version, skipping Assets.car file creation");
return Ok(None);
}
} else {
log::error!("failed to get actool version, skipping Assets.car file creation");
return Ok(None);
}
let temp_dir = tempfile::tempdir()
.map_err(|e| crate::Error::GenericError(format!("failed to create temp dir: {e}")))?;
let icon_dest_path = temp_dir.path().join("Icon.icon");
let output_path = temp_dir.path().join("out");
if icon_composer_icon_path.is_dir() {
fs_utils::copy_dir(&icon_composer_icon_path, &icon_dest_path)?;
} else {
return Err(crate::Error::GenericError(format!(
"{} must be a directory",
icon_composer_icon_path.display()
)));
}
fs::create_dir_all(&output_path)?;
let mut cmd = Command::new("actool");
cmd.arg(&icon_dest_path);
cmd.arg("--compile");
cmd.arg(&output_path);
cmd.arg("--output-format");
cmd.arg("human-readable-text");
cmd.arg("--notices");
cmd.arg("--warnings");
cmd.arg("--output-partial-info-plist");
cmd.arg(output_path.join("assetcatalog_generated_info.plist"));
cmd.arg("--app-icon");
cmd.arg("Icon");
cmd.arg("--include-all-app-icons");
cmd.arg("--accent-color");
cmd.arg("AccentColor");
cmd.arg("--enable-on-demand-resources");
cmd.arg("NO");
cmd.arg("--development-region");
cmd.arg("en");
cmd.arg("--target-device");
cmd.arg("mac");
cmd.arg("--minimum-deployment-target");
cmd.arg("26.0");
cmd.arg("--platform");
cmd.arg("macosx");
cmd.output_ok()?;
let assets_car_path = output_path.join("Assets.car");
if !assets_car_path.exists() {
return Err(crate::Error::GenericError(
"actool did not generate Assets.car file".to_owned(),
));
}
fs_utils::copy_file(&assets_car_path, &out_dir.join("Assets.car"))?;
Ok(Some(out_dir.join("Assets.car")))
}
#[derive(serde::Deserialize)]
struct AssetsCarInfo {
#[serde(rename = "AssetType", default)]
asset_type: String,
#[serde(rename = "Name", default)]
name: String,
}
pub fn app_icon_name_from_assets_car(assets_car_path: &Path) -> Option<String> {
let Ok(output) = Command::new("assetutil")
.arg("--info")
.arg(assets_car_path)
.output_ok()
.inspect_err(|e| log::error!("Failed to get app icon name from Assets.car file: {e}"))
else {
return None;
};
let output = String::from_utf8(output.stdout).ok()?;
let assets_car_info: Vec<AssetsCarInfo> = serde_json::from_str(&output)
.inspect_err(|e| log::error!("Failed to parse Assets.car file info: {e}"))
.ok()?;
assets_car_info
.iter()
.find(|info| info.asset_type == "Icon Image")
.map(|info| info.name.clone())
}
pub fn get_actool_version() -> Option<String> {
let Ok(output) = Command::new("actool")
.arg("--version")
.arg("--output-format=human-readable-text")
.output_ok()
.inspect_err(|e| log::error!("Failed to get actool version: {e}"))
else {
return None;
};
let output = String::from_utf8(output.stdout).ok()?;
parse_actool_version(&output)
}
fn parse_actool_version(output: &str) -> Option<String> {
for line in output.lines() {
let line = line.trim();
if let Some(version) = line.strip_prefix("short-bundle-version:") {
return Some(version.trim().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_actool_version() {
let output = r#"/* com.apple.actool.version */
some other line
bundle-version: 24411
short-bundle-version: 26.1
another line
"#;
let version = parse_actool_version(output).expect("Failed to parse version");
assert_eq!(version, "26.1");
}
#[test]
fn test_parse_actool_version_missing_fields() {
let output = r#"/* com.apple.actool.version */
bundle-version: 24411
"#;
assert!(parse_actool_version(output).is_none());
}
#[test]
fn test_parse_actool_version_empty() {
assert!(parse_actool_version("").is_none());
}
}