Create Dynamic Application Packages with PowerShell

Dynamic? In what way?

When I say the packages are dynamic, I mean that you don’t have to update the application package when a new version is released by the vendor.

There are caveats to both methods that we walk through below, there are also other community and paid for tools to do similar things.

However, the reason I wrote this post and started focusing on packaging applications in this was to avoid having support tickets when a new version is released, I also wanted to use this with ConfigMGR and Intune without the requirement of additional modules.

Show me the way!

Well lets show you a couple ways to do this, Web (HTML) Scraping and using the GitHub API.

Web Scraping

In my opinion, this is the most flawed method, as this replies on the website layout and/or table structure to stay the same as when you write your script. However, it is still an option and it works really well.

To be able to get the data from the tables in PowerShell we are going to need to use Invoke-WebRequest, Normally this would be super easy to use as it parses the HTML data for you. However, as this script will run as system in Intune you will need to launch it with -UseBasicParsing which complicates things a little more.

For this example we will use the Microsoft Remote Desktop Client, Are you ready? Lets begin. (You can achieve this using API Calls, However, this is a good example of table structure for Web Scraping)

Detection

Lets start by looking at the way we obtain the latest version and check it again the version in the registry.

As you can see from the image below, there is a version table right at the top of the web page.

If you press F12 and open the developer options, you can click through the HTML sections in the Elements tab and find the table like below;

As you can see from the snippet below we have to use a HTMLContent COM object to parse the HTML data so we can interact with the tables.

In it’s simplest form we get the RawContent and then write it to the IHTMLDocument2 object with the COM object, giving us the functionality work with the tables.

function Get-LatestVersion {
    [String]$URL = "https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/windowsdesktop-whatsnew"
    $WebResult = Invoke-WebRequest -Uri $URL -UseBasicParsing 
    $WebResultHTML = $WebResult.RawContent

    $HTML = New-Object -Com "HTMLFile"
    $HTML.IHTMLDocument2_write($WebResultHTML)

    $Tables = @($html.all.tags('table'))
    $LatestVer = $null
    [System.Collections.ArrayList]$LatestVer = New-Object -TypeName psobject
    for ($i = 0; $i -le $tables.count; $i++) {
        $table = $tables[0] 
        $titles = @()
        $rows = @($table.Rows)
        ## Go through all of the rows in the table
        foreach ($row in $rows) {
            $cells = @($row.Cells)
    
            ## If we've found a table header, remember its titles
            if ($cells[0].tagName -eq "TH") {
                $titles = @($cells | ForEach-Object {
                        ("" + $_.InnerText).Trim()
                    })
                continue
            }
            $resultObject = [Ordered] @{}
            $counter = 0
            foreach ($cell in $cells) {
                $title = $titles[$counter]
                if (-not $title) { continue }
                $resultObject[$title] = ("" + $cell.InnerText).Trim()
                $Counter++
            }
            #$Version_Data = @()
            $Version_Data = [PSCustomObject]@{
                'LatestVersion'          = $resultObject.'Latest version'
            }
            $LatestVer.Add($Version_Data) | Out-null 
        }
    }
    $LatestVer
}

Let’s take a closer look at the interaction with the tables, as you can see the variable $Tables uses the the $HTML variable which contains the COM object data to select everything with the tag of table ($Tables = @($html.all.tags('table'))). From this point it uses a for loop to gather the table data, until finally we decide which part of the table we want to use.

For example, We are focusing on the latest version, so if you run the for loop manually and look at $resultObject in PowerShell it will return something like this;

From this point you can create a PSCustomObject with the table header you want. Now this is kind of over complicating it for this example as you could just return $resultObject.'Latest version' however, I use this loop for other methods and keeping it in this format helps me standardise the way I work, but it also gives you the ability to use if for other things too.

All of this is wrapped inside a function (Get-LatestVersion) as I plan on using the same script for the detection method as for the install, but I also like to re-check in my install script that the application definitely is not installed before the install action executes. If you look at the Detect-Application function you can see that I check both the 64-bit and 32-bit registry locations with an IF statement based on the variables below;

$UninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\"
$UninstallKeyWow6432Node = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\"
$LatestVersion = ((Get-LatestVersion | Get-Unique | Sort-Object $_.LatestVersion)[0]).LatestVersion
$AppName = "Remote Desktop"

function Detect-Application {
    IF (((Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).DisplayVersion -Match $LatestVersion) -or ((Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).DisplayVersion -Match $LatestVersion))
    {
        $True
    }
}

The IF statement uses an -or operator, meaning if one of the conditions matches then run the code within the brackets below. As you can see from the variables the $LatestVersion uses the Get-LatestVersion function which is used to match the display version in the registry.

This the fundamental foundation of the operation, as we can now detect the application without using any additional modules in the next section we will look at the download and installation of the app.

Download Link
Download & Install

Before we look at the install function, lets look at the logic that calls the install.

Lets just assume you called the script with the Install execution type (.\<ScriptName.ps1 -ExecutionType Install) or launched it without any parameters.

Lets look inside the default section highlighted below, firstly it will check if the latest version of the application is not installed using an IF statement, ELSE return that it is already installed.

If the application is not installed it then proceeds to attempt the installation in a try{} catch{} statement. The basics of this is as it says, it will try the install, if it fails it will catch it and throw back the Write-Error text.

switch ($ExecutionType) {
    Detect { 
        Detect-Application 
    }
    Uninstall {
        try {
                Uninstall-Application -ErrorAction Stop
                "Uninstallation Complete"
        } 
        catch {
            Write-Error "Failed to Install $AppName"
        }
    }
    Default {
        IF (!(Detect-Application)) {
            try {
                "The latest version is not installed, Attempting install"
                Install-Application -ErrorAction Stop
                "Installation Complete"
            } catch {
                Write-Error "Failed to Install $AppName"
            }
        } ELSE {
            "The Latest Version ($LatestVersion) of $AppName is already installed"
        }
    }
}

Lets take a look at the Install-Application function that is called in the statement.

Lets Break it down into stages.

  1. Checks if the $DownloadPath exists, if not it will try to create it.
  2. Download the installer from the Link to the Download folder ($DownloadPath)
  3. Install the MSI with the additional command line arguments "$DownloadPath\$InstallerName"" /qn /norestart /l* ""$DownloadPath\RDINSTALL$(get-Date -format yyyy-MM-dd).log""

When using double quotes (") inside double quotes you must double them up.
For Example "The file is located: ""$Variable\Path.txt"""

$LatestVersion = ((Get-LatestVersion | Get-Unique | Sort-Object $_.LatestVersion)[0]).LatestVersion
$InstallerName = "RemoteDesktop-$LatestVersion-$Arch.msi"

function Install-Application {
    IF (!(Test-Path $DownloadPath)) {
        try {
            Write-Verbose "$DownloadPath Does not exist, Creating the folder"
            MKDIR $DownloadPath -ErrorAction Stop | Out-Null
        } catch {
            Write-Verbose "Failed to create folder $DownloadPath"
        }
    }
    
    try {
        Write-Verbose "Attempting client download"
        Invoke-WebRequest -Usebasicparsing -URI $DownloadLink -Outfile "$DownloadPath\$InstallerName" -ErrorAction Stop
    }
    catch {
        Write-Error "Failed to download $AppName"
    }
    
    try {
        "Installing $AppName v$($LatestVersion)"
        Start-Process "MSIEXEC.exe" -ArgumentList "/I ""$DownloadPath\$InstallerName"" /qn /norestart /l* ""$DownloadPath\RDINSTALL$(get-Date -format yyyy-MM-dd).log""" -Wait
    }
    catch {
        Write-Error "failed to Install $AppName"
    }
}
Uninstall

As we have a dynamic installation, we want the same for the uninstall right?

Well this is also achievable, take a look the the Uninstall-Application function below;

$UninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\"
$UninstallKeyWow6432Node = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\"
$AppName = "Remote Desktop"

function Uninstall-Application {
    try {
        "Uninstalling $AppName"

        IF (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName"
            $UninstallGUID = (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).PSChildName
            $UninstallArgs = "/X " + $UninstallGUID + " /qn"
            Start-Process "MSIEXEC.EXE" -ArgumentList $UninstallArgs -Wait
        }      
        
        IF (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName" 
            $UninstallGUID = (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).UninstallString
            $UninstallArgs = "/X " + $UninstallGUID + " /qn"
            Start-Process "MSIEXEC.EXE" -ArgumentList $UninstallArgs -Wait
        } 

    } catch {
        Write-Error "failed to Uninstall $AppName"
    }
}

This is using some of the same logic as the Detection method, It checks both the 64-bit and the 32-bit registry keys to see if an application that is like the display name of our application.

If a registry entry is detected, it will obtain the Key Name in this case as we are dealing with an MSI. This is because the MSI Key name is the GUID, it will then build up the MSIEXEC arguments for the uninstall. After it has completed both steps it will then process with the uninstallation.

Finished Script

If you compile all of the sections together with a little bit of formatting you will end up with a script like the one below.

Examples

To Install the 64-Bit version .\Dynamic-RemoteDesktopClient.ps1

To Install the 32-Bit version .\Dynamic-RemoteDesktopClient.ps1 -Arch '32-bit'

To detect the installation only .\Dynamic-RemoteDesktopClient.ps1 -ExecutionType Detect

To uninstall the application .\Dynamic-RemoteDesktopClient.ps1 -ExecutionType Uninstall

You will need to change the param block variable for $ExecutionType to $ExecutionType = Detect when using this as a detection method within Intune or ConfigMGR.

<#
.SYNOPSIS
  This is a script to Dynamically Detect, Install and Uninstall the Microsoft Remote Desktop Client for Windows.
  
  https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/windowsdesktop

.DESCRIPTION
  Use this script to detect, install or uninstall the Microsoft Remote Desktop client for Windows

.PARAMETER Arch
    Select the architecture you would like to install, select from the following
    - 64-bit (Default)
    - 32-bit
    - ARM64

.PARAMETER ExecutionType
    Select the Execution type, this determines if you will be detecting, installing uninstalling the application.
    
    The options are as follows;
    - Install (Default)
    - Detect
    - Uninstall

.Parameter DownloadPath
    The location you would like the downloaded installer to go. 

    Default: $env:TEMP\RDInstaller

.NOTES
  Version:        1.2
  Author:         David Brook
  Creation Date:  21/02/2021
  Purpose/Change: Initial script development
  
#>

param (
    [ValidateSet('64-bit','32-bit','ARM64')]
    [String]$Arch = '64-bit',
    [ValidateSet('Install','Uninstall',"Detect")]
    [string]$ExecutionType,
    [string]$DownloadPath = "$env:Temp\RDInstaller\"
)

function Get-LatestVersion {
    [String]$URL = "https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/windowsdesktop-whatsnew"
    $WebResult = Invoke-WebRequest -Uri $URL -UseBasicParsing 
    $WebResultHTML = $WebResult.RawContent

    $HTML = New-Object -Com "HTMLFile"
    $HTML.IHTMLDocument2_write($WebResultHTML)

    $Tables = @($html.all.tags('table'))
    $LatestVer = $null
    [System.Collections.ArrayList]$LatestVer = New-Object -TypeName psobject
    for ($i = 0; $i -le $tables.count; $i++) {
        $table = $tables[0] 
        $titles = @()
        $rows = @($table.Rows)
        ## Go through all of the rows in the table
        foreach ($row in $rows) {
            $cells = @($row.Cells)
    
            ## If we've found a table header, remember its titles
            if ($cells[0].tagName -eq "TH") {
                $titles = @($cells | ForEach-Object {
                        ("" + $_.InnerText).Trim()
                    })
                continue
            }
            $resultObject = [Ordered] @{}
            $counter = 0
            foreach ($cell in $cells) {
                $title = $titles[$counter]
                if (-not $title) { continue }
                $resultObject[$title] = ("" + $cell.InnerText).Trim()
                $Counter++
            }
            #$Version_Data = @()
            $Version_Data = [PSCustomObject]@{
                'LatestVersion'          = $resultObject.'Latest version'
            }
            $LatestVer.Add($Version_Data) | Out-null 
        }
    }
    $LatestVer
}
function Get-DownloadLink {
    $URL = "https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/windowsdesktop"
    $WebResult = Invoke-WebRequest -Uri $URL -UseBasicParsing 
    $WebResultHTML = $WebResult.RawContent

    $HTML = New-Object -Com "HTMLFile"
    $HTML.IHTMLDocument2_write($WebResultHTML)

    ($HTML.links | Where-Object {$_.InnerHTMl -Like "*$Arch*"}).href        
}
function Detect-Application {
    IF (((Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).DisplayVersion -Match $LatestVersion) -or ((Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).DisplayVersion -Match $LatestVersion))
    {
        $True
    }
}
function Install-Application {
    IF (!(Test-Path $DownloadPath)) {
        try {
            Write-Verbose "$DownloadPath Does not exist, Creating the folder"
            MKDIR $DownloadPath -ErrorAction Stop | Out-Null
        } catch {
            Write-Verbose "Failed to create folder $DownloadPath"
        }
    }
    
    try {
        Write-Verbose "Attempting client download"
        Invoke-WebRequest -Usebasicparsing -URI $DownloadLink -Outfile "$DownloadPath\$InstallerName" -ErrorAction Stop
    }
    catch {
        Write-Error "Failed to download $AppName"
    }
    
    try {
        "Installing $AppName v$($LatestVersion)"
        Start-Process "MSIEXEC.exe" -ArgumentList "/I ""$DownloadPath\$InstallerName"" /qn /norestart /l* ""$DownloadPath\RDINSTALL$(get-Date -format yyyy-MM-dd).log""" -Wait
    }
    catch {
        Write-Error "failed to Install $AppName"
    }
}

function Uninstall-Application {
    try {
        "Uninstalling $AppName"

        IF (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName"
            $UninstallGUID = (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).PSChildName
            $UninstallArgs = "/X " + $UninstallGUID + " /qn"
            Start-Process "MSIEXEC.EXE" -ArgumentList $UninstallArgs -Wait
        }      
        
        IF (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName" 
            $UninstallGUID = (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$AppName*"}).UninstallString
            $UninstallArgs = "/X " + $UninstallGUID + " /qn"
            Start-Process "MSIEXEC.EXE" -ArgumentList $UninstallArgs -Wait
        } 

    } catch {
        Write-Error "failed to Uninstall $AppName"
    }
}

$UninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\"
$UninstallKeyWow6432Node = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\"
$LatestVersion = ((Get-LatestVersion | Get-Unique | Sort-Object $_.LatestVersion)[0]).LatestVersion
$InstallerName = "RemoteDesktop-$LatestVersion-$Arch.msi"
$AppName = "Remote Desktop"
$DownloadLink = Get-DownloadLink

switch ($ExecutionType) {
    Detect { 
        Detect-Application 
    }
    Uninstall {
        try {
                Uninstall-Application -ErrorAction Stop
                "Uninstallation Complete"
        } 
        catch {
            Write-Error "Failed to Install $AppName"
        }
    }
    Default {
        IF (!(Detect-Application)) {
            try {
                "The latest version is not installed, Attempting install"
                Install-Application -ErrorAction Stop
                "Installation Complete"
            } catch {
                Write-Error "Failed to Install $AppName"
            }
        } ELSE {
            "The Latest Version ($LatestVersion) of $AppName is already installed"
        }
    }
}

That wraps up the Web Scraping method, I hope this proves useful when trying to make your apps more dynamic.

GitHub API

Using API calls is a better way to do dynamic updates. Some vendors host their content on GitHub as this provides build pipelines, wikis, projects and a whole host of other things. This is the method that is least likely to change, and if it does it will be documented using the GitHub API Docs.

For this example we are going to look at using Git for Windows, we will be using their GitHub Repo to query the version and also get the download.

GitHub has a rate limit for the API calls, unautenticated calls has a rate limit of 60, GitHub authenticated accounts has a limit of 5000 and GitHub Enterprise accounts has a limit of 15000 calls.

Each time the script is launched it used1 call, so in terms of a detection and installation you will need a 2 api calls.

You will need to take this into account if you plan to package multiple applications in this way, you could use multiple accounts and randomise the PAC Key from an array, however this is something that should be highlighted.

GIT Detection

Lets start by looking at the latest releases page.

The first thing you may notice that it automatically redirects the URL, but we just want to check the version.

Now that we know what the latest version is on the GitHub page, lets take a look at the API. If you change the URL in your browser to https://api.github.com/repos/git-for-windows/git/releases/latest, you will see a JSON response like the below.

{
  "url": "https://api.github.com/repos/git-for-windows/git/releases/37800609",
  "assets_url": "https://api.github.com/repos/git-for-windows/git/releases/37800609/assets",
  "upload_url": "https://uploads.github.com/repos/git-for-windows/git/releases/37800609/assets{?name,label}",
  "html_url": "https://github.com/git-for-windows/git/releases/tag/v2.30.1.windows.1",
  "id": 37800609,
  "author": {
    "login": "git-for-windows-ci",
    "id": 24522801,
    "node_id": "MDQ6VXNlcjI0NTIyODAx",
    "avatar_url": "https://avatars.githubusercontent.com/u/24522801?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/git-for-windows-ci",
    "html_url": "https://github.com/git-for-windows-ci",
    "followers_url": "https://api.github.com/users/git-for-windows-ci/followers",
    "following_url": "https://api.github.com/users/git-for-windows-ci/following{/other_user}",
    "gists_url": "https://api.github.com/users/git-for-windows-ci/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/git-for-windows-ci/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/git-for-windows-ci/subscriptions",
    "organizations_url": "https://api.github.com/users/git-for-windows-ci/orgs",
    "repos_url": "https://api.github.com/users/git-for-windows-ci/repos",
    "events_url": "https://api.github.com/users/git-for-windows-ci/events{/privacy}",
    "received_events_url": "https://api.github.com/users/git-for-windows-ci/received_events",
    "type": "User",
    "site_admin": false
  },
  "node_id": "MDc6UmVsZWFzZTM3ODAwNjA5",
  "tag_name": "v2.30.1.windows.1",
  "target_commitish": "main",
  "name": "Git for Windows 2.30.1",
  "draft": false,
  "prerelease": false,
  "created_at": "2021-02-09T12:53:04Z",
  "published_at": "2021-02-09T13:41:03Z",
  "assets": [ All objects in assets, this is just a snippet]
}

If you look at the highlighted line above, you will notice that the versions matches the one on the latest release page.

Now we know what property within the API we are looking for and how it displays, we can head into PowerShell and start working on the detection.

First of all we need to get the latest version, to do this we first perform and API Call to get all of the information and store the information in the $RestResult variable.

Take a look at the below snippet;

param (
    [ValidateSet('64-bit','32-bit','ARM64')]
    [String]$Arch = '64-bit',
    [ValidateSet('Install','Uninstall',"Detect")]
    [string]$ExecutionType = "Detect",
    [string]$DownloadPath = "$env:Temp\GitInstaller\",
    [string]$GITPAC 
)

##############################################################################
##################### Get the Information from the API #######################
##############################################################################
[String]$GitHubURI = "https://api.github.com/repos/git-for-windows/git/releases/latest"
IF ($GITPAC) {
    $RestResult = Invoke-RestMethod -Method GET -Uri $GitHubURI -ContentType "application/json" -Headers @{Authorization = "token $GITPAC"}
} ELSE {
    $RestResult = Invoke-RestMethod -Method GET -Uri $GitHubURI -ContentType "application/json"
}

##############################################################################
########################## Set Required Variables ############################
##############################################################################
$LatestVersion = $RestResult.name.split()[-1]

}

The first thing to note on this snippet is the method it will use to connect to the API, If you specify a Personal Access Token with the -GITPAC parameter or via the variable in the script you will be able to have 5000 API calls for your application installs.

In short we specify the $URL variable and then run a GET request with Invoke-RestMethod and specify that we want the output as application/json. Once it has the data we want to then format the $LatestVersion variable to return just the version number, for this we use the .split() operator, by default this splits on spaces, you can specify other characters to split it with by adding in something like '.' and it would split the string at every point there is a dot. Now we have split the string, we want to select the index, for this example as the version number is at the end we want to select the index [-1]. If the index was at the start we would use [0], feel free to experiment with this.

This variable is then used to call the Detect-Application function which will return True if the application is installed, otherwise it will return null.

$LatestVersion = $RestResult.name.split()[-1]
$UninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\"
$UninstallKeyWow6432Node = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\"
$DetectionString = "Git version"
$AppName = "Git For Windows"

##############################################################################
########################## Application Detection #############################
##############################################################################
function Detect-Application {
    IF (((Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).DisplayVersion -Match $LatestVersion) -or ((Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).DisplayVersion -Match $LatestVersion))
    {
        Write-Output "$AppName is installed"
        $True
    }
}
GIT Download Link
GIT Download & Install

The Install logic is the same as web scraping, however we will cover it here too so you don’t need to scroll up.

Lets just assume you called the script with the Install execution type (.\<ScriptName.ps1 -ExecutionType Install) or launched it without any parameters.

Lets look inside the default section highlighted below, firstly it will check if the latest version of the application is not installed using an IF statement, ELSE return that it is already installed.

If the application is not installed it then proceeds to attempt the installation in a try{} catch{} statement. The basics of this is as it says, it will try the installation, if it fails it will catch it and throw back the Write-Error text.

 posh
switch ($ExecutionType) {
    Detect { 
        Detect-Application 
    }
    Uninstall {
        try {
                Uninstall-Application -ErrorAction Stop
                "Uninstallation Complete"
        } 
        catch {
                Write-Error "Failed to Install $AppName"
        }
    }
    Default {
        IF (!(Detect-Application)) {
            try {
                "The latest version is not installed, Attempting install"
                Install-Application -ErrorAction Stop
                "Installation Complete"
            } catch {
                Write-Error "Failed to Install $AppName"
            }
        } ELSE {
            "The Latest Version is already installed"
        }
    }
}

Lets take a look at the Install-Application function that is called in the statement.

Lets Break it down into stages.

  1. Checks if the $DownloadPath exists, if not it will try to create it.
  2. Download the installer from the Link to the Download folder ($DownloadPath)
  3. Install the application with the additional command line arguments stored in the $InstallArgs variable.
param (
    [ValidateSet('64-bit','32-bit','ARM64')]
    [String]$Arch = '64-bit',
    [ValidateSet('Install','Uninstall',"Detect")]
    [string]$ExecutionType = "Detect",
    [string]$DownloadPath = "$env:Temp\GitInstaller\",
    [string]$GITPAC 
)

$LatestVersion = $RestResult.name.split()[-1]
$DownloadLink = ($RestResult.assets | Where-Object {$_.Name -Match $EXEName}).browser_download_url
$EXEName = "Git-$LatestVersion-$Arch.exe"
$InstallArgs = "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
$AppName = "Git For Windows"

##############################################################################
################## Application Installation/Uninstallation ###################
##############################################################################
function Install-Application {
    # If the Download Path does not exist, Then try and crate it. 
    IF (!(Test-Path $DownloadPath)) {
        try {
            Write-Verbose "$DownloadPath Does not exist, Creating the folder"
            New-Item -Path $DownloadPath -ItemType Directory -ErrorAction Stop | Out-Null
        } catch {
            Write-Verbose "Failed to create folder $DownloadPath"
        }
    }
    # Once the folder exists, download the installer
    try {
        Write-Verbose "Downloading Application Binaries for $AppName"
        Invoke-WebRequest -Usebasicparsing -URI $DownloadLink -Outfile "$DownloadPath\$EXEName" -ErrorAction Stop
    }
    catch {
        Write-Error "Failed to download application binaries"
    }
    # Once Downloaded, Install the application
    try {
        "Installing $AppName $($LatestVersion)"
        Start-Process "$DownloadPath\$EXEName" -ArgumentList $InstallArgs -Wait
    }
    catch {
        Write-Error "Failed to Install $AppName, please check the transcript file ($TranscriptFile) for further details."
    }
}
GIT Uninstall

As we have a dynamic installation, we want the same for the uninstall right?

Well this is also achievable, take a look the the Uninstall-Application function below;

Lets break this down,

  1. Check if an application is installed with a display name like the string stored in $DetectionString (Checks both 64 and 32 Uninstall Keys)
  2. If the application is installed, get the UninstallString from the key and store this in the $UninstallEXE variable.
  3. Uninstall the application using the $UninstallEXE with the command line arguments stored in the $UninstallArgs variable.

##############################################################################
################## Application Installation/Uninstallation ###################
##############################################################################

$UninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\"
$UninstallKeyWow6432Node = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\"
$DetectionString = "Git version"
$UninstallArgs = "/VERYSILENT /NORESTART"

function Uninstall-Application {
    try {
               
        IF (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName"
            $UninstallExe = (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).UninstallString
            Start-Process $UninstallExe -ArgumentList $UninstallArgs -Wait
        }      
        
        IF (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName" 
            $UninstallExe = (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).UninstallString
            Start-Process $UninstallExe -ArgumentList $UninstallArgs -Wait
        } 

    } catch {
        Write-Error "failed to Uninstall $AppName"
    }
}
GIT Finished Script

If you compile all of the sections together with a little bit of formatting you will end up with a script like the one below.

Examples

To install the 64-Bit version .\Dynamic-GitforWindows.ps1

To install the 32-Bit version .\Dynamic-GitforWindows.ps1 -Arch '32-bit'

To detect the installation only .\Dynamic-GitforWindows.ps1 -ExecutionType Detect

To install the application with a Git Personal Access Key .\Dynamic-GitforWindows.ps1 -ExecutionType Install -GITPAC <YourPAC>

To uninstall the application .\Dynamic-GitforWindows.ps1 -ExecutionType Uninstall

You will need to change the param block variable for $ExecutionType to $ExecutionType = Detect when using this as a detection method within Intune or ConfigMGR.


<#
.SYNOPSIS
  This is a script to Dynamically Detect, Install and Uninstall the Git for Windows Client.

  https://gitforwindows.org/

.DESCRIPTION
  Use this script to detect, install or uninstall the Git for Windows client.

.PARAMETER Arch
    Select the architecture you would like to install, select from the following
    - 64-bit (Default)
    - 32-bit
    - ARM64

.PARAMETER ExecutionType
    Select the Execution type, this determines if you will be detecting, installing uninstalling the application.
    
    The options are as follows;
    - Install (Default)
    - Detect
    - Uninstall

.Parameter DownloadPath
    The location you would like the downloaded installer to go. 

    Default: $env:TEMP\GitInstall

.NOTES
  Version:        1.0
  Author:         David Brook
  Creation Date:  21/02/2021
  Purpose/Change: Initial script development
  
#>

param (
    [ValidateSet('64-bit','32-bit','ARM64')]
    [String]$Arch = '64-bit',
    [ValidateSet('Install','Uninstall',"Detect")]
    [string]$ExecutionType = "Detect",
    [string]$DownloadPath = "$env:Temp\GitInstaller\",
    [string]$GITPAC 
)

$TranscriptFile = "$env:SystemRoot\Logs\Software\GitForWindows_Dynamic_Install.Log"
IF (-Not ($ExecutionType -Match "Detect")) {
    Start-Transcript -Path $TranscriptFile
}


##############################################################################
########################## Application Detection #############################
##############################################################################
function Detect-Application {
    IF (((Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).DisplayVersion -Match $LatestVersion) -or ((Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).DisplayVersion -Match $LatestVersion))
    {
        Write-Output "$AppName is installed"
        $True
    }
}

##############################################################################
################## Application Installation/Uninstallation ###################
##############################################################################
function Install-Application {
    # If the Download Path does not exist, Then try and crate it. 
    IF (!(Test-Path $DownloadPath)) {
        try {
            Write-Verbose "$DownloadPath Does not exist, Creating the folder"
            New-Item -Path $DownloadPath -ItemType Directory -ErrorAction Stop | Out-Null
        } catch {
            Write-Verbose "Failed to create folder $DownloadPath"
        }
    }
    # Once the folder exists, download the installer
    try {
        Write-Verbose "Downloading Application Binaries for $AppName"
        Invoke-WebRequest -Usebasicparsing -URI $DownloadLink -Outfile "$DownloadPath\$EXEName" -ErrorAction Stop
    }
    catch {
        Write-Error "Failed to download application binaries"
    }
    # Once Downloaded, Install the application
    try {
        "Installing $AppName $($LatestVersion)"
        Start-Process "$DownloadPath\$EXEName" -ArgumentList $InstallArgs -Wait
    }
    catch {
        Write-Error "Failed to Install $AppName, please check the transcript file ($TranscriptFile) for further details."
    }
}

function Uninstall-Application {
    try {
               
        IF (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName"
            $UninstallExe = (Get-ChildItem -Path $UninstallKey | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).UninstallString
            Start-Process $UninstallExe -ArgumentList $UninstallArgs -Wait
        }      
        
        IF (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"} -ErrorAction SilentlyContinue) {
            "Uninstalling $AppName" 
            $UninstallExe = (Get-ChildItem -Path $UninstallKeyWow6432Node | Get-ItemProperty | Where-Object {$_.DisplayName -like "*$DetectionString*"}).UninstallString
            Start-Process $UninstallExe -ArgumentList $UninstallArgs -Wait
        } 

    } catch {
        Write-Error "failed to Uninstall $AppName"
    }
}


##############################################################################
##################### Get the Information from the API #######################
##############################################################################
[String]$GitHubURI = "https://api.github.com/repos/git-for-windows/git/releases/latest"
IF ($GITPAC) {
    $RestResult = Invoke-RestMethod -Method GET -Uri $GitHubURI -ContentType "application/json" -Headers @{Authorization = "token $GITPAC"}
} ELSE {
    $RestResult = Invoke-RestMethod -Method GET -Uri $GitHubURI -ContentType "application/json"
}

##############################################################################
########################## Set Required Variables ############################
##############################################################################
$LatestVersion = $RestResult.name.split()[-1]
$EXEName = "Git-$LatestVersion-$Arch.exe"
$DownloadLink = ($RestResult.assets | Where-Object {$_.Name -Match $EXEName}).browser_download_url

##############################################################################
########################## Install/Uninstall Params ##########################
##############################################################################
$UninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\"
$UninstallKeyWow6432Node = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\"
$DetectionString = "Git version"
$UninstallArgs = "/VERYSILENT /NORESTART"
$InstallArgs = "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
$AppName = "Git For Windows"


##############################################################################
############################# Do the Business ################################
##############################################################################
switch ($ExecutionType) {
    Detect { 
        Detect-Application 
    }
    Uninstall {
        try {
                Uninstall-Application -ErrorAction Stop
                "Uninstallation Complete"
        } 
        catch {
                Write-Error "Failed to Install $AppName"
        }
    }
    Default {
        IF (!(Detect-Application)) {
            try {
                "The latest version is not installed, Attempting install"
                Install-Application -ErrorAction Stop
                "Installation Complete"
            } catch {
                Write-Error "Failed to Install $AppName"
            }
        } ELSE {
            "The Latest Version is already installed"
        }
    }
}

IF (-Not ($ExecutionType -Match "Detect")) {
    Stop-Transcript
}

Application Deployment

Please see Creating Intune Win32 Apps for creating an Intune Win32 App Package.

Lets look at how we deploy these applications from ConfigMG (MEMCM) and Intune.

Intune

Load up Microsoft Intune

  • Select Apps from the navigation pane
  • Select All Apps, Click Add
  • Select App type Other>Windows app (Win32), Click Select
  • Click Select app package file, Click the Blue Folder icon to open the browse window
  • Select the .intunewin file you have created containing a copy of the script, Click Open and then click OK
  • Fill out the Name and Publisher mandatory fields, and any other fields you desire
  • Upload an icon if you desire, I would recommend doing this if you are deploying this to users via the Company Portal
  • Click Next
  • Enter your install command powershell.exe -executionpolicy bypass ".\<Script Name.ps1>"
  • Enter your uninstall command powershell.exe -executionpolicy bypass ".\<Script Name.ps1>" -ExecutionType Uninstall
  • Select your install behaviour as System
  • Select your desired restart behaviour, Adding custom return codes if required
  • Click Next
  • Complete your OS Requirements, At a minimum you need to specify the Architecture and the minimum OS Version (e.g. 1607/1703 etc.)
  • Click Next
  • For Detection rules, select Use a custom detection script
    • Script File: Browse to a copy of the Script where the ExecutionType was amended to $ExecutionType = "Detect".
  • Assign the application to your desired group

If you want to display the app in the company portal, it MUST be assigned to a group containing that user. Required Assignments will force the app to install, whereas Available will show this in the Company Portal. Click Next

  • Click Create
ConfigMGR

Head over to your Software Library and Start Creating an application in your desired folder

  1. General Tab - Select Manually Specify the application information
  2. General Information - Input the information for your app
  3. Software Center - Input any additional information and upload an icon
  4. Deployment Types - Click Add
    1. Deployment Type - General - Change the Type to Script Installer
    2. Deployment Type - General Information - Provide a name and admin comments for your deployment type
    3. Deployment Type - Content
      1. Content Location - Select your content location (Where you saved the PowerShell Script)
      2. Installation Program - Powershell.exe -ExecutionPolicy Bypass -File “..ps1” -ExecutionType Install
      3. Uninstallation Program - Powershell.exe -ExecutionPolicy Bypass -File “..ps1” -ExecutionType Uninstall
      4. Detection Method - Select Use a custom script and click Edit
      5. Script Type - PowerShell
      6. Script Content - Paste the content of the script adding Detect to the header (If you are using a GitHub PAC key, you will also need to add this in)
    4. Installation Behavior - Install for System (Leave the reset as default or change as you desire)
    5. Dependencies & Requirements - Add any dependencies and requirements you wish
  5. Click through the windows to complete the creation
  6. Deploy the app to your desired collection

During the installation and the uninstallation of the apps, there is a transcript of the session that is by default stored in C:\Windows\Logs\Software. This will help in troubleshooting the install should you have any issues.


Other Blogs and Tools

Evergreen - Arron Parker

I came across this when putting a tweet out to see if this post was worth while, Well worth a read.

GitHub - aaronparker/Evergreen: Create evergeen Windows image build scripts with the latest version and download links for applications

GaryTown Blog Post Using Ninite Apps - Gary Blok

Ninite, is an awesome tool and Gary used this along with ConfigMGR to deploy applications with no content.

ConfigMgr Lab – Adding Ninite Apps – GARYTOWN ConfigMgr Blog

PatchMy PC - A leader in the 3rd Party Patching world

Now, this is not a community tool and it is licensed, however if you want to have this manage some of your Third Party apps with ConfigMGR, Intune or WSUS I would highly recommend them. This will save you a ton of time and help you on your way to having a fully patched estate.

Patch My PC: Simplify Third-Party Patching in Microsoft SCCM and Intune

comments powered by Disqus