Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / PowerShell

Creating a Mozilla Firefox MSI for enterprise deployment

4.68/5 (9 votes)
21 Apr 2014CPOL8 min read 23.8K  
Mozilla Firefox MSI using WIX and Powershell

Introduction

Enterprise administrators often encounter a need (or desire) to deploy Mozilla Firefox. As Firefox is not currently compiled to msi, a need exists for wrapping the installer in an msi file. In this article, I will provide one approach for creating a customizable deployment for use in the enterprise.

Background

Requirements: Powershell, WIX, any Mozilla Firefox Setup executable and optional customization files local-settings.js, override.ini, mozilla.cfg, install (or config) ini as well as 7zip's command line executable and SFX module 7zSD.sfx.

For this script to function properly, it must be run elevated. I run most of my scripts elevated, so my initial submition omitted this. Here is the registry export for the Run As option:

Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\Microsoft.PowerShellScript.1\Shell\runas]
[HKEY_CLASSES_ROOT\Microsoft.PowerShellScript.1\Shell\runas\command]
@="\"c:\\windows\\system32\\windowspowershell\\v1.0\\powershell.exe\" -file \"%1\""

Additionally, the code could implement a security check:

function Has-Role {
    param([Security.Principal.WindowsBuiltInRole]$Role = [Security.Principal.WindowsBuiltInRole]::Administrator)
    
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal $identity
    return $principal.IsInRole($Role)
}


# Test For Admin Privileges
    if((Has-Role) -ne $true) {
        Write-Error "Elevated permissions are required."
        Write-OutPut "Use an elevated command to complete these tasks."
        return
    }

Using the code

The powershell script is designed to be executed from the same directory as all the files that will be used to create the msi. It can be run without supplying any parameters as the installation executable is a required parameter, so this information can be entered at the prompt.

Param(
    [Parameter(Position=0,Mandatory=$true)]
    [String] $FirefoxSetupExecutable,
    $ProductID = "*",
    $InstallerName = "Firefox Setup",
    $IconFile = "Icon.ico",
    $CustomActionFile = "CustomAction.cs",
    $WixFile = "Product.wxs")
  1. The parameters declaration contains
  2. $FirefoxSetupExecutable = File name of the Firefox installer from www.mozilla.org
  3. $ProductID = Product Id for Wix installer
  4. $InstallerName = Name of the new FireFox installer after modification.
  5. $IconFile = The icon to be used for Add/Remove Programs
  6. $CustomActionFile = C# file for custom actions.
  7. $WixFile = The Wix file to generate the msi.

These parameters can be ignored when defaults are suitable.

Additional declarations

$scriptPath = split-path -parent $MyInvocation.MyCommand.Definition
# Upgrade code to allow removal of previous versions
Set-Variable -Name "upgradeguid" -Value "5DD94F3B-0E8D-46FF-A7D6-41D9E5576860"
Set-Variable -Name "namespaceURI" -Value "http://schemas.microsoft.com/wix/2006/wi" 
Set-Variable -Name "targetFramework" -Value "v2.0"
# Build environment
Set-Variable -Name "buildEnv" -Value "x86"
# Temp extraction directory
Set-Variable -Name "workingdirectory" -Value "Customize"
# %wix%
Set-Variable -Name "wixDir" -Value ([System.Environment]::GetEnvironmentVariable("Wix"))
  • Things to note
  • All files must be relative to the script: $scriptPath
  • Keeping the same upgrade code between versions allows removal of previous versions.
  • The current Wix build uses .Net 2 and the custom actions dll must target this.
  • The build environment for Firefox is x86.
  • Wix must be installed. This script uses the wix environment variable to locate the Wix directory.

Customizing the Firefox installer

Details for customizing Firefox are readily available and will not be covered in detail here.

I used:

local-settings.js

pref("general.config.obscure_value", 0);
pref("general.config.filename", "mozilla.cfg");

mozilla.cfg

// set Firefox Default homepage
pref("browser.startup.homepage","http://(company website)");
// disable default browser check
pref("browser.shell.checkDefaultBrowser", false);
pref("browser.startup.homepage_override.mstone", "ignore");
// disable application updates
pref("app.update.enabled", false);
// disables the 'know your rights' button from displaying on first run 
pref("browser.rights.3.shown", true);

override.ini

[XRE]
EnableProfileMigrator=false

install.ini

[Install]
QuickLaunchShortcut=false
DesktopShortcut=false
MaintenanceService=false

These are the expected files, but are not required. Additional files can be added by modifying the code.

Extracting the installer for customization

function Customize-Installer
{
    # Create custom installer for Firefox
    if (Test-Path -Path "$scriptPath\$workingdirectory") {
        Remove-Item -Path "$scriptPath\$workingdirectory" -Recurse -Force
    }
    if(Test-Path "$scriptPath\7za.exe") {
        # Extract Firefox Setup x.x.exe
        Write-Host "Extracting $FirefoxSetupExecutable"
        $arg = [String]::Format('x "{0}\{1}" -o"{0}\{2}" -y', $scriptPath, $FirefoxSetupExecutable, $workingdirectory)
        Start-Process -FilePath "$scriptPath\7za.exe" -ArgumentList $arg -Wait -WindowStyle Hidden
    }
    else {
        throw "7za.exe missing."
        return $false
    }

Copying files to the target directories

# Copy Customization
    Write-Host "Customizing install."
    $files = "install.ini", "override.ini", "mozilla.cfg", "local-settings.js"
    $dirs = "$scriptPath\$workingdirectory", "$scriptPath\$workingdirectory\core\browser", "$scriptPath\$workingdirectory\core", "$scriptPath\$workingdirectory\core\defaults\pref"    
    $i = 0
    do {
        if(Test-Path ([String]::Format("{0}\{1}", $scriptPath, $files[$i]))) {
            Copy-Item -Path ([String]::Format("{0}\{1}", $scriptPath, $files[$i])) -Destination ([String]::Format("{0}\{1}", $dirs[$i], $files[$i]))
        }
        $i++
    }
    until ($i -eq 4)

The file of note here is install.ini. To call setup.exe with the /INI switch requires a full path to the ini file. This file is copied to the same location as setup.exe so the path can be declared using ".\" to qualify the file location.

Creating the new Firefox installer

# Build Archive (Mozilla Source 1.0.2\7zip.bat)
    Write-Host "Compressing files."
    $arg = [String]::Format('a -t7z "{0}\Customize.7z" "{0}\{1}\*" -mx -m0=BCJ2 -m1=LZMA:d24 -m2=LZMA:d19 -m3=LZMA:d19 -mb0:1 -mb0s1:2 -mb0s2:3', $scriptPath, $workingdirectory)
    Start-Process -FilePath "$scriptPath\7za.exe" -ArgumentList $arg -Wait -WindowStyle Hidden

Using the compression settings from Firefox release 1.0.2, a "temporary" zip file is created with the customization files.

# Create Installer config file
    Out-File -InputObject ";!@Install@!UTF-8!" -FilePath "$scriptPath\app.tag" -Encoding ascii # First Out-File Overwrites existing file
    Out-File -InputObject 'Title="Mozilla Firefox"' -FilePath "$scriptPath\app.tag" -Encoding ascii -Append
    if(Test-Path "$scriptPath\$workingdirectory\install.ini") {
        Out-File -InputObject 'RunProgram="setup.exe /INI=.\install.ini"' -FilePath "$scriptPath\app.tag" -Encoding ascii -Append
    }
    else {
    Write-Host "Default Setup..."
        Out-File -InputObject 'RunProgram="setup.exe"' -FilePath "$scriptPath\app.tag" -Encoding ascii -Append
    }
    Out-File -InputObject ";!@InstallEnd@!" -FilePath "$scriptPath\app.tag" -Encoding ascii -Append

Next, a file is created to have the SFX execute the setup.exe file. If the install.ini file was used, the setup.exe is given the /INI switch, otherwise, setup.exe is run without the ini file.

    if(Test-Path "$scriptPath\$InstallerName.exe") {
        Remove-Item "$scriptPath\$InstallerName.exe" -Force
    }
    Write-Host "Making $scriptPath\$InstallerName.exe"
    # Self-extract archive for installers must be created as joining 3 files: SFX_Module, Installer_Config, 7z_Archive
    Get-Content "$scriptPath\7zSD.sfx", "$scriptPath\app.tag", "$scriptPath\Customize.7z" -Encoding Byte -ReadCount 0 | Set-Content "$scriptPath\$InstallerName.exe" -Encoding Byte
    return $true
} # End Customize-Installer

Finally, the SFX module, tag, and zip files are merged. At this point, a fully functioning Mozilla Firefox setup executable is built. (For those looking to customize Firefox - that's all there is to it.)

Custom Actions

A C# Custom Actions DLL

Taking a break from Powershell to cover custom actions...

CustomAction.cs

C#
using System;
using Microsoft.Deployment.WindowsInstaller;

Required imports--if copying code from this page, the build will fail without these.

C#
namespace FirefoxInstaller
{
    public class CustomActions
    {
        [CustomAction]
        public static ActionResult FixQuote(Session session)
        {
            string propertyValue = session["QtExecCmdLine"];
            if (propertyValue.Contains("\"\""))
            {
                session.Log("Too many quotes found.");
                session["QtExecCmdLine"] = propertyValue.Replace("\"\"", "\"");
            }
            if (!propertyValue.Contains("\""))
            {
                // Add "
                session.Log("Quotes missing.");
                propertyValue = propertyValue.Replace(" " + session["HelperArgs"], "");
                session["QtExecCmdLine"] = String.Format("\"{0}\" {1}", propertyValue, session["HelperArgs"]);
            }
            return ActionResult.Success;
        }

Covering this "odd" piece of code first, I encountered an issue where program path randomly received too many quotes.

PROPERTY CHANGE: Modifying QtExecCmdLine property. Its current value is 'helper.exe -ms'. Its new value: '""C:\Program Files (x86)\Mozilla Firefox\uninstall\helper.exe"" -ms'. ... CAQuietExec: Error 0x80070057: Command failed to execute.

After a few hours of searching I concluded it would be quicker to work around the issue than fix it. (Any comments here are welcome...)

C#
[CustomAction]
public static ActionResult CASetQtExecCmdLine(Session session)
{
    session.Log("Begin SetQtExecCmdLine");
    string ffUk = session["FIREFOXUNINSTALLKEY"];
    if (ffUk == null || ffUk == String.Empty)
    {
        session.Log("FIREFOXUNINSTALLKEY missing.");
        return ActionResult.Failure;
    }
    string helper = Microsoft.Win32.Registry.GetValue(ffUk, "UninstallString", "\"[ProgramFilesFolder]Mozilla Firefox\\uninstall\\helper.exe\"").ToString();
    session["QtExecCmdLine"] = String.Format("\"{0}\" {1}", helper, session["HelperArgs"]);
    session.Log("End SetQtExecCmdLine");
    return ActionResult.Success;
}

As this configuration installs Firefox in the default location, helper.exe can be expected to reside in the default path. However, it is possible to modify the install.ini to change this location. So a better approach is to locate it by the uninstall key (covered next). This could also be accomplished with a Wix custom action but finding the registry key was still a requirement -- better just to set it when the registry key is checked.

C#
[CustomAction]
public static ActionResult GetFirefoxUninstallKey(Session session)
{
    session.Log("Begin GetFirefoxUninstallKey");
    // Must have x86 for Wow6432Node access
    object ffVersion = Microsoft.Win32.Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\Mozilla Firefox", "CurrentVersion", null);
    if (ffVersion == null)
    {
        session.Log("CurrentVersion value not found.");
        return ActionResult.Failure;
    }
    object ffUk = Microsoft.Win32.Registry.GetValue(String.Format(@"HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\Mozilla Firefox\{0}\Uninstall", ffVersion.ToString()), "Description", null);
    if (ffUk == null)
    {
        session.Log("Description value not found.");
        return ActionResult.Failure;
    }
    session["FIREFOXUNINSTALLKEY"] = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" + ffUk.ToString();
    session.Log("End GetFirefoxUninstallKey");
    return ActionResult.Success;
}

Obtaining the uninstall key requires getting the current version and the name of the uninstall key.

C#
        [CustomAction]
        public static ActionResult HideFirefoxUninstallKey(Session session)
        {
            session.Log("Begin HideFirefoxUninstallKey");
            string ffUk = session["FIREFOXUNINSTALLKEY"];
            if (ffUk == null || ffUk == String.Empty)
            {
                session.Log("FIREFOXUNINSTALLKEY missing.");
                return ActionResult.Failure;
            }
            try
            {
                Microsoft.Win32.Registry.SetValue(ffUk, "SystemComponent", 1, Microsoft.Win32.RegistryValueKind.DWord);
            }
            catch (Exception ex)
            {
                session.Log("Error setting registry value: " + ex.Message);
                return ActionResult.Failure;
            }
            session.Log("End HideFirefoxUninstallKey");
            return ActionResult.Success;
        }
    } // End class
} // End namespace

One additional step is required to tie the msi to the Firefox install. That is to create the association in the Add/Remove Programs by hiding Firefox. In this way, uninstalling Firefox goes through the msi. The alternative would be two entries in ARP, and to potential of removing Firefox without removing the msi.

Compiling the dll

Returning to the Powershell code...

function Build-CustomActions
{
Param(
    [Parameter(Position=0)] $CAfile)    
    if(!(Test-Path $CAfile)) {
        throw "Missing custom actions"
        return $false
    }
    if(Test-Path "$scriptPath\CustomAction.dll") {
        Remove-Item "$scriptPath\CustomAction.dll" -Force
    }
    if(Test-Path "$scriptPath\CA.dll") {
        Remove-Item "$scriptPath\CA.dll" -Force
    }
    Write-Host "Building custom actions."
    $csImport = @'
        public static System.CodeDom.Compiler.CompilerResults CompileCA(string frameworkVersion, string csFile, string dllFile, string platform, string wixWindowsInstaller)
        {
            System.CodeDom.Compiler.CompilerResults results = null;
            Dictionary<string, string> d = new Dictionary<string, string>();
            d.Add("CompilerVersion", frameworkVersion);
            using (Microsoft.CSharp.CSharpCodeProvider p = new Microsoft.CSharp.CSharpCodeProvider(d))
            {
                System.CodeDom.Compiler.CompilerParameters parameters =
                    new System.CodeDom.Compiler.CompilerParameters(
                        new string[] { "System.dll", 
                            wixWindowsInstaller }, dllFile);
                parameters.TreatWarningsAsErrors = false;
                parameters.CompilerOptions = "/platform:" + platform;
                results = p.CompileAssemblyFromFile(parameters, csFile);
            }
            return results;
        }
'@
    $csCompiler = Add-Type -MemberDefinition $csImport -Name Compiler -Namespace CsCompiler -UsingNamespace System.Collections.Generic -PassThru
    $result = $csCompiler::CompileCA("$targetFramework", "$CAfile", "$scriptPath\CustomAction.dll", "$buildEnv", "$wixDir\SDK\Microsoft.Deployment.WindowsInstaller.dll")
    if($result.Errors.Count -gt 0) {
        $result.Errors | % {
            Write-Host $_.ErrorText
        }
        return $false
    }

After encountering issues getting Powershell v4 to create the CSharpCodeProvider using the overload for compiler options I reverted back to Add-Type that worked in previous versions. Because the Wix msi will target Framework 2, the CSharpCodeProvider must build the custom action file for v2.0. A little C# code allows just this. I've included Write-Host $_.ErrorText for issues getting the custom action built.

# Compile for use in Wix MSI (Use default config)
    Write-Host "Converting custom actions file."
    $arg = [String]::Format('"{0}\CA.dll" "{1}\SDK\{2}\sfxca.dll" "{0}\CustomAction.dll" "{1}\SDK\MakeSfxCA.exe.config" "{1}\SDK\Microsoft.Deployment.WindowsInstaller.dll"', $scriptPath, $wixDir, $buildEnv)    
    $proc = Start-Process -FilePath "$wixDir\SDK\MakeSfxCA.exe" -ArgumentList $arg -Wait -PassThru -RedirectStandardOutput "$scriptPath\makesfxca.log" -WindowStyle Hidden
    Get-Content "$scriptPath\makesfxca.log"
    Remove-Item "$scriptPath\makesfxca.log"
    if($proc.ExitCode -ne 0) {
        throw "Error occured generating CA.dll"
        Write-Host $proc.ExitCode
        return $false
    }    
    return $true
} # End Build-CustomActions

Now the C# dll needs to be packaged for Wix.

The Wix file

Before covering how the Wix file will be updated through builds, we must cover the Wix file. This will not be an in-depth discussion of Wix. More can be found at Wix Toolset among others.

Windows Installation XML

Product.wxs

XML
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"

Basic Wix declaration--the xmlns presented additional challenges for XPath later in this article.

XML
<Product Id="*" Name="Mozilla Firefox 28.0" Language="1033" Version="28.0.0.0" Manufacturer="Mozilla" UpgradeCode="5DD94F3B-0E8D-46FF-A7D6-41D9E5576860">
  <Package InstallerVersion="300" Compressed="yes" InstallScope="perMachine" InstallPrivileges="elevated" />
  <Media Id="1" Cabinet="install.cab" EmbedCab="yes" DiskPrompt="CD-ROM #1" />
  <Property Id="DiskPrompt" Value="Mozilla Firefox [1]" />
  <Property Id="PREVIOUSVERSIONSINSTALLED" Secure="yes" />
  <Upgrade Id="5DD94F3B-0E8D-46FF-A7D6-41D9E5576860">
    <UpgradeVersion Minimum="1.0.0.0" Maximum="28.0.0.0" Property="PREVIOUSVERSIONSINSTALLED" IncludeMinimum="yes" IncludeMaximum="no" />
  </Upgrade>

To upgrade previous versions, an entry in the upgrade table is required. This is accomplished by an Upgrade element.

XML
<Directory Id="TARGETDIR" Name="SourceDir" />
<DirectoryRef Id="TARGETDIR">
  <!-- Dummy Component -->
  <Component Id="A769DA8582654F72AA112C816C8CC998" Guid="39FB70F3-F95F-490D-BE6B-A75A4C3C7F96">
    <RemoveFolder Id="ProgramMenuDir" On="uninstall" />
  </Component>
</DirectoryRef>
<Feature Id="ProductFeature" Title="MozillaInstaller" Level="1">
  <ComponentRef Id="A769DA8582654F72AA112C816C8CC998" />
</Feature>

This Wix file contains a "dummy" directory with a bogus removal action. This allows the msi to install with the only real payload being the binary Firefox installer. Later, during the Wix build, a warning is still generated that the msi cab file does not have any files. That's to be expected.

XML
<Binary Id="CA.dll" SourceFile="CA.dll" />
<Binary Id="SetupFile" SourceFile="Firefox Setup.exe" />
<Icon Id="Icon1" SourceFile="Icon.ico" />

Here is the list of files included in the msi. The custom action dll, setup file, and icon are the only files being used.

XML
<Property Id="ARPPRODUCTICON" Value="Icon1" />
<Property Id="ARPNOMODIFY" Value="1" />
<Property Id="ARPNOREPAIR" Value="1" />
<Property Id="FIREFOXUNINSTALLKEY" Value=" " />
<Property Id="HelperArgs" Value="-ms" />
<Property Id="QtExecCmdLine" Value="helper.exe -ms" />

Basic properties used for the msi. As of Firefox 28.0, the installer only allows for removal. To mirror this, ARPNOMODIFY and ARPNOREPAIR are set so only removal is possible.

XML
<CustomAction BinaryKey="SetupFile" ExeCommand="/s" Execute="immediate" Id="CAinstaller" Return="check" />
<CustomAction Id="QtExecUninstall" BinaryKey="WixCA" DllEntry="CAQuietExec" Execute="immediate" Return="ignore" />
<CustomAction Id="CAHideFirefoxUninstallKey" Return="ignore" Execute="immediate" BinaryKey="CA.dll" DllEntry="HideFirefoxUninstallKey" />
<CustomAction Id="CAGetFirefoxUninstallKey" Return="ignore" Execute="immediate" BinaryKey="CA.dll" DllEntry="GetFirefoxUninstallKey" />
<CustomAction Id="CAGetFirefoxUninstallKey2" Return="ignore" Execute="immediate" BinaryKey="CA.dll" DllEntry="GetFirefoxUninstallKey" />
<CustomAction Id="CASetQtExecCmdLine" Return="ignore" Execute="immediate" BinaryKey="CA.dll" DllEntry="CASetQtExecCmdLine" />
<CustomAction Id="FixQuote" Return="ignore" Execute="immediate" BinaryKey="CA.dll" DllEntry="FixQuote" />

In the msi, an additional parameter is passed to the Firefox installer: /S. This is so the extraction window remains hidden as well. (The /INI switch on setup.exe makes the installation invisible.)

XML
    <InstallExecuteSequence>
      <Custom Action="CAGetFirefoxUninstallKey" After="InstallValidate">Installed AND (REMOVE = "ALL")</Custom>
      <Custom Action="CASetQtExecCmdLine" After="CAGetFirefoxUninstallKey">Installed AND (REMOVE = "ALL")</Custom>
      <Custom Action="FixQuote" After="CASetQtExecCmdLine">Installed AND (REMOVE = "ALL")</Custom>
      <Custom Action="QtExecUninstall" After="CASetQtExecCmdLine">Installed AND (REMOVE = "ALL")</Custom>
      <RemoveExistingProducts Before="InstallInitialize" />
      <Custom Action="CAinstaller" After="InstallFiles">NOT Installed</Custom>
      <!-- InstallExecuteAgain -->
      <Custom Action="CAGetFirefoxUninstallKey2" After="CAinstaller" />
      <Custom Action="CAHideFirefoxUninstallKey" Before="InstallFinalize" />
    </InstallExecuteSequence>
  </Product>
</Wix>

RemoveExistingProducts is set before InstallInitialize. (It must be scheduled between this and InstallValidate.) The last actions needed are locating the uninstall key to hide the actual Firefox install from ARP. For removal purposes, the uninstall key is also required early in the execution sequence.

Keeping the Wix file updated

Back to the Powershell script...

Before getting to updates to the Wix file, the script needs to know which version of Firefox is being used for install. This is accomplished with a Get-Version function that locates the firefox.exe file currently extracted from the installer.

function Get-Version
{
    # Get Firefox version info
    if(Test-Path "$scriptPath\Customize\core\firefox.exe") {
        return (Get-Item "$scriptPath\Customize\core\firefox.exe").VersionInfo.ProductVersion
    }
}

function Update-WixFile
{
    $xml = New-Object System.Xml.XmlDocument
    $xml.Load("$scriptPath\$WixFile")
    Write-Host "Updating $scriptPath\$WixFile."
    $node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product" -Namespace @{"ns" = $namespaceURI}
    $fVersion = Get-Version
    $versionString = "0", "0", "0", "0"
    #alter version string
    $pl = $fVersion.Split(".")
    $i = 0
    while ($i -lt $pl.Length) {
        $versionString[$i] = $pl[$i]
        $i++
    }
    $productVersion = [String]::Join(".", $versionString)

Remember the namespace variable? This is required to use XPath to navigate the Wix file. The xmlns attribute requires prefixing node names.

This function begins by getting the Firefox executable file version, building it into the Wix version string needed for the upgrade table.

$node.Node.Attributes["Id"].Value = $ProductID
$node.Node.Attributes["UpgradeCode"].Value = $upgradeguid
$node.Node.Attributes["Name"].Value = "Mozilla Firefox $fVersion"
# update version
$node.Node.Attributes["Version"].Value = $productVersion

Here, product details are updated. Most important are the Name and Version properties used in Add/Remove Progams.

# upgrade info
$node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product/ns:Upgrade" -Namespace @{"ns" = $namespaceURI}
if($node -ne $null) {
    $node.Node.Attributes["Id"].Value = $upgradeguid
}
$node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product/ns:Upgrade/ns:UpgradeVersion" -Namespace @{"ns" = $namespaceURI}
if($node -ne $null) {
    $node.Node.Attributes["Maximum"].Value = $productVersion
}

Upgrade information is updated for the current build. The same Upgrade Code must be used between builds. This can be obtained using [guid]::NewGuid() or GUID Generator (like the one from the MS SDK.)

$node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product/ns:Binary[@Id='SetupFile']" -Namespace @{"ns" = $namespaceURI}
# update setup file name
$node.Node.Attributes["SourceFile"].Value = "$InstallerName.exe"

The binary file name is updated. (Only needed if default $InstallerName is not used.)

    if(!(Test-Path "$scriptPath\$IconFile"))
    {
        # No Icon - Remove Icon Node
        $node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product/ns:Icon" -Namespace @{"ns" = $namespaceURI}
        if($node -ne $null) {
            $node.Node.ParentNode.RemoveChild($node.Node) | Out-Null
        }
        $node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product/ns:Property[@Id='ARPPRODUCTICON']" -Namespace @{"ns" = $namespaceURI}
        if($node -ne $null) {
            $node.Node.ParentNode.RemoveChild($node.Node) | Out-Null
        }
    }
    else
    {
        $node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product/ns:Icon" -Namespace @{"ns" = $namespaceURI}
        if($node -ne $null) {
            $node.Node.Attributes["SourceFile"].Value = "$IconFile"
        }
        else {
            $node = $xml.CreateElement("Icon", $namespaceURI)
            $node.Attributes.Append($xml.CreateAttribute("Id")) | Out-Null
            $node.Attributes.Append($xml.CreateAttribute("SourceFile")) | Out-Null
            $node.SetAttribute("Id", "Icon1")
            $node.SetAttribute("SourceFile", "$IconFile")
            $p = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product" -Namespace @{"ns" = $namespaceURI}
            $p.Node.AppendChild($node) | Out-Null
            $node = Select-Xml -Xml $xml.DocumentElement -XPath "//ns:Product/ns:Property[@Id='ARPPRODUCTICON']" -Namespace @{"ns" = $namespaceURI}
            if($node -eq $null) {
                $node = $xml.CreateElement("Property", $namespaceURI)
                $node.Attributes.Append($xml.CreateAttribute("Id")) | Out-Null
                $node.Attributes.Append($xml.CreateAttribute("Value")) | Out-Null
                $node.SetAttribute("Id", "ARPPRODUCTICON")
                $node.SetAttribute("Value", "Icon1")
                
                $p.Node.AppendChild($node) | Out-Null
            }
            else {
                $node.Node.Attributes["Value"].Value = "Icon1"
            }
        }
    }
    $xml.Save("$scriptPath\$WixFile")
} # End Update-WixFile

The last update is the icon file. The original intent was to include a function to pull the icon from the executable. However, this did not go so well. The Windows API like CreateIconFromResourceEx (and the wrapper ExtractAssociatedIcon do a major job on the icons extracted from firefox.exe. Ultimately, I used a third party tool to extract the resource and cleaned it up with VS 2012. This section of code is larger than others because it modifies the Wix file based on the presence or absence of the icon file. Two elements are required for the icon in ARP, the ARPPRODUCTICON property and the Icon element.

Building the Wix file into an msi

function Build-Msi
{
    if(Test-Path "$scriptPath\$InstallerName.msi") {
        Remove-Item "$scriptPath\$InstallerName.msi" -Force
    }
    Write-Host "Building msi."
    $arg = [String]::Format('"{0}\{1}" -out "{0}\{2}"', $scriptPath, $WixFile, $WixFile.Replace("wxs", "wixobj"))
    Start-Process -FilePath "$wixDir\bin\candle.exe" -ArgumentList $arg -RedirectStandardOutput "$scriptPath\candle.log" -WindowStyle Hidden -Wait
    Get-Content "$scriptPath\candle.log"
    Remove-Item "$scriptPath\candle.log"
    Write-Host ""
    $arg = [String]::Format('"{0}\{1}" -ext WixUtilExtension.dll -out "{0}\{2}.msi"', $scriptPath, $WixFile.Replace("wxs", "wixobj"), $InstallerName)
    $process = Start-Process -FilePath "$wixDir\bin\light.exe" -ArgumentList $arg -RedirectStandardOutput "$scriptPath\light.log" -WindowStyle Hidden -Wait
    Get-Content "$scriptPath\light.log"
    Remove-Item "$scriptPath\light.log"
}

The last thing to do to make this work is build the msi.

The Clean Up

function Cleanup-Directory
{
    Write-Host "Removing files."
    # Custom Action
    if(Test-Path "$scriptPath\CustomAction.dll") {
        Remove-Item "$scriptPath\CustomAction.dll" -Force
    }
    if(Test-Path "$scriptPath\CA.dll") {
        Remove-Item "$scriptPath\CA.dll" -Force
    }
    # Modification files
    if (Test-Path -Path "$scriptPath\$workingdirectory") {
        Remove-Item -Path "$scriptPath\$workingdirectory" -Recurse -Force
    }
    if (Test-Path -Path "$scriptPath\app.tag") {
        Remove-Item -Path "$scriptPath\app.tag" -Force
    }
    
    if(Test-Path "$scriptPath\Customize.7z") {
        Remove-Item "$scriptPath\Customize.7z" -Force
    }

    # Wix Files
    $wFile = $WixFile.Replace(".wxs", "")
    if (Test-Path "$scriptPath\$wFile.wixobj") {
        Remove-Item "$scriptPath\$wFile.wixobj" -Recurse -Force
    }
    
    if (Test-Path "$scriptPath\$InstallerName.wixpdb") {
        Remove-Item "$scriptPath\$InstallerName.wixpdb" -Recurse -Force
    }
    
    if (Test-Path "$scriptPath\$InstallerName.exe") {
        Remove-Item "$scriptPath\$InstallerName.exe" -Recurse -Force
    }
}

Additionally, it helps to keep the work area clean.

The Script Process

$r = Customize-Installer
if($r -eq $false) {
    Write-Host "Failed to customize installer."
    return
}
$r = Build-CustomActions "$scriptPath\$CustomActionFile"
if($r -eq $false) {
    Write-Host "Failed to build custom actions."
    return
}
Update-WixFile
Build-Msi
Cleanup-Directory
Out-File -InputObject ([String]::Format('msiexec /i "%~dp0{0}.msi" /q /lvx* "%~dp0install.log"', $InstallerName)) -FilePath "$scriptPath\install.cmd" -Encoding ascii
Out-File -InputObject ([String]::Format('msiexec /x "%~dp0{0}.msi" /q /lvx* "%~dp0uninstall.log"', $InstallerName)) -FilePath "$scriptPath\uninstall.cmd" -Encoding ascii

Executing the script requires just a few lines.

Included are command files to install and uninstall the msi for testing purposes.

Points of Interest

The customization files, C# code, Wix, and powershell are available by copying from this article.

The icon file must be obtained from the executable or a custom icon can be used. This icon only applies to the Add/Remove Programs

The 7zip files, Wix, and Powershell are available from their respective sites.

Note that no return checking is performed on the Update-WixFile and Build-Msi functions. This is not because these methods have no chance of failing. Additional error checking can be added, but was not required at this point in script execution. Xml errors will be displayed by Powershell, and even well formed Xml may fail during the Wix build--this information will also be displayed.

In the code for this article, I chose to redirect the process stream to a file and load it to the Powershell host. At present, getting the standardoutput would require creating a process and setting processinfo--it was just easier to use Start-Process.

This project was tested with Firefox versions 21.0 and 28.0 using Group Policy Software Installation.

History

April 20, 2014: First draft of my very first article.

April 20, 2014: Updated Product.wxs section to include additional comments and added NameSpace parameters in the XmlDocument method CreateElement.

April 21, 2014: Included namespaces for the C# file. Also uploaded product.wxs, customaction.cs, and the Powershell script. While it's possible to build by copying the code, it may be easier to download these files.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)