From 883d5b001a96eed5b7e20b3c3acfbbe097373b0a Mon Sep 17 00:00:00 2001 From: Lucas Nogueira <118899497+lucasfernog-crabnebula@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:10:02 -0300 Subject: [PATCH] feat(packager): add support to macOS .app bundles (#17) * feat(packager): add support to macOS .app bundles * do not remove out dir [skip ci] * consistency changes * test windows and macOS * clippy * fix macos build * fix fmt --------- Co-authored-by: Amr Bashir --- .github/workflows/check.yml | 6 +- Cargo.lock | 160 +++---- crates/config/schema.json | 2 +- crates/config/src/lib.rs | 19 +- crates/packager/Cargo.toml | 10 +- crates/packager/schema.json | 2 +- crates/packager/src/app/mod.rs | 301 +++++++++++- crates/packager/src/config.rs | 6 + crates/packager/src/deb/mod.rs | 4 +- crates/packager/src/error.rs | 63 +++ crates/packager/src/lib.rs | 4 +- crates/packager/src/nsis/mod.rs | 10 +- crates/packager/src/shell.rs | 84 ++++ crates/packager/src/sign/macos.rs | 432 ++++++++++++++++++ crates/packager/src/sign/mod.rs | 10 + .../packager/src/{sign.rs => sign/windows.rs} | 0 crates/packager/src/util.rs | 116 ++++- examples/tauri/Cargo.toml | 4 +- 18 files changed, 1102 insertions(+), 131 deletions(-) create mode 100644 crates/packager/src/shell.rs create mode 100644 crates/packager/src/sign/macos.rs create mode 100644 crates/packager/src/sign/mod.rs rename crates/packager/src/{sign.rs => sign/windows.rs} (100%) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4866fba0..a0f13f0c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -52,14 +52,18 @@ jobs: run: cargo fmt --all -- --check test: - runs-on: ubuntu-latest strategy: fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - name: install webkit2gtk + if: matrix.platform == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y webkit2gtk-4.1 libayatana-appindicator3-dev diff --git a/Cargo.lock b/Cargo.lock index a899306f..f9d1fe7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,6 +403,8 @@ dependencies = [ "log", "md5", "once_cell", + "os_pipe", + "plist", "regex", "relative-path", "schemars", @@ -412,7 +414,10 @@ dependencies = [ "sha1", "sha2", "tar", + "tauri-icns", + "tempfile", "thiserror", + "time", "toml 0.8.0", "ureq", "uuid", @@ -526,9 +531,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.4.2" +version = "4.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a13b88d2c62ff462f88e4a121f17a82c1af05693a2f192b5c38d14de73c19f6" +checksum = "84ed82781cea27b43c9b106a979fe450a13a31aab0500595fb3fc06616de08e6" dependencies = [ "clap_builder", "clap_derive", @@ -1836,21 +1841,7 @@ checksum = "e5c13fb08e5d4dfc151ee5e88bae63f7773d61852f3bdc73c9f4b9e1bde03148" dependencies = [ "log", "mac", - "markup5ever 0.10.1", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "html5ever" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" -dependencies = [ - "log", - "mac", - "markup5ever 0.11.0", + "markup5ever", "proc-macro2", "quote", "syn 1.0.109", @@ -2216,20 +2207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ea8e9c6e031377cff82ee3001dc8026cdf431ed4e2e6b51f98ab8c73484a358" dependencies = [ "cssparser", - "html5ever 0.25.2", - "matches", - "selectors", -] - -[[package]] -name = "kuchikiki" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" -dependencies = [ - "cssparser", - "html5ever 0.26.0", - "indexmap 1.9.3", + "html5ever", "matches", "selectors", ] @@ -2248,9 +2226,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.147" +version = "0.2.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" [[package]] name = "libflate" @@ -2360,21 +2338,7 @@ checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" dependencies = [ "log", "phf 0.8.0", - "phf_codegen 0.8.0", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "markup5ever" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" -dependencies = [ - "log", - "phf 0.10.1", - "phf_codegen 0.10.0", + "phf_codegen", "string_cache", "string_cache_codegen", "tendril", @@ -2632,6 +2596,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "os_pipe" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae859aa07428ca9a929b936690f8b12dc5f11dd8c6992a18ca93919f28bc177" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "overload" version = "0.1.1" @@ -2776,16 +2750,6 @@ dependencies = [ "phf_shared 0.8.0", ] -[[package]] -name = "phf_codegen" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", -] - [[package]] name = "phf_generator" version = "0.8.0" @@ -3353,9 +3317,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.100.2" +version = "0.100.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e98ff011474fa39949b7e5c0428f9b4937eda7da7848bbb947786b7be0b27dab" +checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3" dependencies = [ "ring", "untrusted", @@ -3459,7 +3423,7 @@ dependencies = [ "log", "matches", "phf 0.8.0", - "phf_codegen 0.8.0", + "phf_codegen", "precomputed-hash", "servo_arc", "smallvec", @@ -3975,7 +3939,7 @@ dependencies = [ "unicode-segmentation", "uuid", "windows 0.44.0", - "windows-implement 0.44.0", + "windows-implement", "x11-dl", ] @@ -4057,15 +4021,14 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.0.0-alpha.7" +version = "2.0.0-alpha.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75ac9e0bbddccd26d0de7864d578fa5fb39acef6da2109553d45a588b394630" +checksum = "a52990870fd043f1d3bd6719ae713ef2e0c50431334d7249f6ae8509d1b8c326" dependencies = [ "anyhow", "cargo_toml", "heck", "json-patch", - "plist", "semver", "serde", "serde_json", @@ -4077,9 +4040,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.0.0-alpha.7" +version = "2.0.0-alpha.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f98a67c7ef3cb3c25de91fe1fa16cc3681997f6ec99da0a7496d6feae2ea91e" +checksum = "5c1f1611ab0896f2693163ba4e8f3e39c02a1b70cdca4314286b5e365a5e08c6" dependencies = [ "base64 0.21.4", "brotli", @@ -4110,6 +4073,17 @@ dependencies = [ "tauri", "tauri-build", "tauri-macros", + "tauri-utils", +] + +[[package]] +name = "tauri-icns" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b7eb4d0d43724ba9ba6a6717420ee68aee377816a3edbb45db8c18862b1431" +dependencies = [ + "byteorder", + "png", ] [[package]] @@ -4170,19 +4144,19 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.0.0-alpha.7" +version = "2.0.0-alpha.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06bcd7c6f67fd6371dcc22da7d7f26ec12c4eae26ad7bc54943bb9f35b5db302" +checksum = "2e2812e0cdfffb892c654555b2f1b8c84a035b4c56eb1646cb3eb5a9d8164d8e" dependencies = [ "brotli", "ctor", "dunce", "glob", "heck", - "html5ever 0.26.0", + "html5ever", "infer 0.12.0", "json-patch", - "kuchikiki", + "kuchiki", "memchr", "phf 0.10.1", "proc-macro2", @@ -4194,7 +4168,7 @@ dependencies = [ "thiserror", "url", "walkdir", - "windows 0.48.0", + "windows 0.44.0", ] [[package]] @@ -4540,9 +4514,9 @@ checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" [[package]] name = "unicode-ident" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-normalization" @@ -4576,7 +4550,7 @@ dependencies = [ "log", "once_cell", "rustls", - "rustls-webpki 0.100.2", + "rustls-webpki 0.100.3", "url", "webpki-roots", ] @@ -4888,7 +4862,7 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" dependencies = [ - "rustls-webpki 0.100.2", + "rustls-webpki 0.100.3", ] [[package]] @@ -4900,7 +4874,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows 0.44.0", - "windows-implement 0.44.0", + "windows-implement", ] [[package]] @@ -4972,8 +4946,8 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" dependencies = [ - "windows-implement 0.44.0", - "windows-interface 0.44.0", + "windows-implement", + "windows-interface", "windows-targets 0.42.2", ] @@ -4983,8 +4957,6 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "windows-implement 0.48.0", - "windows-interface 0.48.0", "windows-targets 0.48.5", ] @@ -5009,17 +4981,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "windows-implement" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2ee588991b9e7e6c8338edf3333fbe4da35dc72092643958ebb43f0ab2c49c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "windows-interface" version = "0.44.0" @@ -5031,17 +4992,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "windows-interface" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6fb8df20c9bcaa8ad6ab513f7b40104840c8867d5751126e4df3b08388d0cc7" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "windows-metadata" version = "0.44.0" @@ -5231,7 +5181,7 @@ dependencies = [ "gio", "glib", "gtk", - "html5ever 0.25.2", + "html5ever", "http", "javascriptcore-rs", "kuchiki", @@ -5251,7 +5201,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows 0.44.0", - "windows-implement 0.44.0", + "windows-implement", ] [[package]] diff --git a/crates/config/schema.json b/crates/config/schema.json index 3d475eaa..13aad53c 100644 --- a/crates/config/schema.json +++ b/crates/config/schema.json @@ -206,7 +206,7 @@ ] }, "deb": { - "description": "Platform-specific configurations. Debian-specific settings.", + "description": "Debian-specific settings.", "anyOf": [ { "$ref": "#/definitions/DebianConfig" diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index efa7cac0..20bbff54 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, fmt::Display, path::PathBuf}; +use std::{ + collections::HashMap, + fmt::{self, Display}, + path::PathBuf, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -115,6 +119,18 @@ impl Default for BundleTypeRole { } } +impl Display for BundleTypeRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Editor => write!(f, "Editor"), + Self::Viewer => write!(f, "Viewer"), + Self::Shell => write!(f, "Shell"), + Self::QLGenerator => write!(f, "QLGenerator"), + Self::None => write!(f, "None"), + } + } +} + /// A file association configuration. #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -576,7 +592,6 @@ pub struct Config { pub external_binaries: Option>, /// Signing configuration. pub signing: Option, - /// Platform-specific configurations. /// Debian-specific settings. pub deb: Option, /// WiX configuration. diff --git a/crates/packager/Cargo.toml b/crates/packager/Cargo.toml index 7d128810..a34f500f 100644 --- a/crates/packager/Cargo.toml +++ b/crates/packager/Cargo.toml @@ -36,7 +36,8 @@ zip = { version = "0.6", default-features = false, features = ["deflate"] } handlebars = "4.4" glob = "0.3" relative-path = "1.9" -walkdir = "2.4" +walkdir = "2" +os_pipe = "1" [target."cfg(target_os = \"windows\")".dependencies] winreg = "0.51" @@ -55,3 +56,10 @@ heck = "0.4" ar = "0.9" tar = "0.4" libflate = "2.0" + +[target."cfg(target_os = \"macos\")".dependencies] +icns = { package = "tauri-icns", version = "0.1" } +time = { version = "0.3", features = [ "formatting" ] } +plist = "1" +image = "0.24" +tempfile = "3" diff --git a/crates/packager/schema.json b/crates/packager/schema.json index 3d475eaa..13aad53c 100644 --- a/crates/packager/schema.json +++ b/crates/packager/schema.json @@ -206,7 +206,7 @@ ] }, "deb": { - "description": "Platform-specific configurations. Debian-specific settings.", + "description": "Debian-specific settings.", "anyOf": [ { "$ref": "#/definitions/DebianConfig" diff --git a/crates/packager/src/app/mod.rs b/crates/packager/src/app/mod.rs index 3f8c2170..ee8ffd5c 100644 --- a/crates/packager/src/app/mod.rs +++ b/crates/packager/src/app/mod.rs @@ -1,8 +1,299 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::config::Config; +use crate::{ + config::{Config, ConfigExt, ConfigExtInternal}, + sign, util, +}; -pub fn package(_config: &Config) -> crate::Result> { - log::warn!("`app` format is not implemented yet! skipping..."); - Ok(vec![]) +pub fn package(config: &Config) -> crate::Result> { + // we should use the bundle name (App name) as a MacOS standard. + // version or platform shouldn't be included in the App name. + let app_product_name = format!("{}.app", config.product_name); + let app_bundle_path = config.out_dir().join(&app_product_name); + + log::info!(action = "Packaging"; "{} ({})", app_product_name, app_bundle_path.display()); + + let contents_directory = app_bundle_path.join("Contents"); + std::fs::create_dir_all(&contents_directory)?; + + let resources_dir = contents_directory.join("Resources"); + let bin_dir = contents_directory.join("MacOS"); + + let bundle_icon_file = util::create_icns_file(&resources_dir, config)?; + + log::debug!("creating info.plist"); + create_info_plist(&contents_directory, bundle_icon_file, config)?; + + log::debug!("copying frameworks"); + copy_frameworks_to_bundle(&contents_directory, config)?; + + log::debug!("copying resources"); + config.copy_resources(&resources_dir)?; + + log::debug!("copying external binaries"); + config.copy_external_binaries(&bin_dir)?; + + log::debug!("copying binaries"); + copy_binaries_to_bundle(&contents_directory, config)?; + + if let Some(identity) = config + .macos() + .and_then(|macos| macos.signing_identity.as_ref()) + { + sign::try_sign(app_bundle_path.clone(), identity, config, true)?; + // notarization is required for distribution + match sign::notarize_auth() { + Ok(auth) => { + sign::notarize(app_bundle_path.clone(), auth, config)?; + } + Err(e) => { + log::warn!("skipping app notarization, {}", e.to_string()); + } + } + } + + Ok(vec![app_bundle_path]) +} + +// Copies the app's binaries to the bundle. +fn copy_binaries_to_bundle(contents_directory: &Path, config: &Config) -> crate::Result<()> { + let bin_dir = contents_directory.join("MacOS"); + std::fs::create_dir_all(&bin_dir)?; + + for bin in &config.binaries { + let bin_path = config.binary_path(bin); + std::fs::copy(&bin_path, bin_dir.join(&bin.filename))?; + } + + Ok(()) +} + +// Creates the Info.plist file. +fn create_info_plist( + contents_directory: &Path, + bundle_icon_file: Option, + config: &Config, +) -> crate::Result<()> { + let format = time::format_description::parse("[year][month][day].[hour][minute][second]") + .map_err(time::error::Error::from)?; + let build_number = time::OffsetDateTime::now_utc() + .format(&format) + .map_err(time::error::Error::from)?; + + let mut plist = plist::Dictionary::new(); + plist.insert("CFBundleDevelopmentRegion".into(), "English".into()); + plist.insert( + "CFBundleDisplayName".into(), + config.product_name.clone().into(), + ); + plist.insert( + "CFBundleExecutable".into(), + config.main_binary_name()?.clone().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(), config.identifier().into()); + plist.insert("CFBundleInfoDictionaryVersion".into(), "6.0".into()); + plist.insert("CFBundleName".into(), config.product_name.clone().into()); + plist.insert("CFBundlePackageType".into(), "APPL".into()); + plist.insert( + "CFBundleShortVersionString".into(), + config.version.clone().into(), + ); + plist.insert("CFBundleVersion".into(), build_number.into()); + plist.insert("CSResourcesFileMapped".into(), true.into()); + if let Some(category) = &config.category { + plist.insert( + "LSApplicationCategoryType".into(), + category.macos_application_category_type().into(), + ); + } + if let Some(version) = config + .macos() + .and_then(|macos| macos.minimum_system_version.as_deref()) + { + plist.insert("LSMinimumSystemVersion".into(), version.into()); + } + + if let Some(associations) = &config.file_associations { + plist.insert( + "CFBundleDocumentTypes".into(), + plist::Value::Array( + associations + .iter() + .map(|association| { + let mut dict = plist::Dictionary::new(); + dict.insert( + "CFBundleTypeExtensions".into(), + plist::Value::Array( + association + .ext + .iter() + .map(|ext| ext.to_string().into()) + .collect(), + ), + ); + dict.insert( + "CFBundleTypeName".into(), + association + .name + .as_ref() + .unwrap_or(&association.ext[0]) + .to_string() + .into(), + ); + dict.insert( + "CFBundleTypeRole".into(), + association.role.to_string().into(), + ); + plist::Value::Dictionary(dict) + }) + .collect(), + ), + ); + } + + plist.insert("LSRequiresCarbon".into(), true.into()); + plist.insert("NSHighResolutionCapable".into(), true.into()); + if let Some(copyright) = &config.copyright { + plist.insert("NSHumanReadableCopyright".into(), copyright.clone().into()); + } + + if let Some(exception_domain) = config + .macos() + .and_then(|macos| macos.exception_domain.clone()) + { + let mut security = plist::Dictionary::new(); + let mut domain = plist::Dictionary::new(); + domain.insert("NSExceptionAllowsInsecureHTTPLoads".into(), true.into()); + domain.insert("NSIncludesSubdomains".into(), true.into()); + + let mut exception_domains = plist::Dictionary::new(); + exception_domains.insert(exception_domain, domain.into()); + security.insert("NSExceptionDomains".into(), exception_domains.into()); + plist.insert("NSAppTransportSecurity".into(), security.into()); + } + + if let Some(user_plist_path) = config + .macos() + .and_then(|macos| macos.info_plist_path.as_ref()) + { + let user_plist = plist::Value::from_file(user_plist_path)?; + if let Some(dict) = user_plist.into_dictionary() { + for (key, value) in dict { + plist.insert(key, value); + } + } + } + + plist::Value::Dictionary(plist).to_file_xml(contents_directory.join("Info.plist"))?; + + Ok(()) +} + +fn copy_dir(from: &Path, to: &Path) -> crate::Result<()> { + if !from.exists() { + return Err(crate::Error::AlreadyExists(from.to_path_buf())); + } + if !from.is_dir() { + return Err(crate::Error::IsNotDirectory(from.to_path_buf())); + } + if to.exists() { + return Err(crate::Error::AlreadyExists(to.to_path_buf())); + } + + let parent = to.parent().expect("No data in parent"); + std::fs::create_dir_all(parent)?; + for entry in walkdir::WalkDir::new(from) { + let entry = entry?; + debug_assert!(entry.path().starts_with(from)); + let rel_path = entry.path().strip_prefix(from)?; + let dest_path = to.join(rel_path); + if entry.file_type().is_symlink() { + let target = std::fs::read_link(entry.path())?; + std::os::unix::fs::symlink(&target, &dest_path)?; + } else if entry.file_type().is_dir() { + std::fs::create_dir(dest_path)?; + } else { + std::fs::copy(entry.path(), dest_path)?; + } + } + Ok(()) +} + +// Copies the framework under `{src_dir}/{framework}.framework` to `{dest_dir}/{framework}.framework`. +fn copy_framework_from(dest_dir: &Path, framework: &str, src_dir: &Path) -> crate::Result { + let src_name = format!("{}.framework", framework); + let src_path = src_dir.join(&src_name); + if src_path.exists() { + copy_dir(&src_path, &dest_dir.join(&src_name))?; + Ok(true) + } else { + Ok(false) + } +} + +// Copies the macOS application bundle frameworks to the .app +fn copy_frameworks_to_bundle(contents_directory: &Path, config: &Config) -> crate::Result<()> { + if let Some(frameworks) = config.macos().and_then(|m| m.frameworks.as_ref()) { + let dest_dir = contents_directory.join("Frameworks"); + std::fs::create_dir_all(contents_directory)?; + + for framework in frameworks { + if framework.ends_with(".framework") { + let src_path = PathBuf::from(framework); + let src_name = src_path + .file_name() + .expect("Couldn't get framework filename"); + copy_dir(&src_path, &dest_dir.join(src_name))?; + continue; + } else if framework.ends_with(".dylib") { + let src_path = PathBuf::from(&framework); + if !src_path.exists() { + return Err(crate::Error::FrameworkNotFound(framework.to_string())); + } + let src_name = src_path.file_name().expect("Couldn't get library filename"); + std::fs::create_dir_all(&dest_dir)?; + std::fs::copy(&src_path, dest_dir.join(src_name))?; + continue; + } else if framework.contains('/') { + return Err(crate::Error::InvalidFramework { + framework: framework.to_string(), + reason: "path should have the .framework extension", + }); + } + if let Some(home_dir) = dirs::home_dir() { + if copy_framework_from( + &dest_dir, + &framework, + &home_dir.join("Library/Frameworks/"), + )? { + continue; + } + } + if copy_framework_from( + &dest_dir, + &framework, + &PathBuf::from("/Library/Frameworks/"), + )? || copy_framework_from( + &dest_dir, + &framework, + &PathBuf::from("/Network/Library/Frameworks/"), + )? { + continue; + } + + return Err(crate::Error::FrameworkNotFound(framework.to_string())); + } + } + + Ok(()) } diff --git a/crates/packager/src/config.rs b/crates/packager/src/config.rs index 0be79e4f..d2c425cb 100644 --- a/crates/packager/src/config.rs +++ b/crates/packager/src/config.rs @@ -20,6 +20,8 @@ pub trait ConfigExt { fn wix(&self) -> Option<&WixConfig>; /// Returns the debian specific configuration fn deb(&self) -> Option<&DebianConfig>; + /// Returns the macos specific configuration + fn macos(&self) -> Option<&MacOsConfig>; /// Returns the target triple for the package to be built (e.g. "aarch64-unknown-linux-gnu"). fn target_triple(&self) -> String; /// Returns the architecture for the package to be built (e.g. "arm", "x86" or "x86_64"). @@ -39,6 +41,10 @@ impl ConfigExt for Config { self.windows.as_ref() } + fn macos(&self) -> Option<&MacOsConfig> { + self.macos.as_ref() + } + fn nsis(&self) -> Option<&NsisConfig> { self.nsis.as_ref() } diff --git a/crates/packager/src/deb/mod.rs b/crates/packager/src/deb/mod.rs index 6f59dc66..c1c8b4e1 100644 --- a/crates/packager/src/deb/mod.rs +++ b/crates/packager/src/deb/mod.rs @@ -131,13 +131,13 @@ pub fn generate_data(config: &Config, package_dir: &Path) -> crate::Result std::io::Result; + fn output_ok(&mut self) -> crate::Result; +} + +impl CommandExt for Command { + fn piped(&mut self) -> std::io::Result { + self.stdout(os_pipe::dup_stdout()?); + self.stderr(os_pipe::dup_stderr()?); + let program = self.get_program().to_string_lossy().into_owned(); + debug!(action = "Running"; "Command `{} {}`", program, self.get_args().map(|arg| arg.to_string_lossy()).fold(String::new(), |acc, arg| format!("{acc} {arg}"))); + + self.status().map_err(Into::into) + } + + fn output_ok(&mut self) -> crate::Result { + let program = self.get_program().to_string_lossy().into_owned(); + debug!(action = "Running"; "Command `{} {}`", program, self.get_args().map(|arg| arg.to_string_lossy()).fold(String::new(), |acc, arg| format!("{} {}", acc, arg))); + + self.stdout(Stdio::piped()); + self.stderr(Stdio::piped()); + + let mut child = self.spawn()?; + + let mut stdout = child.stdout.take().map(BufReader::new).unwrap(); + let stdout_lines = Arc::new(Mutex::new(Vec::new())); + let stdout_lines_ = stdout_lines.clone(); + std::thread::spawn(move || { + let mut buf = String::new(); + let mut lines = stdout_lines_.lock().unwrap(); + loop { + buf.clear(); + match stdout.read_line(&mut buf) { + Ok(s) if s == 0 => break, + _ => (), + } + debug!(action = "stdout"; "{buf}"); + lines.extend(buf.as_bytes().to_vec()); + lines.push(b'\n'); + } + }); + + let mut stderr = child.stderr.take().map(BufReader::new).unwrap(); + let stderr_lines = Arc::new(Mutex::new(Vec::new())); + let stderr_lines_ = stderr_lines.clone(); + std::thread::spawn(move || { + let mut buf = String::new(); + let mut lines = stderr_lines_.lock().unwrap(); + loop { + buf.clear(); + match stderr.read_line(&mut buf) { + Ok(s) if s == 0 => break, + _ => (), + } + debug!(action = "stderr"; "{buf}"); + lines.extend(buf.as_bytes().to_vec()); + lines.push(b'\n'); + } + }); + + let status = child.wait()?; + let output = Output { + status, + stdout: std::mem::take(&mut *stdout_lines.lock().unwrap()), + stderr: std::mem::take(&mut *stderr_lines.lock().unwrap()), + }; + + if output.status.success() { + Ok(output) + } else { + Err(crate::Error::FailedToRunCommand(program)) + } + } +} diff --git a/crates/packager/src/sign/macos.rs b/crates/packager/src/sign/macos.rs new file mode 100644 index 00000000..4686e7b8 --- /dev/null +++ b/crates/packager/src/sign/macos.rs @@ -0,0 +1,432 @@ +use std::{ffi::OsString, fs::File, io::prelude::*, path::PathBuf, process::Command}; + +use cargo_packager_config::Config; +use serde::Deserialize; + +use crate::{config::ConfigExt, shell::CommandExt, Error}; + +const KEYCHAIN_ID: &str = "cargo-packager.keychain"; +const KEYCHAIN_PWD: &str = "cargo-packager"; + +// Import certificate from ENV variables. +// APPLE_CERTIFICATE is the p12 certificate base64 encoded. +// By example you can use; openssl base64 -in MyCertificate.p12 -out MyCertificate-base64.txt +// Then use the value of the base64 in APPLE_CERTIFICATE env variable. +// You need to set APPLE_CERTIFICATE_PASSWORD to the password you set when you exported your certificate. +// https://help.apple.com/xcode/mac/current/#/dev154b28f09 see: `Export a signing certificate` +pub fn setup_keychain( + certificate_encoded: OsString, + certificate_password: OsString, +) -> crate::Result<()> { + // we delete any previous version of our keychain if present + delete_keychain(); + log::info!("setting up keychain from environment variables..."); + + let keychain_list_output = Command::new("security") + .args(["list-keychain", "-d", "user"]) + .output()?; + + let tmp_dir = tempfile::tempdir()?; + + let cert_path = tmp_dir + .path() + .join("cert.p12") + .to_string_lossy() + .to_string(); + let cert_path_tmp = tmp_dir + .path() + .join("cert.p12.tmp") + .to_string_lossy() + .to_string(); + let certificate_encoded = certificate_encoded + .to_str() + .expect("failed to convert APPLE_CERTIFICATE to string") + .as_bytes(); + let certificate_password = certificate_password + .to_str() + .expect("failed to convert APPLE_CERTIFICATE_PASSWORD to string") + .to_string(); + + // as certificate contain whitespace decoding may be broken + // https://github.com/marshallpierce/rust-base64/issues/105 + // we'll use builtin base64 command from the OS + let mut tmp_cert = File::create(cert_path_tmp.clone())?; + tmp_cert.write_all(certificate_encoded)?; + + Command::new("base64") + .args(["--decode", "-i", &cert_path_tmp, "-o", &cert_path]) + .output_ok()?; + + Command::new("security") + .args(["create-keychain", "-p", KEYCHAIN_PWD, KEYCHAIN_ID]) + .output_ok()?; + + Command::new("security") + .args(["unlock-keychain", "-p", KEYCHAIN_PWD, KEYCHAIN_ID]) + .output_ok()?; + + Command::new("security") + .args([ + "import", + &cert_path, + "-k", + KEYCHAIN_ID, + "-P", + &certificate_password, + "-T", + "/usr/bin/codesign", + "-T", + "/usr/bin/pkgbuild", + "-T", + "/usr/bin/productbuild", + ]) + .output_ok()?; + + Command::new("security") + .args(["set-keychain-settings", "-t", "3600", "-u", KEYCHAIN_ID]) + .output_ok()?; + + Command::new("security") + .args([ + "set-key-partition-list", + "-S", + "apple-tool:,apple:,codesign:", + "-s", + "-k", + KEYCHAIN_PWD, + KEYCHAIN_ID, + ]) + .output_ok()?; + + let current_keychains = String::from_utf8_lossy(&keychain_list_output.stdout) + .split('\n') + .map(|line| { + line.trim_matches(|c: char| c.is_whitespace() || c == '"') + .to_string() + }) + .filter(|l| !l.is_empty()) + .collect::>(); + + Command::new("security") + .args(["list-keychain", "-d", "user", "-s"]) + .args(current_keychains) + .arg(KEYCHAIN_ID) + .output_ok()?; + + Ok(()) +} + +pub fn delete_keychain() { + // delete keychain if needed and skip any error + let _ = Command::new("security") + .arg("delete-keychain") + .arg(KEYCHAIN_ID) + .output_ok(); +} + +pub fn try_sign( + path_to_sign: PathBuf, + identity: &str, + config: &Config, + is_an_executable: bool, +) -> crate::Result<()> { + log::info!(action = "Signing"; "{} with identity \"{}\"", path_to_sign.display(), identity); + + let packager_keychain = if let (Some(certificate_encoded), Some(certificate_password)) = ( + std::env::var_os("APPLE_CERTIFICATE"), + std::env::var_os("APPLE_CERTIFICATE_PASSWORD"), + ) { + // setup keychain allow you to import your certificate + // for CI build + setup_keychain(certificate_encoded, certificate_password)?; + true + } else { + false + }; + + let res = sign( + path_to_sign, + identity, + config, + is_an_executable, + packager_keychain, + ); + + if packager_keychain { + // delete the keychain again after signing + delete_keychain(); + } + + res +} + +fn sign( + path_to_sign: PathBuf, + identity: &str, + config: &Config, + is_an_executable: bool, + pcakger_keychain: bool, +) -> crate::Result<()> { + let mut args = vec!["--force", "-s", identity]; + + if pcakger_keychain { + args.push("--keychain"); + args.push(KEYCHAIN_ID); + } + + if let Some(entitlements_path) = config.macos().and_then(|macos| macos.entitlements.as_ref()) { + args.push("--entitlements"); + args.push(entitlements_path); + } + + if is_an_executable { + args.push("--options"); + args.push("runtime"); + } + + if path_to_sign.is_dir() { + args.push("--deep"); + } + + Command::new("codesign") + .args(args) + .arg(path_to_sign.to_string_lossy().to_string()) + .output_ok()?; + + Ok(()) +} + +#[derive(Deserialize)] +struct NotarytoolSubmitOutput { + id: String, + status: String, + message: String, +} + +pub fn notarize( + app_bundle_path: PathBuf, + auth: NotarizeAuth, + config: &Config, +) -> crate::Result<()> { + let bundle_stem = app_bundle_path + .file_stem() + .expect("failed to get bundle filename"); + + let tmp_dir = tempfile::tempdir()?; + let zip_path = tmp_dir + .path() + .join(format!("{}.zip", bundle_stem.to_string_lossy())); + let zip_args = vec![ + "-c", + "-k", + "--keepParent", + "--sequesterRsrc", + app_bundle_path + .to_str() + .expect("failed to convert bundle_path to string"), + zip_path + .to_str() + .expect("failed to convert zip_path to string"), + ]; + + // use ditto to create a PKZip almost identical to Finder + // this remove almost 99% of false alarm in notarization + Command::new("ditto").args(zip_args).output_ok()?; + + // sign the zip file + if let Some(identity) = &config + .macos() + .and_then(|macos| macos.signing_identity.as_ref()) + { + try_sign(zip_path.clone(), identity, config, false)?; + }; + + let notarize_args = vec![ + "notarytool", + "submit", + zip_path + .to_str() + .expect("failed to convert zip_path to string"), + "--wait", + "--output-format", + "json", + ]; + + log::info!(action = "Notarizing"; "{}", app_bundle_path.display()); + + let output = Command::new("xcrun") + .args(notarize_args) + .notarytool_args(&auth) + .output_ok()?; + + if !output.status.success() { + return Err(Error::FailedToNotarize); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + if let Ok(submit_output) = serde_json::from_str::(&output_str) { + let log_message = format!( + "Finished with status {} for id {} ({})", + submit_output.status, submit_output.id, submit_output.message + ); + if submit_output.status == "Accepted" { + log::info!(action = "Notarizing"; "{}", log_message); + staple_app(app_bundle_path)?; + Ok(()) + } else { + Err(Error::NotarizeRejected(log_message)) + } + } else { + Err(Error::FailedToParseNotarytoolOutput( + output_str.into_owned(), + )) + } +} + +fn staple_app(mut app_bundle_path: PathBuf) -> crate::Result<()> { + let app_bundle_path_clone = app_bundle_path.clone(); + let filename = app_bundle_path_clone + .file_name() + .expect("failed to get bundle filename") + .to_str() + .expect("failed to convert bundle filename to string"); + + app_bundle_path.pop(); + + Command::new("xcrun") + .args(vec!["stapler", "staple", "-v", filename]) + .current_dir(app_bundle_path) + .output_ok()?; + + Ok(()) +} + +pub enum NotarizeAuth { + AppleId { + apple_id: String, + password: String, + }, + ApiKey { + key: String, + key_path: PathBuf, + issuer: String, + }, +} + +pub trait NotarytoolCmdExt { + fn notarytool_args(&mut self, auth: &NotarizeAuth) -> &mut Self; +} + +impl NotarytoolCmdExt for Command { + fn notarytool_args(&mut self, auth: &NotarizeAuth) -> &mut Self { + match auth { + NotarizeAuth::AppleId { apple_id, password } => self + .arg("--apple-id") + .arg(apple_id) + .arg("--password") + .arg(password), + NotarizeAuth::ApiKey { + key, + key_path, + issuer, + } => self + .arg("--key-id") + .arg(key) + .arg("--key") + .arg(key_path) + .arg("--issuer") + .arg(issuer), + } + } +} + +pub fn notarize_auth() -> crate::Result { + match ( + std::env::var_os("APPLE_ID"), + std::env::var_os("APPLE_PASSWORD"), + ) { + (Some(apple_id), Some(apple_password)) => { + let apple_id = apple_id + .to_str() + .expect("failed to convert APPLE_ID to string") + .to_string(); + let password = apple_password + .to_str() + .expect("failed to convert APPLE_PASSWORD to string") + .to_string(); + Ok(NotarizeAuth::AppleId { apple_id, password }) + } + _ => { + match ( + std::env::var_os("APPLE_API_KEY"), + std::env::var_os("APPLE_API_ISSUER"), + std::env::var("APPLE_API_KEY_PATH"), + ) { + (Some(api_key), Some(api_issuer), Ok(key_path)) => { + let key = api_key + .to_str() + .expect("failed to convert APPLE_API_KEY to string") + .to_string(); + let issuer = api_issuer + .to_str() + .expect("failed to convert APPLE_API_ISSUER to string") + .to_string(); + Ok(NotarizeAuth::ApiKey { + key, + key_path: key_path.into(), + issuer, + }) + } + (Some(api_key), Some(api_issuer), Err(_)) => { + let key = api_key + .to_str() + .expect("failed to convert APPLE_API_KEY to string") + .to_string(); + let issuer = api_issuer + .to_str() + .expect("failed to convert APPLE_API_ISSUER to string") + .to_string(); + + let api_key_file_name = format!("AuthKey_{key}.p8"); + let mut key_path = None; + + let mut search_paths = vec!["./private_keys".into()]; + if let Some(home_dir) = dirs::home_dir() { + search_paths.push(home_dir.join("private_keys")); + search_paths.push(home_dir.join(".private_keys")); + search_paths.push(home_dir.join(".appstoreconnect").join("private_keys")); + } + + for folder in search_paths { + if let Some(path) = find_api_key(folder, &api_key_file_name) { + key_path = Some(path); + break; + } + } + + if let Some(key_path) = key_path { + Ok(NotarizeAuth::ApiKey { + key, + key_path, + issuer, + }) + } else { + Err(Error::ApiKeyMissing { + filename: api_key_file_name, + }) + } + } + _ => Err(Error::MissingNotarizeAuthVars), + } + } + } +} + +fn find_api_key(folder: PathBuf, file_name: &str) -> Option { + let path = folder.join(file_name); + if path.exists() { + Some(path) + } else { + None + } +} diff --git a/crates/packager/src/sign/mod.rs b/crates/packager/src/sign/mod.rs new file mode 100644 index 00000000..6b5b018e --- /dev/null +++ b/crates/packager/src/sign/mod.rs @@ -0,0 +1,10 @@ +#[cfg(target_os = "macos")] +#[path = "macos.rs"] +mod imp; + +#[cfg(windows)] +#[path = "windows.rs"] +mod imp; + +#[cfg(any(windows, target_os = "macos"))] +pub use imp::*; diff --git a/crates/packager/src/sign.rs b/crates/packager/src/sign/windows.rs similarity index 100% rename from crates/packager/src/sign.rs rename to crates/packager/src/sign/windows.rs diff --git a/crates/packager/src/util.rs b/crates/packager/src/util.rs index c7a8977c..4016f0cc 100644 --- a/crates/packager/src/util.rs +++ b/crates/packager/src/util.rs @@ -18,12 +18,9 @@ pub fn display_path>(p: P) -> String { /// Try to determine the current target triple. /// /// Returns a target triple (e.g. `x86_64-unknown-linux-gnu` or `i686-pc-windows-msvc`) or an -/// `Error::Config` if the current config cannot be determined or is not some combination of the +/// error if the current config cannot be determined or is not some combination of the /// following values: /// `linux, mac, windows` -- `i686, x86, armv7` -- `gnu, musl, msvc` -/// -/// * Errors: -/// * Unexpected system config pub fn target_triple() -> crate::Result { let arch = if cfg!(target_arch = "x86") { "i686" @@ -212,7 +209,8 @@ pub(crate) fn log_if_needed_and_error_out( target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", - target_os = "openbsd" + target_os = "openbsd", + target_os = "macos", ))] pub(crate) fn is_retina>(path: P) -> bool { path.as_ref() @@ -237,3 +235,111 @@ pub(crate) fn create_file(path: &Path) -> crate::Result let file = File::create(path)?; Ok(std::io::BufWriter::new(file)) } + +// Given a list of icon files, try to produce an ICNS file in the out_dir +// and return the path to it. Returns `Ok(None)` if no usable icons +// were provided. +#[cfg(target_os = "macos")] +pub fn create_icns_file(out_dir: &Path, config: &crate::Config) -> crate::Result> { + use image::GenericImageView; + + if config.icons.as_ref().map(|i| i.len()).unwrap_or_default() == 0 { + return Ok(None); + } + + // If one of the icon files is already an ICNS file, just use that. + if let Some(icons) = &config.icons { + std::fs::create_dir_all(out_dir)?; + + for icon_path in icons { + let icon_path = PathBuf::from(icon_path); + if icon_path.extension() == Some(std::ffi::OsStr::new("icns")) { + let dest_path = + out_dir.join(icon_path.file_name().expect("could not get icon filename")); + std::fs::copy(&icon_path, &dest_path)?; + + return Ok(Some(dest_path)); + } + } + } + + // Otherwise, read available images and pack them into a new ICNS file. + let mut family = icns::IconFamily::new(); + + #[inline] + fn add_icon_to_family( + icon: image::DynamicImage, + density: u32, + family: &mut icns::IconFamily, + ) -> std::io::Result<()> { + // Try to add this image to the icon family. Ignore images whose sizes + // don't map to any ICNS icon type; print warnings and skip images that + // fail to encode. + match icns::IconType::from_pixel_size_and_density(icon.width(), icon.height(), density) { + Some(icon_type) => { + if !family.has_icon_with_type(icon_type) { + let icon = make_icns_image(icon)?; + family.add_icon_with_type(&icon, icon_type)?; + } + Ok(()) + } + None => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "No matching IconType", + )), + } + } + + let mut images_to_resize: Vec<(image::DynamicImage, u32, u32)> = vec![]; + if let Some(icons) = &config.icons { + for icon_path in icons { + let icon = image::open(icon_path)?; + let density = if is_retina(icon_path) { 2 } else { 1 }; + let (w, h) = icon.dimensions(); + let orig_size = std::cmp::min(w, h); + let next_size_down = 2f32.powf((orig_size as f32).log2().floor()) as u32; + if orig_size > next_size_down { + images_to_resize.push((icon, next_size_down, density)); + } else { + add_icon_to_family(icon, density, &mut family)?; + } + } + } + + for (icon, next_size_down, density) in images_to_resize { + let icon = icon.resize_exact( + next_size_down, + next_size_down, + image::imageops::FilterType::Lanczos3, + ); + add_icon_to_family(icon, density, &mut family)?; + } + + if !family.is_empty() { + std::fs::create_dir_all(out_dir)?; + let mut dest_path = out_dir.to_path_buf(); + dest_path.push(config.product_name.clone()); + dest_path.set_extension("icns"); + let icns_file = std::io::BufWriter::new(File::create(&dest_path)?); + family.write(icns_file)?; + Ok(Some(dest_path)) + } else { + Err(crate::Error::InvalidIconList) + } +} + +// Converts an image::DynamicImage into an icns::Image. +#[cfg(target_os = "macos")] +fn make_icns_image(img: image::DynamicImage) -> std::io::Result { + let pixel_format = match img.color() { + image::ColorType::Rgba8 => icns::PixelFormat::RGBA, + image::ColorType::Rgb8 => icns::PixelFormat::RGB, + image::ColorType::La8 => icns::PixelFormat::GrayAlpha, + image::ColorType::L8 => icns::PixelFormat::Gray, + _ => { + let msg = format!("unsupported ColorType: {:?}", img.color()); + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)); + } + }; + icns::Image::from_data(pixel_format, img.width(), img.height(), img.into_bytes()) +} diff --git a/examples/tauri/Cargo.toml b/examples/tauri/Cargo.toml index 928714cd..3197e5e7 100644 --- a/examples/tauri/Cargo.toml +++ b/examples/tauri/Cargo.toml @@ -27,12 +27,12 @@ icons = [ [package.metadata.packager.deb] depends = ["libgtk-3-0", "libwebkit2gtk-4.1-0", "libayatana-appindicator3-1"] - [build-dependencies] -tauri-build = { version = "=2.0.0-alpha.7", features = [] } +tauri-build = { version = "=2.0.0-alpha.6", features = [] } [dependencies] tauri = { version = "=2.0.0-alpha.10", features = [] } +tauri-utils = { version = "=2.0.0-alpha.6", features = [] } tauri-macros = "=2.0.0-alpha.6" serde.workspace = true serde_json.workspace = true