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 System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
 
namespace Microsoft.DotNet.Build.Tasks.Installers
{
    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 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.");
                }
 
                CopySourceFilesAndContent();
                CopyExtensions();
                CopyIncludeSearchPathsContents();
                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 UpdatePaths()
        {
            // Update ProjectDir to just '.'
            if (_defineConstantsDictionary.ContainsKey("ProjectDir"))
            {
                _defineConstantsDictionary["ProjectDir"] = ".";
            }
 
            // Update ProjectPath to just the project file name
            if (_defineConstantsDictionary.ContainsKey("ProjectPath"))
            {
                _defineConstantsDictionary["ProjectPath"] = Path.GetFileName(_defineConstantsDictionary["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.ContainsKey("TargetPath"))
            {
                _defineConstantsDictionary["TargetPath"] = Path.Combine("%outputfolder%", Path.GetFileName(_defineConstantsDictionary["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))
                {
                    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)));
            }
        }
    }
}