Public/Get-BSADellDriverPack.ps1

<#
    .SYNOPSIS
        Download Dell driver packs (CAB files) filtered by model, operating system and architecture.

    .DESCRIPTION
        Downloads the Dell Driver Pack Catalog, then downloads every driver pack CAB
        file matching the requested models, operating systems and architectures.
        Packages already downloaded (and valid) are skipped, duplicate copies are
        created as hard links by default, and older versions of a package are
        removed once a newer one has been downloaded.

        Progress is reported via Write-Progress. Use -Verbose for a detailed,
        step-by-step trace of what the command is doing. Returns a summary
        object (packages considered/matched/downloaded/failed/removed) once done.

    .PARAMETER DriverCatalog
        Driver Pack Catalog download address.
        Default is "http://downloads.dell.com/catalog/DriverPackCatalog.cab"

    .PARAMETER DownloadFolder
        Path to the folder where the drivers pack will be downloaded.
        Certain variables can be used to sort drivers pack into download folder (but don't expand variables in the parameter!).
        Default is "$env:LOCALAPPDATA\BSADellDriverPack\Drivers".
        Example : "C:\Drivers".

    .PARAMETER DriversStructure
        Folder structure to sort drivers pack into download folder.
        Leave empty to save drivers pack into the root of the download folder.
        Default structure is "$($package.OperatingSystems)\$($package.Models)\$($package.Architectures)".
        Example : "Windows11\Latitude E7470\X64".

    .PARAMETER MonthsBack
        Download drivers pack newer than X month.
        Default is 0 (no time limit).

    .PARAMETER DuplicateHandling
        How to reconcile duplicate copies of the same package across the folder
        structure. 'HardLink' (default) requires no admin rights but source and
        destination must be on the same volume. 'SymbolicLink' requires admin
        rights. 'Copy' uses more disk space but works across volumes.

    .PARAMETER Models
        Filter drivers pack by model. Supports tab-completion once the catalog has been downloaded at least once. Default is every model.

    .PARAMETER OperatingSystems
        Filter drivers pack by operating system. Supports tab-completion once the catalog has been downloaded at least once. Default is every operating system.

    .PARAMETER Architectures
        Filter drivers pack by architecture. Supports tab-completion once the catalog has been downloaded at least once. Default is every architecture.

    .EXAMPLE
        Get-BSADellDriverPack -MonthsBack 12

        Download CAB files less than 12 months old.

    .EXAMPLE
        Get-BSADellDriverPack -Architectures x86, x64 -OperatingSystems Windows10, Windows7 -MonthsBack 6

        Download CAB files less than 6 months old corresponding to x86 or x64 architectures and Windows 7 or 10 operating systems.

    .EXAMPLE
        Get-BSADellDriverPack -Models 'Latitude 7370', 'Latitude 7490'

        Download CAB files corresponding to models Latitude 7370 or Latitude 7490.

    .EXAMPLE
        Get-BSADellDriverPack -MonthsBack 12 -Verbose

        Same as the first example, but with a detailed, step-by-step trace of every
        filtering and download decision.

    .NOTES
        Created by: Brice SARRAZIN
        Filename: Get-BSADellDriverPack.ps1
#>

function Get-BSADellDriverPack {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param
    (
        [Parameter(HelpMessage = 'Driver Pack Catalog download address.')]
        [ValidateNotNullOrEmpty()]
        [string]$DriverCatalog = 'http://downloads.dell.com/catalog/DriverPackCatalog.cab',
        [Parameter(HelpMessage = 'Path to the folder where the drivers pack will be downloaded.')]
        [ValidateNotNullOrEmpty()]
        [string]$DownloadFolder = (Join-Path -Path $script:BSADellDriverPackDataPath -ChildPath 'Drivers'),
        [Parameter(HelpMessage = 'Folder structure to sort drivers pack into download folder.')]
        [string]$DriversStructure = '$($package.OperatingSystems)\$($package.Models)\$($package.Architectures)',
        [Parameter(HelpMessage = 'Download drivers pack newer than X month. Default is 0 (no time limit).')]
        [ValidateRange(0, 240)]
        [int]$MonthsBack = 0,
        [Parameter(HelpMessage = 'How to reconcile duplicate copies of the same package. Default is HardLink.')]
        [ValidateSet('HardLink', 'SymbolicLink', 'Copy')]
        [string]$DuplicateHandling = 'HardLink'
    )

    DynamicParam {
        # Dynamic parameters
        $parametersName = 'Models', 'OperatingSystems', 'Architectures'
        $runtimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary

        foreach ($parameterName in $parametersName) {
            Write-Debug "Creating dynamic parameter : $parameterName"
            $attributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
            $parameterAttribute = New-Object System.Management.Automation.parameterAttribute
            $attributeCollection.Add($parameterAttribute)
            $parameterCachePath = Join-Path -Path $script:BSADellDriverPackDataPath -ChildPath "$parameterName.txt"
            if (Test-Path $parameterCachePath) {
                $arrSet = Get-Content -Path $parameterCachePath
            } else {
                $arrSet = ''
            }
            $validateSetAttribute = New-Object System.Management.Automation.validateSetAttribute($arrSet)
            $attributeCollection.Add($validateSetAttribute)
            $runtimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($parameterName, [array], $attributeCollection)
            $PSBoundParameters[$parameterName] = '*'

            $runtimeParameterDictionary.Add($parameterName, $runtimeParameter)
            Write-Debug "Dynamic parameter created : $parameterName"
        }
        return $runtimeParameterDictionary
    }

    BEGIN {
        Write-Verbose "Duplicate handling mode : $DuplicateHandling"
        if ($DuplicateHandling -eq 'SymbolicLink') {
            # Check if the script is running as administrator
            $isAdmin = [bool](([System.Security.Principal.WindowsIdentity]::GetCurrent()).groups -match 'S-1-5-32-544')
            if (-not $isAdmin) {
                throw 'Administrator privileges are required to create symbolic links. Run this command as administrator, or use -DuplicateHandling HardLink (default) or Copy.'
            }
        }

        # Initialize variables
        $script:currentOperation = 0
        $script:totalOperations = 0
        $script:percentComplete = 0
        $script:lastProgressUpdate = $null
        $packagesMatched = 0
        $packagesDownloaded = 0
        $packagesAlreadyDownloaded = 0
        $packagesFailed = 0

        $models = $PSBoundParameters['Models']
        $operatingSystems = $PSBoundParameters['OperatingSystems']
        $architectures = $PSBoundParameters['Architectures']
        Write-Verbose "Models filter : $models"
        Write-Verbose "OperatingSystems filter : $operatingSystems"
        Write-Verbose "Architectures filter : $architectures"

        Write-Progress -Activity 'Get-BSADellDriverPack' -Status 'Checking download folder'
        if (-not (Test-Path $DownloadFolder)) {
            try {
                New-Item -Path $DownloadFolder -ItemType Directory -ErrorAction Stop | Out-Null
                Write-Verbose "Created download folder : $DownloadFolder"
            } catch {
                Write-Warning "Failed to create download folder '$DownloadFolder' : $($_.Exception.Message)"
            }
        }
    }

    PROCESS {

        #region Driver Pack Catalog
        Write-Progress -Activity 'Get-BSADellDriverPack' -Status 'Downloading Driver Pack Catalog'
        try {
            $driverCatalogFilename = Split-Path -Path $DriverCatalog -Leaf
            $temp = "$env:TEMP\$([guid]::NewGuid())"
            New-Item -Path $temp -ItemType Directory -ErrorAction Stop | Out-Null
            Start-BitsTransfer -DisplayName 'Driver Pack Catalog (CAB)' -Description "$DriverCatalog" -Source $DriverCatalog -Destination $temp -ErrorAction Stop
            Write-Verbose "Downloaded Driver Pack Catalog (CAB) from $DriverCatalog"
        } catch {
            Write-Warning "Failed to download Driver Pack Catalog from '$DriverCatalog' : $($_.Exception.Message)"
        }

        Write-Progress -Activity 'Get-BSADellDriverPack' -Status 'Expanding Driver Pack Catalog'
        try {
            $cabCatalogTempPath = Join-Path -Path $temp -ChildPath $driverCatalogFilename
            $oShell = New-Object -ComObject Shell.Application
            $sourceFile = $oShell.Namespace("$cabCatalogTempPath").items()
            $destinationFolder = $oShell.Namespace("$temp")
            $destinationFolder.CopyHere($sourceFile)
            Write-Verbose 'Expanded Driver Pack Catalog (CAB to XML)'
        } catch {
            Write-Warning "Failed to expand Driver Pack Catalog : $($_.Exception.Message)"
        }

        Write-Progress -Activity 'Get-BSADellDriverPack' -Status 'Moving Driver Pack Catalog to download folder'
        try {
            $xmlCatalogFilename = $driverCatalogFilename.Replace('.cab', '.xml')
            $xmlCatalogTempPath = Join-Path -Path $temp -ChildPath $xmlCatalogFilename
            $xmlCatalogPath = Join-Path -Path $DownloadFolder -ChildPath $xmlCatalogFilename
            Move-Item -Path $xmlCatalogTempPath -Destination $xmlCatalogPath -ErrorAction Stop -Force | Out-Null
            Write-Verbose "Moved Driver Pack Catalog (XML) to $xmlCatalogPath"
        } catch {
            Write-Warning "Failed to move Driver Pack Catalog to download folder : $($_.Exception.Message)"
        }

        Write-Progress -Activity 'Get-BSADellDriverPack' -Status 'Loading Driver Pack Catalog'
        $script:catalog = [xml](Get-Content $xmlCatalogPath)
        $uriRoot = 'http://' + $($script:catalog.DriverPackManifest | Select-Object -ExpandProperty baseLocation)
        Write-Verbose 'Loaded Driver Pack Catalog (XML)'
        #endregion


        #region Create/update attibute set for variables models, operatingSystems, architectures
        Write-MyProgress -Activity 'Updating models, operating systems and architectures variables'
        # Member access on a collection flattens the property across every element,
        # so this collects every model/OS/architecture in one pass, without the
        # O(n^2) cost of growing an array with += inside a loop.
        [array]$supportedModels = @($script:catalog.DriverPackManifest.DriverPackage.SupportedSystems.Brand.Model.name) | Select-Object -Unique | Sort-Object
        [array]$supportedOS = @($script:catalog.DriverPackManifest.DriverPackage.SupportedOperatingSystems.OperatingSystem.osCode) | Select-Object -Unique | Sort-Object
        [array]$supportedArch = @($script:catalog.DriverPackManifest.DriverPackage.SupportedOperatingSystems.OperatingSystem.osArch) | Select-Object -Unique | Sort-Object
        $supportedModels | Set-Content (Join-Path -Path $script:BSADellDriverPackDataPath -ChildPath 'Models.txt') -Force
        $supportedOS | Set-Content (Join-Path -Path $script:BSADellDriverPackDataPath -ChildPath 'OperatingSystems.txt') -Force
        $supportedArch | Set-Content (Join-Path -Path $script:BSADellDriverPackDataPath -ChildPath 'Architectures.txt') -Force
        Write-Verbose "Updated models, operating systems and architectures variables ($($supportedModels.Count) distinct models, $($supportedOS.Count) distinct operating systems, $($supportedArch.Count) distinct architectures)"
        #endregion


        # Create drivers pack list
        Write-MyProgress -Activity 'Creating drivers packs list'
        # Assigning the pipeline output directly (instead of += inside the loop)
        # avoids reallocating and copying the whole array on every package.
        $driversPacks = @($script:catalog.DriverPackManifest.DriverPackage | ForEach-Object {
            $packageName = $_.Name.Display.'#cdata-section'

            $package = New-Object PSObject -Property @{
                name             = $packageName
                format           = $_.format
                version          = $_.dellVersion
                models           = $_.SupportedSystems.Brand.Model.name | Select-Object -Unique
                operatingSystems = $_.SupportedOperatingSystems.OperatingSystem.osCode | Select-Object -Unique
                architectures    = $_.SupportedOperatingSystems.OperatingSystem.osArch | Select-Object -Unique
                date             = [datetime]$_.dateTime
                uri              = $uriRoot + '/' + $_.path
                hash             = $_.hashMD5
            }

            # DriversStructure is expanded only now that $package is fully populated,
            # since its default value (and documented syntax) references
            # $package.OperatingSystems / .Models / .Architectures. Expanding it
            # earlier, while $package didn't exist yet, silently produced an empty
            # subfolder structure.
            $package | Add-Member -MemberType NoteProperty -Name 'path' -Value (Join-Path -Path (Join-Path -Path $DownloadFolder -ChildPath ($ExecutionContext.InvokeCommand.ExpandString($DriversStructure))) -ChildPath $packageName)

            $package
        })
        Write-Verbose "Created drivers packs list ($($driversPacks.Count) packages)"

        # Filter drivers pack by date
        if ($MonthsBack -gt 0) {
            Write-MyProgress -Activity 'Filtering drivers pack by date'
            $monthsBackDate = [datetime]::Today.AddMonths(- $MonthsBack)
            $driversPacksBeforeFilter = $driversPacks.Count - 1
            $driversPacks = $driversPacks | Where-Object { $_.date -ge $monthsBackDate }
            $script:currentOperation = $script:currentOperation + ($driversPacksBeforeFilter - $driversPacks.Count - 1)
            Write-Verbose "Filtered drivers pack by date (newer than $MonthsBack month(s)) : $($driversPacks.Count) package(s) remaining"
        }

        # Filter drivers pack by models, operating systems and architectures
        foreach ($package in $driversPacks) {

            Write-MyProgress -Activity 'Downloading driver packs' -Status $package.name
            Write-Verbose "Package $($package.name)"

            # Filter by models
            if ($models -ne '*') {
                $modelsFound = Get-PackageFilterMatch -PackageValues $package.models -FilterValues $models
                if (!$modelsFound) {
                    Write-Verbose " - Models not matched : $($package.models)"
                    continue
                }
                Write-Verbose " - Models matched : $($modelsFound -join ', ')"
            }

            # Filter by operating systems
            if ($operatingSystems -ne '*') {
                $operatingSystemsFound = Get-PackageFilterMatch -PackageValues $package.operatingSystems -FilterValues $operatingSystems
                if (!$operatingSystemsFound) {
                    Write-Verbose " - Operating systems not matched : $($package.operatingSystems)"
                    continue
                }
                Write-Verbose " - Operating systems matched : $($operatingSystemsFound -join ', ')"
            }

            # Filter by architectures
            if ($architectures -ne '*') {
                $architecturesFound = Get-PackageFilterMatch -PackageValues $package.architectures -FilterValues $architectures
                if (!$architecturesFound) {
                    Write-Verbose " - Architectures not matched : $($package.architectures)"
                    continue
                }
                Write-Verbose " - Architectures matched : $($architecturesFound -join ', ')"
            }

            # All filters are OK
            $packagesMatched++

            # Check if the package is already downloaded
            try {
                $packageAlreadyExists = Test-ExistingPackage -Package $package -DownloadFolder $DownloadFolder -DuplicateHandling $DuplicateHandling
            } catch {
                Write-Warning "Failed to check existing copies of package '$($package.name)' : $($_.Exception.Message)"
                $packagesFailed++
                continue
            }
            if ($packageAlreadyExists) {
                Write-Verbose "Package $($package.name) already downloaded"
                $packagesAlreadyDownloaded++
                continue
            }

            # Download drivers pack
            try {
                $bitsTransferProps = @{
                    DisplayName = 'Downloading ' + $package.name + ' for'
                    Description = $package.models + ' - ' + $package.operatingSystems + ' ' + $package.architectures
                    Source      = $package.uri
                    Destination = Split-Path -Path $package.path
                    ErrorAction = 'Stop'
                }
                if (!(Test-Path $bitsTransferProps.Destination)) {
                    New-Item -Path $bitsTransferProps.Destination -ItemType Directory -ErrorAction Stop | Out-Null
                }
                Start-BitsTransfer @bitsTransferProps

                # Check file hash
                if (Test-PackageHash -FilePath $package.path -FileHash $package.hash) {
                    Write-Verbose "Downloaded package $($package.name)"
                    $packagesDownloaded++
                } else {
                    throw "File hash mismatch for package $($package.name)"
                }
            } catch {
                Write-Warning "Failed to download package '$($package.name)' : $($_.Exception.Message)"
                $packagesFailed++

                # Remove corrupted package
                if (Test-Path $package.path) {
                    try {
                        Remove-Item -Path $package.path -Force -ErrorAction Stop | Out-Null
                        Write-Verbose "Removed corrupted package : $($package.name)"
                    } catch {
                        Write-Warning "Failed to remove corrupted package '$($package.name)' : $($_.Exception.Message)"
                    }
                }
            }
        }

        Write-Progress -Activity 'Downloading driver packs' -Completed
    }
    END {
        $packagesRemoved = 0
        $Filter = '*.CAB'
        $LocalCABs = Get-Item $(Join-Path $DownloadFolder $Filter) -ErrorAction SilentlyContinue | Sort-Object Name | Select-Object -ExpandProperty Name

        if ($LocalCABs) {
            Write-Progress -Activity 'Get-BSADellDriverPack' -Status 'Removing outdated packages'
            foreach ($CurrentCAB in $LocalCABs) {

                $Filter = $CurrentCAB.Split('-')[0] + '-' + $CurrentCAB.Split('-')[1] + '-*-*'

                Get-Item $(Join-Path $DownloadFolder $Filter) -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name |
                ForEach-Object {
                    $oldCabName = $_

                    if (Test-NewerPackage -CandidateName $CurrentCAB -ReferenceName $oldCabName) {
                        try {
                            Remove-Item -Path $(Join-Path $DownloadFolder $oldCabName) -Force -ErrorAction Stop | Out-Null
                            Write-Verbose "Removed old package : $oldCabName"
                            $packagesRemoved++
                        } catch {
                            Write-Warning "Failed to remove $(Join-Path $DownloadFolder $oldCabName) : $($_.Exception.Message)"
                        }
                    }
                }
            }
        }

        Write-Progress -Activity 'Get-BSADellDriverPack' -Completed

        # Summary report, returned to the pipeline so it can be consumed by the
        # caller too (e.g. $result = Get-BSADellDriverPack; $result.Failed)
        [PSCustomObject]@{
            PackagesConsidered = $driversPacks.Count
            PackagesMatched    = $packagesMatched
            Downloaded         = $packagesDownloaded
            AlreadyDownloaded  = $packagesAlreadyDownloaded
            Failed             = $packagesFailed
            RemovedOutdated    = $packagesRemoved
            DownloadFolder     = $DownloadFolder
        }
    }
}