feat(bundler): support Liquid Glass icons, closes #14207 (#14671)

* 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

* fmt

* fix build

* add version checks

* fmt

* fix icns fallback

* fmt
This commit is contained in:
Lucas Fernandes Nogueira
2026-03-26 13:58:58 -03:00
committed by GitHub
parent 5dc2cee603
commit 5a0ca7edbb
8 changed files with 308 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"tauri-bundler": minor:feat
---
Added support to Liquid Glass icons.

View File

@@ -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<Vec<PathBuf>> {
let bin_dir = bundle_directory.join("MacOS");
let mut sign_paths = Vec::new();
let bundle_icon_file: Option<PathBuf> =
{ 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<PathBuf>,
assets_car_file: Option<PathBuf>,
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(),

View File

@@ -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<Op
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().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> {
};
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<Option<PathBuf>> {
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<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 {
// 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<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())
}
/// 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<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> {
// 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());
}
}

View File

@@ -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)?)?;

View File

@@ -804,6 +804,8 @@ pub struct Settings {
local_tools_directory: Option<PathBuf>,
/// the bundle settings.
bundle_settings: BundleSettings,
/// Same as `bundle_settings.icon`, but without the .icon directory.
icon_files: Option<Vec<String>>,
/// the binaries to bundle.
binaries: Vec<BundleBinary>,
/// 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<String>> {
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),
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -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"
}
}

View File

@@ -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": {