Files
Invoke-VirtIODrivers/Toolkit/Functions/Invoke-FileDownloadWithProgress.ps1
gsadmin a9479d2faa Use cached downloads as-is and follow redirects to the resolved download URL
- Skip the web request entirely when the destination file already exists: the cached file is always used and never automatically updated (delete the local file to force a fresh download)
- Only contact the download URL when a download is actually required, with a 30 second metadata request timeout and explicit redirect following
- Download directly from the resolved post-redirect URL (mirror style URLs commonly redirect)
- An unreachable download URL is only fatal when no local copy of the file exists

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 16:58:37 -04:00

556 lines
32 KiB
PowerShell

#region Invoke-FileDownloadWithProgress
Function Invoke-FileDownloadWithProgress
{
<#
.SYNOPSIS
Downloads the specified URL to the specified destination directory with progress reporting and optional proxy support.
.DESCRIPTION
The file will only be downloaded when it does not already exist within the destination directory. When the destination file already exists, the existing local copy is always used and no web request is made, so a cached file is never automatically updated. Delete the local file to force a fresh download.
By default, the system default proxy is automatically detected: when a proxy applies to the download URL it is used with default credentials, and otherwise the connection is made directly. An explicit proxy can be specified with the ProxyURI parameter, optionally with a dedicated credential.
.PARAMETER URL
The URL where the file is located.
.PARAMETER Destination
The directory path where the URL will be downloaded to. If not specified, a default value will be used.
.PARAMETER FileName
The file name the download will be saved as. If not specified, the file name will be extracted from the URL.
.PARAMETER ProxyURI
An explicit proxy server URI (with full scheme) to route the download through. If not specified, the system default proxy will be used.
.PARAMETER ProxyCredential
The credential used to authenticate against the proxy specified by the ProxyURI parameter. If not specified, default credentials will be used.
.PARAMETER ContinueOnError
Ignore failures.
.EXAMPLE
Invoke-FileDownloadWithProgress -URL 'https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/virtio-win.iso' -Destination "$($Env:ProgramData)\ISOs" -Verbose
.EXAMPLE
$InvokeFileDownloadWithProgressParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$InvokeFileDownloadWithProgressParameters.URL = 'https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/virtio-win.iso' -As [System.URI]
$InvokeFileDownloadWithProgressParameters.Destination = "$($Env:ProgramData)\ISOs" -As [System.IO.DirectoryInfo]
$InvokeFileDownloadWithProgressParameters.FileName = 'virtio-win.iso'
$InvokeFileDownloadWithProgressParameters.ProxyURI = 'http://proxy.example.com:8080' -As [System.URI]
$InvokeFileDownloadWithProgressParameters.ContinueOnError = $False
$InvokeFileDownloadWithProgressParameters.Verbose = $True
$InvokeFileDownloadWithProgressResult = Invoke-FileDownloadWithProgress @InvokeFileDownloadWithProgressParameters
Write-Output -InputObject ($InvokeFileDownloadWithProgressResult)
.NOTES
The returned object exposes the following properties: DownloadRequired, Success, DownloadPath, URL, URLHeaders, and CompletionTimespan.
.LINK
https://learn.microsoft.com/en-us/dotnet/api/system.net.webclient
#>
[CmdletBinding(ConfirmImpact = 'Low', SupportsShouldProcess = $True)]
Param
(
[Parameter(Mandatory=$True, ValueFromPipelineByPropertyName = $True)]
[ValidateNotNullOrEmpty()]
[System.URI]$URL,
[Parameter(Mandatory=$False, ValueFromPipelineByPropertyName = $True)]
[ValidateNotNullOrEmpty()]
[System.IO.DirectoryInfo]$Destination,
[Parameter(Mandatory=$False, ValueFromPipelineByPropertyName = $True)]
[ValidateNotNullOrEmpty()]
[ValidatePattern('^.*\.(.*)$')]
[String]$FileName,
[Parameter(Mandatory=$False)]
[ValidateNotNullOrEmpty()]
[Alias('Proxy')]
[System.URI]$ProxyURI,
[Parameter(Mandatory=$False)]
[ValidateNotNullOrEmpty()]
[Alias('PC')]
[System.Management.Automation.PSCredential]$ProxyCredential,
[Parameter(Mandatory=$False)]
[Switch]$ContinueOnError
)
Begin
{
Try
{
#Determine the date and time we executed the function
$FunctionStartTime = (Get-Date)
[String]$CmdletName = $MyInvocation.MyCommand.Name
$WriteLogMessage.Invoke(0, @("Function `'$($CmdletName)`' is beginning. Please Wait..."))
#Define Default Action Preferences
$ErrorActionPreference = 'Stop'
[String[]]$SuppliedScriptParameters = $PSBoundParameters.GetEnumerator() | ForEach-Object {"-$($_.Key):$($_.Value.GetType().Name)"}
$WriteLogMessage.Invoke(0, @("Supplied Function Parameter(s) = $($SuppliedScriptParameters -Join ', ')"))
#Set default parameter values (If necessary)
Switch ($True)
{
{([String]::IsNullOrEmpty($Destination) -eq $True) -or ([String]::IsNullOrWhiteSpace($Destination) -eq $True)}
{
$Destination = [System.IO.DirectoryInfo][System.IO.Path]::Combine("$($Env:Windir)", 'Temp')
}
{([String]::IsNullOrEmpty($FileName) -eq $True) -or ([String]::IsNullOrWhiteSpace($FileName) -eq $True)}
{
[String]$FileName = [System.IO.Path]::GetFileName($URL.OriginalString)
}
}
$DestinationPath = [System.IO.FileInfo][System.IO.Path]::Combine($Destination.FullName, $FileName)
#Determine the proxy configuration for all outbound requests made by this function
Switch ($Null -ine $ProxyURI)
{
{($_ -eq $True)}
{
$WriteLogMessage.Invoke(0, @("An explicit proxy was specified. [Proxy URI: $($ProxyURI.AbsoluteURI)]"))
$WebProxy = New-Object -TypeName 'System.Net.WebProxy' -ArgumentList ($ProxyURI.AbsoluteURI)
$WebProxy.BypassProxyOnLocal = $True
Switch ($Null -ine $ProxyCredential)
{
{($_ -eq $True)}
{
$WriteLogMessage.Invoke(0, @("The supplied proxy credential will be used for proxy authentication. [Username: $($ProxyCredential.UserName)]"))
$WebProxy.Credentials = $ProxyCredential.GetNetworkCredential()
}
Default
{
$WriteLogMessage.Invoke(0, @("Default credentials will be used for proxy authentication."))
$WebProxy.UseDefaultCredentials = $True
}
}
}
Default
{
#Determine whether the system default proxy actually applies to the download URL (If not, the connection will be made directly)
$SystemProxy = [System.Net.WebRequest]::DefaultWebProxy
$WebProxy = $Null
Switch ($Null -ine $SystemProxy)
{
{($_ -eq $True)}
{
$ProxyURIForRequest = Try {$SystemProxy.GetProxy($URL)} Catch {$Null}
Switch (($Null -ine $ProxyURIForRequest) -and ($ProxyURIForRequest.AbsoluteURI -ine $URL.AbsoluteURI) -and ($SystemProxy.IsBypassed($URL) -eq $False))
{
{($_ -eq $True)}
{
$WriteLogMessage.Invoke(0, @("A system default proxy was detected and will be used with default credentials. [Proxy: $($ProxyURIForRequest.AbsoluteURI)]"))
$SystemProxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
$WebProxy = $SystemProxy
}
Default
{
$WriteLogMessage.Invoke(0, @("No proxy was detected. The connection will be made directly."))
}
}
}
Default
{
$WriteLogMessage.Invoke(0, @("No proxy was detected. The connection will be made directly."))
}
}
}
}
$OutputObjectProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$OutputObjectProperties.DownloadRequired = $False
$OutputObjectProperties.Success = $True
#region Function Convert-FileSize
Function Convert-FileSize
{
<#
.SYNOPSIS
Converts a size in bytes to its upper most value.
.PARAMETER Size
The size in bytes to convert.
.PARAMETER DecimalPlaces
The number of decimal places to round the calculated size to.
#>
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[Alias('Length')]
$Size,
[Parameter(Mandatory=$False)]
[ValidateNotNullOrEmpty()]
[Alias('DP')]
[Int]$DecimalPlaces
)
Try
{
Switch ($True)
{
{([String]::IsNullOrEmpty($DecimalPlaces) -eq $True) -or ([String]::IsNullOrWhiteSpace($DecimalPlaces) -eq $True)}
{
[Int]$DecimalPlaces = 2
}
}
$OutputObjectProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$OutputObjectProperties.Size = $Size
$OutputObjectProperties.DecimalPlaces = $DecimalPlaces
Switch ($Size)
{
{($_ -lt 1MB)}
{
$OutputObjectProperties.Divisor = 1KB
$OutputObjectProperties.SizeUnit = 'KB'
$OutputObjectProperties.SizeUnitAlias = 'Kilobytes'
Break
}
{($_ -lt 1GB)}
{
$OutputObjectProperties.Divisor = 1MB
$OutputObjectProperties.SizeUnit = 'MB'
$OutputObjectProperties.SizeUnitAlias = 'Megabytes'
Break
}
{($_ -lt 1TB)}
{
$OutputObjectProperties.Divisor = 1GB
$OutputObjectProperties.SizeUnit = 'GB'
$OutputObjectProperties.SizeUnitAlias = 'Gigabytes'
Break
}
{($_ -ge 1TB)}
{
$OutputObjectProperties.Divisor = 1TB
$OutputObjectProperties.SizeUnit = 'TB'
$OutputObjectProperties.SizeUnitAlias = 'Terabytes'
Break
}
}
$OutputObjectProperties.CalculatedSize = [System.Math]::Round(($Size / $OutputObjectProperties.Divisor), $OutputObjectProperties.DecimalPlaces)
$OutputObjectProperties.CalculatedSizeStr = "$($OutputObjectProperties.CalculatedSize) $($OutputObjectProperties.SizeUnit)"
}
Catch
{
Write-Error -Exception $_
}
Finally
{
$OutputObject = New-Object -TypeName 'PSObject' -Property ($OutputObjectProperties)
Write-Output -InputObject ($OutputObject)
}
}
#endregion
}
Catch
{
$ErrorHandlingDefinition.Invoke($Error[0], 2, $ContinueOnError.IsPresent)
}
}
Process
{
Try
{
$DownloadExecutionStopwatch = New-Object -TypeName 'System.Diagnostics.Stopwatch'
[ScriptBlock]$ExecuteDownload = {
Try
{
$WebClient = New-Object -TypeName 'System.Net.WebClient'
$WebClient.UseDefaultCredentials = $True
$WebClient.Proxy = $WebProxy
$WriteLogMessage.Invoke(0, @("Attempting to download URL `"$($ResolvedDownloadURL.AbsoluteURI)`" to `"$($DestinationPath.FullName)`". Please Wait..."))
$WriteLogMessage.Invoke(0, @("Download Size: $($ContentLengthInMB) MB"))
If ([System.IO.Directory]::Exists($DestinationPath.Directory.FullName) -eq $False) {$Null = [System.IO.Directory]::CreateDirectory($DestinationPath.Directory.FullName)}
$Downloader = $WebClient.DownloadFileTaskAsync($ResolvedDownloadURL.AbsoluteURI, $DestinationPath.FullName)
$Null = Register-ObjectEvent -InputObject ($WebClient) -EventName 'DownloadProgressChanged' -SourceIdentifier 'WebClient.DownloadProgressChanged'
$Null = Start-Sleep -Seconds 3
$Null = $DownloadExecutionStopwatch.Start()
Switch ($Downloader.IsFaulted)
{
{($_ -eq $True)}
{
$WriteLogMessage.Invoke(2, @("A download error has occured. Attempting to generate the error record. Please Wait..."))
Write-Error -Message ($Downloader.GetAwaiter().GetResult())
}
}
While ($Downloader.IsCompleted -eq $False)
{
Switch ($Downloader.IsFaulted)
{
{($_ -eq $True)}
{
$WriteLogMessage.Invoke(2, @("A download error has occured. Attempting to generate the error record. Please Wait..."))
Write-Error -Message ($Downloader.GetAwaiter().GetResult())
}
}
$EventData = Get-Event -SourceIdentifier 'WebClient.DownloadProgressChanged' | Select-Object -ExpandProperty 'SourceEventArgs' -Last 1
$EventDetails = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$EventDetails.Received = Convert-FileSize -Size ($EventData.BytesReceived) -DecimalPlaces 2
$EventDetails.TotalToReceive = Convert-FileSize -Size ($EventData.TotalBytesToReceive) -DecimalPlaces 2
$EventDetails.ProgressPercentage = $EventData.ProgressPercentage
Switch ($DownloadExecutionStopwatch.Elapsed.TotalSeconds -gt 0)
{
{($_ -eq $True)}
{
[Single]$TransferRate = [System.Math]::Round(($EventData.BytesReceived / $DownloadExecutionStopwatch.Elapsed.TotalSeconds / 1MB), 2)
}
Default
{
[Single]$TransferRate = 0.00
}
}
$WriteProgressParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$WriteProgressParameters.Activity = "Downloading `"$($DestinationPath.Name)`""
$WriteProgressParameters.Status = "Completion Percentage: $($EventDetails.ProgressPercentage)%"
$WriteProgressParameters.PercentComplete = $EventDetails.ProgressPercentage
$WriteProgressParameters.CurrentOperation = "Downloaded $($EventDetails.Received.CalculatedSizeStr) of $($EventDetails.TotalToReceive.CalculatedSizeStr) @ $($TransferRate) MB/s"
Write-Progress @WriteProgressParameters
$WriteLogMessage.Invoke(1, @("$($EventDetails.ProgressPercentage)% - $($WriteProgressParameters.CurrentOperation)"))
$Null = Start-Sleep -Milliseconds 1500
}
}
Catch
{
Throw
}
Finally
{
$WriteProgressParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$WriteProgressParameters.Activity = "Downloading `"$($DestinationPath.Name)`""
$WriteProgressParameters.Completed = $True
Write-Progress @WriteProgressParameters
$Null = Unregister-Event -SourceIdentifier 'WebClient.DownloadProgressChanged' -ErrorAction SilentlyContinue
Switch (($Downloader.IsCompleted -eq $False) -or ($Downloader.IsFaulted -eq $True))
{
{($_ -eq $True)}
{
Try {$Null = $WebClient.CancelAsync()} Catch {}
$WriteLogMessage.Invoke(2, @("Download failed. Attempting to remove incomplete file `"$($DestinationPath.FullName)`". Please Wait..."))
$Null = [System.IO.File]::Delete($DestinationPath.FullName)
}
}
Try {$Null = $WebClient.Dispose()} Catch {}
$Null = $DownloadExecutionStopwatch.Stop()
}
$WriteLogMessage.Invoke(0, @("Download completed in $($DownloadExecutionStopwatch.Elapsed.Hours.ToString()) hour(s), $($DownloadExecutionStopwatch.Elapsed.Minutes.ToString()) minute(s), $($DownloadExecutionStopwatch.Elapsed.Seconds.ToString()) second(s), and $($DownloadExecutionStopwatch.Elapsed.Milliseconds.ToString()) millisecond(s)."))
Switch ($URLLastModifiedAvailable)
{
{($_ -eq $True)}
{
$WriteLogMessage.Invoke(0, @("Attempting to update the last modified date of `"$($DestinationPath.FullName)`" to match the last modified date of `"$($URL.OriginalString)`". Please Wait..."))
$DestinationPathDetails = Get-Item -Path $DestinationPath.FullName -Force
$Null = $DestinationPathDetails.LastWriteTimeUTC = $WebRequestHeaders.'Last-Modified'
}
}
}
Switch ([System.IO.File]::Exists($DestinationPath.FullName))
{
{($_ -eq $True)}
{
$WriteLogMessage.Invoke(0, @("Destination path `"$($DestinationPath.FullName)`" already exists. The existing local copy will be used and the download will be skipped."))
}
Default
{
#region Contact the download URL for metadata (Only required when the file must be downloaded)
$URLLastModifiedAvailable = $False
$ResolvedDownloadURL = $URL
Try
{
$WriteLogMessage.Invoke(0, @("Attempting to create a web request for `"$($URL.OriginalString)`". Please Wait..."))
$WebRequest = [System.Net.WebRequest]::Create($URL.AbsoluteURI)
$WebRequest.Proxy = $WebProxy
$WebRequest.UseDefaultCredentials = $True
$WebRequest.AllowAutoRedirect = $True
$WebRequest.Timeout = [System.Convert]::ToInt32([System.TimeSpan]::FromSeconds(30).TotalMilliseconds)
$WebRequestResponse = $WebRequest.GetResponse()
$WriteLogMessage.Invoke(0, @("Web Request Response Status: $($WebRequestResponse.StatusCode) [Status Code: $($WebRequestResponse.StatusCode.value__)]"))
#Follow any redirects to the final download URL so that the download comes directly from the resolved location (Mirror style URLs commonly redirect)
Switch (($Null -ine $WebRequestResponse.ResponseUri) -and ($WebRequestResponse.ResponseUri.AbsoluteURI -ine $URL.AbsoluteURI))
{
{($_ -eq $True)}
{
[System.URI]$ResolvedDownloadURL = $WebRequestResponse.ResponseUri
$WriteLogMessage.Invoke(0, @("The download URL was redirected. [Final URL: $($ResolvedDownloadURL.AbsoluteURI)]"))
}
}
$WebRequestResponseHeaders = $WebRequestResponse.Headers
$WebRequestHeaderProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
ForEach ($WebRequestResponseHeader In $WebRequestResponseHeaders.AllKeys)
{
$WebRequestHeaderProperties."$($WebRequestResponseHeader)" = ($WebRequestResponseHeaders.GetValues($WebRequestResponseHeader))[0]
}
$WebRequestHeaders = New-Object -TypeName 'PSObject' -Property ($WebRequestHeaderProperties)
#Determine whether the source URL exposes a last modified date, which will be stamped onto the downloaded file for reference (Not all web servers do)
$URLLastModifiedAvailable = ([String]::IsNullOrEmpty($WebRequestHeaders.'Last-Modified') -eq $False) -and ([String]::IsNullOrWhiteSpace($WebRequestHeaders.'Last-Modified') -eq $False)
Switch ($URLLastModifiedAvailable)
{
{($_ -eq $True)}
{
$WebRequestHeaders.'Last-Modified' = (Get-Date -Date $WebRequestHeaders.'Last-Modified').ToUniversalTime()
}
{($_ -eq $False)}
{
$WriteLogMessage.Invoke(0, @("The source URL does not expose a `"Last-Modified`" header. The last modified date of the downloaded file will not be adjusted."))
}
}
$ContentLengthInMB = [System.Math]::Round(($WebRequestHeaders.'Content-Length' / 1MB), 2)
}
Catch
{
Throw "The download URL `"$($URL.OriginalString)`" could not be contacted and destination path `"$($DestinationPath.FullName)`" does not exist. [Error: $($_.Exception.Message)]"
}
#endregion
$WriteLogMessage.Invoke(2, @("Destination path `"$($DestinationPath.FullName)`" does not exist.", "A download of `"$($URL.OriginalString)`" is necessary."))
$OutputObjectProperties.DownloadRequired = $True
$ExecuteDownload.Invoke()
}
}
}
Catch
{
$OutputObjectProperties.Success = $False
$ErrorHandlingDefinition.Invoke($Error[0], 2, $ContinueOnError.IsPresent)
}
Finally
{
Try {$Null = $WebRequestResponse.Dispose()} Catch {}
}
}
End
{
Try
{
#Determine the date and time the function completed execution
$FunctionEndTime = (Get-Date)
#Log the total function execution time
$FunctionExecutionTimespan = New-TimeSpan -Start ($FunctionStartTime) -End ($FunctionEndTime)
$WriteLogMessage.Invoke(0, @("Function execution took $($FunctionExecutionTimespan.Hours.ToString()) hour(s), $($FunctionExecutionTimespan.Minutes.ToString()) minute(s), $($FunctionExecutionTimespan.Seconds.ToString()) second(s), and $($FunctionExecutionTimespan.Milliseconds.ToString()) millisecond(s)."))
$WriteLogMessage.Invoke(0, @("Function `'$($CmdletName)`' is completed."))
}
Catch
{
$OutputObjectProperties.Success = $False
$ErrorHandlingDefinition.Invoke($Error[0], 2, $ContinueOnError.IsPresent)
}
Finally
{
$DestinationPathDetails = Try {Get-Item -Path $DestinationPath.FullName -Force -ErrorAction SilentlyContinue} Catch {$Null}
$OutputObjectProperties.DownloadPath = $DestinationPathDetails
$OutputObjectProperties.URL = $URL
$OutputObjectProperties.URLHeaders = $WebRequestHeaders
$OutputObjectProperties.CompletionTimespan = $Null
Switch ($True)
{
{($OutputObjectProperties.DownloadRequired -eq $True)}
{
$OutputObjectProperties.CompletionTimespan = $DownloadExecutionStopwatch.Elapsed
}
}
$OutputObject = New-Object -TypeName 'PSObject' -Property ($OutputObjectProperties)
Write-Output -InputObject ($OutputObject)
}
}
}
#endregion