File: src\CreateWixBuildWixpack.cs
Web Access
Project: src\src\Microsoft.DotNet.Build.Tasks.Installers\Microsoft.DotNet.Build.Tasks.Installers.csproj (Microsoft.DotNet.Build.Tasks.Installers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
 
namespace Microsoft.DotNet.Build.Tasks.Installers
{
    /*
     * This task creates a Wixpack package from the provided source files and configuration.
     * It processes the source files, copies necessary content files to a working directory,
     * updates paths and variables in source-files, and generates a command line file
     * for building the Wixpack. Content files get copied to a subfolder named after the File@Id
     * or similar unique value, based on the content element type.
     * We are including extensions in wixpack, which allows us to skip restoring these packages
     * and discover extension binaries during signing/repacking.
     * Finally, this task creates a zip package containing all the necessary files.
     * The task supports various configurations such as cultures, define constants, extensions,
     * include search paths, installer platform, output folder, and more.
     */
    public class CreateWixBuildWixpack : Task
    {
        public ITaskItem BindTrackingFile { get; set; }
 
        public string[] Cultures { get; set; }
 
        public string[] DefineConstants { get; set; }
 
        public ITaskItem[] Extensions { get; set; }
 
        public string[] IncludeSearchPaths { get; set; }
 
        public string InstallerPlatform { get; set; }
 
        [Required]
        public string InstallerFile { get; set; }
 
        [Required]
        public ITaskItem IntermediateDirectory { get; set; }
 
        /// <summary>
        /// path of wixpackage file
        /// </summary>
        [Output]
        public string OutputFile { get; set; }
 
        /// <summary>
        /// folder to place wixpackage output file
        /// </summary>
        [Required]
        public string OutputFolder { get; set; }
 
        public string OutputType { get; set; }
 
        public ITaskItem PdbFile { get; set; }
 
        public string PdbType { get; set; }
 
        [Required]
        public ITaskItem[] SourceFiles { get; set; }
 
        [Required]
        public string WixpackWorkingDir { get; set; }
 
        private Dictionary<string, string> _defineConstantsDictionary;
        private Dictionary<string, string> _defineVariablesDictionary;
        private string _wixprojDir;
        private string _installerFilename;
 
        private const string _packageExtension = ".wixpack.zip";
 
        public override bool Execute()
        {
            try
            {
                _defineConstantsDictionary = GetDefineConstantsDictionary();
 
                if (string.IsNullOrWhiteSpace(WixpackWorkingDir))
                {
                    WixpackWorkingDir = Path.Combine(Path.GetTempPath(), "WixpackTemp", Guid.NewGuid().ToString().Split('-')[0]);
                }
 
                _wixprojDir = string.Empty;
                if (!_defineConstantsDictionary.TryGetValue("ProjectDir", out _wixprojDir))
                {
                    throw new InvalidOperationException("ProjectDir not defined in DefineConstants. Task cannot proceed.");
                }
 
                _installerFilename = Path.GetFileName(InstallerFile);
 
                if (Directory.Exists(WixpackWorkingDir))
                {
                    Directory.Delete(WixpackWorkingDir, true);
                }
                Directory.CreateDirectory(WixpackWorkingDir);
 
                // Copy wixproj file - fail if ProjectPath is not defined
                if (_defineConstantsDictionary.TryGetValue("ProjectPath", out var projectPath))
                {
                    string destPath = Path.Combine(WixpackWorkingDir, Path.GetFileName(projectPath));
                    File.Copy(projectPath, destPath, overwrite: true);
                }
                else
                {
                    throw new InvalidOperationException("ProjectPath not defined in DefineConstants. Task cannot proceed.");
                }
 
                CopyIncludeSearchPathsContents();
                ProcessIncludeFiles();
                CopySourceFilesAndContent();
                CopyExtensions();
                UpdatePaths();
                GenerateWixBuildCommandLineFile();
                CreateWixpackPackage();
            }
            catch (Exception e)
            {
                Log.LogErrorFromException(e, true);
            }
 
            return !Log.HasLoggedErrors;
        }
 
        private void CreateWixpackPackage()
        {
            OutputFile = Path.Combine(OutputFolder, $"{_installerFilename}{_packageExtension}");
            if (File.Exists(OutputFile))
            {
                File.Delete(OutputFile);
            }
 
            if (!Directory.Exists(OutputFolder))
            {
                Directory.CreateDirectory(OutputFolder);
            }
 
            ZipFile.CreateFromDirectory(WixpackWorkingDir, OutputFile);
        }
 
        private void CopyIncludeSearchPathsContents()
        {
            if (IncludeSearchPaths == null || IncludeSearchPaths.Length == 0)
            {
                return;
            }
 
            for (int i = 0; i < IncludeSearchPaths.Length; i++)
            {
                // If not rooted, resolve relative to _wixprojDir
                var fullSourceDir = GetAbsoluteSourcePath(IncludeSearchPaths[i]);
                if (!Directory.Exists(fullSourceDir))
                {
                    Log.LogWarning($"IncludeSearchPath directory not found: {fullSourceDir}");
                    continue;
                }
 
                // Use a random directory name for the destination
                var randomDirName = Path.GetRandomFileName();
                IncludeSearchPaths[i] = randomDirName;
 
                CopyDirectoryRecursive(fullSourceDir, Path.Combine(WixpackWorkingDir, randomDirName));
            }
        }
 
        private void ProcessIncludeFiles()
        {
            _defineVariablesDictionary = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
            foreach (var includeFile in Directory.GetFiles(WixpackWorkingDir, "*.wxi", SearchOption.AllDirectories))
            {
                try
                {
                    // We're processing a Wix include file, which contains preprocessor elements
                    // in the format <?define KEY="value"?>
                    // It can also contain XML comments that we need to remove, so we don't ingest
                    // variables from elements that are commented out.
 
                    XDocument xmlDocument = XDocument.Load(includeFile);
                    xmlDocument.DescendantNodes()
                               .OfType<XComment>()
                               .ToList()
                               .ForEach(comment => comment.Remove());
 
                    // We use regular expressions to process wix preprocessor defines
                    var regex = new Regex(@"<\?define\s+(\w+)\s*=\s*""([^""]*)""\s*\?>");
 
                    foreach (Match match in regex.Matches(xmlDocument.ToString()))
                    {
                        if (match.Groups.Count == 3)
                        {
                            _defineVariablesDictionary[match.Groups[1].Value] = match.Groups[2].Value;
                        }
                    }
                }
                catch (Exception ex)
                {
                    Log.LogError($"Error processing include file {includeFile}: {ex.Message}");
                }
            }
        }
 
        private void UpdatePaths()
        {
            // Update ProjectDir to just '.'
            if (_defineConstantsDictionary.ContainsKey("ProjectDir"))
            {
                _defineConstantsDictionary["ProjectDir"] = ".";
            }
 
            // Update ProjectPath to just the project file name
            if (_defineConstantsDictionary.TryGetValue("ProjectPath", out var projectPath))
            {
                _defineConstantsDictionary["ProjectPath"] = Path.GetFileName(projectPath);
            }
 
            // Update OutDir to just '.''
            if (_defineConstantsDictionary.ContainsKey("OutDir"))
            {
                _defineConstantsDictionary["OutDir"] = "%outputfolder%";
            }
 
            // Update TargetDir to just '.''
            if (_defineConstantsDictionary.ContainsKey("TargetDir"))
            {
                _defineConstantsDictionary["TargetDir"] = "%outputfolder%";
            }
 
            // Update TargetPath to %outputfolder%\<target file name>
            if (_defineConstantsDictionary.TryGetValue("TargetPath", out var targetPath))
            {
                _defineConstantsDictionary["TargetPath"] = Path.Combine("%outputfolder%", Path.GetFileName(targetPath));
            }
 
            // Update InstallerFile to %outputfolder%\<installer filename>
            InstallerFile = Path.Combine("%outputfolder%", Path.GetFileName(InstallerFile));
 
            // Update IntermediateDirectory to %outputfolder%
            IntermediateDirectory.ItemSpec = "%outputfolder%";
 
            // Update PdbFile to %outputfolder%\<pdb file name>
            if (PdbFile != null && !string.IsNullOrEmpty(PdbFile.ItemSpec))
            {
                PdbFile.ItemSpec = Path.Combine("%outputfolder%", Path.GetFileName(PdbFile.ItemSpec));
            }
 
            // Update BindTrackingFile to %outputfolder%\<bind tracking file name>
            if (BindTrackingFile != null && !string.IsNullOrEmpty(BindTrackingFile.ItemSpec))
            {
                BindTrackingFile.ItemSpec = Path.Combine("%outputfolder%", Path.GetFileName(BindTrackingFile.ItemSpec));
            }
        }
 
        private void GenerateWixBuildCommandLineFile()
        {
            var commandLineArgs = new List<string>();
 
            // Add InstallerPlatform if specified
            if (!string.IsNullOrEmpty(InstallerPlatform))
            {
                commandLineArgs.Add($"-platform {InstallerPlatform}");
            }
 
            commandLineArgs.Add($"-out {InstallerFile}");
 
            // Add OutputType if specified
            if (!string.IsNullOrEmpty(OutputType))
            {
                commandLineArgs.Add($"-outputType {OutputType}");
            }
 
            // Add PdbFile if specified
            if (PdbFile != null && !string.IsNullOrEmpty(PdbFile.ItemSpec))
            {
                commandLineArgs.Add($"-pdb {PdbFile.ItemSpec}");
            }
 
            // Add PdbType if specified
            if (!string.IsNullOrEmpty(PdbType))
            {
                commandLineArgs.Add($"-pdbType {PdbType}");
            }
 
            // Add each culture from Cultures array
            if (Cultures != null && Cultures.Length > 0)
            {
                foreach (var culture in Cultures)
                {
                    commandLineArgs.Add($"-culture {culture}");
                }
            }
 
            // Add all define constants from dictionary
            if (_defineConstantsDictionary != null && _defineConstantsDictionary.Count > 0)
            {
                foreach (var kvp in _defineConstantsDictionary)
                {
                    // Escape strings only if there is a space in the value
                    string kv = $"{kvp.Key}={kvp.Value}";
                    commandLineArgs.Add($"-d {(kv.Contains(' ') ? $"\"{kv}\"" : kv)}");
                }
            }
 
            // Add IncludeSearchPaths
            if (IncludeSearchPaths != null && IncludeSearchPaths.Length > 0)
            {
                foreach (var includePath in IncludeSearchPaths)
                {
                    commandLineArgs.Add($"-I {includePath}");
                }
            }
 
            // Add Extensions
            if (Extensions != null)
            {
                foreach (var extension in Extensions)
                {
                    commandLineArgs.Add($"-ext {extension.ItemSpec}");
                }
            }
 
            // Add IntermediateDirectory
            commandLineArgs.Add($"-intermediatefolder {IntermediateDirectory.ItemSpec}");
 
            // Add BindTrackingFile if specified
            if (BindTrackingFile != null && !string.IsNullOrEmpty(BindTrackingFile.ItemSpec))
            {
                commandLineArgs.Add($"-trackingfile {BindTrackingFile.ItemSpec}");
            }
 
            commandLineArgs.Add($"-nologo");
            commandLineArgs.Add($"-wx");
 
            // Add SourceFiles
            if (SourceFiles != null && SourceFiles.Length > 0)
            {
                foreach (var sourceFile in SourceFiles)
                {
                    commandLineArgs.Add($"{Path.GetFileName(sourceFile.ItemSpec)}");
                }
            }
 
            string commandLine = "wix.exe build " + string.Join(" ", commandLineArgs);
 
            StringBuilder createCmdFileContents = new();
            createCmdFileContents.AppendLine("@echo off");
            createCmdFileContents.AppendLine("set outputfolder=%1");
            createCmdFileContents.AppendLine("if \"%outputfolder%\" NEQ \"\" (");
            createCmdFileContents.AppendLine("  if \"%outputfolder:~-1%\" NEQ \"\\\" ( ");
            createCmdFileContents.AppendLine("    set outputfolder=%outputfolder%\\");
            createCmdFileContents.AppendLine("  )");
            createCmdFileContents.AppendLine(")");
            createCmdFileContents.AppendLine("REM Wix build command");
            createCmdFileContents.AppendLine(commandLine);
            File.WriteAllText(Path.Combine(WixpackWorkingDir, "create.cmd"), createCmdFileContents.ToString());
        }
 
        /// <summary>
        /// Gets a Dictionary from DefineConstants string array (format: key=value)
        /// </summary>
        private Dictionary<string, string> GetDefineConstantsDictionary()
        {
            var dict = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
            if (DefineConstants == null)
            {
                return dict;
            }
 
            foreach (var entry in DefineConstants)
            {
                if (string.IsNullOrWhiteSpace(entry))
                {
                    continue;
                }
 
                var idx = entry.IndexOf('=');
                if (idx > -1)
                {
                    var key = entry.Substring(0, idx);
                    var value = entry.Substring(idx + 1);
 
                    if (!string.IsNullOrEmpty(key))
                    {
                        dict[key] = value;
                    }
                }
            }
 
            return dict;
        }
 
        /// <summary>
        /// For each item in SourceFiles, reads the XML, finds all File elements, gets File@Id and File@Source values.
        /// If File@Source contains $(<value>), replaces it with the value from _defineConstantsDictionary.
        /// Creates a subfolder in WixpackWorkingDir with the name equal to File@Id value.
        /// </summary>
        private void CopySourceFilesAndContent()
        {
            if (SourceFiles == null || _defineConstantsDictionary == null || string.IsNullOrEmpty(WixpackWorkingDir))
            {
                throw new InvalidOperationException("Task not initialized. Run Execute() first.");
            }
 
            foreach (var sourceFile in SourceFiles)
            {
                var xmlPath = GetAbsoluteSourcePath(sourceFile.ItemSpec);
                if (!File.Exists(xmlPath))
                {
                    Log.LogError($"Source file not found: {sourceFile.ItemSpec}");
                    continue;
                }
 
                // Copy the sourceFile to WixpackWorkingDir
                var copiedXmlPath = Path.Combine(WixpackWorkingDir, Path.GetFileName(xmlPath));
                File.Copy(xmlPath, copiedXmlPath, overwrite: true);
 
                try
                {
                    var doc = XDocument.Load(copiedXmlPath);
 
                    var contentElements = new (string, string, string[])[]
                    {
                        ("File", "Id", ["Source"]),
                        ("MsiPackage", "Id", ["SourceFile"]),
                        ("ExePackage", "Id", ["SourceFile"]),
                        ("Payload", "Id", ["SourceFile"]),
                        ("WixStandardBootstrapperApplication", "Id", ["LicenseFile", "LocalizationFile", "ThemeFile"]),
                        ("WixVariable", "Id", ["Value"]),
                        ("Icon", "Id", ["SourceFile"])
                    };
 
                    foreach (var (elementName, idAttr, sourceAttrArray) in contentElements)
                    {
                        var elements = doc.Descendants().Where(e => e.Name.LocalName == elementName);
                        foreach (var element in elements)
                        {
                            foreach (var sourceAttr in sourceAttrArray)
                            {
                                var source = element.Attribute(sourceAttr)?.Value;
 
                                if (string.IsNullOrEmpty(source))
                                {
                                    continue;
                                }
 
                                source = ResolvePath(source);
 
                                // If there are any unprocessed tokens, process all matching file patterns
                                // We only support one unprocessed token, specified as immediate file parent
                                // [<path>\\]{pattern}\\<filename>
                                if (source.Contains("$("))
                                {
                                    int startIdx = source.IndexOf("$(");
                                    if (startIdx != source.LastIndexOf("$("))
                                    {
                                        Log.LogError($"Multiple unprocessed tokens found in source: {source}.");
                                        continue;
                                    }
 
                                    string pattern = source.Substring(startIdx, source.IndexOf(')', startIdx + 2) - startIdx + 1);
 
                                    source = GetAbsoluteSourcePath(source);
 
                                    var parts = source.Split([$"\\{pattern}\\"], StringSplitOptions.None);
                                    if (parts.Length > 2 || parts[1].Contains('\\'))
                                    {
                                        Log.LogError($"Unsupported source format: {source}");
                                        continue;
                                    }
 
                                    // Enumerate directories in parts[0]
                                    var dirs = Directory.GetDirectories(parts[0], "*", SearchOption.TopDirectoryOnly);
                                    foreach (var dir in dirs)
                                    {
                                        var filePath = Path.Combine(dir, Path.GetFileName(source));
                                        CopySourceFile(Path.GetFileName(dir), filePath);
                                    }
 
                                    element.SetAttributeValue(sourceAttr, $"{pattern}\\{parts[1]}");
                                }
                                else
                                {
                                    // Resolved source is a single file, copy it to a subfolder
                                    var id = element.Attribute(idAttr)?.Value;
                                    if (string.IsNullOrEmpty(id))
                                    {
                                        id = Path.GetFileName(source);
                                    }
 
                                    CopySourceFile(id, source);
 
                                    // Update the original attribute to "<id>\\<filename>"
                                    var newSourceValue = $"{id}\\{Path.GetFileName(source)}";
                                    element.SetAttributeValue(sourceAttr, newSourceValue);
                                }
                            }
                        }
                    }
 
                    doc.Save(copiedXmlPath);
                }
                catch (Exception ex)
                {
                    Log.LogError($"Error processing {copiedXmlPath}: {ex.Message}");
                }
            }
        }
 
        private string ResolvePath(string path)
        {
            // Replace $(<value>) with value from _defineConstantsDictionary
            int startIdx = path.IndexOf("$(");
            while (startIdx != -1)
            {
                int endIdx = path.IndexOf(')', startIdx + 2);
                if (endIdx == -1)
                {
                    Log.LogError($"Unmatched $() in path: {path}");
                    break;
                }
 
                var varName = path.Substring(startIdx + 2, endIdx - (startIdx + 2));
                if (_defineConstantsDictionary.TryGetValue(varName, out var varValue) ||
                    _defineVariablesDictionary.TryGetValue(varName, out varValue))
                {
 
                    path = path.Substring(0, startIdx) + varValue + path.Substring(endIdx + 1);
                }
                else
                {
                    // We support tokenized paths
                    break;
                }
 
                startIdx = path.IndexOf("$(");
            }
 
            return path;
        }
 
        private void CopySourceFile(string fileId, string source)
        {
            var destDir = Path.Combine(WixpackWorkingDir, fileId);
            Directory.CreateDirectory(destDir);
 
            source = GetAbsoluteSourcePath(source);
 
            if (File.Exists(source))
            {
                var destPath = Path.Combine(destDir, Path.GetFileName(source));
                File.Copy(source, destPath, overwrite: true);
            }
            else
            {
                throw new FileNotFoundException($"Source file not found: {source}");
            }
        }
 
        private void CopyExtensions()
        {
            for (int i = 0; i < Extensions.Length; i++)
            {
                var extensionPath = Extensions[i].ItemSpec;
                string filename = Path.GetFileName(extensionPath);
                CopySourceFile(filename, extensionPath);
 
                // Update the extension item spec to the new relative path
                Extensions[i] = new TaskItem(Path.Combine(filename, filename));
            }
        }
 
        private string GetAbsoluteSourcePath(string source)
        {
            // If the source is relative, resolve it against the project directory
            if (!Path.IsPathRooted(source))
            {
                return Path.Combine(_wixprojDir, source);
            }
 
            return source;
        }
 
        private static void CopyDirectoryRecursive(string sourceDir, string destDir)
        {
            Directory.CreateDirectory(destDir);
 
            foreach (var file in Directory.GetFiles(sourceDir))
            {
                File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)), overwrite: true);
            }
 
            foreach (var dir in Directory.GetDirectories(sourceDir))
            {
                CopyDirectoryRecursive(dir, Path.Combine(destDir, Path.GetFileName(dir)));
            }
        }
    }
}