WiX and DTF: Using a Custom Action to list available web sites on IIS

This is my fourth post in my WiX and DTF series. Here are some others I’ve written:

Intro

In this article I’ll cover how to allow a user to choose which web site to install an application to by listing available web sites in a list box in the MSI installation wizard. As mention in my previous articles I’m applying best practices (at the best of my knowledge) to have the installer pass the Vista certification, so hopefully that will be the case if you use any of this in production :-)

When doing lookups in IIS using custom actions in the InstallUISequence (as we need to do in order to get available web sites), elevated privileges is required (at least in Vista SP1). This means we need the installation to run as admin. This is also an issue in the WiX extension for IIS as described in this bug report. See my previous article of a workaround for both the UI Sequence part and the bug: Using a bootstrapper to force elevated privileges in Vista

If you did not quite understand what I just said about the sequence stuff, elevated privileges etc., relax and keep reading, you will know by the end of this post and/or by clicking the link above :-)

Overview

This article assume you’ve read my previous article about Using WiX to author MSI installations or that you are somewhat familiar with WiX. If you haven’t done so already and want to follow this article step-by-step, download the WiX project from my previous article here: SimpleWebApp.zip

There is another possibility of course :-) If you’re lazy and just want the complete source for this article, you can download everything from here: SimpleWebApp_WSSelect.zip

Since this article became rather long I’ve structured it into 3 main sections with related sub sections, which hopefully will help you later if you need to look up something:

Now let’s dig into it!

Custom actions

To add a CA to your solution, do this:

  1. Add a new class library project to your solution and name it IISCustomAction
  2. Add a reference to Microsoft.Deployment.WindowsInstaller found in the WiX SDK
  3. Add a reference to System.DirectoryServices
  4. Rename the class created by VS to CustomAction.cs
  5. Add the following using’s to CustomAction.cs:
    1. using System.DirectoryServices;
    2. using Microsoft.Deployment.WindowsInstaller;

When building your project a couple of things happens:

  • The managed dll (IISCustomAction.dll) is created as expected by a class library
  • MakeSfxCA.exe (which is automatically called when building in VS) is creating a new dll based on the managed dll named IISCustomAction.CA.dll (this is the one you need to use in your WiX project)

It’s MakeSfxCA.exe that does all the magic here. The output dll that MakeSfxCA has created is actually a Win32 DLL. Here’s what Christopher Painter says about this:

At runtime, MSI thinks it’s calling a Win32 DLL in it’s own sandbox but in reality the CLR is being fired up out of process and communicated with through a named pipe.

Read the complete article here.

A typical CA looks something like this:

[CustomAction]
public static ActionResult MyCustomAction(Session session)
{
    try
    {
        ...
    }
    catch (Exception ex)
    {
        session.Log("CustomActionException: " + ex.ToString());
        return ActionResult.Failure;
    }
    return ActionResult.Success;
}

The [CustomAction] attribute is needed to tag this method as a CA. The ActionResult returns either (in my case) Failure or Success allowing the installer to respond accordingly. The parameter for this method is a Session object. This object gives me access to the MSI database and the inner workings of Windows Installer allowing me to query the database, access MSI properties etc.

Now you have what you need to start writing CA’s for the Windows Installer, so let’s get cranking.

Custom action – Get web sites

Copy and paste the code below into the CustomAction.cs class in the project you created above:

[CustomAction]
public static ActionResult GetWebSites(Session session)
{
    try
    {
        View listBoxView = session.Database.OpenView("select * from ListBox");
        View availableWSView = session.Database.OpenView("select * from AvailableWebSites");

        DirectoryEntry iisRoot = new DirectoryEntry("IIS://localhost/W3SVC");
        int order = 1;

        foreach (DirectoryEntry webSite in iisRoot.Children)
        {
            if (webSite.SchemaClassName.ToLower() == "iiswebserver" &&
                webSite.Name.ToLower() != "administration web site")
            {
                StoreWebSiteDataInListBoxTable(webSite, order, listBoxView);
                StoreWebSiteDataInAvailableWebSitesTable(webSite, availableWSView);
                order++;
            }
        }
    }
    catch (Exception ex)
    {
        session.Log("CustomActionException: " + ex.ToString());
        return ActionResult.Failure;
    }
    return ActionResult.Success;
}

Here’s what I’ve done in the code above:

  • Open two views to the msi database. One for the ListBox table and one for a custom table which I will cover later called AvailableWebSites.
  • Get the root entry in IIS by using an LDAP query.
  • Iterate every child of the web site to get and store the information I need.

Here’s the code for the two helper methods I use to store the IIS data to the Windows Installer database:

private static void StoreWebSiteDataInListBoxTable(DirectoryEntry webSite, int order, View listBoxView)
{
    Record newListBoxRecord = new Record(4);
    newListBoxRecord[1] = "WEBSITE";
    newListBoxRecord[2] = order;
    newListBoxRecord[3] = webSite.Name;
    newListBoxRecord[4] = webSite.Properties["ServerComment"].Value;

    listBoxView.Modify(ViewModifyMode.InsertTemporary, newListBoxRecord);
}

private static void StoreWebSiteDataInAvailableWebSitesTable(DirectoryEntry webSite, View availableWSView)
{
    //Get Ip, Port and Header from server bindings
    string[] serverBindings = ((string)webSite.Properties["ServerBindings"].Value).Split(':');
    string ip = serverBindings[0];
    string port = serverBindings[1];
    string header = serverBindings[2];

    Record newFoundWebSiteRecord = new Record(5);
    newFoundWebSiteRecord[1] = webSite.Name;
    newFoundWebSiteRecord[2] = webSite.Properties["ServerComment"].Value;
    newFoundWebSiteRecord[3] = port;
    newFoundWebSiteRecord[4] = ip;
    newFoundWebSiteRecord[5] = header;

    availableWSView.Modify(ViewModifyMode.InsertTemporary, newFoundWebSiteRecord);
}

The first method stores data in the ListBox table and the second in my custom AvailableWebSites table.

My custom table (which I’ll explain in more detail later) contains the following columns:

  • WebSiteNo
  • WebSiteDescription
  • WebSitePort
  • WebSiteIP
  • WebSiteHeader

The ListBox table has these four columns:

  • Property
  • Order
  • Value
  • Text

For the ListBox table the Property is used for identifying one specific list box and will allow you to get the Value of the selected item later. The Property must be the same for every record that you want displayed in one single UI list box. Order defines in which order the item are displayed in the list. Value is what’s being returned to you when accessing the property later on. The Text field is what is shown in the UI list box that the end user sees in the MSI wizard.

In my code I set the Property for all records to WEBSITE. The Order is set to 1 the first time and then incremented by 1 every iteration. The Value is set to the web site id and theText is set to the web site name.

After the user have selected the web site we can find out which one by using the WEBSITE property, which will give us the id of the web site (the Value field).

In my custom AvailableWebSites table I store some more details about every web site. But before I can do that I need to get the server bindings from IIS which is formatted like this: ip:port:header. I split these up and add them to to the database. I do the same with WebSiteNo and WebSiteDescription.

In both cases I store the values to the record and update the database (using modify). Note that you have to use InsertTemporary because you’re not allowed to write permanent data to the MSI database during installation.

Custom action – Add selected web site info to properties

After the user have selected a web site in the list box, I call a CA where I store the details about the web site to some public properties. This will make more sense later when we stitch everything together in the WiX product file, but for now here’s the code:

[CustomAction]
public static ActionResult UpdatePropsWithSelectedWebSite(Session session)
{
    try
    {
        string selectedWebSiteId = session["WEBSITE"];
        session.Log("CA: Found web site id: " + selectedWebSiteId);

        View availableWebSitesView = session.Database.OpenView("Select * from AvailableWebSites where WebSiteNo=" + selectedWebSiteId);
        availableWebSitesView.Execute();

        Record record = availableWebSitesView.Fetch();
        if ((record[1].ToString()) == selectedWebSiteId)
        {
            session["WEBSITE_DESCRIPTION"] = (string)record[2];
            session["WEBSITE_PORT"] = (string)record[3];
            session["WEBSITE_IP"] = (string)record[4];
            session["WEBSITE_HEADER"] = (string)record[5];
        }
    }
    catch(Exception ex)
    {
        session.Log("CustomActionException: " + ex.ToString());
        return ActionResult.Failure;
    }
    return ActionResult.Success;
}

Remember the id for the web site that the user selected is stored in the WEBSITE property in the ListBox table? I can get this value by calling session[“WEBSITE”]. I then query my custom table that I populated earlier for details about this web site using Select with the id of the web site in the where clause. I then store these values to the properties: WEBSITE_DESCRIPTION, WEBSITE_PORT, WEBSITE_IP and WEBSITE_HEADER. These properties and where they came from will be explained later :-)

Create a new dialog

To have the user see and select from available web sites, we need to create a dialog displaying a list box. Add a new wxs file to your project and name it SelectWebSiteDlg.wxs. Then replace any generated xml with this xml:

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
  <Fragment>
    <CustomAction Id="UpdatePropsWithSelectedWebSite" BinaryKey="WebSiteCA" DllEntry="UpdatePropsWithSelectedWebSite" Execute="immediate" Return="check" />
    <Binary Id="WebSiteCA" SourceFile="$(var.SolutionDir)\IISCustomAction\bin\Debug\IISCustomAction.CA.dll" />
  </Fragment>
  <Fragment>
    <UI>
      <Dialog Id="SelectWebSiteDlg" Width="370" Height="270" Title="Select Web Site">
        <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)" />
        <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)" />
        <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)">
          <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish>
        </Control>

        <Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="Please select which web site you want to install to." />
        <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="Select Web Site" />
        <Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="!(loc.InstallDirDlgBannerBitmap)" />
        <Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />
        <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />

        <Control Id="SelectWebSiteLabel" Type="Text" X="20" Y="60" Width="290" Height="14" NoPrefix="yes" Text="Select web site:" />
        <Control Id="SelectWebSiteCombo" Type="ListBox" X="20" Y="75" Width="200" Height="150" Property="WEBSITE" Sorted="yes" />
      </Dialog>
    </UI>
  </Fragment>
</Wix>

This xml file is separated into two fragments; one for the CA and one for the actual UI. The CA entry define in which assembly the CA is located and defines an id we can use later when calling the CA. This CA is the one I created previously for updating some properties with IIS data. The reason I’m defining it here is that I can, and it makes sense to group it with the dialog that are using it. Because, as you will see later, we’re going to call this CA after the user have clicked next on this dialog.

The UI section is where the layout of the actual UI components is defined. Most elements are default for any dialog, except SelectWebSiteLabel and SelectWebSiteCombo which is the two controls specific to the web site dialog.

I also need to update MyUI.wxs which controls which and in which order the dialogs are displayed in the wizard. I’ve added the SelectWebSiteDlg between InstallDirDlg and VerifyReadyDlg as shown here:

<Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath" Order="2">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"><![CDATA[WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="SelectWebSiteDlg" Order="6"><![CDATA[WIXUI_INSTALLDIR_VALID="1"]]></Publish>
<Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
<Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>

<Publish Dialog="SelectWebSiteDlg" Control="Next" Event="DoAction" Value="UpdatePropsWithSelectedWebSite" Order="1">1</Publish>
<Publish Dialog="SelectWebSiteDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="2">1</Publish>
<Publish Dialog="SelectWebSiteDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg" Order="1">1</Publish>

<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="SelectWebSiteDlg" Order="1">NOT Installed</Publish>
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2">Installed</Publish>

See that I use DoAction to call the CA I defined earlier? This is how you hook up a CA to the “click event” of a button (or any control for that matter). Not quite as easy as in Win Forms, but it’s still quite logical.

Also note that I’ve changed the Next event for InstallDirDlg and the Back event for VerifyReadyDlg to point to my new dialog. This so the wizard will navigate correctly when the user clicks the next and back buttons.

Changes to Product.wxs (the main WiX file)

Custom Table – Storing web site info

I promised to cover my custom table in more detail later, so here it is. First the code:

<CustomTable Id="AvailableWebSites" >
  <Column Id="WebSiteNo" Category="Identifier" PrimaryKey="yes" Type="int" Width="4" />
  <Column Id="WebSiteDescription" Category="Text" Type="string" PrimaryKey="no"/>
  <Column Id="WebSitePort" Category="Text" Type="string" PrimaryKey="no"/>
  <Column Id="WebSiteIP" Category="Text" Type="string" PrimaryKey="no" Nullable="yes"/>
  <Column Id="WebSiteHeader" Category="Text" Type="string" PrimaryKey="no" Nullable="yes"/>

  <Row>
    <Data Column="WebSiteNo">0</Data>
    <Data Column="WebSiteDescription">Bogus</Data>
    <Data Column="WebSitePort">0</Data>
    <Data Column="WebSiteIP"></Data>
    <Data Column="WebSiteHeader"></Data>
  </Row>
</CustomTable>

When a user have selected the web site to install to, instead of querying IIS for details about the web site (web site number, description, port etc), I can just get it from my AvailableWebSite table. Note that I’ve added some dummy data in the first row. Without this the custom table was not stored to the MSI and I did not find any other way of getting this to work.

Properties: Define, store and retrieve

Earlier I created a CA that stored info about the selected web site into some public properties. Where did these properties come from? Well, they’re defined in the Product.wxs file like this:

<Property Id="WEBSITE_DESCRIPTION">
  <RegistrySearch Id="WebSiteDescription"
          Name="WebSiteDescription"
          Root="HKLM"
          Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
          Type="raw" />
</Property>
<Property Id="WEBSITE_PORT">
  <RegistrySearch Id="WebSitePort"
          Name="WebSitePort"
          Root="HKLM"
          Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
          Type="raw" />
</Property>
<Property Id="WEBSITE_IP">
  <RegistrySearch Id="WebSiteIP"
          Name="WebSiteIP"
          Root="HKLM"
          Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
          Type="raw" />
</Property>
<Property Id="WEBSITE_HEADER">
  <RegistrySearch Id="WebSiteHeader"
          Name="WebSiteHeader"
          Root="HKLM"
          Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
          Type="raw" />
</Property>

I’ve also defined a registry search within each property. Why? In order to have access to these values during e.g. repair or uninstall we need to store them in the registry for later use. Why? Well, the Windows Installer does not maintain state (except from INSTALLDIR and maybe a few others). Meaning any values or selections a user did during installation is not kept for later use. Since we will need to know which web site the user installed the application to, so we can remove it on uninstall, we need to store the state to the registry. If the application has been installed before, the registry search above get’s these values from the registry and stores them in their respective properties.

But how did they end up in the registry to begin with? I do that by defining a new component like this:

<Component Id="PersistWebSiteValues" Guid="C3DAE2E2-FB49-48ba-ACB0-B2B5B726AE65">
  <RegistryKey Root="HKLM" Key="SOFTWARE\torresdal.net\SimpleWebApp\Install">
    <RegistryValue Name="WebSiteDescription" Type="string" Value="[WEBSITE_DESCRIPTION]"/>
    <RegistryValue Name="WebSitePort" Type="string" Value="[WEBSITE_PORT]"/>
    <RegistryValue Name="WebSiteIP" Type="string" Value="[WEBSITE_IP]"/>
    <RegistryValue Name="WebSiteHeader" Type="string" Value="[WEBSITE_HEADER]"/>
  </RegistryKey>
</Component>

And we need to reference the component under the Feature tag:

<ComponentRef Id="PersistWebSiteValues" />

Registry key’s in WiX require a component, which is good. That means that these keys/values will be added on install and removed on uninstall, saving us manual work. The registry key above is created at SOFTWARE\torresdal.net\SimpleWebApp\Install. The actual values are the properties as you see in the Value attributes. To reference a property just use [MY_PROPERTY]. This comes in handy many times when authoring MSI installations.

Reference CA’s

One of the CA’s (the UpdatePropsWithSelectedWebSite) is actually already referenced in the SelectWebSiteDlg, but we need to reference the GetWebSites CA as well:

<CustomAction Id="GetIISWebSites" BinaryKey="IISCA" DllEntry="GetWebSites" Execute="immediate"  Return="check" />
<Binary Id="IISCA" SourceFile="$(var.SolutionDir)IISCustomAction\bin\Debug\IISCustomAction.CA.dll" />

The CustomAction tag defines a logical entry that we can use later to call this CA by using the id GetIISWebSites. In addition there is a Binary node that tells where WiX can find this CA when build the project. The CustomAction node point to this by using the BynaryKey attribute.

Then we need to make sure it’s being called:

<InstallUISequence>
  <Custom Action="GetIISWebSites" After="CostFinalize" Overridable="yes">NOT Installed</Custom>
</InstallUISequence>

The above code tells the installer to run this CA in the UISequence only when the application is not already installed (defined by the NOT Installed condition).

Change the WebSite standard action

In order for the installer to pick up the properties we’ve set for the selected web site (description, port, ip etc) we need to change the code we had previously:

<iis:WebSite Id='DefaultWebSite' Description='Default Web Site'>
    <iis:WebAddress Id='AllUnassigned' Port='80' />
</iis:WebSite>

To this:

<iis:WebSite Id="SelectedWebSite" Description="[WEBSITE_DESCRIPTION]">
  <iis:WebAddress Id="AllUnassigned" Port="[WEBSITE_PORT]" IP="[WEBSITE_IP]" Header="[WEBSITE_HEADER]" />
</iis:WebSite>

Makes sense right? I’ve also changed the id to SelectedWebSite which is more accurate in this case, meaning you need to update the WebSite reference in WebVirtualDir in the IISAppplication component as well. If you fail to do this the WiX compiler will complain, so you’re in safe hands :-)

The complete Product.wxs

There’s a lot of snippets above, so for your convenience I’ve included the complete code for Product.wxs here so you see everything together:

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" xmlns:iis="http://schemas.microsoft.com/wix/IIsExtension">
  <Product Id="77da05c4-1644-4bc3-ac14-c0f721fe31fe" Name="Simple Web App" Language="1033" Version="1.0.0.0" Manufacturer="Jon Torresdal" UpgradeCode="a897ccc5-1c81-47e0-a837-65c07a72a7bc">
    <Package InstallerVersion="400" Compressed="yes" />

    <Media Id="1" Cabinet="WixProject1.cab" EmbedCab="yes" />

    <Property Id="TARGETVDIR" Value="SimpleWebApp"/>

    <Property Id="WEBSITE_DESCRIPTION">
      <RegistrySearch Id="WebSiteDescription"
              Name="WebSiteDescription"
              Root="HKLM"
              Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
              Type="raw" />
    </Property>
    <Property Id="WEBSITE_PORT">
      <RegistrySearch Id="WebSitePort"
              Name="WebSitePort"
              Root="HKLM"
              Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
              Type="raw" />
    </Property>
    <Property Id="WEBSITE_IP">
      <RegistrySearch Id="WebSiteIP"
              Name="WebSiteIP"
              Root="HKLM"
              Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
              Type="raw" />
    </Property>
    <Property Id="WEBSITE_HEADER">
      <RegistrySearch Id="WebSiteHeader"
              Name="WebSiteHeader"
              Root="HKLM"
              Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
              Type="raw" />
    </Property>

    <CustomAction Id="GetIISWebSites" BinaryKey="IISCA" DllEntry="GetWebSites" Execute="immediate"  Return="check" />
    <Binary Id="IISCA" SourceFile="$(var.SolutionDir)IISCustomAction\bin\Debug\IISCustomAction.CA.dll" />

    <InstallUISequence>
      <Custom Action="GetIISWebSites" After="CostFinalize" Overridable="yes">NOT Installed</Custom>
    </InstallUISequence>

    <Directory Id="TARGETDIR" Name="SourceDir">
      <Directory Id="ProgramFilesFolder">
        <Directory Id="INSTALLLOCATION" Name="SimpleWebApp">
          <Component Id="Default.aspx" Guid="fc46b1a2-35e9-4a17-896b-80b2daaae567">
            <File Id="Default.aspx" Name="Default.aspx" Source="$(var.SolutionDir)SimpleWebApp\Default.aspx" DiskId="1" KeyPath="yes" />
          </Component>
          <Component Id="Web.config" Guid="2ED81B77-F153-4003-9006-4770D789D4B6">
            <File Id="Web.config" Name="Web.config" Source="$(var.SolutionDir)SimpleWebApp\Web.config" DiskId="1" KeyPath="yes" />
            <util:XmlFile Id="AppSettingsAddNode" File="[INSTALLLOCATION]Web.config" Action="createElement" ElementPath="/configuration/appSettings" Name="add" Sequence="1" />
            <util:XmlFile Id="AppSettingsKeyAttribute" Action="setValue" File="[INSTALLLOCATION]Web.config" ElementPath="/configuration/appSettings/add" Name="key" Value="AddedDuringInstall" Sequence="2" />
            <util:XmlFile Id="AppSettingsValueAttribute" Action="setValue" File="[INSTALLLOCATION]Web.config" ElementPath="/configuration/appSettings/add" Name="value" Value="This text was added during installation." Sequence="3" />
          </Component>
          <Directory Id="binFolder" Name="bin">
            <Component Id="SimpleWebApp.dll" Guid="7FC6DA37-12E5-463d-8E7E-08F73E40CCF2">
              <File Id="SimpleWebApp.dll" Name="SimpleWebApp.dll" Source="$(var.SolutionDir)SimpleWebApp\Bin\SimpleWebApp.dll" DiskId="1" KeyPath="yes" />
            </Component>
          </Directory>
        </Directory>
      </Directory>
      <Directory Id="ProgramMenuFolder">
        <Directory Id="MyWebAppStartMenuFolder" Name="SimpleWebApp">
          <Component Id="StartMenuFolder" Guid="B3AEC4C4-3F8E-4865-B87A-B750533776B5" >
            <util:InternetShortcut Id="SimpleWebAppShortcut" Name="SimpleWebApp" Target="http://localhost/SimpleWebApp/Default.aspx" Directory="MyWebAppStartMenuFolder" />
            <RemoveFolder Id="RemoveStartMenuFolder1" On="uninstall"/>
            <RegistryKey Root="HKCU" Key="SOFTWARE\torresdal.net\SimpleWebApp\SimpleWebAppShortcut">
              <RegistryValue Type="string" Value="Default Value"/>
            </RegistryKey>
          </Component>
        </Directory>
      </Directory>
      <Component Id="IISApplication" Guid="FFA12D9C-5AEC-45f8-AA7D-5C4CEC7FA466">
        <iis:WebAppPool Id="SWAAppPool" Name="SWAAppPool" />
        <iis:WebVirtualDir Id="VirtualDir" Alias="[TARGETVDIR]" Directory="INSTALLLOCATION" WebSite="SelectedWebSite">
          <iis:WebApplication Id="SimpleWebAppApp" Name="[TARGETVDIR]" WebAppPool="SWAAppPool" />
          <iis:WebDirProperties Id="WebVirtualDirProperties" Execute="yes" Script="yes" Read="yes" WindowsAuthentication="no" AnonymousAccess="yes" IIsControlledPassword="yes" />
        </iis:WebVirtualDir>
      </Component>

      <Component Id="PersistWebSiteValues" Guid="C3DAE2E2-FB49-48ba-ACB0-B2B5B726AE65">
        <RegistryKey Root="HKLM" Key="SOFTWARE\torresdal.net\SimpleWebApp\Install">
          <RegistryValue Name="WebSiteDescription" Type="string" Value="[WEBSITE_DESCRIPTION]"/>
          <RegistryValue Name="WebSitePort" Type="string" Value="[WEBSITE_PORT]"/>
          <RegistryValue Name="WebSiteIP" Type="string" Value="[WEBSITE_IP]"/>
          <RegistryValue Name="WebSiteHeader" Type="string" Value="[WEBSITE_HEADER]"/>
        </RegistryKey>
      </Component>
    </Directory>

    <CustomTable Id="AvailableWebSites" >
      <Column Id="WebSiteNo" Category="Identifier" PrimaryKey="yes" Type="int" Width="4" />
      <Column Id="WebSiteDescription" Category="Text" Type="string" PrimaryKey="no"/>
      <Column Id="WebSitePort" Category="Text" Type="string" PrimaryKey="no"/>
      <Column Id="WebSiteIP" Category="Text" Type="string" PrimaryKey="no" Nullable="yes"/>
      <Column Id="WebSiteHeader" Category="Text" Type="string" PrimaryKey="no" Nullable="yes"/>

      <Row>
        <Data Column="WebSiteNo">0</Data>
        <Data Column="WebSiteDescription">Bogus</Data>
        <Data Column="WebSitePort">0</Data>
        <Data Column="WebSiteIP"></Data>
        <Data Column="WebSiteHeader"></Data>
      </Row>
    </CustomTable>

    <iis:WebSite Id="SelectedWebSite" Description="[WEBSITE_DESCRIPTION]">
      <iis:WebAddress Id="AllUnassigned" Port="[WEBSITE_PORT]" IP="[WEBSITE_IP]" Header="[WEBSITE_HEADER]" />
    </iis:WebSite>

    <Property Id="WIXUI_INSTALLDIR" Value="INSTALLLOCATION" />
    <UIRef Id="MyUI" />

    <Feature Id="ProductFeature" Title="SimpleWebApp" Level="1">
      <ComponentRef Id="Default.aspx" />
      <ComponentRef Id="Web.config" />
      <ComponentRef Id="SimpleWebApp.dll" />
      <ComponentRef Id="StartMenuFolder" />
      <ComponentRef Id="IISApplication" />
      <ComponentRef Id="PersistWebSiteValues" />
    </Feature>
  </Product>
</Wix>

The End! 
That’s that! If you’ve come this far you (hopefully) have a functional CA with goodies that allow your user to select web sites in your installer. Hope you found this post useful and let me know if you find any errors or questions, and I’ll help you out. Btw here’s the end result:

MsiSelectWebSite

26 Comments

  • Peter

    Nice article.

    4 Nov
    Reply
  • Khushboo

    Hey Jon,

    Thanks a lot for the tutorial. It was a great help. I was looking for something exactly like this to start with writing custom actions in C#

    12 Nov
    Reply
  • Michael Walmsley

    Jon, in regards to MakeSfxCA.exe. I can find it, but there is no reference to it in your sample solution. so how do you get it to run and create the special dll that’s needed. this cant be manual as our build process is automated and thus it has to be automated in some way. eg in a build event.

    also the sample wix files assume everything is in your solution folders. when we deploy the msi to another server this folder doesnt exist.

    30 Jan
    Reply
  • Michael Walmsley

    Jon another problem with the wix files. they contain the following
    <Property Id="WEBSITE_DESCRIPTION">
    <RegistrySearch Id="WebSiteDescription"
    Name="WebSiteDescription"
    Root="HKLM"
    Key="SOFTWARE\torresdal.net\SimpleWebApp\Install"
    Type="raw" />
    </Property>

    of cource these registry keys dont exist on any machine except yours.

    30 Jan
    Reply
  • Michael Walmsley

    Jon you need to add this to your post build events of the custon action project to create the CA.DLL

    "C:\Program Files\Windows Installer XML v3\SDK\MakeSfxCA.exe" $(TargetDir)$(TargetName).CA.dll "C:\Program Files\Windows Installer XML v3\SDK\x86\SfxCA.dll" $(TargetDir)$(TargetName).dll $(TargetDir)Microsoft.Deployment.WindowsInstaller.dll

    this is for x86 and assumes the above locations for wix.

    30 Jan
    Reply
  • Jon Arild Tørresdal

    Michael,

    Regarding MakeSfxCA.exe this is being executed automatically by WiX when you do build in Visual Studio. If not, there are something wrong with your WiX installation.

    About the registry values, these are created in this section:
    <RegistryValue Name="WebSiteDescription" Type="string" Value="[WEBSITE_DESCRIPTION]"/>

    <RegistryValue Name="WebSitePort" Type="string" Value="[WEBSITE_PORT]"/>

    <RegistryValue Name="WebSiteIP" Type="string" Value="[WEBSITE_IP]"/>

    <RegistryValue Name="WebSiteHeader" Type="string" Value="[WEBSITE_HEADER]"/>

    …so this will work on any machine. The reg search is only for checking if the values exist.

    Regarding your last comment about post build event, again this should not be nessesary if WiX is properly installed.

    Jon.

    30 Jan
    Reply
  • Chris

    Better option for post-build events is working with environment variables:

    "%WIX%SDK\MakeSfxCA.exe" "$(TargetDir)$(TargetName).CA.dll" "%WIX%SDK\x86\SfxCA.dll" "$(TargetDir)$(TargetName).dll" "$(TargetDir)Microsoft.Deployment.WindowsInstaller.dll"

    9 Feb
    Reply
  • Jon Arild Tørresdal

    Chris,

    Sure, but my whole point is that you don’t have to worry about SfxCA being called when using VS, since these actions are automatically performed when doing build.

    9 Feb
    Reply
  • Gábor Vas

    Nice tutorial, thank you.
    I have implemented the custom actions in VBScript, so the .Net Framework is no longer a prerequisite for running the installer (on the other hand, when you are installing a .Net application, then it’s no use being independent of the framework :)
    I’ve made a minor improvement by creating the AvailableWebSites table runtime, so you don’t have to declare it and add a dummy record to it in the Wix source.

    The CA declarations:
    <Binary Id="Iis" SourceFile="$(var.ProjectDir)Scripts\Iis.vbs" />
    <CustomAction Id="EnumerateWebSites" BinaryKey="Iis" VBScriptCall="EnumerateWebSitesCA" Execute="immediate" Return="check" />
    <CustomAction Id="UpdatePropsWithSelectedWebSite" BinaryKey="Iis" VBScriptCall="UpdatePropsWithSelectedWebSiteCA" Execute="immediate" Return="check" />

    Iis.vbs:

    Const IDOK = 0
    Const IDABORT = 3
    Const MODIFY_INSERTTEMPORARY = 7

    ‘*****************************************************************************
    ‘ Purpose: Custom action that enumerates the local websites, and stores their
    ‘ properties in the ListBox and AvailableWebSites tables.
    ‘ Effects: Fills the ListBox table and creates and fills the AvailableWebSites
    ‘ tables.
    ‘ Returns: IDOK, if the custom action executes without error.
    ‘ IDABORT, if any error is raised.
    ‘*****************************************************************************
    Function EnumerateWebSites()
    On Error Resume Next

    Call EnumerateWebSites_()
    If (Err <> 0) Then
    Call MsgBox("Error " & Hex(Err.Number) & " (" & Err.Description & ") occurred while enumerating the websites.", vbOKOnly + vbCritical, "Error")
    EnumerateWebSites = IDABORT
    Else
    EnumerateWebSites = IDOK
    End If
    End Function

    ‘*****************************************************************************
    ‘ Purpose: Implementating the CA in this inner function makes it possible to
    ‘ simplify the error handling.
    ‘*****************************************************************************
    Function EnumerateWebSites_()

    Dim objW3SVC, objListBoxView, objAvailableWebSitesView, intOrder, objSITE, objRecord
    Dim objServerBindings, arrServerBindings

    Set objW3SVC = GetObject("IIS://LOCALHOST/W3SVC")

    Set objListBoxView = Session.Database.OpenView("SELECT * FROM ListBox")
    Call objListBoxView.Execute()

    Set objAvailableWebSitesView = Session.Database.OpenView("CREATE TABLE `AvailableWebSites` (`WebSiteNo` INT NOT NULL, `WebSiteDescription` CHAR(50), `WebSitePort` CHAR(50) NOT NULL, `WebSiteIP` CHAR(50), `WebSiteHeader` CHAR(50) PRIMARY KEY `WebSiteNo`)")
    Call objAvailableWebSitesView.Execute()
    Set objAvailableWebSitesView = Session.Database.OpenView("SELECT * FROM `AvailableWebSites`")
    Call objAvailableWebSitesView.Execute()
    intOrder = 1
    For Each objSITE In objW3SVC
    If objSITE.class = "IIsWebServer" Then
    objServerBindings = objSITE.ServerBindings
    arrServerBindings = Split(objServerBindings(0), ":")

    Set objRecord = Session.Installer.CreateRecord(4)
    objRecord.StringData(1) = "WEBSITE"
    objRecord.IntegerData(2) = intOrder
    objRecord.StringData(3) = objSITE.Name
    objRecord.StringData(4) = objSITE.ServerComment
    Call objListBoxView.Modify(MODIFY_INSERTTEMPORARY, objRecord)

    Set objRecord = Session.Installer.CreateRecord(5)
    objRecord.IntegerData(1) = CInt(objSITE.Name)
    objRecord.StringData(2) = objSITE.ServerComment
    objRecord.StringData(3) = arrServerBindings(1) ‘port
    objRecord.StringData(4) = arrServerBindings(0) ‘ip
    objRecord.StringData(5) = arrServerBindings(2) ‘header
    Call objAvailableWebSitesView.Modify(MODIFY_INSERTTEMPORARY, objRecord)
    End If
    Next
    Call objListBoxView.Close()
    Call objAvailableWebSitesView.Close()
    End Function

    ‘*****************************************************************************
    ‘ Purpose: Custom action that copies the selected website’s properties from the
    ‘ AvailableWebSites table to properties.
    ‘ Effects: Fills the WEBSITE_DESCRIPTION, WEBSITE_PORT, WEBSITE_IP, WEBSITE_HEADER
    ‘ properties.
    ‘ Returns: IDOK, if the custom action executes without error.
    ‘ IDABORT, if any error is raised.
    ‘*****************************************************************************
    Function UpdatePropsWithSelectedWebSite()
    On Error Resume Next

    Call UpdatePropsWithSelectedWebSite_()
    If (Err <> 0) Then
    Call MsgBox("Error " & Hex(Err.Number) & " (" & Err.Description & ") occurred while storing the selected website’s properties.", vbOKOnly + vbCritical, "Error")
    UpdatePropsWithSelectedWebSite = IDABORT
    Else
    UpdatePropsWithSelectedWebSite = IDOK
    End If
    End Function

    ‘*****************************************************************************
    ‘ Purpose: Implementating the CA in this inner function makes it possible to
    ‘ simplify the error handling.
    ‘*****************************************************************************
    Function UpdatePropsWithSelectedWebSite_()

    Dim strSelectedWebSiteId, objAvailableWebSitesView, objRecord

    strSelectedWebSiteId = Session.Property("WEBSITE")

    Set objAvailableWebSitesView = Session.Database.OpenView("SELECT * FROM `AvailableWebSites` WHERE `WebSiteNo`=" & strSelectedWebSiteId)
    Call objAvailableWebSitesView.Execute()
    Set objRecord = objAvailableWebSitesView.Fetch()
    If objRecord.IntegerData(1) = CInt(strSelectedWebSiteId) Then
    Session.Property("WEBSITE_DESCRIPTION") = objRecord.StringData(2)
    Session.Property("WEBSITE_PORT") = objRecord.StringData(3)
    Session.Property("WEBSITE_IP") = objRecord.StringData(4)
    Session.Property("WEBSITE_HEADER") = objRecord.StringData(5)
    End If
    Call objAvailableWebSitesView.Close()
    End Function

    26 Feb
    Reply
  • Gábor Vas

    After rereading the article I have realized that its main objective is to introduce programming with DTF (as it’s category, "WiX and DTF" clearly states), so this VBScript is slightly inappropriate here, sorry.

    26 Feb
    Reply
  • Alex Cater

    Note that I’ve added some dummy data in the first row. Without this the custom table was not stored to the MSI and I did not find any other way of getting this to work.

    The EnsureTable element can be used to cleanly accomplish this:

    e.g. <EnsureTable Id="AvailableWebSites"/>

    Aside: The element is also particularly useful for addressing merge module problems like validation related ICE warnings.

    19 Mar
    Reply
  • Jon Arild Tørresdal

    Thanks Alex. Didn’t know about that one. I’ll update the article.

    19 Mar
    Reply
  • Peter Phillips

    Really useful article and helped me no end, thanks very much.
    One thing I added was to disable the ‘Next’ button in the SelectWebSiteDlg until the user has selected a website:
    <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)" >
    <Condition Action="disable">WEBSITE = ""</Condition>
    <Condition Action="enable"><![CDATA[WEBSITE <> ""]]></Condition>
    </Control>

    What I’d like to do in addition is to automatically select the first item in the listbox when the dialog is displayed. Do you know any way of doing this?

    24 Apr
    Reply
  • Lynn Crumbling

    Peter, if "Default Website" is present in the combo, you can get away with defaulting the combo to "1" by adding a property line to your code:

    <Property Id="WEBSITE" Value="1" />

    1 Jun
    Reply
  • Scott Vickery

    Hey Jon, thanks for the article. It helped a lot.

    Things seem to work great when run interactively, but, not when run silently from the command line. I am trying to do something like this:

    msiexec /q /i <some installer>.msi WEBSITE=<some web site> [OTHER VARIABLES]

    But, the session variables from the CA (i.e. WEBSITE_DESCRIPTION) do not seem to get set. I think the secret is that I need to call UpdatePropsWithSelectedWebSite from somewhere other than the UI sequence. In addition, I think that I would have to use the WEBSITE_DESCRIPTION in the following line instead of the web site ID:

    if ((record[1].ToString()) == selectedWebSiteId)

    I realize the description might not be unique, but, in my situation, I can require that.

    Any ideas are appreciated.

    Thanks again,
    Scott

    9 Jun
    Reply
  • Sebastian Martens

    Hi Jon,

    thank you very much for this article. It is one of the kind where you step through and everything works from the first build…i just love that.

    Great!!!

    Thanks again,
    Sebastian

    18 Jun
    Reply
  • Carlos Thomas

    Hi Jon. Is it possible for a server’s security policy to prevent the enumeration of web sites using this code?

    19 Jun
    Reply
  • Carlos Thomas

    One warning for those who use this code is that it will fail if there are multiple identities defined for a website. The code

    string[] serverBindings =
    ((string)webSite.Properties["ServerBindings"].Value).Split(‘:’);

    doesn’t account for the possibility that an array of objects may be returned. The result is the runtime error

    CustomActionException: System.InvalidCastException: Unable to cast object of type ‘System.Object’ to type ‘System.String’.

    I got around this by using:

    string serverBindingsVals = string.Empty;
    int serverBindingsCount =
    webSite.Properties["ServerBindings"].Count;

    if (serverBindingsCount > 1)
    {
    object[] serverBindingObjs =
    (object[])webSite.Properties["ServerBindings"].Value;
    serverBindingsVals = (string)serverBindingObjs[0];
    }
    else
    {
    serverBindingsVals =
    (string)webSite.Properties["ServerBindings"].Value;
    }

    string[] serverBindings = serverBindingsVals.Split(‘:’);

    23 Jun
    Reply
  • Brian Pearson

    Thanks Carlos. I just ran into this very error. Modifying my code now.

    26 Jun
    Reply
  • Blair Murri

    Regarding your "bogus" row in your AvailableWebSites custom table, you don’t need that. Remove the <Row> (and <Data>) elements, and after the <CustomTable> row add this:

    <EnsureTable Id="AvailableWebSites">

    17 Aug
    Reply
  • Dave

    I don’t see how MakeSfxCA runs automagically in VS2008. Chris Painter even talks about adding a post build step. If there is a way to have this run automatically I would like to know what it is. Thanks, Dave

    11 Oct
    Reply
  • Dave

    OK, upon further inspection I see there is a special project for a wix custom action that adds a wix target to the build. I added this and now build the special dll wrapped around my assembly. THanks, Dave

    11 Oct
    Reply
  • ChrisC

    Very good article, my need was just to avoid conflicting bindings for a deferred CA website setup…

    One comment above is regarding the enumeration in a UAC secured (eg W2k8) environment. This seems indeed to be a problem as immediate CAs run in (restricted) user context rather than as SYSTEM.

    Even using passing usr,pwd into the DirectoryEntry does not help. Doesn’t seem to be any way round this short of running the whole installer (using manifest) as admin, unless anyone knows better???

    16 Oct
    Reply
  • Khoa Do

    Regarding the Custom Action Project – Just To Be Super Clear…

    The custom action project that you should add should not be just any old project. It needs to be a Project types = "WiX", Visual Studio installed templates = "C# Custom Action Project". Not realizing this, I almost mentally crashed trying to figure out how you got MakeSfxCA to run itself automagically.

    2 Dec
    Reply
  • Megh

    Hi,
    It’s very nice article.
    Can you please share the .wxs file and c# code please?

    19 Jul
    Reply
  • agustin

    http://blog.torresdal.net/download/wix/SimpleWebApp_WSSelect.zip

    your download links do not work right now

    28 Aug
    Reply