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)
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.
Now we know that we can Detect the application, lets look at obtaining the download link.
If you look at the below snippet, you can see we use a variable which calls a function to get the download link ($DownloadLink = Get-DownloadLink
). For this to work there is a reliance on the the Variable $Arch
been set, by default this is set to 64-bit. However, this is available as a command line parameter.
param (
[ValidateSet('64-bit','32-bit','ARM64')]
[String]$Arch = '64-bit',
[ValidateSet('Install','Uninstall',"Detect")]
[string]$ExecutionType,
[string]$DownloadPath = "$env:Temp\RDInstaller\"
)
$DownloadLink = Get-DownloadLink
Lets take a look at the Get-DownloadLink
function, the basics of getting the data and writing it to an HTML COM object is the same as the detection method, however this time we do not need to look at a table, we are specifically looking for a link which matched the $Arch
variable.
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
}
If you look at the web page for the downloads you will see that the links are in an unordered list;
Again if you hit F12 and look at the html content behind the table, you will see the data we are looking for.
To get this using the script we simply run ($HTML.links | Where-Object {$_.InnerHTMl -Like "*$Arch*"}).href
simple right?
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.
- Checks if the
$DownloadPath
exists, if not it will try to create it. - Download the installer from the Link to the Download folder (
$DownloadPath
) - 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"
}
}
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.
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.
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
}
}
If you take a look back at the latest releases page, and scroll down to Assets, if you hover over one of them you will see the URL it links to in the bottom left-hand corner of your browser.
Now we know that we can Detect the application, lets look at obtaining the download link.
If you look at the script snippet below, you can see that we are still using the $RestResult
to obtain the download link. To get the download link for the architecture you specify we first have to build up the $EXEName
Variable, this uses the $LatestVersion
and $Arch
variables to bring the name together.
Once the name EXE Name is sorted, we then use this to get the link, by using the Where-Object
function to select the download URL from the asset where the $_.name
matches $EXEName
.
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
)
##############################################################################
########################## Set Required Variables ############################
##############################################################################
$LatestVersion = $RestResult.name.split()[-1]
$EXEName = "Git-$LatestVersion-$Arch.exe"
$DownloadLink = ($RestResult.assets | Where-Object {$_.Name -Match $EXEName}).browser_download_url
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.
- Checks if the
$DownloadPath
exists, if not it will try to create it. - Download the installer from the Link to the Download folder (
$DownloadPath
) - 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."
}
}
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,
- Check if an application is installed with a display name like the string stored in
$DetectionString
(Checks both 64 and 32 Uninstall Keys) - If the application is installed, get the UninstallString from the key and store this in the
$UninstallEXE
variable. - 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"
}
}
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.
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"
.
- Script File: Browse to a copy of the Script where the ExecutionType was amended to
- 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
Head over to your Software Library and Start Creating an application in your desired folder
- General Tab - Select Manually Specify the application information
- General Information - Input the information for your app
- Software Center - Input any additional information and upload an icon
- Deployment Types - Click Add
- Deployment Type - General - Change the Type to Script Installer
- Deployment Type - General Information - Provide a name and admin comments for your deployment type
- Deployment Type - Content
- Content Location - Select your content location (Where you saved the PowerShell Script)
- Installation Program - Powershell.exe -ExecutionPolicy Bypass -File “..ps1” -ExecutionType Install
- Uninstallation Program - Powershell.exe -ExecutionPolicy Bypass -File “..ps1” -ExecutionType Uninstall
- Detection Method - Select Use a custom script and click Edit
- Script Type - PowerShell
- 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)
- Installation Behavior - Install for System (Leave the reset as default or change as you desire)
- Dependencies & Requirements - Add any dependencies and requirements you wish
- Click through the windows to complete the creation
- 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.
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