Private/Invoke-IntuneGraphWin32Import.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Imports a Win32 app into Microsoft Intune via the Microsoft Graph API using
    the existing Connect-MgGraph session.
 
.DESCRIPTION
    Takes a parsed App.json definition, a .intunewin package, and the resolved
    version string, then performs the full Graph-based Win32 LobApp upload:
 
      1. Reads encryption metadata from the .intunewin zip (detection.xml)
      2. Creates the Win32 app metadata object via POST /mobileApps
      3. Creates a content version
      4. Creates a file entry and obtains the Azure Blob SAS upload URI
      5. Uploads the encrypted inner package (IntunePackage.intunewin) to
         Azure Blob Storage in 4 MB chunks
      6. Commits the file with encryption metadata
      7. Polls until the commit state is 'commitFileSuccess'
      8. Patches the app to set committedContentVersion
 
    All calls use Invoke-MgGraphRequest so the existing Connect-MgGraph session
    is the only auth dependency; Connect-MSIntuneGraph is never called.
 
.PARAMETER DefinitionObject
    Parsed App.json object.
 
.PARAMETER DefinitionPath
    Full path to App.json (used to build the Notes Date field and for logging).
 
.PARAMETER IntuneWinPath
    Full path to the .intunewin file produced by New-IntuneWin32AppPackage.
 
.PARAMETER SetupFilePath
    Setup file name used when the .intunewin package was built. When provided,
    this is used as win32LobApp.setupFilePath to avoid mismatches with
    PackageInformation.SetupFile in App.json.
 
.PARAMETER DownloadedVersion
    Version string of the installer that was packaged (may differ from the
    version recorded in App.json PackageInformation.Version).
 
.PARAMETER PSPackageFactoryGuid
    The PSPackageFactory GUID from App.json Information.PSPackageFactoryGuid.
 
.PARAMETER SyncHash
    The shared UI synchronised hashtable used by Write-UILog.
 
.OUTPUTS
    PSCustomObject with:
        Succeeded : bool
        IntuneAppId : string - the newly created Intune app ID
        DisplayName : string - the display name used at import time
        Error : string - populated on failure
#>

function Invoke-IntuneGraphWin32Import {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$DefinitionObject,

        [Parameter(Mandatory)]
        [string]$IntuneWinPath,

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

        [Parameter(Mandatory)]
        [string]$DownloadedVersion,

        [Parameter(Mandatory)]
        [string]$PSPackageFactoryGuid,

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

        [Parameter(Mandatory)]
        [System.Collections.Hashtable]$SyncHash
    )

    $fail = {
        param([string]$Msg)
        return [PSCustomObject]@{
            Succeeded   = $false
            IntuneAppId = ''
            DisplayName = ''
            Error       = $Msg
        }
    }

    # -- Validate prerequisites ------------------------------------------------
    if (-not (Get-Command -Name 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue)) {
        return (& $fail 'Invoke-MgGraphRequest is not available. Ensure Microsoft.Graph.Authentication is imported and Connect-MgGraph has been called.')
    }

    $mgContext = Get-MgContext -ErrorAction SilentlyContinue
    if ($null -eq $mgContext) {
        return (& $fail 'No active Microsoft Graph context. Call Connect-MgGraph first.')
    }

    if (-not (Test-Path -LiteralPath $IntuneWinPath -PathType Leaf)) {
        return (& $fail "IntuneWin file not found: $IntuneWinPath")
    }

    # -- Parse encryption info from the .intunewin zip ------------------------
    Write-UILog -Message 'Parsing .intunewin package metadata...' -Level Info -SyncHash $SyncHash

    Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue

    $encKey             = ''
    $encMacKey          = ''
    $encIV              = ''
    $encMac             = ''
    $encFileDigest      = ''
    $encFileDigestAlg   = 'SHA256'
    $encProfileId       = 'ProfileVersion1'
    $unencryptedSize    = [long]0
    $encryptedSize      = [long]0
    $encryptedFilePath  = ''

    $zipEntry_package   = $null
    $zipEntry_detection = $null

    try {
        $zip = [System.IO.Compression.ZipFile]::OpenRead($IntuneWinPath)
        try {
            foreach ($entry in $zip.Entries) {
                if ($entry.Name -ieq 'detection.xml')       { $zipEntry_detection = $entry }
                if ($entry.Name -ieq 'IntunePackage.intunewin') { $zipEntry_package = $entry }
            }

            if ($null -eq $zipEntry_detection) {
                return (& $fail 'detection.xml not found inside the .intunewin archive.')
            }
            if ($null -eq $zipEntry_package) {
                return (& $fail 'IntunePackage.intunewin not found inside the .intunewin archive.')
            }

            # Read detection.xml
            $detReader = [System.IO.StreamReader]::new($zipEntry_detection.Open())
            $detXml    = $detReader.ReadToEnd()
            $detReader.Close()
            [xml]$xmlDoc = $detXml
            $appInfo = $xmlDoc.ApplicationInfo
            $encInfo = $appInfo.EncryptionInfo

            $unencryptedSize  = [long]$appInfo.UnencryptedContentSize
            $encKey           = [string]$encInfo.EncryptionKey
            $encMacKey        = [string]$encInfo.MacKey
            $encIV            = [string]$encInfo.InitializationVector
            $encMac           = [string]$encInfo.Mac
            $encFileDigest    = [string]$encInfo.FileDigest
            $encFileDigestAlg = [string]$encInfo.FileDigestAlgorithm
            $encProfileId     = [string]$encInfo.ProfileIdentifier

            # Extract the encrypted inner package to a temp file
            $tempUploadFile  = Join-Path -Path $env:TEMP -ChildPath "IntuneUpload_$([System.Guid]::NewGuid().ToString('N')).intunewin"
            [System.IO.Compression.ZipFileExtensions]::ExtractToFile($zipEntry_package, $tempUploadFile, $true)
            $encryptedFilePath = $tempUploadFile
            $encryptedSize = ([System.IO.FileInfo]::new($encryptedFilePath)).Length
        }
        finally {
            $zip.Dispose()
        }
    }
    catch {
        return (& $fail "Failed to parse .intunewin archive: $($_.Exception.Message)")
    }

    # -- Build Win32 app display name ------------------------------------------
    $baseDisplayName = [string]$DefinitionObject.Information.DisplayName
    if ([string]::IsNullOrWhiteSpace($baseDisplayName)) {
        $baseDisplayName = [string]$DefinitionObject.Application.Name
    }
    # Replace version placeholder: strip existing version and append current
    $displayName = "$baseDisplayName $DownloadedVersion" -replace '\s+\d[\d\.]+$', ''
    $displayName = "$baseDisplayName $DownloadedVersion"

    Write-UILog -Message "Creating Win32 app: '$displayName'..." -Level Info -SyncHash $SyncHash

    # -- Build detection rules -------------------------------------------------
    $detectionRules = @()
    if ($null -ne $DefinitionObject.DetectionRule) {
        foreach ($rule in $DefinitionObject.DetectionRule) {
            $ruleType = [string]$rule.Type
            switch ($ruleType) {
                'File' {
                    $detType = switch ([string]$rule.DetectionMethod) {
                        'Version'   { 'version' }
                        'Existence' { 'exists' }
                        'Size'      { 'sizeOrDateModified' }
                        'DateModified' { 'modifiedDate' }
                        default     { 'version' }
                    }
                    $graphRule = [ordered]@{
                        '@odata.type'       = '#microsoft.graph.win32LobAppFileSystemDetection'
                        'path'              = [string]$rule.Path
                        'fileOrFolderName'  = [string]$rule.FileOrFolder
                        'check32BitOn64System' = ([string]$rule.Check32BitOn64System -ieq 'true')
                        'detectionType'     = $detType
                    }
                    if ($detType -ne 'exists') {
                        $graphRule['operator']       = [string]$rule.Operator
                        $graphRule['detectionValue'] = [string]$rule.VersionValue
                    }
                    $detectionRules += $graphRule
                }
                'Registry' {
                    $detType = switch ([string]$rule.DetectionMethod) {
                        'Version'   { 'version' }
                        'Existence' { 'exists' }
                        'String'    { 'string' }
                        'Integer'   { 'integer' }
                        'Boolean'   { 'boolean' }
                        default     { 'string' }
                    }
                    $graphRule = [ordered]@{
                        '@odata.type'         = '#microsoft.graph.win32LobAppRegistryDetection'
                        'keyPath'             = [string]$rule.KeyPath
                        'valueName'           = [string]$rule.ValueName
                        'check32BitOn64System' = ([string]$rule.Check32BitOn64System -ieq 'true')
                        'detectionType'       = $detType
                    }
                    if ($detType -ne 'exists') {
                        $graphRule['operator']       = [string]$rule.Operator
                        $graphRule['detectionValue'] = [string]$rule.DetectionValue
                    }
                    $detectionRules += $graphRule
                }
                'MSI' {
                    $detectionRules += [ordered]@{
                        '@odata.type'             = '#microsoft.graph.win32LobAppProductCodeDetection'
                        'productCode'             = [string]$rule.ProductCode
                        'productVersionOperator'  = [string]$rule.ProductVersionOperator
                        'productVersion'          = [string]$rule.ProductVersion
                    }
                }
            }
        }
    }

    # Fallback: create a minimal PowerShell script detection if none present
    if ($detectionRules.Count -eq 0) {
        Write-UILog -Message 'No detection rules defined in App.json; skipping detection rule upload.' -Level Warning -SyncHash $SyncHash
    }

    # -- Build minimum OS version object --------------------------------------
    $minOsRaw = [string]$DefinitionObject.RequirementRule.MinimumRequiredOperatingSystem
    $minOsKey = ($minOsRaw -replace '^W', 'v' -replace '^(v10|v11)_', '$1_').ToLowerInvariant()
    # Map "W10_1809" -> "v10_1809"
    $minOsKey = $minOsRaw -replace '^W10_', 'v10_' -replace '^W11_', 'v11_'

    $minimumOsObject = [ordered]@{ '@odata.type' = '#microsoft.graph.windowsMinimumOperatingSystem' }
    if (-not [string]::IsNullOrWhiteSpace($minOsKey)) {
        $minimumOsObject[$minOsKey] = $true
    }
    else {
        $minimumOsObject['v10_1809'] = $true  # safe default
    }

    # -- Build architecture value ----------------------------------------------
    $archRaw = [string]$DefinitionObject.RequirementRule.Architecture
    $applicableArchitectures = switch ($archRaw.ToLower()) {
        'x64'   { 'x64' }
        'x86'   { 'x86' }
        'arm64' { 'arm64' }
        'arm'   { 'arm' }
        default { 'x64' }
    }

    # -- Build install experience ----------------------------------------------
    $runAs          = if ([string]$DefinitionObject.Program.InstallExperience -ieq 'user') { 'user' } else { 'system' }
    $deviceRestart  = [string]$DefinitionObject.Program.DeviceRestartBehavior
    if ([string]::IsNullOrWhiteSpace($deviceRestart)) { $deviceRestart = 'suppress' }

    # -- Build Notes JSON (PSPackageFactory format) ----------------------------
    $importDate = (Get-Date).ToUniversalTime().ToString('o')
    $notesObject = [ordered]@{
        CreatedBy = 'PSPackageFactory'
        Guid      = $PSPackageFactoryGuid
        Date      = $importDate
    }
    $notesJson = $notesObject | ConvertTo-Json -Compress

    # -- Build icon (best-effort, skip on failure) -----------------------------
    $largeIcon = $null
    $iconUrl   = [string]$DefinitionObject.PackageInformation.IconFile
    if (-not [string]::IsNullOrWhiteSpace($iconUrl)) {
        try {
            $iconBytes    = (Invoke-WebRequest -Uri $iconUrl -UseBasicParsing -ErrorAction Stop).Content
            $iconMimeType = if ($iconUrl -match '\.png') { 'image/png' } elseif ($iconUrl -match '\.jpg|\.jpeg') { 'image/jpeg' } else { 'image/png' }
            $largeIcon    = [ordered]@{
                '@odata.type' = '#microsoft.graph.mimeContent'
                'type'        = $iconMimeType
                'value'       = [Convert]::ToBase64String($iconBytes)
            }
        }
        catch {
            Write-UILog -Message "Icon download skipped (non-fatal): $($_.Exception.Message)" -Level Warning -SyncHash $SyncHash
        }
    }

    # -- Build app body --------------------------------------------------------
    $setupFilePath = ''
    if (-not [string]::IsNullOrWhiteSpace($SetupFilePath)) {
        $setupFilePath = [System.IO.Path]::GetFileName($SetupFilePath)
    }
    else {
        $setupFilePath = [System.IO.Path]::GetFileName([string]$DefinitionObject.PackageInformation.SetupFile)
    }

    if ([string]::IsNullOrWhiteSpace($setupFilePath)) {
        $setupFilePath = [System.IO.Path]::GetFileName($IntuneWinPath)
    }

    $appBodyJson = ''
    $appBody = [ordered]@{
        '@odata.type'                = '#microsoft.graph.win32LobApp'
        'displayName'                = $displayName
        'description'                = [string]$DefinitionObject.Information.Description
        'publisher'                  = [string]$DefinitionObject.Information.Publisher
        'informationUrl'             = [string]$DefinitionObject.Information.InformationURL
        'privacyInformationUrl'      = [string]$DefinitionObject.Information.PrivacyURL
        'isFeatured'                 = $false
        'displayVersion'             = $DownloadedVersion
        'fileName'                   = [System.IO.Path]::GetFileName($IntuneWinPath)
        'installCommandLine'         = [string]$DefinitionObject.Program.InstallCommand
        'uninstallCommandLine'       = [string]$DefinitionObject.Program.UninstallCommand
        'installExperience'          = [ordered]@{
            'runAsAccount'          = $runAs
        }
        'deviceRestartBehavior'      = $deviceRestart
        'allowAvailableUninstall'    = [bool]$DefinitionObject.Program.AllowAvailableUninstall
        'setupFilePath'              = $setupFilePath
        'applicableArchitectures'    = $applicableArchitectures
        'minimumSupportedOperatingSystem' = $minimumOsObject
        'detectionRules'             = @($detectionRules)
        'requirementRules'           = @()
        'notes'                      = $notesJson
    }

    if ($null -ne $largeIcon) {
        $appBody['largeIcon'] = $largeIcon
    }

    $appBodyJson = $appBody | ConvertTo-Json -Depth 20

    # Log an equivalent Add-IntuneWin32App command line for troubleshooting parity.
    $logDisplayName = ([string]$displayName).Replace('"', "''")
    $logSetupFilePath = ([string]$setupFilePath).Replace('"', "''")
    $logInstallCommand = ([string]$DefinitionObject.Program.InstallCommand).Replace('"', "''")
    $logUninstallCommand = ([string]$DefinitionObject.Program.UninstallCommand).Replace('"', "''")
    $logInfoUrl = ([string]$DefinitionObject.Information.InformationURL).Replace('"', "''")
    $logPrivacyUrl = ([string]$DefinitionObject.Information.PrivacyURL).Replace('"', "''")

    $addIntuneCmdLine = @(
        'Add-IntuneWin32App'
        ("-FilePath {0}" -f $IntuneWinPath)
        ("-DisplayName {0}" -f $logDisplayName)
        ("-Publisher {0}" -f ([string]$DefinitionObject.Information.Publisher).Replace('"', "''"))
        ("-Description {0}" -f ([string]$DefinitionObject.Information.Description).Replace('"', "''"))
        ("-AppVersion {0}" -f $DownloadedVersion)
        ("-SetupFilePath {0}" -f $logSetupFilePath)
        ("-InstallCommandLine {0}" -f $logInstallCommand)
        ("-UninstallCommandLine {0}" -f $logUninstallCommand)
        ("-InformationURL {0}" -f $logInfoUrl)
        ("-PrivacyURL {0}" -f $logPrivacyUrl)
    ) -join ' '

    Write-UILog -Message $addIntuneCmdLine -Level Cmd -SyncHash $SyncHash

    # -- Step 1: Create the app ------------------------------------------------
    Write-UILog -Message "POST /beta/deviceAppManagement/mobileApps" -Level Cmd -SyncHash $SyncHash
    $appResponse = $null
    try {
        $appResponse = Invoke-MgGraphRequest -Method POST `
            -Uri 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' `
            -Body $appBodyJson `
            -ContentType 'application/json' `
            -OutputType PSObject -ErrorAction Stop
    }
    catch {
        $errorMessage = $_.Exception.Message
        $errorDetails = ''

        if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace([string]$_.ErrorDetails.Message)) {
            $errorDetails = [string]$_.ErrorDetails.Message
        }

        if ([string]::IsNullOrWhiteSpace($errorDetails)) {
            try {
                if ($null -ne $_.Exception.Response -and $null -ne $_.Exception.Response.Content) {
                    $rawResponse = $_.Exception.Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
                    if (-not [string]::IsNullOrWhiteSpace($rawResponse)) {
                        $errorDetails = $rawResponse
                    }
                }
            }
            catch {
                # Ignore secondary parse/read errors when building diagnostics.
            }
        }

        if (-not [string]::IsNullOrWhiteSpace($errorDetails)) {
            Write-UILog -Message ("Graph create-app response: {0}" -f $errorDetails) -Level Error -SyncHash $SyncHash
        }

        Write-UILog -Message ("Graph create-app request payload: {0}" -f $appBodyJson) -Level Cmd -SyncHash $SyncHash
        return (& $fail "Failed to create Win32 app: $errorMessage")
    }

    $appId = [string]$appResponse.id
    if ([string]::IsNullOrWhiteSpace($appId)) {
        return (& $fail 'App creation response did not contain an ID.')
    }
    Write-UILog -Message "Win32 app created: id=$appId" -Level Info -SyncHash $SyncHash

    $baseAppUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/microsoft.graph.win32LobApp"

    # -- Step 2: Create a content version -------------------------------------
    Write-UILog -Message "POST $baseAppUri/contentVersions" -Level Cmd -SyncHash $SyncHash
    $contentVersionResponse = $null
    try {
        $contentVersionResponse = Invoke-MgGraphRequest -Method POST `
            -Uri "$baseAppUri/contentVersions" `
            -Body '{}' `
            -ContentType 'application/json' `
            -OutputType PSObject -ErrorAction Stop
    }
    catch {
        return (& $fail ("Failed to create content version for app {0} - {1}" -f $appId, $_.Exception.Message))
    }

    $contentVersionId = [string]$contentVersionResponse.id
    Write-UILog -Message "Content version created: id=$contentVersionId" -Level Info -SyncHash $SyncHash

    # -- Step 3: Create the file entry -----------------------------------------
    Write-UILog -Message "POST $baseAppUri/contentVersions/$contentVersionId/files" -Level Cmd -SyncHash $SyncHash
    $fileBody = [ordered]@{
        '@odata.type'  = '#microsoft.graph.mobileAppContentFile'
        'name'         = 'IntunePackage.intunewin'
        'size'         = $unencryptedSize
        'sizeEncrypted' = $encryptedSize
        'isDependency' = $false
    }
    $fileEntryResponse = $null
    try {
        $fileEntryResponse = Invoke-MgGraphRequest -Method POST `
            -Uri "$baseAppUri/contentVersions/$contentVersionId/files" `
            -Body ($fileBody | ConvertTo-Json) `
            -ContentType 'application/json' `
            -OutputType PSObject -ErrorAction Stop
    }
    catch {
        return (& $fail "Failed to create file entry: $($_.Exception.Message)")
    }

    $fileId  = [string]$fileEntryResponse.id
    $sasUri  = [string]$fileEntryResponse.azureStorageUri
    Write-UILog -Message "File entry created: id=$fileId" -Level Info -SyncHash $SyncHash

    # Poll for SAS URI to become available if not immediately returned
    if ([string]::IsNullOrWhiteSpace($sasUri)) {
        $fileUri = "$baseAppUri/contentVersions/$contentVersionId/files/$fileId"
        $pollingAttempts = 0
        $maxPollingAttempts = 20
        while ([string]::IsNullOrWhiteSpace($sasUri) -and $pollingAttempts -lt $maxPollingAttempts) {
            Start-Sleep -Seconds 3
            $pollingAttempts++
            try {
                $fileStatus = Invoke-MgGraphRequest -Method GET -Uri $fileUri -OutputType PSObject -ErrorAction Stop
                $sasUri = [string]$fileStatus.azureStorageUri
            }
            catch { }
        }
    }

    if ([string]::IsNullOrWhiteSpace($sasUri)) {
        return (& $fail 'Azure Blob SAS URI was not returned after polling.')
    }

    # -- Step 4: Upload encrypted content to Azure Blob ------------------------
    Write-UILog -Message "Uploading package to Azure Blob Storage ($('{0:N0}' -f ($encryptedSize / 1MB)) MB)..." -Level Info -SyncHash $SyncHash

    try {
        $innerFileInfo = [System.IO.FileInfo]::new($encryptedFilePath)
        $actualSize    = $innerFileInfo.Length
        if ($encryptedSize -le 0) {
            $encryptedSize = $actualSize
        }
        $chunkSize     = 4 * 1024 * 1024  # 4 MB
        $blockIds      = [System.Collections.Generic.List[string]]::new()
        $blockNum      = 0
        $fileStream    = [System.IO.File]::OpenRead($encryptedFilePath)
        $buffer        = [byte[]]::new($chunkSize)

        try {
            while (($bytesRead = $fileStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
                # Always upload an exact byte[] slice for this block.
                # Using range slicing on a byte[] can produce object[] in PowerShell,
                # which corrupts the payload and causes commitFileFailed.
                $chunk = [byte[]]::new($bytesRead)
                [System.Array]::Copy($buffer, 0, $chunk, 0, $bytesRead)

                $blockId = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($blockNum.ToString('D8')))
                $blockIds.Add($blockId)

                $separator = if ($sasUri -match '\?') { '&' } else { '?' }
                $blockUrl  = "$sasUri${separator}comp=block&blockid=$([Uri]::EscapeDataString($blockId))"

                Invoke-RestMethod -Uri $blockUrl -Method Put `
                    -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } `
                    -Body $chunk -ContentType 'application/octet-stream' -ErrorAction Stop | Out-Null

                $blockNum++
                if ($blockNum % 10 -eq 0) {
                    $pct = [Math]::Round(($blockNum * $chunkSize / $actualSize) * 100, 0)
                    Write-UILog -Message "Upload progress: ~$pct%" -Level Info -SyncHash $SyncHash
                }
            }
        }
        finally {
            $fileStream.Close()
        }

        # Finalize: PUT block list
        $blockListXml = '<?xml version="1.0" encoding="utf-8"?><BlockList>'
        foreach ($id in $blockIds) { $blockListXml += "<Latest>$id</Latest>" }
        $blockListXml += '</BlockList>'

        $separator     = if ($sasUri -match '\?') { '&' } else { '?' }
        $blockListUrl  = "$sasUri${separator}comp=blocklist"
        Invoke-RestMethod -Uri $blockListUrl -Method Put -ContentType 'application/xml' `
            -Body $blockListXml -ErrorAction Stop | Out-Null

        Write-UILog -Message 'Upload to Azure Blob Storage complete.' -Level Info -SyncHash $SyncHash
    }
    catch {
        return (& $fail "Azure Blob upload failed: $($_.Exception.Message)")
    }
    finally {
        # Clean up temp file regardless of success/failure
        if (-not [string]::IsNullOrWhiteSpace($encryptedFilePath) -and
            (Test-Path -LiteralPath $encryptedFilePath)) {
            Remove-Item -LiteralPath $encryptedFilePath -Force -ErrorAction SilentlyContinue
        }
    }

    # -- Step 5: Commit the file ------------------------------------------------
    Write-UILog -Message 'Committing file to Intune...' -Level Info -SyncHash $SyncHash
    $commitBody = [ordered]@{
        'fileEncryptionInfo' = [ordered]@{
            '@odata.type'          = '#microsoft.graph.fileEncryptionInfo'
            'encryptionKey'       = $encKey
            'macKey'              = $encMacKey
            'initializationVector' = $encIV
            'mac'                 = $encMac
            'profileIdentifier'   = $encProfileId
            'fileDigest'          = $encFileDigest
            'fileDigestAlgorithm' = $encFileDigestAlg
        }
    }

    $fileUri = "$baseAppUri/contentVersions/$contentVersionId/files/$fileId"
    try {
        Invoke-MgGraphRequest -Method POST `
            -Uri "$fileUri/commit" `
            -Body ($commitBody | ConvertTo-Json) `
            -ContentType 'application/json' -ErrorAction Stop | Out-Null
    }
    catch {
        return (& $fail "File commit failed: $($_.Exception.Message)")
    }

    # Poll for commit success
    $commitSuccess  = $false
    $commitAttempts = 0
    $maxCommitAttempts = 40
    while (-not $commitSuccess -and $commitAttempts -lt $maxCommitAttempts) {
        Start-Sleep -Seconds 5
        $commitAttempts++
        try {
            $fileStatus   = Invoke-MgGraphRequest -Method GET -Uri $fileUri -OutputType PSObject -ErrorAction Stop
            $uploadState  = [string]$fileStatus.uploadState
            Write-UILog -Message "Commit state: $uploadState" -Level Info -SyncHash $SyncHash
            if ($uploadState -ieq 'commitFileSuccess') {
                $commitSuccess = $true
            }
            elseif ($uploadState -imatch 'failed|error') {
                try {
                    $failureDetails = $fileStatus | ConvertTo-Json -Depth 12 -Compress
                    Write-UILog -Message "Commit failure details: $failureDetails" -Level Error -SyncHash $SyncHash
                }
                catch {}
                return (& $fail "File commit reached failed state: $uploadState")
            }
        }
        catch {
            Write-UILog -Message "Commit poll error (retrying): $($_.Exception.Message)" -Level Warning -SyncHash $SyncHash
        }
    }

    if (-not $commitSuccess) {
        return (& $fail 'File commit did not reach commitFileSuccess state within the timeout period.')
    }

    # -- Step 6: Patch app with committed content version ---------------------
    Write-UILog -Message "Updating app '$appId' with committedContentVersion '$contentVersionId'..." -Level Info -SyncHash $SyncHash
    $patchBody = [ordered]@{
        '@odata.type'               = '#microsoft.graph.win32LobApp'
        'committedContentVersion'   = $contentVersionId
    }
    try {
        Invoke-MgGraphRequest -Method PATCH `
            -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId" `
            -Body ($patchBody | ConvertTo-Json) `
            -ContentType 'application/json' -ErrorAction Stop | Out-Null
    }
    catch {
        return (& $fail "Failed to set committedContentVersion on app '$appId': $($_.Exception.Message)")
    }

    Write-UILog -Message "Import complete. App '$displayName' created with id: $appId" -Level Info -SyncHash $SyncHash

    return [PSCustomObject]@{
        Succeeded   = $true
        IntuneAppId = $appId
        DisplayName = $displayName
        Error       = ''
    }
}