WiX Custom .NET Bootstrapper with UI Part 1

Posted on: May 15, 2020 1:35:36 AM
I recently had the task of creating a custom UI for a WiX installer that uses a bootstrapper. This particular UI had to allow the user to customize their installed features along with installing the primary product. As it turns out, there is not many examples out there of how to best do this. I did find one (https://www.wrightfully.com/part-1-of-writing-your-own-net-based-installer-with-wix-overview), but it wasn't a complete project, so there was still a lot to figure out on my own. I did base a lot of my bootstrapper code from the example I found, it provided a great starting place.
There are a few requirements to get the WiX bootstrapper to use a custom UI.
  1. Your UI project must reference BootstrapperCore - installed with WiX
  2. Your project must contain a BootstrapperCore.config - this provides WiX with the .NET Framework requirements to run your UI
  3. Your project must contain a class that inherits from BootstrapperApplication - this is the entry point for WiX
  4. You must inform the bootstrapper about your project
Since setting a project reference should be pretty self explanatory, I'm going to start with BootstrapperCore.config. My first attempt at adding the config, I tried adding it to the App.config file. As it turns out, this doesn't work, the file must be named BootstrapperCore.config for it to be picked up. This is one of those things that isn't documented very well. Here is the contents this file in the example project I made.
BootstrapperCore.config
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <sectionGroup name="wix.bootstrapper" type="Microsoft.Tools.WindowsInstallerXml.Bootstrapper.BootstrapperSectionGroup, BootstrapperCore">
      <section name="host" type="Microsoft.Tools.WindowsInstallerXml.Bootstrapper.HostSection, BootstrapperCore" />
    </sectionGroup>
  </configSections>
  <startup useLegacyV2RuntimeActivationPolicy="true">
    <supportedRuntime version="v4.0" />
  </startup>
  <wix.bootstrapper>
    <host assemblyName="Bootstrapper.UI">
      <supportedFramework version="v4\Full" />
      <supportedFramework version="v4\Client" />
    </host>
  </wix.bootstrapper>
</configuration>
An important thing to point out in this file that is easy to miss is setting the assemblyName. This value must be the name of the assembly that contains your UI, in my case Bootstrapper.UI. I will admit that I played around with the supportedFramework properties, because my project targets .NET Framework 4.6.2. I wasn't able to find a combination that worked, so I left it at 4.0, it seems to work fine but it may cause issues if the .NET Framework 4.0 is not installed.
Next I'd like to talk about informing the bootstrapper about your project. I'm doing this one next because it's one of the smaller pieces needed. When you're telling the bootstrapper about your project, you need to tell it about your library along with all of it's dependencies. You also need to inform it where to find the BootstrapperCore.config file. Below you will find the contents of my example project bootstrapper.
Bundle.wxs
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
    <Bundle Name="Bootstrapper" Version="!(bind.packageVersion.Msi_Installer)" Manufacturer="Justin" UpgradeCode="1b9106f0-5ba8-4857-ac0a-c1becba1fe51">
    <BootstrapperApplicationRef Id="ManagedBootstrapperApplicationHost">
        <Payload SourceFile="$(var.Bootstrapper.UI.TargetPath)" />
        <Payload SourceFile="$(var.Bootstrapper.UI.ProjectDir)BootstrapperCore.config"/>
        <Payload SourceFile="$(var.Bootstrapper.UI.TargetDir)BootstrapperCore.dll"/>
        <Payload SourceFile="$(var.Bootstrapper.UI.TargetDir)Microsoft.Deployment.WindowsInstaller.dll"/>
    </BootstrapperApplicationRef>

        <Chain>
        <PackageGroupRef Id="NetFx462Web"/>
        
        <MsiPackage Id="Msi_Installer" SourceFile="$(var.Installer.TargetPath)" EnableFeatureSelection="yes" Compressed="yes" />
        </Chain>
    </Bundle>
</Wix>
You'll notice that I'm using variables to inform the project about the file paths. I can do this because I added my project as a reference to the bootstrapper project. You can find a list of all available reference variables at https://wixtoolset.org/documentation/manual/v3/votive/votive_project_references.html.
For this next part, I'm going to only go over the key pieces of it because it's quite a bit larger than the others. This next part is going to be going over the entry point for the UI application, the BootstrapperApplication. The entry point into this class is the Run() method. This is where you will want to determine if UI should be shown or execute the command line options. It's important to remember that you can't just ignore the command line arguments because things like upgrade installations will attempt to uninstall the previous installation using command line arguments. If you don't handle these, the uninstall will never happen and the installers will hang indefinitely without informing your user about anything. Here is a code snippet from my example project.
BootstrapperEntry.cs - snippet
    protected override void Run()
    {
        WaitForDebugger();

        InitializePackages();

        _BootstrapDispatcher = Dispatcher.CurrentDispatcher;

        // should UI be displayed
        if (Command.Display == Display.Full || Command.Display == Display.Unknown)
        {
            Engine.Log(LogLevel.Verbose, "Launching custom UX");

            _InstallerWindowViewModel = new InstallerWindowViewModel(this);

            InstallerWindow installerWindow = new InstallerWindow
            {
                DataContext = _InstallerWindowViewModel
            };
            installerWindow.Closed += (s, e) => _BootstrapDispatcher.InvokeShutdown();
            installerWindow.Show();

            Dispatcher.Run();

            Engine.Quit(_ErrorCode);
        }
        else
        {
            DetectComplete += (sender, args) => Plan(Command.Action);
            PlanComplete += (sender, args) => Execute();
            ExecuteComplete += (sender, args) =>
            {
                Engine.Quit(args.Status);
                _BootstrapDispatcher.InvokeShutdown();
            };

            Detect();

            Dispatcher.Run();
        }
    }
First, with an MSI there are certain steps that always have to happen. You first need to detect the current state, then plan the next state, lastly you execute the plan. So when we aren't showing the UI, these steps still need to occur. The Engine property provided by the base class allows you to register to events during the entire installation process. In this case, we want to register to the completion step of each of these steps so we can automatically start the next step. We then tell then want to push the main execution frame on the event queue by calling Dispatcher.Run().
Debugging can be very difficult, that's why the very first method I call is WaitForDebugger(). This method checks for a command line parameter being passed in of DEBUG. If this value exists, the process will enter a loop checking if a debugger has been attached every half second. This gives you time to attach your favorite debugger to help you troubleshoot from the very beginning. Here is my implementation of that method.
BootstrapperEntry.cs - snippet
    
    private void WaitForDebugger()
    {
        if (Command.GetCommandLineArgs().Contains("DEBUG"))
        {
            Engine.Log(LogLevel.Verbose, "Waiting for debugger to be attached...");

            while (!Debugger.IsAttached)
            {
                Thread.Sleep(500);
            }

            Debugger.Break();
        }
    }
We also need to be able to collect and store the packages and features that are going to be installed. This step is important because it is going to help us tell the installer what to do and tell our UI the what state to show based on what the installer needs to do. At this point, we only know what the installer has in it, we won't know the current state until the Detect() method is called. In order to get what is in the installer, we need to parse an XML file that every bootstrapper creates upon launch. This file is created in the working directory of the installer and can be very easily parsed. I've created POCO classes to hold the information we want to gather from this file. I store this data in a property called Packages
BootstrapperEntry.cs - snippet
    private readonly XNamespace ManifestName = "http://schemas.microsoft.com/wix/2010/BootstrapperApplicationData";
…
    private void InitializePackages()
    {
        const string DataFilePathName = "BootstrapperApplicationData.xml";
        const string ApplicationDataNamespace = "BootstrapperApplicationData";
        const string MbaPrereqNamespace = "WixMbaPrereqInformation";
        const string PackageNamespace = "WixPackageProperties";
        const string FeatureNamespace = "WixPackageFeatureInfo";

        var workingDir = Path.GetDirectoryName(GetType().Assembly.Location);
        var dataFilePath = Path.Combine(workingDir, DataFilePathName);
        XElement applicationData = null;

        try
        {
            using (var reader = new StreamReader(dataFilePath))
            {
                var xml = reader.ReadToEnd();
                var xDoc = XDocument.Parse(xml);
                applicationData = xDoc.Element(ManifestName + ApplicationDataNamespace);
            }
        }
        catch (Exception ex)
        {
            Engine.Log(LogLevel.Error, $"Unable to parse {DataFilePathName}.\nReason: {ex.Message}");
        }

        var mbaPrereqs = applicationData.Descendants(ManifestName + MbaPrereqNamespace)
                                        .Select(x => new MbaPrereqPackage(x));
        // exclude prereq packages
        Packages = applicationData.Descendants(ManifestName + PackageNamespace)
                                      .Select(x => new BundlePackage(x))
                                      .Where(pkg => !mbaPrereqs.Any(preReq => preReq.PackageId == pkg.Id))
                                      .ToArray();

        // get features and associate with their package
        var featureNodes = applicationData.Descendants(ManifestName + FeatureNamespace);
        foreach (var featureNode in featureNodes)
        {
            var feature = new PackageFeature(featureNode);
            var parentPkg = Packages.First(pkg => pkg.Id == feature.PackageId);
            parentPkg.Features.Add(feature);
            feature.Package = parentPkg;
        }
    }
This will conclude part 1 of this series. There is a lot to cover and I feel that splitting it up is the best way to handle it. I will post a link to the complete project in the final part of the series.

Comments


When I initially left a comment I seem to have clicked on the -Notify me when new comments are added- checkbox and from now on every time a comment is added I recieve four emails with the same comment. Perhaps there is a means you can remove me from that service? Thank you!

online canadian pharmacy October 28, 2023 4:36:13 AM

Leave a Comment