Wix fragment component generation
In order to get Wix to generate good MSIs, you can have no more than one DLL/EXE per component. In addition, each component needs it's own GUID. My team wanted a way to automate the definition of these components for our .NET apps as part of our automated build process. We tried using tallow/mallow, but that didn't really work out too well for us.
Here's the solution we came up, in a nutshell: a custom nant task runs against a build directory and catalogs all files in an XML file. Special logic applies for DLLs and EXEs, which get inspected and have relevant metadata added to thir elements in the XML. Once we have this XML, we apply an XSLT which transforms it into a Wix-friendly fragment file. We made one XSLT for executables, and another one for web services. We activate this XSLT as part of our nant script, and then included it in the call to Wix.
I'm including code listings for the relevant files here. It's available for use under a BSD license, which I'll include at the bottom.
The nant custom task: "BuildOutputToXmlTask.cs"
-
using System;
-
using System.Collections;
-
using System.Collections.Specialized;
-
using System.Diagnostics;
-
using System.IO;
-
using System.Reflection;
-
using System.Runtime.InteropServices;
-
using System.Text;
-
using System.Text.RegularExpressions;
-
using System.Xml;
-
using NAnt.Core;
-
using NAnt.Core.Attributes;
-
using NAnt.Core.Types;
-
-
namespace Oxygen.Build.Tasks
-
{
-
/// <summary>
-
/// &lt;buildoutputtoxml dir="build/app-client" output="build-output.xml" /&gt;
-
/// </summary>
-
[TaskName("buildoutputtoxml")]
-
public class BuildOutputToXmlTask : Task
-
{
-
private string _dir;
-
private string _output = "build-output.xml";
-
private bool _debug;
-
private const int MAX_PATH = 260;
-
-
[TaskAttribute("dir", Required = true, ExpandProperties=true)]
-
[StringValidator(AllowEmpty = false)]
-
public string Dir
-
{
-
get { return _dir; }
-
}
-
-
[TaskAttribute("output", Required = false)]
-
[StringValidator(AllowEmpty = false)]
-
public string Output
-
{
-
get { return _output; }
-
set { _output = value; }
-
}
-
-
[TaskAttribute("debug", Required = false)]
-
[BooleanValidator]
-
public bool Debug
-
{
-
get { return _debug; }
-
set { _debug = value; }
-
}
-
-
/// <summary>
-
/// Used to select the files to create XML for.
-
/// </summary>
-
[BuildElement("fileset")]
-
public virtual FileSet BuildOutputFileSet
-
{
-
get { return _fileset; }
-
set { _fileset = value; }
-
}
-
-
protected override void ExecuteTask()
-
{
-
if (this.Debug) Debugger.Break();
-
//Check the directory exists
-
if (!Directory.Exists(this.Dir))
-
-
// ensure base directory is set, even if fileset was not initialized
-
// from XML
-
if (BuildOutputFileSet.BaseDirectory == null)
-
{
-
}
-
if (BuildOutputFileSet.Includes.Count == 0)
-
BuildOutputFileSet.Includes.Add("**/*");
-
-
XmlNode parentNode = xmlDoc.AppendChild(xmlDoc.CreateElement("buildOutput"));
-
AddFilesToXml(GetFileSetFilesInDir(this.Dir, false), xmlDoc, parentNode);
-
AddDirectoriesToXml(Directory.GetDirectories(this.Dir), xmlDoc, parentNode);
-
Project.Log(Level.Info, "Writing " + this.Output + " with contents of " + this.Dir);
-
try
-
{
-
writer.Formatting = Formatting.Indented;
-
xmlDoc.WriteTo(writer);
-
}
-
finally
-
{
-
writer.Close();
-
}
-
}
-
-
private void AddDirectoriesToXml(string[] directories, XmlDocument xmlDoc, XmlNode parentNode)
-
{
-
foreach (string subDir in directories)
-
{
-
if (DirectoryContainsFileSetFiles(subDir))
-
{
-
string directoryName = subDir.Replace(this.Dir + "\\", String.Empty);
-
string shortName = Path.GetFileName(GetShortPathName(subDir));
-
XmlElement directoryElement = xmlDoc.CreateElement("directory");
-
directoryElement.Attributes.Append(xmlDoc.CreateAttribute("shortName")).Value = shortName;
-
directoryElement.Attributes.Append(xmlDoc.CreateAttribute("longName")).Value = directoryName;
-
directoryElement.Attributes.Append(xmlDoc.CreateAttribute("processedName")).Value = ProcessDirectoryName(directoryName);
-
parentNode.AppendChild(directoryElement);
-
AddFilesToXml(GetFileSetFilesInDir(subDir, false), xmlDoc, directoryElement);
-
AddDirectoriesToXml(Directory.GetDirectories(subDir), xmlDoc, directoryElement);
-
}
-
}
-
}
-
-
private void AddFilesToXml(StringCollection files, XmlDocument xmlDoc, XmlNode parentNode)
-
{
-
foreach (string path in files)
-
{
-
string shortName = Path.GetFileName(GetShortPathName(path));
-
// Check if 8.3 name exists
-
// HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem NtfsDisable8dot3NameCreation=1
-
if (!Regex.IsMatch(shortName, @"^\S{1,8}\.\S{0,3}$"))
-
{
-
shortName = GenerateShortName(fileName, shortNames);
-
}
-
shortNames.Add(shortName, null);
-
-
XmlElement fileElement = xmlDoc.CreateElement("file");
-
fileElement.Attributes.Append(xmlDoc.CreateAttribute("name")).Value = shortName;
-
fileElement.Attributes.Append(xmlDoc.CreateAttribute("longName")).Value = Path.GetFileName(fileName);
-
fileElement.Attributes.Append(xmlDoc.CreateAttribute("extension")).Value = Path.GetExtension(fileName);
-
fileElement.Attributes.Append(xmlDoc.CreateAttribute("processedName")).Value = ProcessFilePath(path);
-
fileElement.Attributes.Append(xmlDoc.CreateAttribute("generatedId")).Value = Guid.NewGuid().ToString();
-
if (path.ToLower().EndsWith(".exe") || path.ToLower().EndsWith(".dll"))
-
{
-
try
-
{
-
// LoadFile vs LoadFrom lets us load an assembly without resolving anything just
-
// to examine it. So already loaded assemblies or duplicate assemblies don't matter
-
AssemblyName assemblyName = AssemblyName.GetAssemblyName(Path.GetFullPath(path));
-
XmlElement assemblyElement = xmlDoc.CreateElement("assembly");
-
fileElement.AppendChild(assemblyElement);
-
string[] nameParts = Regex.Split(assemblyName.FullName, @", *|=");
-
assemblyElement.Attributes.Append(xmlDoc.CreateAttribute("fullName")).Value = assemblyName.FullName;
-
assemblyElement.Attributes.Append(xmlDoc.CreateAttribute("name")).Value = nameParts[0];
-
-
for (int i = 1; i + 1 <nameParts.Length; i += 2)
-
{
-
string name = ToLowerFirstChar(nameParts[i]);
-
string value = nameParts[i + 1];
-
if (value != "null")
-
assemblyElement.Attributes.Append(xmlDoc.CreateAttribute(name)).Value = value;
-
}
-
}
-
catch (Exception ex)
-
{
-
Console.Out.WriteLine(ex.ToString());
-
}
-
}
-
parentNode.AppendChild(fileElement);
-
}
-
Project.Log(Level.Info, "Writing " + this.Output + " with contents of " + this.Dir);
-
try
-
{
-
writer.Formatting = Formatting.Indented;
-
xmlDoc.WriteTo(writer);
-
}
-
finally
-
{
-
writer.Close();
-
}
-
}
-
-
private string ProcessFilePath(string fullPath)
-
{
-
string path = fullPath.Substring(fullPath.IndexOf(this.Dir) + this.Dir.Length + 1);
-
return StripDotsAndSlashesAndInterCap(path);
-
}
-
-
private string ProcessDirectoryName(string directoryName)
-
{
-
return StripDotsAndSlashesAndInterCap(directoryName);
-
}
-
-
private string StripDotsAndSlashesAndInterCap(string s)
-
{
-
bool lastCharWasPeriodOrBackslash = false;
-
for (int i = 0; i <s.Length; i++)
-
{
-
char c = s[i];
-
if ((c == '.') || (c == '\\'))
-
{
-
lastCharWasPeriodOrBackslash = true;
-
}
-
else
-
{
-
if (lastCharWasPeriodOrBackslash || i == 0)
-
buffer.Append(Char.ToUpper(c));
-
else
-
buffer.Append(c);
-
lastCharWasPeriodOrBackslash = false;
-
}
-
}
-
return buffer.ToString();
-
}
-
-
private static string ToLowerFirstChar(string input)
-
{
-
if (input == null) return null;
-
if (input.Length == 0) return input;
-
if (input.Length == 1) return input.ToLower();
-
output[0] = Char.ToLower(input[0]);
-
input.CopyTo(1, output, 1, input.Length - 1);
-
}
-
-
/// <summary>
-
/// Gets the short name for a file.
-
/// </summary>
-
/// <param name="fullPath">Fullpath to file on disk.</param>
-
/// <returns>Short name for file.</returns>
-
private static string GetShortPathName(string fullPath)
-
{
-
-
uint result = GetShortPathName(fullPath, shortPath, MAX_PATH);
-
-
if (0 == result)
-
{
-
int err = Marshal.GetLastWin32Error();
-
}
-
-
return shortPath.ToString();
-
}
-
-
/// <summary>
-
/// Gets the short name for a file.
-
/// </summary>
-
/// <param name="longPath">Long path to convert to short path.</param>
-
/// <param name="shortPath">Short path from long path.</param>
-
/// <param name="buffer">Size of short path.</param>
-
/// <returns>zero if success.</returns>
-
[DllImport("kernel32.dll", EntryPoint="GetShortPathNameW", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
-
internal static extern uint GetShortPathName(string longPath, StringBuilder shortPath, [MarshalAs(UnmanagedType.U4)] int buffer);
-
-
private string GenerateShortName(string name, SortedList shortNames)
-
{
-
int i = 1;
-
string shortName;
-
do
-
{
-
string extension = Path.GetExtension(name).ToUpper();
-
if (extension.Length> 4)
-
extension = extension.Substring(0, 4);
-
string baseName = Path.GetFileNameWithoutExtension(name).ToUpper();
-
if (baseName.Length> 6)
-
baseName = baseName.Substring(0, 6);
-
if (i <5) // win 2003 this is <5 for speed
-
{
-
shortName = baseName + '~' + i + extension;
-
i++;
-
}
-
else
-
{
-
int randomNumber = random.Next(0x0, 0xFFFF);
-
shortName = baseName.Substring(0, 2) + randomNumber.ToString("X4") + '~' + 1 + extension;
-
}
-
} while (shortNames.Contains(shortName));
-
return shortName;
-
}
-
-
-
private StringCollection GetFileSetFilesInDir(string dir, bool recursive)
-
{
-
foreach (string fileName in this.BuildOutputFileSet.FileNames)
-
{
-
if (fileInfo.FullName.IndexOf(dirInfo.FullName) == 0)
-
{
-
if (recursive)
-
{
-
results.Add(fileName);
-
}
-
else
-
{
-
if (fileInfo.DirectoryName == dirInfo.FullName)
-
results.Add(fileName);
-
}
-
}
-
}
-
return results;
-
}
-
-
private bool DirectoryContainsFileSetFiles(string dir)
-
{
-
if (GetFileSetFilesInDir(dir, true).Count> 0)
-
return true;
-
-
return false;
-
}
-
}
-
}
The XSLT for transforming a Windows executable project: "OutputToComponents.xslt":
-
<?xml version="1.0" encoding="UTF-8" ?>
-
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns="http://schemas.microsoft.com/wix/2003/01/wi">
-
<xsl:output indent="yes" method="xml" />
-
<xsl:param name="applicationName" select="MyApplication" />
-
<xsl:template match="/">
-
<Wix>
-
<Fragment>
-
<Media Id="1" Cabinet="ProductFeature.cab" EmbedCab="yes" />
-
<Directory Id="TARGETDIR" Name="SourceDir">
-
<xsl:for-each select="//file">
-
<Component Id="{@processedName}" Guid="{@generatedId}">
-
<File Id="{@processedName}File" Name="{@name}" LongName="{@longName}" src="{@longName}"
-
Vital="yes" KeyPath="yes" DiskId="1">
-
<xsl:if test="count(assembly)=1">
-
<xsl:attribute name="Assembly">.net</xsl:attribute>
-
<xsl:attribute name="AssemblyApplication"><xsl:value-of select="@processedName" />File</xsl:attribute>
-
<xsl:attribute name="AssemblyManifest"><xsl:value-of select="@processedName" />File</xsl:attribute>
-
</xsl:if>
-
<xsl:if test="@extension='.exe'">
-
<Shortcut Id="ApplicationShortcut" Advertise="yes" Icon="AppIcon.exe" Directory="OxygenMenuFolder"
-
Name="{substring($applicationName,0,7)}~1" LongName="{$applicationName}" />
-
</xsl:if>
-
</File>
-
</Component>
-
</xsl:for-each>
-
<Directory Id="ProgramMenuFolder" Name="USER'S~1" LongName="User's Programs Menu">
-
<Directory Id="OxygenMenuFolder" Name='OXYGEN~1' LongName="Oxygen Media" />
-
</Directory>
-
<Component Id="RemoveOxygenMenuFolderComponent" Guid="A7A96ACE-AA12-4503-A02C-AF0F55D6E194">
-
<RemoveFolder Id="RemoveOxygenMenuFolder" On="uninstall" Directory="OxygenMenuFolder" />
-
</Component>
-
</Directory>
-
<Icon Id="AppIcon.exe" src="app.ico" />
-
<Feature Id="ProductFeature" Title="{$applicationName}" Level="1">
-
<xsl:for-each select="//file">
-
<ComponentRef Id="{@processedName}" />
-
</xsl:for-each>
-
<ComponentRef Id="RemoveOxygenMenuFolderComponent" />
-
</Feature>
-
</Fragment>
-
</Wix>
-
</xsl:template>
-
</xsl:stylesheet>
-
</code></pre>
-
-
The XSLT for transforming a web service project:
-
-
<pre><code allow="none">
-
<?xml version="1.0" encoding="UTF-8" ?>
-
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns="http://schemas.microsoft.com/wix/2003/01/wi">
-
<xsl:output indent="yes" method="xml" />
-
<xsl:param name="applicationName" select="MyApplication" />
-
<xsl:template match="/">
-
<Wix>
-
<Fragment>
-
<Media Id="1" Cabinet="ProductFeature.cab" EmbedCab="yes" />
-
<Directory Id="TARGETDIR" Name="SourceDir">
-
<xsl:apply-templates />
-
<Component Id="WixWebVirtualDirComponent" Guid="INSERT_GUID_HERE">
-
<WebVirtualDir Id="WebVirtualDir" Alias="[VIRTUALDIRECTORYNAME]" Directory="TARGETDIR" WebSite="DefaultWebSite">
-
<WebApplication Id="WebApplication" Name="[VIRTUALDIRECTORYNAME]" />
-
</WebVirtualDir>
-
</Component>
-
</Directory>
-
<WebSite Id="DefaultWebSite" Description="Default Web Site">
-
<WebAddress Id="AllUnassigned" Port="80" />
-
</WebSite>
-
<Feature Id="ProductFeature" Title="{$applicationName}" Level="1">
-
<xsl:for-each select="//file">
-
<ComponentRef Id="{@processedName}" />
-
</xsl:for-each>
-
<ComponentRef Id="WixWebVirtualDirComponent" />
-
</Feature>
-
</Fragment>
-
</Wix>
-
</xsl:template>
-
<xsl:template name="directoryTemplate" match="directory">
-
<Directory Id="{@processedName}" Name="{@shortName}">
-
<xsl:if test="@shortName != @longname">
-
<xsl:attribute name="LongName"><xsl:value-of select="@longName" /></xsl:attribute>
-
</xsl:if>
-
<xsl:apply-templates />
-
</Directory>
-
</xsl:template>
-
<xsl:template name="fileTemplate" match="file">
-
<Component Id="{@processedName}" Guid="{@generatedId}">
-
<File Id="{@processedName}File" Name="{@name}" LongName="{@longName}"
-
Vital="yes" KeyPath="yes" DiskId="1">
-
<xsl:if test="count(parent::directory)=0">
-
<xsl:attribute name="src"><xsl:value-of select="@longName" /></xsl:attribute>
-
</xsl:if>
-
<xsl:choose>
-
<xsl:when test="count(parent::directory)=1">
-
<xsl:attribute name="src"><xsl:value-of select="parent::directory/@longName" />/<xsl:value-of select="@longName" /></xsl:attribute>
-
</xsl:when>
-
<xsl:otherwise>
-
<xsl:attribute name="src"><xsl:value-of select="@longName" /></xsl:attribute>
-
</xsl:otherwise>
-
</xsl:choose>
-
</File>
-
</Component>
-
</xsl:template>
-
</xsl:stylesheet>
-
</code></pre>
-
-
A snippet of a nant build file showing the use of this code:
-
-
<pre><code allow="none">
-
<target name="client-installer" depends="init, version">
-
<buildoutputtoxml dir="${client.build.dir}" output="${client.build.dir}/buildoutput.xml" debug="false" />
-
<style in="${client.build.dir}/buildoutput.xml" out="${client.build.dir}/Component.wxs" style="lib/OutputToComponents.xslt" verbose="true" failonerror="true">
-
<parameters>
-
<parameter name="applicationName" namespaceuri="" value="HelloWorldApp" />
-
</parameters>
-
</style>
-
-
<copy todir="${client.build.dir}" flatten="true" overwrite="true" failonerror="true">
-
<fileset basedir="wix">
-
<include name="ClientInstaller.wxs" />
-
</fileset>
-
</copy>
-
-
<copy file="src/ClientUI/app.ico" tofile="${client.build.dir}/app.ico" failonerror="true" overwrite="true" />
-
-
<exec workingdir="${client.build.dir}"
-
program="tools\wix\candle.exe"
-
commandline="Component.wxs ClientInstaller.wxs" />
-
<exec workingdir="${client.build.dir}"
-
program="tools\wix\light.exe"
-
commandline="Component.wixobj ClientInstaller.wixobj -out ..\HelloWorldApp.msi" />
-
<delete failonerror="false">
-
<fileset basedir="${client.build.dir}">
-
<include name="buildoutput.xml" />
-
<include name="*.wxs" />
-
<include name="*.wixobj" />
-
<include name="app.ico" />
-
</fileset>
-
</delete>
-
</target>
Copyright and open source license:
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of the Oxygen Media nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Really good looking stuff. I've been doing CI, NAnt development for about 2 years and it's nice to see someone doing it right.
Cash
December 6th, 2005 at 6:23 pm
Hi,
I'm really interested in using a similar setup but have had endless trouble trying to combine the Component.wxs file with a base wix file to correctly setup an MSI. Is it possible to reveal some of the ClientInstaller.wxs file to see how it includes the generated Component.wxs
January 11th, 2006 at 3:13 am
Thanks very much for this! I was thinking about writing this same thing myself, but you've saved me a ton of pain and time. Thank you.
March 29th, 2006 at 3:02 pm