use std::{
collections::{BTreeMap, HashMap, HashSet},
fs::{self, File},
io::Write,
path::{Path, PathBuf},
process::Command,
};
use handlebars::{to_json, Handlebars};
use regex::Regex;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::Context;
use crate::{
codesign::windows as codesign,
config::{Config, LogLevel, WixLanguage},
shell::CommandExt,
util::{self, download_and_verify, extract_zip, HashAlgorithm},
Error,
};
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",
];
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,
}
fn generate_guid(key: &[u8]) -> Uuid {
let namespace = Uuid::from_bytes(UUID_NAMESPACE);
Uuid::new_v5(&namespace, key)
}
fn generate_package_guid(config: &Config) -> Uuid {
generate_guid(config.identifier().as_bytes())
}
pub fn convert_version(version_str: &str) -> crate::Result<String> {
let version = semver::Version::parse(version_str)?;
if version.major > 255 {
return Err(Error::InvalidAppVersion(
"major number cannot be greater than 255".into(),
));
}
if version.minor > 255 {
return Err(Error::InvalidAppVersion(
"minor number cannot be greater than 255".into(),
));
}
if version.patch > 65535 {
return Err(Error::InvalidAppVersion(
"patch number cannot be greater than 65535".into(),
));
}
if !version.build.is_empty() {
let build = version.build.parse::<u64>();
if build.map(|b| b <= 65535).unwrap_or_default() {
return Ok(format!(
"{}.{}.{}.{}",
version.major, version.minor, version.patch, version.build
));
} else {
return Err(Error::NonNumericBuildMetadata(Some(
"and cannot be greater than 65535 for msi target".into(),
)));
}
}
if !version.pre.is_empty() {
let pre = version.pre.parse::<u64>();
if pre.is_ok() && pre.unwrap() <= 65535 {
return Ok(format!(
"{}.{}.{}.{}",
version.major, version.minor, version.patch, version.pre
));
} else {
return Err(Error::NonNumericBuildMetadata(Some(
"and cannot be greater than 65535 for msi target".into(),
)));
}
}
Ok(version_str.to_string())
}
#[derive(Serialize)]
struct Binary {
guid: String,
id: String,
path: String,
}
#[tracing::instrument(level = "trace", skip(config))]
fn generate_binaries_data(config: &Config) -> crate::Result<Vec<Binary>> {
let mut binaries = Vec::new();
let tmp_dir = std::env::temp_dir();
let regex = Regex::new(r"[^\w\d\.]")?;
if let Some(external_binaries) = &config.external_binaries {
let cwd = std::env::current_dir()?;
let target_triple = config.target_triple();
for src in external_binaries {
let file_name = src
.file_name()
.ok_or_else(|| Error::FailedToExtractFilename(src.clone()))?
.to_string_lossy();
let src = src.with_file_name(format!("{file_name}-{target_triple}.exe"));
let path = cwd.join(src);
let bin_path = dunce::canonicalize(&path).map_err(|e| Error::IoWithPath(path, e))?;
let dest_file_name = format!("{file_name}.exe");
let dest = tmp_dir.join(&*dest_file_name);
fs::copy(&bin_path, &dest).map_err(|e| Error::CopyFile(bin_path, dest.clone(), e))?;
binaries.push(Binary {
guid: Uuid::new_v4().to_string(),
path: dest.into_os_string().into_string().unwrap_or_default(),
id: regex
.replace_all(&dest_file_name.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)
.with_extension("exe")
.into_os_string()
.to_string_lossy()
.to_string(),
id: regex
.replace_all(
&bin.path
.file_stem()
.unwrap()
.to_string_lossy()
.replace('-', "_"),
"",
)
.to_string(),
})
}
}
Ok(binaries)
}
#[derive(Serialize, Clone)]
struct ResourceFile {
guid: String,
id: String,
path: PathBuf,
}
#[derive(Serialize)]
struct ResourceDirectory {
path: String,
name: String,
files: Vec<ResourceFile>,
directories: Vec<ResourceDirectory>,
}
impl ResourceDirectory {
fn add_file(&mut self, file: ResourceFile) {
self.files.push(file);
}
fn get_wix_data(self) -> (String, Vec<String>) {
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#"<Component Id="{id}" Guid="{guid}" Win64="$(var.Win64)" KeyPath="yes"><File Id="PathFile_{id}" Source="{path}" /></Component>"#,
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#"<Directory Id="I{id}" Name="{name}">{files}{directories}</Directory>"#,
id = Uuid::new_v4().as_simple(),
name = self.name,
files = files,
directories = directories,
)
};
(wix_string, file_ids)
}
}
type ResourceMap = BTreeMap<String, ResourceDirectory>;
#[tracing::instrument(level = "trace", skip(config))]
fn generate_resource_data(config: &Config) -> crate::Result<ResourceMap> {
let mut resources_map = ResourceMap::new();
for resource in config.resources()? {
let resource_entry = ResourceFile {
id: format!("I{}", Uuid::new_v4().as_simple()),
guid: Uuid::new_v4().to_string(),
path: resource.src,
};
let components_count = resource.target.components().count();
let directories = resource
.target
.components()
.take(components_count - 1) .collect::<Vec<_>>();
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).unwrap();
let mut path = String::new();
for directory in directories.into_iter().skip(1) {
let directory_name = directory
.as_os_str()
.to_os_string()
.into_string()
.unwrap_or_default();
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);
}
Ok(resources_map)
}
#[derive(Serialize)]
struct MergeModule<'a> {
name: &'a str,
path: &'a PathBuf,
}
fn clear_env_for_wix(cmd: &mut Command) {
cmd.env_clear();
let required_vars: Vec<std::ffi::OsString> =
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("CARGO_PACKAGER") {
cmd.env(k, v);
}
}
}
fn run_candle(
config: &Config,
wix_path: &Path,
intermediates_path: &Path,
arch: &str,
wxs_file_path: PathBuf,
extensions: Vec<PathBuf>,
) -> crate::Result<()> {
let main_binary = config.main_binary()?;
let mut args = vec![
"-arch".to_string(),
arch.to_string(),
wxs_file_path.to_string_lossy().to_string(),
format!(
"-dSourceDir={}",
util::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_path.join("candle.exe");
tracing::info!("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 let Some(level) = config.log_level {
if level >= LogLevel::Debug {
cmd.arg("-v");
}
}
cmd.args(&args)
.current_dir(intermediates_path)
.output_ok()
.map_err(|e| Error::WixFailed("candle.exe".into(), e))?;
Ok(())
}
fn run_light(
config: &Config,
wix_path: &Path,
intermediates_path: &Path,
arguments: Vec<String>,
extensions: &Vec<PathBuf>,
output_path: &Path,
) -> crate::Result<()> {
let light_exe = wix_path.join("light.exe");
let mut args: Vec<String> = vec!["-o".to_string(), util::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 let Some(level) = config.log_level {
if level >= LogLevel::Debug {
cmd.arg("-v");
}
}
cmd.args(&args)
.current_dir(intermediates_path)
.output_ok()
.map_err(|e| Error::WixFailed("light.exe".into(), e))?;
Ok(())
}
#[tracing::instrument(level = "trace")]
fn get_and_extract_wix(path: &Path) -> crate::Result<()> {
let data = download_and_verify(
"wix311-binaries.zip",
WIX_URL,
WIX_SHA256,
HashAlgorithm::Sha256,
)?;
tracing::debug!("extracting WIX");
extract_zip(&data, path)
}
#[tracing::instrument(level = "trace", skip(ctx))]
fn build_wix_app_installer(ctx: &Context, wix_path: &Path) -> crate::Result<Vec<PathBuf>> {
let Context {
config,
intermediates_path,
..
} = ctx;
let arch = match config.target_arch()? {
"x86_64" => "x64",
"x86" => "x86",
"aarch64" => "arm64",
target => return Err(Error::UnsupportedArch("wix".into(), target.into())),
};
let main_binary = config.main_binary()?;
let main_binary_name = config.main_binary_name()?;
let main_binary_path = config.binary_path(main_binary).with_extension("exe");
tracing::debug!("Codesigning {}", main_binary_path.display());
codesign::try_sign(&main_binary_path, config)?;
let intermediates_path = intermediates_path.join("wix").join(arch);
util::create_clean_dir(&intermediates_path)?;
let mut data = BTreeMap::new();
data.insert("product_name", to_json(&config.product_name));
data.insert("version", to_json(convert_version(&config.version)?));
let identifier = config.identifier();
let manufacturer = config.publisher();
data.insert("identifier", to_json(identifier));
data.insert("manufacturer", to_json(manufacturer));
let upgrade_code = Uuid::new_v5(
&Uuid::NAMESPACE_DNS,
format!("{main_binary_name}.app.x64").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));
data.insert("app_exe_source", to_json(&main_binary_path));
if let Some(icon) = config.find_ico()? {
let icon_path = dunce::canonicalize(&icon).map_err(|e| Error::IoWithPath(icon, e))?;
data.insert("icon_path", to_json(icon_path));
}
if let Some(license) = &config.license_file {
if license.ends_with(".rtf") {
data.insert("license", to_json(license));
} else {
let license_contents =
fs::read_to_string(license).map_err(|e| Error::IoWithPath(license.clone(), e))?;
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 = intermediates_path.join("LICENSE.rtf");
tracing::debug!("Writing {}", util::display_path(&rtf_output_path));
fs::write(&rtf_output_path, license_rtf)
.map_err(|e| Error::IoWithPath(rtf_output_path.clone(), e))?;
data.insert("license", to_json(rtf_output_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;
if let Some(wix) = config.wix() {
data.insert("custom_action_refs", to_json(&wix.custom_action_refs));
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));
custom_template_path.clone_from(&wix.template);
fragment_paths = wix.fragment_paths.clone().unwrap_or_default();
if let Some(ref inline_fragments) = wix.fragments {
tracing::debug!(
"Writing inline fragments to {}",
util::display_path(&intermediates_path)
);
for (idx, fragment) in inline_fragments.iter().enumerate() {
let path = intermediates_path.join(format!("inline_fragment{idx}.wxs"));
fs::write(&path, fragment).map_err(|e| Error::IoWithPath(path.clone(), e))?;
fragment_paths.push(path);
}
}
if let Some(banner_path) = &wix.banner_path {
let canonicalized = dunce::canonicalize(banner_path)
.map_err(|e| Error::IoWithPath(banner_path.clone(), e))?;
data.insert("banner_path", to_json(canonicalized));
}
if let Some(dialog_image_path) = &wix.dialog_image_path {
let canonicalized = dunce::canonicalize(dialog_image_path)
.map_err(|e| Error::IoWithPath(dialog_image_path.clone(), e))?;
data.insert("dialog_image_path", to_json(canonicalized));
}
if let Some(merge_modules) = &wix.merge_modules {
let merge_modules = merge_modules
.iter()
.map(|path| MergeModule {
name: path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or_default(),
path,
})
.collect::<Vec<_>>();
data.insert("merge_modules", to_json(merge_modules));
}
}
if let Some(file_associations) = &config.file_associations {
data.insert("file_associations", to_json(file_associations));
}
if let Some(protocols) = &config.deep_link_protocols {
let schemes = protocols
.iter()
.flat_map(|p| &p.schemes)
.collect::<Vec<_>>();
data.insert("deep_link_protocols", to_json(schemes));
}
if let Some(path) = custom_template_path {
handlebars
.register_template_string("main.wxs", fs::read_to_string(path)?)
.map_err(Box::new)?;
} else {
handlebars
.register_template_string("main.wxs", include_str!("./main.wxs"))
.map_err(Box::new)?;
}
let main_wxs_path = intermediates_path.join("main.wxs");
tracing::debug!("Writing {}", util::display_path(&main_wxs_path));
fs::write(&main_wxs_path, handlebars.render("main.wxs", &data)?)
.map_err(|e| Error::IoWithPath(main_wxs_path.clone(), e))?;
let mut candle_inputs = vec![(main_wxs_path, 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 = fs::read_to_string(&fragment_path)
.map_err(|e| Error::IoWithPath(fragment_path.clone(), e))?;
let mut extensions = Vec::new();
for cap in extension_regex.captures_iter(&fragment) {
extensions.push(wix_path.join(format!("Wix{}.dll", &cap[1])));
}
candle_inputs.push((fragment_path, extensions));
}
let mut fragment_extensions = HashSet::new();
fragment_extensions.insert(wix_path.join("WixUIExtension.dll"));
fragment_extensions.insert(wix_path.join("WixUtilExtension.dll"));
for (path, extensions) in candle_inputs {
for ext in &extensions {
fragment_extensions.insert(ext.clone());
}
run_candle(
config,
wix_path,
&intermediates_path,
arch,
path,
extensions,
)?;
}
let mut output_paths = Vec::new();
let language_map: HashMap<String, LanguageMetadata> =
serde_json::from_str(include_str!("./languages.json"))?;
let configured_languages = config
.wix()
.and_then(|w| w.languages.clone())
.unwrap_or_else(|| vec![WixLanguage::default()]);
for language in configured_languages {
let (language, locale_path) = match language {
WixLanguage::Identifier(identifier) => (identifier, None),
WixLanguage::Custom { identifier, path } => (identifier, path),
};
let language_metadata = language_map.get(&language).ok_or_else(|| {
Error::UnsupportedWixLanguage(
language.clone(),
language_map
.keys()
.cloned()
.collect::<Vec<String>>()
.join(", "),
)
})?;
let locale_contents = match locale_path {
Some(p) => fs::read_to_string(&p).map_err(|e| Error::IoWithPath(p, e))?,
None => format!(
r#"<WixLocalization Culture="{}" xmlns="http://schemas.microsoft.com/wix/2006/localization"></WixLocalization>"#,
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 = "<String ".len();
for locale_string in locale_strings.split('\n').filter(|s| !s.is_empty()) {
let id = locale_string
.chars()
.skip(prefix_len)
.take(locale_string.find('>').unwrap() - prefix_len)
.collect::<String>();
if !locale_contents.contains(&id) {
unset_locale_strings.push_str(locale_string);
}
}
let locale_contents = locale_contents.replace(
"</WixLocalization>",
&format!("{unset_locale_strings}</WixLocalization>"),
);
let locale_path = intermediates_path.join("locale.wxl");
{
tracing::debug!("Writing {}", util::display_path(&locale_path));
let mut fileout = File::create(&locale_path)
.map_err(|e| Error::IoWithPath(locale_path.clone(), e))?;
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(),
util::display_path(&locale_path),
"*.wixobj".into(),
];
let msi_output_path = intermediates_path.join("output.msi");
let msi_path = config.out_dir().join(format!(
"{}_{}_{}_{}.msi",
main_binary_name, config.version, arch, language
));
let msi_path_parent = msi_path
.parent()
.ok_or_else(|| Error::ParentDirNotFound(msi_path.clone()))?;
fs::create_dir_all(msi_path_parent)
.map_err(|e| Error::IoWithPath(msi_path_parent.to_path_buf(), e))?;
tracing::info!(
"Running light.exe to produce {}",
util::display_path(&msi_path)
);
run_light(
config,
wix_path,
&intermediates_path,
arguments,
&(fragment_extensions.clone().into_iter().collect()),
&msi_output_path,
)?;
fs::rename(&msi_output_path, &msi_path)
.map_err(|e| Error::RenameFile(msi_output_path, msi_path.clone(), e))?;
tracing::debug!("Codesigning {}", msi_path.display());
codesign::try_sign(&msi_path, config)?;
output_paths.push(msi_path);
}
Ok(output_paths)
}
#[tracing::instrument(level = "trace", skip(ctx))]
pub(crate) fn package(ctx: &Context) -> crate::Result<Vec<PathBuf>> {
let wix_path = ctx.tools_path.join("WixTools");
if !wix_path.exists() {
get_and_extract_wix(&wix_path)?;
} else if WIX_REQUIRED_FILES
.iter()
.any(|p| !wix_path.join(p).exists())
{
tracing::warn!("WixTools directory is missing some files. Recreating it.");
fs::remove_dir_all(&wix_path).map_err(|e| Error::IoWithPath(wix_path.clone(), e))?;
get_and_extract_wix(&wix_path)?;
}
build_wix_app_installer(ctx, &wix_path)
}