Enumerating installed MSI products with PowerShell and msi.dll

If you were ever wondering how to properly read the list of installed MSI software, then two popular choices are available:

  • Querying uninstall registry keys (HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall), filtering out-non MSI entries and outputting the rest
  • Using Win32_Product from WMI

They both have their pros and cons. Querying registry is straightforward on its own, but requires awkward manipulations and accessing the data which is actually backing the Add/Remove Applet, not necessarily the Windows Installer API which uses its own complex registration. Additionally it may not work correctly with different installation context (user/machine) and you may have to query two places to get both x86 and x64 installations on a x64 system.

On the other hand, while WMI query is also pretty straightforward (see https://msdn.microsoft.com/en-us/library/aa394378(v=VS.85).aspx?f=255&MSPPError=-2147217396), it has a really big drawback. Running it is painfully slow, because Windows Installer checks integrity of each entry and triggers appropriate action (for example repair) if necessary.

So to have a solution which is both fast, reliable and without any side-effects, you may go for a third solution which is more complex, but once setup can be reused not only for querying but for a whole management of MSI-based installations. And so this blog today will be about P/invoking native msi.dll to get results returned by the true Windows Installer API.

This post may be too technical if you have never programmed in C/C++ or C#. If you just want the results without understanding how to implement them on your own, scroll to the bottom, the full content of the PowerShell script is there.

Required methods

We will certainly need the following functions:

  • MsiEnumProducts
    This method enumerates all installed or advertised products. However, it does not return anything else but ProductCode (GUID). For properties like name, version etc. we need a separate method
  • MsiGetProductInfo
    This method takes a ProductCode and returns a required property, for example name or version.

Marshalling

To P/invoke both methods in PowerShell, we need a small piece of code which creates a new static helper class that exposes managed methods calling unmanaged (P/invoked) msi.dll functions:

$pinvokeSignature = @'
using System.Runtime.InteropServices;
using System.Text;
public class MsiInterop
{
[DllImport("msi.dll", CharSet=CharSet.Unicode)]
public static extern uint MsiEnumProducts(int iProductIndex, StringBuilder lpProductBuf);

[DllImport("msi.dll", CharSet=CharSet.Unicode)]
public static extern int MsiGetProductInfo(string product, string property, [Out] StringBuilder valueBuf, ref int len);
}
'@

Add-Type -TypeDefinition $pinvokeSignature

For example, the original function signature

UINT MsiEnumProducts(
_In_  DWORD  iProductIndex,
_Out_ LPTSTR lpProductBuf
);

Has been translated to managed types using the following marshalling logic:

  • DWORD becomes int (a short alias for Int32)
  • LPTSTR becomes a out reference to StringBuilder
  • Returned type is UINT, which becomes uint (a short alias for UInt32)

Similar is done for the second method. For both I am specifying that I want the Unicode version of the method (which internally will be MsiEnumProductsW).

Enumerating products

To get the list of products, we pass a zero-based index to our wrapper, and if we get a success result (0) then the buffer should be filled with the actual ProductCode. The index i corresponds to the i-th entry available in the list, and therefore should be increased by one before requerying. The method returns 0 if everything went fine, and then and only then we may check the buffer. The documentation says that the buffer should have a lenght of 39 characters (which is the length of GUID 38 + 1 character for the sequence termination mark), and that the calls should be done from the same thread (which is not a problem for us).

We need to call the method in a loop, each time checking if its result is non-zero. Before the loop, a StringBuilder has to be created (with desired capacity) that will store the current ProductCode. After each call, we increase an index by one and use that index in the next call. All put together, the code looks like:

$stringBuilder = New-Object System.Text.StringBuilder 40;

[UInt32]$j = 0;
[UInt32]$res = 0;

while (($res = [MsiInterop]::MsiEnumProducts($j, $stringBuilder)) -eq 0)
{
$guid = $stringBuilder.ToString();
// more processing to do here
$j++;
}

Getting product properties

Now let’s get back to the second method. This one has additional challenge, that we ask for properties whose length are not statically known (as opposed to a constant 38+1 length for GUID buffer). According to the documentation

When the MsiGetProductInfo function returns, the pcchValueBuf parameter contains the length of the string stored in the buffer. The count returned does not include the terminating null character. If the buffer is not large enough, MsiGetProductInfo returns ERROR_MORE_DATA and pcchValueBuf contains the size of the string, in characters, without counting the null character.

This means that we should actually perform two concesutive calls to the same method, once passing a deliberately small buffer, reading out the required buffer length, and then making a proper call with a buffer of that size. The backing StringBuilder instance itself can be reused, given the fact that we may need to call the same code several hundreds of thousands time, we only have to take care of managing its capacity. A small function which does all of these is:

function Get-MsiProperty
{
param([string]$guid, [string]$propertyName, [System.Text.StringBuilder]$stringBuilder)
[int]$buffer = 0;
[MsiInterop]::MsiGetProductInfo($guid, $propertyName, $null, [ref]$buffer) | Out-Null;

$buffer++;

if ($buffer -gt $stringBuilder.Capacity)
{
$stringBuilder.Capacity = $buffer;
}

[MsiInterop]::MsiGetProductInfo($guid, $propertyName, $stringBuilder, [ref]$buffer) | Out-Null;
$stringBuilder.ToString(0, $buffer);
}

We should make sure the capacity of the buffer is set to that value we have just read from first call, increased by 1 (the capacity may be changed only if it is lower than required, we do not have to make downsizing). Then in the second call, a buffer of a proper length is used. The method accepts three parameters: ProductCode, name of the property and the StringBuilder which will be used as the buffer. Note that we call ToString() with a parameter determining the required length. Since we do not downsize the capacity, is is important to only get relevant part of the buffer at this point.

By the way, available property names are shown on the msdn website. According to header files msi.h, the following are available:

  • PackageName
  • Transforms
  • Language
  • ProductName
  • AssignmentType
  • InstanceType
  • AuthorizedLUAApp
  • PackageCode
  • Version
  • ProductIcon
  • InstalledProductName
  • VersionString
  • HelpLink
  • HelpTelephone
  • InstallLocation
  • InstallSource
  • InstallDate
  • Publisher
  • LocalPackage
  • URLInfoAbout
  • URLUpdateInfo
  • VersionMinor
  • VersionMajor
  • ProductID
  • RegCompany
  • RegOwner
  • Uninstallable
  • State
  • PatchType
  • LUAEnabled
  • DisplayName
  • MoreInfoURL
  • LastUsedSource
  • LastUsedType
  • MediaPackagePath
  • DiskPrompt

Bootstrapping

For a better readibility and easier extensibility (including chaining and other methods which may work on pipelined results), we will create a small container class to hold our MSI information:

class MsiProductInfo
{
[Guid]$ProductCode
[string]$ProductName
[string]$ProductVersion
[string]$ProductLanguage
[string]$PackageName
}

So to create and populate the container object, it is enough to do the following in a loop:

$c = New-Object MsiProductInfo;
$c.ProductCode = [System.Guid]::Parse($guid);
$c.ProductName = Get-MsiProperty $guid "ProductName" $tempStringBuilder;
$c.ProductVersion = Get-MsiProperty $guid "VersionString" $tempStringBuilder;
$c.PackageName = Get-MsiProperty $guid "PackageName" $tempStringBuilder;
$c.ProductLanguage = Get-MsiProperty $guid "Language" $tempStringBuilder;

The $tempStringBuilder will be a reference to a StringBuilder object which is filled with buffer data, and we pass it to avoid creating thousands of objects when enumerating the whole list.

The code

Now if we glue all of these together, we get a pretty simple PowerShell script:

function Get-MsiProperty
{
param([string]$guid, [string]$propertyName, [System.Text.StringBuilder]$stringBuilder)
[int]$buffer = 0;
[MsiInterop]::MsiGetProductInfo($guid, $propertyName, $null,  [ref]$buffer) | Out-Null;

$buffer++;

if ($buffer -gt $stringBuilder.Capacity)
{
$stringBuilder.Capacity = $buffer;
}

[MsiInterop]::MsiGetProductInfo($guid, $propertyName, $stringBuilder, [ref]$buffer) | Out-Null;
$stringBuilder.ToString(0, $buffer);
}

class MsiProductInfo
{
[Guid]$ProductCode
[string]$ProductName
[string]$ProductVersion
[string]$ProductLanguage
[string]$PackageName
}

$pinvokeSignature = @'
using System.Runtime.InteropServices;
using System.Text;
public class MsiInterop
{
[DllImport("msi.dll", CharSet=CharSet.Unicode)]
public static extern uint MsiEnumProducts(int iProductIndex, StringBuilder lpProductBuf);

[DllImport("msi.dll", CharSet=CharSet.Unicode)]
public static extern int MsiGetProductInfo(string product, string property, [Out] StringBuilder valueBuf, ref int len);
}
'@

Add-Type -TypeDefinition $pinvokeSignature
$stringBuilder = New-Object System.Text.StringBuilder 40;
$tempStringBuilder = New-Object System.Text.StringBuilder 0;

[UInt32]$j = 0;
[UInt32]$res = 0;

while (($res = [MsiInterop]::MsiEnumProducts($j, $stringBuilder)) -eq 0)
{
$guid = $stringBuilder.ToString();
$c = New-Object MsiProductInfo;
$c.ProductCode = [System.Guid]::Parse($guid);

$c.ProductName = Get-MsiProperty $guid "ProductName" $tempStringBuilder;
$c.ProductVersion = Get-MsiProperty $guid "VersionString" $tempStringBuilder;
$c.PackageName = Get-MsiProperty $guid "PackageName" $tempStringBuilder;
$c.ProductLanguage = Get-MsiProperty $guid "Language" $tempStringBuilder;

Write-Output ($c);
$j++;
}

Let’s try to run it to display first 10 products starting with Microsoft, and format the results in a table. Save the content of the script in a file (for example test1.ps1) and run it, adding a chain of piped results:

.\test.ps1 | where { $_.ProductName -like "Microsoft*" } | Select-Object -Property ProductName, ProductVersion | select -first 10 |ft

The result on my machine is:

Seems to be just fine, and it tooks a fraction of second.

What’s next

I am going to expand this concept to a PowerShell module which can be used to query, install, uninstall and repair MSI packages, and post it later to my GitHub.

2 comments

Thanks for sharing!

Is there a way to leverage msi.dll to retrieve a products installed features/sub components?
This is seen by going through Control Panel / Add Remove Programs / Modify / Vieweing sub components and features

Marcin Otorowski

Sure, use a similar approach and wrap MsiEnumFeatures (having a ProductCode, this returns all belonging features). Additionally, use MsiQueryFeatureState to determine whether the feature is installed or advertised.

Useful links:

Leave a Reply