icon.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. // Copyright 2019-2023 Tauri Programme within The Commons Conservancy
  2. // SPDX-License-Identifier: Apache-2.0
  3. // SPDX-License-Identifier: MIT
  4. use crate::{
  5. helpers::{app_paths::tauri_dir, config::get as get_tauri_config},
  6. Result,
  7. };
  8. use std::{
  9. collections::HashMap,
  10. fs::{create_dir_all, File},
  11. io::{BufWriter, Write},
  12. path::{Path, PathBuf},
  13. str::FromStr,
  14. };
  15. use anyhow::Context;
  16. use clap::Parser;
  17. use icns::{IconFamily, IconType};
  18. use image::{
  19. codecs::{
  20. ico::{IcoEncoder, IcoFrame},
  21. png::{CompressionType, FilterType as PngFilterType, PngEncoder},
  22. },
  23. imageops::FilterType,
  24. open, ColorType, DynamicImage, ImageBuffer, ImageEncoder, Rgba,
  25. };
  26. use serde::Deserialize;
  27. #[derive(Debug, Deserialize)]
  28. struct IcnsEntry {
  29. size: u32,
  30. ostype: String,
  31. }
  32. #[derive(Debug)]
  33. struct PngEntry {
  34. name: String,
  35. size: u32,
  36. out_path: PathBuf,
  37. }
  38. #[derive(Debug, Parser)]
  39. #[clap(about = "Generates various icons for all major platforms")]
  40. pub struct Options {
  41. // TODO: Confirm 1240px
  42. /// Path to the source icon (png, 1240x1240px with transparency).
  43. #[clap(default_value = "./app-icon.png")]
  44. input: PathBuf,
  45. /// Output directory.
  46. /// Default: 'icons' directory next to the tauri.conf.json file.
  47. #[clap(short, long)]
  48. output: Option<PathBuf>,
  49. /// Custom PNG icon sizes to generate. When set, the default icons are not generated.
  50. #[clap(short, long, use_value_delimiter = true)]
  51. png: Option<Vec<u32>>,
  52. /// The background color of the iOS icon - string as defined in the W3C's CSS Color Module Level 4 <https://www.w3.org/TR/css-color-4/>.
  53. #[clap(long, default_value = "#fff")]
  54. ios_color: String,
  55. }
  56. pub fn command(options: Options) -> Result<()> {
  57. let input = options.input;
  58. let out_dir = options.output.unwrap_or_else(|| tauri_dir().join("icons"));
  59. let png_icon_sizes = options.png.unwrap_or_default();
  60. let ios_color = css_color::Srgb::from_str(&options.ios_color)
  61. .map(|color| {
  62. Rgba([
  63. (color.red * 255.) as u8,
  64. (color.green * 255.) as u8,
  65. (color.blue * 255.) as u8,
  66. (color.alpha * 255.) as u8,
  67. ])
  68. })
  69. .map_err(|_| anyhow::anyhow!("failed to parse iOS color"))?;
  70. create_dir_all(&out_dir).context("Can't create output directory")?;
  71. let source = open(input)
  72. .context("Can't read and decode source image")?
  73. .into_rgba8();
  74. let source = DynamicImage::ImageRgba8(source);
  75. if source.height() != source.width() {
  76. panic!("Source image must be square");
  77. }
  78. if png_icon_sizes.is_empty() {
  79. appx(&source, &out_dir).context("Failed to generate appx icons")?;
  80. icns(&source, &out_dir).context("Failed to generate .icns file")?;
  81. ico(&source, &out_dir).context("Failed to generate .ico file")?;
  82. png(&source, &out_dir, ios_color).context("Failed to generate png icons")?;
  83. } else {
  84. for target in png_icon_sizes
  85. .into_iter()
  86. .map(|size| {
  87. let name = format!("{size}x{size}.png");
  88. let out_path = out_dir.join(&name);
  89. PngEntry {
  90. name,
  91. out_path,
  92. size,
  93. }
  94. })
  95. .collect::<Vec<PngEntry>>()
  96. {
  97. log::info!(action = "PNG"; "Creating {}", target.name);
  98. resize_and_save_png(&source, target.size, &target.out_path)?;
  99. }
  100. }
  101. Ok(())
  102. }
  103. fn appx(source: &DynamicImage, out_dir: &Path) -> Result<()> {
  104. log::info!(action = "Appx"; "Creating StoreLogo.png");
  105. resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"))?;
  106. for size in [30, 44, 71, 89, 107, 142, 150, 284, 310] {
  107. let file_name = format!("Square{size}x{size}Logo.png");
  108. log::info!(action = "Appx"; "Creating {}", file_name);
  109. resize_and_save_png(source, size, &out_dir.join(&file_name))?;
  110. }
  111. Ok(())
  112. }
  113. // Main target: macOS
  114. fn icns(source: &DynamicImage, out_dir: &Path) -> Result<()> {
  115. log::info!(action = "ICNS"; "Creating icon.icns");
  116. let entries: HashMap<String, IcnsEntry> =
  117. serde_json::from_slice(include_bytes!("helpers/icns.json")).unwrap();
  118. let mut family = IconFamily::new();
  119. for (name, entry) in entries {
  120. let size = entry.size;
  121. let mut buf = Vec::new();
  122. let image = source.resize_exact(size, size, FilterType::Lanczos3);
  123. write_png(image.as_bytes(), &mut buf, size)?;
  124. let image = icns::Image::read_png(&buf[..])?;
  125. family
  126. .add_icon_with_type(
  127. &image,
  128. IconType::from_ostype(entry.ostype.parse().unwrap()).unwrap(),
  129. )
  130. .with_context(|| format!("Can't add {name} to Icns Family"))?;
  131. }
  132. let mut out_file = BufWriter::new(File::create(out_dir.join("icon.icns"))?);
  133. family.write(&mut out_file)?;
  134. out_file.flush()?;
  135. Ok(())
  136. }
  137. // Generate .ico file with layers for the most common sizes.
  138. // Main target: Windows
  139. fn ico(source: &DynamicImage, out_dir: &Path) -> Result<()> {
  140. log::info!(action = "ICO"; "Creating icon.ico");
  141. let mut frames = Vec::new();
  142. for size in [32, 16, 24, 48, 64, 256] {
  143. let image = source.resize_exact(size, size, FilterType::Lanczos3);
  144. // Only the 256px layer can be compressed according to the ico specs.
  145. if size == 256 {
  146. let mut buf = Vec::new();
  147. write_png(image.as_bytes(), &mut buf, size)?;
  148. frames.push(IcoFrame::with_encoded(buf, size, size, ColorType::Rgba8)?)
  149. } else {
  150. frames.push(IcoFrame::as_png(
  151. image.as_bytes(),
  152. size,
  153. size,
  154. ColorType::Rgba8,
  155. )?);
  156. }
  157. }
  158. let mut out_file = BufWriter::new(File::create(out_dir.join("icon.ico"))?);
  159. let encoder = IcoEncoder::new(&mut out_file);
  160. encoder.encode_images(&frames)?;
  161. out_file.flush()?;
  162. Ok(())
  163. }
  164. // Generate .png files in 32x32, 128x128, 256x256, 512x512 (icon.png)
  165. // Main target: Linux
  166. fn png(source: &DynamicImage, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
  167. fn desktop_entries(out_dir: &Path) -> Vec<PngEntry> {
  168. let mut entries = Vec::new();
  169. for size in [32, 128, 256, 512] {
  170. let file_name = match size {
  171. 256 => "128x128@2x.png".to_string(),
  172. 512 => "icon.png".to_string(),
  173. _ => format!("{size}x{size}.png"),
  174. };
  175. entries.push(PngEntry {
  176. out_path: out_dir.join(&file_name),
  177. name: file_name,
  178. size,
  179. });
  180. }
  181. entries
  182. }
  183. fn android_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
  184. struct AndroidEntry {
  185. name: &'static str,
  186. size: u32,
  187. foreground_size: u32,
  188. }
  189. let mut entries = Vec::new();
  190. let targets = vec![
  191. AndroidEntry {
  192. name: "hdpi",
  193. size: 49,
  194. foreground_size: 162,
  195. },
  196. AndroidEntry {
  197. name: "mdpi",
  198. size: 48,
  199. foreground_size: 108,
  200. },
  201. AndroidEntry {
  202. name: "xhdpi",
  203. size: 96,
  204. foreground_size: 216,
  205. },
  206. AndroidEntry {
  207. name: "xxhdpi",
  208. size: 144,
  209. foreground_size: 324,
  210. },
  211. AndroidEntry {
  212. name: "xxxhdpi",
  213. size: 192,
  214. foreground_size: 432,
  215. },
  216. ];
  217. for target in targets {
  218. let folder_name = format!("mipmap-{}", target.name);
  219. let out_folder = out_dir.join(&folder_name);
  220. create_dir_all(&out_folder).context("Can't create Android mipmap output directory")?;
  221. entries.push(PngEntry {
  222. name: format!("{}/{}", folder_name, "ic_launcher_foreground.png"),
  223. out_path: out_folder.join("ic_launcher_foreground.png"),
  224. size: target.foreground_size,
  225. });
  226. entries.push(PngEntry {
  227. name: format!("{}/{}", folder_name, "ic_launcher_round.png"),
  228. out_path: out_folder.join("ic_launcher_round.png"),
  229. size: target.size,
  230. });
  231. entries.push(PngEntry {
  232. name: format!("{}/{}", folder_name, "ic_launcher.png"),
  233. out_path: out_folder.join("ic_launcher.png"),
  234. size: target.size,
  235. });
  236. }
  237. Ok(entries)
  238. }
  239. fn ios_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
  240. struct IosEntry {
  241. size: f32,
  242. multipliers: Vec<u8>,
  243. has_extra: bool,
  244. }
  245. let mut entries = Vec::new();
  246. let targets = vec![
  247. IosEntry {
  248. size: 20.,
  249. multipliers: vec![1, 2, 3],
  250. has_extra: true,
  251. },
  252. IosEntry {
  253. size: 29.,
  254. multipliers: vec![1, 2, 3],
  255. has_extra: true,
  256. },
  257. IosEntry {
  258. size: 40.,
  259. multipliers: vec![1, 2, 3],
  260. has_extra: true,
  261. },
  262. IosEntry {
  263. size: 60.,
  264. multipliers: vec![2, 3],
  265. has_extra: false,
  266. },
  267. IosEntry {
  268. size: 76.,
  269. multipliers: vec![1, 2],
  270. has_extra: false,
  271. },
  272. IosEntry {
  273. size: 83.5,
  274. multipliers: vec![2],
  275. has_extra: false,
  276. },
  277. IosEntry {
  278. size: 512.,
  279. multipliers: vec![2],
  280. has_extra: false,
  281. },
  282. ];
  283. for target in targets {
  284. let size_str = if target.size == 512. {
  285. "512".to_string()
  286. } else {
  287. format!("{size}x{size}", size = target.size)
  288. };
  289. if target.has_extra {
  290. let name = format!("AppIcon-{size_str}@2x-1.png");
  291. entries.push(PngEntry {
  292. out_path: out_dir.join(&name),
  293. name,
  294. size: (target.size * 2.) as u32,
  295. });
  296. }
  297. for multiplier in target.multipliers {
  298. let name = format!("AppIcon-{size_str}@{multiplier}x.png");
  299. entries.push(PngEntry {
  300. out_path: out_dir.join(&name),
  301. name,
  302. size: (target.size * multiplier as f32) as u32,
  303. });
  304. }
  305. }
  306. Ok(entries)
  307. }
  308. let mut entries = desktop_entries(out_dir);
  309. // Android
  310. let (config, _metadata) = {
  311. let tauri_config = get_tauri_config(None)?;
  312. let tauri_config_guard = tauri_config.lock().unwrap();
  313. let tauri_config_ = tauri_config_guard.as_ref().unwrap();
  314. crate::mobile::android::get_config(
  315. &crate::mobile::get_app(tauri_config_),
  316. tauri_config_,
  317. &Default::default(),
  318. )
  319. };
  320. let android_out = out_dir.parent().unwrap().join(format!(
  321. "gen/android/{}/app/src/main/res/",
  322. config.app().name_snake()
  323. ));
  324. let out = if android_out.exists() {
  325. android_out
  326. } else {
  327. let out = out_dir.join("android");
  328. create_dir_all(&out).context("Can't create Android output directory")?;
  329. out
  330. };
  331. entries.extend(android_entries(&out)?);
  332. let ios_out = out_dir
  333. .parent()
  334. .unwrap()
  335. .join("gen/apple/Assets.xcassets/AppIcon.appiconset");
  336. let out = if ios_out.exists() {
  337. ios_out
  338. } else {
  339. let out = out_dir.join("ios");
  340. create_dir_all(&out).context("Can't create iOS output directory")?;
  341. out
  342. };
  343. for entry in entries {
  344. log::info!(action = "PNG"; "Creating {}", entry.name);
  345. resize_and_save_png(source, entry.size, &entry.out_path)?;
  346. }
  347. let source_rgba8 = source.as_rgba8().expect("unexpected image type");
  348. let mut img = ImageBuffer::from_fn(source_rgba8.width(), source_rgba8.height(), |_, _| {
  349. ios_color
  350. });
  351. image::imageops::overlay(&mut img, source_rgba8, 0, 0);
  352. let image = DynamicImage::ImageRgba8(img);
  353. for entry in ios_entries(&out)? {
  354. log::info!(action = "iOS"; "Creating {}", entry.name);
  355. resize_and_save_png(&image, entry.size, &entry.out_path)?;
  356. }
  357. Ok(())
  358. }
  359. // Resize image and save it to disk.
  360. fn resize_and_save_png(source: &DynamicImage, size: u32, file_path: &Path) -> Result<()> {
  361. let image = source.resize_exact(size, size, FilterType::Lanczos3);
  362. let mut out_file = BufWriter::new(File::create(file_path)?);
  363. write_png(image.as_bytes(), &mut out_file, size)?;
  364. Ok(out_file.flush()?)
  365. }
  366. // Encode image data as png with compression.
  367. fn write_png<W: Write>(image_data: &[u8], w: W, size: u32) -> Result<()> {
  368. let encoder = PngEncoder::new_with_quality(w, CompressionType::Best, PngFilterType::Adaptive);
  369. encoder.write_image(image_data, size, size, ColorType::Rgba8)?;
  370. Ok(())
  371. }