Public/Save-WingetInstaller.ps1
|
function Save-WingetInstaller { <# .SYNOPSIS Downloads a WinGet package installer with hash verification. .DESCRIPTION Downloads the installer file for a specified WinGet package and verifies its integrity using the SHA256 hash from the manifest. Supports filtering by architecture and installer type. .PARAMETER App The package identifier (e.g., 'Microsoft.PowerToys', '7zip.7zip'). .PARAMETER Path The directory where the installer should be saved. Defaults to current directory. .PARAMETER Architecture The architecture to download (e.g., 'x64', 'x86', 'arm64'). If not specified, prefers x64, then x86, then arm64. .PARAMETER InstallerType The installer type to download (e.g., 'exe', 'msi', 'msix'). If not specified, downloads the first available installer. .PARAMETER Force Overwrites existing files without prompting. .PARAMETER SkipHashValidation Skips SHA256 hash validation. Not recommended unless necessary. .PARAMETER PassThru Returns the downloaded file information. .EXAMPLE Save-WingetInstaller -App 'Microsoft.PowerToys' Downloads the latest PowerToys installer to the current directory. .EXAMPLE Save-WingetInstaller -App '7zip.7zip' -Path 'C:\Downloads' -Architecture 'x64' Downloads the x64 version of 7-Zip to C:\Downloads. .EXAMPLE Save-WingetInstaller -App 'Git.Git' -InstallerType 'exe' -PassThru Downloads the EXE installer for Git and returns the file information. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string]$App, [Parameter(Position = 1)] [string]$Path = $(Get-Location), [Parameter()] [ValidateSet('x64', 'x86', 'arm64', 'arm', 'neutral')] [string]$Architecture, [Parameter()] [string]$InstallerType, [Parameter()] [switch]$Force, [Parameter()] [switch]$SkipHashValidation, [Parameter()] [switch]$PassThru ) Write-Verbose "Getting package information for '$App'..." $package = Get-LatestWingetVersion -App $App -ErrorAction Stop if (-not $package.Installers) { Write-Error -Exception ([System.Exception]::new("No installers found for package '$App'")) -ErrorId NoInstallersFound -Category ObjectNotFound -TargetObject $App -ErrorAction Stop } $availableInstallers = @($package.Installers) if ($Architecture) { $availableInstallers = @($availableInstallers | Where-Object { $_.Architecture -eq $Architecture }) if ($availableInstallers.Count -eq 0) { Write-Error -Exception ([System.Exception]::new("No installer found for architecture '$Architecture'")) -ErrorId ArchitectureNotFound -Category ObjectNotFound -TargetObject $Architecture -ErrorAction Stop } } if ($InstallerType) { $availableInstallers = @($availableInstallers | Where-Object { $_.InstallerType -eq $InstallerType }) if ($availableInstallers.Count -eq 0) { Write-Error -Exception ([System.Exception]::new("No installer found for type '$InstallerType'")) -ErrorId InstallerTypeNotFound -Category ObjectNotFound -TargetObject $InstallerType -ErrorAction Stop } } $installer = $availableInstallers | Sort-Object @{ Expression = { switch ($_.Architecture) { 'x64' { 0 }; 'x86' { 1 }; default { 2 } } } } | Select-Object -First 1 $uri = [Uri]$installer.InstallerUrl if (-not $uri.IsAbsoluteUri -or $uri.Scheme -ne [Uri]::UriSchemeHttps) { Write-Error -Exception ([System.Security.SecurityException]::new("Installer URL must use HTTPS: $($installer.InstallerUrl)")) -ErrorId InsecureInstallerUrl -Category SecurityError -TargetObject $installer.InstallerUrl -ErrorAction Stop } if (-not $SkipHashValidation -and [string]::IsNullOrWhiteSpace($installer.InstallerSha256)) { Write-Error -Exception ([System.Security.SecurityException]::new('Installer manifest does not provide a SHA256 hash. Use -SkipHashValidation to download without verification.')) -ErrorId InstallerHashMissing -Category SecurityError -TargetObject $installer.InstallerUrl -ErrorAction Stop } $filename = [IO.Path]::GetFileName($uri.LocalPath) if (-not [IO.Path]::GetExtension($filename)) { $extension = switch ($installer.InstallerType) { 'msi' { '.msi' } 'msix' { '.msix' } 'zip' { '.zip' } default { '.exe' } } $filename = "$($package.PackageIdentifier)_$($package.PackageVersion)_$($installer.Architecture)$extension" } $destinationPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $outputPath = Join-Path -Path $destinationPath -ChildPath $filename if ((Test-Path -LiteralPath $outputPath) -and -not $Force) { Write-Error -Exception ([IO.IOException]::new("File already exists: $outputPath. Use -Force to overwrite.")) -ErrorId FileExists -Category ResourceExists -TargetObject $outputPath -ErrorAction Stop } if (-not (Test-Path -LiteralPath $destinationPath -PathType Container)) { if ($PSCmdlet.ShouldProcess($destinationPath, 'Create directory')) { $null = New-Item -ItemType Directory -Path $destinationPath -Force } elseif (-not $WhatIfPreference) { return } } if (-not $PSCmdlet.ShouldProcess($installer.InstallerUrl, "Download to $outputPath")) { return } $temporaryPath = Join-Path -Path $destinationPath -ChildPath ".$filename.$([Guid]::NewGuid().ToString('N')).download" try { try { if (Get-Command -Name Start-BitsTransfer -ErrorAction SilentlyContinue) { Start-BitsTransfer -Source $installer.InstallerUrl -Destination $temporaryPath -Description "Downloading $($package.PackageName)" } else { Invoke-WebRequest -Uri $installer.InstallerUrl -OutFile $temporaryPath -UseBasicParsing } } catch { Write-Error -Exception $_.Exception -ErrorId DownloadFailed -Category ConnectionError -TargetObject $installer.InstallerUrl -ErrorAction Stop } if ($SkipHashValidation) { Write-Warning 'Hash validation skipped. The file integrity has not been verified.' } else { $actualHash = (Get-FileHash -LiteralPath $temporaryPath -Algorithm SHA256).Hash if ($actualHash -ne $installer.InstallerSha256) { Write-Error -Exception ([System.Security.SecurityException]::new("Hash verification failed. Expected: $($installer.InstallerSha256), Actual: $actualHash")) -ErrorId HashMismatch -Category SecurityError -TargetObject $temporaryPath -ErrorAction Stop } } Complete-WingetAtomicFileWrite -TemporaryPath $temporaryPath -DestinationPath $outputPath -ReplaceExisting:$Force $temporaryPath = $null if ($PassThru) { Get-Item -LiteralPath $outputPath | Add-Member -MemberType NoteProperty -Name PackageId -Value $package.PackageIdentifier -PassThru | Add-Member -MemberType NoteProperty -Name PackageVersion -Value $package.PackageVersion -PassThru | Add-Member -MemberType NoteProperty -Name Architecture -Value $installer.Architecture -PassThru | Add-Member -MemberType NoteProperty -Name InstallerType -Value $installer.InstallerType -PassThru | Add-Member -MemberType NoteProperty -Name HashVerified -Value (-not $SkipHashValidation) -PassThru } } finally { if ($temporaryPath) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } |