|
4 | 4 | // SPDX-License-Identifier: MIT |
5 | 5 |
|
6 | 6 | use crate::bundle::Settings; |
7 | | -use crate::utils::{self, fs_utils}; |
| 7 | +use crate::utils::{self, fs_utils, CommandExt}; |
8 | 8 | use std::{ |
9 | 9 | cmp::min, |
10 | 10 | ffi::OsStr, |
11 | 11 | fs::{self, File}, |
12 | 12 | io::{self, BufWriter}, |
13 | 13 | path::{Path, PathBuf}, |
| 14 | + process::Command, |
14 | 15 | }; |
15 | 16 |
|
16 | 17 | use image::GenericImageView; |
@@ -63,6 +64,11 @@ pub fn create_icns_file(out_dir: &Path, settings: &Settings) -> crate::Result<Op |
63 | 64 | let mut images_to_resize: Vec<(image::DynamicImage, u32, u32)> = vec![]; |
64 | 65 | for icon_path in settings.icon_files() { |
65 | 66 | let icon_path = icon_path?; |
| 67 | + |
| 68 | + if icon_path.extension().map_or(false, |ext| ext == "car") { |
| 69 | + continue; |
| 70 | + } |
| 71 | + |
66 | 72 | let icon = image::open(&icon_path)?; |
67 | 73 | let density = if utils::is_retina(&icon_path) { 2 } else { 1 }; |
68 | 74 | let (w, h) = icon.dimensions(); |
@@ -113,3 +119,206 @@ fn make_icns_image(img: image::DynamicImage) -> io::Result<icns::Image> { |
113 | 119 | }; |
114 | 120 | icns::Image::from_data(pixel_format, img.width(), img.height(), img.into_bytes()) |
115 | 121 | } |
| 122 | + |
| 123 | +/// Creates an Assets.car file from a .icon file if there are any in the settings. |
| 124 | +/// Uses an existing Assets.car file if it exists in the settings. |
| 125 | +/// Returns the path to the Assets.car file. |
| 126 | +pub fn create_assets_car_file( |
| 127 | + out_dir: &Path, |
| 128 | + settings: &Settings, |
| 129 | +) -> crate::Result<Option<PathBuf>> { |
| 130 | + let Some(icons) = settings.icons() else { |
| 131 | + return Ok(None); |
| 132 | + }; |
| 133 | + // If one of the icon files is already a CAR file, just use that. |
| 134 | + let mut icon_composer_icon_path = None; |
| 135 | + for icon in icons { |
| 136 | + let icon_path = Path::new(&icon).to_path_buf(); |
| 137 | + if icon_path.extension() == Some(OsStr::new("car")) { |
| 138 | + let dest_path = out_dir.join("Assets.car"); |
| 139 | + fs_utils::copy_file(&icon_path, &dest_path)?; |
| 140 | + return Ok(Some(dest_path)); |
| 141 | + } |
| 142 | + |
| 143 | + if icon_path.extension() == Some(OsStr::new("icon")) { |
| 144 | + icon_composer_icon_path.replace(icon_path); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + let Some(icon_composer_icon_path) = icon_composer_icon_path else { |
| 149 | + return Ok(None); |
| 150 | + }; |
| 151 | + |
| 152 | + // Check actool version - must be >= 26 |
| 153 | + if let Some(version) = get_actool_version() { |
| 154 | + // Parse the major version number (before the dot) |
| 155 | + let major_version: Option<u32> = version.split('.').next().and_then(|s| s.parse().ok()); |
| 156 | + |
| 157 | + if let Some(major) = major_version { |
| 158 | + if major < 26 { |
| 159 | + log::error!("actool version is less than 26, skipping Assets.car file creation. Please update Xcode to 26 or above and try again."); |
| 160 | + return Ok(None); |
| 161 | + } |
| 162 | + } else { |
| 163 | + // If we can't parse the version, return None to be safe |
| 164 | + log::error!("failed to parse actool version, skipping Assets.car file creation"); |
| 165 | + return Ok(None); |
| 166 | + } |
| 167 | + } else { |
| 168 | + log::error!("failed to get actool version, skipping Assets.car file creation"); |
| 169 | + // If we can't get the version, return None to be safe |
| 170 | + return Ok(None); |
| 171 | + } |
| 172 | + |
| 173 | + // Create a temporary directory for actool work |
| 174 | + let temp_dir = tempfile::tempdir() |
| 175 | + .map_err(|e| crate::Error::GenericError(format!("failed to create temp dir: {e}")))?; |
| 176 | + |
| 177 | + let icon_dest_path = temp_dir.path().join("Icon.icon"); |
| 178 | + let output_path = temp_dir.path().join("out"); |
| 179 | + |
| 180 | + // Copy the input .icon directory to the temp directory |
| 181 | + if icon_composer_icon_path.is_dir() { |
| 182 | + fs_utils::copy_dir(&icon_composer_icon_path, &icon_dest_path)?; |
| 183 | + } else { |
| 184 | + return Err(crate::Error::GenericError(format!( |
| 185 | + "{} must be a directory", |
| 186 | + icon_composer_icon_path.display() |
| 187 | + ))); |
| 188 | + } |
| 189 | + |
| 190 | + // Create the output directory |
| 191 | + fs::create_dir_all(&output_path)?; |
| 192 | + |
| 193 | + // Run actool command |
| 194 | + let mut cmd = Command::new("actool"); |
| 195 | + cmd.arg(&icon_dest_path); |
| 196 | + cmd.arg("--compile"); |
| 197 | + cmd.arg(&output_path); |
| 198 | + cmd.arg("--output-format"); |
| 199 | + cmd.arg("human-readable-text"); |
| 200 | + cmd.arg("--notices"); |
| 201 | + cmd.arg("--warnings"); |
| 202 | + cmd.arg("--output-partial-info-plist"); |
| 203 | + cmd.arg(output_path.join("assetcatalog_generated_info.plist")); |
| 204 | + cmd.arg("--app-icon"); |
| 205 | + cmd.arg("Icon"); |
| 206 | + cmd.arg("--include-all-app-icons"); |
| 207 | + cmd.arg("--accent-color"); |
| 208 | + cmd.arg("AccentColor"); |
| 209 | + cmd.arg("--enable-on-demand-resources"); |
| 210 | + cmd.arg("NO"); |
| 211 | + cmd.arg("--development-region"); |
| 212 | + cmd.arg("en"); |
| 213 | + cmd.arg("--target-device"); |
| 214 | + cmd.arg("mac"); |
| 215 | + cmd.arg("--minimum-deployment-target"); |
| 216 | + cmd.arg("26.0"); |
| 217 | + cmd.arg("--platform"); |
| 218 | + cmd.arg("macosx"); |
| 219 | + |
| 220 | + cmd.output_ok()?; |
| 221 | + |
| 222 | + let assets_car_path = output_path.join("Assets.car"); |
| 223 | + if !assets_car_path.exists() { |
| 224 | + return Err(crate::Error::GenericError( |
| 225 | + "actool did not generate Assets.car file".to_owned(), |
| 226 | + )); |
| 227 | + } |
| 228 | + |
| 229 | + // copy to out_dir |
| 230 | + fs_utils::copy_file(&assets_car_path, &out_dir.join("Assets.car"))?; |
| 231 | + |
| 232 | + Ok(Some(out_dir.join("Assets.car"))) |
| 233 | +} |
| 234 | + |
| 235 | +#[derive(serde::Deserialize)] |
| 236 | +struct AssetsCarInfo { |
| 237 | + #[serde(rename = "AssetType", default)] |
| 238 | + asset_type: String, |
| 239 | + #[serde(rename = "Name", default)] |
| 240 | + name: String, |
| 241 | +} |
| 242 | + |
| 243 | +pub fn app_icon_name_from_assets_car(assets_car_path: &Path) -> Option<String> { |
| 244 | + let Ok(output) = Command::new("assetutil") |
| 245 | + .arg("--info") |
| 246 | + .arg(assets_car_path) |
| 247 | + .output_ok() |
| 248 | + .inspect_err(|e| log::error!("Failed to get app icon name from Assets.car file: {e}")) |
| 249 | + else { |
| 250 | + return None; |
| 251 | + }; |
| 252 | + |
| 253 | + let output = String::from_utf8(output.stdout).ok()?; |
| 254 | + let assets_car_info: Vec<AssetsCarInfo> = serde_json::from_str(&output) |
| 255 | + .inspect_err(|e| log::error!("Failed to parse Assets.car file info: {e}")) |
| 256 | + .ok()?; |
| 257 | + assets_car_info |
| 258 | + .iter() |
| 259 | + .find(|info| info.asset_type == "Icon Image") |
| 260 | + .map(|info| info.name.clone()) |
| 261 | +} |
| 262 | + |
| 263 | +/// Returns the actool short bundle version by running `actool --version --output-format=human-readable-text`. |
| 264 | +/// Returns `None` if the command fails or the output cannot be parsed. |
| 265 | +pub fn get_actool_version() -> Option<String> { |
| 266 | + let Ok(output) = Command::new("actool") |
| 267 | + .arg("--version") |
| 268 | + .arg("--output-format=human-readable-text") |
| 269 | + .output_ok() |
| 270 | + .inspect_err(|e| log::error!("Failed to get actool version: {e}")) |
| 271 | + else { |
| 272 | + return None; |
| 273 | + }; |
| 274 | + |
| 275 | + let output = String::from_utf8(output.stdout).ok()?; |
| 276 | + parse_actool_version(&output) |
| 277 | +} |
| 278 | + |
| 279 | +fn parse_actool_version(output: &str) -> Option<String> { |
| 280 | + // The output format is: |
| 281 | + // /* com.apple.actool.version */ |
| 282 | + // bundle-version: 24411 |
| 283 | + // short-bundle-version: 26.1 |
| 284 | + for line in output.lines() { |
| 285 | + let line = line.trim(); |
| 286 | + if let Some(version) = line.strip_prefix("short-bundle-version:") { |
| 287 | + return Some(version.trim().to_string()); |
| 288 | + } |
| 289 | + } |
| 290 | + |
| 291 | + None |
| 292 | +} |
| 293 | + |
| 294 | +#[cfg(test)] |
| 295 | +mod tests { |
| 296 | + use super::*; |
| 297 | + |
| 298 | + #[test] |
| 299 | + fn test_parse_actool_version() { |
| 300 | + let output = r#"/* com.apple.actool.version */ |
| 301 | +some other line |
| 302 | +bundle-version: 24411 |
| 303 | +short-bundle-version: 26.1 |
| 304 | +another line |
| 305 | +"#; |
| 306 | + |
| 307 | + let version = parse_actool_version(output).expect("Failed to parse version"); |
| 308 | + assert_eq!(version, "26.1"); |
| 309 | + } |
| 310 | + |
| 311 | + #[test] |
| 312 | + fn test_parse_actool_version_missing_fields() { |
| 313 | + let output = r#"/* com.apple.actool.version */ |
| 314 | +bundle-version: 24411 |
| 315 | +"#; |
| 316 | + |
| 317 | + assert!(parse_actool_version(output).is_none()); |
| 318 | + } |
| 319 | + |
| 320 | + #[test] |
| 321 | + fn test_parse_actool_version_empty() { |
| 322 | + assert!(parse_actool_version("").is_none()); |
| 323 | + } |
| 324 | +} |
0 commit comments