diff --git a/.changes/cli-tauricon.md b/.changes/cli-tauricon.md new file mode 100644 index 000000000..15a353b91 --- /dev/null +++ b/.changes/cli-tauricon.md @@ -0,0 +1,5 @@ +--- +"cli.rs": minor +--- + +Add `icon` command to generate icons. diff --git a/tooling/bundler/Cargo.toml b/tooling/bundler/Cargo.toml index 4e240457c..1c1928a98 100644 --- a/tooling/bundler/Cargo.toml +++ b/tooling/bundler/Cargo.toml @@ -49,7 +49,7 @@ zip = "0.6" semver = "1" [target."cfg(target_os = \"macos\")".dependencies] -icns = "0.3" +icns = { package = "tauri-icns", version = "0.1" } time = { version = "0.3", features = [ "formatting" ] } plist = "1" diff --git a/tooling/cli/Cargo.lock b/tooling/cli/Cargo.lock index 3ed0d5ff5..55b787a30 100644 --- a/tooling/cli/Cargo.lock +++ b/tooling/cli/Cargo.lock @@ -590,16 +590,6 @@ dependencies = [ "syn", ] -[[package]] -name = "deflate" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73770f8e1fe7d64df17ca66ad28994a0a623ea497fa69486e14984e715c5d174" -dependencies = [ - "adler32", - "byteorder", -] - [[package]] name = "deflate" version = "1.0.0" @@ -748,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14cc0e06fb5f67e5d6beadf3a382fec9baca1aa751c6d5368fdeee7e5932c215" dependencies = [ "bit_field", - "deflate 1.0.0", + "deflate", "flume", "half", "inflate", @@ -791,7 +781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" dependencies = [ "crc32fast", - "miniz_oxide 0.5.3", + "miniz_oxide", ] [[package]] @@ -1073,16 +1063,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" -[[package]] -name = "icns" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ccfbad7e08da70a5b48a924994a5afd93125ce5d45a3b0ba0b8da7bda59a40" -dependencies = [ - "byteorder", - "png 0.16.8", -] - [[package]] name = "ident_case" version = "1.0.1" @@ -1132,7 +1112,7 @@ dependencies = [ "jpeg-decoder", "num-rational", "num-traits", - "png 0.17.5", + "png", "scoped_threadpool", "tiff", ] @@ -1484,15 +1464,6 @@ dependencies = [ "scrypt", ] -[[package]] -name = "miniz_oxide" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791daaae1ed6889560f8c4359194f56648355540573244a5448a83ba1ecc7435" -dependencies = [ - "adler32", -] - [[package]] name = "miniz_oxide" version = "0.5.3" @@ -2051,18 +2022,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "png" -version = "0.16.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3287920cb847dee3de33d301c463fba14dda99db24214ddf93f83d3021f4c6" -dependencies = [ - "bitflags", - "crc32fast", - "deflate 0.8.6", - "miniz_oxide 0.3.7", -] - [[package]] name = "png" version = "0.17.5" @@ -2071,8 +2030,8 @@ checksum = "dc38c0ad57efb786dd57b9864e5b18bae478c00c824dc55a38bbc9da95dde3ba" dependencies = [ "bitflags", "crc32fast", - "deflate 1.0.0", - "miniz_oxide 0.5.3", + "deflate", + "miniz_oxide", ] [[package]] @@ -2828,7 +2787,6 @@ dependencies = [ "handlebars", "heck", "hex", - "icns", "image", "libflate", "log", @@ -2841,6 +2799,7 @@ dependencies = [ "sha2", "strsim", "tar", + "tauri-icns", "tauri-utils", "tempfile", "thiserror", @@ -2867,6 +2826,7 @@ dependencies = [ "handlebars", "heck", "ignore", + "image", "include_dir", "json-patch", "lazy_static", @@ -2885,6 +2845,7 @@ dependencies = [ "serde_with 2.0.0", "shared_child", "tauri-bundler", + "tauri-icns", "tauri-utils", "tempfile", "terminal_size 0.2.1", @@ -2909,6 +2870,16 @@ dependencies = [ "tauri-cli", ] +[[package]] +name = "tauri-icns" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b7eb4d0d43724ba9ba6a6717420ee68aee377816a3edbb45db8c18862b1431" +dependencies = [ + "byteorder", + "png", +] + [[package]] name = "tauri-utils" version = "1.0.3" diff --git a/tooling/cli/Cargo.toml b/tooling/cli/Cargo.toml index 0387ec363..91a99574d 100644 --- a/tooling/cli/Cargo.toml +++ b/tooling/cli/Cargo.toml @@ -65,6 +65,8 @@ ignore = "0.4" ctrlc = "3.2" log = { version = "0.4.17", features = [ "kv_unstable", "kv_unstable_std" ] } env_logger = "0.9.0" +icns = { package = "tauri-icns", version = "0.1" } +image = { version = "0.24", default-features = false, features = [ "ico" ] } [target."cfg(windows)".dependencies] winapi = { version = "0.3", features = [ "handleapi", "processenv", "winbase", "wincon", "winnt" ] } diff --git a/tooling/cli/src/helpers/icns.json b/tooling/cli/src/helpers/icns.json new file mode 100644 index 000000000..326756c55 --- /dev/null +++ b/tooling/cli/src/helpers/icns.json @@ -0,0 +1,52 @@ +{ + "16x16": { + "name": "icon_16x16.png", + "size": 16, + "ostype": "icp4" + }, + "16x16@2x": { + "name": "icon_16x16@2x.png", + "size": 32, + "ostype": "ic11" + }, + "32x32": { + "name": "icon_32x32.png", + "size": 32, + "ostype": "icp5" + }, + "32x32@2x": { + "name": "icon_32x32@2x.png", + "size": 64, + "ostype": "ic12" + }, + "128x128": { + "name": "icon_128x128.png", + "size": 128, + "ostype": "ic07" + }, + "128x128@2x": { + "name": "icon_128x128@2x.png", + "size": 256, + "ostype": "ic13" + }, + "256x256": { + "name": "icon_256x256.png", + "size": 256, + "ostype": "ic08" + }, + "256x256@2x": { + "name": "icon_256x256@2x.png", + "size": 512, + "ostype": "ic14" + }, + "512x512": { + "name": "icon_512x512.png", + "size": 512, + "ostype": "ic09" + }, + "512x512@2x": { + "name": "icon_512x512@2x.png", + "size": 1024, + "ostype": "ic10" + } +} diff --git a/tooling/cli/src/icon.rs b/tooling/cli/src/icon.rs new file mode 100644 index 000000000..2df0a0cc0 --- /dev/null +++ b/tooling/cli/src/icon.rs @@ -0,0 +1,188 @@ +// Copyright 2019-2022 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use crate::{helpers::app_paths::tauri_dir, Result}; + +use std::{ + collections::HashMap, + fs::{create_dir_all, File}, + io::{BufWriter, Write}, + path::{Path, PathBuf}, +}; + +use anyhow::Context; +use clap::Parser; +use icns::{IconFamily, IconType}; +use image::{ + codecs::{ + ico::{IcoEncoder, IcoFrame}, + png::{CompressionType, FilterType as PngFilterType, PngEncoder}, + }, + imageops::FilterType, + open, ColorType, DynamicImage, ImageEncoder, +}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct IcnsEntry { + size: u32, + ostype: String, +} + +#[derive(Debug, Parser)] +#[clap(about = "Generates various icons for all major platforms")] +pub struct Options { + // TODO: Confirm 1240px + /// Path to the source icon (png, 1240x1240px with transparency). + #[clap(default_value = "./app-icon.png")] + input: PathBuf, + /// Output directory. + /// Default: 'icons' directory next to the tauri.conf.json file. + #[clap(short, long)] + output: Option, +} + +pub fn command(options: Options) -> Result<()> { + let input = options.input; + let out_dir = options.output.unwrap_or_else(|| tauri_dir().join("icons")); + create_dir_all(&out_dir).context("Can't create output directory")?; + + // Try to read the image as a DynamicImage, convert it to rgba8 and turn it into a DynamicImage again. + // Both things should be catched by the explicit conversions to rgba8 anyway. + let source = open(input) + .context("Can't read and decode source image")? + .into_rgba8(); + + let source = DynamicImage::ImageRgba8(source); + + if source.height() != source.width() { + panic!("Source image must be square"); + } + + appx(&source, &out_dir).context("Failed to generate appx icons")?; + + icns(&source, &out_dir).context("Failed to generate .icns file")?; + + ico(&source, &out_dir).context("Failed to generate .ico file")?; + + png(&source, &out_dir).context("Failed to generate png icons")?; + + Ok(()) +} + +fn appx(source: &DynamicImage, out_dir: &Path) -> Result<()> { + log::info!(action = "Appx"; "Creating StoreLogo.png"); + resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"))?; + + for size in [30, 44, 71, 89, 107, 142, 150, 284, 310] { + let file_name = format!("Square{}x{}Logo.png", size, size); + log::info!(action = "Appx"; "Creating {}", file_name); + + resize_and_save_png(source, size, &out_dir.join(&file_name))?; + } + + Ok(()) +} + +// Main target: macOS +fn icns(source: &DynamicImage, out_dir: &Path) -> Result<()> { + log::info!(action = "ICNS"; "Creating icon.icns"); + let entries: HashMap = + serde_json::from_slice(include_bytes!("helpers/icns.json")).unwrap(); + + let mut family = IconFamily::new(); + + for (name, entry) in entries { + let size = entry.size; + let mut buf = Vec::new(); + + let image = source.resize_exact(size, size, FilterType::Lanczos3); + + write_png(image.as_bytes(), &mut buf, size)?; + + let image = icns::Image::read_png(&buf[..])?; + + family + .add_icon_with_type( + &image, + IconType::from_ostype(entry.ostype.parse().unwrap()).unwrap(), + ) + .with_context(|| format!("Can't add {} to Icns Family", name))?; + } + + let mut out_file = BufWriter::new(File::create(out_dir.join("icon.icns"))?); + family.write(&mut out_file)?; + out_file.flush()?; + + Ok(()) +} + +// Generate .ico file with layers for the most common sizes. +// Main target: Windows +fn ico(source: &DynamicImage, out_dir: &Path) -> Result<()> { + log::info!(action = "ICO"; "Creating icon.ico"); + let mut frames = Vec::new(); + + for size in [32, 16, 24, 48, 64, 256] { + let image = source.resize_exact(size, size, FilterType::Lanczos3); + + // Only the 256px layer can be compressed according to the ico specs. + if size == 256 { + let mut buf = Vec::new(); + + write_png(image.as_bytes(), &mut buf, size)?; + + frames.push(IcoFrame::with_encoded(buf, size, size, ColorType::Rgba8)?) + } else { + frames.push(IcoFrame::as_png( + image.as_bytes(), + size, + size, + ColorType::Rgba8, + )?); + } + } + + let mut out_file = BufWriter::new(File::create(out_dir.join("icon.ico"))?); + let encoder = IcoEncoder::new(&mut out_file); + encoder.encode_images(&frames)?; + out_file.flush()?; + + Ok(()) +} + +// Generate .png files in 32x32, 128x128, 256x256, 512x512 (icon.png) +// Main target: Linux +fn png(source: &DynamicImage, out_dir: &Path) -> Result<()> { + for size in [32, 128, 256, 512] { + let file_name = match size { + 256 => "128x128@2.png".to_string(), + 512 => "icon.png".to_string(), + _ => format!("{}x{}.png", size, size), + }; + + log::info!(action = "PNG"; "Creating {}", file_name); + resize_and_save_png(source, size, &out_dir.join(&file_name))?; + } + + Ok(()) +} + +// Resize image and save it to disk. +fn resize_and_save_png(source: &DynamicImage, size: u32, file_path: &Path) -> Result<()> { + let image = source.resize_exact(size, size, FilterType::Lanczos3); + + let mut out_file = BufWriter::new(File::create(file_path)?); + + write_png(image.as_bytes(), &mut out_file, size)?; + + Ok(out_file.flush()?) +} + +// Encode image data as png with compression. +fn write_png(image_data: &[u8], w: W, size: u32) -> Result<()> { + let encoder = PngEncoder::new_with_quality(w, CompressionType::Best, PngFilterType::Adaptive); + encoder.write_image(image_data, size, size, ColorType::Rgba8)?; + Ok(()) +} diff --git a/tooling/cli/src/lib.rs b/tooling/cli/src/lib.rs index 6da8c05e8..2a52bc272 100644 --- a/tooling/cli/src/lib.rs +++ b/tooling/cli/src/lib.rs @@ -7,6 +7,7 @@ pub use anyhow::Result; mod build; mod dev; mod helpers; +mod icon; mod info; mod init; mod interface; @@ -62,6 +63,7 @@ struct Cli { enum Commands { Build(build::Options), Dev(dev::Options), + Icon(icon::Options), Info(info::Options), Init(init::Options), Plugin(plugin::Cli), @@ -160,6 +162,7 @@ where match cli.command { Commands::Build(options) => build::command(options)?, Commands::Dev(options) => dev::command(options)?, + Commands::Icon(options) => icon::command(options)?, Commands::Info(options) => info::command(options)?, Commands::Init(options) => init::command(options)?, Commands::Plugin(cli) => plugin::command(cli)?,