As part of the 2.3 build of SQLPSX I built an MSI based installer to package all 10 SQLPSX modules. The installer was created with Windows XML Installer (Wix). To build an MSI using Wix you manually edit XML files and run several command-line tool to generate the necessary files. Note if you’re a Visual Studio user there’s also a plug-ins to make some of the tasks easier. Let’s take a look at the solution built without Visual Studio…
Getting Started
- Download and install Windows XML Installer
- Wix has a learning curve, so work through the tutorial to understand the concepts
Requirements
- Do no block scripts for execution
- Invoke UAC
- Remove Previous versions of the module
- Install to the default user module directory (C:\Users\<myUserID>\Documents\WindowsPowerShell\Modules\), but allow the user to select an alternative directory including system module location (C:\Windows\system32\WindowsPowerShell\v1.0\Modules\)
Solution
Unlike a zip file in which all of the contained files inherit the blocked settings when extracted, the files contained in MSI based installer will not be blocked. So, simply by using an MSI based installer over a zip file for module distribution I’ve eliminated a support issue where new PowerShell users forget to unblock the zip file downloaded from CodePlex.
The remaining requirements are solved by defining a Wix file with the proper attributes. The Wix XML file below which we’ll call modules.wxs provides a template:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | <?xml version="1.0" encoding="utf-8"?> <?include $(sys.CURRENTDIR)\Config.wxi?> <!-- NEVER change the UPGRADE code. ALWAYS change the Id. Version 2.2.3.1 Product Id was: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX Version 2.2.3.2 Product Id was: YYYYYYYY-YYYYYYYYY-YYYY-YYYYYYYYYYYY Version 2.3.0.0 Product Id was: ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ --> <?XML:NAMESPACE PREFIX = [default] http://schemas.microsoft.com/wix/2006/wi NS = "http://schemas.microsoft.com/wix/2006/wi" /><?XML:NAMESPACE PREFIX = [default] http://schemas.microsoft.com/wix/2006/wi NS = "http://schemas.microsoft.com/wix/2006/wi" /><?XML:NAMESPACE PREFIX = [default] http://schemas.microsoft.com/wix/2006/wi NS = "http://schemas.microsoft.com/wix/2006/wi" /><?XML:NAMESPACE PREFIX = [default] http://schemas.microsoft.com/wix/2006/wi NS = "http://schemas.microsoft.com/wix/2006/wi" /><?XML:NAMESPACE PREFIX = [default] http://schemas.microsoft.com/wix/2006/wi NS = "http://schemas.microsoft.com/wix/2006/wi" /><wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> <product id="ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ" language="1033" name="SQLPSX" version="$(var.MajorVersion).$(var.MinorVersion).$(var.MicroVersion).$(var.BuildVersion)" manufacturer="MyApp" upgradecode="AAAAAAAA-AAAA-AAAAAAAAA-AAAAAAAAAAAA"> <package description="MyApp Installer" installprivileges="elevated" comments="MyApp Installer" installerversion="200" compressed="yes"></package> <upgrade id="AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"> <upgradeversion onlydetect="no" property="PREVIOUSFOUND" minimum="1.0.0" includeminimum="yes" maximum="$(var.MajorVersion).$(var.MinorVersion).$(var.MicroVersion).$(var.BuildVersion)" includemaximum="no"></upgradeversion> </upgrade> <installexecutesequence> <removeexistingproducts after="InstallInitialize"></removeexistingproducts> </installexecutesequence> <media id="1" cabinet="SQLPSXInstaller.cab" embedcab="yes"></media> <wixvariable id="WixUILicenseRtf" value="License.rtf"></wixvariable> <directory id="TARGETDIR" name="SourceDir"> <directory id="PersonalFolder" name="PersonalFolder"> <directory id="WindowsPowerShell" name="WindowsPowerShell"> <directory id="INSTALLDIR" name="Modules"> <directory id="MyModule1" name="MyModule1"> </directory> <directory id="MyModule2" name="MyMode2"> </directory> </directory> </directory> </directory> </directory> <property id="ARPHELPLINK" value="http://myapp.com/support"></property> <property id="ARPURLINFOABOUT" value="http://myapp.codeplex.com"></property> <feature id="Module" title="MyApp" level="1" configurabledirectory="INSTALLDIR"> <componentgroupref id="MyModule1"></componentgroupref> <componentgroupref id="MyModule2"></componentgroupref> </feature> <ui></ui> <uiref id="WixUI_InstallDir"></uiref> <property id="WIXUI_INSTALLDIR" value="INSTALLDIR"></property> </product> </wix> |
In line 2 we see the use of a include file (wxi), which we’ll call Config.wxi. The include file allows you to externalize things like variable names. Config.wxi is located in the same directory as the wxs file and looks like this:
<?xml version="1.0" encoding="utf-8"?> <?define MyModule1="MyModule1" ?> <?define MyModule2="MyModule2" ?> <?define MajorVersion="2" ?> <?define MinorVersion="3" ?> <?define MicroVersion="0" ?> <?define BuildVersion="0" ?>
In lines 4-7, I like to include comments for the product ID. This ID is a GUID which you’ll need to generate. Using PowerShell you run the following command to create a GUID:
([System.Guid]::NewGuid().toString()).ToUpper()
Most of the options are self-explanatory see the Wix manual for details. Of note on line 11, InstallPrivileges=”elevated” will invoke UAC. To handle upgrades I define the product version and specify to remove previous versions on initialization (lines 12-19).
A custom license agreement can be used by creating an RTF file (preferably in Wordpad as the Wordpad RTFs are smaller in size then Word), placing the file in the same directory as our wxs file, and finally setting WixUILicenseRtf to the RTF file as in line 21.
Next I define the define the directory structure in lines 22 through 33. The important thing to keep in mind is the location where the files will be installed is configurable by the user. In line 22 the Wix convention is to define the outer most directory using the syntax rectory Id=”TARGETDIR” Name=”SourceDir”. Here again, there are built-in proprties in Wix. For a complete listing of the available properties see the Wix Property Reference. In line 23 the Document or My Documents folder is specified using the builtin Wix variable PersonalFolder:
Directory Id=”PersonalFolder” Name=”PersonalFolder”
In lines 24 and 25 I create the WindowsPowerShell and Modules directory if they don’t already exist. Finally I’ll define the root directories for each module in lines 25-29. In the example wxs file I only have two modules MyModule1 and MyModule2.
Lines 34 and 35 add help and about links to the installer.
Because PowerShell modules can be made up of multiple files and even subdirectories you’ll want to make use of Wix fragments. Lines 37and 38 define each module as a component.
<ComponentGroupRef Id="MyModule1" ?> <ComponentGroupRef Id="MyModule2" ?>
Wix includes a command-line utility called heat, which among other things harvest a directories, and subdirectories and all files into a wxs XML structure. We’ll use the utility to create our fragments:
Because PowerShell ISE allows you to highlight and run each line individual, I’ll use ISE to run the following commands:
1 2 3 4 5 | $env:modules = "C:\Users\u00\Projects\Modules" cd $env:modules heat dir "$($env:modules)\MyModule1" -nologo -sfrag -suid -ag -srd -dir MyModule1 -out MyModule1.wxs -cg MyModule1 -dr MyModule1 -var var.MyModule1 heat dir "$($env:modules)\MyModule2" -nologo -sfrag -suid -ag -srd -dir MyModule2 -out MyModule2.wxs -cg MyModule2 -dr MyModule2 -var var.MyModule2 |
The above command will generate two wxs files for each module, one for MyModule1.wxs and one for MyModule2.wxs (see the Wix reference documentation for an explanation of each parameter). I’ll make use of Wix variables stored in a separate include file, Config.wxi. Each wxs file will need to be modified to a add a reference to the include file. I prefer to use sed for inserting lines into text-based files because its easy and fast. Here’ a command to add “<?include $(sys.CURRENTDIR\\Config.wxi?> as the second line to each wxs file generated from heat.exe:
1 2 3 4 5 | #Download Install sed #http://gnuwin32.sourceforge.net/ set-alias -Name sed -Value "C:\Program Files (x86)\GnuWin32\bin\sed.exe" dir *.wxs -Exclude modules.wxs | foreach {sed -e "2i <?include `$(sys.CURRENTDIR)\\Config.wxi?>" -i $_.Name} remove-item sed* |
Once the wxs files have been defined describing what the installer will do and the layout of the folder structure we’re ready to build the MSI-based installer using the following Wix commands:
1 2 | candle.exe modules.wxs MyModule1.wxs MyModule2.wxs light.exe -ext WixUIExtension -out modules.msi modules.wixobj MyModule1.wixobj MyModule2.wixobj -b "$($env:modules)" |
Provided there are no errors, an MSI file called modules.msi will be created.