Public/Publish-IntuneWingetApp.ps1

<#
.SYNOPSIS
    Publishes a compiled .intunewin package directly to Microsoft Intune via Microsoft Graph.
.DESCRIPTION
    Automates the full Win32 app ingestion pipeline: creates the win32LobApp entity,
    extracts encryption headers, uploads chunked blocks to Azure Storage SAS URIs,
    commits the file, waits for Intune server-side processing to reach terminal succeeded state,
    and assigns to Entra groups.
#>

function Publish-IntuneWingetApp {
    [CmdletBinding()]
    [Alias('Publish-IntuneWin32App')]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$IntuneWinPath,

        [Parameter()]
        [string]$MetadataJsonPath = '',

        [Parameter()]
        [string[]]$AssignTo = @(),

        [Parameter()]
        [ValidateSet('Required', 'Available')]
        [string]$Intent = 'Required',

        [Parameter()]
        [int]$ProcessingTimeoutMinutes = 15
    )

    Write-Host "`n [WingetIntune] Microsoft Graph Win32 App Cloud Publisher" -ForegroundColor Cyan
    Write-Host " --------------------------------------------------------" -ForegroundColor DarkGray

    if (-not (Test-Path $IntuneWinPath)) {
        throw "IntuneWin package not found at: $IntuneWinPath"
    }

    # 1. Connect to Microsoft Graph with DeviceManagementApps scope
    $token = Connect-GraphToken -Scopes @('https://graph.microsoft.com/DeviceManagementApps.ReadWrite.All')
    $authHeader = @{
        'Authorization' = "Bearer $token"
        'Content-Type'  = 'application/json'
    }

    # 2. Parse Metadata
    $meta = $null
    if ($MetadataJsonPath -and (Test-Path $MetadataJsonPath)) {
        $meta = Get-Content $MetadataJsonPath -Raw | ConvertFrom-Json
    } else {
        $meta = [PSCustomObject]@{
            DisplayName          = [System.IO.Path]::GetFileNameWithoutExtension($IntuneWinPath)
            Publisher            = 'Enterprise IT'
            Description          = 'Automated Win32 Package deployed via WingetIntune.'
            InstallCommandLine   = 'powershell.exe -ExecutionPolicy Bypass -File .\Install.ps1'
            UninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass -File .\Uninstall.ps1'
        }
    }

    # 3. Read Detection Script content
    $detectionScriptBase64 = ''
    $detectFile = Join-Path (Split-Path $IntuneWinPath -Parent) 'Detect.ps1'
    if (Test-Path $detectFile) {
        $scriptBytes = [System.IO.File]::ReadAllBytes($detectFile)
        $detectionScriptBase64 = [Convert]::ToBase64String($scriptBytes)
    }

    # 4. Create win32LobApp in Microsoft Graph
    $appName = $meta.DisplayName
    Write-Host " [+] Creating Win32 App entity in Microsoft Intune: '$appName'..." -ForegroundColor Cyan
    $appPayload = @{
        '@odata.type'                    = '#microsoft.graph.win32LobApp'
        displayName                      = $meta.DisplayName
        description                      = $meta.Description
        publisher                        = $meta.Publisher
        installCommandLine               = $meta.InstallCommandLine
        uninstallCommandLine             = $meta.UninstallCommandLine
        applicableArchitectures          = 'x64'
        minimumSupportedOperatingSystem  = @{
            v10_19041 = $true
        }
    }

    if ($detectionScriptBase64) {
        $appPayload['detectionRules'] = @(
            @{
                '@odata.type'           = '#microsoft.graph.win32LobAppPowerShellScriptDetection'
                scriptContent           = $detectionScriptBase64
                enforceSignatureCheck   = $false
                runAs32Bit              = $false
            }
        )
    }

    $createUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps"
    $appObj = Invoke-ResilientGraphRest -Uri $createUri -Method POST -Headers $authHeader -Body $appPayload
    $appId = $appObj.id
    Write-Host " [OK] App entity created successfully! (App ID: $appId)" -ForegroundColor Green

    # 5. Extract detection.xml encryption metadata from .intunewin (ZIP)
    Write-Host " [+] Extracting package encryption metadata..." -ForegroundColor Cyan
    $tempZip = Join-Path $env:TEMP ("IntuneWinExtract_" + [Guid]::NewGuid().ToString('N'))
    [System.IO.Compression.ZipFile]::ExtractToDirectory($IntuneWinPath, $tempZip)
    $detectionXmlPath = Join-Path $tempZip 'Contents\detection.xml'

    if (-not (Test-Path $detectionXmlPath)) {
        throw "Invalid .intunewin package: missing Contents\detection.xml."
    }

    [xml]$doc = Get-Content $detectionXmlPath
    $encInfo = $doc.ApplicationInfo.EncryptionInfo
    $fileName = $doc.ApplicationInfo.FileName
    $unencryptedSize = [int64]$doc.ApplicationInfo.UnencryptedContentSize

    # 6. Create Content Version
    Write-Host " [+] Creating Intune Content Version..." -ForegroundColor Cyan
    $versionUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/contentVersions"
    $versionObj = Invoke-ResilientGraphRest -Uri $versionUri -Method POST -Headers $authHeader -Body @{}
    $versionId = $versionObj.id

    # 7. Create File Entry in Graph
    Write-Host " [+] Registering file entry in Microsoft Graph..." -ForegroundColor Cyan
    $fileUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/contentVersions/$versionId/files"
    $filePayload = @{
        '@odata.type'            = '#microsoft.graph.mobileAppContentFile'
        name                     = $fileName
        size                     = (Get-Item $IntuneWinPath).Length
        sizeEncrypted            = (Get-Item $IntuneWinPath).Length
        manifest                 = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($detectionXmlPath))
        isDependency             = $false
    }

    $fileObj = Invoke-ResilientGraphRest -Uri $fileUri -Method POST -Headers $authHeader -Body $filePayload
    $fileId = $fileObj.id

    # 8. Poll for Azure Storage SAS URI
    Write-Host " [+] Requesting Azure Storage SAS upload URI from Intune..." -ForegroundColor Cyan
    $fileStatusUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/contentVersions/$versionId/files/$fileId"
    $azureSasUri = $null

    for ($i = 0; $i -lt 30; $i++) {
        Start-Sleep -Seconds 3
        $fileState = Invoke-ResilientGraphRest -Uri $fileStatusUri -Method GET -Headers $authHeader
        if ($fileState.azureStorageUri) {
            $azureSasUri = $fileState.azureStorageUri
            break
        }
    }

    if (-not $azureSasUri) {
        throw "Timed out waiting for Azure Storage upload SAS URI from Intune."
    }

    # 9. Upload Chunked Blocks to Azure Storage with Durable State Machine
    Send-AzureBlockBlob -FilePath $IntuneWinPath -SasUri $azureSasUri

    # 10. Commit File in Microsoft Graph
    Write-Host " [+] Submitting file commit action to Microsoft Graph..." -ForegroundColor Cyan
    $commitUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/contentVersions/$versionId/files/$fileId/commit"
    $commitPayload = @{
        fileEncryptionInfo = @{
            encryptionKey          = $encInfo.EncryptionKey
            macKey                 = $encInfo.MacKey
            initializationVector   = $encInfo.InitializationVector
            mac                    = $encInfo.Mac
            profileIdentifier      = $encInfo.ProfileIdentifier
            fileDigest             = $encInfo.FileDigest
            fileDigestAlgorithm    = $encInfo.FileDigestAlgorithm
        }
    }

    Invoke-ResilientGraphRest -Uri $commitUri -Method POST -Headers $authHeader -Body $commitPayload | Out-Null

    # 11. Wait for Intune Server-Side Processing to reach Terminal Success State
    Write-Host " [+] Waiting for Intune server-side processing & metadata validation..." -NoNewline -ForegroundColor Cyan
    $procSw = [System.Diagnostics.Stopwatch]::StartNew()
    $fileCommitted = $false

    while ($procSw.Elapsed.TotalMinutes -lt $ProcessingTimeoutMinutes) {
        Start-Sleep -Seconds 10
        Write-Host "." -NoNewline -ForegroundColor Cyan

        try {
            $fileStatus = Invoke-ResilientGraphRest -Uri $fileStatusUri -Method GET -Headers $authHeader
            $state = $fileStatus.uploadState

            # Check for terminal success states
            if ($state -eq 'succeeded' -or $state -eq 'commitFileSuccess' -or ($fileStatus.sizeEncrypted -gt 0 -and $fileStatus.isCommitted)) {
                $fileCommitted = $true
                Write-Host "`n [OK] Intune file processing complete (State: $state, Encrypted Size: $($fileStatus.sizeEncrypted) bytes)." -ForegroundColor Green
                break
            }
            elseif ($state -eq 'commitFileFailed' -or $state -eq 'azureStorageUriRequestFailed' -or $state -eq 'failed') {
                throw "Intune server-side processing failed with uploadState: $state"
            }
        } catch {
            if ($_.Exception.Message -match 'failed') { throw $_ }
        }
    }
    $procSw.Stop()

    if (-not $fileCommitted) {
        Write-Warning "`nFile processing timed out after $ProcessingTimeoutMinutes minutes. Proceeding with content version binding."
    }

    # 12. Bind Committed Content Version to Mobile App
    Write-Host " [+] Binding Content Version to Mobile App..." -ForegroundColor Cyan
    $updateAppUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId"
    $updateAppPayload = @{
        '@odata.type'               = '#microsoft.graph.win32LobApp'
        committedContentVersion     = $versionId
    }
    Invoke-ResilientGraphRest -Uri $updateAppUri -Method PATCH -Headers $authHeader -Body $updateAppPayload | Out-Null

    # Cleanup temp extraction
    Remove-Item -Path $tempZip -Recurse -Force -ErrorAction SilentlyContinue

    Write-Host " [OK] App '$appName' successfully published to Intune!" -ForegroundColor Green

    # 13. Handle Group Assignments
    if ($AssignTo -and $AssignTo.Count -gt 0) {
        foreach ($group in $AssignTo) {
            Add-IntuneWingetAssignment -AppId $appId -GroupId $group -Intent $Intent
        }
    }

    return $appObj
}