Carbon.Windows.Installer.psm1
# Copyright WebMD Health Services # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License #Requires -Version 5.1 Set-StrictMode -Version 'Latest' # Functions should use $moduleRoot as the relative root from which to find # things. A published module has its function appended to this file, while a # module in development has its functions in the Functions directory. $moduleRoot = $PSScriptRoot # Store each of your module's functions in its own file in the Functions # directory. On the build server, your module's functions will be appended to # this file, so only dot-source files that exist on the file system. This allows # developers to work on a module without having to build it first. Grab all the # functions that are in their own files. $functionsPath = Join-Path -Path $moduleRoot -ChildPath 'Functions\*.ps1' if( (Test-Path -Path $functionsPath) ) { foreach( $functionPath in (Get-Item $functionsPath) ) { . $functionPath.FullName } } function Get-InstalledProgram { <# .SYNOPSIS Gets information about the programs installed on the computer. .DESCRIPTION The `Get-CInstalledProgram` function is the PowerShell equivalent of the Programs and Features/Apps and Features settings UI. It inspects the registry to determine what programs are installed. When running as an administrator, it returns programs installed for *all* users, not just the current user. The function looks in the following registry keys for install information: * HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Uninstall * HKEY_LOCAL_MACHINE\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall * HKEY_USERS\*\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' A key is skipped if: * it doesn't have a `DisplayName` value. * it has a `ParentKeyName` value. * it has a `SystemComponent` value and its value is `1`. `Get-CInstalledProgram` tries its best to get accurate data. The following properties either aren't stored consistently, is in strange formats, can't be parsed, etc. * The `ProductCode` property is set to `[Guid]::Empty` if the software doesn't have a product code. * The `User` property will only be set for software installed for specific users. For global software, the `User` property will be `[String]::Empty`. * The `InstallDate` property is set to `[DateTime]::MinValue` if the install date can't be determined. * The `Version` property is `$null` if the version can't be parsed. .EXAMPLE Get-CInstalledProgram | Sort-Object 'DisplayName' Demonstrates how to get a list of all the installed programs, similar to what the Programs and Features settings UI shows. The returned objects are not sorted, so you'll usually want to pipe the output to `Sort-Object`. .EXAMPLE Get-CInstalledProgram -Name 'Google Chrome' Demonstrates how to get a specific program. If the specific program isn't found, `$null` is returned. .EXAMPLE Get-CInstalledProgram -Name 'Microsoft*' Demonstrates that you can use wildcards to search for programs. #> [CmdletBinding()] param( # The name of a specific program to get. Wildcards supported. [String] $Name ) Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState $msgPrefix = "[$($MyInvocation.MyCommand.Name)] " Write-Debug "$($msgPrefix)+" function Get-KeyStringValue { [CmdletBinding()] param( [Parameter(Mandatory)] [Microsoft.Win32.RegistryKey] $Key, [Parameter(Mandatory)] [String] $ValueName ) $value = $Key.GetValue($ValueName) if( $null -eq $value ) { return '' } return $value.ToString() } function Get-KeyIntValue { [CmdletBinding()] param( [Parameter(Mandatory)] [Microsoft.Win32.RegistryKey] $Key, [Parameter(Mandatory)] [String] $ValueName ) [int] $value = 0 $rawValue = $Key.GetValue($ValueName) if( [int]::TryParse([Convert]::ToString($rawValue), [ref]$value) ) { return $value } return 0 } if( -not (Test-Path -Path 'hku:\') ) { $null = New-PSDrive -Name 'HKU' -PSProvider Registry -Root 'HKEY_USERS' -WhatIf:$false } $foundPrograms = @() & { Get-ChildItem -Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall' Get-ChildItem -Path 'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' Get-ChildItem -Path 'hku:\*\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction Ignore } | Where-Object { [Microsoft.Win32.RegistryKey] $key = $_ $valueNames = $key.GetValueNames() if( $valueNames -notcontains 'DisplayName' ) { Write-Debug ('Skipping {0}: DisplayName not found.' -f $key.Name) return $false } return $true } | Where-Object { [Microsoft.Win32.RegistryKey] $key = $_ if( $Name ) { return $key.GetValue('DisplayName') -like $Name } return $true } | Where-Object { [Microsoft.Win32.RegistryKey] $key = $_ $valueNames = $key.GetValueNames() if( $valueNames -contains 'ParentKeyName' ) { $displayName = $key.GetValue('DisplayName') Write-Debug ('Skipping {0} ({1}): found ParentKeyName property.' -f $displayName,$key.Name) return $false } return $true } | Where-Object { [Microsoft.Win32.RegistryKey] $key = $_ $valueNames = $key.GetValueNames() if( $valueNames -contains 'SystemComponent' -and $key.GetValue('SystemComponent') -eq 1 ) { $displayName = $key.GetValue('DisplayName') Write-Debug ('Skipping {0} ({1}): SystemComponent property is 1.' -f $displayName,$key.Name) return $false } return $true } | ForEach-Object { $key = [Microsoft.Win32.RegistryKey] $_ $info = [pscustomobject]@{ Comments = Get-KeyStringValue -Key $key -ValueName 'Comments'; Contact = Get-KeyStringValue -Key $key -ValueName 'Contact'; DisplayName = Get-KeyStringValue -Key $key -ValueName 'DisplayName'; DisplayVersion = Get-KeyStringValue -Key $key -ValueName 'DisplayVersion'; EstimatedSize = Get-KeyIntValue -Key $key -ValueName 'EstimatedSize'; HelpLink = Get-KeyStringValue -Key $key -ValueName 'HelpLink'; HelpTelephone = Get-KeyStringValue -Key $key -ValueName 'HelpTelephone'; InstallDate = $null; InstallLocation = Get-KeyStringValue -Key $key -ValueName 'InstallLocation'; InstallSource = Get-KeyStringValue -Key $key -ValueName 'InstallSource'; Key = $key; Language = Get-KeyIntValue -Key $key -ValueName 'Language'; ModifyPath = Get-KeyStringValue -Key $key -ValueName 'ModifyPath'; Path = Get-KeyStringValue -Key $key -ValueName 'Path'; ProductCode = $null; Publisher = Get-KeyStringValue -Key $key -ValueName 'Publisher'; Readme = Get-KeyStringValue -Key $key -ValueName 'Readme'; Size = Get-KeyStringValue -Key $key -ValueName 'Size'; UninstallString = Get-KeyStringValue -Key $key -ValueName 'UninstallString'; UrlInfoAbout = Get-KeyStringValue -Key $key -ValueName 'URLInfoAbout'; UrlUpdateInfo = Get-KeyStringValue -Key $key -ValueName 'URLUpdateInfo'; User = $null; Version = $null; VersionMajor = Get-KeyIntValue -Key $key -ValueName 'VersionMajor'; VersionMinor = Get-KeyIntValue -Key $key -ValueName 'VersionMinor'; WindowsInstaller = $false; } $info | Add-Member -Name 'Name' -MemberType AliasProperty -Value 'DisplayName' $installDateValue = Get-KeyStringValue -Key $key -ValueName 'InstallDate' [DateTime] $installDate = [DateTime]::MinValue if( [DateTime]::TryParse($installDateValue, [ref]$installDate) -or [DateTime]::TryParseExact($installDateValue, 'yyyyMMdd', [cultureinfo]::CurrentCulture, [Globalization.DateTimeStyles]::None, [ref]$installDate) ) { $info.InstallDate = $installDate } [Guid]$productCode = [Guid]::Empty $keyName = [IO.Path]::GetFileName($key.Name) if( [Guid]::TryParse($keyName, [ref]$productCode) ) { $info.ProductCode = $productCode } if( $key.Name -match '^HKEY_USERS\\([^\\]+)\\') { $info.User = $Matches[1] $numErrors = $Global:Error.Count try { $sid = [Security.Principal.SecurityIdentifier]::New($user) if( $sid.IsValidTargetType([Security.Principal.NTAccount])) { $ntAccount = $sid.Translate([Security.Principal.NTAccount]) if( $ntAccount ) { $info.User = $ntAccount.Value } } } catch { for( $idx = $numErrors; $idx -lt $Global:Error.Count; ++$idx ) { $Global:Error.RemoveAt(0) } } } $intVersion = Get-KeyIntValue -Key $key -ValueName 'Version' if( $intVersion ) { $major = $intVersion -shr 24 # first 8 bits are major version number $minor = ($intVersion -band 0x00ff0000) -shr 16 # bits 9 - 16 are the minor version number $build = $intVersion -band 0x0000ffff # last 16 bits are the build number $rawVersion = "$($major).$($minor).$($build)" } else { $rawVersion = Get-KeyStringValue -Key $key -ValueName 'Version' } [Version]$version = $null if( [Version]::TryParse($rawVersion, [ref]$version) ) { $info.Version = $version } $windowsInstallerValue = Get-KeyIntValue -Key $key -ValueName 'WindowsInstaller' $info.WindowsInstaller = ($windowsInstallerValue -gt 0) $info.pstypenames.Insert(0, 'Carbon.Windows.Installer.ProgramInfo') $info | Write-Output } | Tee-Object -Variable 'foundPrograms' if( $Name -and -not [wildcardpattern]::ContainsWildcardCharacters($Name) -and -not $foundPrograms ) { $msg = "Program ""$($Name)"" is not installed." Write-Error -Message $msg -ErrorAction $ErrorActionPreference } Write-Debug "$($msgPrefix)-" } function Get-Msi { <# .SYNOPSIS Gets information from an MSI. .DESCRIPTION The `Get-CMsi` function uses the `WindowsInstaller.Installer` COM API to read properties from an MSI file. Pass the path to the MSI file or files to the `Path`, or pipe file objects to `Get-CMsi`. An object is returned that exposes the internal metadata of the MSI file: * `ProductName`, the value of the MSI's `ProductName` property. * `ProductCode`, the value of the MSI's `ProductCode` property as a `Guid`. * `ProduceLanguage`, the value of the MSI's `ProduceLanguage` property, as an integer. * `Manufacturer`, the value of the MSI's `Manufacturer` property. * `ProductVersion`, the value of the MSI's `ProductVersion` property, as a `Version` * `Path`, the path of the MSI file. * `TableNames`: the names of all the tables in the MSI's internal database * `Tables`: records from tables in the MSI's internal database The function can also return the records from the MSI's internal database tables. Tables included are returned as properties on the return object's `Tables` property. It is expensive to read all the records in all the database tables, so by default, `Get-CMsi` only returns the records from the `Property` and `Feature` tables. The `Property` table contains program metadata like product name, product code, etc. The `Feature` table contains the feature names of any optional features you might want to install. When installing, these feature names would get passed to the `msiexec` install command as a comma-separated list as the `ADDLOCAL` property, e.g. ADDLOCAL="Feature1,Feature2". To return the records from additional tables, pass the table name or names to the `IncludeTable` parameter. Wildcards supported. Records from the `Property` and `Feature` tables are *always* returned. The `TableNames` property on returned objects is the list of all tables in the MSI's database. Because this function uses the Windows Installer COM API, it requires Windows PowerShell 5.1 or PowerShell 7.1+ on Windows. .LINK https://msdn.microsoft.com/en-us/library/aa370905.aspx .EXAMPLE Get-CMsi -Path MyCool.msi Demonstrates how to get information from an MSI file. .EXAMPLE Get-ChildItem *.msi -Recurse | Get-CMsi Demonstrates that you can pipe file info objects into `Get-CMsi`. .EXAMPLE Get-CMsi -Path example.msi -IncludeTable 'Component' Demonstrates how to return records from one of an MSI's internal database tables by passing the table name to the `IncludeTable` parameter. Wildcards supported. .EXAMPLE Get-CMsi -Url 'https://example.com/example.msi' Demonstrates how to download an MSI file to read its metadata. The file is saved to the current user's temp directory with the same name as the file name in the URL. The return object will have the path to the MSI file. .EXAMPLE Get-CMsi -Url 'https://example.com/example.msi' -OutputPath '~\Downloads' Demonstrates how to download an MSI file and save it to a directory using the name of the file from the download URL as the filename. In this case, the file will be saved to `~\Downloads\example.msi`. The return object's `Path` property will contain the full path to the downloaded MSI file. .EXAMPLE Get-CMsi -Url 'https://example.com/example.msi' -OutputPath '~\Downloads\new_example.msi' Demonstrates how to use a custom file name for the downloaded file by making `OutputPath` be a path to an item that doesn't exist or the path to an existing file. #> [CmdletBinding(DefaultParameterSetName='ByPath')] param( # Path to the MSI file whose information to retrieve. Wildcards supported. [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName='ByPath', Position=0)] [Alias('FullName')] [String[]] $Path, # The URL to the MSI file to get. The file will be downloaded to the current user's temp directory. Use the # `OutputPath` parameter to save it somewhere else or use the `Path` property on the returned object to copy the # downloaded file somewhere else. [Parameter(Mandatory, ParameterSetName='ByUrl')] [Uri] $Url, # The path where the downloaded MSI file should be saved. By default, the file is downloaded to the current # user's temp directory. If `OutputPath` is a directory, the file will be saved to that directory with the # same name as file's name in the `Url`. Otherwise, `OutputPath` is considered to be the path to the file where # the downloaded MSI should be saved. Any existing file will be overwritten. [Parameter(ParameterSetName='ByUrl')] [String] $OutputPath, # Extra tables to read from the MSI and return. By default, only the installer's Property and Feature tables # are returned. Wildcards supported. See https://docs.microsoft.com/en-us/windows/win32/msi/database-tables for # the list of all MSI database tables. [String[]] $IncludeTable ) begin { Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState $timer = [Diagnostics.Stopwatch]::StartNew() $lastWrite = [Diagnostics.Stopwatch]::New() function Debug { param( [String] $Message ) $msg = "[$([Math]::Round($timer.Elapsed.TotalMinutes))m $($timer.Elapsed.Seconds.toString('00'))s " + "$($timer.Elapsed.Milliseconds.ToString('000'))ms] " + "[$([Math]::Round($lastWrite.Elapsed.TotalSeconds).ToString('00'))s " + "$($lastWrite.Elapsed.Milliseconds.ToString('000'))ms] $($Message)" Microsoft.PowerShell.Utility\Write-Debug -Message $msg $lastWrite.Restart() } if( $PSCmdlet.ParameterSetName -eq 'ByUrl' ) { $msiFileName = $Url.Segments[-1] if( $OutputPath ) { if( (Test-Path -Path $OutputPath -PathType Container) ) { $OutputPath = Join-Path -Path $OutputPath -ChildPath $msiFileName } } else { $OutputPath = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath $msiFileName } $ProgressPreference = [Management.Automation.ActionPreference]::SilentlyContinue Invoke-WebRequest -Uri $Url -OutFile $OutputPath | Out-Null Get-Item -LiteralPath $OutputPath | Get-Msi -IncludeTable $IncludeTable return } $IncludeTable = & { 'Feature' 'Property' $IncludeTable | Write-Output } | Select-Object -Unique } process { if( $PSCmdlet.ParameterSetName -eq 'ByUrl' ) { return } $Path = Resolve-Path -Path $Path | Select-Object -ExpandProperty 'ProviderPath' if( -not $Path ) { return } foreach( $msiPath in $Path ) { $info = [pscustomobject]@{ Manufacturer = $null; Path = $null; ProductCode = $null; ProductLanguage = $null; ProductName = $null; ProductVersion = $null; TableNames = @(); Tables = [pscustomobject]@{}; } $info | Add-Member -Name 'Name' -MemberType AliasProperty -Value 'ProductName' -PassThru | Add-Member -Name 'Property' -MemberType 'ScriptProperty' -Value { $this.Tables.Property } -PassThru | Add-Member -Name 'GetPropertyValue' -MemberType 'ScriptMethod' -Value { param( [Parameter(Mandatory)] [String] $Name ) if( -not $this.Property ) { return } $this.Property | Where-Object 'Property' -eq $Name | Select-Object -ExpandProperty 'Value' } $installer = New-Object -ComObject 'WindowsInstaller.Installer' $database = $null; Debug "[$($PSCmdlet.MyInvocation.MyCommand.Name)] Opening ""$($msiPath)""." try { $database = $installer.OpenDatabase($msiPath, 0) if( -not $database ) { $msg = "$($msiPath): failed to open database." Write-Error -Message $msg -ErrorAction $ErrorActionPreference continue } } catch { $msg = "Exception opening MSI database in file ""$($msiPath)"": $($_)" Write-Error -Message $msg -ErrorAction $ErrorActionPreference continue } try { Debug ' _Tables' $tables = Read-MsiTable -Database $database -Name '_Tables' -MsiPath $msiPath $info.TableNames = $tables | Select-Object -ExpandProperty 'Name' foreach( $tableName in $info.TableNames ) { $info.Tables | Add-Member -Name $tableName -MemberType NoteProperty -Value @() if( $IncludeTable -and -not ($IncludeTable | Where-Object { $tableName -like $_ }) ) { Debug " ! $($tableName)" continue } Debug " $($tableName)" $info.Tables.$tableName = Read-MsiTable -Database $database -Name $tableName -MsiPath $msiPath } [Guid] $productCode = [Guid]::Empty [String] $rawProductCode = $info.GetPropertyValue('ProductCode') if( [Guid]::TryParse($rawProductCode, [ref]$productCode) ) { $info.ProductCode = $productCode } [int] $langID = 0 [String] $rawLangID = $info.GetPropertyValue('ProductLanguage') if( [int]::TryParse($rawLangID, [ref]$langID) ) { $info.ProductLanguage = $langID } $info.Path = $msiPath; $info.Manufacturer = $info.GetPropertyValue('Manufacturer') $info.ProductName = $info.GetPropertyValue('ProductName') $info.ProductVersion = $info.GetPropertyValue('ProductVersion') [void]$info.pstypenames.Insert(0, 'Carbon.Windows.Installer.MsiInfo') } finally { if( $database ) { [void][Runtime.InteropServices.Marshal]::ReleaseCOMObject($database) } } $info | Write-Output } } end { if( $PSCmdlet.ParameterSetName -eq 'ByUrl' ) { return } [GC]::Collect() } } function Install-Msi { <# .SYNOPSIS Installs an MSI. .DESCRIPTION `Install-CMsi` installs software from an MSI file, without displaying any user interface. Pass the path to the MSI to install to the `Path` property. The `Install-CMsi` function reads the product name code from the MSI file, and does nothing if a program with that product code is already installed. Otherwise, the function runs the installer in quiet mode (i.e. no UI is visible) with `msiexec`. All the program's features will be installed with their default values. You can control the installer's display mode with the `DisplayMode` parameter: set it to `Passive` to show a UI with just a progress bar, or `Full` to show the UI as-if the user double-clicked the MSI file. `Install-CMsi` can also download an MSI and install it. Pass the URL to the MSI file to the `Url` parameter. Pass the MSI file's SHA256 checksum to the `Checksum` parameter. (Use PowerShell's `Get-FileHash` cmdlet to get the checksum.) In order avoid downloading an MSI that is already installed, you must also pass the MSI's product name to the `ProductName` parameter and its product code to the `ProductCode` parameter. Use this module's `Get-CMsi` function to get an MSI file's product metadata. If the install fails, the function writes an error and leaves a debug-level log file in the current user's temp directory. The log file name begins with the name of the MSI file name, then has a `.`, then a random file name (e.g. `xxxxxxxx.xxx`), then ends with a `.log` extension. You can customize the location of the log file with the `LogPath` parameter. You can customize logging options with the `LogOption` parameter. Default log options are `!*vx` (log all messages, all verbose message, all debug messages, and flush each line to the log file as it is written). If you want to install the MSI even if it is already installed, use the `-Force` switch. For downloaded MSI files, this will cause the file to be downloaded every time `Install-CMsi` is run. You can pass additional arguments to `msiexec.exe` when installing the MSI file with the `ArgumentList` parameter. Requires Windows PowerShell 5.1 or PowerShell 7.1+ on Windows. .EXAMPLE Install-CMsi -Path '.\Path\to\installer.msi' Demonstrates how to install a program with its MSI. .EXAMPLE Get-ChildItem *.msi | Install-CMsi Demonstrates that you can pipe file objects to `Install-CMsi`. .EXAMPLE Install-CMsi -Path 'installer.msi' -Force Demonstrates how to re-install an MSI file even if it's already installed. .EXAMPLE Install-CMsi -Url 'https://example.com/installer.msi' -Checksum '63c34def9153659a825757ec475a629dff5be93d0f019f1385d07a22a1df7cde' -ProductName 'Carbon Test Installer' -ProductCode 'e1724abc-a8d6-4d88-bbed-2e077c9ae6d2' Demonstrates that `Install-CMsi` can download and install MSI files. #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName='ByPath')] param( # The path to the installer to run. Wildcards supported. [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName='ByPath')] [Alias('FullName')] [string[]] $Path, # The URL to an installer to download and install. Requires the `Checksum` parameter to ensure the correct file # was downloaded. [Parameter(Mandatory, ParameterSetName='ByUrl')] [Uri] $Url, # Used with the `Url` parameter. The SHA256 hash the downloaded installer should have. Case-insensitive. [Parameter(Mandatory, ParameterSetName='ByUrl')] [String] $Checksum, # The product name of the downloaded MSI. Used to determine if the program is installed or not. Used with the # `Url` parameter. The installer is only downloaded if the product is not installed or the `-Force` switch is # used. Use the `Get-CMsi` function to get the product code of an MSI. [Parameter(Mandatory, ParameterSetName='ByUrl')] [String] $ProductName, # The product code of the downloaded MSI. Used to determine if the program is installed or not. Used with the # `Url` parameter. The installer is only downloaded if the product is not installed or the `-Force` switch is # used. Use the `Get-CMsi` function to get the product code from an MSI. [Parameter(Mandatory, ParameterSetName='ByUrl')] [Guid] $ProductCode, # Install the MSI even if it has already been installed. Will cause a repair/reinstall to run. [Switch] $Force, # Controls how the MSI UI is displayed to the user. The default is `Quiet`, meaning no UI is shown. Valid values # are `Passive`, a UI showing a progress bar is shown, or `Full`, the UI is displayed to the user as if they # double-clicked the MSI file. [ValidateSet('Quiet', 'Passive', 'Full')] [String] $DisplayMode = 'Quiet', # The logging options to use. The default is to log all information (`*`), log verbose output (`v`), log exta # debugging information (`x`), and to flush each line to the log (`!`). [String] $LogOption = '!*vx', # The path to the log file. The default is to log to a file in the temporary directory and delete the log file # unless the installation fails. The default log file name begins with the name of the MSI file name, then # has a `.`, then a random file name (e.g. `xxxxxxxx.xxx`), then ends with a `.log` extension. [String] $LogPath, # Extra arguments to pass to the installer. These are passed directly after the install option and path to the # MSI file. Do not pass any install option, display option, or logging option in this parameter. Instead, use # the `DisplayMode` parameter to control display options, the `LogOption` parameter to control logging options, # and the `LogPath` parameter to control where the installation log file is saved. [String[]] $ArgumentList ) process { function Test-ProgramInstalled { [CmdletBinding()] param( [Parameter(Mandatory)] [String] $Name, [Guid] $Code ) $DebugPreference = 'SilentlyContinue' $installInfo = Get-CInstalledProgram -Name $Name -ErrorAction Ignore if( -not $installInfo ) { return $false } $installed = $installInfo.ProductCode -eq $Code if( $installed ) { $msg = "$($msgPrefix)[$($installInfo.DisplayName)] Installed $($installInfo.InstallDate)." Write-Verbose -Message $msg return $true } return $false } function Invoke-Msiexec { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [Object] $Msi, [String] $From ) process { $target = $Msi.ProductName if( $Msi.Manufacturer ) { $target = "$($Msi.Manufacturer)'s ""$($target)""" } if( $Msi.ProductVersion ) { $target = "$($target) $($Msi.ProductVersion)" } $deleteLog = $false if( -not $LogPath ) { $LogPath = "$($Msi.Path | Split-Path -Leaf).$([IO.Path]::GetRandomFileName()).log" $LogPath = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath $LogPath $deleteLog = $true } $logParentDir = $LogPath | Split-Path -Parent if( $logParentDir -and -not (Test-Path -Path $logParentDir) ) { New-Item -Path $logParentDir -Force -ItemType 'Directory' | Out-Null } if( -not (Test-Path -Path $LogPath) ) { New-Item -Path $LogPath -ItemType 'File' | Out-Null } if( -not $From ) { $From = $Msi.Path } $displayOptions = @{ 'Quiet' = '/quiet'; 'Passive' = '/passive'; 'Full' = ''; } $ArgumentList = & { '/i' # Must surround with double quotes. Single quotes are interpreted as part of the path. """$($msi.Path)""" $displayOptions[$DisplayMode] $ArgumentList | Write-Output "/l$($LogOption)", # Must surround with double quotes. Single quotes are interpreted as part of the path. """$($LogPath)""" } | Where-Object { $_ } $action = 'Install' $verb = 'Installing' if( $Force ) { $action = 'Repair' $verb = 'Repairing' } if( $PSCmdlet.ShouldProcess( $From, $action ) ) { Write-Information -Message "$($msgPrefix)$($verb) $($target) from ""$($From)""" Write-Debug -Message "msiexec.exe $($ArgumentList -join ' ')" $msiProcess = Start-Process -FilePath 'msiexec.exe' ` -ArgumentList $ArgumentList ` -NoNewWindow ` -PassThru ` -Wait if( $null -ne $msiProcess.ExitCode -and $msiProcess.ExitCode -ne 0 ) { $msg = "$($target) $($action.ToLowerInvariant()) failed. Installer ""$($msi.Path)"" returned " + "exit code $($msiProcess.ExitCode). See the installation log file ""$($LogPath)"" for " + 'more information and https://docs.microsoft.com/en-us/windows/win32/msi/error-codes ' + 'for a description of the exit code.' Write-Error $msg -ErrorAction $ErrorActionPreference return } } if( $deleteLog -and (Test-Path -Path $LogPath) ) { Remove-Item -Path $LogPath -ErrorAction Ignore } } } Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState $msgPrefix = "[$($MyInvocation.MyCommand.Name)] " Write-Debug "$($msgPrefix)+" if( $Path ) { Get-CMsi -Path $Path | Where-Object { $msiInfo = $_ $installed = Test-ProgramInstalled -Name $msiInfo.ProductName -Code $msiInfo.ProductCode if( $installed ) { return $Force.IsPresent } # Not installed so $Force has no meaning. $Force also controls whether the action is "Install" or # "Repair". We're always installing if not installed so set $Force to $false. $Force = $false return $true } | Invoke-Msiexec return } # If the program we are going to download is already installed, don't re-download it. $installed = Test-ProgramInstalled -Name $ProductName -Code $ProductCode if( $installed -and -not $Force ) { return } # Make sure action is properly reported as Install or Repair. if( -not $installed ) { $Force = $false } $msi = Get-Msi -Url $Url if( -not $msi ) { return } $actualChecksum = Get-FileHash -LiteralPath $msi.Path if( $actualChecksum.Hash -ne $Checksum ) { $msg = "Install failed: checksum ""$($actualChecksum.Hash.ToLowerInvariant())"" for installer " + "downloaded from ""$($Url)"" does not match expected checksum ""$($Checksum.ToLowerInvariant())""." Write-Error -Message $msg -ErrorAction $ErrorActionPreference return } $msi | Invoke-Msiexec -From $Url Write-Debug "$($msgPrefix)-" } } function Read-MsiTable { [CmdletBinding()] param( [Parameter(Mandatory)] [Object] $Database, [Parameter(Mandatory)] [String] $Name, [Parameter(Mandatory)] [String] $MsiPath ) Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState $numErrors = $Global:Error.Count $view = $null try { $view = $Database.OpenView("select * from ``$($Name)``") if( -not $view ) { $msg = "$($msiPath): failed to query $($Name) table." Write-Error -Message $msg -ErrorAction $ErrorActionPreference return } } catch { for( $idx = $numErrors; $idx -lt $Global:Error.Count; ++$idx ) { $Global:Error.RemoveAt(0) } $msg = "Exception opening table ""$($Name)"" from MSI ""$($MsiPath)"": $($_)" Write-Debug -Message $msg return } $numErrors = $Global:Error.Count try { [void]$view.Execute() $colIdxToName = [Collections.ArrayList]::New() for( $idx = 0; $idx -le $view.ColumnInfo(0).FieldCount(); ++$idx ) { $numErrors = $Global:Error.Count $columnName = $view.ColumnInfo(0).StringData($idx) Write-Debug " [$($idx)] $columnName" [void]$colIdxToName.Add($columnName) } $msiRecord = $view.Fetch() while( $msiRecord ) { $record = [pscustomobject]@{}; Write-Debug ' +-----+' for( $idx = 0; $idx -lt $colIdxToName.Count; ++$idx ) { $fieldName = $colIdxToName[$idx] if( -not $fieldName ) { continue } $fieldValue = $msiRecord.StringData($idx) Write-Debug " [$($idx)][$($fieldName)] $($fieldValue)" $record | Add-Member -Name $fieldName -MemberType NoteProperty -Value $fieldValue } $record.pstypenames.Insert(0, "Carbon.Windows.Installer.Records.$($Name)") $record | Write-Output $msiRecord = $view.Fetch() } } catch { $msg = "Exception reading ""$($Name)"" table record data from MSI ""$($MsiPath)"": " + "$($_)" Write-Debug -Message $msg } finally { if( $view ) { [void]$view.Close() } } } function Use-CallerPreference { <# .SYNOPSIS Sets the PowerShell preference variables in a module's function based on the callers preferences. .DESCRIPTION Script module functions do not automatically inherit their caller's variables, including preferences set by common parameters. This means if you call a script with switches like `-Verbose` or `-WhatIf`, those that parameter don't get passed into any function that belongs to a module. When used in a module function, `Use-CallerPreference` will grab the value of these common parameters used by the function's caller: * ErrorAction * Debug * Confirm * InformationAction * Verbose * WarningAction * WhatIf This function should be used in a module's function to grab the caller's preference variables so the caller doesn't have to explicitly pass common parameters to the module function. This function is adapted from the [`Get-CallerPreference` function written by David Wyatt](https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d). There is currently a [bug in PowerShell](https://connect.microsoft.com/PowerShell/Feedback/Details/763621) that causes an error when `ErrorAction` is implicitly set to `Ignore`. If you use this function, you'll need to add explicit `-ErrorAction $ErrorActionPreference` to every `Write-Error` call. Please vote up this issue so it can get fixed. .LINK about_Preference_Variables .LINK about_CommonParameters .LINK https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d .LINK http://powershell.org/wp/2014/01/13/getting-your-script-module-functions-to-inherit-preference-variables-from-the-caller/ .EXAMPLE Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Demonstrates how to set the caller's common parameter preference variables in a module function. #> [CmdletBinding()] param ( [Parameter(Mandatory)] #[Management.Automation.PSScriptCmdlet] # The module function's `$PSCmdlet` object. Requires the function be decorated with the `[CmdletBinding()]` # attribute. $Cmdlet, [Parameter(Mandatory)] # The module function's `$ExecutionContext.SessionState` object. Requires the function be decorated with the # `[CmdletBinding()]` attribute. # # Used to set variables in its callers' scope, even if that caller is in a different script module. [Management.Automation.SessionState]$SessionState ) Set-StrictMode -Version 'Latest' # List of preference variables taken from the about_Preference_Variables and their common parameter name (taken # from about_CommonParameters). $commonPreferences = @{ 'ErrorActionPreference' = 'ErrorAction'; 'DebugPreference' = 'Debug'; 'ConfirmPreference' = 'Confirm'; 'InformationPreference' = 'InformationAction'; 'VerbosePreference' = 'Verbose'; 'WarningPreference' = 'WarningAction'; 'WhatIfPreference' = 'WhatIf'; } foreach( $prefName in $commonPreferences.Keys ) { $parameterName = $commonPreferences[$prefName] # Don't do anything if the parameter was passed in. if( $Cmdlet.MyInvocation.BoundParameters.ContainsKey($parameterName) ) { continue } $variable = $Cmdlet.SessionState.PSVariable.Get($prefName) # Don't do anything if caller didn't use a common parameter. if( -not $variable ) { continue } if( $SessionState -eq $ExecutionContext.SessionState ) { Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false } else { $SessionState.PSVariable.Set($variable.Name, $variable.Value) } } } |