I have this multi-project solution in Visual Studio 2013 and one of the projects is a Windows Installer project. It uses WiX (Windows Installer XML) 3.8 and when I rebuild the solution, the final result is a nice .MSI file that will install the executable bits from the other projects.
To get the files that need to be bundled with the installer, I copy the files that I need from the project bin folders to a folder in the WiX project named “files”. This folder is not part of the project or the solution and is not in source control. I started out with a prebuild event of the WIX project that did the following:
- Delete the files folder. I just assume that everything in the folder is obsolete
- Robocopy the deployable files from a WPF project to the files folder.
- Robocopy an ASP.Net MVC 4 project to the filles folder
- Run ctt.exe (Config Transformation Tool) to clean up the web.config file and set some default values.
- Run the WiX Harvest tool, heat.exe, to generate a .wxi include file of all of the files in the files folder.
Using robocopy makes it easy to just the files that you want and not include the files that are not needed for deployment.
With Windows Installer, every object that gets installed has to be defined in a WiX source file. You get end up with stuff that looks like:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Show hidden characters
</a> </div> </div>
</template>
xml version="1.0" encoding="utf-8"?> | |
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> | |
<Fragment> | |
<DirectoryRef Id="INSTALLLOCATION"> | |
<Component Id="cmp18BCDCBE7DFEDEAC86EBAA695FE5CDC3" Guid="{542781FF-5726-48AC-93B1-DF0C05363017}"> | |
<File Id="fil62DDF69CC427A28D37806B9D6E98E9DE" KeyPath="yes" Source="$(var.AdminSource)\favicon.ico" /> | |
</Component> | |
<Component Id="cmp93645FCD1A13835D0DFCD156CF2C6C64" Guid="{1F90BC80-59F5-4E51-8359-C3B39B78C052}"> | |
<File Id="fil00A150C9AD477E1EE4E8CA4503876DA9" KeyPath="yes" Source="$(var.AdminSource)\Global.asax" /> | |
</Component> | |
<Component Id="cmp1F6DF8646619108DFBB033EF4679F1AC" Guid="{E6B85075-DFA4-4A2B-A96F-21904E17A32A}"> | |
<File Id="filCAAC665964D1DB313016DB15221ED3C8" KeyPath="yes" Source="$(var.AdminSource)\log4net.config" /> | |
</Component> | |
<Component Id="cmpFED9CE4C040C6D017106CB6B92C2F157" Guid="{668F5139-6B0A-47D5-9030-FC89F1972559}"> | |
<File Id="fil43F031812C2D8647225696DDDF358FCA" KeyPath="yes" Source="$(var.AdminSource)\packages.config" /> | |
</Component> | |
<Component Id="cmp96166321C384ACAF2E55370F693BBA87" Guid="{EE876A20-1353-472A-B942-09C755DD4ECC}"> | |
<File Id="fil288668DCAE437D221881AFEB1D9D3919" KeyPath="yes" Source="$(var.AdminSource)\QrCodeHandler.ashx" /> | |
</Component> | |
</DirectoryRef> | |
</Fragment> | |
<Fragment> | |
<ComponentGroup Id="AdminFilesGroup"> | |
<ComponentRef Id="cmp18BCDCBE7DFEDEAC86EBAA695FE5CDC3" /> | |
<ComponentRef Id="cmp93645FCD1A13835D0DFCD156CF2C6C64" /> | |
<ComponentRef Id="cmp1F6DF8646619108DFBB033EF4679F1AC" /> | |
<ComponentRef Id="cmpFED9CE4C040C6D017106CB6B92C2F157" /> | |
<ComponentRef Id="cmp96166321C384ACAF2E55370F693BBA87" /> | |
</ComponentGroup> | |
</Fragment> | |
</Wix> |
Which is hideous to do by hand. You can run heat.exe on a folder and it will generate that the include file for all the files in that folder for you. In my prebuild event, I had the following lines:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Show hidden characters
</a> </div> </div>
</template>
rd /s /q $(ProjectDir)files | |
robocopy $(ProjectDir)..\AdminConsole\bin\$(ConfigurationName) $(ProjectDir)files\adminconsole *.* /s /XF *.pdb *vshost* *.xml | |
robocopy $(ProjectDir)..\webapi $(ProjectDir)\files *.* /s /XF *.pdb *vshost* *.xml *.cs *.user *.vspscc web.debug.config web.release.config *.layout *.csproj /XD obj properties | |
"$(SolutionDir)ctt.exe" s:$(ProjectDir)..\webapi\web.config t:$(ProjectDir)..\webapi\web.release.config d:$(ProjectDir)files\web.config pw v | |
"$(WIX)bin\heat" dir $(ProjectDir)files -cg AdminFilesGroup -gg -scom -sfrag -srd -dr INSTALLLOCATION -out $(ProjectDir)adminfiles.wxs -var var.AdminSource |
The 5th line is the heat command line. The various command line options are documented here. This ran without any problems on my dev machine. Hilarity ensued when I tried to make a build from our TFS server. I was getting build errors when it executed heat.exe
heat.exe: Access to the path 'C:\Builds\31\VSTancillary\FleetVision_Dev\Sources\WixSetupProject\adminfiles.wxs' is denied.
That was annoying. During the build, heat was recreating the adminfiles.wxs file each time. Since that file was in source control, it was set to read only on the build server. That caused heat.exe to abort out since it couldn’t recreate that file. Our build engineer suggested using the attrib command to clear the read only bit. The light bulb (LED, should last longer than incandescent) flickered above my head and I realized that since that file was in source control, I didn’t need to created it on the build server. I just needed to set the build so that heat didn’t run on the build server.
There are probably a few ways of doing this, I went with setting it up so that heat would only get run for debug builds. Our build server is only doing release builds, this would work for me. So I moved the prebuild code out of the project property settings and implemented them as individual MSBuild tasks.
The first part of doing that was to install the MSBuild Extension Pack from CodePlex. I did that to get a RoboCopy task for MSBuild. Robocopy is very powerful tool for copying and synching up files, but has this one little quirk. It return 1 as a success code. Everything else on Planet DOS returns 0 for success and non-zero values to indicate an error. The MSBuild.ExtensionPack.FileSystem.RoboCopy task knows about that quirk and prevents MSBuild from reporting a robocopy success code as an error. Lots of good stuff in the Extension Pack, you’ll want to have one in your toolbelt.
When you install WIX, you get WIX specific extensions for MSBuild. The task for heat is called HeatDirectory. The HeatDirectory equivalent of the heat.exe command line that I was using looks like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Show hidden characters
</a> </div> </div>
</template>
<HeatDirectory | |
Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' " | |
ToolPath="$(WixToolPath)" | |
Directory="$(ProjectDir)files" | |
DirectoryRefId="INSTALLLOCATION" | |
OutputFile="$(ProjectDir)adminfiles.wxs" | |
ComponentGroupName="AdminFilesGroup" | |
GenerateGuidsNow="true" | |
SuppressCom="true" | |
SuppressFragments="true" | |
SuppressRootDirectory="true" | |
PreprocessorVariable="var.AdminSource" /> |
The first element is Condition, which is comes with MSBuild. By setting the value to ” ‘$(Configuration)|$(Platform)’ == ‘Debug|x86’ “, MSBuild will only execute that task when the condition evaluates to true.
That worked perfectly, but only for the first time. After doing one debug build, the next build bombed out on the RoboCopy task. There was a problem with the files being in use. If I restarted VS, I could do another build. If I commented out the HeatDirectory task, the build would work. I went to the WIX site and sure enough, this was a known bug. The heat.exe was keeping the file handles open for the files that it read.
By default, HeatDirectory was running heat.exe from within the Visual Studio process. This was the fast way to execute heat, but you pick up any handle heaks from the heat.exe. In one of the comments to the bug report, a work around was suggested. Add RunAsSeparateProcess=”true” to HeatDirectory. This forces heat.exe to be run as a separate process and the leaked handles get flushed when that process ends.
That took care of the problem. While this is a known bug, the comments associated with that bug made it clear that it’s not going toget addressed any time soon.
So what is CTT? It is a command line version of the XDT transform that Visual Studio uses when it transforms web.config from web.release.config and web.debug.config. It’s another good tool.
If you are still reading this, here is the final version of the prebuild events
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Show hidden characters
</a> </div> </div>
</template>
<Import Project="$(MSBuildExtensionsPath)\ExtensionPack\4.0\MSBuild.ExtensionPack.tasks"/> | |
<Target Name="BeforeBuild"> | |
<MSBuild.ExtensionPack.FileSystem.Folder TaskAction="RemoveContent" Path="$(ProjectDir)files" /> | |
<MSBuild.ExtensionPack.FileSystem.RoboCopy Source="$(ProjectDir)..\AdminConsole\bin\$(ConfigurationName)" Destination="$(ProjectDir)files\adminconsole" Files="*.*" Options="/s /XF *.pdb *avshost* *.xml"> | |
<Output TaskParameter="ExitCode" PropertyName="Exit" /> | |
<Output TaskParameter="ReturnCode" PropertyName="Return" /> | |
</MSBuild.ExtensionPack.FileSystem.RoboCopy> | |
<MSBuild.ExtensionPack.FileSystem.RoboCopy Source="$(ProjectDir)..\webapi" Destination="$(ProjectDir)\files" Files="*.*" Options="/s /XF *.pdb *avshost* *.xml %2a.cs *.user *.vspscc web.debug.config web.release.config *.layout *.csproj /XD obj properties"> | |
<Output TaskParameter="ExitCode" PropertyName="Exit" /> | |
<Output TaskParameter="ReturnCode" PropertyName="Return" /> | |
</MSBuild.ExtensionPack.FileSystem.RoboCopy> | |
<Exec Command="$(SolutionDir)ctt.exe s:$(ProjectDir)..\webapi\web.config t:$(ProjectDir)..\webapi\web.release.config d:$(ProjectDir)files\web.config pw v"/> | |
<HeatDirectory | |
Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' " | |
ToolPath="$(WixToolPath)" | |
Directory="$(ProjectDir)files" | |
DirectoryRefId="INSTALLLOCATION" | |
OutputFile="$(ProjectDir)adminfiles.wxs" | |
ComponentGroupName="AdminFilesGroup" | |
GenerateGuidsNow="true" | |
SuppressCom="true" | |
SuppressFragments="true" | |
SuppressRootDirectory="true" | |
RunAsSeparateProcess="true" | |
PreprocessorVariable="var.AdminSource" /> | |
</Target> |