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 ITaskItem[] BindPaths { 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; }
 
        public ITaskItem[] LocalizationFiles { 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 Dictionary<string, string> _systemVariablesDictionary;
        private string _wixprojDir;
        private string _installerFilename;
 
        private const string _packageExtension = ".wixpack.zip";
 
        public override bool Execute()
        {
            try
            {
                _defineConstantsDictionary = GetDefineConstantsDictionary();
                _systemVariablesDictionary = GetSystemVariablesDictionary();
 
                if (string.IsNullOrWhiteSpace(WixpackWorkingDir))
                {
                    WixpackWorkingDir = Path.Combine(Path.GetTempPath(), "WixpackTemp", Guid.NewGuid().ToString().Split('-')[0]);
                }
 
                _installerFilename = Path.GetFileName(InstallerFile);
 
                if (Directory.Exists(WixpackWorkingDir))
                {
                    Directory.Delete(WixpackWorkingDir, true);
                }
                Directory.CreateDirectory(WixpackWorkingDir);
 
                if (_defineConstantsDictionary.TryGetValue("ProjectDir", out _wixprojDir))
                {
                    // 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.");
                    }
                }
                else
                {
                    _wixprojDir = string.Empty;
                }
 
                CopyIncludeSearchPathsContents();
                ProcessIncludeFilesInSearchPaths();
                CopySourceFilesAndContent();
                CopyExtensions();
                CopyBindPathContents();
                CopyLocalizationFiles();
                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 ProcessIncludeFilesInSearchPaths()
        {
            _defineVariablesDictionary = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
            foreach (var includeFile in Directory.GetFiles(WixpackWorkingDir, "*.wxi", SearchOption.AllDirectories))
            {
                ProcessIncludeFile(includeFile);
            }
        }
 
        private void ProcessIncludeFile(string includeFile)
        {
            // Copy includeFile to %temp% folder
            // We want to keep original files in wixpack, and only preprocess
            // them for wixpack creation. This ensures that repacking process would not be
            // affected by some unintentional change, or a bug in preprocessor.
            var tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(includeFile));
            File.Copy(includeFile, tempFilePath, overwrite: true);
 
            // We're processing a Wix include file, which contains preprocessor
            // and other custom elements that are processed as strings using Regex.
            // It can also contain XML comments that we need to remove, so we don't ingest
            // variables from elements that are commented out.
            RemoveAllXmlComments(tempFilePath);
            PreprocessWixSourceFile(tempFilePath);
            IngestDefineVariablesFromWixFile(tempFilePath);
 
            File.Delete(tempFilePath);
        }
 
        private void IngestDefineVariablesFromWixFile(string file)
        {
            try
            {
                IngestDefineVariablesFromString(XDocument.Load(file).ToString());
            }
            catch (Exception ex)
            {
                Log.LogError($"Error ingesting variables from include file {file}: {ex.Message}");
            }
        }
 
        private void IngestDefineVariablesFromString(string content)
        {
            // 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(content))
            {
                if (match.Groups.Count == 3)
                {
                    _defineVariablesDictionary[match.Groups[1].Value] = ResolvePath(match.Groups[2].Value);
                }
            }
        }
 
        private static void RemoveAllXmlComments(string file)
        {
            XDocument xmlDocument = XDocument.Load(file);
            xmlDocument.DescendantNodes()
                       .OfType<XComment>()
                       .ToList()
                       .ForEach(comment => comment.Remove());
            xmlDocument.Save(file);
        }
 
        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 LocalizationFiles
            if (LocalizationFiles != null)
            {
                foreach (var localizationFile in LocalizationFiles)
                {
                    commandLineArgs.Add($"-loc {localizationFile.ItemSpec}");
                }
            }
 
            // Add BindPaths
            if (BindPaths != null && BindPaths.Length > 0)
            {
                foreach (var bindPath in BindPaths)
                {
                    string bindName = bindPath.GetMetadata("BindName");
                    if (!string.IsNullOrEmpty(bindName))
                    {
                        // Wix build specifies both arguments - matching it here.
                        commandLineArgs.Add($"-bindPath {bindPath.ItemSpec}");
                        commandLineArgs.Add($"-bindPath {bindName}={bindPath.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;
        }
 
        private Dictionary<string, string> GetSystemVariablesDictionary()
        {
            var dict = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
 
            // Add support for known used system variables
            if (_defineConstantsDictionary.TryGetValue("InstallerPlatform", out var installerPlatform))
            {
                dict.Add("BUILDARCH", installerPlatform);
            }
 
            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);
                string sourceFileFolder = Path.GetDirectoryName(xmlPath);
 
                // First preprocess the source file to remove non-applicable include files.
                // We defer ingestion of variables, until all variables from include files
                // were ingested, as they may reference each other.
                RemoveAllXmlComments(copiedXmlPath);
                PreprocessWixSourceFile(copiedXmlPath);
 
                // Ingest variables after file preprocessing
                ProcessAllReferencedIncludeFiles(copiedXmlPath, sourceFileFolder);
                IngestDefineVariablesFromWixFile(copiedXmlPath);
 
                try
                {
                    var doc = XDocument.Load(copiedXmlPath);
 
                    var contentElements = new (string, string, string[])[]
                    {
                        ("File", "Id", ["Source"]),
                        ("Binary", "Id", ["SourceFile"]),
                        ("MsiPackage", "Id", ["SourceFile"]),
                        ("ExePackage", "Id", ["SourceFile"]),
                        ("Payload", "Id", ["SourceFile"]),
                        ("WixStandardBootstrapperApplication", "Id", ["LicenseFile", "LocalizationFile", "ThemeFile", "LogoFile"]),
                        ("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, sourceFileFolder);
 
                                    var parts = source.Split([$"\\{pattern}\\"], StringSplitOptions.None);
                                    if (parts.Length < 2)
                                    {
                                        Log.LogError($"Unprocessed token: {pattern} in {xmlPath}");
                                        continue;
                                    }
 
                                    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, sourceFileFolder);
 
                                    // 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}");
                }
            }
        }
 
        /// <summary>
        /// This method will process all include files, from this source file.
        /// It will update the source file with new paths for included files,
        /// in the wixpack working directory.
        /// </summary>
        /// <param name="file"></param>
        private void ProcessAllReferencedIncludeFiles(string file, string relativeRoot)
        {
            string content = File.ReadAllText(file);
 
            // Regex to match <?include value ?>
            var regex = new Regex(@"<\?include\s+([^\s\?>]+)\s*\?>", RegexOptions.IgnoreCase);
            foreach (Match match in regex.Matches(content))
            {
                if (match.Groups.Count > 1)
                {
                    string filename = match.Groups[1].Value.Trim('\"');
                    string includeFilePath = GetAbsoluteSourcePath(ResolvePath(filename), relativeRoot);
                    if (File.Exists(includeFilePath))
                    {
                        // Copy the include file, update the source file with new path
                        // and ingest the variables.
                        string id = Path.GetFileName(includeFilePath);
                        string path = CopySourceFile(id, includeFilePath, relativeRoot);
                        ProcessIncludeFile(path);
                        content = content.Replace(filename, $"{id}\\{id}");
                    }
                    else
                    {
                        // Include file could be in IncludeSearchPaths and already copied and processed.
                        bool foundInSearchPath = false;
                        if (IncludeSearchPaths != null)
                        {
                            foreach (var searchPath in IncludeSearchPaths)
                            {
                                var potentialPath = Path.Combine(WixpackWorkingDir, searchPath, Path.GetFileName(includeFilePath));
                                if (File.Exists(potentialPath))
                                {
                                    foundInSearchPath = true;
                                    break;
                                }
                            }
                        }
 
                        if (!foundInSearchPath)
                        {
                            Log.LogError($"Included file not found: {includeFilePath}");
                        }
                    }
                }
            }
 
            File.WriteAllText(file, content);
        }
 
        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 (varName.StartsWith("sys."))
                {
                    if (_systemVariablesDictionary.TryGetValue(varName.Substring(4), out var varValue))
                    {
                        path = path.Substring(0, startIdx) + varValue + path.Substring(endIdx + 1);
                    }
                    else
                    {
                        // We support tokenized paths
                        break;
                    }
                }
                else
                {
                    if (varName.StartsWith("var."))
                    {
                        varName = varName.Substring(4);
                    }
 
                    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;
        }
 
        /// <summary>
        /// Simple preprocessor of Wix preprocessor tokens.
        /// This uses simple string processing and Regex as tokens are not valid XML elements.
        /// Supported tokens and blocks:
        /// <?if ... ?> <?elseif ... ?> <?else?> <?endif?>
        /// <?ifdef ... ?>
        /// <?ifndef ... ?>
        /// </summary>
        /// <param name="sourceFile"></param>
        /// <exception cref="InvalidOperationException"></exception>
        private void PreprocessWixSourceFile(string sourceFile)
        {
            string input = File.ReadAllText(sourceFile);
            var output = new StringBuilder();
 
            int pos = 0;
            while (pos < input.Length)
            {
                // Find the next preprocessor block
                int ifStart = input.IndexOf("<?if ", pos, StringComparison.OrdinalIgnoreCase);
                int ifdefStart = input.IndexOf("<?ifdef ", pos, StringComparison.OrdinalIgnoreCase);
                int ifndefStart = input.IndexOf("<?ifndef ", pos, StringComparison.OrdinalIgnoreCase);
 
                // Find the earliest block
                int nextBlockStart = -1;
                string blockType = null;
                if (ifStart != -1 && (ifdefStart == -1 || ifStart < ifdefStart) && (ifndefStart == -1 || ifStart < ifndefStart))
                {
                    nextBlockStart = ifStart;
                    blockType = "if";
                }
                else if (ifdefStart != -1 && (ifndefStart == -1 || ifdefStart < ifndefStart))
                {
                    nextBlockStart = ifdefStart;
                    blockType = "ifdef";
                }
                else if (ifndefStart != -1)
                {
                    nextBlockStart = ifndefStart;
                    blockType = "ifndef";
                }
 
                if (nextBlockStart == -1)
                {
                    // No more preprocessor blocks, copy the rest
                    output.Append(input.Substring(pos));
                    break;
                }
 
                // Copy up to the next block
                output.Append(input.Substring(pos, nextBlockStart - pos));
 
                if (blockType == "if")
                {
                    int ifEnd = input.IndexOf("?>", nextBlockStart, StringComparison.OrdinalIgnoreCase);
                    if (ifEnd == -1)
                    {
                        throw new InvalidOperationException("Malformed <?if?> block.");
                    }
 
                    // Store all blocks and conditions
                    var blocks = new List<(string condition, string content)>();
                    // Parse the initial <?if ... ?>
                    string ifCondition = input.Substring(nextBlockStart + 4, ifEnd - (nextBlockStart + 4)).Trim();
                    int blockStart = ifEnd + 2;
 
                    int searchPos = blockStart;
                    int endifStart = input.IndexOf("<?endif?>", searchPos, StringComparison.OrdinalIgnoreCase);
                    if (endifStart == -1)
                    {
                        throw new InvalidOperationException("Missing <?endif?> for <?if?> block.");
                    }
 
                    // Find all <?elseif ... ?> and <?else?>
                    var blockBoundaries = new List<(int start, int end, string condition, bool isElse)>();
                    int nextTagStart = searchPos;
                    string currentCondition = ifCondition;
                    while (true)
                    {
                        int elseifStart = input.IndexOf("<?elseif", nextTagStart, StringComparison.OrdinalIgnoreCase);
                        int elseStart = input.IndexOf("<?else?>", nextTagStart, StringComparison.OrdinalIgnoreCase);
                        int nextTag = -1;
                        string elseifCondition = null;
 
                        if ((elseifStart != -1 && elseifStart < endifStart) &&
                            (elseStart == -1 || elseifStart < elseStart))
                        {
                            nextTag = elseifStart;
                            int elseifEnd = input.IndexOf("?>", elseifStart, StringComparison.OrdinalIgnoreCase);
                            if (elseifEnd == -1)
                            {
                                throw new InvalidOperationException("Malformed <?elseif?> block.");
                            }
 
                            elseifCondition = input.Substring(elseifStart + 8, elseifEnd - (elseifStart + 8)).Trim();
                            blockBoundaries.Add((blockStart, elseifStart, currentCondition, false));
                            blockStart = elseifEnd + 2;
                            nextTagStart = blockStart;
                            currentCondition = elseifCondition;
                        }
                        else if (elseStart != -1 && elseStart < endifStart)
                        {
                            nextTag = elseStart;
                            blockBoundaries.Add((blockStart, elseStart, currentCondition, false));
                            blockStart = elseStart + 8;
                            nextTagStart = blockStart;
                            currentCondition = null;
                            break;
                        }
                        else
                        {
                            // No more elseif/else before endif
                            blockBoundaries.Add((blockStart, endifStart, currentCondition, false));
                            break;
                        }
                    }
 
                    // If there was an else, add its block
                    if (currentCondition == null)
                    {
                        blockBoundaries.Add((blockStart, endifStart, null, true));
                    }
 
                    // Find the first block whose condition evaluates to true, or else block
                    string selectedContent = "";
                    foreach (var (start, end, cond, isElse) in blockBoundaries)
                    {
                        if (cond == null && isElse)
                        {
                            selectedContent = input.Substring(start, end - start);
                            break;
                        }
                        else if (cond != null && EvaluateCondition(cond))
                        {
                            selectedContent = input.Substring(start, end - start);
                            break;
                        }
                    }
 
                    output.Append(selectedContent);
 
                    // Ingest variables from the selected content asap as
                    // variables could be referenced in subsequent blocks or conditions.
                    IngestDefineVariablesFromString(selectedContent);
 
                    pos = endifStart + 9; // Move past <?endif?>
                }
                else if (blockType == "ifdef" || blockType == "ifndef")
                {
                    int tagEnd = input.IndexOf("?>", nextBlockStart, StringComparison.OrdinalIgnoreCase);
                    if (tagEnd == -1) throw new InvalidOperationException($"Malformed <?{blockType}?> block.");
                    string variableName = input.Substring(nextBlockStart + (blockType == "ifdef" ? 7 : 8), tagEnd - (nextBlockStart + (blockType == "ifdef" ? 7 : 8))).Trim();
 
                    bool isDefined;
                    if (variableName.StartsWith("$(var.", StringComparison.OrdinalIgnoreCase) && variableName.EndsWith(")"))
                    {
                        // Extract the variable name inside $(var.something)
                        string innerVar = variableName.Substring(6, variableName.Length - 7);
                        isDefined = _defineVariablesDictionary.ContainsKey(innerVar);
                    }
                    else if (variableName.StartsWith("$(") && variableName.EndsWith(")"))
                    {
                        // Extract the variable name inside $(something)
                        string innerConst = variableName.Substring(2, variableName.Length - 3);
                        isDefined = _defineConstantsDictionary.ContainsKey(innerConst);
                    }
                    else
                    {
                        // Fallback: treat as a plain variable name
                        isDefined = _defineConstantsDictionary.ContainsKey(variableName);
                    }
 
                    int endifStart = input.IndexOf("<?endif?>", tagEnd + 2, StringComparison.OrdinalIgnoreCase);
                    if (endifStart == -1)
                    {
                        throw new InvalidOperationException($"Missing <?endif?> for <?{blockType}?> block.");
                    }
 
                    int blockStart = tagEnd + 2;
                    int blockEnd = endifStart;
 
                    bool keepBlock = (blockType == "ifdef" && isDefined) || (blockType == "ifndef" && !isDefined);
 
                    if (keepBlock)
                    {
                        string selectedContent = input.Substring(blockStart, blockEnd - blockStart);
                        output.Append(selectedContent);
 
                        // Ingest variables from the selected content asap as
                        // variables could be referenced in subsequent blocks or conditions.
                        IngestDefineVariablesFromString(selectedContent);
                    }
                    pos = endifStart + 9; // Move past <?endif?>
                }
            }
 
            File.WriteAllText(sourceFile, output.ToString());
        }
 
        /// <summary>
        /// Simple conditions evaluator for Wix preprocessor conditions.
        /// Supports =, != (case-sensitive), ~= (case-insensitive), and quoted/unquoted values and variables.
        /// </summary>
        /// <param name="condition"></param>
        /// <returns></returns>
        /// <exception cref="NotSupportedException"></exception>
        private bool EvaluateCondition(string condition)
        {
            condition = condition.Trim();
 
            // Supports both:
            // $(Variable) <op> "value"
            // and
            // "value" <op> $(Variable)
            // Supports all combinations of quoted and unquoted values and variables.
 
            // Regex for both forms: variable on left or right, quoted or unquoted
            var eqMatch = Regex.Match(
                condition,
                @"^(?:(?:""?\$\(([^)]+)\)""?)\s*(=|!=|~=)\s*(?:""([^""]+)""|([^\s]+))|(?:""([^""]+)""|([^\s]+))\s*(=|!=|~=)\s*(""?\$\(([^)]+)\)""?))$"
            );
 
            if (!eqMatch.Success)
            {
                throw new NotSupportedException($"Unsupported condition: {condition}");
            }
 
            string varName, op, value, actualValue = "";
 
            if (!string.IsNullOrEmpty(eqMatch.Groups[1].Value))
            {
                // $(Variable) or "$(Variable)" <op> value
                varName = eqMatch.Groups[1].Value;
                op = eqMatch.Groups[2].Value;
                value = eqMatch.Groups[3].Success ? eqMatch.Groups[3].Value : eqMatch.Groups[4].Value;
            }
            else
            {
                // value <op> $(Variable) or "$(Variable)"
                value = eqMatch.Groups[5].Success ? eqMatch.Groups[5].Value : eqMatch.Groups[6].Value;
                op = eqMatch.Groups[7].Value;
                varName = eqMatch.Groups[9].Value;
            }
 
            // Trim quotes if present
            varName = varName.Trim('"');
 
            if (varName.StartsWith("var.", StringComparison.OrdinalIgnoreCase))
            {
                varName = varName.Substring(4);
                _defineVariablesDictionary.TryGetValue(varName, out actualValue);
            }
            else if (varName.StartsWith("sys.", StringComparison.OrdinalIgnoreCase))
            {
                varName = varName.Substring(4);
                _systemVariablesDictionary.TryGetValue(varName, out actualValue);
            }
 
            // Fallback to _defineConstantsDictionary if not found in variables
            if (actualValue == null || actualValue == "")
            {
                _defineConstantsDictionary.TryGetValue(varName, out actualValue);
            }
 
            switch (op)
            {
                case "=":
                    return string.Equals(actualValue, value, StringComparison.Ordinal);
                case "!=":
                    return !string.Equals(actualValue, value, StringComparison.Ordinal);
                case "~=":
                    return string.Equals(actualValue, value, StringComparison.OrdinalIgnoreCase);
                default:
                    throw new NotSupportedException($"Unsupported operator: {op}");
            }
        }
 
        private string CopySourceFile(string fileId, string source, string relativeRoot = "")
        {
            var destDir = Path.Combine(WixpackWorkingDir, fileId);
            Directory.CreateDirectory(destDir);
 
            source = GetAbsoluteSourcePath(source, relativeRoot);
 
            if (File.Exists(source))
            {
                var destPath = Path.Combine(destDir, Path.GetFileName(source));
                File.Copy(source, destPath, overwrite: true);
                return destPath;
            }
            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 void CopyBindPathContents()
        {
            for (int i = 0; i < BindPaths?.Length; i++)
            {
                // Skip items where BindName metadata is not present
                string bindName = BindPaths[i].GetMetadata("BindName");
                if (!string.IsNullOrEmpty(bindName))
                {
                    string wixpackSubfolder = Path.GetRandomFileName();
                    string bindPath = BindPaths[i].ItemSpec;
 
                    foreach (string file in Directory.GetFiles(bindPath, "*", SearchOption.TopDirectoryOnly))
                    {
                        // Copy known usable files only
                        // .dll, .exe, .msi
                        if (file.EndsWith(".dll") ||
                            file.EndsWith(".exe") ||
                            file.EndsWith(".msi"))
                        {
                            CopySourceFile(wixpackSubfolder, file);
                        }
                    }
 
                    // Update the bind path item spec to the new relative folder
                    BindPaths[i].ItemSpec = wixpackSubfolder;
                    continue;
                }
            }
        }
 
        private void CopyLocalizationFiles()
        {
            for (int i = 0; i < LocalizationFiles?.Length; i++)
            {
                var localizationPath = LocalizationFiles[i].ItemSpec;
                string filename = Path.GetFileName(localizationPath);
                CopySourceFile(filename, localizationPath);
 
                // Update the localization item spec to the new relative path
                LocalizationFiles[i] = new TaskItem(Path.Combine(filename, filename));
            }
        }
 
        private string GetAbsoluteSourcePath(string source, string relativeRoot = "")
        {
            // If the source is relative, resolve it against the project directory
            if (!Path.IsPathRooted(source))
            {
                return string.IsNullOrEmpty(relativeRoot) ?
                    Path.Combine(_wixprojDir, source) :
                    Path.Combine(relativeRoot, 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)));
            }
        }
    }
}