While considering titles for this post another that came to my mind was, “Why a Poorly Written Installer Requires 400 Lines of Code to Run Silently” but I felt as if the one I used was a little less harsh. What do you all think?
In my years of packaging applications, few have posed as many challenges as the one I recently encountered. This particular app package, OfficeConnect for WorkDay, demanded an unconventional approach to installation, requiring me to address unique requirements and devise innovative solutions. In sharing this experience, I hope to provide insight into how similar challenges can be tackled and why Intune admins must learn PowerShell.
The app I was tasked with packaging had several peculiarities that made the installation process anything but straightforward. From version management to user context execution, each aspect presented its own set of hurdles that needed to be overcome.
One particularly challenging aspect was the vendor’s policy regarding app versions. Once a new version is released, previous versions cease to function after a 30-day grace period. The app also does not support in-place upgrades. This meant that not only was updating necessary, but also that older versions could not simply be upgraded—they had to be completely uninstalled and replaced before they stop working.
As I have said many times, if you are going to do Intune you must learn PowerShell. Without PowerShell deploying this app would not have been possible. PowerShell allowed me to automate the deployment of the app as an Intune Win32 app while addressing the following key requirements:
Sharing Lessons Learned: This project served as a reminder of the importance of adaptability and innovation in the face of complex challenges. By documenting my experiences and solutions, I hope to offer guidance to others grappling with similar obstacles in their own app packaging endeavors.
While this app package may have been one of the most challenging, I’ve encountered in recent memory, it also provided an opportunity for growth and learning. I had fun with this!
I believe that you will find that the script is very well documented in its comments. It should be easy to adapt pieces of it to solve your own packaging challenges.
Script Highlights:
Enjoy!
<#
.DESCRIPTION
This script is used to install OfficeConnect for WorkDay.
https://doc.workday.com/adaptive-planning/en-us/product-downloads/gch1623709533848.html
The script will check for the presence of OfficeConnect. If the version is current, we do nothing.
If the version is not current, we remove it and install the user-based version of OfficeConnect.
Pre-reqs are also installed by the script.
Note: Workday Event Log Components must be installed first and uninstalled last!
Version: 5.0
Author: John Marcum
Date: 2/15/2024
#>
# Start logging
Start-Transcript "$($env:ProgramData)\Microsoft\IntuneManagementExtension\Logs\OfficeConnect_Install.log"
# Determine is anyone is logged on to the computer
$loggedOnUsers = Get-WmiObject -Class Win32_ComputerSystem | Select-Object -ExpandProperty UserName
if ($loggedOnUsers) {
Write-Host "Someone is logged on. Proceeding with script."
$loggedOnUsers
######## Begin Setting Variables ########
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
# Module that allows us to run code as the logged-on user
$ModuleName = 'RunAsUser'
# Names and versions of the required apps
$OCName = 'OfficeConnect'
$exeVersionMajor = [int]'2023'
$exeVersionMinor = [int]'214'
$exeBundleVersion = '2023.214.202.5527'
$msiVersionMajor = [int]'23'
$msiVersionMinor = [int]'2'
$CompName = 'Workday Event Log Components'
$CompVersion = [int]''
# Install variables for the Event Log Component
$ELMsiPath = Join-Path -Path $ScriptPath -ChildPath "EventLog\EventLogComponents.msi"
$ELLogFile = "$($env:ProgramData)\Microsoft\IntuneManagementExtension\Logs\EL_EventLogComponents_Install.log"
# Create and prepare installation folder
$OCInstallFolderPath = "C:\Temp\OC_Install"
# Create the installation folder if it doesn't exist
if (-not (Test-Path $OCInstallFolderPath)) {
New-Item -Path $OCInstallFolderPath -ItemType Directory
}
# Copy the contents of $ScriptPath\OfficeConnect to the installation folder
$OfficeConnectSourcePath = Join-Path -Path $ScriptPath -ChildPath "OfficeConnect"
Copy-Item -Path $OfficeConnectSourcePath -Destination $OCInstallFolderPath -Recurse -Force
# Create the script block used by RunAsUser to install the app as the logged on user.
$InstallBlock = {
Start-Transcript "C:\Temp\Script_Block.log"
# Define the path to the MSI file
$MSIPath = "C:\Temp\OC_Install\OfficeConnect\OfficeConnect.msi"
# Check if the MSI file exists
if (-not (Test-Path $MSIPath)) {
Write-Host "Error: OfficeConnect.msi not found at $MSIPath."
return
}
# Install the MSI file
Write-Host "Installing OfficeConnect from $MSIPath..."
$process = Start-Process -FilePath "msiexec.exe" -ArgumentList "/i `"$MSIPath`" /quiet /norestart /L*v `"C:\Temp\OfficeConnect_msi.log`"" -Wait -PassThru
# Capture the exit code
$exitCode = $process.ExitCode
# Output the exit code
Write-Host "msi installer exited with code:" $exitCode
Stop-Transcript
}
######## End: Setting Variables ########
######## Begin Functions ########
# Gets all installed software
function Get-InstalledSoftware {
[CmdletBinding()]
param (
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$ComputerName = $env:COMPUTERNAME,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$Name,
[Parameter()]
[guid]$Guid
)
process {
try {
$scriptBlock = {
$args[0].GetEnumerator() | ForEach-Object { New-Variable -Name $_.Key -Value $_.Value }
$UninstallKeys = @(
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall",
"HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
)
New-PSDrive -Name HKU -PSProvider Registry -Root Registry::HKEY_USERS | Out-Null
$UninstallKeys += Get-ChildItem HKU: | where { $_.Name -match 'S-\d-\d+-(\d+-){1,14}\d+$' } | foreach {
"HKU:\$($_.PSChildName)\Software\Microsoft\Windows\CurrentVersion\Uninstall"
}
if (-not $UninstallKeys) {
Write-Host 'No software registry keys found'
}
else {
foreach ($UninstallKey in $UninstallKeys) {
$friendlyNames = @{
'DisplayName' = 'Name'
'DisplayVersion' = 'Version'
'InstallLocation' = 'InstallPath'
}
Write-Host "Checking uninstall key [$($UninstallKey)]"
if ($Name) {
$WhereBlock = { $_.GetValue('DisplayName') -like "$Name*" }
}
elseif ($GUID) {
$WhereBlock = { $_.PsChildName -eq $Guid.Guid }
}
else {
$WhereBlock = { $_.GetValue('DisplayName') }
}
$SwKeys = Get-ChildItem -Path $UninstallKey -ErrorAction SilentlyContinue | Where-Object $WhereBlock
if (-not $SwKeys) {
Write-Host "No software keys in uninstall key $UninstallKey"
}
else {
Write-Host "$Name Found the keys $SwKeys"
foreach ($SwKey in $SwKeys) {
$output = @{ }
foreach ($ValName in $SwKey.GetValueNames()) {
if ($ValName -ne 'Version') {
$output.InstallLocation = ''
if ($ValName -eq 'InstallLocation' -and
($SwKey.GetValue($ValName)) -and
(@('C:', 'C:\Windows', 'C:\Windows\System32', 'C:\Windows\SysWOW64') -notcontains $SwKey.GetValue($ValName).TrimEnd('\'))) {
$output.InstallLocation = $SwKey.GetValue($ValName).TrimEnd('\')
}
[string]$ValData = $SwKey.GetValue($ValName)
if ($friendlyNames[$ValName]) {
$output[$friendlyNames[$ValName]] = $ValData.Trim() ## Some registry values have trailing spaces.
}
else {
$output[$ValName] = $ValData.Trim() ## Some registry values trailing spaces
}
}
}
$output.GUID = ''
if ($SwKey.PSChildName -match '\b[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\b') {
$output.GUID = $SwKey.PSChildName
}
New-Object -TypeName PSObject -Prop $output
}
}
}
}
}
if ($ComputerName -eq $env:COMPUTERNAME) {
& $scriptBlock $PSBoundParameters
}
else {
Invoke-Command -ComputerName $ComputerName -ScriptBlock $scriptBlock -ArgumentList $PSBoundParameters
}
}
catch {
Write-Host "Error: $($_.Exception.Message) - Line Number: $($_.InvocationInfo.ScriptLineNumber)"
}
}
}
# Determine if we have the modules we need. If not, install them.
Function Assert-ModuleExsist {
[CmdletBinding()]
param (
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$Name
)
if (Get-Module -ListAvailable -Name $Name) {
Write-Host "$Name Module exists, loading"
Import-Module $Name
}
else {
# no module, does user have admin rights?
Write-Host "$Name Module does not exist please install`r`n with install-module $Name"
if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(`
[Security.Principal.WindowsBuiltInRole] "Administrator")) {
Write-Host "Insufficient permissions to install module. Please run as an administrator and try again."
return(0)
}
else {
Write-Host "Attempting to install $Name"
Install-Module $Name -Confirm:$False -Force
}
}
}
# Function to uninstall apps using the uninstall strings found in the registry. Works for .exe and .msi
function Uninstall-Software {
param(
$uninstallString,
$displayName
)
if ([string]::IsNullOrEmpty($uninstallString)) {
Write-Host "Uninstall command not provided."
}
else {
# Check if the uninstall string is using msiexec.exe
if ($uninstallString -like '{*') {
Write-Host "Found a valid uninstall string!"
$MSIArguments = @(
'/x'
$uninstallString
'/quiet'
'/norestart'
'/L*v "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\Uninstall-' + $displayName + '-' + $count + '.log"'
)
Write-Host "Uninstalling software using msiexec:" $MSIArguments
$Process = Start-Process -FilePath "msiexec.exe" -ArgumentList $MSIArguments -Wait -PassThru
$process.ExitCode
Write-Host "Software has been uninstalled with exit code:" $process.ExitCode
}
else {
# Execute the uninstall command
Write-Host "Uninstalling exe installed software."
$Process = Start-Process -FilePath "cmd.exe" -ArgumentList "/c $uninstallString" -Wait
$process.ExitCode
Write-Host "Software has been uninstalled with exit code" $process.ExitCode
}
}
}
######## End Functions ########
######## Script Entry Point ########
# Install the RunAsUser module if it is not installed
Assert-ModuleExsist -Name $ModuleName
# Determine if Workday Event Log Components are installed
$CompInstalled = Get-InstalledSoftware -Name $CompName # | Select Name, Publisher, UninstallString, Version, VersionMajor, VersionMinor, BundleVersion , InstallDate, InstallPath
$CompInstalled
# Install Workday Event Log Components if it is not installed.
if (!($CompInstalled)) {
Write-Host "Workday Event Log Components Are Not Installed. Let's Install it"
# Check if the .msi file exists
if (-not (Test-Path $ELMsiPath)) {
Write-Host "Error: EventLogComponents.msi not found in the EventLog subfolder."
}
else {
# Install the .msi file
$result = Start-Process -FilePath "msiexec.exe" -ArgumentList "/i `"$ELMsiPath`" /qn /log `"$ELLogFile`"" -Wait -PassThru
Write-Host "Event Log Components Installation Exit Code: $($result.ExitCode)"
}
}
else {
Write-Host "Workday Event Log Components Are already installed."
}
# Determine if OfficeConnect is installed and its version
$OCInstalled = Get-InstalledSoftware -Name $OCName # | Select Name, Publisher, UninstallString, Version, VersionMajor, VersionMinor, BundleVersion , InstallDate, InstallPath
$OCInstalled
if ($OCInstalled) {
Write-Host "OfficeConnect is Insttalled. Let's check the version(s)."
# Initialize count to keep track of uninstalls
$count = 0
foreach ($Install in $OCInstalled) {
# Write-Host "Checking the following data:"$Install
Write-Host "Found the uninstall string:" $Install.UninstallString
#Convert the version sting to an integer
$intVersionMajor = [int]$Install.VersionMajor
$intVersionMinor = [int]$Install.VersionMinor
if ($Install.UninstallString -like "msiexec*") {
Write-host "Found and msi installer"
Write-Host "Expecting VersionMajor" $msiVersionMajor
Write-Host "Expect VersionMinor $msiVersionMinor or higher"
Write-Host "VersionMajor for this install is:" $Install.VersionMajor
Write-Host "VersionMinor for this install is:" $Install.VersionMinor
if (!($intVersionMajor -eq $msiVersionMajor -and $IntVersionMinor -ge $msiVersionMinor)) {
Write-Host "$intVersionMajor does not equal $msiVersionMajor or $intVersionMinor -not greater or equal to $msiVersionMinor"
Write-Host "OfficeConnect version is not what we expect. Remove and reinstall it."
# Increment the count variable
$count++
# Strip the GUID out of the uninstall string
$msiString = $Install.UninstallString
$braceIndex = $msiString.IndexOf('{')
# Check if the "{" character is found
if ($braceIndex -ge 0) {
# Remove everything before the "{" character
$msiGuid = $msiString.Substring($braceIndex)
# Output the MSI GUID
Write-Output "MSI GUID:" $msiGuid
}
else {
Write-Output "No MSI GUID found."
}
# Pass the GUID to the uninstaller
if ($msiGuid) {
Write-host "Passing" $msiGuid
Uninstall-Software -uninstallString $msiGuid -displayName $Install.Name +"-"+ $Count
}
else {
Write-Output "No MSI GUID found. We can't uninstall this instance!"
}
}
else {
Write-host "This instance of OfficeConnect version is current"
}
}
else {
Write-Host "Found an exe intaller"
Write-Host "Expecting VersionMajor" $exeVersionMajor
Write-host "Expecting VersionMinor $exeVersionMinor or higher"
Write-Host "We could also check the bundle version but major/minor allows for using greater than and less than whereas bundle version does not"
Write-Host "VersionMajor for this install is:" $Install.VersionMajor
Write-Host "VersionMinor for this install is:" $Install.VersionMinor
Write-Host "Bundle Version for this install is:" $Install.BundleVersion
if (!($intVersionMajor -eq $exeVersionMajor -and $IntVersionMinor -ge $exeVersionMinor)) {
Write-Host "$intVersionMajor does not equal $exeVersionMajor or $intVersionMinor -not greater or equal to $exeVersionMinor"
Write-Host "OfficeConnect version is not what we expect. Remove and reinstall it."
# Increment the count variable
$count++
# Pass the exe uninstall string to the uninstaller
Write-host "Passing the .exe uninstall string to the uninstaller" $Install.QuietUninstallString
Uninstall-Software -uninstallString $Install.QuietUninstallString
}
else {
Write-host "This instance of OfficeConnect version is current"
}
}
}
# If we performed any uninstalls we need to do an install as the logged on user.
Write-host "Number of uninstalls performed:" $count
if ($count -gt 0) {
# Invoke the script block to install OfficeConnect as the current user
Write-Host "Uninstalls are complete. Let's install the latest version as the logged on user"
Write-Host "Invoking OfficeConnect installation script block..."
Invoke-AsCurrentUser -ScriptBlock $InstallBlock
}
}
# OfficeConnect is not installed. Let's install it.
else {
Write-Host "OfficeConnect is Not Installed. Let's Install it"
Write-Host "Invoking OfficeConnect installation script block..."
# Invoke the script block as the current user
Invoke-AsCurrentUser -ScriptBlock $InstallBlock
}
# Cleanup the files we saved in C:\Temp
if (Test-Path $OCInstallFolderPath -PathType Container) {
Remove-Item -Path $OCInstallFolderPath -Recurse -Force
}
}
# Script did not run because no user was logged on.
else {
write-host "No user was logged on when the script ran. We should try again later."
}
# End: Main script logging
Stop-Transcript
Cookie | Duration | Description |
---|---|---|
cookielawinfo-checkbox-analytics | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Analytics". |
cookielawinfo-checkbox-functional | 11 months | The cookie is set by GDPR cookie consent to record the user consent for the cookies in the category "Functional". |
cookielawinfo-checkbox-necessary | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookies is used to store the user consent for the cookies in the category "Necessary". |
cookielawinfo-checkbox-others | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Other. |
cookielawinfo-checkbox-performance | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Performance". |
viewed_cookie_policy | 11 months | The cookie is set by the GDPR Cookie Consent plugin and is used to store whether or not user has consented to the use of cookies. It does not store any personal data. |