Public/New-ADTMsiTransform.ps1

#-----------------------------------------------------------------------------
#
# MARK: New-ADTMsiTransform
#
#-----------------------------------------------------------------------------

function New-ADTMsiTransform
{
    <#
    .SYNOPSIS
        Create a transform file for an MSI database.
 
    .DESCRIPTION
        Create a transform file for an MSI database and create/modify properties in the Properties table. This function allows you to specify an existing transform to apply before making changes and to define the path for the new transform file. If the new transform file already exists, it will be deleted before creating a new one.
 
    .PARAMETER MsiPath
        Specify the path to an MSI file.
 
    .PARAMETER ApplyTransformPath
        Specify the path to a transform which should be applied to the MSI database before any new properties are created or modified.
 
    .PARAMETER NewTransformPath
        Specify the path where the new transform file with the desired properties will be created. If a transform file of the same name already exists, it will be deleted before a new one is created.
 
        Default is:
        a) If -ApplyTransformPath was specified but not -NewTransformPath, then <ApplyTransformPath>.new.mst
        b) If only -MsiPath was specified, then <MsiPath>.mst
 
    .PARAMETER TransformProperties
        Hashtable which contains calls to Set-ADTMsiProperty for configuring the desired properties which should be included in the new transform file.
 
        Example hashtable: [Hashtable]$TransformProperties = @{ 'ALLUSERS' = '1' }
 
    .INPUTS
        None
 
        You cannot pipe objects to this function.
 
    .OUTPUTS
        None
 
        This function does not generate any output.
 
    .EXAMPLE
        New-ADTMsiTransform -MsiPath 'C:\Temp\PSADTInstall.msi' -TransformProperties @{
            'ALLUSERS' = '1'
            'AgreeToLicense' = 'Yes'
            'REBOOT' = 'ReallySuppress'
            'RebootYesNo' = 'No'
            'ROOTDRIVE' = 'C:'
        }
 
        Creates a new transform file for the specified MSI with the given properties.
 
    .NOTES
        An active ADT session is NOT required to use this function.
 
        Tags: psadt
        Website: https://psappdeploytoolkit.com
        Copyright: (C) 2024 PSAppDeployToolkit Team (Sean Lillis, Dan Cunningham, Muhammad Mashwani, Mitch Richters, Dan Gough).
        License: https://opensource.org/license/lgpl-3-0
 
    .LINK
        https://psappdeploytoolkit.com
    #>


    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = "This function does not change system state.")]
    [CmdletBinding(SupportsShouldProcess = $false)]
    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateScript({
                if (!(Test-Path -Path $_ -PathType Leaf))
                {
                    $PSCmdlet.ThrowTerminatingError((New-ADTValidateScriptErrorRecord -ParameterName MsiPath -ProvidedValue $_ -ExceptionMessage 'The specified path does not exist.'))
                }
                return ![System.String]::IsNullOrWhiteSpace($_)
            })]
        [System.String]$MsiPath,

        [Parameter(Mandatory = $false)]
        [ValidateScript({
                if (!(Test-Path -Path $_ -PathType Leaf))
                {
                    $PSCmdlet.ThrowTerminatingError((New-ADTValidateScriptErrorRecord -ParameterName ApplyTransformPath -ProvidedValue $_ -ExceptionMessage 'The specified path does not exist.'))
                }
                return ![System.String]::IsNullOrWhiteSpace($_)
            })]
        [System.String]$ApplyTransformPath,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [System.String]$NewTransformPath,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.Collections.Hashtable]$TransformProperties
    )

    begin
    {
        # Make this function continue on error.
        Initialize-ADTFunction -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState -ErrorAction SilentlyContinue

        # Define properties for how the MSI database is opened.
        $msiOpenDatabaseTypes = @{
            OpenDatabaseModeReadOnly = 0
            OpenDatabaseModeTransact = 1
            ViewModifyUpdate = 2
            ViewModifyReplace = 4
            ViewModifyDelete = 6
            TransformErrorNone = 0
            TransformValidationNone = 0
            SuppressApplyTransformErrors = 63
        }
    }

    process
    {
        Write-ADTLogEntry -Message "Creating a transform file for MSI [$MsiPath]."
        try
        {
            try
            {
                # Create a second copy of the MSI database.
                $MsiParentFolder = Split-Path -Path $MsiPath -Parent
                $TempMsiPath = Join-Path -Path $MsiParentFolder -ChildPath ([System.IO.Path]::GetRandomFileName())
                Write-ADTLogEntry -Message "Copying MSI database in path [$MsiPath] to destination [$TempMsiPath]."
                $null = Copy-Item -LiteralPath $MsiPath -Destination $TempMsiPath -Force

                # Open both copies of the MSI database.
                Write-ADTLogEntry -Message "Opening the MSI database [$MsiPath] in read only mode."
                $Installer = New-Object -ComObject WindowsInstaller.Installer
                $MsiPathDatabase = Invoke-ADTObjectMethod -InputObject $Installer -MethodName OpenDatabase -ArgumentList @($MsiPath, $msiOpenDatabaseTypes.OpenDatabaseModeReadOnly)
                Write-ADTLogEntry -Message "Opening the MSI database [$TempMsiPath] in view/modify/update mode."
                $TempMsiPathDatabase = Invoke-ADTObjectMethod -InputObject $Installer -MethodName OpenDatabase -ArgumentList @($TempMsiPath, $msiOpenDatabaseTypes.ViewModifyUpdate)

                # If a MSI transform file was specified, then apply it to the temporary copy of the MSI database.
                if ($ApplyTransformPath)
                {
                    Write-ADTLogEntry -Message "Applying transform file [$ApplyTransformPath] to MSI database [$TempMsiPath]."
                    $null = Invoke-ADTObjectMethod -InputObject $TempMsiPathDatabase -MethodName ApplyTransform -ArgumentList @($ApplyTransformPath, $msiOpenDatabaseTypes.SuppressApplyTransformErrors)
                }

                # Determine the path for the new transform file that will be generated.
                if (!$NewTransformPath)
                {
                    $NewTransformFileName = if ($ApplyTransformPath)
                    {
                        [System.IO.Path]::GetFileNameWithoutExtension($ApplyTransformPath) + '.new' + [System.IO.Path]::GetExtension($ApplyTransformPath)
                    }
                    else
                    {
                        [System.IO.Path]::GetFileNameWithoutExtension($MsiPath) + '.mst'
                    }
                    $NewTransformPath = Join-Path -Path $MsiParentFolder -ChildPath $NewTransformFileName
                }

                # Set the MSI properties in the temporary copy of the MSI database.
                foreach ($property in $TransformProperties.GetEnumerator())
                {
                    Set-ADTMsiProperty -Database $TempMsiPathDatabase -PropertyName $property.Key -PropertyValue $property.Value
                }

                # Commit the new properties to the temporary copy of the MSI database
                $null = Invoke-ADTObjectMethod -InputObject $TempMsiPathDatabase -MethodName Commit

                # Reopen the temporary copy of the MSI database in read only mode.
                Write-ADTLogEntry -Message "Re-opening the MSI database [$TempMsiPath] in read only mode."
                $null = [System.Runtime.InteropServices.Marshal]::ReleaseComObject($TempMsiPathDatabase)
                $TempMsiPathDatabase = Invoke-ADTObjectMethod -InputObject $Installer -MethodName OpenDatabase -ArgumentList @($TempMsiPath, $msiOpenDatabaseTypes.OpenDatabaseModeReadOnly)

                # Delete the new transform file path if it already exists.
                if (Test-Path -LiteralPath $NewTransformPath -PathType Leaf)
                {
                    Write-ADTLogEntry -Message "A transform file of the same name already exists. Deleting transform file [$NewTransformPath]."
                    $null = Remove-Item -LiteralPath $NewTransformPath -Force
                }

                # Generate the new transform file by taking the difference between the temporary copy of the MSI database and the original MSI database.
                Write-ADTLogEntry -Message "Generating new transform file [$NewTransformPath]."
                $null = Invoke-ADTObjectMethod -InputObject $TempMsiPathDatabase -MethodName GenerateTransform -ArgumentList @($MsiPathDatabase, $NewTransformPath)
                $null = Invoke-ADTObjectMethod -InputObject $TempMsiPathDatabase -MethodName CreateTransformSummaryInfo -ArgumentList @($MsiPathDatabase, $NewTransformPath, $msiOpenDatabaseTypes.TransformErrorNone, $msiOpenDatabaseTypes.TransformValidationNone)

                if (!(Test-Path -LiteralPath $NewTransformPath -PathType Leaf))
                {
                    $naerParams = @{
                        Exception = [System.IO.IOException]::new("Failed to generate transform file in path [$NewTransformPath].")
                        Category = [System.Management.Automation.ErrorCategory]::InvalidResult
                        ErrorId = 'MsiTransformFileMissing'
                        TargetObject = $NewTransformPath
                    }
                    throw (New-ADTErrorRecord @naerParams)
                }
                Write-ADTLogEntry -Message "Successfully created new transform file in path [$NewTransformPath]."
            }
            catch
            {
                Write-Error -ErrorRecord $_
            }
        }
        catch
        {
            Invoke-ADTFunctionErrorHandler -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState -ErrorRecord $_ -LogMessage "Failed to create new transform file in path [$NewTransformPath]."
        }
        finally
        {
            # Release all COM objects to prevent file locks.
            $null = foreach ($variable in (Get-Variable -Name TempMsiPathDatabase, MsiPathDatabase, Installer -ValueOnly -ErrorAction Ignore))
            {
                try
                {
                    [System.Runtime.InteropServices.Marshal]::ReleaseComObject($variable)
                }
                catch
                {
                    $null
                }
            }

            # Delete the temporary copy of the MSI database.
            $null = Remove-Item -LiteralPath $TempMsiPath -Force -ErrorAction Ignore
        }
    }

    end
    {
        Complete-ADTFunction -Cmdlet $PSCmdlet
    }
}