Skip to content

Commit 5a0ca7e

Browse files
authored
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
1 parent 5dc2cee commit 5a0ca7e

8 files changed

Lines changed: 308 additions & 20 deletions

File tree

.changes/liquid-glass-icon.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tauri-bundler": minor:feat
3+
---
4+
5+
Added support to Liquid Glass icons.

crates/tauri-bundler/src/bundle/macos/app.rs

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
// files into the `Contents` directory of the bundle.
2424

2525
use super::{
26-
icon::create_icns_file,
26+
icon::{app_icon_name_from_assets_car, create_assets_car_file, create_icns_file},
2727
sign::{notarize, notarize_auth, notarize_without_stapling, sign, SignTarget},
2828
};
2929
use crate::{
@@ -76,11 +76,19 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
7676
let bin_dir = bundle_directory.join("MacOS");
7777
let mut sign_paths = Vec::new();
7878

79-
let bundle_icon_file: Option<PathBuf> =
80-
{ create_icns_file(&resources_dir, settings).with_context(|| "Failed to create app icon")? };
79+
let bundle_icon_file =
80+
create_icns_file(&resources_dir, settings).with_context(|| "Failed to create app icon")?;
8181

82-
create_info_plist(&bundle_directory, bundle_icon_file, settings)
83-
.with_context(|| "Failed to create Info.plist")?;
82+
let assets_car_file = create_assets_car_file(&resources_dir, settings)
83+
.with_context(|| "Failed to create app Assets.car")?;
84+
85+
create_info_plist(
86+
&bundle_directory,
87+
bundle_icon_file,
88+
assets_car_file,
89+
settings,
90+
)
91+
.with_context(|| "Failed to create Info.plist")?;
8492

8593
let framework_paths = copy_frameworks_to_bundle(&bundle_directory, settings)
8694
.with_context(|| "Failed to bundle frameworks")?;
@@ -204,6 +212,7 @@ fn copy_custom_files_to_bundle(bundle_directory: &Path, settings: &Settings) ->
204212
fn create_info_plist(
205213
bundle_dir: &Path,
206214
bundle_icon_file: Option<PathBuf>,
215+
assets_car_file: Option<PathBuf>,
207216
settings: &Settings,
208217
) -> crate::Result<()> {
209218
let mut plist = plist::Dictionary::new();
@@ -213,17 +222,6 @@ fn create_info_plist(
213222
"CFBundleExecutable".into(),
214223
settings.main_binary_name()?.into(),
215224
);
216-
if let Some(path) = bundle_icon_file {
217-
plist.insert(
218-
"CFBundleIconFile".into(),
219-
path
220-
.file_name()
221-
.expect("No file name")
222-
.to_string_lossy()
223-
.into_owned()
224-
.into(),
225-
);
226-
}
227225
plist.insert(
228226
"CFBundleIdentifier".into(),
229227
settings.bundle_identifier().into(),
@@ -362,6 +360,27 @@ fn create_info_plist(
362360
);
363361
}
364362

363+
if let Some(path) = bundle_icon_file {
364+
plist.insert(
365+
"CFBundleIconFile".into(),
366+
path
367+
.file_name()
368+
.expect("No file name")
369+
.to_string_lossy()
370+
.into_owned()
371+
.into(),
372+
);
373+
}
374+
375+
if let Some(assets_car_file) = assets_car_file {
376+
if let Some(icon_name) = app_icon_name_from_assets_car(&assets_car_file) {
377+
// only set CFBundleIconName for the Assets.car, CFBundleIconFile is the fallback icns file
378+
plist.insert("CFBundleIconName".into(), icon_name.clone().into());
379+
} else {
380+
log::warn!("Failed to get icon name from Assets.car file");
381+
}
382+
}
383+
365384
if let Some(protocols) = settings.deep_link_protocols() {
366385
plist.insert(
367386
"CFBundleURLTypes".into(),

crates/tauri-bundler/src/bundle/macos/icon.rs

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
// SPDX-License-Identifier: MIT
55

66
use crate::bundle::Settings;
7-
use crate::utils::{self, fs_utils};
7+
use crate::utils::{self, fs_utils, CommandExt};
88
use std::{
99
cmp::min,
1010
ffi::OsStr,
1111
fs::{self, File},
1212
io::{self, BufWriter},
1313
path::{Path, PathBuf},
14+
process::Command,
1415
};
1516

1617
use image::GenericImageView;
@@ -63,6 +64,11 @@ pub fn create_icns_file(out_dir: &Path, settings: &Settings) -> crate::Result<Op
6364
let mut images_to_resize: Vec<(image::DynamicImage, u32, u32)> = vec![];
6465
for icon_path in settings.icon_files() {
6566
let icon_path = icon_path?;
67+
68+
if icon_path.extension().map_or(false, |ext| ext == "car") {
69+
continue;
70+
}
71+
6672
let icon = image::open(&icon_path)?;
6773
let density = if utils::is_retina(&icon_path) { 2 } else { 1 };
6874
let (w, h) = icon.dimensions();
@@ -113,3 +119,206 @@ fn make_icns_image(img: image::DynamicImage) -> io::Result<icns::Image> {
113119
};
114120
icns::Image::from_data(pixel_format, img.width(), img.height(), img.into_bytes())
115121
}
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+
}

crates/tauri-bundler/src/bundle/macos/ios.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@ fn generate_icon_files(bundle_dir: &Path, settings: &Settings) -> crate::Result<
106106
// Fall back to non-PNG files for any missing sizes.
107107
for icon_path in settings.icon_files() {
108108
let icon_path = icon_path?;
109-
if icon_path.extension() == Some(OsStr::new("png")) {
109+
if icon_path
110+
.extension()
111+
.map_or(false, |ext| ext == "png" || ext == "car")
112+
{
110113
continue;
111114
} else if icon_path.extension() == Some(OsStr::new("icns")) {
112115
let icon_family = icns::IconFamily::read(File::open(&icon_path)?)?;

crates/tauri-bundler/src/bundle/settings.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,8 @@ pub struct Settings {
804804
local_tools_directory: Option<PathBuf>,
805805
/// the bundle settings.
806806
bundle_settings: BundleSettings,
807+
/// Same as `bundle_settings.icon`, but without the .icon directory.
808+
icon_files: Option<Vec<String>>,
807809
/// the binaries to bundle.
808810
binaries: Vec<BundleBinary>,
809811
/// The target platform.
@@ -915,6 +917,14 @@ impl SettingsBuilder {
915917
};
916918
let target_platform = TargetPlatform::from_triple(&target);
917919

920+
let icon_files = self.bundle_settings.icon.as_ref().map(|paths| {
921+
paths
922+
.iter()
923+
.filter(|p| !p.ends_with(".icon"))
924+
.cloned()
925+
.collect()
926+
});
927+
918928
Ok(Settings {
919929
log_level: self.log_level.unwrap_or(log::Level::Error),
920930
package: self
@@ -934,6 +944,7 @@ impl SettingsBuilder {
934944
.map(|bins| external_binaries(bins, &target, &target_platform)),
935945
..self.bundle_settings
936946
},
947+
icon_files,
937948
target_platform,
938949
target,
939950
no_sign: self.no_sign,
@@ -967,6 +978,11 @@ impl Settings {
967978
&self.target_platform
968979
}
969980

981+
/// Raw list of icons.
982+
pub fn icons(&self) -> Option<&Vec<String>> {
983+
self.bundle_settings.icon.as_ref()
984+
}
985+
970986
/// Returns the architecture for the binary being bundled (e.g. "arm", "x86" or "x86_64").
971987
pub fn binary_arch(&self) -> Arch {
972988
if self.target.starts_with("x86_64") {
@@ -1101,7 +1117,7 @@ impl Settings {
11011117

11021118
/// Returns an iterator over the icon files to be used for this bundle.
11031119
pub fn icon_files(&self) -> ResourcePaths<'_> {
1104-
match self.bundle_settings.icon {
1120+
match self.icon_files {
11051121
Some(ref paths) => ResourcePaths::new(paths.as_slice(), false),
11061122
None => ResourcePaths::new(&[], false),
11071123
}
48.8 KB
Loading

0 commit comments

Comments
 (0)