victory is mine

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:

  1. Version Checking: Implementing a version check mechanism to ensure that outdated versions of the app were removed and replaced. To add to the difficulty the app has multiple entries in Add/Remove programs. One for the .exe installer and another for an .msi that is embedded within the .exe.
  2. User Context Execution: Leveraging the RunAsUser module by Kelvin Tegelaar to execute installation tasks within the context of the logged-on user, thus sidestepping permission issues. This was crucial as the main app needed to be installed under the user context, while dependencies required installation under the system context.
  3. Dependency Management: Handling dependencies required by the app, such as pre-requisites or supporting components, to ensure a seamless installation process.

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:

  1. Script does nothing unless someone is logged on. (technically it does one thing, writes a log that says nobody was logged on)
  2. Checks for Workday Event Log Components and installs them if they are missing.
    1. Most of the logic to handles upgrades for this is there too but not implemented.
    2. If it installs this, it does so in the context of the local system.
  3. Checks for OfficeConnect and installs if not installed.
    1. Any time it installs OfficeConnect it uses the .msi and installs as the logged-on user. This gets rid of the mess in the registry and allows the user to perform upgrades themselves.  
    2. It is not necessary to run the script as the logged-on user, the script impersonates the logged-on user.
  4. If OfficeConnect is installed it checks the .exe installer as well as the .msi installer version numbers to see if they are current. (Those are not the same as one another.)
    1. If a version does not match the expected version it is uninstalled.
    2. OfficeConnect will not in-place upgrade, uninstalling before installing a new version is required.
    3. The uninstalls are tracked under a count variable. If count > 0 OfficeConnect is installed using the .msi and executed as the logged-on user. It is not necessary to run the script as the logged-on user, the script impersonates the logged-on user. 
    4. This could result in having more than one version of OfficeConnect installed if you put the wrong version number in the script for either the .exe or the .msi.  Be careful and test a lot!

Enjoy!

PowerShell
<#
.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