diff --git a/.changes/liquid-glass-icon.md b/.changes/liquid-glass-icon.md new file mode 100644 index 000000000..ab00b0fb3 --- /dev/null +++ b/.changes/liquid-glass-icon.md @@ -0,0 +1,5 @@ +--- +"tauri-bundler": minor:feat +--- + +Added support to Liquid Glass icons. diff --git a/crates/tauri-bundler/src/bundle/macos/app.rs b/crates/tauri-bundler/src/bundle/macos/app.rs index c90f652d8..5c6581fb6 100644 --- a/crates/tauri-bundler/src/bundle/macos/app.rs +++ b/crates/tauri-bundler/src/bundle/macos/app.rs @@ -23,7 +23,7 @@ // files into the `Contents` directory of the bundle. use super::{ - icon::create_icns_file, + icon::{app_icon_name_from_assets_car, create_assets_car_file, create_icns_file}, sign::{notarize, notarize_auth, notarize_without_stapling, sign, SignTarget}, }; use crate::{ @@ -76,11 +76,19 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { let bin_dir = bundle_directory.join("MacOS"); let mut sign_paths = Vec::new(); - let bundle_icon_file: Option = - { create_icns_file(&resources_dir, settings).with_context(|| "Failed to create app icon")? }; + let bundle_icon_file = + create_icns_file(&resources_dir, settings).with_context(|| "Failed to create app icon")?; - create_info_plist(&bundle_directory, bundle_icon_file, settings) - .with_context(|| "Failed to create Info.plist")?; + let assets_car_file = create_assets_car_file(&resources_dir, settings) + .with_context(|| "Failed to create app Assets.car")?; + + create_info_plist( + &bundle_directory, + bundle_icon_file, + assets_car_file, + settings, + ) + .with_context(|| "Failed to create Info.plist")?; let framework_paths = copy_frameworks_to_bundle(&bundle_directory, settings) .with_context(|| "Failed to bundle frameworks")?; @@ -204,6 +212,7 @@ fn copy_custom_files_to_bundle(bundle_directory: &Path, settings: &Settings) -> fn create_info_plist( bundle_dir: &Path, bundle_icon_file: Option, + assets_car_file: Option, settings: &Settings, ) -> crate::Result<()> { let mut plist = plist::Dictionary::new(); @@ -213,17 +222,6 @@ fn create_info_plist( "CFBundleExecutable".into(), settings.main_binary_name()?.into(), ); - if let Some(path) = bundle_icon_file { - plist.insert( - "CFBundleIconFile".into(), - path - .file_name() - .expect("No file name") - .to_string_lossy() - .into_owned() - .into(), - ); - } plist.insert( "CFBundleIdentifier".into(), settings.bundle_identifier().into(), @@ -362,6 +360,27 @@ fn create_info_plist( ); } + if let Some(path) = bundle_icon_file { + plist.insert( + "CFBundleIconFile".into(), + path + .file_name() + .expect("No file name") + .to_string_lossy() + .into_owned() + .into(), + ); + } + + if let Some(assets_car_file) = assets_car_file { + if let Some(icon_name) = app_icon_name_from_assets_car(&assets_car_file) { + // only set CFBundleIconName for the Assets.car, CFBundleIconFile is the fallback icns file + plist.insert("CFBundleIconName".into(), icon_name.clone().into()); + } else { + log::warn!("Failed to get icon name from Assets.car file"); + } + } + if let Some(protocols) = settings.deep_link_protocols() { plist.insert( "CFBundleURLTypes".into(), diff --git a/crates/tauri-bundler/src/bundle/macos/icon.rs b/crates/tauri-bundler/src/bundle/macos/icon.rs index c226fb233..69278ec05 100644 --- a/crates/tauri-bundler/src/bundle/macos/icon.rs +++ b/crates/tauri-bundler/src/bundle/macos/icon.rs @@ -4,13 +4,14 @@ // SPDX-License-Identifier: MIT use crate::bundle::Settings; -use crate::utils::{self, fs_utils}; +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; @@ -63,6 +64,11 @@ pub fn create_icns_file(out_dir: &Path, settings: &Settings) -> crate::Result = vec![]; for icon_path in settings.icon_files() { let icon_path = icon_path?; + + if icon_path.extension().map_or(false, |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(); @@ -113,3 +119,206 @@ fn make_icns_image(img: image::DynamicImage) -> io::Result { }; icns::Image::from_data(pixel_format, img.width(), img.height(), img.into_bytes()) } + +/// Creates an Assets.car file from a .icon file if there are any in the settings. +/// Uses an existing Assets.car file if it exists in the settings. +/// Returns the path to the Assets.car file. +pub fn create_assets_car_file( + out_dir: &Path, + settings: &Settings, +) -> crate::Result> { + let Some(icons) = settings.icons() else { + return Ok(None); + }; + // If one of the icon files is already a CAR file, just use that. + 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); + }; + + // Check actool version - must be >= 26 + if let Some(version) = get_actool_version() { + // Parse the major version number (before the dot) + let major_version: Option = 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 { + // If we can't parse the version, return None to be safe + 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"); + // If we can't get the version, return None to be safe + return Ok(None); + } + + // Create a temporary directory for actool work + 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"); + + // Copy the input .icon directory to the temp directory + 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() + ))); + } + + // Create the output directory + fs::create_dir_all(&output_path)?; + + // Run actool command + 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(), + )); + } + + // copy to out_dir + 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 { + 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 = 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()) +} + +/// Returns the actool short bundle version by running `actool --version --output-format=human-readable-text`. +/// Returns `None` if the command fails or the output cannot be parsed. +pub fn get_actool_version() -> Option { + 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 { + // The output format is: + // /* com.apple.actool.version */ + // bundle-version: 24411 + // short-bundle-version: 26.1 + 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()); + } +} diff --git a/crates/tauri-bundler/src/bundle/macos/ios.rs b/crates/tauri-bundler/src/bundle/macos/ios.rs index ac035127a..644f85027 100644 --- a/crates/tauri-bundler/src/bundle/macos/ios.rs +++ b/crates/tauri-bundler/src/bundle/macos/ios.rs @@ -106,7 +106,10 @@ fn generate_icon_files(bundle_dir: &Path, settings: &Settings) -> crate::Result< // Fall back to non-PNG files for any missing sizes. for icon_path in settings.icon_files() { let icon_path = icon_path?; - if icon_path.extension() == Some(OsStr::new("png")) { + if icon_path + .extension() + .map_or(false, |ext| ext == "png" || ext == "car") + { continue; } else if icon_path.extension() == Some(OsStr::new("icns")) { let icon_family = icns::IconFamily::read(File::open(&icon_path)?)?; diff --git a/crates/tauri-bundler/src/bundle/settings.rs b/crates/tauri-bundler/src/bundle/settings.rs index 4b4804d17..d94708dae 100644 --- a/crates/tauri-bundler/src/bundle/settings.rs +++ b/crates/tauri-bundler/src/bundle/settings.rs @@ -804,6 +804,8 @@ pub struct Settings { local_tools_directory: Option, /// the bundle settings. bundle_settings: BundleSettings, + /// Same as `bundle_settings.icon`, but without the .icon directory. + icon_files: Option>, /// the binaries to bundle. binaries: Vec, /// The target platform. @@ -915,6 +917,14 @@ impl SettingsBuilder { }; let target_platform = TargetPlatform::from_triple(&target); + let icon_files = self.bundle_settings.icon.as_ref().map(|paths| { + paths + .iter() + .filter(|p| !p.ends_with(".icon")) + .cloned() + .collect() + }); + Ok(Settings { log_level: self.log_level.unwrap_or(log::Level::Error), package: self @@ -934,6 +944,7 @@ impl SettingsBuilder { .map(|bins| external_binaries(bins, &target, &target_platform)), ..self.bundle_settings }, + icon_files, target_platform, target, no_sign: self.no_sign, @@ -967,6 +978,11 @@ impl Settings { &self.target_platform } + /// Raw list of icons. + pub fn icons(&self) -> Option<&Vec> { + self.bundle_settings.icon.as_ref() + } + /// Returns the architecture for the binary being bundled (e.g. "arm", "x86" or "x86_64"). pub fn binary_arch(&self) -> Arch { if self.target.starts_with("x86_64") { @@ -1101,7 +1117,7 @@ impl Settings { /// Returns an iterator over the icon files to be used for this bundle. pub fn icon_files(&self) -> ResourcePaths<'_> { - match self.bundle_settings.icon { + match self.icon_files { Some(ref paths) => ResourcePaths::new(paths.as_slice(), false), None => ResourcePaths::new(&[], false), } diff --git a/examples/.icons/AppIcon.icon/Assets/icon.png b/examples/.icons/AppIcon.icon/Assets/icon.png new file mode 100644 index 000000000..d1756ce45 Binary files /dev/null and b/examples/.icons/AppIcon.icon/Assets/icon.png differ diff --git a/examples/.icons/AppIcon.icon/icon.json b/examples/.icons/AppIcon.icon/icon.json new file mode 100644 index 000000000..08f040f23 --- /dev/null +++ b/examples/.icons/AppIcon.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "blend-mode": "normal", + "fill": "automatic", + "glass": false, + "hidden": false, + "image-name": "icon.png", + "name": "icon", + "position": { + "scale": 2, + "translation-in-points": [0, 0] + } + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index 936e73af4..babd73b07 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -81,7 +81,8 @@ "../../.icons/128x128.png", "../../.icons/128x128@2x.png", "../../.icons/icon.icns", - "../../.icons/icon.ico" + "../../.icons/icon.ico", + "../../.icons/AppIcon.icon" ], "windows": { "wix": {