PowerShell: Check if Program or Update is Installed and Download with BITS and Install


Scripts & Tools

I recently wrote a PowerShell script for Windows that will check if a program or update is installed and, if not, download it using BITS in low priority, verify the download hash, and then install it and copy the verbose log to a central repository. The example is for the current latest Microsoft Surface Pro 7 firmware, but it can be adapted for just about any installer. 

Read on for the script code...

Some design considerations for the script are explained below. While solid and production-ready, sometimes I try to use these scripts as a teaching method for newer sysadmins.

  • I left a couple of debugging lines in the code to demonstrate how to do some basic troubleshooting & logging.
  • For the few locations output is written, I used Write-Host rather than the best practice of Write-Verbose. You are welcome to read up on the differences and decide if you want to adjust.
  • This is a simple script that most sysadmins can quickly review to understand exactly what it does and modify for their needs. If you need a more robust solution using PS, please look into the PowerShell App Deployment Toolkit.
  • UPDATE: The sample script previously used Win32_Product to query whether the product is already installed. This command is not optimized for queries and will create a delay of a few seconds while it looks up the information. It also performs a consistency check of installed apps. The updated version of this script queries the registry for install information. While querying the registry is faster, it sometimes misses installed applications. You will need to determine what works best for your own circumstances. I left the old line in the script, commented out, for reference.

Note: BITS supports the HTTP and HTTPS protocols. In the example, the download has been made available from a web server over HTTP. BITS also supports SMB.

Important: This script was written to work with installers/updaters in the MSI file format. Adjust the following line in the code to match your specific installer needs.
Start-Process -FilePath 'msiexec.exe' -ArgumentList "/i `"$DestinationFile`" /qn /norestart /l*v `"$InstallLogFile`"" -Wait -NoNewWindow

# Install SP7 Firmware

# Copyright © 2020 The Grim Admin (https://www.grimadmin.com)
# This code is licensed under the MIT license
# This software is provided 'as-is', without any express or implied warranties whatsoever.
# In no event will the authors, partners or contributors be held liable for any damages,
# claims or other liabilities direct or indirect, arising from the use of this software.

# DEBUGGING - Transcript Start & Write Verbose Messages
# Start-Transcript -Path "C:\TempPath\$(get-date -f "yyyy.MM.dd-HH.mm.ss")-HelpMe.txt" -Force
# $VerbosePreference = "Continue"

# Source File Information
$FileName = '2020-09-26 - SurfacePro7_Win10_18362_20.082.25905.0.msi'
$SourceFile = 'http://server.domain.com/customupdates/' + $FileName
$SourceFileHashSHA256 = 'F9602F61E57B9EB11939B2B7C23F380C4EFCD0906C544ED02C93C4AD8F6ADF9E'
$SourceProductName = 'Surface Pro 7 Update' # Partial Name is Fine as Long as it is Unique enough for a match
$SourceProductVersion = '20.082.25905.0'

# Destination File Information
$DestinationFolder = 'C:\SurfaceUpdate\'
$DestinationFile = $DestinationFolder + $FileName

# Set BITS Job Name
$BITSJobName = $SourceProductName

# Installed Log File Central Repository
# MAKE SURE THIS ALREADY EXISTS AND CAN BE WRITTEN TO BY THE PRINCIPLE (ACCOUNT)
# USED TO RUN THE SCRIPT (e.g., DOMAIN COMPUTERS)
$InstalledLogFileCentralRepository = $PSScriptRoot + '\Install Logs'

# FUNCTIONS
function Remove-BITSJobs
{
    param (
        [Parameter(Mandatory=$true)]
        [string]$DisplayName
    )

    $BITSJobs = Get-BITSTransfer | Where-Object 'DisplayName' -like $DisplayName
    $BITSJobs | Remove-BitsTransfer 
}

# DEBUGGING - Clear All BITS Jobs
# Remove-BITSJobs -DisplayName $BITSJobName

# Get a Listing of Installed Applications From the Registry
$InstalledApplicationsFromRegistry = @()
$InstalledApplicationsFromRegistry += Get-ItemProperty "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" # x86 Apps
$InstalledApplicationsFromRegistry += Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" # x64 Apps

# Is the Update or Newer Version Already Installed? If So, Copy the Log File Over.
$CurrentInstall = $InstalledApplicationsFromRegistry | Where-Object {$_.DisplayName -match $SourceProductName}
if ($CurrentInstall.DisplayVersion -eq $SourceProductVersion -or [System.Version]$CurrentInstall.DisplayVersion -ge [System.Version]$SourceProductVersion)
{
    Write-Verbose "$SourceProductName ($($CurrentInstall.DisplayVersion)) is already installed."

    # Try Creating Central Repository Log File if it Doesn't Already Exist and there is a file to copy over
    $CentralRepositoryLogFilePath = "$InstalledLogFileCentralRepository\$env:COMPUTERNAME.log"

    # Get Latest Log File But Only if the Destination Folder Exists
    $LatestLogFile = $null
    if (Test-Path -Path $DestinationFolder)
    {
        $LatestLogFile = Get-ChildItem $DestinationFolder | Where-Object Extension -eq '.log' | Sort-Object -Descending -Property 'LastWriteTime' | Select-Object -First 1
    }

    # Copy the log file if it exists and hasn't already been copied over
    if ((-Not (Test-Path -Path $CentralRepositoryLogFilePath)) -and ($null -ne $LatestLogFile))
    {
        Write-Verbose "Writing Log File to: $CentralRepositoryLogFilePath"
        Copy-Item -Path $LatestLogFile.VersionInfo.FileName -Destination $CentralRepositoryLogFilePath
    }

    # Exit the Script
    exit
}

# Is the Update File Already Downloaded?
if (Test-Path -Path $DestinationFile)
{
    # Check File Hash
    $DownloadedFileHash = Get-FileHash -Algorithm SHA256 -Path $DestinationFile | Select-Object -ExpandProperty 'Hash' 
    if ($DownloadedFileHash -eq $SourceFileHashSHA256)
    {
        # Install It Then Exit
        $DataStamp = get-date -Format yyyy-MM-dd-THHmmss
        $InstallLogFile = $DestinationFolder + '{0} - {1}.log' -f $DataStamp,$SourceProductName
        Start-Process -FilePath 'msiexec.exe' -ArgumentList "/i `"$DestinationFile`" /qn /norestart /l*v `"$InstallLogFile`"" -Wait -NoNewWindow
        exit
    }
    else
    {
        # Something is unfortunately wrong with the file. Delete it.
        Remove-Item -Path $DestinationFile -Force
    }
}

# Import BITSTransfer Module
Import-Module BITSTransfer
if (!(Get-Module -Name "BITSTransfer"))
{
   # module is not loaded
   Write-Error "Error loading the BITSTransfer Module"
   exit
}

# Create Destination Folder if Necessary
New-Item -ItemType Directory -Path $DestinationFolder -Force | Out-Null
if (-Not (Test-Path -Path $DestinationFolder))
{
    Write-Error "Cannot Create Destination Folder"
    exit
}

# See if there is already a job
$CurrentJob = Get-BITSTransfer | Where-Object 'DisplayName' -like $BitsJobName

# Reset if for some reason there is more than one job
if ($CurrentJob.Count -gt 1)
{
    Write-Verbose "Clearing out old $BITSJobName jobs"
    Remove-BITSJobs -DisplayName $BITSJobName

    # Null out $CurrentJob to reset the count to 0
    $CurrentJob = $null
}

# Start New Job If None Exist, otherwise attempt to resume 
if ($CurrentJob.Count -eq 0)
{
    Start-BITSTransfer -Source $SourceFile -Destination $DestinationFile -DisplayName $BITSJobName -Priority Low -Asynchronous
    $NextStep = 'WaitThenCheck'
}
else
{
    # Do Something Based on Job Status
    $CurrentJob = Get-BITSTransfer | Where-Object 'DisplayName' -like $BitsJobName
    switch ($CurrentJob.JobState)
    {
        'Transferred' {Complete-BitsTransfer -BitsJob $CurrentJob; $NextStep = 'Install'} # Renames the temporary download file to its final destination name and removes the job from the queue.
        'TransientError' {$NextStep = 'WaitThenCheck'} # No action needed. It will try again in a bit or eventually time out and goes to fatal error state.
        'Transferring' {$NextStep = 'WaitThenCheck'} # No action needed. It's currently downloading.
        'Connecting' {$NextStep = 'WaitThenCheck'} # No action needed. It's currently connecting.
        'Queued' {$NextStep = 'WaitThenCheck'} # No action needed. Specifies that the job is in the queue, and waiting to run. If a user logs off while their job is transferring, the job transitions to the queued state.
        'Suspended' {Resume-BitsTransfer -BitsJob $CurrentJob -Asynchronous; $NextStep = 'WaitThenCheck'}
        'Error' {Resume-BitsTransfer -BitsJob $CurrentJob -Asynchronous; $NextStep = 'WaitThenCheck'}
        Default {Write-Verbose "Current BITS job state is: $CurrentJob.JobState"; Write-Error "Unexpected job state."; exit} # The only two other options are Acknowledged & Cancelled and neither of these should appear. If they do exit.
    }
}

# Wait For Download to Complete or Error Out
$ErrorCheckCount = 0
$MaxAllowedErrorChecks = 10
while ($NextStep -eq 'WaitThenCheck')
{
    # Exit If Job Not Leaving Error State
    if ($ErrorCheckCount -ge $MaxAllowedErrorChecks)
    {
        Write-Error "Job remains in error state after $ErrorCheckCount attempts. Exiting."
        exit
    }
    
    # Wait for 60 seconds
    Start-Sleep -Seconds 60

    # Do Something Based on Job Status
    $CurrentJob = Get-BITSTransfer | Where-Object 'DisplayName' -like $BitsJobName
    switch ($CurrentJob.JobState)
    {
        'Transferred' {Complete-BitsTransfer -BitsJob $CurrentJob; $NextStep = 'Install'} # Renames the temporary download file to its final destination name and removes the job from the queue.
        'TransientError' {} # No action needed. It will try again in a bit or eventually time out and goes to fatal error state.
        'Transferring' {} # No action needed. It's currently downloading.
        'Connecting' {} # No action needed. It's currently connecting.
        'Queued' {} # No action needed. Specifies that the job is in the queue, and waiting to run. If a user logs off while their job is transferring, the job transitions to the queued state.
        'Suspended' {Resume-BitsTransfer -BitsJob $CurrentJob -Asynchronous}
        'Error' {Resume-BitsTransfer -BitsJob $CurrentJob -Asynchronous; $ErrorCheckCount += 1}
        Default {Write-Verbose "Current BITS job state is: $CurrentJob.JobState"; Write-Error "Unexpected job state."; exit} # The only two other options are Acknowledged & Cancelled and neither of these should appear. If they do, exit with error.
    }
}

# Install the Update
if ($NextStep -eq 'Install')
{
    # Sleep 10 Seconds Just in Case the File Rename Isn't Complete Yet.
    Start-Sleep -Seconds 10

    # Install It
    if (Test-Path -Path $DestinationFile)
    {
        # Check File Hash
        $DownloadedFileHash = Get-FileHash -Algorithm SHA256 -Path $DestinationFile | Select-Object -ExpandProperty 'Hash' 
        if ($DownloadedFileHash -eq $SourceFileHashSHA256)
        {
            # Install It Then Exit
            $DataStamp = get-date -Format yyyy-MM-dd-THHmmss
            $InstallLogFile = $DestinationFolder + '{0} - {1}.log' -f $DataStamp,$SourceProductName
            Start-Process -FilePath 'msiexec.exe' -ArgumentList "/i `"$DestinationFile`" /qn /norestart /l*v `"$InstallLogFile`"" -Wait -NoNewWindow
            exit
        }
        else
        {
            # Something is unfortunately wrong with the file. Delete it.
            Remove-Item -Path $DestinationFile -Force
        }
    }
}

# DEBUGGING - Transcript Stop
# Stop-Transcript

Tag: powershell bits installer install

Share It!

Be the first to comment