diff --git a/.changes/adaptive_icons.md b/.changes/adaptive_icons.md new file mode 100644 index 000000000..cee66fe93 --- /dev/null +++ b/.changes/adaptive_icons.md @@ -0,0 +1,5 @@ +--- +'tauri-cli': 'patch:enhance' +--- + +Add support for Android's adaptive and themed icons. diff --git a/crates/tauri-cli/src/icon.rs b/crates/tauri-cli/src/icon.rs index da3522df3..5b2dd8f0f 100644 --- a/crates/tauri-cli/src/icon.rs +++ b/crates/tauri-cli/src/icon.rs @@ -22,7 +22,7 @@ use image::{ png::{CompressionType, FilterType as PngFilterType, PngEncoder}, }, imageops::FilterType, - open, DynamicImage, ExtendedColorType, ImageBuffer, ImageEncoder, Rgba, + open, DynamicImage, ExtendedColorType, GenericImageView, ImageBuffer, ImageEncoder, Rgba, }; use resvg::{tiny_skia, usvg}; use serde::Deserialize; @@ -40,10 +40,48 @@ struct PngEntry { out_path: PathBuf, } +enum AndroidIconKind { + Regular, + Rounded, +} + +struct AndroidEntries { + icon: Vec<(PngEntry, AndroidIconKind)>, + foreground: Vec, + background: Vec, + monochrome: Vec, +} + +#[derive(Deserialize)] +struct Manifest { + default: String, + bg_color: Option, + android_bg: Option, + android_fg: Option, + android_monochrome: Option, + android_fg_scale: Option, +} + #[derive(Debug, Parser)] #[clap(about = "Generate various icons for all major platforms")] pub struct Options { - /// Path to the source icon (squared PNG or SVG file with transparency). + /// Path to the source icon (squared PNG or SVG file with transparency) or a manifest file. + /// + /// The manifest file is a JSON file with the following structure: + /// { + /// "default": "app-icon.png", + /// "bg_color": "#fff", + /// "android_bg": "app-icon-bg.png", + /// "android_fg": "app-icon-fg.png", + /// "android_fg_scale": 85, + /// "android_monochrome": "app-icon-monochrome.png" + /// } + /// + /// All file paths defined in the manifest JSON are relative to the manifest file path. + /// + /// Only the `default` manifest property is required. + /// + /// The `bg_color` manifest value overwrites the `--ios-color` option if set. #[clap(default_value = "./app-icon.png")] input: PathBuf, /// Output directory. @@ -60,6 +98,7 @@ pub struct Options { ios_color: String, } +#[derive(Clone)] #[allow(clippy::large_enum_variant)] enum Source { Svg(resvg::usvg::Tree), @@ -99,14 +138,41 @@ impl Source { } } -pub fn command(options: Options) -> Result<()> { - let input = options.input; - let out_dir = options.output.unwrap_or_else(|| { - crate::helpers::app_paths::resolve(); - tauri_dir().join("icons") - }); - let png_icon_sizes = options.png.unwrap_or_default(); - let ios_color = css_color::Srgb::from_str(&options.ios_color) +fn read_source(path: PathBuf) -> Result { + if let Some(extension) = path.extension() { + if extension == "svg" { + let rtree = { + let mut fontdb = usvg::fontdb::Database::new(); + fontdb.load_system_fonts(); + + let opt = usvg::Options { + // Get file's absolute directory. + resources_dir: std::fs::canonicalize(&path) + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())), + fontdb: Arc::new(fontdb), + ..Default::default() + }; + + let svg_data = std::fs::read(&path).unwrap(); + usvg::Tree::from_data(&svg_data, &opt).unwrap() + }; + + Ok(Source::Svg(rtree)) + } else { + Ok(Source::DynamicImage(DynamicImage::ImageRgba8( + open(&path) + .context(format!("Can't read and decode source image: {:?}", path))? + .into_rgba8(), + ))) + } + } else { + anyhow::bail!("Error loading image"); + } +} + +fn parse_bg_color(bg_color_string: &String) -> Result> { + let bg_color = css_color::Srgb::from_str(bg_color_string) .map(|color| { Rgba([ (color.red * 255.) as u8, @@ -115,41 +181,44 @@ pub fn command(options: Options) -> Result<()> { (color.alpha * 255.) as u8, ]) }) - .map_err(|_| anyhow::anyhow!("failed to parse iOS color"))?; + .map_err(|_| anyhow::anyhow!("failed to parse color {}", bg_color_string))?; + + Ok(bg_color) +} + +pub fn command(options: Options) -> Result<()> { + let input = options.input; + let out_dir = options.output.unwrap_or_else(|| { + crate::helpers::app_paths::resolve(); + tauri_dir().join("icons") + }); + let png_icon_sizes = options.png.unwrap_or_default(); create_dir_all(&out_dir).context("Can't create output directory")?; - let source = if let Some(extension) = input.extension() { - if extension == "svg" { - let rtree = { - let mut fontdb = usvg::fontdb::Database::new(); - fontdb.load_system_fonts(); - - let opt = usvg::Options { - // Get file's absolute directory. - resources_dir: std::fs::canonicalize(&input) - .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())), - fontdb: Arc::new(fontdb), - ..Default::default() - }; - - let svg_data = std::fs::read(&input).unwrap(); - usvg::Tree::from_data(&svg_data, &opt).unwrap() - }; - - Source::Svg(rtree) - } else { - Source::DynamicImage(DynamicImage::ImageRgba8( - open(&input) - .context("Can't read and decode source image")? - .into_rgba8(), - )) - } + let manifest = if input.extension().is_some_and(|ext| ext == "json") { + parse_manifest(&input).map(Some)? } else { - anyhow::bail!("Error loading image"); + None }; + let bg_color_string = match manifest { + Some(ref manifest) => manifest + .bg_color + .as_ref() + .unwrap_or(&options.ios_color) + .clone(), + None => options.ios_color, + }; + let bg_color = parse_bg_color(&bg_color_string)?; + + let default_icon = match manifest { + Some(ref manifest) => input.parent().unwrap().join(manifest.default.clone()), + None => input.clone(), + }; + + let source = read_source(default_icon)?; + if source.height() != source.width() { anyhow::bail!("Source image must be square"); } @@ -159,7 +228,9 @@ pub fn command(options: Options) -> Result<()> { icns(&source, &out_dir).context("Failed to generate .icns file")?; ico(&source, &out_dir).context("Failed to generate .ico file")?; - png(&source, &out_dir, ios_color).context("Failed to generate png icons")?; + png(&source, &out_dir, bg_color).context("Failed to generate png icons")?; + android(&source, &input, manifest, &bg_color_string, &out_dir) + .context("Failed to generate android icons")?; } else { for target in png_icon_sizes .into_iter() @@ -175,22 +246,32 @@ pub fn command(options: Options) -> Result<()> { .collect::>() { log::info!(action = "PNG"; "Creating {}", target.name); - resize_and_save_png(&source, target.size, &target.out_path, None)?; + resize_and_save_png(&source, target.size, &target.out_path, None, None)?; } } Ok(()) } +fn parse_manifest(manifest_path: &Path) -> Result { + let manifest: Manifest = serde_json::from_str( + &std::fs::read_to_string(manifest_path) + .with_context(|| format!("cannot read manifest file {}", manifest_path.display()))?, + ) + .with_context(|| format!("failed to parse manifest file {}", manifest_path.display()))?; + log::debug!("Read manifest file from {}", manifest_path.display()); + Ok(manifest) +} + fn appx(source: &Source, out_dir: &Path) -> Result<()> { log::info!(action = "Appx"; "Creating StoreLogo.png"); - resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"), None)?; + resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"), None, None)?; for size in [30, 44, 71, 89, 107, 142, 150, 284, 310] { let file_name = format!("Square{size}x{size}Logo.png"); log::info!(action = "Appx"; "Creating {}", file_name); - resize_and_save_png(source, size, &out_dir.join(&file_name), None)?; + resize_and_save_png(source, size, &out_dir.join(&file_name), None, None)?; } Ok(()) @@ -268,38 +349,20 @@ fn ico(source: &Source, out_dir: &Path) -> Result<()> { Ok(()) } -// Generate .png files in 32x32, 64x64, 128x128, 256x256, 512x512 (icon.png) -// Main target: Linux -fn png(source: &Source, out_dir: &Path, ios_color: Rgba) -> Result<()> { - fn desktop_entries(out_dir: &Path) -> Vec { - let mut entries = Vec::new(); - - for size in [32, 64, 128, 256, 512] { - let file_name = match size { - 256 => "128x128@2x.png".to_string(), - 512 => "icon.png".to_string(), - _ => format!("{size}x{size}.png"), - }; - - entries.push(PngEntry { - out_path: out_dir.join(&file_name), - name: file_name, - size, - }); - } - - entries - } - - fn android_entries(out_dir: &Path) -> Result> { +fn android( + source: &Source, + input: &Path, + manifest: Option, + bg_color: &String, + out_dir: &Path, +) -> Result<()> { + fn android_entries(out_dir: &Path) -> Result { struct AndroidEntry { name: &'static str, size: u32, foreground_size: u32, } - let mut entries = Vec::new(); - let targets = vec![ AndroidEntry { name: "hdpi", @@ -327,6 +390,10 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba) -> Result<()> { foreground_size: 432, }, ]; + let mut icon_entries = Vec::new(); + let mut fg_entries = Vec::new(); + let mut bg_entries = Vec::new(); + let mut monochrome_entries = Vec::new(); for target in targets { let folder_name = format!("mipmap-{}", target.name); @@ -334,24 +401,204 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba) -> Result<()> { create_dir_all(&out_folder).context("Can't create Android mipmap output directory")?; - entries.push(PngEntry { + fg_entries.push(PngEntry { name: format!("{}/{}", folder_name, "ic_launcher_foreground.png"), out_path: out_folder.join("ic_launcher_foreground.png"), size: target.foreground_size, }); - entries.push(PngEntry { - name: format!("{}/{}", folder_name, "ic_launcher_round.png"), - out_path: out_folder.join("ic_launcher_round.png"), - size: target.size, + icon_entries.push(( + PngEntry { + name: format!("{}/{}", folder_name, "ic_launcher_round.png"), + out_path: out_folder.join("ic_launcher_round.png"), + size: target.size, + }, + AndroidIconKind::Rounded, + )); + icon_entries.push(( + PngEntry { + name: format!("{}/{}", folder_name, "ic_launcher.png"), + out_path: out_folder.join("ic_launcher.png"), + size: target.size, + }, + AndroidIconKind::Regular, + )); + + bg_entries.push(PngEntry { + name: format!("{}/{}", folder_name, "ic_launcher_background.png"), + out_path: out_folder.join("ic_launcher_background.png"), + size: target.foreground_size, }); - entries.push(PngEntry { - name: format!("{}/{}", folder_name, "ic_launcher.png"), - out_path: out_folder.join("ic_launcher.png"), - size: target.size, + + monochrome_entries.push(PngEntry { + name: format!("{}/{}", folder_name, "ic_launcher_monochrome.png"), + out_path: out_folder.join("ic_launcher_monochrome.png"), + size: target.foreground_size, }); } - Ok(entries) + Ok(AndroidEntries { + icon: icon_entries, + foreground: fg_entries, + background: bg_entries, + monochrome: monochrome_entries, + }) + } + fn create_color_file(out_dir: &Path, color: &String) -> Result<()> { + let values_folder = out_dir.join("values"); + create_dir_all(&values_folder).context("Can't create Android values output directory")?; + let mut color_file = File::create(values_folder.join("ic_launcher_background.xml"))?; + color_file.write_all( + format!( + r#" + + {} +"#, + color + ) + .as_bytes(), + )?; + Ok(()) + } + + let android_out = out_dir + .parent() + .unwrap() + .join("gen/android/app/src/main/res/"); + let out = if android_out.exists() { + android_out + } else { + let out = out_dir.join("android"); + create_dir_all(&out).context("Can't create Android output directory")?; + out + }; + let entries = android_entries(&out)?; + + let fg_source = match manifest { + Some(ref manifest) => { + Some(read_source(input.parent().unwrap().join( + manifest.android_fg.as_ref().unwrap_or(&manifest.default), + ))?) + } + None => None, + }; + + for entry in entries.foreground { + log::info!(action = "Android"; "Creating {}", entry.name); + resize_and_save_png( + fg_source.as_ref().unwrap_or(source), + entry.size, + &entry.out_path, + None, + None, + )?; + } + + let mut bg_source = None; + let mut has_monochrome_image = false; + if let Some(ref manifest) = manifest { + if let Some(ref background_path) = manifest.android_bg { + let bg = read_source(input.parent().unwrap().join(background_path))?; + for entry in entries.background { + log::info!(action = "Android"; "Creating {}", entry.name); + resize_and_save_png(&bg, entry.size, &entry.out_path, None, None)?; + } + bg_source.replace(bg); + } + if let Some(ref monochrome_path) = manifest.android_monochrome { + has_monochrome_image = true; + let mc = read_source(input.parent().unwrap().join(monochrome_path))?; + for entry in entries.monochrome { + log::info!(action = "Android"; "Creating {}", entry.name); + resize_and_save_png(&mc, entry.size, &entry.out_path, None, None)?; + } + } + } + + for (entry, kind) in entries.icon { + log::info!(action = "Android"; "Creating {}", entry.name); + + let (margin, radius) = match kind { + AndroidIconKind::Regular => { + let radius = ((entry.size as f32) * 0.0833).round() as u32; + (radius, radius) + } + AndroidIconKind::Rounded => { + let margin = ((entry.size as f32) * 0.04).round() as u32; + let radius = ((entry.size as f32) * 0.5).round() as u32; + (margin, radius) + } + }; + + let image = if let (Some(bg_source), Some(fg_source)) = (bg_source.as_ref(), fg_source.as_ref()) + { + resize_png( + fg_source, + entry.size, + Some(Background::Image(bg_source)), + manifest + .as_ref() + .and_then(|manifest| manifest.android_fg_scale), + )? + } else { + resize_png(source, entry.size, None, None)? + }; + + let image = apply_round_mask(&image, entry.size, margin, radius); + + let mut out_file = BufWriter::new(File::create(entry.out_path)?); + write_png(image.as_bytes(), &mut out_file, entry.size)?; + out_file.flush()?; + } + + let mut launcher_content = r#" + + "# + .to_owned(); + + if bg_source.is_some() { + launcher_content + .push_str("\n "); + } else { + create_color_file(&out, bg_color)?; + launcher_content + .push_str("\n "); + } + if has_monochrome_image { + launcher_content + .push_str("\n "); + } + launcher_content.push_str("\n"); + + let any_dpi_folder = out.join("mipmap-anydpi-v26"); + create_dir_all(&any_dpi_folder) + .context("Can't create Android mipmap-anydpi-v26 output directory")?; + let mut launcher_file = File::create(any_dpi_folder.join("ic_launcher.xml"))?; + launcher_file.write_all(launcher_content.as_bytes())?; + + Ok(()) +} + +// Generate .png files in 32x32, 64x64, 128x128, 256x256, 512x512 (icon.png) +// Main target: Linux +fn png(source: &Source, out_dir: &Path, ios_color: Rgba) -> Result<()> { + fn desktop_entries(out_dir: &Path) -> Vec { + let mut entries = Vec::new(); + + for size in [32, 64, 128, 256, 512] { + let file_name = match size { + 256 => "128x128@2x.png".to_string(), + 512 => "icon.png".to_string(), + _ => format!("{size}x{size}.png"), + }; + + entries.push(PngEntry { + out_path: out_dir.join(&file_name), + name: file_name, + size, + }); + } + + entries } fn ios_entries(out_dir: &Path) -> Result> { @@ -428,20 +675,7 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba) -> Result<()> { Ok(entries) } - let mut entries = desktop_entries(out_dir); - - let android_out = out_dir - .parent() - .unwrap() - .join("gen/android/app/src/main/res/"); - let out = if android_out.exists() { - android_out - } else { - let out = out_dir.join("android"); - create_dir_all(&out).context("Can't create Android output directory")?; - out - }; - entries.extend(android_entries(&out)?); + let entries = desktop_entries(out_dir); let ios_out = out_dir .parent() @@ -457,32 +691,73 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba) -> Result<()> { for entry in entries { log::info!(action = "PNG"; "Creating {}", entry.name); - resize_and_save_png(source, entry.size, &entry.out_path, None)?; + resize_and_save_png(source, entry.size, &entry.out_path, None, None)?; } for entry in ios_entries(&out)? { log::info!(action = "iOS"; "Creating {}", entry.name); - resize_and_save_png(source, entry.size, &entry.out_path, Some(ios_color))?; + resize_and_save_png( + source, + entry.size, + &entry.out_path, + Some(Background::Color(ios_color)), + None, + )?; } Ok(()) } +enum Background<'a> { + Color(Rgba), + Image(&'a Source), +} + +// Resize image. +fn resize_png( + source: &Source, + size: u32, + bg: Option, + scale_percent: Option, +) -> Result { + let mut image = source.resize_exact(size)?; + + match bg { + Some(Background::Color(bg_color)) => { + let mut bg_img = ImageBuffer::from_fn(size, size, |_, _| bg_color); + + let fg = scale_percent + .map(|scale| resize_asset(&image, size, scale)) + .unwrap_or(image); + + image::imageops::overlay(&mut bg_img, &fg, 0, 0); + image = bg_img.into(); + } + Some(Background::Image(bg_source)) => { + let mut bg = bg_source.resize_exact(size)?; + + let fg = scale_percent + .map(|scale| resize_asset(&image, size, scale)) + .unwrap_or(image); + + image::imageops::overlay(&mut bg, &fg, 0, 0); + image = bg; + } + None => {} + } + + Ok(image) +} + // Resize image and save it to disk. fn resize_and_save_png( source: &Source, size: u32, file_path: &Path, - bg_color: Option>, + bg: Option, + scale_percent: Option, ) -> Result<()> { - let mut image = source.resize_exact(size)?; - - if let Some(bg_color) = bg_color { - let mut bg_img = ImageBuffer::from_fn(size, size, |_, _| bg_color); - image::imageops::overlay(&mut bg_img, &image, 0, 0); - image = bg_img.into(); - } - + let image = resize_png(source, size, bg, scale_percent)?; let mut out_file = BufWriter::new(File::create(file_path)?); write_png(image.as_bytes(), &mut out_file, size)?; Ok(out_file.flush()?) @@ -494,3 +769,133 @@ fn write_png(image_data: &[u8], w: W, size: u32) -> Result<()> { encoder.write_image(image_data, size, size, ExtendedColorType::Rgba8)?; Ok(()) } + +// finds the bounding box of non-transparent pixels in an RGBA image. +fn content_bounds(img: &DynamicImage) -> Option<(u32, u32, u32, u32)> { + let rgba = img.to_rgba8(); + let (width, height) = img.dimensions(); + + let mut min_x = width; + let mut min_y = height; + let mut max_x = 0; + let mut max_y = 0; + let mut found = false; + + for y in 0..height { + for x in 0..width { + let a = rgba.get_pixel(x, y)[3]; + if a > 0 { + found = true; + if x < min_x { + min_x = x; + } + if y < min_y { + min_y = y; + } + if x > max_x { + max_x = x; + } + if y > max_y { + max_y = y; + } + } + } + } + + if found { + Some((min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)) + } else { + None + } +} + +fn resize_asset(img: &DynamicImage, target_size: u32, scale_percent: f32) -> DynamicImage { + let cropped = if let Some((x, y, cw, ch)) = content_bounds(img) { + img.crop_imm(x, y, cw, ch) + } else { + img.clone() + }; + + let (cw, ch) = cropped.dimensions(); + let max_dim = cw.max(ch) as f32; + let scale = (target_size as f32 * (scale_percent / 100.0)) / max_dim; + + let new_w = (cw as f32 * scale).round() as u32; + let new_h = (ch as f32 * scale).round() as u32; + + let resized = image::imageops::resize(&cropped, new_w, new_h, image::imageops::Lanczos3); + + // Place on transparent square canvas + let mut canvas = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0])); + let offset_x = if new_w > target_size { + // Image wider than canvas → start at negative offset + -((new_w - target_size) as i32 / 2) + } else { + (target_size - new_w) as i32 / 2 + }; + + let offset_y = if new_h > target_size { + -((new_h - target_size) as i32 / 2) + } else { + (target_size - new_h) as i32 / 2 + }; + + image::imageops::overlay(&mut canvas, &resized, offset_x.into(), offset_y.into()); + + DynamicImage::ImageRgba8(canvas) +} + +fn apply_round_mask( + img: &DynamicImage, + target_size: u32, + margin: u32, + radius: u32, +) -> DynamicImage { + // Clamp radius to half of inner size + let inner_size = target_size.saturating_sub(2 * margin); + let radius = radius.min(inner_size / 2); + + // Resize inner image to fit inside margins + let resized = img.resize_exact(inner_size, inner_size, image::imageops::Lanczos3); + + // Prepare output canvas + let mut out = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0])); + + // Draw the resized image at (margin, margin) + image::imageops::overlay(&mut out, &resized, margin as i64, margin as i64); + + // Apply rounded corners + for y in 0..target_size { + for x in 0..target_size { + let inside = if x >= margin + radius + && x < target_size - margin - radius + && y >= margin + radius + && y < target_size - margin - radius + { + true // inside central rectangle + } else { + // Determine corner centers + let (cx, cy) = if x < margin + radius && y < margin + radius { + (margin + radius, margin + radius) // top-left + } else if x >= target_size - margin - radius && y < margin + radius { + (target_size - margin - radius, margin + radius) // top-right + } else if x < margin + radius && y >= target_size - margin - radius { + (margin + radius, target_size - margin - radius) // bottom-left + } else if x >= target_size - margin - radius && y >= target_size - margin - radius { + (target_size - margin - radius, target_size - margin - radius) // bottom-right + } else { + continue; // edges that are not corners are inside + }; + let dx = x as i32 - cx as i32; + let dy = y as i32 - cy as i32; + dx * dx + dy * dy <= (radius as i32 * radius as i32) + }; + + if !inside { + out.put_pixel(x, y, Rgba([0, 0, 0, 0])); + } + } + } + + DynamicImage::ImageRgba8(out) +}