Private/Win32Forge.Private.ps1

$script:Win32LobAppType = 'microsoft.graph.win32LobApp'
$script:DefaultGraphScopes = @('DeviceManagementApps.ReadWrite.All')
$script:DefaultChunkSizeInBytes = 6MB
$script:SasRenewalIntervalMilliseconds = 450000
$script:ComparisonDetectionOperators = @('notConfigured', 'equal', 'notEqual', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual')
$script:FileSystemDetectionTypes = @('notConfigured', 'exists', 'modifiedDate', 'createdDate', 'version', 'sizeInMB', 'doesNotExist')
$script:RegistryDetectionTypes = @('notConfigured', 'exists', 'doesNotExist', 'string', 'integer', 'version')

function Test-PublisherRequiredModule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [string]$CommandName
    )

    $module = Get-Module -ListAvailable -Name $Name | Select-Object -First 1
    if (-not $module) {
        throw "Required PowerShell module '$Name' is not installed. Install it with: Install-Module $Name -Scope CurrentUser"
    }

    Import-Module $Name -ErrorAction Stop

    if ($CommandName -and -not (Get-Command -Name $CommandName -ErrorAction SilentlyContinue)) {
        throw "Required command '$CommandName' was not found after importing module '$Name'."
    }
}

function Resolve-IntuneWin32PublisherPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Container', 'Leaf')]
        [string]$PathType
    )

    $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop
    $providerPath = $resolved.ProviderPath

    if (-not (Test-Path -LiteralPath $providerPath -PathType $PathType)) {
        throw "Path '$Path' must be a $PathType path."
    }

    $providerPath
}

function Resolve-IntuneWin32PublisherFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceDirectory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$FileName,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Purpose
    )

    $candidate = if ([System.IO.Path]::IsPathRooted($FileName)) {
        $FileName
    }
    else {
        Join-Path $SourceDirectory $FileName
    }

    if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) {
        throw "$Purpose file '$FileName' was not found under '$SourceDirectory'."
    }

    (Resolve-Path -LiteralPath $candidate -ErrorAction Stop).ProviderPath
}

function Get-SourceRelativePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceDirectory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$FilePath
    )

    [System.IO.Path]::GetRelativePath($SourceDirectory, $FilePath).Replace('\', '/')
}

function Compress-IntuneWin32PackageFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceDirectory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SetupFile,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputDirectory
    )

    Test-PublisherRequiredModule -Name 'SvRooij.ContentPrep.Cmdlet' -CommandName 'New-IntuneWinPackage'

    if (-not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) {
        New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null
    }

    # Package into a fresh per-invocation directory so the output file is unambiguous,
    # then move it to the caller-facing output directory.
    $packageDirectory = Join-Path $OutputDirectory ('pkg-' + [guid]::NewGuid().ToString('N'))
    New-Item -Path $packageDirectory -ItemType Directory -Force | Out-Null

    try {
        New-IntuneWinPackage -SourcePath $SourceDirectory -SetupFile $SetupFile -DestinationPath $packageDirectory | Out-Null

        $package = Get-ChildItem -LiteralPath $packageDirectory -Filter '*.intunewin' -File | Select-Object -First 1
        if (-not $package) {
            throw "New-IntuneWinPackage completed, but no .intunewin file was found in '$packageDirectory'."
        }

        $destinationPath = Join-Path $OutputDirectory $package.Name
        Move-Item -LiteralPath $package.FullName -Destination $destinationPath -Force
        (Resolve-Path -LiteralPath $destinationPath).ProviderPath
    }
    finally {
        Remove-Item -LiteralPath $packageDirectory -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Get-IntuneWin32PackageManifest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$PackagePath
    )

    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath)
    try {
        $entry = $archive.Entries | Where-Object { $_.Name -eq 'detection.xml' } | Select-Object -First 1
        if (-not $entry) {
            throw "Package '$PackagePath' does not contain detection.xml."
        }

        $stream = $entry.Open()
        try {
            $reader = [System.IO.StreamReader]::new($stream)
            try {
                [xml]$xml = $reader.ReadToEnd()
            }
            finally {
                $reader.Dispose()
            }
        }
        finally {
            $stream.Dispose()
        }
    }
    finally {
        $archive.Dispose()
    }

    if (-not $xml.ApplicationInfo) {
        throw "Package '$PackagePath' has an invalid detection.xml file."
    }

    $xml
}

function Expand-IntuneWin32EncryptedFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$PackagePath,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$FileName,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$DestinationDirectory
    )

    if (-not (Test-Path -LiteralPath $DestinationDirectory -PathType Container)) {
        New-Item -Path $DestinationDirectory -ItemType Directory -Force | Out-Null
    }

    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath)
    try {
        $entry = $archive.Entries | Where-Object { $_.Name -eq $FileName } | Select-Object -First 1
        if (-not $entry) {
            throw "Package '$PackagePath' does not contain encrypted content file '$FileName'."
        }

        $destinationPath = Join-Path $DestinationDirectory $FileName
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destinationPath, $true)
        $destinationPath
    }
    finally {
        $archive.Dispose()
    }
}

function ConvertTo-PowerShellScriptDetectionRule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ScriptPath,

        [bool]$EnforceSignatureCheck = $false,

        [bool]$RunAs32Bit = $false
    )

    if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) {
        throw "Detection script '$ScriptPath' was not found."
    }

    @{
        '@odata.type'         = '#microsoft.graph.win32LobAppPowerShellScriptDetection'
        enforceSignatureCheck = $EnforceSignatureCheck
        runAs32Bit            = $RunAs32Bit
        scriptContent         = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($ScriptPath))
    }
}

function Get-DetectionRuleValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RuleType,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [object]$Default
    )

    if ($Rule.Contains($Name)) {
        return $Rule[$Name]
    }

    if ($PSBoundParameters.ContainsKey('Default')) {
        return $Default
    }

    throw "$RuleType detection rule is missing required property '$Name'."
}

function Get-DetectionRuleStringValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RuleType,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [object]$Default
    )

    $parameters = @{
        Rule     = $Rule
        RuleType = $RuleType
        Name     = $Name
    }

    if ($PSBoundParameters.ContainsKey('Default')) {
        $parameters.Default = $Default
    }

    [string](Get-DetectionRuleValue @parameters)
}

function Get-DetectionRuleBooleanValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RuleType,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [bool]$Default = $false
    )

    $value = Get-DetectionRuleValue -Rule $Rule -RuleType $RuleType -Name $Name -Default $Default
    if ($value -isnot [bool]) {
        throw "$RuleType detection rule $Name must be `$true or `$false."
    }

    $value
}

function Get-DetectionRuleEnumValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RuleType,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$AllowedValue,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Description,

        [object]$Default
    )

    $parameters = @{
        Rule     = $Rule
        RuleType = $RuleType
        Name     = $Name
    }

    if ($PSBoundParameters.ContainsKey('Default')) {
        $parameters.Default = $Default
    }

    $value = Get-DetectionRuleStringValue @parameters
    if ($value -cnotin $AllowedValue) {
        throw "Unsupported $Description '$value'."
    }

    $value
}

function ConvertTo-Win32LobAppPowerShellScriptDetectionRule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceDirectory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$DefaultDetectionScript
    )

    $scriptFileName = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'PowerShellScript' -Name 'ScriptPath' -Default $DefaultDetectionScript
    $scriptPath = Resolve-IntuneWin32PublisherFile -SourceDirectory $SourceDirectory -FileName $scriptFileName -Purpose 'Detection script'

    ConvertTo-PowerShellScriptDetectionRule `
        -ScriptPath $scriptPath `
        -EnforceSignatureCheck (Get-DetectionRuleBooleanValue -Rule $Rule -RuleType 'PowerShellScript' -Name 'EnforceSignatureCheck') `
        -RunAs32Bit (Get-DetectionRuleBooleanValue -Rule $Rule -RuleType 'PowerShellScript' -Name 'RunAs32Bit')
}

function ConvertTo-Win32LobAppFileSystemDetectionRule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule
    )

    @{
        '@odata.type'        = '#microsoft.graph.win32LobAppFileSystemDetection'
        path                 = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'FileSystem' -Name 'Path'
        fileOrFolderName     = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'FileSystem' -Name 'FileOrFolderName'
        check32BitOn64System = Get-DetectionRuleBooleanValue -Rule $Rule -RuleType 'FileSystem' -Name 'Check32BitOn64System'
        detectionType        = Get-DetectionRuleEnumValue -Rule $Rule -RuleType 'FileSystem' -Name 'DetectionType' -AllowedValue $script:FileSystemDetectionTypes -Description 'file system detection type'
        operator             = Get-DetectionRuleEnumValue -Rule $Rule -RuleType 'FileSystem' -Name 'Operator' -AllowedValue $script:ComparisonDetectionOperators -Description 'file system detection operator' -Default 'notConfigured'
        detectionValue       = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'FileSystem' -Name 'DetectionValue' -Default ''
    }
}

function ConvertTo-Win32LobAppRegistryDetectionRule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule
    )

    @{
        '@odata.type'        = '#microsoft.graph.win32LobAppRegistryDetection'
        check32BitOn64System = Get-DetectionRuleBooleanValue -Rule $Rule -RuleType 'Registry' -Name 'Check32BitOn64System'
        keyPath              = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'Registry' -Name 'KeyPath'
        valueName            = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'Registry' -Name 'ValueName' -Default ''
        detectionType        = Get-DetectionRuleEnumValue -Rule $Rule -RuleType 'Registry' -Name 'DetectionType' -AllowedValue $script:RegistryDetectionTypes -Description 'registry detection type'
        operator             = Get-DetectionRuleEnumValue -Rule $Rule -RuleType 'Registry' -Name 'Operator' -AllowedValue $script:ComparisonDetectionOperators -Description 'registry detection operator' -Default 'notConfigured'
        detectionValue       = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'Registry' -Name 'DetectionValue' -Default ''
    }
}

function ConvertTo-Win32LobAppProductCodeDetectionRule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule
    )

    @{
        '@odata.type'          = '#microsoft.graph.win32LobAppProductCodeDetection'
        productCode            = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'ProductCode' -Name 'ProductCode'
        productVersionOperator = Get-DetectionRuleEnumValue -Rule $Rule -RuleType 'ProductCode' -Name 'ProductVersionOperator' -AllowedValue $script:ComparisonDetectionOperators -Description 'product version operator' -Default 'notConfigured'
        productVersion         = Get-DetectionRuleStringValue -Rule $Rule -RuleType 'ProductCode' -Name 'ProductVersion' -Default ''
    }
}

function ConvertTo-Win32LobAppDetectionRule {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Rule,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceDirectory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$DefaultDetectionScript
    )

    if ($Rule.Contains('@odata.type')) {
        return , $Rule
    }

    if (-not $Rule.Contains('Type')) {
        throw "Detection rule is missing required property 'Type'."
    }

    $ruleType = [string]$Rule['Type']

    switch ($ruleType) {
        'PowerShellScript' {
            ConvertTo-Win32LobAppPowerShellScriptDetectionRule -Rule $Rule -SourceDirectory $SourceDirectory -DefaultDetectionScript $DefaultDetectionScript
            break
        }

        'FileSystem' {
            ConvertTo-Win32LobAppFileSystemDetectionRule -Rule $Rule
            break
        }

        'Registry' {
            ConvertTo-Win32LobAppRegistryDetectionRule -Rule $Rule
            break
        }

        'ProductCode' {
            ConvertTo-Win32LobAppProductCodeDetectionRule -Rule $Rule
            break
        }

        default {
            throw "Unsupported detection rule type '$ruleType'. Use PowerShellScript, FileSystem, Registry, ProductCode, or a raw Graph rule with @odata.type."
        }
    }
}

function ConvertTo-Win32LobAppDetectionRuleSet {
    [CmdletBinding()]
    param(
        [System.Collections.IDictionary[]]$Rule,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceDirectory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$DefaultDetectionScript
    )

    if ($null -eq $Rule -or @($Rule).Count -eq 0) {
        $scriptPath = Resolve-IntuneWin32PublisherFile -SourceDirectory $SourceDirectory -FileName $DefaultDetectionScript -Purpose 'Detection script'
        [object[]]$defaultRule = @(ConvertTo-PowerShellScriptDetectionRule -ScriptPath $scriptPath)
        return , $defaultRule
    }

    [object[]]$convertedRules = @($Rule | ForEach-Object {
            ConvertTo-Win32LobAppDetectionRule -Rule $_ -SourceDirectory $SourceDirectory -DefaultDetectionScript $DefaultDetectionScript
        })
    return , $convertedRules
}

function ConvertTo-IntuneMimeContent {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Icon file '$Path' was not found."
    }

    $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()
    $mimeType = switch ($extension) {
        '.png' { 'image/png'; break }
        '.jpg' { 'image/jpeg'; break }
        '.jpeg' { 'image/jpeg'; break }
        default { throw "Unsupported icon file type '$extension'. Use PNG or JPG." }
    }

    @{
        '@odata.type' = '#microsoft.graph.mimeContent'
        type          = $mimeType
        value         = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Path))
    }
}

function ConvertTo-Win32LobAppBody {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$DisplayName,
        [Parameter(Mandatory = $true)] [string]$Publisher,
        [string]$Developer = '',
        [Parameter(Mandatory = $true)] [string]$Version,
        [Parameter(Mandatory = $true)] [string]$Description,
        [Parameter(Mandatory = $true)] [string]$FileName,
        [Parameter(Mandatory = $true)] [string]$SetupFilePath,
        [Parameter(Mandatory = $true)] [string]$InstallCommandLine,
        [Parameter(Mandatory = $true)] [string]$UninstallCommandLine,
        [Parameter(Mandatory = $true)] [object[]]$DetectionRules,
        [Parameter(Mandatory = $true)] [object[]]$ReturnCodes,
        [hashtable]$LargeIcon
    )

    $body = [ordered]@{
        '@odata.type'                   = '#microsoft.graph.win32LobApp'
        displayName                     = $DisplayName
        description                     = $Description
        publisher                       = $Publisher
        displayVersion                  = $Version
        developer                       = $Developer
        owner                           = ''
        notes                           = ''
        informationUrl                  = $null
        privacyInformationUrl           = $null
        isFeatured                      = $false
        fileName                        = $FileName
        setupFilePath                   = $SetupFilePath
        installCommandLine              = $InstallCommandLine
        uninstallCommandLine            = $UninstallCommandLine
        installExperience               = @{ runAsAccount = 'system' }
        minimumSupportedOperatingSystem = @{ v10_1607 = $true }
        msiInformation                  = $null
        runAs32bit                      = $false
        detectionRules                  = @($DetectionRules)
        returnCodes                     = @($ReturnCodes)
    }

    if ($LargeIcon) {
        $body.largeIcon = $LargeIcon
    }

    $body
}

function Invoke-IntuneGraphJsonRequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')]
        [string]$Method,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Uri,

        [object]$Body
    )

    $params = @{
        Method      = $Method
        Uri         = $Uri
        ErrorAction = 'Stop'
    }

    if ($PSBoundParameters.ContainsKey('Body')) {
        $params.Body = $Body | ConvertTo-Json -Depth 20
        $params.ContentType = 'application/json'
    }

    Invoke-MgGraphRequest @params
}

function Get-IntuneWin32AppByDisplayName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$DisplayName
    )

    $escapedName = $DisplayName.Replace("'", "''")
    $filter = [uri]::EscapeDataString("displayName eq '$escapedName'")
    $response = Invoke-MgGraphRequest -Method GET -Uri "beta/deviceAppManagement/mobileApps?`$filter=$filter" -ErrorAction Stop
    @($response.value) | Where-Object { $_.displayName -eq $DisplayName -and $_.'@odata.type' -eq '#microsoft.graph.win32LobApp' }
}

function Remove-IntuneWin32App {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$AppId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$DisplayName
    )

    if ($PSCmdlet.ShouldProcess($DisplayName, 'Remove existing Intune Win32 app')) {
        Invoke-MgGraphRequest -Method DELETE -Uri "beta/deviceAppManagement/mobileApps/$AppId" -ErrorAction Stop | Out-Null
    }
}

function Wait-IntuneWin32FileProcessing {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$FileUri,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Stage,

        [ValidateRange(1, 3600)]
        [int]$MaxAttempts = 600,

        [ValidateRange(1, 300)]
        [int]$DelaySeconds = 5
    )

    $successState = "$($Stage)Success"
    $pendingState = "$($Stage)Pending"

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        $file = Invoke-MgGraphRequest -Method GET -Uri $FileUri -ErrorAction Stop
        if ($file.uploadState -eq $successState) {
            return $file
        }

        if ($file.uploadState -ne $pendingState) {
            throw "File processing failed at stage '$Stage' with state '$($file.uploadState)'."
        }

        Start-Sleep -Seconds $DelaySeconds
    }

    throw "File processing stage '$Stage' did not complete after $MaxAttempts attempts."
}

function Send-AzureStorageFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$SasUri,
        [Parameter(Mandatory = $true)] [string]$FilePath,
        [Parameter(Mandatory = $true)] [string]$FileUri,
        [int64]$ChunkSizeInBytes = $script:DefaultChunkSizeInBytes
    )

    $stream = [System.IO.File]::OpenRead($FilePath)
    $blockIds = [System.Collections.Generic.List[string]]::new()
    $buffer = [byte[]]::new($ChunkSizeInBytes)
    $renewalTimer = [System.Diagnostics.Stopwatch]::StartNew()

    try {
        $index = 0
        while (($bytesRead = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
            $blockId = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($index.ToString('000000')))
            $blockIds.Add($blockId)

            if ($bytesRead -eq $buffer.Length) {
                $chunk = $buffer
            }
            else {
                $chunk = [byte[]]::new($bytesRead)
                [Array]::Copy($buffer, $chunk, $bytesRead)
            }

            $blockUri = "$SasUri&comp=block&blockid=$([uri]::EscapeDataString($blockId))"
            $blockHeaders = @{
                'x-ms-blob-type' = 'BlockBlob'
                'Content-Type'   = 'application/octet-stream'
            }

            Invoke-WebRequest -Uri $blockUri -Method Put -Headers $blockHeaders -Body $chunk -UseBasicParsing -ErrorAction Stop | Out-Null

            if ($stream.Position -lt $stream.Length -and $renewalTimer.ElapsedMilliseconds -ge $script:SasRenewalIntervalMilliseconds) {
                Invoke-MgGraphRequest -Method POST -Uri "$FileUri/renewUpload" -Body '' -ErrorAction Stop | Out-Null
                $renewedFile = Wait-IntuneWin32FileProcessing -FileUri $FileUri -Stage 'AzureStorageUriRenewal'
                $SasUri = $renewedFile.azureStorageUri
                $renewalTimer.Restart()
            }

            $index++
        }
    }
    finally {
        $stream.Dispose()
    }

    $blockList = ($blockIds.ToArray() | ForEach-Object { "<Latest>$_</Latest>" }) -join ''
    $blockListBody = "<?xml version=`"1.0`" encoding=`"utf-8`"?><BlockList>$blockList</BlockList>"
    Invoke-RestMethod -Uri "$SasUri&comp=blocklist" -Method Put -Body $blockListBody -ContentType 'application/xml' -ErrorAction Stop | Out-Null
}

function Invoke-IntuneWin32LobUpload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$PackagePath,
        [Parameter(Mandatory = $true)] [string]$DisplayName,
        [Parameter(Mandatory = $true)] [string]$Publisher,
        [string]$Developer = '',
        [Parameter(Mandatory = $true)] [string]$Version,
        [Parameter(Mandatory = $true)] [string]$Description,
        [Parameter(Mandatory = $true)] [string]$InstallCommandLine,
        [Parameter(Mandatory = $true)] [string]$UninstallCommandLine,
        [Parameter(Mandatory = $true)] [object[]]$DetectionRules,
        [Parameter(Mandatory = $true)] [string]$IconPath,
        [Parameter(Mandatory = $true)] [string]$StagingDirectory
    )

    $metadata = Get-IntuneWin32PackageManifest -PackagePath $PackagePath
    $appInfo = $metadata.ApplicationInfo
    $encryptionInfo = $appInfo.EncryptionInfo

    $icon = ConvertTo-IntuneMimeContent -Path $IconPath
    $appBody = ConvertTo-Win32LobAppBody `
        -DisplayName $DisplayName `
        -Publisher $Publisher `
        -Developer $Developer `
        -Version $Version `
        -Description $Description `
        -FileName $appInfo.FileName `
        -SetupFilePath $appInfo.SetupFile `
        -InstallCommandLine $InstallCommandLine `
        -UninstallCommandLine $UninstallCommandLine `
        -DetectionRules @($DetectionRules) `
        -ReturnCodes @(
        @{ returnCode = 0; type = 'success' }
        @{ returnCode = 1707; type = 'success' }
        @{ returnCode = 3010; type = 'softReboot' }
        @{ returnCode = 1641; type = 'hardReboot' }
        @{ returnCode = 1618; type = 'retry' }
    ) `
        -LargeIcon $icon

    $mobileApp = Invoke-IntuneGraphJsonRequest -Method POST -Uri 'beta/deviceAppManagement/mobileApps/' -Body $appBody
    if (-not $mobileApp.id) {
        throw 'Graph did not return a mobile app id.'
    }

    $appId = $mobileApp.id
    $contentVersionUri = "beta/deviceAppManagement/mobileApps/$appId/$script:Win32LobAppType/contentVersions"
    $contentVersion = Invoke-IntuneGraphJsonRequest -Method POST -Uri $contentVersionUri -Body @{}
    if (-not $contentVersion.id) {
        throw 'Graph did not return a content version id.'
    }

    $contentFilePath = Expand-IntuneWin32EncryptedFile -PackagePath $PackagePath -FileName $appInfo.FileName -DestinationDirectory $StagingDirectory
    try {
        $encryptedSize = (Get-Item -LiteralPath $contentFilePath).Length
        $fileBody = @{
            '@odata.type' = '#microsoft.graph.mobileAppContentFile'
            name          = $appInfo.FileName
            size          = [int64]$appInfo.UnencryptedContentSize
            sizeEncrypted = [int64]$encryptedSize
            manifest      = $null
            isDependency  = $false
        }

        $contentVersionId = $contentVersion.id
        $fileCreateUri = "beta/deviceAppManagement/mobileApps/$appId/$script:Win32LobAppType/contentVersions/$contentVersionId/files"
        $file = Invoke-IntuneGraphJsonRequest -Method POST -Uri $fileCreateUri -Body $fileBody
        if (-not $file.id) {
            throw 'Graph did not return a content file id.'
        }

        $fileId = $file.id
        $fileUri = "beta/deviceAppManagement/mobileApps/$appId/$script:Win32LobAppType/contentVersions/$contentVersionId/files/$fileId"
        $file = Wait-IntuneWin32FileProcessing -FileUri $fileUri -Stage 'AzureStorageUriRequest'
        Send-AzureStorageFile -SasUri $file.azureStorageUri -FilePath $contentFilePath -FileUri $fileUri

        $fileEncryptionInfo = @{
            fileEncryptionInfo = @{
                encryptionKey        = $encryptionInfo.EncryptionKey
                macKey               = $encryptionInfo.macKey
                initializationVector = $encryptionInfo.initializationVector
                mac                  = $encryptionInfo.mac
                profileIdentifier    = 'ProfileVersion1'
                fileDigest           = $encryptionInfo.fileDigest
                fileDigestAlgorithm  = $encryptionInfo.fileDigestAlgorithm
            }
        }

        $commitFileUri = "$fileUri/commit"
        Invoke-IntuneGraphJsonRequest -Method POST -Uri $commitFileUri -Body $fileEncryptionInfo | Out-Null
        Wait-IntuneWin32FileProcessing -FileUri $fileUri -Stage 'CommitFile' | Out-Null

        $commitAppBody = @{
            '@odata.type'           = "#$script:Win32LobAppType"
            committedContentVersion = $contentVersionId
        }
        Invoke-IntuneGraphJsonRequest -Method PATCH -Uri "beta/deviceAppManagement/mobileApps/$appId" -Body $commitAppBody | Out-Null
    }
    finally {
        if (Test-Path -LiteralPath $contentFilePath -PathType Leaf) {
            Remove-Item -LiteralPath $contentFilePath -Force
        }
    }

    $mobileApp
}

function Connect-IntuneGraph {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Microsoft Graph client-secret authentication requires converting the supplied secret to SecureString.')]
    [CmdletBinding()]
    param(
        [string]$TenantId,

        [string]$ClientId,

        [string]$ClientSecret
    )

    Import-Module Microsoft.Graph.Authentication -ErrorAction Stop

    $connectParams = @{
        NoWelcome   = $true
        ErrorAction = 'Stop'
    }

    if ($ClientId -or $ClientSecret) {
        if (-not ($TenantId -and $ClientId -and $ClientSecret)) {
            throw 'TenantId, ClientId, and ClientSecret are all required for app-only authentication.'
        }

        $secureSecret = ConvertTo-SecureString -String $ClientSecret -AsPlainText -Force
        $connectParams.TenantId = $TenantId
        $connectParams.ClientSecretCredential = [pscredential]::new($ClientId, $secureSecret)
    }
    else {
        $connectParams.Scopes = $script:DefaultGraphScopes
        if ($TenantId) {
            $connectParams.TenantId = $TenantId
        }
    }

    Connect-MgGraph @connectParams | Out-Null
    $context = Get-MgContext
    if (-not $context) {
        throw 'Microsoft Graph connection did not return a context.'
    }

    $context
}