Private/Win32AppHelpers.ps1
|
# Win32AppHelpers.ps1 # Win32 App creation and management functions # Configuration variables $sleep = 30 # Icon repository configuration - search both folders $script:IconRepoPaths = @( "https://raw.githubusercontent.com/jorgeasaurus/IntuneIcons/main/icons", "https://raw.githubusercontent.com/jorgeasaurus/IntuneIcons/main/companyportal" ) function Get-AppIcon { <# .SYNOPSIS Searches for and downloads an app icon from the IntuneIcons GitHub repository. .DESCRIPTION Attempts to match a Winget AppId to an icon file using multiple naming patterns. Returns the icon as a base64-encoded object suitable for Intune. .PARAMETER AppId The Winget package ID (e.g., "Google.Chrome", "Notepad++.Notepad++"). .PARAMETER AppName The display name of the application (used as fallback for matching). .OUTPUTS Returns a hashtable with Type and Value properties, or $null if no icon found. #> [cmdletbinding()] param( [Parameter(Mandatory = $true)] [string]$AppId, [Parameter(Mandatory = $false)] [string]$AppName ) # Generate potential icon filenames from AppId # AppId format is typically "Publisher.ProductName" (e.g., "Google.Chrome") $nameCandidates = @() # Split AppId into parts $parts = $AppId -split '\.' if ($parts.Count -ge 2) { $publisher = $parts[0] $product = ($parts[1..($parts.Count - 1)] -join '-') # Primary patterns based on repo naming convention (Vendor-Product.png) $nameCandidates += "$publisher-$product" # Google-Chrome $nameCandidates += $product # Chrome $nameCandidates += "$publisher$product" # GoogleChrome } # Add AppId with dots replaced by hyphens $nameCandidates += ($AppId -replace '\.', '-') # Google-Chrome or Notepad++-Notepad++ # Add AppName-based candidates if provided if ($AppName) { $cleanAppName = $AppName -replace '\s+', '-' -replace '[^a-zA-Z0-9\-]', '' $nameCandidates += $cleanAppName $nameCandidates += ($AppName -replace '\s+', '') } # Add just the product part without special chars if ($parts.Count -ge 2) { $cleanProduct = $parts[-1] -replace '[^a-zA-Z0-9]', '' $nameCandidates += $cleanProduct # NotepadPlusPlus for Notepad++ } # Add pattern with ++ replaced by PP (common abbreviation, e.g., notepadPP) if ($AppId -match '\+\+') { $ppVariant = ($parts[-1] -replace '\+\+', 'PP') -replace '[^a-zA-Z0-9]', '' $nameCandidates += $ppVariant.ToLower() # notepadPP $nameCandidates += $ppVariant # NotepadPP } # Remove duplicates and empty entries $nameCandidates = $nameCandidates | Where-Object { $_ } | Select-Object -Unique Write-Verbose "Searching for icon with candidates: $($nameCandidates -join ', ')" Write-Verbose "Searching for icon for $AppId" foreach ($repoPath in $script:IconRepoPaths) { foreach ($candidate in $nameCandidates) { $iconUrl = "$repoPath/$candidate.png" try { Write-Verbose "Trying icon URL: $iconUrl" $webClient = New-Object System.Net.WebClient $iconBytes = $webClient.DownloadData($iconUrl) $iconBase64 = [Convert]::ToBase64String($iconBytes) Write-Host "Found icon for $AppId at: $candidate.png" -ForegroundColor Green Write-Verbose "Found icon for $AppId`: $candidate.png" return @{ "@odata.type" = "#microsoft.graph.mimeContent" type = "image/png" value = $iconBase64 } } catch { Write-Verbose "Icon not found at: $iconUrl" continue } } } Write-Host "No icon found for $AppId in repository" -ForegroundColor Yellow Write-Verbose "No icon found for $AppId" return $null } function Get-Win32AppBody { param ( [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$displayName, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$publisher, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$description, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$filename, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$SetupFileName, [parameter(Mandatory = $true)] [ValidateSet('system', 'user')] $installExperience, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] $installCommandLine, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] $uninstallCommandLine, [parameter(Mandatory = $false)] [hashtable]$largeIcon ) $body = @{ "@odata.type" = "#microsoft.graph.win32LobApp" description = $description developer = "" displayName = $displayName fileName = $filename installCommandLine = "$installCommandLine" installExperience = @{ "runAsAccount" = "$installExperience" } informationUrl = $null isFeatured = $false minimumSupportedOperatingSystem = @{ "v10_1607" = $true } msiInformation = $null notes = "" owner = "" privacyInformationUrl = $null publisher = $publisher runAs32bit = $false setupFilePath = $SetupFileName uninstallCommandLine = "$uninstallCommandLine" } if ($largeIcon) { $body.largeIcon = $largeIcon } $body } function Get-AppFileBody($name, $size, $sizeEncrypted, $manifest) { $body = @{ "@odata.type" = "#microsoft.graph.mobileAppContentFile" } $body.name = $name $body.size = $size $body.sizeEncrypted = $sizeEncrypted $body.manifest = $manifest $body.isDependency = $false $body } function Get-AppCommitBody($contentVersionId, $LobType) { $body = @{ "@odata.type" = "#$LobType" } $body.committedContentVersion = $contentVersionId $body } function Test-SourceFile { param ( [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] $SourceFile ) if (!(Test-Path "$SourceFile")) { throw "Source File '$SourceFile' doesn't exist" } } function New-DetectionRule { [cmdletbinding()] param ( [parameter(Mandatory = $true)] [Switch]$PowerShell, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ScriptFile, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] $enforceSignatureCheck, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] $runAs32Bit ) if (!(Test-Path "$ScriptFile")) { throw "Could not find detection script file '$ScriptFile'" } $ScriptContent = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes("$ScriptFile")) @{ "@odata.type" = "#microsoft.graph.win32LobAppPowerShellScriptDetection" enforceSignatureCheck = $enforceSignatureCheck runAs32Bit = $runAs32Bit scriptContent = "$ScriptContent" } } function Get-DefaultReturnCodes { <# .SYNOPSIS Returns the default return codes for Win32 apps in Intune #> @( @{ returnCode = 0; type = "success" } @{ returnCode = 1707; type = "success" } @{ returnCode = 3010; type = "softReboot" } @{ returnCode = 1641; type = "hardReboot" } @{ returnCode = 1618; type = "retry" } ) } function Get-IntuneWinXML { param ( [Parameter(Mandatory = $true)] $SourceFile, [Parameter(Mandatory = $true)] $fileName, [Parameter(Mandatory = $false)] [ValidateSet("false", "true")] [string]$removeitem = "true" ) Test-SourceFile "$SourceFile" $Directory = [System.IO.Path]::GetDirectoryName("$SourceFile") Add-Type -Assembly System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead("$SourceFile") $zip.Entries | Where-Object { $_.Name -like "$filename" } | ForEach-Object { [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\$filename", $true) } $zip.Dispose() [xml]$IntuneWinXML = Get-Content "$Directory\$filename" if ($removeitem -eq "true") { Remove-Item "$Directory\$filename" } return $IntuneWinXML } function Get-IntuneWinFile { param ( [Parameter(Mandatory = $true)] $SourceFile, [Parameter(Mandatory = $true)] $fileName, [Parameter(Mandatory = $false)] [string]$Folder = "win32" ) $Directory = [System.IO.Path]::GetDirectoryName("$SourceFile") if (!(Test-Path "$Directory\$folder")) { New-Item -ItemType Directory -Path "$Directory" -Name "$folder" | Out-Null } Add-Type -Assembly System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead("$SourceFile") $zip.Entries | Where-Object { $_.Name -like "$filename" } | ForEach-Object { [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\$folder\$filename", $true) } $zip.Dispose() return "$Directory\$folder\$filename" } function Invoke-UploadWin32Lob { <# .SYNOPSIS This function is used to upload a Win32 Application to the Intune Service .DESCRIPTION This function is used to upload a Win32 Application to the Intune Service .EXAMPLE Invoke-UploadWin32Lob "C:\Packages\package.intunewin" -publisher "Microsoft" -description "Package" This example uses all parameters required to add an intunewin File into the Intune Service .NOTES NAME: Invoke-UploadWin32Lob #> [cmdletbinding()] param ( [parameter(Mandatory = $true, Position = 1)] [ValidateNotNullOrEmpty()] [string]$SourceFile, [parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$displayName, [parameter(Mandatory = $true, Position = 2)] [ValidateNotNullOrEmpty()] [string]$publisher, [parameter(Mandatory = $true, Position = 3)] [ValidateNotNullOrEmpty()] [string]$description, [parameter(Mandatory = $true, Position = 4)] [ValidateNotNullOrEmpty()] $detectionRules, [parameter(Mandatory = $true, Position = 5)] [ValidateNotNullOrEmpty()] $returnCodes, [parameter(Mandatory = $false, Position = 6)] [ValidateNotNullOrEmpty()] [string]$installCmdLine, [parameter(Mandatory = $false, Position = 7)] [ValidateNotNullOrEmpty()] [string]$uninstallCmdLine, [parameter(Mandatory = $false, Position = 8)] [ValidateSet('system', 'user')] $installExperience = "system", [parameter(Mandatory = $false, Position = 9)] [hashtable]$largeIcon ) try { $LOBType = "microsoft.graph.win32LobApp" Write-Verbose "Testing if SourceFile '$SourceFile' Path is valid..." Test-SourceFile "$SourceFile" Write-Verbose "Creating JSON data to pass to the service..." $DetectionXML = Get-IntuneWinXML "$SourceFile" -fileName "detection.xml" if ($displayName) { $DisplayName = $displayName } else { $DisplayName = $DetectionXML.ApplicationInfo.Name } $FileName = $DetectionXML.ApplicationInfo.FileName $SetupFileName = $DetectionXML.ApplicationInfo.SetupFile $Ext = [System.IO.Path]::GetExtension($SetupFileName) $mobileAppBody = Get-Win32AppBody -displayName "$DisplayName" -publisher "$publisher" ` -description $description -filename $FileName -SetupFileName "$SetupFileName" ` -installExperience $installExperience -installCommandLine $installCmdLine ` -uninstallCommandLine $uninstallcmdline -largeIcon $largeIcon if ($DetectionRules.'@odata.type' -contains "#microsoft.graph.win32LobAppPowerShellScriptDetection" -and @($DetectionRules).'@odata.type'.Count -gt 1) { throw "Detection rules cannot mix script-based and manual rules" } $mobileAppBody | Add-Member -MemberType NoteProperty -Name 'detectionRules' -Value $detectionRules if (-not $returnCodes) { Write-Warning "ReturnCodes required. Use Get-DefaultReturnCodes for defaults." break } $mobileAppBody | Add-Member -MemberType NoteProperty -Name 'returnCodes' -Value @($returnCodes) Write-Verbose "Creating application in Intune..." $mobileApp = Invoke-MgGraphRequest -Method POST -Uri "beta/deviceAppManagement/mobileApps/" -Body ($mobileAppBody | ConvertTo-Json) -ContentType "application/json" -OutputType PSObject -ErrorAction Stop if (-not $mobileApp -or -not $mobileApp.id) { throw "Graph API returned null response or missing app ID" } Write-Verbose "Creating Content Version in the service..." $appId = $mobileApp.id $contentVersionUri = "beta/deviceAppManagement/mobileApps/$appId/$LOBType/contentVersions" $contentVersion = Invoke-MgGraphRequest -Method POST -Uri $contentVersionUri -Body "{}" -ErrorAction Stop Write-Verbose "Getting Encryption Information for '$SourceFile'..." $encInfo = $DetectionXML.ApplicationInfo.EncryptionInfo $encryptionInfo = @{ encryptionKey = $encInfo.EncryptionKey macKey = $encInfo.macKey initializationVector = $encInfo.initializationVector mac = $encInfo.mac profileIdentifier = "ProfileVersion1" fileDigest = $encInfo.fileDigest fileDigestAlgorithm = $encInfo.fileDigestAlgorithm } $fileEncryptionInfo = @{ fileEncryptionInfo = $encryptionInfo } $IntuneWinFile = Get-IntuneWinFile "$SourceFile" -fileName "$filename" [int64]$Size = $DetectionXML.ApplicationInfo.UnencryptedContentSize $EncrySize = (Get-Item "$IntuneWinFile").Length Write-Verbose "Creating file entry in Azure for upload..." $contentVersionId = $contentVersion.id $fileBody = Get-AppFileBody "$FileName" $Size $EncrySize $null $filesUri = "beta/deviceAppManagement/mobileApps/$appId/$LOBType/contentVersions/$contentVersionId/files" $file = Invoke-MgGraphRequest -Method POST -Uri $filesUri -Body ($fileBody | ConvertTo-Json) -ErrorAction Stop Write-Verbose "Waiting for file entry URI..." $fileId = $file.id $fileUri = "beta/deviceAppManagement/mobileApps/$appId/$LOBType/contentVersions/$contentVersionId/files/$fileId" $file = Wait-FileProcessing $fileUri "AzureStorageUriRequest" Write-Verbose "Uploading file to Azure Storage..." Invoke-AzureStorageUpload $file.azureStorageUri "$IntuneWinFile" $fileUri Remove-Item "$IntuneWinFile" -Force Write-Verbose "Committing file..." $commitFileUri = "beta/deviceAppManagement/mobileApps/$appId/$LOBType/contentVersions/$contentVersionId/files/$fileId/commit" Invoke-MgGraphRequest -Uri $commitFileUri -Method POST -Body ($fileEncryptionInfo | ConvertTo-Json) -ErrorAction Stop | Out-Null Write-Verbose "Waiting for commit processing..." $file = Wait-FileProcessing $fileUri "CommitFile" Write-Verbose "Committing app version..." $commitAppUri = "beta/deviceAppManagement/mobileApps/$appId" $commitAppBody = Get-AppCommitBody $contentVersionId $LOBType Invoke-MgGraphRequest -Method PATCH -Uri $commitAppUri -Body ($commitAppBody | ConvertTo-Json) -ErrorAction Stop | Out-Null foreach ($i in 0..$sleep) { Write-Progress -Activity "Sleeping for $($sleep-$i) seconds" -PercentComplete ($i / $sleep * 100) -SecondsRemaining ($sleep - $i) Start-Sleep -s 1 } return $mobileApp } catch { Write-Error "Aborting with exception: $($_.Exception.ToString())" throw } } function Wait-AppPublishing { <# .SYNOPSIS Waits for a Win32 app to be published in Intune. .PARAMETER AppId The ID of the application to wait for. .PARAMETER MaxRetries Maximum number of retries (default 30, each retry waits 10 seconds). #> param ( [Parameter(Mandatory = $true)] [string]$AppId, [int]$MaxRetries = 30 ) Write-Host "Waiting for app to be published..." -ForegroundColor Yellow $retryCount = 0 $isPublished = $false while (-not $isPublished -and $retryCount -lt $MaxRetries) { try { $app = Invoke-MgGraphRequest -Uri "beta/deviceAppManagement/mobileApps/$AppId" -Method GET -ErrorAction Stop if ($app.publishingState -eq "published") { $isPublished = $true Write-Host "App is now published" -ForegroundColor Green } else { Write-Host "App publishing state: $($app.publishingState). Waiting..." -ForegroundColor Yellow Start-Sleep -Seconds 10 $retryCount++ } } catch { Write-Host "Error checking app status: $_" -ForegroundColor Red Start-Sleep -Seconds 10 $retryCount++ } } if (-not $isPublished) { Write-Warning "App did not become published after $($MaxRetries * 10) seconds" } return $isPublished } function Grant-Win32AppAssignment { <# .SYNOPSIS Assigns a Win32 app to install and uninstall groups, with optional available deployment. .PARAMETER AppName The display name of the application in Intune. .PARAMETER InstallGroupId The Azure AD group ID for required installation. .PARAMETER UninstallGroupId The Azure AD group ID for uninstallation. .PARAMETER AvailableInstall Optional available deployment: Device, User, Both, or None (default). #> param ( [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $true)] [string]$InstallGroupId, [Parameter(Mandatory = $true)] [string]$UninstallGroupId, [ValidateSet("Device", "User", "Both", "None")] [string]$AvailableInstall = "None" ) $Application = Get-IntuneApplication | Where-Object { $_.displayName -eq $AppName -and $_.description -like "*Winget*" } | Select-Object -First 1 if (-not $Application) { Write-Error "Application '$AppName' not found in Intune" return } # Wait for app to be published before assigning $isPublished = Wait-AppPublishing -AppId $Application.id if (-not $isPublished) { Write-Error "Application '$AppName' is not published. Cannot assign groups." return } # Helper to create assignment object function New-Assignment($intent, $targetType, $groupId = $null) { $assignment = @{ "@odata.type" = "#microsoft.graph.mobileAppAssignment" intent = $intent target = @{ "@odata.type" = $targetType } } if ($groupId) { $assignment.target.groupId = $groupId } if ($intent -eq "available") { $assignment.settings = @{ "@odata.type" = "#microsoft.graph.win32LobAppAssignmentSettings" deliveryOptimizationPriority = "foreground" notifications = "showAll" installTimeSettings = $null restartSettings = $null } } return $assignment } # Build assignments array $assignments = @( New-Assignment -intent "required" -targetType "#microsoft.graph.groupAssignmentTarget" -groupId $InstallGroupId New-Assignment -intent "uninstall" -targetType "#microsoft.graph.groupAssignmentTarget" -groupId $UninstallGroupId ) # Add available assignments based on parameter switch ($AvailableInstall) { "Device" { Write-Host "Making available for devices" $assignments += New-Assignment -intent "available" -targetType "#microsoft.graph.allDevicesAssignmentTarget" } "User" { Write-Host "Making available for users" $assignments += New-Assignment -intent "available" -targetType "#microsoft.graph.allLicensedUsersAssignmentTarget" } "Both" { Write-Host "Making available for users and devices" $assignments += New-Assignment -intent "available" -targetType "#microsoft.graph.allLicensedUsersAssignmentTarget" $assignments += New-Assignment -intent "available" -targetType "#microsoft.graph.allDevicesAssignmentTarget" } } $body = @{ mobileAppAssignments = $assignments } Invoke-MgGraphRequest -Uri "beta/deviceAppManagement/mobileApps/$($Application.id)/assign" -Method POST -Body ($body | ConvertTo-Json -Depth 10) -ErrorAction Stop | Out-Null } function New-Win32App { [cmdletbinding()] param ( $appid, $appname, $appfile, $installcmd, $uninstallcmd, $detectionfile, [hashtable]$largeIcon ) # Defining Intunewin32 detectionRules $PSRule = New-DetectionRule -PowerShell -ScriptFile $detectionfile -enforceSignatureCheck $false -runAs32Bit $false # Creating Array for detection Rule $DetectionRule = @($PSRule) $ReturnCodes = Get-DefaultReturnCodes # Win32 Application Upload $uploadParams = @{ SourceFile = "$appfile" DisplayName = "$appname" publisher = "Winget" description = "$appname $script:PublisherTag" detectionRules = $DetectionRule returnCodes = $ReturnCodes installCmdLine = "$installcmd" uninstallCmdLine = "$uninstallcmd" } if ($largeIcon) { $uploadParams.largeIcon = $largeIcon } $appupload = Invoke-UploadWin32Lob @uploadParams return $appupload } function Test-ExistingIntuneApp { <# .SYNOPSIS Checks if an app already exists in Intune by name .PARAMETER AppName The display name of the application to check .RETURNS Hashtable with Exists (bool) and Apps (array) properties #> param( [Parameter(Mandatory = $true)] [string]$AppName ) $existingApps = Get-IntuneApplication -AppName $AppName | Where-Object { $_.displayName -eq $AppName -or ($_.displayName -like "$AppName*" -and $_.description -like "*Winget*") } if ($existingApps) { return @{ Exists = $true Apps = @($existingApps) } } return @{ Exists = $false Apps = $null } } function New-IntuneWinFile { param ( $appid, $appname, $apppath, $setupfilename, $destpath ) New-IntuneWinPackage -SourcePath "$apppath" -SetupFile "$setupfilename" -DestinationPath "$destpath" | Out-Null } |