From 8254e5af6df4f780bf5bd879ada4ab69599b6545 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Thu, 18 Dec 2025 09:39:34 -0300 Subject: [PATCH] feat(bundler): support Liquid Glass icons, closes #14207 the `icon` config now supports loading an Assets.car directly or a `.icon` (Icon Composer asset) that gets compiled into an Assets.car file --- .changes/liquid-glass-icon.md | 5 + crates/tauri-bundler/src/bundle/macos/app.rs | 51 ++++--- crates/tauri-bundler/src/bundle/macos/icon.rs | 127 +++++++++++++++++- crates/tauri-bundler/src/bundle/macos/ios.rs | 2 +- crates/tauri-bundler/src/bundle/settings.rs | 13 +- 5 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 .changes/liquid-glass-icon.md 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 703973c2b..8a5e2e40c 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) { + plist.insert("CFBundleIconName".into(), icon_name.clone().into()); + plist.insert("CFBundleIconFile".into(), icon_name.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..fa2390600 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,122 @@ 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.bundle_settings().icon.as_ref() 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); + }; + + // 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()) +} diff --git a/crates/tauri-bundler/src/bundle/macos/ios.rs b/crates/tauri-bundler/src/bundle/macos/ios.rs index ac035127a..e7cada1c3 100644 --- a/crates/tauri-bundler/src/bundle/macos/ios.rs +++ b/crates/tauri-bundler/src/bundle/macos/ios.rs @@ -106,7 +106,7 @@ 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 62f7813d2..78cc27a83 100644 --- a/crates/tauri-bundler/src/bundle/settings.rs +++ b/crates/tauri-bundler/src/bundle/settings.rs @@ -795,6 +795,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. @@ -906,6 +908,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 @@ -925,6 +935,7 @@ impl SettingsBuilder { .map(|bins| external_binaries(bins, &target, &target_platform)), ..self.bundle_settings }, + icon_files, target_platform, target, no_sign: self.no_sign, @@ -1092,7 +1103,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), }