Public/windowsupdate/Save-WindowsUpdate.ps1
|
#Requires -Version 5.1 function Save-WindowsUpdate { <# .SYNOPSIS Downloads available Windows Updates without installing them .DESCRIPTION Scans for available Windows Updates and downloads them to the local cache without installing. Uses the COM API (Microsoft.Update.Session) to find and download updates. Internally calls Get-WindowsUpdate to discover available updates, then downloads each one using IUpdateDownloader. A progress bar displays download status with speed, percentage based on total size, and estimated time remaining. Updates are downloaded one at a time for granular progress tracking. Use this function to pre-stage updates before a maintenance window, then install them later with Install-WindowsUpdate. .PARAMETER ComputerName One or more computer names to target. Defaults to the local computer. Accepts pipeline input by value and by property name. .PARAMETER Credential Optional PSCredential for authenticating to remote computers. Not required for local queries. .PARAMETER MicrosoftUpdate When specified, queries the full Microsoft Update catalog instead of the machine's configured source (WSUS, WUFB, or Windows Update). .PARAMETER KBArticleID Optional filter to download only updates matching the specified KB article IDs. Accepts one or more KB identifiers with or without the 'KB' prefix. .PARAMETER Classification Optional filter to download only updates matching the specified classifications. When not specified, all classifications are downloaded. .PARAMETER Product Optional filter to download only updates matching the specified product names. When not specified, all products are downloaded. .PARAMETER IncludeHidden When specified, includes updates that have been hidden (declined). .PARAMETER AcceptEula When specified, automatically accepts the End User License Agreement for each update before downloading. Required for updates whose EULA has not been previously accepted. .EXAMPLE Save-WindowsUpdate Downloads all available updates on the local computer. .EXAMPLE Save-WindowsUpdate -ComputerName 'SRV01' -KBArticleID 'KB5034441' -AcceptEula Downloads a specific update on SRV01, accepting the EULA automatically. .EXAMPLE 'SRV01', 'SRV02' | Save-WindowsUpdate -MicrosoftUpdate -Classification 'Security Updates' Downloads security updates from Microsoft Update on SRV01 and SRV02. .OUTPUTS PSWinOps.WindowsUpdateDownloadResult Returns objects with ComputerName, Title, KBArticle, SizeMB, Result, HResult, and Timestamp properties. .NOTES Author: Franck SALLET Version: 1.0.0 Last Modified: 2026-04-08 Requires: PowerShell 5.1+ / Windows only Requires: Administrator privileges for downloading updates Requires: Windows Update service must be accessible on target machines .LINK https://github.com/k9fr4n/PSWinOps .LINK https://learn.microsoft.com/en-us/windows/win32/api/wuapi/nn-wuapi-iupdatedownloader #> [CmdletBinding(SupportsShouldProcess = $true)] [Alias('Download-WindowsUpdate')] [OutputType('PSWinOps.WindowsUpdateDownloadResult')] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('CN', 'DNSHostName')] [string[]]$ComputerName = $env:COMPUTERNAME, [Parameter(Mandatory = $false)] [ValidateNotNull()] [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential, [Parameter(Mandatory = $false)] [switch]$MicrosoftUpdate, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string[]]$KBArticleID, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string[]]$Classification, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string[]]$Product, [Parameter(Mandatory = $false)] [switch]$IncludeHidden, [Parameter(Mandatory = $false)] [switch]$AcceptEula ) begin { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting" $resultCodeMap = @{ 0 = 'NotStarted' 1 = 'InProgress' 2 = 'Succeeded' 3 = 'SucceededWithErrors' 4 = 'Failed' 5 = 'Aborted' } $downloadScriptBlock = { param( [string]$UpdateIdToDownload, [bool]$UseMicrosoftUpdate, [bool]$DoAcceptEula ) try { $session = New-Object -ComObject 'Microsoft.Update.Session' $session.ClientApplicationID = 'PSWinOps' $searcher = $session.CreateUpdateSearcher() if ($UseMicrosoftUpdate) { $serviceManager = New-Object -ComObject 'Microsoft.Update.ServiceManager' $serviceManager.ClientApplicationID = 'PSWinOps' $service = $serviceManager.AddService2('7971f918-a847-4430-9279-4a52d1efe18d', 7, '') $searcher.ServerSelection = 3 $searcher.ServiceID = $service.ServiceID } $searchResult = $searcher.Search("UpdateID='$UpdateIdToDownload'") if ($searchResult.Updates.Count -eq 0) { throw "Update '$UpdateIdToDownload' not found" } $update = $searchResult.Updates.Item(0) if ($DoAcceptEula -and -not $update.EulaAccepted) { $update.AcceptEula() } if ($update.IsDownloaded) { return [PSCustomObject]@{ ResultCode = 2 HResult = 0 AlreadyDownloaded = $true } } if (-not $update.EulaAccepted) { throw "EULA not accepted for '$($update.Title)'. Use -AcceptEula to accept automatically." } $updateColl = New-Object -ComObject 'Microsoft.Update.UpdateColl' $updateColl.Add($update) | Out-Null $downloader = $session.CreateUpdateDownloader() $downloader.Updates = $updateColl $downloadResult = $downloader.Download() return [PSCustomObject]@{ ResultCode = [int]$downloadResult.ResultCode HResult = [int]$downloadResult.HResult AlreadyDownloaded = $false } } catch { throw "Failed to download update '$UpdateIdToDownload': $_" } } } process { foreach ($computer in $ComputerName) { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing '$computer'" try { # Step 1: Scan for available updates using Get-WindowsUpdate $getParams = @{ ComputerName = $computer } if ($MicrosoftUpdate) { $getParams['MicrosoftUpdate'] = $true } if ($KBArticleID) { $getParams['KBArticleID'] = $KBArticleID } if ($Classification) { $getParams['Classification'] = $Classification } if ($Product) { $getParams['Product'] = $Product } if ($IncludeHidden) { $getParams['IncludeHidden'] = $true } if ($PSBoundParameters.ContainsKey('Credential')) { $getParams['Credential'] = $Credential } $activityLabel = "Save-WindowsUpdate — $computer" # Step 1: Scan Write-Progress -Activity $activityLabel -Status 'Scanning for available updates...' -PercentComplete 0 $updates = @(Get-WindowsUpdate @getParams) Write-Progress -Activity $activityLabel -Status 'Scan complete' -PercentComplete 0 if ($updates.Count -eq 0) { Write-Progress -Activity $activityLabel -Completed Write-Verbose -Message "[$($MyInvocation.MyCommand)] No updates to download on '$computer'" continue } $totalUpdates = $updates.Count $totalSizeMB = ($updates | Measure-Object -Property 'SizeMB' -Sum).Sum Write-Information -MessageData "[$($MyInvocation.MyCommand)] $computer — $totalUpdates update(s) to download ($([math]::Round($totalSizeMB, 1)) MB)" -InformationAction Continue # Step 2: Download each update with progress $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $downloadedSizeMB = 0 for ($i = 0; $i -lt $totalUpdates; $i++) { $update = $updates[$i] $kbLabel = if ($update.KBArticle) { " ($($update.KBArticle))" } else { '' } # Calculate progress $percentComplete = if ($totalSizeMB -gt 0) { [math]::Min([math]::Floor($downloadedSizeMB / $totalSizeMB * 100), 99) } else { [math]::Min([math]::Floor($i / $totalUpdates * 100), 99) } $elapsedSec = $stopwatch.Elapsed.TotalSeconds $speedMBps = if ($elapsedSec -gt 0 -and $downloadedSizeMB -gt 0) { $downloadedSizeMB / $elapsedSec } else { 0 } $remainingMB = $totalSizeMB - $downloadedSizeMB $etaSeconds = if ($speedMBps -gt 0) { [int]($remainingMB / $speedMBps) } else { -1 } $speedLabel = if ($speedMBps -gt 0) { "$([math]::Round($speedMBps, 1)) MB/s" } else { 'starting...' } $downloadedLabel = "$([math]::Round($downloadedSizeMB, 1))/$([math]::Round($totalSizeMB, 1)) MB" $progressParams = @{ Activity = $activityLabel Status = "($($i + 1)/$totalUpdates) $downloadedLabel — $speedLabel" CurrentOperation = "$($update.Title)$kbLabel" PercentComplete = $percentComplete } if ($etaSeconds -ge 0) { $progressParams['SecondsRemaining'] = $etaSeconds } Write-Progress @progressParams if ($PSCmdlet.ShouldProcess("$($update.Title)$kbLabel [$($update.SizeMB) MB]", "Download update on '$computer'")) { $invokeParams = @{ ComputerName = $computer ScriptBlock = $downloadScriptBlock ArgumentList = @($update.UpdateId, [bool]$MicrosoftUpdate, [bool]$AcceptEula) } if ($PSBoundParameters.ContainsKey('Credential')) { $invokeParams['Credential'] = $Credential } try { $dlResult = Invoke-RemoteOrLocal @invokeParams $resultString = if ($resultCodeMap.ContainsKey($dlResult.ResultCode)) { $resultCodeMap[$dlResult.ResultCode] } else { 'Unknown' } if ($dlResult.AlreadyDownloaded) { $resultString = 'AlreadyDownloaded' Write-Verbose -Message "[$($MyInvocation.MyCommand)] '$($update.Title)' already downloaded on '$computer'" } $hResultHex = if ($dlResult.HResult -ne 0) { '0x{0:X8}' -f $dlResult.HResult } else { '0x00000000' } [PSCustomObject]@{ PSTypeName = 'PSWinOps.WindowsUpdateDownloadResult' ComputerName = $computer Title = $update.Title KBArticle = $update.KBArticle SizeMB = $update.SizeMB Result = $resultString HResult = $hResultHex Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' } } catch { Write-Error -Message "[$($MyInvocation.MyCommand)] Failed to download '$($update.Title)' on '${computer}': $_" [PSCustomObject]@{ PSTypeName = 'PSWinOps.WindowsUpdateDownloadResult' ComputerName = $computer Title = $update.Title KBArticle = $update.KBArticle SizeMB = $update.SizeMB Result = 'Failed' HResult = 'Error' Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' } } } $downloadedSizeMB += $update.SizeMB } Write-Progress -Activity $activityLabel -Completed $stopwatch.Stop() $elapsed = $stopwatch.Elapsed $avgSpeed = if ($elapsed.TotalSeconds -gt 0 -and $downloadedSizeMB -gt 0) { "$([math]::Round($downloadedSizeMB / $elapsed.TotalSeconds, 1)) MB/s" } else { 'N/A' } Write-Information -MessageData "[$($MyInvocation.MyCommand)] $computer — Done in $($elapsed.ToString('mm\:ss')) ($avgSpeed)" -InformationAction Continue } catch { Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${computer}': $_" } } } end { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed" } } |