Private/Get-ApplicationFailures.ps1

# Private: Windows apps with failed installs, from the Intune app install status report (top 50 by failed devices).
function Get-ApplicationFailures {
    [CmdletBinding()]
    param([Parameter(Mandatory)] [AllowEmptyString()] [string] $TenantName)

    # Cross-platform temporary file path
    # Detect OS and set appropriate temp path
    $detectedWindows = $false

    # Check if automatic variables exist (PowerShell Core 6.0+)
    if (Get-Variable -Name "IsWindows" -ErrorAction SilentlyContinue) {
        $detectedWindows = $IsWindows
    }
    # Fallback for older PowerShell versions
    else {
        $osInfo = [System.Environment]::OSVersion.Platform
        switch ($osInfo) {
            "Win32NT" { $detectedWindows = $true }
            "Unix" {
                # macOS/Linux detected but not used in this context
            }
            default {
                try {
                    if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) {
                        $detectedWindows = $true
                    }
                    else {
                        # Non-Windows OS detected
                    }
                } catch {
                    $detectedWindows = $true
                }
            }
        }
    }

    $apps = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$filter=(isof(%27microsoft.graph.win32CatalogApp%27)%20or%20isof(%27microsoft.graph.windowsStoreApp%27)%20or%20isof(%27microsoft.graph.microsoftStoreForBusinessApp%27)%20or%20isof(%27microsoft.graph.officeSuiteApp%27)%20or%20(isof(%27microsoft.graph.win32LobApp%27)%20and%20not(isof(%27microsoft.graph.win32CatalogApp%27)))%20or%20isof(%27microsoft.graph.windowsMicrosoftEdgeApp%27)%20or%20isof(%27microsoft.graph.windowsPhone81AppX%27)%20or%20isof(%27microsoft.graph.windowsPhone81StoreApp%27)%20or%20isof(%27microsoft.graph.windowsPhoneXAP%27)%20or%20isof(%27microsoft.graph.windowsAppX%27)%20or%20isof(%27microsoft.graph.windowsMobileMSI%27)%20or%20isof(%27microsoft.graph.windowsUniversalAppX%27)%20or%20isof(%27microsoft.graph.webApp%27)%20or%20isof(%27microsoft.graph.windowsWebApp%27)%20or%20isof(%27microsoft.graph.winGetApp%27))%20and%20(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName&").value

    # Use a unique temporary file to avoid race conditions. Created after the app list, so a missing permission
    # (which fails that first call) leaves no file behind.
    $Data = [System.IO.Path]::GetTempFileName()

    $params = @{
        Select  = @(
            "DisplayName"
            "Publisher"
            "Platform"
            "AppVersion"
            "FailedDevicePercentage"
            "FailedDeviceCount"
            "FailedUserCount"
            "ApplicationId"
        )
        Skip    = 0
        Top     = 50
        Filter  = "(FailedDeviceCount gt '0')"
        OrderBy = @(
            "FailedDeviceCount desc"
        )
    }
    Invoke-MgGraphRequest -Body $params -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/getAppsInstallSummaryReport" -Method POST -OutputFilePath $Data

    $DataFile = Get-Content $Data
    # Fix char encoding to UTF-8
    $Response = [system.Text.Encoding]::UTF8.GetString(($DataFile).ToCharArray()) | ConvertFrom-Json

    # Pre-build app lookup (id -> app) so the per-row joins are O(1) instead of O(N) Where-Object scans.
    $appLookup = @{}
    foreach ($a in $apps) { if ($a.Id) { $appLookup[[string]$a.Id] = $a } }

    # First pass: materialise the schema-flattened rows that have a matching app,
    # collecting unique ApplicationIds so we can batch-fetch their assignments.
    $rows = [System.Collections.Generic.List[hashtable]]::new()
    $expandIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($value in $Response.Values) {
        $LineObject = @{ }
        foreach ($prop in $Response.Schema) {
            $LineObject[$prop.Column] = $value[$Response.Schema.IndexOf($prop)]
        }
        $appId = [string]$LineObject.ApplicationId
        if ($appLookup.ContainsKey($appId)) {
            $rows.Add($LineObject)
            [void]$expandIds.Add($appId)
        }
    }

    # Batch-fetch ?$expand=assignments for every failed app in parallel sub-requests.
    $assignmentStatusByAppId = @{}
    if ($expandIds.Count -gt 0) {
        $expandRequests = foreach ($id in $expandIds) {
            [PSCustomObject]@{
                Id  = "app:$id"
                Url = "/deviceAppManagement/mobileApps/$id/?`$expand=assignments"
            }
        }
        $expandResponses = Invoke-RKGraphBatch -Requests @($expandRequests) -Activity 'App assignments'
        foreach ($resp in $expandResponses) {
            if ($resp.Status -ne 200 -or -not $resp.Body) { continue }
            $appId = $resp.Id -replace '^app:', ''
            $hasAssignments = $resp.Body.assignments -and (@($resp.Body.assignments).Count -gt 0)
            $assignmentStatusByAppId[$appId] = [bool]$hasAssignments
        }
    }

    # Build result array from the prepared rows using O(1) lookups.
    $ReturnObject = New-Object System.Collections.ArrayList
    foreach ($LineObject in $rows) {
        $appId = [string]$LineObject.ApplicationId
        $PlatformName = if ($LineObject.Platform_loc) { $LineObject.Platform_loc } else { $LineObject.Platform }
        $AssignmentStatus = $false
        if ($assignmentStatusByAppId.ContainsKey($appId)) { $AssignmentStatus = $assignmentStatusByAppId[$appId] }

        $ReturnObject.Add([PSCustomObject][ordered]@{
                Customer               = $TenantName
                Application            = $appLookup[$appId].displayName
                Platform               = $PlatformName
                Version                = $LineObject.AppVersion
                AssignmentStatus       = $AssignmentStatus
                FailedUserCount        = $LineObject.FailedUserCount
                FailedDeviceCount      = $LineObject.FailedDeviceCount
                FailedDevicePercentage = [math]::Round([double]$LineObject.FailedDevicePercentage, 2)
            }) | Out-Null
    }

    # Clean up temporary data file
    if (Test-Path -Path $Data) {
        Remove-Item -Path $Data -Force
    }

    return $ReturnObject | Sort-Object -Property FailedDeviceCount -Descending
}