Invoke-IntuneWin32AppRedeploy.ps1

<#PSScriptInfo
.VERSION 2.0.6
.GUID 3f8e7d2a-5c4b-4e9f-a1d6-8b7c3e2f1a0d
.AUTHOR Mark Orr
.COPYRIGHT (c) 2026 Orr365. All rights reserved.
.DESCRIPTION Forces a redeploy of Intune Win32 applications by clearing local registry state (including GRS and Reporting keys) and restarting the Intune Management Extension service. Uses Microsoft Graph for app and user name resolution. Includes error code translation and automatic failed app detection.
.TAGS Intune Win32App Redeploy MicrosoftGraph Endpoint GRS Remediation
.LICENSEURI https://github.com/markorr321/Invoke-IntuneWin32AppRedeploy/blob/main/LICENSE
.PROJECTURI https://github.com/markorr321/Invoke-IntuneWin32AppRedeploy
.ICONURI
.EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication,Microsoft.Graph.DeviceManagement,Microsoft.Graph.Users
#>


function Invoke-IntuneWin32AppRedeploy {
    <#
    .SYNOPSIS
    Function for forcing redeploy of selected Win32App deployed from Intune.
 
    .DESCRIPTION
    Function for forcing redeploy of selected Win32App deployed from Intune.
     
    Features:
    - Interactive menu to select specific apps or redeploy all failed apps
    - GRS and Reporting registry cleanup for thorough state reset
    - Human-readable error code descriptions
    - Automatic transcript logging
 
    Redeploy means that corresponding registry keys (including GRS and Reporting) will be deleted and service IntuneManagementExtension will be restarted.
 
    .PARAMETER Online
    Switch for getting Apps and User names from Intune, so locally used IDs can be translated to them.
 
    .PARAMETER excludeSystemApp
    Switch for excluding Apps targeted to SYSTEM.
 
    .PARAMETER LogPath
    Path for transcript logging. Defaults to C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\
 
    .EXAMPLE
    Invoke-IntuneWin32AppRedeploy
 
    Get and show Win32App(s) deployed from Intune to this computer. Interactive menu allows selecting specific apps or all failed apps.
 
    .EXAMPLE
    Invoke-IntuneWin32AppRedeploy -Online
 
    Get and show Win32App(s) deployed from Intune with friendly names. Interactive menu allows selecting specific apps or all failed apps.
 
    .NOTES
    Original Author: @AndrewZtrhgf
    Updated for Microsoft.Graph module with GRS remediation features
    #>


    [CmdletBinding()]
    param (
        [Alias('Online')]
        [switch] $fetchOnline,

        [switch] $excludeSystemApp,

        [string] $LogPath = "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\"
    )

    #region helper function
    function _getErrorDescription {
        param ([int]$errorCode)

        $errorCodes = @{
            0x00000000 = "Success"
            0x0000000D = "The data is invalid."
            0x00000057 = "One of the parameters was invalid."
            0x00000078 = "Function can't be called from custom actions."
            0x000004EB = "User chose not to try installation due to compatibility warning."
            0x80070641 = "Windows Installer service couldn't be accessed."
            0x80070642 = "User canceled installation."
            0x80070643 = "Fatal error during installation."
            0x80070644 = "Installation suspended, incomplete."
            0x80070645 = "Action only valid for currently installed products."
            0x80070646 = "Feature identifier isn't registered."
            0x80070647 = "Component identifier isn't registered."
            0x80070648 = "Unknown property."
            0x80070649 = "Handle is in an invalid state."
            0x8007064A = "Configuration data for this product is corrupt."
            0x8007064B = "Component qualifier not present."
            0x8007064C = "Installation source not available."
            0x8007064D = "Windows Installer package not supported - need newer version."
            0x8007064E = "Product is uninstalled."
            0x8007064F = "SQL query syntax invalid or unsupported."
            0x80070650 = "Record field does not exist."
            0x80070652 = "Another installation already in progress."
            0x80070653 = "Installation package couldn't be opened - verify package exists."
            0x80070654 = "Installation package couldn't be opened - invalid package."
            0x80070655 = "Error starting Windows Installer service UI."
            0x80070656 = "Error opening installation log file."
            0x80070657 = "Language of installation package not supported."
            0x80070658 = "Error applying transforms."
            0x80070659 = "Installation forbidden by system policy."
            0x8007065A = "Function couldn't be executed."
            0x8007065B = "Function failed during execution."
            0x8007065C = "Invalid or unknown table specified."
            0x8007065D = "Data supplied is wrong type."
            0x8007065E = "Data type not supported."
            0x8007065F = "Windows Installer service failed to start."
            0x80070660 = "Temp folder full or inaccessible."
            0x80070661 = "Installation package not supported on this platform."
            0x80070662 = "Component not used on this machine."
            0x80070663 = "Patch package couldn't be opened."
            0x80070664 = "Invalid Windows Installer patch package."
            0x80070665 = "Patch can't be processed - need newer Windows Installer."
            0x80070666 = "Another version already installed."
            0x80070667 = "Invalid command line argument."
            0x80070668 = "Installation not permitted from Terminal Server client session."
            0x80070669 = "Installer initiated a restart (success)."
            0x8007066A = "Upgrade patch can't find program to upgrade."
            0x8007066B = "Patch package not permitted by policy."
            0x8007066C = "Customizations not permitted by policy."
            0x8007066D = "Installation not permitted from Remote Desktop."
            0x8007066E = "Patch package isn't removable."
            0x8007066F = "Patch not applied to this product."
            0x80070670 = "No valid sequence for patches."
            0x80070671 = "Patch removal disallowed by policy."
            0x80070672 = "XML patch data is invalid."
            0x80070673 = "Admin failed to apply patch for advertised app."
            0x80070674 = "Windows Installer not accessible in Safe Mode."
            0x80070675 = "Multiple-package transaction failed - rollback disabled."
            0x80070676 = "App not supported on this Windows version (unsigned on ARM)."
            0x80070BC2 = "Restart required to complete install (3010)."
            0x80070BB8 = "Restart required to complete install."
        }

        if ($errorCode -eq 0) { return "Success" }
        if ($errorCode -eq 3010) { return "Success (restart required)" }

        $hexCode = '0x' + ([convert]::ToString($errorCode, 16).ToUpper().PadLeft(8, '0'))
        
        if ($errorCodes.ContainsKey([int64]$hexCode)) {
            return $errorCodes[[int64]$hexCode]
        }
        return "Unknown error (0x$($hexCode.Substring(2)))"
    }

    function _getLastHashValue {
        param (
            [string]$userObjectId,
            [string]$appId
        )

        $reportingKeyPath = "HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps\Reporting\$userObjectId\$appId"
        $reportCachePath = Get-ChildItem -Path $reportingKeyPath -Recurse -ErrorAction SilentlyContinue | 
            Where-Object { $_.PSChildName -eq $userObjectId -or $_.PSChildName -match '^[a-f0-9-]{36}$' } |
            Select-Object -First 1

        if ($reportCachePath) {
            $hashValue = Get-ItemProperty -Path $reportCachePath.PSPath -Name LastHashValue -ErrorAction SilentlyContinue
            return $hashValue.LastHashValue
        }
        return $null
    }

    function _removeAppRegistryKeys {
        param (
            [string]$userObjectId,
            [string]$appId,
            [string]$appKeyPath
        )

        $lastHashValue = _getLastHashValue -userObjectId $userObjectId -appId $appId

        $pathsToRemove = @(
            $appKeyPath,  # Main app key
            "HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps\Reporting\$userObjectId\$appId"  # Reporting key
        )

        # Add GRS key if we have the hash value
        if ($lastHashValue) {
            $pathsToRemove += "HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps\$userObjectId\GRS\$lastHashValue"
        }

        foreach ($path in $pathsToRemove) {
            if (Test-Path -Path $path) {
                Remove-Item -Path $path -Recurse -Force
                Write-Verbose "Removed registry key: $path"
            }
        }
    }

    function _getTargetName {
        param ([string] $id)

        Write-Verbose "Translating $id"

        if (!$id) {
            Write-Verbose "id was null"
            return
        } elseif ($id -eq 'device') {
            return 'Device'
        }

        $errPref = $ErrorActionPreference
        $ErrorActionPreference = "Stop"
        try {
            if ($id -eq '00000000-0000-0000-0000-000000000000' -or $id -eq 'S-0-0-00-0000000000-0000000000-000000000-000') {
                return 'Device'
            } elseif ($id -match "^S-1-5-21") {
                # it is local account
                return ((New-Object System.Security.Principal.SecurityIdentifier($id)).Translate([System.Security.Principal.NTAccount])).Value
            } else {
                # it is Entra ID account
                if ($fetchOnline) {
                    return ($intuneUser | Where-Object { $_.Id -eq $id }).UserPrincipalName
                } else {
                    return $id
                }
            }
        } catch {
            Write-Warning "Unable to translate $id to account name ($_)"
            $ErrorActionPreference = $errPref
            return $id
        }
    }

    function _getIntuneApp {
        param ([string] $appID)

        $intuneApp | Where-Object { $_.Id -eq $appID }
    }
    #endregion helper function

    #region prepare
    # Check if running as administrator, if not - self-elevate
    if (! ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
        Write-Host "Not running as administrator. Elevating..." -ForegroundColor Yellow
        
        # Get script path from script-level variable or MyInvocation
        $scriptPath = if ($script:ScriptPath) { $script:ScriptPath } else { $MyInvocation.MyCommand.Path }
        
        if ($scriptPath) {
            # Build parameters
            $params = @()
            if ($fetchOnline) { $params += "-fetchOnline" }
            if ($excludeSystemApp) { $params += "-excludeSystemApp" }
            if ($LogPath -ne "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\") { $params += "-LogPath `"$LogPath`"" }
            $paramString = $params -join " "
            
            $command = ". '$scriptPath'; Invoke-IntuneWin32AppRedeploy $paramString"
            Start-Process pwsh -Verb RunAs -ArgumentList "-NoExit", "-Command", $command
        } else {
            Write-Warning "Unable to determine script path. Please run as administrator manually."
        }
        return
    }

    # Start logging
    $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
    $logFile = Join-Path $LogPath "Win32AppRedeploy_$timestamp.log"
    if (!(Test-Path $LogPath)) { New-Item -Path $LogPath -ItemType Directory -Force | Out-Null }
    Start-Transcript -Path $logFile -ErrorAction SilentlyContinue | Out-Null
    Write-Host "Logging to: $logFile" -ForegroundColor Cyan

    if ($fetchOnline) {
        # Check for Microsoft.Graph modules
        $requiredModules = @('Microsoft.Graph.Authentication', 'Microsoft.Graph.DeviceManagement', 'Microsoft.Graph.Users')
        foreach ($module in $requiredModules) {
            if (!(Get-Module $module -ListAvailable)) {
                throw "Module '$module' is required. To install it call: Install-Module 'Microsoft.Graph' -Scope CurrentUser"
            }
        }

        # Connect to Microsoft Graph (interactive authentication)
        $requiredScopes = @('DeviceManagementApps.Read.All', 'User.Read.All')
        try {
            $context = Get-MgContext
            if (-not $context) {
                Connect-MgGraph -Scopes $requiredScopes -NoWelcome -ErrorAction Stop
            } else {
                # Validate that current connection has required scopes
                $missingScopes = $requiredScopes | Where-Object { $_ -notin $context.Scopes }
                if ($missingScopes) {
                    Write-Warning "Current Graph connection is missing required scopes: $($missingScopes -join ', '). Reconnecting..."
                    Disconnect-MgGraph -ErrorAction SilentlyContinue
                    Connect-MgGraph -Scopes $requiredScopes -NoWelcome -ErrorAction Stop
                }
            }
        } catch {
            throw "Failed to connect to Microsoft Graph: $_"
        }

        Write-Verbose "Getting Intune data"

        # Get mobile apps using Microsoft Graph
        $intuneApp = Get-MgDeviceAppManagementMobileApp -All -Property Id, DisplayName |
            Select-Object Id, DisplayName

        # Get users
        $intuneUser = Get-MgUser -All -Property Id, UserPrincipalName |
            Select-Object Id, UserPrincipalName
    }
    #endregion prepare

    #region get data
    # Folders to exclude - these are not app keys
    $excludedFolders = @('GRS', 'Reporting', 'OperationalState', 'AppAuthority')
    
    $win32App = foreach ($app in (Get-ChildItem "HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps" -ErrorAction SilentlyContinue)) {
        $userEntraObjectID = Split-Path $app.Name -Leaf

        # Skip non-user folders
        if ($userEntraObjectID -in $excludedFolders) {
            Write-Verbose "Skipping $userEntraObjectID folder"
            continue
        }

        if ($excludeSystemApp -and $userEntraObjectID -eq "00000000-0000-0000-0000-000000000000") {
            Write-Verbose "Skipping system deployments"
            continue
        }

        $userWin32AppRoot = $app.PSPath
        $win32AppIDList = Get-ChildItem $userWin32AppRoot -ErrorAction SilentlyContinue | 
            Where-Object { $_.PSChildName -notin $excludedFolders } |
            Select-Object -ExpandProperty PSChildName | 
            ForEach-Object { $_ -replace "_\d+$" } | 
            Select-Object -Unique

        $win32AppIDList | ForEach-Object {
            $win32AppID = $_

            # Skip if it looks like a non-app folder
            if ($win32AppID -in $excludedFolders) { return }

            Write-Verbose "Processing App ID $win32AppID"

            $newestWin32AppRecord = Get-ChildItem $userWin32AppRoot -ErrorAction SilentlyContinue | 
                Where-Object { $_.PSChildName -Match ([regex]::escape($win32AppID)) -and $_.PSChildName -notin $excludedFolders } | 
                Sort-Object -Descending -Property PSChildName | 
                Select-Object -First 1

            if (-not $newestWin32AppRecord) { return }

            $lastUpdatedTimeUtc = Get-ItemPropertyValue $newestWin32AppRecord.PSPath -Name LastUpdatedTimeUtc -ErrorAction SilentlyContinue
            try {
                $complianceStateMessage = Get-ItemPropertyValue "$($newestWin32AppRecord.PSPath)\ComplianceStateMessage" -Name ComplianceStateMessage -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
            } catch {
                Write-Verbose "`tUnable to get Compliance State Message data"
            }

            $lastError = $complianceStateMessage.ErrorCode
            if (!$lastError) { $lastError = 0 }
            $errorDescription = _getErrorDescription -errorCode $lastError
            $isFailed = ($lastError -ne 0 -and $lastError -ne 3010)

            if ($fetchOnline) {
                $property = [ordered]@{
                    "Scope"              = _getTargetName $userEntraObjectID
                    "DisplayName"        = (_getIntuneApp $win32AppID).DisplayName
                    "Id"                 = $win32AppID
                    "LastUpdatedTimeUtc" = $lastUpdatedTimeUtc
                    "ProductVersion"     = $complianceStateMessage.ProductVersion
                    "LastError"          = $lastError
                    "ErrorDescription"   = $errorDescription
                    "IsFailed"           = $isFailed
                    "ScopeId"            = $userEntraObjectID
                    "AppKeyPath"         = $newestWin32AppRecord.PSPath
                }
            } else {
                $property = [ordered]@{
                    "ScopeId"            = _getTargetName $userEntraObjectID
                    "Id"                 = $win32AppID
                    "LastUpdatedTimeUtc" = $lastUpdatedTimeUtc
                    "ProductVersion"     = $complianceStateMessage.ProductVersion
                    "LastError"          = $lastError
                    "ErrorDescription"   = $errorDescription
                    "IsFailed"           = $isFailed
                    "AppKeyPath"         = $newestWin32AppRecord.PSPath
                }
            }

            New-Object -TypeName PSObject -Property $property
        }
    }
    #endregion get data

    #region let user redeploy chosen app
    if ($win32App) {
        $failedApps = $win32App | Where-Object { $_.IsFailed -eq $true }
        $failedCount = ($failedApps | Measure-Object).Count
        $totalCount = ($win32App | Measure-Object).Count

        # Display summary
        Write-Host ""
        Write-Host "========================================" -ForegroundColor Cyan
        Write-Host " Win32 App Redeploy Tool" -ForegroundColor Cyan
        Write-Host "========================================" -ForegroundColor Cyan
        Write-Host ""
        Write-Host "Total apps found: $totalCount" -ForegroundColor White
        Write-Host "Failed apps: $failedCount" -ForegroundColor $(if ($failedCount -gt 0) { "Red" } else { "Green" })
        Write-Host ""

        # Interactive menu
        Write-Host "Select an option:" -ForegroundColor Cyan
        Write-Host " [1] Select specific apps to redeploy (Grid View)" -ForegroundColor White
        Write-Host " [2] Redeploy ALL failed apps ($failedCount apps)" -ForegroundColor $(if ($failedCount -gt 0) { "Yellow" } else { "Gray" })
        Write-Host " [3] Exit" -ForegroundColor White
        Write-Host ""

        $choice = Read-Host "Enter your choice (1-3)"

        $appToRedeploy = $null

        switch ($choice) {
            "1" {
                # Original Out-GridView selection
                $hasDisplayNameProp = $win32App | Get-Member -Name DisplayName
                $displayProps = if ($hasDisplayNameProp) {
                    @("Scope", "DisplayName", "Id", "LastUpdatedTimeUtc", "ProductVersion", "LastError", "ErrorDescription", "IsFailed")
                } else {
                    @("ScopeId", "Id", "LastUpdatedTimeUtc", "ProductVersion", "LastError", "ErrorDescription", "IsFailed")
                }
                $appToRedeploy = $win32App | Where-Object { if ($hasDisplayNameProp) { if ($_.DisplayName) { $true } } else { $true } } | 
                    Select-Object $displayProps |
                    Out-GridView -PassThru -Title "Pick app(s) for redeploy (Failed apps have IsFailed=True)"
                
                # Map back to full objects with AppKeyPath
                if ($appToRedeploy) {
                    $appToRedeploy = $win32App | Where-Object { $_.Id -in $appToRedeploy.Id }
                }
            }
            "2" {
                if ($failedCount -gt 0) {
                    Write-Host ""
                    Write-Host "Selected $failedCount failed app(s) for redeploy..." -ForegroundColor Yellow
                    $appToRedeploy = $failedApps
                } else {
                    Write-Host "No failed apps to redeploy." -ForegroundColor Green
                }
            }
            "3" {
                Write-Host "Exiting..." -ForegroundColor Gray
                Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
                return
            }
            default {
                Write-Warning "Invalid choice. Exiting."
                Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
                return
            }
        }

        if (!$appToRedeploy) {
            Write-Warning "No apps selected for redeploy"
            Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
            return
        }

        Write-Host ""
        Write-Host "Preparing to redeploy $($appToRedeploy.Count) app(s)..." -ForegroundColor Cyan

        $appToRedeploy | ForEach-Object {
            $appId = $_.Id
            $scopeId = if ($_.ScopeId) { $_.ScopeId } else { $_.Scope }
            if ($scopeId -eq 'Device') { $scopeId = "00000000-0000-0000-0000-000000000000" }
            $appKeyPath = $_.AppKeyPath
            $appName = if ($_.DisplayName) { $_.DisplayName } else { $appId }

            Write-Host " Removing registry keys for: $appName" -ForegroundColor Yellow
            Write-Verbose "App ID: $appId, Scope: $scopeId"

            # Use new comprehensive cleanup function
            _removeAppRegistryKeys -userObjectId $scopeId -appId $appId -appKeyPath $appKeyPath
        }

        Write-Host ""
        Write-Host "Restarting IntuneManagementExtension service..." -ForegroundColor Cyan
        Write-Warning "Redeploy can take several minutes!"
        Restart-Service IntuneManagementExtension -Force
        Write-Host "Service restarted. Apps will begin redeploying." -ForegroundColor Green
        
        Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
    } else {
        Write-Warning "No deployed Win32App detected"
        Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
    }
    #endregion let user redeploy chosen app
}

# Auto-run when script is executed directly (not dot-sourced)
if ($MyInvocation.InvocationName -ne '.') {
    # Store script path before entering function
    $script:ScriptPath = $PSCommandPath
    Invoke-IntuneWin32AppRedeploy @PSBoundParameters
}