Functions/Update-InstallerCache.ps1
<#
.SYNOPSIS Populates the shared MSI/MSP cache from local machines using merged report CSVs. .DESCRIPTION Intended to be deployed via MECM (or similar) after Merge-InstallerCacheReports has created the following: - MSIProductCodes.csv - MSPPatchCodes.csv This script: 1. Reads the merged MSI/MSP summary CSVs from \\<Server>\<Share>\FixMissingMSI\Reports. 2. Identifies any "unregistered" MSI files present in C:\Windows\Installer that are not listed as LocalPackage for any installed product, extracts ProductCode/PackageCode, and uploads them. 3. For each MSI in the report, queries local registry/Windows Installer to locate the cached MSI and uploads it to the shared cache (if not already present). 4. Repeats similar logic for MSP patch files. > Note: Only files referenced in the merged CSVs are considered for upload to the cache. > This minimizes noise and ensures we only collect files that were reported missing elsewhere. .PARAMETER FileSharePath UNC path to the share root hosting the FixMissingMSI app folder. Example: \\FS01\Software The script expects: \\<Server>\<Share>\FixMissingMSI\Reports \\<Server>\<Share>\FixMissingMSI\Cache\{Products,Patches} .EXAMPLE PS> Update-InstallerCache -FileSharePath \\FS01\Software Uploads locally cached MSI/MSP files to \\FS01\Software\FixMissingMSI\Cache based on the merged reports in \\FS01\Software\FixMissingMSI\Reports. .NOTES Author: Joey Eckelbarger Credits: The Compress-GUID implementation is adapted from Microsoft’s "Windows Program Install and Uninstall Troubleshooter" logic and reworked for this script. Requires: - PowerShell 5.1+ - Read access to \\<Server>\<Share>\FixMissingMSI\Reports - Write access to \\<Server>\<Share>\FixMissingMSI\Cache\Products and \Cache\Patches - Local permission to query HKLM:\...\Installer and COM WindowsInstaller.Installer #> function Update-InstallerCache { param( [Parameter(Mandatory = $true)] [string]$FileSharePath ) $ErrorActionPreference = 'Stop' # Compose shared paths once; all shared I/O happens under the FixMissingMSI app tree. $ShareRoot = $FileSharePath.TrimEnd('\') $AppFolder = Join-Path $ShareRoot 'FixMissingMSI' $ReportsPath = Join-Path $AppFolder 'Reports' $CacheRoot = Join-Path $AppFolder 'Cache' $ProductsCache = Join-Path $CacheRoot 'Products' $PatchesCache = Join-Path $CacheRoot 'Patches' # Prepare transcript path and ensure folder exists. $LocalWork = Join-Path $env:TEMP 'FixMissingMSI' if (-not (Test-Path -LiteralPath $LocalWork)) { New-Item -ItemType Directory -Path $LocalWork -Force | Out-Null } $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $LocalTranscriptPath = Join-Path $LocalWork "Transcript-Update-InstallerCache-$($env:COMPUTERNAME)-$timestamp.txt" Start-Transcript -Path $LocalTranscriptPath | Out-Null <# .SYNOPSIS Returns the compressed form of a ProductCode GUID (e.g. used in HKLM\...\Installer keys to identify Products rather than the typical {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} GUID). .NOTES Compress-GUID implementation adapted from Microsoft’s "Program Install and Uninstall Troubleshooter" tool. This reimplementation is provided as-is for compatibility with Windows Installer. Microsoft retains copyright to its original tool; this script is an independent rework that replicates the same transformation logic. #> function Compress-GUID { param([Parameter(Mandatory=$true)][string]$Guid) $csharp = @" using System; public class CleanUpRegistry { public static string ReverseString(string s) { char[] a = s.ToCharArray(); Array.Reverse(a); return new string(a); } public static string CompressGUID(string g) { g = g.Substring(1,36); return ReverseString(g.Substring(0,8)) + ReverseString(g.Substring(9,4)) + ReverseString(g.Substring(14,4)) + ReverseString(g.Substring(19,2)) + ReverseString(g.Substring(21,2)) + ReverseString(g.Substring(24,2)) + ReverseString(g.Substring(26,2)) + ReverseString(g.Substring(28,2)) + ReverseString(g.Substring(30,2)) + ReverseString(g.Substring(32,2)) + ReverseString(g.Substring(34,2)); } } "@ if (-not [Type]::GetType('CleanUpRegistry')) { Add-Type -TypeDefinition $csharp -Language CSharp } [CleanUpRegistry]::CompressGUID($Guid) } function Get-InstalledPackageCode { param ( [Parameter(Mandatory = $true)] [string]$ProductCode # {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} ) # Windows Installer exposes package metadata via the WindowsInstaller.Installer COM interface. $installer = New-Object -ComObject WindowsInstaller.Installer try { $installer.ProductInfo($ProductCode, 'PackageCode') } finally { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($installer) | Out-Null } } function Get-CachedMsiInformation { param( [string]$ProductCode, [string]$DisplayName ) # Determine compressed product key (as used in HKLM\...\Installer). if ($ProductCode) { $compressed = Compress-GUID $ProductCode } else { $basePath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products' $found = Get-ChildItem $basePath -ErrorAction SilentlyContinue | ForEach-Object { $instProps = Join-Path $_.PSPath 'InstallProperties' $props = Get-ItemProperty $instProps -ErrorAction Ignore if ($props.DisplayName -eq $DisplayName) { $_.PSChildName } } if (-not $found) { throw "No product found with DisplayName '$DisplayName'" } $compressed = $found } # Read properties from Installer hives. $ipPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\$compressed\InstallProperties" $installProps = Get-ItemProperty -Path $ipPath -ErrorAction Stop $classesSourceMSI = "HKLM:\SOFTWARE\Classes\Installer\Products\$compressed\SourceList" $classesSourceMSIProps = Get-ItemProperty -Path $classesSourceMSI -ErrorAction SilentlyContinue $classesSourceNet = "HKLM:\SOFTWARE\Classes\Installer\Products\$compressed\SourceList\Net" $classesSourceNetProps = Get-ItemProperty -Path $classesSourceNet -ErrorAction SilentlyContinue [PSCustomObject]@{ InstallSourcePath = $installProps.InstallSource CachedMsiVersion = $installProps.DisplayVersion CachedMsiPath = $installProps.LocalPackage CachedMsiExists = [bool](Test-Path -LiteralPath $installProps.LocalPackage) LastUsedSourcePath = $classesSourceNetProps.'1' LastUsedSourceMsi = $classesSourceMSIProps.PackageName ProductCode = $ProductCode PackageCode = (Get-InstalledPackageCode -ProductCode $ProductCode) EncodedProductCode = $compressed } } function Get-CachedMspInformation { param( [Parameter(Mandatory = $true)] [string]$PatchCode ) $compressed = Compress-GUID $PatchCode $patchRoot = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Patches\$compressed" $installProps = Get-ItemProperty -Path $patchRoot -ErrorAction Stop $classesSourceMSP = "HKLM:\SOFTWARE\Classes\Installer\Patches\$compressed\SourceList" $classesSourceMSPProps = Get-ItemProperty -Path $classesSourceMSP -ErrorAction SilentlyContinue $classesSourceNet = "HKLM:\SOFTWARE\Classes\Installer\Patches\$compressed\SourceList\Net" $classesSourceNetProps = Get-ItemProperty -Path $classesSourceNet -ErrorAction SilentlyContinue if($installProps.PSObject.Properties.Name -notcontains "InstallSource"){ $calculatedInstallSource = (Join-Path $classesSourceNetProps.'1' $classesSourceMSPProps.PackageName) if($calculatedInstallSource){ $installProps | Add-Member -NotePropertyName "InstallSource" -NotePropertyValue $calculatedInstallSource } else { $installProps | Add-Member -NotePropertyName "InstallSource" -NotePropertyValue "" } } [PSCustomObject]@{ InstallSourcePath = $installProps.InstallSource CachedMspPath = $installProps.LocalPackage CachedMspExists = [bool](Test-Path -LiteralPath $installProps.LocalPackage) LastUsedSourcePath = $classesSourceNetProps.'1' LastUsedSourceMsp = $classesSourceMSPProps.PackageName PatchCode = $PatchCode EncodedPatchCode = $compressed } } <# .SYNOPSIS Reads ProductCode and PackageCode from an MSI file. .DESCRIPTION Uses Windows Installer COM to open the MSI database and SummaryInformation. ProductCode is read from the Property table; PackageCode is the SummaryInformation revision GUID. #> function Get-MsiProp { param( [Parameter(Mandatory=$true)][string]$Path ) $installer = New-Object -ComObject WindowsInstaller.Installer try { if ($Path -like '*.msi') { $db = $installer.OpenDatabase($Path, 0) # 0 = read-only $view = $db.OpenView("SELECT `Value` FROM `Property` WHERE `Property`='ProductCode'") $view.Execute() | Out-Null $rec = $view.Fetch() $productCode = if ($rec) { $rec.StringData(1) } else { $null } $pkgCode = ($installer.SummaryInformation($Path,0)).Property(9) # PID_REVNUMBER (PackageCode) [PSCustomObject]@{ ProductCode = $productCode PackageCode = $pkgCode } } } finally { $null = $view.Close() $null = [System.Runtime.InteropServices.Marshal]::ReleaseComObject($installer) $null = [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) $null = [System.Runtime.InteropServices.Marshal]::ReleaseComObject($db) } } # Load merged MSI report $msiReport = Join-Path $ReportsPath 'MSIProductCodes.csv' if (-not (Test-Path -LiteralPath $msiReport)) { Write-Warning "MSI report not found: $msiReport. Ensure host can access the fileshare." } else { $MSIList = Import-Csv -LiteralPath $msiReport # Build list of currently registered LocalPackage MSIs (to identify "unregistered" MSI files). $registeredLocal = Get-Item -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\*' | Select-Object -ExpandProperty PSPath | ForEach-Object { Get-ItemPropertyValue -Path "$_\InstallProperties" -Name LocalPackage -ErrorAction Ignore } | Where-Object { $_ } | Select-Object -Unique # Unregistered MSIs under C:\Windows\Installer that are not listed as LocalPackage. $unregistered = Get-ChildItem 'C:\Windows\Installer' -Filter '*.msi' -File | Where-Object { $_.FullName -notin $registeredLocal } | Select-Object -ExpandProperty FullName # Upload any unregistered MSIs that match the merged report identity. foreach ($file in $unregistered) { $props = Get-MsiProp -Path $file if (-not $props -or -not $props.ProductCode -or -not $props.PackageCode) { continue } $row = $MSIList | Where-Object { $_.ProductCode -eq $props.ProductCode -and $_.PackageCode -eq $props.PackageCode } | Select-Object -First 1 # skip if this isn't in the missing report if (-not $row) { continue } $destDir = Join-Path (Join-Path $ProductsCache $row.ProductCode) $row.PackageCode $destFile = Join-Path $destDir ($row.PackageName.Trim('\')) if (-not (Test-Path -LiteralPath $destFile)) { if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } Copy-Item -LiteralPath $file -Destination $destFile -Force "Unregistered populated product $($row.ProductCode)\$($row.PackageCode)\$($row.PackageName.Trim('\'))" } } # Upload registered/cached MSIs referenced by the merged report. foreach ($row in $MSIList) { try { $info = Get-CachedMsiInformation -ProductCode $row.ProductCode } catch { continue } if ($info.CachedMsiExists -and $info.PackageCode -eq $row.PackageCode) { $destDir = Join-Path (Join-Path $ProductsCache $row.ProductCode) $row.PackageCode $destFile = Join-Path $destDir ($row.PackageName.Trim('\')) if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } if (-not (Test-Path -LiteralPath $destFile)) { Copy-Item -LiteralPath $info.CachedMsiPath -Destination $destFile -Force "Populated product $destFile" } } } } # Load merged MSP report $mspReport = Join-Path $ReportsPath 'MSPPatchCodes.csv' if (-not (Test-Path -LiteralPath $mspReport)) { Write-Warning "MSP report not found: $mspReport. Ensure host can access $FileSharePath" } else { $MSPList = Import-Csv -LiteralPath $mspReport foreach ($row in $MSPList) { try { $info = Get-CachedMspInformation -PatchCode $row.PatchCode if ($info.CachedMspExists -and $info.PatchCode -eq $row.PatchCode) { $destDir = Join-Path (Join-Path $PatchesCache $row.ProductCode) $row.PatchCode $destFile = Join-Path $destDir ($row.PackageName.Trim('\')) if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } if (-not (Test-Path -LiteralPath $destFile)) { Copy-Item -LiteralPath $info.CachedMspPath -Destination $destFile -Force "Populated patch $destFile" } } } catch { continue } } } Stop-Transcript | Out-Null } |