From 8309a63750c7c41432648b4ab76670bf145fa5f0 Mon Sep 17 00:00:00 2001 From: amr-crabnebula Date: Wed, 6 Sep 2023 21:48:57 +0300 Subject: [PATCH] feat: implement `wix` --- Cargo.lock | 10 + Cargo.toml | 1 + crates/config/Cargo.toml | 4 + crates/config/src/lib.rs | 23 +- crates/packager/Cargo.toml | 6 +- crates/packager/schema.json | 10 +- crates/packager/src/config.rs | 31 +- crates/packager/src/error.rs | 17 + crates/packager/src/lib.rs | 8 +- crates/packager/src/main.rs | 36 +- crates/packager/src/msi/mod.rs | 22 - crates/packager/src/nsis/mod.rs | 377 ++++---- crates/packager/src/util.rs | 32 +- .../src/wix/default-locale-strings.xml | 6 + crates/packager/src/wix/install-task.ps1 | 28 + crates/packager/src/wix/languages.json | 154 ++++ crates/packager/src/wix/main.wxs | 324 +++++++ crates/packager/src/wix/mod.rs | 869 ++++++++++++++++++ crates/packager/src/wix/uninstall-task.ps1 | 23 + crates/packager/src/wix/update-task.xml | 43 + examples/dioxus/Cargo.toml | 1 - examples/tauri/Cargo.toml | 8 +- 22 files changed, 1767 insertions(+), 266 deletions(-) delete mode 100644 crates/packager/src/msi/mod.rs create mode 100644 crates/packager/src/wix/default-locale-strings.xml create mode 100644 crates/packager/src/wix/install-task.ps1 create mode 100644 crates/packager/src/wix/languages.json create mode 100644 crates/packager/src/wix/main.wxs create mode 100644 crates/packager/src/wix/mod.rs create mode 100644 crates/packager/src/wix/uninstall-task.ps1 create mode 100644 crates/packager/src/wix/update-task.xml diff --git a/Cargo.lock b/Cargo.lock index 280b2cdf..d0f42c52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,6 +380,7 @@ dependencies = [ "hex", "log", "once_cell", + "regex", "relative-path", "schemars", "semver", @@ -389,6 +390,7 @@ dependencies = [ "sha2", "thiserror", "ureq", + "uuid", "windows-sys 0.48.0", "winreg 0.51.0", "zip", @@ -398,6 +400,7 @@ dependencies = [ name = "cargo-packager-config" version = "0.0.0" dependencies = [ + "clap", "schemars", "serde", ] @@ -3369,6 +3372,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "sha2" version = "0.10.7" @@ -4285,6 +4294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ "getrandom 0.2.10", + "sha1_smol", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a7b41310..4b893486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ serde_json = "1.0" dunce = "1" schemars = { version = "0.8", features = ["url", "preserve_order", "derive"] } cargo-packager-config = { path = "crates/config", version = "0.0.0" } +clap = { version = "4.0", features = ["derive"] } diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 8af46dd0..671ab232 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -5,6 +5,10 @@ description = "Config types for cargo-packager." edition = "2021" license = "Apache-2.0 OR MIT" +[features] +clap = ["dep:clap"] + [dependencies] serde.workspace = true schemars.workspace = true +clap = { workspace = true, optional = true } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index cc271038..1bd77e99 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; /// The type of the package we're bundling. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, JsonSchema)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum PackageFormat { @@ -14,8 +15,8 @@ pub enum PackageFormat { Dmg, /// The iOS app bundle. Ios, - /// The Microsoft Software Installer (.msi). - Msi, + /// The Microsoft Software Installer (.msi) through WiX Toolset. + Wix, /// The NSIS installer (.exe). Nsis, /// The Linux Debian package (.deb). @@ -34,14 +35,14 @@ impl Display for PackageFormat { impl PackageFormat { /// Maps a short name to a [PackageFormat]. - /// Possible values are "deb", "ios", "msi", "app", "rpm", "appimage", "dmg". + /// Possible values are "deb", "ios", "wix", "app", "rpm", "appimage", "dmg". pub fn from_short_name(name: &str) -> Option { // Other types we may eventually want to support: apk. match name { "app" => Some(PackageFormat::App), "dmg" => Some(PackageFormat::Dmg), "ios" => Some(PackageFormat::Ios), - "msi" => Some(PackageFormat::Msi), + "wix" => Some(PackageFormat::Wix), "nsis" => Some(PackageFormat::Nsis), "deb" => Some(PackageFormat::Deb), "rpm" => Some(PackageFormat::Rpm), @@ -56,7 +57,7 @@ impl PackageFormat { PackageFormat::App => "app", PackageFormat::Dmg => "dmg", PackageFormat::Ios => "ios", - PackageFormat::Msi => "msi", + PackageFormat::Wix => "wix", PackageFormat::Nsis => "nsis", PackageFormat::Deb => "deb", PackageFormat::Rpm => "rpm", @@ -78,7 +79,7 @@ const ALL_PACKAGE_TYPES: &[PackageFormat] = &[ #[cfg(target_os = "macos")] PackageFormat::Ios, #[cfg(target_os = "windows")] - PackageFormat::Msi, + PackageFormat::Wix, #[cfg(target_os = "windows")] PackageFormat::Nsis, #[cfg(any( @@ -276,7 +277,7 @@ impl Default for WixLanguages { } } -/// Settings specific to the WiX implementation. +/// The wix format configuration #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WixConfig { @@ -359,7 +360,7 @@ impl Default for NSISInstallerMode { } } -/// Settings specific to the NSIS implementation. +/// The NSIS format configuration. #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct NsisConfig { @@ -422,7 +423,7 @@ pub struct WindowsConfig { /// use a TSP timestamp server, like e.g. SSL.com does. If so, enable TSP by setting to true. #[serde(default)] pub tsp: bool, - // TODO find an agnostic way to specify custom logic to install webview2 + // TODO: find an agnostic way to specify custom logic to install webview2 // /// The installation mode for the Webview2 runtime. // pub webview_install_mode: WebviewInstallMode, // /// Path to the webview fixed runtime to use. @@ -497,7 +498,7 @@ pub enum LogLevel { impl Default for LogLevel { fn default() -> Self { - Self::Info + Self::Error } } @@ -568,7 +569,7 @@ pub struct Config { /// The package types we're creating. /// /// if not present, we'll use the PackageType list for the target OS. - pub format: Option>, + pub formats: Option>, /// the directory where the packages will be placed. #[serde(default, alias = "out-dir", alias = "out_dir")] pub out_dir: PathBuf, diff --git a/crates/packager/Cargo.toml b/crates/packager/Cargo.toml index d76f83b5..1ab44228 100644 --- a/crates/packager/Cargo.toml +++ b/crates/packager/Cargo.toml @@ -8,7 +8,7 @@ license = "Apache-2.0 OR MIT" [features] default = ["cli"] -cli = ["env_logger", "clap"] +cli = ["env_logger", "clap", "cargo-packager-config/clap"] [build-dependencies] cargo-packager-config.workspace = true @@ -24,7 +24,7 @@ serde_json.workspace = true dunce.workspace = true cargo_metadata = "0.17" env_logger = { version = "0.9", optional = true } -clap = { version = "4.0", features = ["derive"], optional = true } +clap = { workspace = true, optional = true } dirs = "5.0" ureq = "2.7" hex = "0.4" @@ -39,6 +39,8 @@ relative-path = "1.9" [target."cfg(target_os = \"windows\")".dependencies] winreg = "0.51" once_cell = "1.18" +uuid = { version = "1", features = ["v4", "v5"] } +regex = "1.9" [target."cfg(target_os = \"windows\")".dependencies.windows-sys] version = "0.48" diff --git a/crates/packager/schema.json b/crates/packager/schema.json index 582bf15e..687b94ee 100644 --- a/crates/packager/schema.json +++ b/crates/packager/schema.json @@ -46,7 +46,7 @@ } ] }, - "format": { + "formats": { "description": "The package types we're creating.\n\nif not present, we'll use the PackageType list for the target OS.", "type": [ "array", @@ -363,10 +363,10 @@ ] }, { - "description": "The Microsoft Software Installer (.msi).", + "description": "The Microsoft Software Installer (.msi) through WiX Toolset.", "type": "string", "enum": [ - "msi" + "wix" ] }, { @@ -621,7 +621,7 @@ "additionalProperties": false }, "WixConfig": { - "description": "Settings specific to the WiX implementation.", + "description": "The wix format configuration", "type": "object", "properties": { "languages": { @@ -766,7 +766,7 @@ "additionalProperties": false }, "NsisConfig": { - "description": "Settings specific to the NSIS implementation.", + "description": "The NSIS format configuration.", "type": "object", "properties": { "template": { diff --git a/crates/packager/src/config.rs b/crates/packager/src/config.rs index 2f2c52f9..85878f22 100644 --- a/crates/packager/src/config.rs +++ b/crates/packager/src/config.rs @@ -8,15 +8,13 @@ pub(crate) struct Resource { pub target: PathBuf, } -pub(crate) trait ConfigExtInternal { - fn resources(&self) -> Option>; -} - pub trait ConfigExt { /// Returns the windows specific configuration fn windows(&self) -> Option<&WindowsConfig>; /// Returns the nsis specific configuration fn nsis(&self) -> Option<&NsisConfig>; + /// Returns the wix specific configuration + fn wix(&self) -> Option<&WixConfig>; /// Returns the architecture for the binary being packaged (e.g. "arm", "x86" or "x86_64"). fn target_arch(&self) -> crate::Result<&str>; /// Returns the path to the specified binary. @@ -36,6 +34,10 @@ impl ConfigExt for Config { self.nsis.as_ref() } + fn wix(&self) -> Option<&WixConfig> { + self.wix.as_ref() + } + fn target_arch(&self) -> crate::Result<&str> { Ok(if self.target_triple.starts_with("x86_64") { "x86_64" @@ -70,6 +72,11 @@ impl ConfigExt for Config { } } +pub(crate) trait ConfigExtInternal { + fn resources(&self) -> Option>; + fn find_ico(&self) -> Option; +} + impl ConfigExtInternal for Config { fn resources(&self) -> Option> { self.resources.as_ref().map(|resources| { @@ -111,4 +118,20 @@ impl ConfigExtInternal for Config { out }) } + + fn find_ico(&self) -> Option { + self.icons + .as_ref() + .and_then(|icons| { + icons + .iter() + .find(|i| PathBuf::from(i).extension().and_then(|s| s.to_str()) == Some("ico")) + .or_else(|| { + icons.iter().find(|i| { + PathBuf::from(i).extension().and_then(|s| s.to_str()) == Some("png") + }) + }) + }) + .map(PathBuf::from) + } } diff --git a/crates/packager/src/error.rs b/crates/packager/src/error.rs index ab068789..eafb3712 100644 --- a/crates/packager/src/error.rs +++ b/crates/packager/src/error.rs @@ -63,10 +63,27 @@ pub enum Error { /// Nsis error #[error("error running makensis.exe: {0}")] NsisFailed(String), + /// Nsis error + #[error("error running {0}: {0}")] + WixFailed(String, String), + /// Failed to get parent directory of a path #[error("Failed to get parent directory of a path")] ParentDirNotFound, #[error("{0} `{1}` failed with exit code {2}")] HookCommandFailure(String, String, i32), + /// Regex error. + #[cfg(windows)] + #[error(transparent)] + RegexError(#[from] regex::Error), + /// Glob pattern error. + #[error(transparent)] + GlobPatternError(#[from] glob::PatternError), + /// Glob error. + #[error(transparent)] + Glob(#[from] glob::GlobError), + /// Unsupported WiX language + #[error("Language {0} not found. It must be one of {1}")] + UnsupportedWixLanguage(String, String), } /// Convenient type alias of Result type for cargo-packager. diff --git a/crates/packager/src/lib.rs b/crates/packager/src/lib.rs index 5a11bc73..208c019d 100644 --- a/crates/packager/src/lib.rs +++ b/crates/packager/src/lib.rs @@ -36,8 +36,6 @@ mod dmg; mod error; #[cfg(target_os = "macos")] mod ios; -#[cfg(windows)] -mod msi; mod nsis; #[cfg(any( target_os = "linux", @@ -49,6 +47,8 @@ mod nsis; mod rpm; mod sign; pub mod util; +#[cfg(windows)] +mod wix; use std::{path::PathBuf, process::Command}; @@ -92,7 +92,7 @@ pub fn package(config: &Config) -> Result> { let mut packages = Vec::new(); let formats = config - .format + .formats .clone() .unwrap_or_else(|| PackageFormat::all().to_vec()); @@ -161,7 +161,7 @@ pub fn package(config: &Config) -> Result> { #[cfg(target_os = "macos")] PackageFormat::Ios => ios::package(config), #[cfg(target_os = "windows")] - PackageFormat::Msi => msi::package(config), + PackageFormat::Wix => wix::package(config), PackageFormat::Nsis => nsis::package(config), #[cfg(any( target_os = "linux", diff --git a/crates/packager/src/main.rs b/crates/packager/src/main.rs index a32b1ada..16be9e7d 100644 --- a/crates/packager/src/main.rs +++ b/crates/packager/src/main.rs @@ -4,7 +4,7 @@ use cargo_packager::{ config::{Binary, Config}, package, util, Result, }; -use cargo_packager_config::LogLevel; +use cargo_packager_config::{LogLevel, PackageFormat}; use clap::{ArgAction, CommandFactory, FromArgMatches, Parser}; use env_logger::fmt::Color; use log::{log_enabled, Level}; @@ -18,17 +18,20 @@ fn load_configs_from_cwd(profile: &str, cli: &Cli) -> Result Result LogLevel::Error, - 1 => LogLevel::Warn, - 2 => LogLevel::Info, - 3 => LogLevel::Debug, - 4.. => LogLevel::Trace, + 0 => LogLevel::Info, + 1 => LogLevel::Debug, + 2.. => LogLevel::Trace, }); } if config.license_file.is_none() { @@ -116,14 +117,16 @@ fn try_run(cli: Cli) -> Result<()> { // print information when finished let len = packages.len(); - let pluralised = if len == 1 { "package" } else { "packages" }; - let mut printable_paths = String::new(); - for p in packages { - for path in &p.paths { - writeln!(printable_paths, " {}", util::display_path(path)).unwrap(); + if len >= 1 { + let pluralised = if len == 1 { "package" } else { "packages" }; + let mut printable_paths = String::new(); + for p in packages { + for path in &p.paths { + writeln!(printable_paths, " {}", util::display_path(path)).unwrap(); + } } + log::info!(action = "Finished"; "{} {} at:\n{}", len, pluralised, printable_paths); } - log::info!(action = "Finished"; "{} {} at:\n{}", len, pluralised, printable_paths); Ok(()) } @@ -168,6 +171,9 @@ pub(crate) struct Cli { /// Specify the manifest path to use for reading the configuration. #[clap(long)] manifest_path: Option, + /// Specify the package fromats to build. + #[clap(long, value_enum)] + formats: Option>, } fn main() { diff --git a/crates/packager/src/msi/mod.rs b/crates/packager/src/msi/mod.rs deleted file mode 100644 index 9143b53d..00000000 --- a/crates/packager/src/msi/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::path::PathBuf; - -use crate::config::Config; - -pub fn package(_config: &Config) -> crate::Result> { - // let mut wix_path = dirs::cache_dir().unwrap(); - // wix_path.push("tauri/WixTools"); - - // if !wix_path.exists() { - // get_and_extract_wix(&wix_path)?; - // } else if WIX_REQUIRED_FILES - // .iter() - // .any(|p| !wix_path.join(p).exists()) - // { - // warn!("WixTools directory is missing some files. Recreating it."); - // std::fs::remove_dir_all(&wix_path)?; - // get_and_extract_wix(&wix_path)?; - // } - - // build_wix_app_installer(settings, &wix_path, updater) - Ok(vec![]) -} diff --git a/crates/packager/src/nsis/mod.rs b/crates/packager/src/nsis/mod.rs index 1aeb9e03..18eb3cb8 100644 --- a/crates/packager/src/nsis/mod.rs +++ b/crates/packager/src/nsis/mod.rs @@ -42,22 +42,162 @@ const NSIS_REQUIRED_FILES: &[&str] = &[ "Plugins/x86-unicode/nsis_tauri_utils.dll", ]; -pub fn package(config: &Config) -> crate::Result> { - let packager_tools_path = dirs::cache_dir().unwrap().join("cargo-packager"); - let nsis_toolset_path = packager_tools_path.join("NSIS"); +/// BTreeMap +type ResourcesMap = BTreeMap; +fn generate_resource_data(config: &Config) -> ResourcesMap { + let mut resources_map = ResourcesMap::new(); + if let Some(resources) = config.resources() { + for resource in resources { + resources_map.insert( + resource.src, + ( + resource + .target + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(), + resource.target, + ), + ); + } + } - if !nsis_toolset_path.exists() { - get_and_extract_nsis(&nsis_toolset_path, &packager_tools_path)?; - } else if NSIS_REQUIRED_FILES - .iter() - .any(|p| !nsis_toolset_path.join(p).exists()) - { - log::warn!("NSIS directory is missing some files. Recreating it."); - std::fs::remove_dir_all(&nsis_toolset_path)?; - get_and_extract_nsis(&nsis_toolset_path, &packager_tools_path)?; + resources_map +} + +/// BTreeMap +type BinariesMap = BTreeMap; +fn generate_binaries_data(config: &Config) -> crate::Result { + let mut binaries = BinariesMap::new(); + let cwd = std::env::current_dir()?; + + if let Some(external_binaries) = &config.external_binaries { + for src in external_binaries { + let binary_path = dunce::canonicalize(cwd.join(&src))?; + let dest_filename = binary_path + .file_name() + .expect("failed to extract external binary filename") + .to_string_lossy() + .replace(&format!("-{}", config.target_triple), ""); + binaries.insert(binary_path, dest_filename); + } } - build_nsis_app_installer(config, &nsis_toolset_path, &packager_tools_path) + for bin in &config.binaries { + if !bin.main { + let bin_path = config.binary_path(bin); + binaries.insert( + bin_path.clone(), + bin_path + .file_name() + .expect("failed to extract binary filename") + .to_string_lossy() + .to_string(), + ); + } + } + + Ok(binaries) +} + +fn get_lang_data( + lang: &str, + custom_lang_files: Option<&HashMap>, +) -> crate::Result)>> { + if let Some(path) = custom_lang_files.and_then(|h| h.get(lang)) { + return Ok(Some((dunce::canonicalize(path)?, None))); + } + + let lang_path = PathBuf::from(format!("{lang}.nsh")); + let lang_content = match lang.to_lowercase().as_str() { + "arabic" => Some(include_str!("./languages/Arabic.nsh")), + "bulgarian" => Some(include_str!("./languages/Bulgarian.nsh")), + "dutch" => Some(include_str!("./languages/Dutch.nsh")), + "english" => Some(include_str!("./languages/English.nsh")), + "japanese" => Some(include_str!("./languages/Japanese.nsh")), + "korean" => Some(include_str!("./languages/Korean.nsh")), + "portuguesebr" => Some(include_str!("./languages/PortugueseBR.nsh")), + "tradchinese" => Some(include_str!("./languages/TradChinese.nsh")), + "simpchinese" => Some(include_str!("./languages/SimpChinese.nsh")), + "french" => Some(include_str!("./languages/French.nsh")), + "spanish" => Some(include_str!("./languages/Spanish.nsh")), + "spanishinternational" => Some(include_str!("./languages/SpanishInternational.nsh")), + "persian" => Some(include_str!("./languages/Persian.nsh")), + "turkish" => Some(include_str!("./languages/Turkish.nsh")), + "swedish" => Some(include_str!("./languages/Swedish.nsh")), + _ => return Ok(None), + }; + + Ok(Some((lang_path, lang_content))) +} + +fn write_ut16_le_with_bom>(path: P, content: &str) -> crate::Result<()> { + use std::fs::File; + use std::io::{BufWriter, Write}; + + let file = File::create(path)?; + let mut output = BufWriter::new(file); + output.write_all(&[0xFF, 0xFE])?; // the BOM part + for utf16 in content.encode_utf16() { + output.write_all(&utf16.to_le_bytes())?; + } + Ok(()) +} + +fn handlebars_or( + h: &handlebars::Helper<'_, '_>, + _: &Handlebars<'_>, + _: &handlebars::Context, + _: &mut handlebars::RenderContext<'_, '_>, + out: &mut dyn handlebars::Output, +) -> handlebars::HelperResult { + let param1 = h.param(0).unwrap().render(); + let param2 = h.param(1).unwrap(); + + out.write(&if param1.is_empty() { + param2.render() + } else { + param1 + })?; + Ok(()) +} + +fn association_description( + h: &handlebars::Helper<'_, '_>, + _: &Handlebars<'_>, + _: &handlebars::Context, + _: &mut handlebars::RenderContext<'_, '_>, + out: &mut dyn handlebars::Output, +) -> handlebars::HelperResult { + let description = h.param(0).unwrap().render(); + let ext = h.param(1).unwrap(); + + out.write(&if description.is_empty() { + format!("{} File", ext.render().to_uppercase()) + } else { + description + })?; + Ok(()) +} + +fn add_build_number_if_needed(version_str: &str) -> crate::Result { + let version = semver::Version::parse(version_str)?; + if !version.build.is_empty() { + let build = version.build.parse::(); + if build.is_ok() { + return Ok(format!( + "{}.{}.{}.{}", + version.major, version.minor, version.patch, version.build + )); + } else { + return Err(crate::Error::NonNumericBuildMetadata(None)); + } + } + + Ok(format!( + "{}.{}.{}.0", + version.major, version.minor, version.patch, + )) } fn get_and_extract_nsis( @@ -125,7 +265,7 @@ fn build_nsis_app_installer( .find(|bin| bin.main) .ok_or_else(|| crate::Error::MainBinaryNotFound)?; let app_exe_source = config.binary_path(main_binary); - crate::sign::try_sign(&app_exe_source, config)?; + crate::sign::try_sign(&app_exe_source.with_extension("exe"), config)?; } #[cfg(not(target_os = "windows"))] @@ -142,7 +282,7 @@ fn build_nsis_app_installer( #[cfg(not(target_os = "windows"))] { let mut dir = dirs::cache_dir().unwrap(); - dir.extend(["tauri", "NSIS", "Plugins", "x86-unicode"]); + dir.extend(["cargo-packager", "NSIS", "Plugins", "x86-unicode"]); data.insert("additional_plugins_path", to_json(dir)); } @@ -243,10 +383,7 @@ fn build_nsis_app_installer( .iter() .find(|bin| bin.main) .ok_or_else(|| crate::Error::MainBinaryNotFound)?; - data.insert( - "main_binary_name", - to_json(main_binary.name.replace(".exe", "")), - ); + data.insert("main_binary_name", to_json(&main_binary.name)); data.insert( "main_binary_path", to_json(config.binary_path(main_binary).with_extension("exe")), @@ -314,12 +451,7 @@ fn build_nsis_app_installer( } } - let package_base_name = format!( - "{}_{}_{}-setup", - main_binary.name.replace(".exe", ""), - config.version, - arch, - ); + let package_base_name = format!("{}_{}_{}-setup", main_binary.name, config.version, arch,); let nsis_output_path = output_path.join(out_file); let nsis_installer_path = config @@ -334,19 +466,23 @@ fn build_nsis_app_installer( #[cfg(not(target_os = "windows"))] let mut nsis_cmd = Command::new("makensis"); - nsis_cmd - .arg(match config.log_level.unwrap_or_default() { - LogLevel::Error => "-V1", - LogLevel::Warn => "-V2", - LogLevel::Info => "-V3", - LogLevel::Debug => "-V3", - _ => "-V4", + let log_level = config.log_level.unwrap_or_default(); + + let output = nsis_cmd + .arg(match log_level { + LogLevel::Error => "/V1", + LogLevel::Warn => "/V2", + LogLevel::Info => "/V3", + LogLevel::Debug => "/V3", + _ => "/V4", }) .arg(installer_nsi_path) .current_dir(output_path) - .status() + .output() .map_err(|e| crate::Error::NsisFailed(e.to_string()))?; + util::log_if_needed(log_level, output); + std::fs::rename(nsis_output_path, &nsis_installer_path)?; #[cfg(target_os = "windows")] @@ -357,169 +493,20 @@ fn build_nsis_app_installer( Ok(vec![nsis_installer_path]) } -/// BTreeMap -type ResourcesMap = BTreeMap; -fn generate_resource_data(config: &Config) -> ResourcesMap { - let mut resources_map = ResourcesMap::new(); - let mut added_resources = Vec::new(); - if let Some(resources) = config.resources() { - for resource in resources { - // In some glob resource paths like `assets/**/*` a file might appear twice - // because the `tauri_utils::resources::ResourcePaths` iterator also reads a directory - // when it finds one. So we must check it before processing the file. - if added_resources.contains(&resource.src) { - continue; - } - added_resources.push(resource.src.clone()); - - resources_map.insert( - resource.src, - ( - resource - .target - .parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(), - resource.target, - ), - ); - } - } - - resources_map -} - -/// BTreeMap -type BinariesMap = BTreeMap; -fn generate_binaries_data(config: &Config) -> crate::Result { - let mut binaries = BinariesMap::new(); - let cwd = std::env::current_dir()?; - - if let Some(external_binaries) = &config.external_binaries { - for src in external_binaries { - let binary_path = dunce::canonicalize(cwd.join(&src))?; - let dest_filename = binary_path - .file_name() - .expect("failed to extract external binary filename") - .to_string_lossy() - .replace(&format!("-{}", config.target_triple), ""); - binaries.insert(binary_path, dest_filename); - } - } - - for bin in &config.binaries { - if !bin.main { - let bin_path = config.binary_path(bin); - binaries.insert( - bin_path.clone(), - bin_path - .file_name() - .expect("failed to extract binary filename") - .to_string_lossy() - .to_string(), - ); - } - } - - Ok(binaries) -} - -fn get_lang_data( - lang: &str, - custom_lang_files: Option<&HashMap>, -) -> crate::Result)>> { - if let Some(path) = custom_lang_files.and_then(|h| h.get(lang)) { - return Ok(Some((dunce::canonicalize(path)?, None))); - } - - let lang_path = PathBuf::from(format!("{lang}.nsh")); - let lang_content = match lang.to_lowercase().as_str() { - "arabic" => Some(include_str!("./languages/Arabic.nsh")), - "bulgarian" => Some(include_str!("./languages/Bulgarian.nsh")), - "dutch" => Some(include_str!("./languages/Dutch.nsh")), - "english" => Some(include_str!("./languages/English.nsh")), - "japanese" => Some(include_str!("./languages/Japanese.nsh")), - "korean" => Some(include_str!("./languages/Korean.nsh")), - "portuguesebr" => Some(include_str!("./languages/PortugueseBR.nsh")), - "tradchinese" => Some(include_str!("./languages/TradChinese.nsh")), - "simpchinese" => Some(include_str!("./languages/SimpChinese.nsh")), - "french" => Some(include_str!("./languages/French.nsh")), - "spanish" => Some(include_str!("./languages/Spanish.nsh")), - "spanishinternational" => Some(include_str!("./languages/SpanishInternational.nsh")), - "persian" => Some(include_str!("./languages/Persian.nsh")), - "turkish" => Some(include_str!("./languages/Turkish.nsh")), - "swedish" => Some(include_str!("./languages/Swedish.nsh")), - _ => return Ok(None), - }; - - Ok(Some((lang_path, lang_content))) -} - -fn write_ut16_le_with_bom>(path: P, content: &str) -> crate::Result<()> { - use std::fs::File; - use std::io::{BufWriter, Write}; - - let file = File::create(path)?; - let mut output = BufWriter::new(file); - output.write_all(&[0xFF, 0xFE])?; // the BOM part - for utf16 in content.encode_utf16() { - output.write_all(&utf16.to_le_bytes())?; - } - Ok(()) -} - -fn handlebars_or( - h: &handlebars::Helper<'_, '_>, - _: &Handlebars<'_>, - _: &handlebars::Context, - _: &mut handlebars::RenderContext<'_, '_>, - out: &mut dyn handlebars::Output, -) -> handlebars::HelperResult { - let param1 = h.param(0).unwrap().render(); - let param2 = h.param(1).unwrap(); - - out.write(&if param1.is_empty() { - param2.render() - } else { - param1 - })?; - Ok(()) -} - -fn association_description( - h: &handlebars::Helper<'_, '_>, - _: &Handlebars<'_>, - _: &handlebars::Context, - _: &mut handlebars::RenderContext<'_, '_>, - out: &mut dyn handlebars::Output, -) -> handlebars::HelperResult { - let description = h.param(0).unwrap().render(); - let ext = h.param(1).unwrap(); - - out.write(&if description.is_empty() { - format!("{} File", ext.render().to_uppercase()) - } else { - description - })?; - Ok(()) -} +pub fn package(config: &Config) -> crate::Result> { + let packager_tools_path = dirs::cache_dir().unwrap().join("cargo-packager"); + let nsis_toolset_path = packager_tools_path.join("NSIS"); -fn add_build_number_if_needed(version_str: &str) -> crate::Result { - let version = semver::Version::parse(version_str)?; - if !version.build.is_empty() { - let build = version.build.parse::(); - if build.is_ok() { - return Ok(format!( - "{}.{}.{}.{}", - version.major, version.minor, version.patch, version.build - )); - } else { - return Err(crate::Error::NonNumericBuildMetadata(None)); - } + if !nsis_toolset_path.exists() { + get_and_extract_nsis(&nsis_toolset_path, &packager_tools_path)?; + } else if NSIS_REQUIRED_FILES + .iter() + .any(|p| !nsis_toolset_path.join(p).exists()) + { + log::warn!("NSIS directory is missing some files. Recreating it."); + std::fs::remove_dir_all(&nsis_toolset_path)?; + get_and_extract_nsis(&nsis_toolset_path, &packager_tools_path)?; } - Ok(format!( - "{}.{}.{}.0", - version.major, version.minor, version.patch, - )) + build_nsis_app_installer(config, &nsis_toolset_path, &packager_tools_path) } diff --git a/crates/packager/src/util.rs b/crates/packager/src/util.rs index c702390b..ff06ebf8 100644 --- a/crates/packager/src/util.rs +++ b/crates/packager/src/util.rs @@ -1,8 +1,10 @@ +use cargo_packager_config::LogLevel; use sha2::Digest; use std::{ fs::File, io::{Cursor, Read, Write}, path::{Path, PathBuf}, + process::Output, }; use zip::ZipArchive; @@ -66,7 +68,7 @@ pub fn target_triple() -> crate::Result { Ok(format!("{arch}-{os}")) } -pub fn download(url: &str) -> crate::Result> { +pub(crate) fn download(url: &str) -> crate::Result> { log::info!(action = "Downloading"; "{}", url); let response = ureq::get(url).call()?; let mut bytes = Vec::new(); @@ -74,14 +76,14 @@ pub fn download(url: &str) -> crate::Result> { Ok(bytes) } -pub enum HashAlgorithm { +pub(crate) enum HashAlgorithm { #[cfg(target_os = "windows")] Sha256, Sha1, } /// Function used to download a file and checks SHA256 to verify the download. -pub fn download_and_verify( +pub(crate) fn download_and_verify( file: &str, url: &str, hash: &str, @@ -118,7 +120,7 @@ fn verify(data: &Vec, hash: &str, mut hasher: impl Digest) -> crate::Result< } /// Extracts the zips from memory into a useable path. -pub fn extract_zip(data: &[u8], path: &Path) -> crate::Result<()> { +pub(crate) fn extract_zip(data: &[u8], path: &Path) -> crate::Result<()> { let cursor = Cursor::new(data); let mut zipa = ZipArchive::new(cursor)?; @@ -150,14 +152,14 @@ pub fn extract_zip(data: &[u8], path: &Path) -> crate::Result<()> { Ok(()) } -pub enum Bitness { +pub(crate) enum Bitness { X86_32, X86_64, Unknown, } #[cfg(windows)] -pub fn os_bitness() -> crate::Result { +pub(crate) fn os_bitness() -> crate::Result { use windows_sys::Win32::System::{ Diagnostics::Debug::{PROCESSOR_ARCHITECTURE_AMD64, PROCESSOR_ARCHITECTURE_INTEL}, SystemInformation::{GetNativeSystemInfo, SYSTEM_INFO}, @@ -174,3 +176,21 @@ pub fn os_bitness() -> crate::Result { }, ) } + +pub(crate) fn log_if_needed(log_level: LogLevel, output: Output) { + if output.status.success() && !output.stdout.is_empty() && log_level >= LogLevel::Debug { + log::debug!(action = "stdout"; "{}", String::from_utf8_lossy(&output.stdout)) + } else if !output.status.success() && log_level >= LogLevel::Error { + let action = if !output.stderr.is_empty() { + "stderr" + } else { + "stdout" + }; + let output = if !output.stderr.is_empty() { + &output.stderr + } else { + &output.stdout + }; + log::error!(action = action; "{}", String::from_utf8_lossy(output)) + } +} diff --git a/crates/packager/src/wix/default-locale-strings.xml b/crates/packager/src/wix/default-locale-strings.xml new file mode 100644 index 00000000..e994c6bf --- /dev/null +++ b/crates/packager/src/wix/default-locale-strings.xml @@ -0,0 +1,6 @@ +__language__ +__codepage__ +Launch __productName__ +A newer version of __productName__ is already installed. +Add the install location of the __productName__ executable to the PATH system environment variable. This allows the __productName__ executable to be called from any location. +Installs __productName__. \ No newline at end of file diff --git a/crates/packager/src/wix/install-task.ps1 b/crates/packager/src/wix/install-task.ps1 new file mode 100644 index 00000000..b3d44366 --- /dev/null +++ b/crates/packager/src/wix/install-task.ps1 @@ -0,0 +1,28 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT +# Adapted from https://superuser.com/a/532109 +param([string]$ChangeDir, [switch]$Elevated) + +function Test-Admin { + $currentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent()) + $currentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) +} + +if ((Test-Admin) -eq $false) { + if ($elevated) { + # tried to elevate, did not work, aborting + } + else { + $InstallDirectory = Get-Location + $ArgList = ('-File "{0}" -ChangeDir "{1}" -Elevated' -f ($myinvocation.MyCommand.Definition, $InstallDirectory)) + Start-Process "$env:SYSTEMROOT\System32\WindowsPowerShell\v1.0\powershell.exe" -WindowStyle hidden -Verb RunAs -ArgumentList $ArgList + } + exit +} + +if ($ChangeDir -ne "") { + # Change directories to the install path + Set-Location -Path $ChangeDir +} +SCHTASKS.EXE /CREATE /XML update.xml /TN "Update {{product_name}} - Skip UAC" /F diff --git a/crates/packager/src/wix/languages.json b/crates/packager/src/wix/languages.json new file mode 100644 index 00000000..8a3d1796 --- /dev/null +++ b/crates/packager/src/wix/languages.json @@ -0,0 +1,154 @@ +{ + "ar-SA": { + "langId": 1025, + "asciiCode": 1256 + }, + "ca-ES": { + "langId": 1027, + "asciiCode": 1252 + }, + "zh-TW": { + "langId": 1028, + "asciiCode": 950 + }, + "zh-CN": { + "langId": 2052, + "asciiCode": 936 + }, + "cs-CZ": { + "langId": 1029, + "asciiCode": 1250 + }, + "da-DK": { + "langId": 1030, + "asciiCode": 1252 + }, + "de-DE": { + "langId": 1031, + "asciiCode": 1252 + }, + "el-GR": { + "langId": 1032, + "asciiCode": 1253 + }, + "en-US": { + "langId": 1033, + "asciiCode": 1252 + }, + "es-ES": { + "langId": 3082, + "asciiCode": 1252 + }, + "et-EE": { + "langId": 1061, + "asciiCode": 1257 + }, + "fi-FI": { + "langId": 1035, + "asciiCode": 1252 + }, + "fr-FR": { + "langId": 1036, + "asciiCode": 1252 + }, + "he-IL": { + "langId": 1037, + "asciiCode": 1255 + }, + "hu-HU": { + "langId": 1038, + "asciiCode": 1250 + }, + "it-IT": { + "langId": 1040, + "asciiCode": 1252 + }, + "ja-JP": { + "langId": 1041, + "asciiCode": 932 + }, + "ko-KR": { + "langId": 1042, + "asciiCode": 949 + }, + "lt-LT": { + "langId": 1063, + "asciiCode": 1257 + }, + "lv-LV": { + "langId": 1062, + "asciiCode": 1257 + }, + "nl-NL": { + "langId": 1043, + "asciiCode": 1252 + }, + "nb-NO": { + "langId": 1044, + "asciiCode": 1252 + }, + "pl-PL": { + "langId": 1045, + "asciiCode": 1250 + }, + "pt-BR": { + "langId": 1046, + "asciiCode": 1252 + }, + "pt-PT": { + "langId": 2070, + "asciiCode": 1252 + }, + "ro-RO": { + "langId": 1048, + "asciiCode": 1250 + }, + "ru-RU": { + "langId": 1049, + "asciiCode": 1251 + }, + "hr-HR": { + "langId": 1050, + "asciiCode": 1250 + }, + "sk-SK": { + "langId": 1051, + "asciiCode": 1250 + }, + "sv-SE": { + "langId": 1053, + "asciiCode": 1252 + }, + "th-TH": { + "langId": 1054, + "asciiCode": 874 + }, + "tr-TR": { + "langId": 1055, + "asciiCode": 1254 + }, + "sl-SI": { + "langId": 1060, + "asciiCode": 1250 + }, + "vi-VN": { + "langId": 1066, + "asciiCode": 1258 + }, + "eu-ES": { + "langId": 1069, + "asciiCode": 1252 + }, + "bg-BG": { + "langId": 1026, + "asciiCode": 1251 + }, + "uk-UA": { + "langId": 1058, + "asciiCode": 1251 + }, + "sr-Latn-CS": { + "langId": 2074, + "asciiCode": 1250 + } +} diff --git a/crates/packager/src/wix/main.wxs b/crates/packager/src/wix/main.wxs new file mode 100644 index 00000000..dfbb963d --- /dev/null +++ b/crates/packager/src/wix/main.wxs @@ -0,0 +1,324 @@ + + + + + + + + + + + + + + + + + + + + {{#if allow_downgrades}} + + {{else}} + + {{/if}} + + + Installed AND NOT UPGRADINGPRODUCTCODE + + + + + {{#if banner_path}} + + {{/if}} + {{#if dialog_image_path}} + + {{/if}} + {{#if license}} + + {{/if}} + + {{#if icon_path}} + + + {{/if}} + + + + + + + + + + + + + + + + + WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed + + + + {{#unless license}} + + 1 + 1 + {{/unless}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + + + + + + {{/each~}} + {{/each~}} + + {{#each binaries as |bin| ~}} + + + + {{/each~}} + {{#if enable_elevated_update_task}} + + + + + + + + + + {{/if}} + {{resources}} + + + + + + + + + + + + + + + + + + + + + {{#each merge_modules as |msm| ~}} + + + + + + + + {{/each~}} + + + + + + {{#each resource_file_ids as |resource_file_id| ~}} + + {{/each~}} + + {{#if enable_elevated_update_task}} + + + + {{/if}} + + + + + + + + + + + {{#each binaries as |bin| ~}} + + {{/each~}} + + + + + {{#each component_group_refs as |id| ~}} + + {{/each~}} + {{#each component_refs as |id| ~}} + + {{/each~}} + {{#each feature_group_refs as |id| ~}} + + {{/each~}} + {{#each feature_refs as |id| ~}} + + {{/each~}} + {{#each merge_refs as |id| ~}} + + {{/each~}} + + + {{#if install_webview}} + + + + + + + {{#if download_bootstrapper}} + + + + + + + {{/if}} + + + {{#if webview2_bootstrapper_path}} + + + + + + + + {{/if}} + + + {{#if webview2_installer_path}} + + + + + + + + {{/if}} + + {{/if}} + + {{#if enable_elevated_update_task}} + + + + + NOT(REMOVE) + + + + + + + (REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE + + + {{/if}} + + + + diff --git a/crates/packager/src/wix/mod.rs b/crates/packager/src/wix/mod.rs new file mode 100644 index 00000000..333d78b6 --- /dev/null +++ b/crates/packager/src/wix/mod.rs @@ -0,0 +1,869 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fs::File, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +use cargo_packager_config::LogLevel; +use handlebars::{to_json, Handlebars}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::{Config, ConfigExt, ConfigExtInternal}, + sign, + util::{self, display_path, download_and_verify, extract_zip, log_if_needed, HashAlgorithm}, +}; + +pub const WIX_URL: &str = + "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip"; +pub const WIX_SHA256: &str = "2c1888d5d1dba377fc7fa14444cf556963747ff9a0a289a3599cf09da03b9e2e"; + +const WIX_REQUIRED_FILES: &[&str] = &[ + "candle.exe", + "candle.exe.config", + "darice.cub", + "light.exe", + "light.exe.config", + "wconsole.dll", + "winterop.dll", + "wix.dll", + "WixUIExtension.dll", + "WixUtilExtension.dll", +]; + +// A v4 UUID that was generated specifically for cargo-packager, to be used as a +// namespace for generating v5 UUIDs from bundle identifier strings. +const UUID_NAMESPACE: [u8; 16] = [ + 0xfd, 0x85, 0x95, 0xa8, 0x17, 0xa3, 0x47, 0x4e, 0xa6, 0x16, 0x76, 0x14, 0x8d, 0xfa, 0x0c, 0x7b, +]; + +#[derive(Debug, Deserialize)] +struct LanguageMetadata { + #[serde(rename = "asciiCode")] + ascii_code: usize, + #[serde(rename = "langId")] + lang_id: usize, +} + +/// Generates a GUID. +fn generate_guid(key: &[u8]) -> Uuid { + let namespace = Uuid::from_bytes(UUID_NAMESPACE); + Uuid::new_v5(&namespace, key) +} + +/// Generates the UUID for the Wix template. +fn generate_package_guid(config: &Config) -> Uuid { + generate_guid(config.identifier().as_bytes()) +} + +// WiX requires versions to be numeric only in a `major.minor.patch.build` format +pub fn convert_version(version_str: &str) -> crate::Result { + let version = semver::Version::parse(version_str)?; + if version.major > 255 { + return Err(crate::Error::InvalidAppVersion( + "major number cannot be greater than 255".into(), + )); + } + if version.minor > 255 { + return Err(crate::Error::InvalidAppVersion( + "minor number cannot be greater than 255".into(), + )); + } + if version.patch > 65535 { + return Err(crate::Error::InvalidAppVersion( + "patch number cannot be greater than 65535".into(), + )); + } + + if !version.build.is_empty() { + let build = version.build.parse::(); + if build.map(|b| b <= 65535).unwrap_or_default() { + return Ok(format!( + "{}.{}.{}.{}", + version.major, version.minor, version.patch, version.build + )); + } else { + return Err(crate::Error::NonNumericBuildMetadata(Some( + "and cannot be greater than 65535 for msi target".into(), + ))); + } + } + + if !version.pre.is_empty() { + let pre = version.pre.parse::(); + if pre.is_ok() && pre.unwrap() <= 65535 { + return Ok(format!( + "{}.{}.{}.{}", + version.major, version.minor, version.patch, version.pre + )); + } else { + return Err(crate::Error::NonNumericBuildMetadata(Some( + "and cannot be greater than 65535 for msi target".into(), + ))); + } + } + + Ok(version_str.to_string()) +} + +/// A binary to bundle with WIX. +/// External binaries or additional project binaries are represented with this data structure. +/// This data structure is needed because WIX requires each path to have its own `id` and `guid`. +#[derive(Serialize)] +struct Binary { + /// the GUID to use on the WIX XML. + guid: String, + /// the id to use on the WIX XML. + id: String, + /// the binary path. + path: String, +} + +/// Generates the data required for the external binaries and extra binaries bundling. +fn generate_binaries_data(config: &Config) -> crate::Result> { + let mut binaries = Vec::new(); + let cwd = std::env::current_dir()?; + let tmp_dir = std::env::temp_dir(); + let regex = Regex::new(r"[^\w\d\.]")?; + + if let Some(external_binaries) = &config.external_binaries { + for src in external_binaries { + let binary_path = cwd.join(&src); + let dest_filename = PathBuf::from(src) + .file_name() + .expect("failed to extract external binary filename") + .to_string_lossy() + .replace(&format!("-{}", config.target_triple), ""); + let dest = tmp_dir.join(&dest_filename); + std::fs::copy(binary_path, &dest)?; + + binaries.push(Binary { + guid: Uuid::new_v4().to_string(), + path: dest + .into_os_string() + .into_string() + .expect("failed to read external binary path"), + id: regex + .replace_all(&dest_filename.replace('-', "_"), "") + .to_string(), + }); + } + } + + for bin in &config.binaries { + if !bin.main { + binaries.push(Binary { + guid: Uuid::new_v4().to_string(), + path: config + .binary_path(bin) + .into_os_string() + .into_string() + .expect("failed to read binary path"), + id: regex + .replace_all(&bin.name.replace('-', "_"), "") + .to_string(), + }) + } + } + + Ok(binaries) +} + +/// A Resource file to bundle with WIX. +/// This data structure is needed because WIX requires each path to have its own `id` and `guid`. +#[derive(Serialize, Clone)] +struct ResourceFile { + /// the GUID to use on the WIX XML. + guid: String, + /// the id to use on the WIX XML. + id: String, + /// the file path. + path: PathBuf, +} + +/// A resource directory to bundle with WIX. +/// This data structure is needed because WIX requires each path to have its own `id` and `guid`. +#[derive(Serialize)] +struct ResourceDirectory { + /// the directory path. + path: String, + /// the directory name of the described resource. + name: String, + /// the files of the described resource directory. + files: Vec, + /// the directories that are children of the described resource directory. + directories: Vec, +} + +impl ResourceDirectory { + /// Adds a file to this directory descriptor. + fn add_file(&mut self, file: ResourceFile) { + self.files.push(file); + } + + /// Generates the wix XML string to bundle this directory resources recursively + fn get_wix_data(self) -> crate::Result<(String, Vec)> { + let mut files = String::from(""); + let mut file_ids = Vec::new(); + for file in self.files { + file_ids.push(file.id.clone()); + files.push_str( + format!( + r#""#, + id = file.id, + guid = file.guid, + path = file.path.display() + ).as_str() + ); + } + let mut directories = String::from(""); + for directory in self.directories { + let (wix_string, ids) = directory.get_wix_data()?; + for id in ids { + file_ids.push(id) + } + directories.push_str(wix_string.as_str()); + } + let wix_string = if self.name.is_empty() { + format!("{}{}", files, directories) + } else { + format!( + r#"{files}{directories}"#, + id = Uuid::new_v4().as_simple(), + name = self.name, + files = files, + directories = directories, + ) + }; + + Ok((wix_string, file_ids)) + } +} + +/// Mapper between a resource directory name and its ResourceDirectory descriptor. +type ResourceMap = BTreeMap; + +/// Generates the data required for the resource bundling on wix +fn generate_resource_data(config: &Config) -> crate::Result { + let mut resources_map = ResourceMap::new(); + if let Some(resources) = config.resources() { + for resource in resources { + let resource_entry = ResourceFile { + id: format!("I{}", Uuid::new_v4().as_simple()), + guid: Uuid::new_v4().to_string(), + path: resource.src, + }; + + // split the resource path directories + let components_count = resource.target.components().count(); + let directories = resource + .target + .components() + .take(components_count - 1) // the last component is the file + .collect::>(); + + // transform the directory structure to a chained vec structure + let first_directory = directories + .first() + .map(|d| d.as_os_str().to_string_lossy().into_owned()) + .unwrap_or_else(String::new); + + if !resources_map.contains_key(&first_directory) { + resources_map.insert( + first_directory.clone(), + ResourceDirectory { + path: first_directory.clone(), + name: first_directory.clone(), + directories: vec![], + files: vec![], + }, + ); + } + + let mut directory_entry = resources_map + .get_mut(&first_directory) + .expect("Unable to handle resources"); + + let mut path = String::new(); + // the first component is already parsed on `first_directory` so we skip(1) + for directory in directories.into_iter().skip(1) { + let directory_name = directory + .as_os_str() + .to_os_string() + .into_string() + .expect("failed to read resource folder name"); + path.push_str(directory_name.as_str()); + path.push(std::path::MAIN_SEPARATOR); + + let index = directory_entry + .directories + .iter() + .position(|f| f.path == path); + match index { + Some(i) => directory_entry = directory_entry.directories.get_mut(i).unwrap(), + None => { + directory_entry.directories.push(ResourceDirectory { + path: path.clone(), + name: directory_name, + directories: vec![], + files: vec![], + }); + directory_entry = directory_entry.directories.iter_mut().last().unwrap(); + } + } + } + directory_entry.add_file(resource_entry); + } + } + + let mut dlls = Vec::new(); + + for dll in glob::glob( + config + .out_dir + .join("*.dll") + .to_string_lossy() + .to_string() + .as_str(), + )? { + let path = dll?; + dlls.push(ResourceFile { + id: format!("I{}", Uuid::new_v4().as_simple()), + guid: Uuid::new_v4().to_string(), + path: dunce::simplified(&path).to_path_buf(), + }); + } + + if !dlls.is_empty() { + resources_map.insert( + "".to_string(), + ResourceDirectory { + path: "".to_string(), + name: "".to_string(), + directories: vec![], + files: dlls, + }, + ); + } + + Ok(resources_map) +} + +#[derive(Serialize)] +struct MergeModule { + name: String, + path: String, +} + +fn get_merge_modules(config: &Config) -> crate::Result> { + let mut merge_modules = Vec::new(); + let regex = Regex::new(r"[^\w\d\.]")?; + for msm in glob::glob( + config + .out_dir + .join("*.msm") + .to_string_lossy() + .to_string() + .as_str(), + )? { + let path = msm?; + let filename = path + .file_name() + .expect("failed to extract merge module filename") + .to_os_string() + .into_string() + .expect("failed to convert merge module filename to string"); + merge_modules.push(MergeModule { + name: regex.replace_all(&filename, "").to_string(), + path: path.to_string_lossy().to_string(), + }); + } + Ok(merge_modules) +} + +/// Copies the icon to the binary path, under the `resources` folder, +/// and returns the path to the file. +fn copy_icon(config: &Config, filename: &str, path: &Path) -> crate::Result { + let resource_dir = config.out_dir.join("resources"); + std::fs::create_dir_all(&resource_dir)?; + let icon_target_path = resource_dir.join(filename); + let icon_path = std::env::current_dir()?.join(path); + std::fs::copy(icon_path, &icon_target_path)?; + Ok(icon_target_path) +} + +fn clear_env_for_wix(cmd: &mut Command) { + cmd.env_clear(); + let required_vars: Vec = + vec!["SYSTEMROOT".into(), "TMP".into(), "TEMP".into()]; + for (k, v) in std::env::vars_os() { + let k = k.to_ascii_uppercase(); + if required_vars.contains(&k) || k.to_string_lossy().starts_with("TAURI") { + cmd.env(k, v); + } + } +} + +/// Runs the Candle.exe executable for Wix. Candle parses the wxs file and generates the code for building the installer. +fn run_candle( + config: &Config, + main_binary: &crate::config::Binary, + arch: &str, + wix_toolset_path: &Path, + cwd: &Path, + wxs_file_path: PathBuf, + extensions: Vec, + log_level: LogLevel, +) -> crate::Result<()> { + let mut args = vec![ + "-arch".to_string(), + arch.to_string(), + wxs_file_path.to_string_lossy().to_string(), + format!( + "-dSourceDir={}", + display_path(config.binary_path(main_binary)) + ), + ]; + + if config.wix().map(|w| w.fips_compliant).unwrap_or_default() { + args.push("-fips".into()); + } + + let candle_exe = wix_toolset_path.join("candle.exe"); + + log::info!(action = "Running"; "candle for {:?}", wxs_file_path); + let mut cmd = Command::new(candle_exe); + for ext in extensions { + cmd.arg("-ext"); + cmd.arg(ext); + } + clear_env_for_wix(&mut cmd); + if log_level >= LogLevel::Debug { + cmd.arg("-v"); + } + let output = cmd + .args(&args) + .current_dir(cwd) + .output() + .map_err(|e| crate::Error::WixFailed("candle.exe".into(), e.to_string()))?; + + util::log_if_needed(log_level, output); + + Ok(()) +} + +/// Runs the Light.exe file. Light takes the generated code from Candle and produces an MSI Installer. +fn run_light( + wix_toolset_path: &Path, + build_path: &Path, + arguments: Vec, + extensions: &Vec, + output_path: &Path, + log_level: LogLevel, +) -> crate::Result<()> { + let light_exe = wix_toolset_path.join("light.exe"); + + let mut args: Vec = vec!["-o".to_string(), display_path(output_path)]; + + args.extend(arguments); + + let mut cmd = Command::new(light_exe); + for ext in extensions { + cmd.arg("-ext"); + cmd.arg(ext); + } + clear_env_for_wix(&mut cmd); + if log_level >= LogLevel::Debug { + cmd.arg("-v"); + } + let output = cmd + .args(&args) + .current_dir(build_path) + .output() + .map_err(|e| crate::Error::WixFailed("light.exe".into(), e.to_string()))?; + + log_if_needed(log_level, output); + + Ok(()) +} + +fn get_and_extract_wix(path: &Path) -> crate::Result<()> { + let data = download_and_verify( + "wix311-binaries.zip", + WIX_URL, + WIX_SHA256, + HashAlgorithm::Sha256, + )?; + log::info!("extracting WIX"); + extract_zip(&data, path) +} + +fn build_wix_app_installer( + config: &Config, + wix_toolset_path: &Path, +) -> crate::Result> { + let arch = match config.target_arch()? { + "x86_64" => "x64", + "x86" => "x86", + "aarch64" => "arm64", + target => return Err(crate::Error::UnsupportedArch("msi".into(), target.into())), + }; + + log::info!("Target: {}", arch); + + let main_binary = config + .binaries + .iter() + .find(|bin| bin.main) + .ok_or_else(|| crate::Error::MainBinaryNotFound)?; + let app_exe_source = config.binary_path(main_binary); + + sign::try_sign(&app_exe_source.with_extension("exe"), config)?; + + let output_path = config.out_dir.join("wix").join(arch); + + if output_path.exists() { + std::fs::remove_dir_all(&output_path)?; + } + std::fs::create_dir_all(&output_path)?; + + let app_version = convert_version(&config.version)?; + + let mut data = BTreeMap::new(); + + // TODO: webview2 logic + + if let Some(license) = &config.license_file { + if license.ends_with(".rtf") { + data.insert("license", to_json(license)); + } else { + let license_contents = std::fs::read_to_string(license)?; + let license_rtf = format!( + r#"{{\rtf1\ansi\ansicpg1252\deff0\nouicompat\deflang1033{{\fonttbl{{\f0\fnil\fcharset0 Calibri;}}}} +{{\*\generator Riched20 10.0.18362}}\viewkind4\uc1 +\pard\sa200\sl276\slmult1\f0\fs22\lang9 {}\par +}} + "#, + license_contents.replace('\n', "\\par ") + ); + let rtf_output_path = config.out_dir.join("wix").join("LICENSE.rtf"); + std::fs::write(&rtf_output_path, license_rtf)?; + data.insert("license", to_json(rtf_output_path)); + } + } + + data.insert("product_name", to_json(&config.product_name)); + data.insert("version", to_json(&app_version)); + let bundle_id = config.identifier(); + let manufacturer = config.publisher(); + data.insert("bundle_id", to_json(bundle_id)); + data.insert("manufacturer", to_json(manufacturer)); + let upgrade_code = Uuid::new_v5( + &Uuid::NAMESPACE_DNS, + format!("{}.app.x64", &main_binary.name).as_bytes(), + ) + .to_string(); + + data.insert("upgrade_code", to_json(upgrade_code.as_str())); + data.insert( + "allow_downgrades", + to_json(config.windows().map(|w| w.allow_downgrades).unwrap_or(true)), + ); + + let path_guid = generate_package_guid(config).to_string(); + data.insert("path_component_guid", to_json(path_guid.as_str())); + + let shortcut_guid = generate_package_guid(config).to_string(); + data.insert("shortcut_guid", to_json(shortcut_guid.as_str())); + + let binaries = generate_binaries_data(config)?; + data.insert("binaries", to_json(binaries)); + + let resources = generate_resource_data(config)?; + let mut resources_wix_string = String::from(""); + let mut files_ids = Vec::new(); + for (_, dir) in resources { + let (wix_string, ids) = dir.get_wix_data()?; + resources_wix_string.push_str(wix_string.as_str()); + for id in ids { + files_ids.push(id); + } + } + data.insert("resources", to_json(resources_wix_string)); + data.insert("resource_file_ids", to_json(files_ids)); + + let merge_modules = get_merge_modules(config)?; + data.insert("merge_modules", to_json(merge_modules)); + + data.insert( + "app_exe_source", + to_json(&app_exe_source.with_extension("exe")), + ); + + // copy icon from `settings.windows().icon_path` folder to resource folder near msi + if let Some(icon) = config.find_ico() { + let icon_path = copy_icon(config, "icon.ico", &icon)?; + data.insert("icon_path", to_json(icon_path)); + } + + let mut fragment_paths = Vec::new(); + let mut handlebars = Handlebars::new(); + handlebars.register_escape_fn(handlebars::no_escape); + let mut custom_template_path = None; + let mut enable_elevated_update_task = false; + + if let Some(wix) = config.wix() { + data.insert("component_group_refs", to_json(&wix.component_group_refs)); + data.insert("component_refs", to_json(&wix.component_refs)); + data.insert("feature_group_refs", to_json(&wix.feature_group_refs)); + data.insert("feature_refs", to_json(&wix.feature_refs)); + data.insert("merge_refs", to_json(&wix.merge_refs)); + fragment_paths = wix.fragment_paths.clone().unwrap_or_default(); + enable_elevated_update_task = wix.enable_elevated_update_task; + custom_template_path = wix.template.clone(); + + if let Some(banner_path) = &wix.banner_path { + let filename = banner_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + data.insert( + "banner_path", + to_json(copy_icon(config, &filename, banner_path)?), + ); + } + + if let Some(dialog_image_path) = &wix.dialog_image_path { + let filename = dialog_image_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + data.insert( + "dialog_image_path", + to_json(copy_icon(config, &filename, dialog_image_path)?), + ); + } + } + + if let Some(file_associations) = &config.file_associations { + data.insert("file_associations", to_json(file_associations)); + } + + if let Some(path) = custom_template_path { + handlebars + .register_template_string("main.wxs", std::fs::read_to_string(path)?) + .map_err(|e| e.to_string()) + .expect("Failed to setup custom handlebar template"); + } else { + handlebars + .register_template_string("main.wxs", include_str!("./main.wxs")) + .map_err(|e| e.to_string()) + .expect("Failed to setup handlebar template"); + } + + if enable_elevated_update_task { + // TODO: updater args + // data.insert( + // "msiexec_args", + // to_json( + // config + // .updater() + // .and_then(|updater| updater.msiexec_args) + // .map(|args| args.join(" ")) + // .unwrap_or_else(|| "/passive".to_string()), + // ), + // ); + + // Create the update task XML + let mut skip_uac_task = Handlebars::new(); + let xml = include_str!("./update-task.xml"); + skip_uac_task + .register_template_string("update.xml", xml) + .map_err(|e| e.to_string()) + .expect("Failed to setup Update Task handlebars"); + let temp_xml_path = output_path.join("update.xml"); + let update_content = skip_uac_task.render("update.xml", &data)?; + std::fs::write(temp_xml_path, update_content)?; + + // Create the Powershell script to install the task + let mut skip_uac_task_installer = Handlebars::new(); + skip_uac_task_installer.register_escape_fn(handlebars::no_escape); + let xml = include_str!("./install-task.ps1"); + skip_uac_task_installer + .register_template_string("install-task.ps1", xml) + .map_err(|e| e.to_string()) + .expect("Failed to setup Update Task Installer handlebars"); + let temp_ps1_path = output_path.join("install-task.ps1"); + let install_script_content = skip_uac_task_installer.render("install-task.ps1", &data)?; + std::fs::write(temp_ps1_path, install_script_content)?; + + // Create the Powershell script to uninstall the task + let mut skip_uac_task_uninstaller = Handlebars::new(); + skip_uac_task_uninstaller.register_escape_fn(handlebars::no_escape); + let xml = include_str!("./uninstall-task.ps1"); + skip_uac_task_uninstaller + .register_template_string("uninstall-task.ps1", xml) + .map_err(|e| e.to_string()) + .expect("Failed to setup Update Task Uninstaller handlebars"); + let temp_ps1_path = output_path.join("uninstall-task.ps1"); + let install_script_content = + skip_uac_task_uninstaller.render("uninstall-task.ps1", &data)?; + std::fs::write(temp_ps1_path, install_script_content)?; + + data.insert("enable_elevated_update_task", to_json(true)); + } + + let main_wxs_path = output_path.join("main.wxs"); + std::fs::write(main_wxs_path, handlebars.render("main.wxs", &data)?)?; + + let mut candle_inputs = vec![("main.wxs".into(), Vec::new())]; + + let current_dir = std::env::current_dir()?; + let extension_regex = Regex::new("\"http://schemas.microsoft.com/wix/(\\w+)\"")?; + for fragment_path in fragment_paths { + let fragment_path = current_dir.join(fragment_path); + let fragment = std::fs::read_to_string(&fragment_path)?; + let mut extensions = Vec::new(); + for cap in extension_regex.captures_iter(&fragment) { + extensions.push(wix_toolset_path.join(format!("Wix{}.dll", &cap[1]))); + } + candle_inputs.push((fragment_path, extensions)); + } + + let mut fragment_extensions = HashSet::new(); + //Default extensions + fragment_extensions.insert(wix_toolset_path.join("WixUIExtension.dll")); + fragment_extensions.insert(wix_toolset_path.join("WixUtilExtension.dll")); + + for (path, extensions) in candle_inputs { + for ext in &extensions { + fragment_extensions.insert(ext.clone()); + } + run_candle( + config, + main_binary, + arch, + wix_toolset_path, + &output_path, + path, + extensions, + config.log_level.unwrap_or_default(), + )?; + } + + let mut output_paths = Vec::new(); + + let language_map: HashMap = + serde_json::from_str(include_str!("./languages.json")).unwrap(); + let configured_languages = config + .wix() + .map(|w| w.languages.clone()) + .unwrap_or_default(); + for (language, language_config) in configured_languages.0 { + let language_metadata = language_map.get(&language).ok_or_else(|| { + crate::Error::UnsupportedWixLanguage( + language.clone(), + language_map + .keys() + .cloned() + .collect::>() + .join(", "), + ) + })?; + + let locale_contents = match language_config.locale_path { + Some(p) => std::fs::read_to_string(p)?, + None => format!( + r#""#, + language.to_lowercase(), + ), + }; + + let locale_strings = include_str!("./default-locale-strings.xml") + .replace("__language__", &language_metadata.lang_id.to_string()) + .replace("__codepage__", &language_metadata.ascii_code.to_string()) + .replace("__productName__", &config.product_name); + + let mut unset_locale_strings = String::new(); + let prefix_len = "{value}').unwrap() - prefix_len) + .collect::(); + if !locale_contents.contains(&id) { + unset_locale_strings.push_str(locale_string); + } + } + + let locale_contents = locale_contents.replace( + "", + &format!("{}", unset_locale_strings), + ); + let locale_path = output_path.join("locale.wxl"); + { + let mut fileout = File::create(&locale_path).expect("Failed to create locale file"); + fileout.write_all(locale_contents.as_bytes())?; + } + + let arguments = vec![ + format!( + "-cultures:{}", + if language == "en-US" { + language.to_lowercase() + } else { + format!("{};en-US", language.to_lowercase()) + } + ), + "-loc".into(), + display_path(&locale_path), + "*.wixobj".into(), + ]; + let msi_output_path = output_path.join("output.msi"); + let msi_path = config.out_dir.join(format!( + "bundle/wix/{}_{}_{}_{}.msi", + main_binary.name, app_version, arch, language + )); + std::fs::create_dir_all(msi_path.parent().unwrap())?; + + log::info!(action = "Running"; "light.exe to produce {}", display_path(&msi_path)); + + run_light( + wix_toolset_path, + &output_path, + arguments, + &(fragment_extensions.clone().into_iter().collect()), + &msi_output_path, + config.log_level.unwrap_or_default(), + )?; + std::fs::rename(&msi_output_path, &msi_path)?; + sign::try_sign(&msi_path, config)?; + output_paths.push(msi_path); + } + + Ok(output_paths) +} + +pub fn package(config: &Config) -> crate::Result> { + let wix_path = dirs::cache_dir().unwrap().join("cargo-packager/WixTools"); + if !wix_path.exists() { + get_and_extract_wix(&wix_path)?; + } else if WIX_REQUIRED_FILES + .iter() + .any(|p| !wix_path.join(p).exists()) + { + log::warn!("WixTools directory is missing some files. Recreating it."); + std::fs::remove_dir_all(&wix_path)?; + get_and_extract_wix(&wix_path)?; + } + + build_wix_app_installer(config, &wix_path) +} diff --git a/crates/packager/src/wix/uninstall-task.ps1 b/crates/packager/src/wix/uninstall-task.ps1 new file mode 100644 index 00000000..b39f326e --- /dev/null +++ b/crates/packager/src/wix/uninstall-task.ps1 @@ -0,0 +1,23 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT +# Adapted from https://superuser.com/a/532109 +param([switch]$Elevated) + +function Test-Admin { + $currentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent()) + $currentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) +} + +if ((Test-Admin) -eq $false) { + if ($elevated) { + # tried to elevate, did not work, aborting + } + else { + $ArgList = ('-File "{0}" -Elevated' -f $myinvocation.MyCommand.Definition) + Start-Process "$env:SYSTEMROOT\System32\WindowsPowerShell\v1.0\powershell.exe" -WindowStyle hidden -Verb RunAs -ArgumentList $ArgList + } + exit +} + +SCHTASKS.EXE /DELETE /TN 'Update {{product_name}} - Skip UAC' /F diff --git a/crates/packager/src/wix/update-task.xml b/crates/packager/src/wix/update-task.xml new file mode 100644 index 00000000..f8681193 --- /dev/null +++ b/crates/packager/src/wix/update-task.xml @@ -0,0 +1,43 @@ + + + + + \Update {{product_name}} - Skip UAC + + + + + InteractiveToken + HighestAvailable + + + + Parallel + false + false + false + false + false + + true + false + + true + true + false + false + false + PT0S + 7 + + + + cmd.exe + /c "%SYSTEMROOT%\System32\msiexec.exe /i %TEMP%\\{{product_name}}.msi {{msiexec_args}} /promptrestart" + + + diff --git a/examples/dioxus/Cargo.toml b/examples/dioxus/Cargo.toml index 7650a509..97960567 100644 --- a/examples/dioxus/Cargo.toml +++ b/examples/dioxus/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" [package.metadata.packager] before-packaging-command = "dx build --platform desktop --release" -format = ["nsis"] product-name = "Dioxus example" identifier = "com.dioxus.example" diff --git a/examples/tauri/Cargo.toml b/examples/tauri/Cargo.toml index bec1bf50..3f1744d7 100644 --- a/examples/tauri/Cargo.toml +++ b/examples/tauri/Cargo.toml @@ -5,9 +5,15 @@ edition = "2021" [package.metadata.packager] before-packaging-command = "cargo tauri build" -format = ["nsis"] product-name = "Tauri example" identifier = "com.tauri.example" +icons = [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", +] [build-dependencies] tauri-build = { version = "=2.0.0-alpha.7", features = [] }