Public/Publish-IntuneAppPackage.ps1

function Publish-IntuneAppPackage {
    <#
    .SYNOPSIS
        Publishes an Intune Win32 application package (.intunewin and its JSON configuration) to Microsoft Intune.

    .DESCRIPTION
        The Publish-IntuneAppPackage function reads the application configuration JSON created by
        New-IntuneApplication and publishes the .intunewin package with Microsoft Graph:

          - When no app with the same display name exists, it creates the Win32 app and uploads the
            package (see New-IntuneWin32Application).
          - When a Win32 app with the same display name exists, it stops unless -Force is used; with
            -Force it uploads the package as a new content version of the existing app and then
            updates the app's properties from the JSON file (PATCH): display name, description,
            publisher, developer, owner, notes (with "Version: <version>"), featured, install and
            uninstall command lines, install experience, logo, rules, and the setup file and file name
            of the new package.

        Then the app is assigned when an assignment type is set, either with -AssignmentType or by
        AssignmentType in the JSON file (New-IntuneApplication writes it): to a group (User-Group or
        Device-Group, by group ID or display name), all users or all devices, with the intent
        required (default), available or uninstall, and optionally an assignment filter
        (FilterRuleType Include or Exclude, FilterRule the filter name or ID). Existing assignments are
        kept; one to the same target is replaced. Use -NoAssignment to skip this. Assignments use the
        Graph beta endpoint because assignment filters are only there.

        The detection and requirement rules come from -Rules or, when omitted, from
        DetectionRuleConfig and RequirementRuleConfig in the JSON file. They must be rules created with
        New-IntuneWin32Rule (hashtables with an '@odata.type').

        Requires the Microsoft.Graph.Authentication module and a connection made with
        Connect-MgGraph -Scopes DeviceManagementApps.ReadWrite.All (and Group.Read.All to assign to a
        group by display name).

    .PARAMETER IntuneAppJSONPath
        The path to the application configuration JSON file created by New-IntuneApplication.

    .PARAMETER IntuneWinPath
        The path to the .intunewin package file.

    .PARAMETER Force
        Upload the package as a new content version when an app with the same display name exists, and
        update the app's properties from the JSON file.

    .PARAMETER NoTenantDetails
        Do not show the tenant connection details.

    .PARAMETER Rules
        Detection and requirement rules created with New-IntuneWin32Rule. Overrides the rules in the JSON file.

    .PARAMETER PollIntervalSeconds
        How often to check the upload and commit state. Default is 5 seconds.

    .PARAMETER TimeoutSeconds
        How long to wait for each upload or commit state. Default is 600 seconds.

    .PARAMETER AssignmentType
        How the app is assigned: User-Group, Device-Group, All-Users or All-Devices. Overrides
        AssignmentType in the JSON file.

    .PARAMETER AssignmentGroup
        The group ID or display name for User-Group and Device-Group. Overrides the JSON file.

    .PARAMETER AssignmentIntent
        The assignment intent: required, available or uninstall. Overrides the JSON file; default required.

    .PARAMETER FilterRuleType
        Include or Exclude, for an assignment filter. Overrides the JSON file.

    .PARAMETER FilterRule
        The name or ID of an existing Intune assignment filter. Overrides the JSON file.

    .PARAMETER NoAssignment
        Do not assign the app, even when the JSON file has an assignment type.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
        The Win32 app (id, displayName and committedContentVersion), with Assignment (the assignment
        that was sent) when the app was assigned.

    .EXAMPLE
        Publish-IntuneAppPackage -IntuneAppJSONPath "C:\Packages\MyApp.1.0.json" -IntuneWinPath "C:\Packages\setup.intunewin"

        Creates the MyApp Win32 app in Intune and uploads setup.intunewin.

    .EXAMPLE
        Publish-IntuneAppPackage -IntuneAppJSONPath "C:\Packages\MyApp.1.1.json" -IntuneWinPath "C:\Packages\setup.intunewin" -Force -NoTenantDetails

        Uploads setup.intunewin as a new content version of the existing MyApp app and updates its
        properties from the JSON file.

    .EXAMPLE
        Publish-IntuneAppPackage -IntuneAppJSONPath "C:\Packages\MyApp.1.0.json" -IntuneWinPath "C:\Packages\setup.intunewin" -AssignmentType User-Group -AssignmentGroup 'Intune-AG-MyApp-Available' -AssignmentIntent available

        Creates the app and makes it available to the members of the Intune-AG-MyApp-Available group.

    .NOTES
        Requires connection to Microsoft Graph using Connect-MgGraph before running this function.

    .LINK
        New-IntuneWin32Application
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
        [string]$IntuneAppJSONPath,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
        [string]$IntuneWinPath,

        [switch]$Force,

        [switch]$NoTenantDetails,

        [hashtable[]]$Rules,

        [ValidateRange(0, 300)]
        [int]$PollIntervalSeconds = 5,

        [ValidateRange(1, 86400)]
        [int]$TimeoutSeconds = 600,

        [ValidateSet('User-Group', 'Device-Group', 'All-Users', 'All-Devices')]
        [string]$AssignmentType,

        [string]$AssignmentGroup,

        [ValidateSet('required', 'available', 'uninstall')]
        [string]$AssignmentIntent,

        [ValidateSet('Include', 'Exclude')]
        [string]$FilterRuleType,

        [string]$FilterRule,

        [switch]$NoAssignment
    )
    process {
        $TelemetryArgs = @{
            ModuleName    = $MyInvocation.MyCommand.Module.Name
            ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version
            CommandName   = $MyInvocation.MyCommand.Name
            ExecutionID   = [guid]::NewGuid().ToString()
        }
        Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer
        try {
            $GraphContext = Assert-MgGraphConnection
            $AppJson = Read-IntuneAppJson -Path $IntuneAppJSONPath -IgnoreRules:$PSBoundParameters.ContainsKey('Rules')
            $AppParameters = $AppJson.AppParameters
            $DisplayName = $AppJson.DisplayName
            if (-not $PSBoundParameters.ContainsKey('Rules')) {
                $Rules = $AppJson.Rules
            }

            # Assignment settings: parameters win over the JSON file. Checked before anything is created.
            $Assignment = $null
            if (-not $NoAssignment) {
                $AssignmentSettings = @{}
                foreach ($Pair in @(@('AssignmentType', 'AssignmentType'), @('AssignmentGroup', 'AssignmentGroup'), @('AssignmentIntent', 'Intent'),
                        @('FilterRuleType', 'FilterRuleType'), @('FilterRule', 'FilterRule'))) {
                    $Value = $(if ($PSBoundParameters.ContainsKey($Pair[0])) { $PSBoundParameters[$Pair[0]] } else { $AppParameters.($Pair[0]) })
                    if (-not [string]::IsNullOrWhiteSpace([string]$Value)) {
                        $AssignmentSettings[$Pair[1]] = [string]$Value
                    }
                }
                if ($AssignmentSettings.ContainsKey('AssignmentType')) {
                    Test-IntuneAppAssignmentSetting @AssignmentSettings
                }
                else {
                    $AssignmentSettings = $null
                }
            }

            if (-not $NoTenantDetails) {
                Write-Information -MessageData ("You're connected to Microsoft Graph as:`n`tUserName: $($GraphContext.Account)`n`t" +
                    "Context scope: $($GraphContext.ContextScope)`n`tTenantId: $($GraphContext.TenantId)") -InformationAction Continue
            }

            # Check if a Win32 app with the same display name already exists
            $Filter = [System.Uri]::EscapeDataString("displayName eq '$($DisplayName.Replace("'", "''"))'")
            $Response = Invoke-MgGraphRequest -Method GET -Uri "v1.0/deviceAppManagement/mobileApps?`$filter=$Filter" -OutputType PSObject -ErrorAction Stop
            $Existing = @($Response.value | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.win32LobApp' }) | Select-Object -First 1

            if ($Existing) {
                if (-not $Force) {
                    throw "The application '$DisplayName' already exists in Intune ($($Existing.id)). Use -Force to upload the package as a new content version."
                }
                if (-not $PSCmdlet.ShouldProcess("$DisplayName ($($Existing.id))", "Upload '$IntuneWinPath' as a new content version and update the app properties")) {
                    Invoke-TelemetryCollection @TelemetryArgs -Stage End
                    return
                }
                $ContentVersion = Publish-IntuneWin32AppContent -AppId $Existing.id -IntuneWinPath $IntuneWinPath -PollIntervalSeconds $PollIntervalSeconds -TimeoutSeconds $TimeoutSeconds -ErrorAction Stop

                # The new content is committed: update the properties to match the JSON file and package
                $Package = Read-IntuneWinPackage -Path $IntuneWinPath -ErrorAction Stop
                $Patch = ConvertTo-IntuneWin32AppPatch -AppParameters $AppParameters -Rules $Rules
                $Patch['setupFilePath'] = $Package.SetupFile
                $Patch['fileName'] = Split-Path -Path $IntuneWinPath -Leaf
                Write-Verbose "Updating the properties of app $($Existing.id)."
                $null = Invoke-MgGraphRequest -Method PATCH -Uri "v1.0/deviceAppManagement/mobileApps/$($Existing.id)" -Body ($Patch | ConvertTo-Json -Depth 10 -Compress) -ContentType 'application/json' -ErrorAction Stop

                $App = [PSCustomObject]@{
                    id                      = $Existing.id
                    displayName             = $(if ($Patch.Contains('displayName')) { $Patch['displayName'] } else { $Existing.displayName })
                    committedContentVersion = $ContentVersion
                }
            }
            else {
                $Splat = @{
                    Name                 = $DisplayName
                    Description          = [string]$AppParameters.Description
                    Publisher            = [string]$AppParameters.Publisher
                    InstallCommandLine   = [string]$AppParameters.InstallCommand
                    UninstallCommandLine = [string]$AppParameters.UninstallCommand
                    Rules                = @($Rules)
                    IntuneWinFilePath    = $IntuneWinPath
                    PollIntervalSeconds  = $PollIntervalSeconds
                    TimeoutSeconds       = $TimeoutSeconds
                    ErrorAction          = 'Stop'
                }
                foreach ($Pair in @(
                        @('Developer', 'Developer'), @('Owner', 'Owner'), @('Notes', 'Notes'), @('IsFeatured', 'IsFeatured'),
                        @('InstallFor', 'InstallExperienceRunAsAccount'), @('RestartBehavior', 'InstallExperienceDeviceRestartBehavior'), @('LogoPath', 'IconFilePath'))) {
                    $Value = $AppParameters.($Pair[0])
                    if ($null -ne $Value -and "$Value" -ne '') {
                        if ($Pair[1] -eq 'InstallExperienceRunAsAccount') {
                            $Value = ([string]$Value).ToLowerInvariant()
                        }
                        $Splat[$Pair[1]] = $Value
                    }
                }
                $ParsedVersion = $null
                if ([version]::TryParse([string]$AppParameters.Version, [ref]$ParsedVersion)) {
                    $Splat['Version'] = $ParsedVersion
                }
                $App = New-IntuneWin32Application @Splat
            }

            if ($App -and $AssignmentSettings) {
                $Target = $(if ($AssignmentSettings['AssignmentGroup']) { "$($AssignmentSettings['AssignmentType']) '$($AssignmentSettings['AssignmentGroup'])'" } else { $AssignmentSettings['AssignmentType'] })
                if ($PSCmdlet.ShouldProcess("$DisplayName ($($App.id))", "Assign to $Target")) {
                    $Assignment = Add-IntuneWin32AppAssignment -AppId $App.id @AssignmentSettings -ErrorAction Stop
                    $App | Add-Member -NotePropertyName Assignment -NotePropertyValue $Assignment -Force
                }
            }
            $App
            Invoke-TelemetryCollection @TelemetryArgs -Stage End
        }
        catch {
            Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }
}