├── .gitattributes ├── .gitignore ├── License ├── LocalPSRepo_Setup.md ├── Private ├── Connect-SiteServer.ps1 ├── Export-CsvDetails.ps1 ├── Export-Icon.ps1 ├── Get-AppInfo.ps1 ├── Get-AppList.ps1 ├── Get-ClientCertificate.ps1 ├── Get-ContentFiles.ps1 ├── Get-ContentInfo.ps1 ├── Get-DeploymentTypeInfo.ps1 ├── Get-FileFromInternet.ps1 ├── Get-InstallCommand.ps1 ├── Get-IntuneWinEncryptionDetails.ps1 ├── Get-IntuneWinInfo.ps1 ├── Get-LocalDetectionMethods.ps1 ├── Get-SasUri.ps1 ├── Get-ScriptEnd.ps1 ├── Initialize-Module.ps1 ├── Invoke-IntuneContentCommit.ps1 ├── Invoke-MgGraphRequestCustom.ps1 ├── Invoke-StorageUpload.ps1 ├── New-FolderToCreate.ps1 ├── New-IntuneDetection.ps1 ├── New-IntuneFramework.ps1 ├── New-IntuneWin.ps1 ├── New-IntuneWinContentRequest.ps1 ├── New-VerboseRegion.ps1 ├── Write-Log.ps1 └── Write-LogAndHost.ps1 ├── Public ├── Connect-MgGraphCustom.ps1 ├── New-Win32App.ps1 └── Test-MgConnection.ps1 ├── README.md ├── Release_Notes.md ├── Win32AppMigrationTool.psd1 └── Win32AppMigrationTool.psm1 /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store -------------------------------------------------------------------------------- /License: -------------------------------------------------------------------------------- 1 | Non-Commercial Software License Agreement 2 | 3 | Version 1.0 4 | 5 | 1. Definitions 6 | • 'Software': Refers to [Win32 App Migration Tool], including all associated files, documentation, updates, and modifications. 7 | • 'Licensor': Ben Whitmore. 8 | • 'Licensee': Any individual or entity that uses the Software under the terms of this Agreement. 9 | 10 | 2. Grant of License 11 | 12 | The Licensor grants the Licensee a non-exclusive, non-transferable, royalty-free license to use, copy, modify, and distribute the Software, subject to the following conditions: 13 | 14 | 3. Non-Commercial Use 15 | • The Software may only be used for non-commercial purposes. 16 | • Commercial use, including but not limited to selling, licensing, or integrating the Software into a commercial product, is strictly prohibited without prior written consent from the Licensor. 17 | 18 | 4. Redistribution 19 | • Redistributions of the Software must retain this License Agreement. 20 | • Sharing the Code/Software in original or modified form, even for the purpose of collaborative non-commercial enhancements, is not permitted 21 | • Redistributions in binary form must reproduce this License Agreement in the associated documentation or other materials provided with the distribution. 22 | • You may not share or distribute modified versions of the Code/Software with third parties, even for non-commercial collaborations or feature additions, without explicit permission from the Licensor 23 | 24 | 5. Modifications 25 | • Modifications to the Software are permitted for non-commercial purposes. 26 | • Modified versions must be clearly marked as such and must not misrepresent the origin of the Software. 27 | 28 | 6. Intellectual Property Rights 29 | • The Licensor retains all rights, title, and interest in and to the Software. 30 | • This Agreement does not grant the Licensee any rights to trademarks or service marks of the Licensor. 31 | 32 | 7. Disclaimer of Warranty 33 | • The Software is provided “as is,” without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. 34 | 35 | 8. Limitation of Liability 36 | • In no event shall the Licensor be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software. 37 | 38 | 9. Termination 39 | • This License Agreement is effective until terminated. 40 | • The Licensee may terminate it at any time by destroying all copies of the Software. 41 | • This Agreement will terminate immediately, without notice from the Licensor, if the Licensee fails to comply with any provision herein. 42 | 43 | 10. Governing Law 44 | • This Agreement shall be governed by and construed in accordance with the laws of [Your Country/State], without regard to its conflict of law principles. 45 | -------------------------------------------------------------------------------- /LocalPSRepo_Setup.md: -------------------------------------------------------------------------------- 1 | # Local PowerShell Repository Setup Guide 2 | 3 | ## Publishing Updates (Run Initially and After Changes) 4 | 5 | ```powershell 6 | function Set-DevEnv { 7 | $ModuleName = 'Win32AppMigrationTool' 8 | $DevPath = '\\tsclient\C\GitHub\byteben\Win32App-Migration-Tool' 9 | $LocalPath = 'C:\LocalModules\Win32AppMigrationTool' 10 | Set-Location -Path $LocalPath 11 | Write-Host -Object ("New-Item -Path {0} -ItemType Directory -Force" -f $LocalPath) 12 | New-Item -Path $LocalPath -ItemType Directory -Force 13 | Write-Host -Object ("Get-ChildItem -Path {0} | Copy-Item -Destination {0} -Recurse -Force" -f $DevPath, $LocalPath) 14 | Get-ChildItem -Path $DevPath | Copy-Item -Destination $LocalPath -Recurse -Force 15 | Write-Host -Object ("Import-Module {0} -Force" -f (Join-Path $LocalPath "$ModuleName.psd1")) 16 | Import-Module (Join-Path -Path $LocalPath -ChildPath "$ModuleName.psd1") -Force 17 | } 18 | Set-DevEnv 19 | ``` 20 | 21 | ## Troubleshooting 22 | 23 | ```powershell 24 | Get-Module $ModuleName # Verify module is loaded with correct version 25 | Get-Module $ModuleName -ListAvailable | Remove-Module -Force # Clear PowerShell module cache 26 | ``` 27 | 28 | ## Cleanup (When Done Testing) 29 | 30 | ```powershell 31 | Remove-Module $ModuleName -Force # Remove module from current session 32 | -------------------------------------------------------------------------------- /Private/Connect-SiteServer.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 28/10/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Connect-SiteServer.ps1 7 | 8 | .Description 9 | Function to connect to a ConfigMgr site server 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER SiteCode 16 | The Site Code of the ConfigMgr Site. If not provided, the function will attempt to retrieve it from the provider machine 17 | 18 | .PARAMETER ProviderMachineName 19 | Server name that has an SMS Provider site system role 20 | #> 21 | function Connect-SiteServer { 22 | [CmdletBinding()] 23 | param ( 24 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 25 | [string]$LogId = $($MyInvocation.MyCommand).Name, 26 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The Site Code of the ConfigMgr Site')] 27 | [ValidatePattern('(?##The Site Code must be only 3 alphanumeric characters##)^[a-zA-Z0-9]{3}$')] 28 | [String]$SiteCode, 29 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = "Server name that has an SMS Provider site system role")] 30 | [String]$ProviderMachineName 31 | ) 32 | 33 | begin { 34 | Write-LogAndHost -Message "Function: Connect-SiteServer was called" -LogId $LogId -ForegroundColor Cyan 35 | Write-LogAndHost ("Importing Module: 'ConfigurationManager.psd1' and connecting to Provider '{0}'..." -f $ProviderMachineName) -LogId $LogId -ForegroundColor Cyan 36 | } 37 | 38 | process { 39 | $attempt = 0 40 | $maxAttempts = 3 41 | $siteCodeRetrieved = $false 42 | 43 | # Attempt to retrieve the Site Code from the provider machine 44 | while (-not $siteCodeRetrieved -and $attempt -lt $maxAttempts) { 45 | 46 | if (-not $SiteCode) { 47 | try { 48 | 49 | # Get the Site Code from the provider machine 50 | $siteCodeQuery = Get-CIMInstance -Namespace "root\SMS" -Class "SMS_ProviderLocation" -ComputerName $ProviderMachineName 51 | $SiteCode = $siteCodeQuery.SiteCode 52 | Write-LogAndHost -Message ("Retrieved Site Code: {0}" -f $SiteCode) -LogId $LogId -ForegroundColor Green 53 | $siteCodeRetrieved = $true 54 | } 55 | catch { 56 | Write-LogAndHost -Message "Failed to retrieve Site Code from provider machine: $($_.Exception.Message)" -Severity 3 57 | } 58 | } 59 | else { 60 | $siteCodeRetrieved = $true 61 | } 62 | 63 | # If the Site Code was not retrieved, prompt the user to enter it 64 | if (-not $siteCodeRetrieved) { 65 | $SiteCode = Read-Host -Prompt "Please enter the Site Code (3 alphanumeric characters)" 66 | 67 | if ($SiteCode -match '^[a-zA-Z0-9]{3}$') { 68 | $siteCodeRetrieved = $true 69 | } 70 | else { 71 | Write-LogAndHost -Message ("Invalid Site Code entered: {0}" -f $SiteCode) -LogId $LogId -Severity 3 72 | } 73 | } 74 | 75 | $attempt++ 76 | } 77 | 78 | # If the Site Code was not retrieved after the maximum attempts, throw an error 79 | if (-not $siteCodeRetrieved) { 80 | Write-LogAndHost -Message ("Failed to retrieve or enter a valid Site Code after {0} attempts." -f $maxAttempts) -LogId $LogId -Severity 3 81 | 82 | throw 83 | } 84 | 85 | # Import the ConfigurationManager.psd1 module 86 | try { 87 | if (-not (Get-Module ConfigurationManager)) { 88 | Import-Module "$($ENV:SMS_ADMIN_UI_PATH)\..\ConfigurationManager.psd1" -Verbose:$false 89 | } 90 | } 91 | catch { 92 | Write-LogAndHost -Message "Failed to import ConfigurationManager module: $($_.Exception.Message)" -LogId $LogId -Severity 3 93 | 94 | throw 95 | } 96 | 97 | # Connect to the site 98 | try { 99 | Set-Location "$SiteCode`:" 100 | Write-LogAndHost ("Connected to site: {0}" -f $SiteCode) -LogId $LogId -ForegroundColor Green 101 | } 102 | catch { 103 | Write-LogAndHost -Message ("Failed to connect to site: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 104 | 105 | throw 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /Private/Export-CsvDetails.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 05/11/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Export-Csv.ps1 7 | 8 | .Description 9 | Function to export data to a csv file. 10 | If the Csv exists, it will be rolled over to a new file with a timestamp appended to the filename 11 | 12 | .PARAMETER LogId 13 | The component (script name) passed as LogID to the 'Write-Log' function. 14 | This parameter is built from the line number of the call from the function up the pipeline 15 | 16 | .PARAMETER Data 17 | The data, as a PSCustomObject, to export 18 | 19 | .PARAMETER Name 20 | The name of the data to export 21 | 22 | .PARAMETER Path 23 | The path to export the data to 24 | #> 25 | function Export-CsvDetails { 26 | param ( 27 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 28 | [string]$LogId = $($MyInvocation.MyCommand).Name, 29 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, HelpMessage = 'The name of the data to export')] 30 | [string]$Name, 31 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1, HelpMessage = 'The data, as a PSCustomObject, to export')] 32 | [pscustomobject]$Data, 33 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 2, HelpMessage = 'The path to export the data to')] 34 | [string]$Path 35 | 36 | ) 37 | 38 | begin { 39 | 40 | # Specify CSV archive folder name 41 | $archiveFolder = 'Old' 42 | 43 | # Log the export 44 | Write-LogAndHost -Message ("Exporting '{0}' information to '{1}\{0}.csv'" -f $Name, $Path) -LogId $LogId -ForegroundColor Cyan 45 | 46 | # Build path string 47 | $fullPath = Join-Path -Path $Path -ChildPath ("{0}.csv" -f $Name) 48 | $archivePath = Join-Path -Path $Path -ChildPath $archiveFolder 49 | 50 | # Rollover the csv if it already exists from a previous run 51 | if (Test-Path -Path $fullPath) { 52 | Write-LogAndHost -Message ("'{0}' already exists, rolling over csv" -f $fullPath) -LogId $LogId -Severity 2 53 | $date = Get-Date -Format 'yyyyMMddHHmmss' 54 | $pathRename = $fullPath -replace '.csv', '' 55 | 56 | # Attempt to rename the file, if it fails continue but the file will be overwritten 57 | try { 58 | $newPath = ("{0}_{1}.csv" -f $pathRename, $date) 59 | Rename-Item -Path $fullPath -NewName $newPath 60 | 61 | # Create the archive folder if it does not exist 62 | if (-not (Test-Path -Path $archivePath)){ 63 | New-FolderToCreate -Root $Path -FolderNames $archiveFolder 64 | } 65 | 66 | # Move the old CSV into the archive folder 67 | Move-Item -Path $newPath -Destination $archivePath -Force 68 | 69 | Write-LogAndHost -Message ("Previous Csv rolled over to '{0}\{1}_{2}.csv'" -f $archivePath, $Name, $date) -LogId $LogId -ForegroundColor Cyan 70 | } 71 | catch { 72 | Write-LogAndHost -Message ("Failed to rollover '{0}'. Csv will be overwritten." -f $Path) -LogId $LogId -Severity 3 73 | } 74 | } 75 | } 76 | 77 | process { 78 | 79 | # Export the data to a csv file 80 | foreach ($object in $Data) { 81 | try { 82 | 83 | # Check if the file already exists to handle the header correctly 84 | if (-not (Test-Path -Path $fullPath) ) { 85 | 86 | # Include headers if the file doesn't exist 87 | $object | ConvertTo-Csv -NoTypeInformation | Out-File -FilePath $fullPath 88 | } 89 | else { 90 | 91 | # If the file exists, skip the header line 92 | $object | ConvertTo-Csv -NoTypeInformation | Select-Object -Skip 1 | Out-File -FilePath $fullPath -Append 93 | } 94 | } 95 | catch { 96 | Write-LogAndHost -Message ("Failed to export '{0}' information to '{1}'" -f $Type, $fullPath) -LogId $LogId -Severity 3 97 | Write-Log -Message ("'{0}'" -f $_.Exception.Message) -LogId $LogId -Severity 3 98 | 99 | throw 100 | } 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /Private/Export-Icon.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 06/11/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Export-Icon.ps1 7 | 8 | .Description 9 | Function to export icon from the selected ConfigMgr Application 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the pipeline 14 | 15 | .PARAMETER AppName 16 | The name of the application to export the icon for 17 | 18 | .PARAMETER IconPath 19 | The icon path to export the icon to 20 | 21 | .PARAMETER IconData 22 | The icon base64 data to export 23 | #> 24 | function Export-Icon { 25 | 26 | param ( 27 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 28 | [string]$LogId = $($MyInvocation.MyCommand).Name, 29 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The name of the application to export the icon for')] 30 | [string]$AppName, 31 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The icon path to export the icon to')] 32 | [string]$IconPath, 33 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 2, HelpMessage = 'The icon base64 data to export')] 34 | [string]$IconData 35 | ) 36 | process { 37 | 38 | try { 39 | 40 | #Check if the file exists 41 | if (Test-Path -Path $IconPath) { 42 | Write-LogAndHost -Message ("Application icon for '{0}' already exists at '{1}'" -f $AppName, $IconPath) -LogId $LogId -Severity 2 43 | } 44 | else { 45 | 46 | # Convert the base64 string to a byte array and save it to a file" 47 | $icon = [Convert]::FromBase64String($IconData) 48 | [System.IO.File]::WriteAllBytes($IconPath, $icon) 49 | 50 | # Check if the file exists 51 | if (Test-Path -Path $IconPath) { 52 | Write-LogAndHost -Message ("Success: Application icon for '{0}' was exported successfully to '{1}'" -f $AppName, $IconPath) -LogId $LogId -ForegroundColor Green 53 | } 54 | } 55 | } 56 | catch { 57 | Write-LogAndHost -Message ("Could not export icon for '{0}' to '{1}'" -f $AppName, "$workingFolder_Root\Logos") -LogId $LogId -Severity 3 58 | Write-Log -Message ("'{0}'" -f $_.Exception.Message) -LogId $LogId -Severity 3 59 | 60 | throw 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /Private/Get-AppInfo.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 28/10/2023 4 | Update on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-AppInfo.ps1 7 | 8 | .Description 9 | Function to get application information from ConfigMgr 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER ApplicationName 16 | The name of the application to get information for 17 | #> 18 | function Get-AppInfo { 19 | param ( 20 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 21 | [string]$LogId = $($MyInvocation.MyCommand).Name, 22 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The name of the application(s) to get information for')] 23 | [object[]]$ApplicationName 24 | ) 25 | begin { 26 | 27 | # Create an array for the display application information 28 | $applicationTypes = @() 29 | 30 | # Count the number of applications to process 31 | $applicationCount = $ApplicationName | Measure-Object | Select-Object -ExpandProperty Count 32 | 33 | # Create a counter 34 | $i = 0 35 | 36 | } 37 | process { 38 | 39 | # Iterate through each application and get the details 40 | foreach ($application in $applicationName) { 41 | 42 | # Increment counter 43 | $i++ 44 | Write-LogAndHost -Message ("Processing application '{0}' of '{1}': '{2}'" -f $i, $applicationCount, $application.LocalizedDisplayName) -LogId $LogId -ForegroundColor Cyan 45 | 46 | try { 47 | # Grab the SDMPackgeXML which contains the application details 48 | Write-LogAndHost -Message ("Invoking Get-CMApplication where Id equals '{0}' for application '{1}'" -f $application.Id, $application.LocalizedDisplayName) -LogId $LogId -ForegroundColor Cyan 49 | $xmlPackage = Get-CMApplication -Id $application.Id | Where-Object { $null -ne $_.SDMPackageXML } | Select-Object -ExpandProperty SDMPackageXML 50 | 51 | # Prepare xml from SDMPackageXML 52 | $xmlContent = [xml]($xmlPackage) 53 | 54 | # Get the total number of deployment types for the application 55 | $totalDeploymentTypes = ($xmlContent.AppMgmtDigest.Application.DeploymentTypes.DeploymentType | Measure-Object | Select-Object -ExpandProperty Count) 56 | Write-LogAndHost -Message ("The total number of deployment types for '{0}' with CI_ID '{1}' is '{2}')" -f $application.LocalizedDisplayName, $application.Id, $totalDeploymentTypes) -LogId $LogId -ForegroundColor Green 57 | 58 | # Create a new custom hashtable to store application details 59 | $applicationObject = [PSCustomObject]@{} 60 | 61 | # Add application details to PSCustomObject 62 | $applicationObject | Add-Member NoteProperty -Name Id -Value $application.Id 63 | $applicationObject | Add-Member NoteProperty -Name LogicalName -Value $xmlContent.AppMgmtDigest.Application.LogicalName 64 | $applicationObject | Add-Member NoteProperty -Name Name -Value $xmlContent.AppMgmtDigest.Application.title.'#text' 65 | $applicationObject | Add-Member NoteProperty -Name Description -Value $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.Description 66 | $applicationObject | Add-Member NoteProperty -Name Publisher -Value $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.Publisher 67 | $applicationObject | Add-Member NoteProperty -Name Version -Value $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.Version 68 | $applicationObject | Add-Member NoteProperty -Name ReleaseDate -Value $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.ReleaseDate 69 | $applicationObject | Add-Member NoteProperty -Name InfoUrl -Value $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.InfoUrl 70 | $applicationObject | Add-Member NoteProperty -Name PrivacyUrl -Value $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.PrivacyUrl 71 | $applicationObject | Add-Member NoteProperty -Name TotalDeploymentTypes -Value $totalDeploymentTypes 72 | $applicationObject | Add-Member NoteProperty -Name IconId -Value $xmlContent.AppMgmtDigest.Resources.Icon.Id 73 | 74 | # If we have the logo, add the path 75 | if (-not ($null -eq $xmlContent.AppMgmtDigest.Resources.Icon.Id) ) { 76 | $iconFileName = $xmlContent.AppMgmtDigest.Resources.Icon.Id + '.png' 77 | $iconPath = (Join-Path -Path "$workingFolder_Root\Icons" -ChildPath $iconFileName) 78 | Write-Log -Message ("Application icon path is '{0}'" -f $iconPath) -LogId $LogId 79 | $applicationObject | Add-Member NoteProperty -Name IconPath -Value $iconPath 80 | } 81 | 82 | # Add IconData to last column for easy reading 83 | $applicationObject | Add-Member NoteProperty -Name IconData -Value $xmlContent.AppMgmtDigest.Resources.Icon.Data 84 | 85 | Write-Log -Message ("Id = '{0}', LogicalName = '{1}', Name = '{2}',Description = '{3}', Publisher = '{4}', Version = '{5}', ReleaseDate = '{6}', InfoUrl = '{7}', Tags = '{8}', TotalDeploymentTypes = '{9}', IconId = '{10}', IconPath = '{11}'" -f ` 86 | $application.Id, ` 87 | $xmlContent.AppMgmtDigest.Application.LogicalName, ` 88 | $xmlContent.AppMgmtDigest.Application.title.'#text', ` 89 | $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.Description, ` 90 | $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.Publisher, ` 91 | $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.Version, ` 92 | $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.ReleaseDate, ` 93 | $xmlContent.AppMgmtDigest.Application.DisplayInfo.Info.InfoUrl, ` 94 | $xmlContent.AppMgmtDigest.Application.DisplayInfo.Tags.Tag, ` 95 | $totalDeploymentTypes, ` 96 | $xmlContent.AppMgmtDigest.Resources.Icon.Id, ` 97 | $iconPath) -LogId $LogId 98 | 99 | # Output the application object but substitue the base64 icon data for readability 100 | $applicationObjectOutput = $applicationObject | Select-Object -Property Id, LogicalName, Name, Description, Publisher, Version, ReleaseDate, InfoUrl, Tags, TotalDeploymentTypes, IconId, IconPath 101 | Write-Host "`n$applicationObjectOutput`n" -ForegroundColor Green 102 | 103 | # Add the application object to the array 104 | $applicationTypes += $applicationObject 105 | } 106 | catch { 107 | Write-LogAndHost -Message ("Could not get application information for '{0}'" -f $application.LocalizedDisplayName) -LogId $LogId -Severity 3 108 | Get-ScriptEnd -LogId $LogId -Message $_.Exception.Message 109 | } 110 | } 111 | return $applicationTypes 112 | } 113 | } -------------------------------------------------------------------------------- /Private/Get-AppList.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 25/11/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-AppList.ps1 7 | 8 | .Description 9 | Function to get applications from ConfigMgr and filter the results 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the pipeline 14 | 15 | .PARAMETER AppName 16 | The name of the application to filter on 17 | 18 | .PARAMETER ExcludePMPC 19 | Exclude Patch My PC applications 20 | 21 | .PARAMETER ExcludeFilter 22 | Exclude applications that match the filter 23 | 24 | .PARAMETER NoOgv 25 | Do not display the results in an Out-GridView 26 | 27 | .PARAMETER PmpcComment 28 | The comment to exclude Patch My PC applications. Default comment is 'Created by Patch My PC*' 29 | #> 30 | function Get-AppList { 31 | param ( 32 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 33 | [string]$LogId = $($MyInvocation.MyCommand).Name, 34 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The name of the application to get information for')] 35 | [String]$AppName, 36 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'Exclude Patch My PC applications')] 37 | [Switch]$ExcludePMPC, 38 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The name of the application to get information for')] 39 | [String]$ExcludeFilter, 40 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'Do not display the results in an Out-GridView')] 41 | [Switch]$NoOgv, 42 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 2, HelpMessage = 'The comment to exclude Patch My PC applications')] 43 | [String]$PmpcComment = 'Created by Patch My PC*' 44 | 45 | ) 46 | begin { 47 | 48 | Write-LogAndHost -Message "Function: Get-AppList was called" -LogId $LogId -ForegroundColor Cyan 49 | 50 | # Check if the ExcludeFilter parameter is null or empty. If it is, set it to $false so we can use it in the switch statement 51 | if ([string]::IsNullOrWhiteSpace($ExcludeFilter)) { 52 | $ExcludeFilter = $false 53 | } 54 | Write-LogAndHost -message "Filter State: ExcludeFilter: $ExcludeFilter, ExcludePMPC: $ExcludePMPC, NoOgv: $NoOgv" -logid $LogId -foregroundcolor cyan 55 | 56 | } 57 | process { 58 | 59 | try { 60 | 61 | # Check the parameters passed to select the correct switch option 62 | switch ($ExcludePMPC) { 63 | $true { 64 | switch ($ExcludeFilter) { 65 | $true { 66 | switch ($NoOgv) { 67 | $true { 68 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}' excluding apps like '{1}' and where the comment field is like '{2}' (NoOgv parameter passed)" -f $AppName, $ExcludeFilter, $Pmpc_Comment) -LogId $LogId -ForegroundColor Cyan 69 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Where-Object { (-not ($_.LocalizedDisplayName -like "$ExcludeFilter") ) -and (-not ($_.LocalizedDescription -like "$Pmpc_Comment") ) } | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName 70 | } 71 | $false { 72 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}' excluding apps like '{1}'" -f $AppName, $ExcludeFilter) -LogId $LogId -ForegroundColor Cyan 73 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Where-Object { (-not ($_.LocalizedDisplayName -like "$ExcludeFilter") ) -and (-not ($_.LocalizedDescription -like "$Pmpc_Comment") ) } | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName | Out-GridView -Title 'Select an application(s) to process the associated deployment types' -OutputMode Single 74 | } 75 | } 76 | } 77 | $false { 78 | switch ($NoOgv) { 79 | $true { 80 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}' where the comment field is like '{1}' (NoOgv parameter passed)" -f $AppName, $Pmpc_Comment) -LogId $LogId -ForegroundColor Cyan 81 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Where-Object { (-not ($_.LocalizedDescription -like "$Pmpc_Comment") ) } | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName 82 | } 83 | $false { 84 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}' where the comment field is like '{1}'" -f $AppName, $Pmpc_Comment) -LogId $LogId -ForegroundColor Cyan 85 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Where-Object { (-not ($_.LocalizedDescription -like "$Pmpc_Comment") ) } | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName | Out-GridView -Title 'Select an application(s) to process the associated deployment types' -OutputMode Single 86 | } 87 | } 88 | } 89 | } 90 | } 91 | $false { 92 | switch ($ExcludeFilter) { 93 | $true { 94 | switch ($NoOgv) { 95 | $true { 96 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}' excluding apps like '{1}' (NoOgv parameter passed)" -f $AppName, $ExcludeFilter) -LogId $LogId -ForegroundColor Cyan 97 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Where-Object { (-not($_.LocalizedDisplayName -like "$ExcludeFilter")) } | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName 98 | } 99 | $false { 100 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}' excluding apps like '{1}'" -f $AppName, $ExcludeFilter) -LogId $LogId -ForegroundColor Cyan 101 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Where-Object { ( -not ($_.LocalizedDisplayName -like "$ExcludeFilter") ) } | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName | Out-GridView -Title 'Select an Application(s) to process the associated deployment types' -OutputMode Single 102 | } 103 | } 104 | } 105 | $false { 106 | switch ($NoOgv) { 107 | $true { 108 | write-host "hank" 109 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}' (NoOgv parameter passed)" -f $AppName) -LogId $LogId -ForegroundColor Cyan 110 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName 111 | } 112 | $false { 113 | Write-LogAndHost -Message ("Invoking Get-CMApplication (fast) including apps like '{0}'" -f $AppName) -LogId $LogId -ForegroundColor Cyan 114 | $applicationResult = Get-CMApplication -Fast -Name "*$AppName*" | Select-Object @{ Name = 'Id'; Expression = { $_.CI_ID.toString() } }, LocalizedDisplayName, HasContent, NumberOfDeploymentTypes, IsDeployable, IsDeployed, DateCreated, DateLastModified, LastModifiedBy | Sort-Object LocalizedDisplayName | Out-GridView -Title 'Select an application to process the associated deployment types' -OutputMode Single 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | # Check if any applications were selected or found 123 | if ($applicationResult) { 124 | 125 | return $applicationResult 126 | 127 | } 128 | else { 129 | Write-LogAndHost -Message "No applications selected" -LogId $LogId -ForegroundColor Yellow 130 | } 131 | } 132 | catch { 133 | Write-LogAndHost -Message ("Could not get application information for '{0}'" -f $AppName) -LogId $LogId -Severity 3 134 | Get-ScriptEnd -LogId $LogId -ErrorMessage $_.Exception.Message 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /Private/Get-ClientCertificate.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 03/04/2024 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-ClientCertificate.ps1 7 | 8 | .Description 9 | Function to get a client certificate from the local certificate store 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER Thumbprint 16 | The thumbprint of the client certificate to get. 17 | #> 18 | function Get-ClientCertificate { 19 | param ( 20 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 21 | [string]$LogId = $($MyInvocation.MyCommand).Name, 22 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, HelpMessage = 'Thumbprint of the client certificate to get')] 23 | [string]$Thumbprint 24 | ) 25 | 26 | process { 27 | 28 | # Define the certificate stores to search 29 | $stores = @('CurrentUser', 'LocalMachine') 30 | foreach ($certStore in $stores) { 31 | 32 | # Get the certificate from the certificate store 33 | $result = Get-Item -Path "Cert:\$($certStore)\My\$($thumbprint)" 34 | if (-not $result) { 35 | Write-LogAndHost -Message ("Certificate with thumbprint '{0}' was not found in the '{1}' certificate store" -f $thumbprint, $certStore) -LogId $LogId -Severity 2 36 | 37 | return $false 38 | } 39 | else { 40 | Write-LogAndHost -Message ("Certificate with thumbprint '{0}' was found in the '{1}' certificate store with the subject '{2}'" -f $thumbprint, $certStore, $result.Subject) -LogId $LogId -ForegroundColor Green 41 | 42 | return $result 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /Private/Get-ContentFiles.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 04/11/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-ContentFiles.ps1 7 | 8 | .Description 9 | Function to get content from the content source folder for the deployment type and copy it to the content destination folder 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER Source 16 | Source path for content to be copied from 17 | 18 | .PARAMETER Destination 19 | Destination path for content to be copied to 20 | 21 | .PARAMETER Flags 22 | Add verbose message if specfic flag is set 23 | #> 24 | function Get-ContentFiles { 25 | param ( 26 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 27 | [string]$LogId = $($MyInvocation.MyCommand).Name, 28 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'Source path for content to be copied from')] 29 | [string]$Source, 30 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = 'Destination path for content to be copied to')] 31 | [string]$Destination, 32 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 2, HelpMessage = 'Add verbose message if specfic flag is set')] 33 | [string]$Flags 34 | ) 35 | 36 | process { 37 | 38 | # Sanitise the source and destination paths 39 | if ($Flags -eq 'UninstallDifferent') { 40 | Write-LogAndHost -Message ("Uninstall content is different for '{0}'. Will copy content to \Uninstall folder" -f $deploymentType.Name) -LogId $LogId -Severity 2 -NewLine 41 | } 42 | 43 | # Create destination folders if they don't exist 44 | Write-Log -Message ("Attempting to create the destination folder '{0}'" -f $Destination) -LogId $LogId 45 | Write-Host ("{0}Attempting to create the destination folder '{1}'" -f $(if ($flags -ne 'UninstallDifferent') { "`n" }), $Destination) -ForegroundColor Cyan 46 | 47 | if (-not (Test-Path -Path $Destination) ) { 48 | try { 49 | 50 | # Create the destination folder 51 | New-Item -Path $Destination -ItemType Directory -Force | Out-Null 52 | Write-LogAndHost -Message ("Successfully created the destination folder '{0}'" -f $Destination) -LogId $LogId -ForegroundColor Green 53 | } 54 | catch { 55 | Write-LogAndHost -Message ("Error: Could not create the destination folder '{0}'" -f $Destination) -LogId $LogId -Severity 3 56 | Write-Log -Message ("'{0}'" -f $_.Exception.Message) -LogId $LogId -Severity 3 57 | 58 | throw 59 | } 60 | } 61 | else { 62 | Write-LogAndHost -Message ("The destination folder '{0}' already exists. Continuing with the copy..." -f $Destination) -LogId $LogId -Severity 2 63 | } 64 | Write-LogAndHost -Message ("Attempting to copy content from '{0}' to '{1}'" -f $Source, $Destination) -LogId $LogId -ForegroundColor Cyan 65 | 66 | # Convert UNC paths to FileSystem paths 67 | $sourceUNC = "FileSystem::$($Source)" 68 | 69 | try { 70 | 71 | # List files to copy 72 | $filesToCopy = Get-ChildItem -Path $sourceUNC -Recurse -ErrorAction Stop 73 | $filesToCopy | Select-Object -ExpandProperty FullName | foreach-object { Write-Log -Message ("'{0}'" -f $_) -LogId $LogId } 74 | Write-LogAndHost -Message ("There are '{0}' items to copy" -f $filesToCopy.Count) -LogId $LogId -ForegroundColor Cyan 75 | 76 | # Initialize a counter 77 | $fileCount = 0 78 | 79 | # Copy items and track progress 80 | foreach ($file in $filesToCopy) { 81 | 82 | # Construct the source path 83 | $sourceFile = "FileSystem::$($file.FullName)" 84 | $relativePath = $file.FullName.Substring($source.Length) 85 | 86 | # Construct the destination path 87 | $destinationFile = Join-Path -Path $Destination -ChildPath $relativePath 88 | 89 | # Copy the item 90 | try { 91 | Write-LogAndHost -Message ("Copying '{0}' to '{1}'" -f $sourceFile, $destinationFile) -LogId $LogId -ForegroundColor Cyan 92 | Copy-Item -Path $sourceFile -Destination $destinationFile -Force 93 | 94 | # Increment the counter 95 | $fileCount++ 96 | 97 | # Calculate and display progress as a percentage 98 | $progressPercentage = ($fileCount / ($filesToCopy).Count) * 100 99 | Write-Progress -PercentComplete $progressPercentage -Activity 'Copying Files' -Status $file.FullName -CurrentOperation "Progress: $progressPercentage%" 100 | } 101 | catch { 102 | Write-LogAndHost -Message ("Error: Could not copy '{0}' to '{1}'" -f $sourceFile, $destinationFile) -LogId $LogId -Severity 3 103 | Write-Log -Message ("'{0}'" -f $_.Exception.Message) -LogId $LogId -Severity 3 104 | 105 | throw 106 | } 107 | } 108 | 109 | # Remove the progress bar once the copying is complete 110 | Write-Progress -Completed -Activity 'File copy completed' 111 | 112 | try { 113 | 114 | # Compare the source and destination folders to ensure the copy was successful 115 | # Extract file names from the paths 116 | $sourceFileNames = $sourceCompare | ForEach-Object { [System.IO.Path]::GetFileName($_) } 117 | $destinationFileNames = $destinationCompare | ForEach-Object { [System.IO.Path]::GetFileName($_) } 118 | $compareResult = Compare-Object -ReferenceObject $sourceFileNames -DifferenceObject $destinationFileNames 119 | 120 | try { 121 | 122 | # Filter out the differences 123 | $differences = $compareResult | Where-Object { $_.SideIndicator -eq '<=' -or $_.SideIndicator -eq '=>' } 124 | 125 | if ($differences) { 126 | 127 | # Files are different but this is OK if the uninstall content has been copied. Check if we have all the source files in the destination folder 128 | foreach ($difference in $differences) { 129 | if ($difference.SideIndicator -eq '<=') { 130 | Write-LogAndHost -Message ("'{0}' found in the source folder, but not in the destination folder" -f $difference.InputObject) -LogId $LogId -Severity 3 131 | } 132 | } 133 | } 134 | else { 135 | 136 | # Files are the same 137 | Write-LogAndHost -Message 'All files were verified in the destination folder. Copy was successful' -LogId $LogId -ForegroundColor Green 138 | } 139 | } 140 | catch { 141 | 142 | # Files are different 143 | Write-LogAndHost -Message 'Could not compare the differences between the source and destination folders' -LogId $LogId -Severity 3 144 | Write-Log -Message ("'{0}'" -f $_.Exception.Message) -LogId $LogId -Severity 3 145 | Get-ScriptEnd -LogId $LogId -Message $_.Exception.Message 146 | } 147 | } 148 | catch { 149 | 150 | # Could not compare the source and destination folders 151 | Write-LogAndHost -Message 'Could not compare the source and destination folders' -LogId $LogId -Severity 3 152 | Write-Log -Message ("'{0}'" -f $_.Exception.Message) -LogId $LogId -Severity 3 153 | Get-ScriptEnd -LogId $LogId -Message $_.Exception.Message 154 | } 155 | } 156 | catch { 157 | 158 | # Could not transfer content 159 | Write-LogAndHost -Message ("Could not transfer content from '{0}' to '{1}'" -f $sourceSanitised, $destinationSanitised) -LogId $LogId -Severity 3 160 | Write-Log -Message ("'{0}'" -f $_.Exception.Message) -LogId $LogId -Severity 3 161 | Get-ScriptEnd -LogId $LogId -Message $_.Exception.Message 162 | } 163 | } 164 | } -------------------------------------------------------------------------------- /Private/Get-ContentInfo.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 04/11/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-ContentInfo.ps1 7 | 8 | .Description 9 | Function to get content from the content source folder for the deployment type and copy it to the content destination folder 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER InstallContent 16 | The content path for intent to install 17 | 18 | .PARAMETER UninstallContent 19 | The content path for intent to uninstall 20 | 21 | .PARAMETER ApplicationId 22 | The id of the application for the deployment type to get content for 23 | 24 | .PARAMETER ApplicationName 25 | The name of the application for the deployment type to get content for 26 | 27 | .PARAMETER DeploymentTypeLogicalName 28 | The logical name of the deployment type to get content for 29 | 30 | .PARAMETER DeploymentTypeName 31 | The name of the deployment type to get content for 32 | 33 | .PARAMETER UninstallSetting 34 | Is uninstall content same as install or different? 35 | 36 | .PARAMETER InstallCommandLine 37 | Command line used to install the deployment type 38 | #> 39 | function Get-ContentInfo { 40 | param ( 41 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 42 | [string]$LogId = $($MyInvocation.MyCommand).Name, 43 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'Content path for intent to install')] 44 | [string]$InstallContent, 45 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = 'Content path for intent to uninstall')] 46 | [string]$UninstallContent, 47 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 2, HelpMessage = 'The id of the application for the deployment type to get content for')] 48 | [string]$ApplicationId, 49 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 3, HelpMessage = 'The name of the application for the deployment type to get content for')] 50 | [string]$ApplicationName, 51 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 4, HelpMessage = 'The logical name of the deployment type to get content for')] 52 | [string]$DeploymentTypeLogicalName, 53 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 5, HelpMessage = 'The name of the deployment type to get content for')] 54 | [string]$DeploymentTypeName, 55 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 6, HelpMessage = 'Is uninstall content same as install or different?')] 56 | [string]$UninstallSetting, 57 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 7, HelpMessage = 'Command line used to install the deployment type')] 58 | [string]$InstallCommandLine 59 | ) 60 | begin { 61 | 62 | # Characters that are not allowed in Windows folder names 63 | $invalidChars = '[<>:"/\\|\?\*]' 64 | 65 | # Sanitize the folder names 66 | $ApplicationNameSanitized = ($ApplicationName -replace $invalidChars, '_').TrimEnd('.', ' ') 67 | $DeploymentTypeNameSanitized = ($DeploymentTypeName -replace $invalidChars, '_').TrimEnd('.', ' ') 68 | 69 | # Content folder(s) to copy to 70 | $destinationInstallFolder = ("{0}\{1}" -f $ApplicationNameSanitized, $DeploymentTypeNameSanitized) 71 | $destinationUninstallFolder = ("{0}\{1}\Uninstall" -f $ApplicationNameSanitized, $DeploymentTypeNameSanitized) 72 | 73 | # Build final folder name strings 74 | $destinationInstallFolder = Join-Path -Path "$workingFolder_Root\Content" -ChildPath $destinationInstallFolder 75 | $destinationUninstallFolder = Join-Path -Path "$workingFolder_Root\Content" -ChildPath $destinationUninstallFolder 76 | } 77 | process { 78 | 79 | Write-LogAndHost -Message ("Getting content details for the application '{0}' and deployment type '{1}'" -f $applicationName, $DeploymentTypeName) -LogId $LogId -ForegroundColor Cyan 80 | 81 | # Create a new custom hashtable to store content details 82 | $contentObject = [PSCustomObject]@{} 83 | 84 | # Add content details to the PSCustomObject 85 | $contentObject | Add-Member NoteProperty -Name Application_Id -Value $ApplicationId 86 | $contentObject | Add-Member NoteProperty -Name Application_Name -Value $ApplicationName 87 | $contentObject | Add-Member NoteProperty -Name DeploymentType_LogicalName -Value $DeploymentTypeLogicalName 88 | $contentObject | Add-Member NoteProperty -Name DeploymentType_Name -Value $DeploymentTypeName 89 | $contentObject | Add-Member NoteProperty -Name Install_Source -Value $InstallContent 90 | $contentObject | Add-Member NoteProperty -Name Uninstall_Setting -Value $UninstallSetting 91 | $contentObject | Add-Member NoteProperty -Name Uninstall_Source -Value $UninstallContent 92 | $contentObject | Add-Member NoteProperty -Name Install_Destination -Value $destinationInstallFolder 93 | $contentObject | Add-Member NoteProperty -Name Uninstall_Destination -Value $destinationUninstallFolder 94 | $contentObject | Add-Member NoteProperty -Name Install_CommandLine -Value $InstallCommandLine 95 | $contentObject | Add-Member NoteProperty -Name Win32app_Destination -Value "$ApplicationNameSanitized\$DeploymentTypeNameSanitized" 96 | 97 | Write-Log -Message ("Application_Id = '{0}', Application_Name = '{1}', DeploymentType_LogicalName = '{2}', DeploymentType_Name = '{3}', Install_Source = '{4}', Uninstall_Setting = '{5}', Uninstall_Source = '{6}', Install_Destination = '{7}', Uninstall_Destination = '{8}', Win32app_Destinaton = '{9}'" -f ` 98 | $ApplicationId, ` 99 | $ApplicationName, ` 100 | $DeploymentTypeLogicalName, ` 101 | $DeploymentTypeName, ` 102 | $InstallContent, ` 103 | $UninstallSetting, ` 104 | $UninstallContent, ` 105 | $destinationInstallFolder, ` 106 | $destinationUninstallFolder, ` 107 | $Win32app_Destination) -LogId $LogId 108 | 109 | # Output the deployment type object 110 | Write-Host "`n$contentObject`n" -ForegroundColor Green 111 | 112 | return $contentObject 113 | } 114 | } -------------------------------------------------------------------------------- /Private/Get-FileFromInternet.ps1: -------------------------------------------------------------------------------- 1 | 2 | <# 3 | .Synopsis 4 | Created on: 28/10/2023 5 | Updated on: 01/01/2025 6 | Created by: Ben Whitmore 7 | Filename: Get-FileFromInternet.ps1 8 | 9 | .Description 10 | Function to download a file from the internet 11 | 12 | .PARAMETER LogID 13 | The component (script name) passed as LogID to the 'Write-Log' function. 14 | This parameter is built from the line number of the call from the function up the pipeline 15 | 16 | .PARAMETER Uri 17 | The URI of the file to download 18 | 19 | .PARAMETER Destination 20 | The destination folder to download the file to 21 | #> 22 | function Get-FileFromInternet { 23 | param ( 24 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 25 | [string]$LogId = $($MyInvocation.MyCommand).Name, 26 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The URI of the file to download')] 27 | [String]$Uri, 28 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The destination folder to download the file to')] 29 | [String]$Destination 30 | ) 31 | 32 | begin { 33 | Write-LogAndHost -Message 'Function: Get-FileFromInternet was called' -LogId $LogId -ForegroundColor Cyan 34 | Write-LogAndHost -Message ("Attempting to download the file from '{0}'" -f $Uri) -LogId $LogId -ForegroundColor Cyan 35 | Write-LogAndHost -Message ("File destination will be '{0}'" -f $Destination) -LogId $LogId -ForegroundColor Cyan 36 | } 37 | 38 | process { 39 | 40 | # Test the Uri is valid 41 | try { 42 | $uriRequest = Invoke-WebRequest -Method Get -UseBasicParsing -URI $Uri -ErrorAction SilentlyContinue 43 | $statusCode = $uriRequest.StatusCode 44 | } 45 | catch { 46 | $statusCode = $_.Exception.Response.StatusCode.Value__ 47 | Write-LogAndHost -Message ("It looks like the Uri '{0}' is invalid. Error '{1}" -f $Uri, $statusCode) -LogId $LogId -Severity 3 48 | 49 | throw 50 | } 51 | 52 | # If the URL is valid, attempt to download the file otherwise break and warn 53 | if ($statusCode -eq 200) { 54 | try { 55 | 56 | Write-LogAndHost -Message ("Response '{0}' received'. Attempting download...'" -f $statusCode) -LogId $LogId -ForegroundColor Cyan 57 | Invoke-WebRequest -UseBasicParsing -Method Get -Uri $Uri -OutFile $Destination -ErrorAction SilentlyContinue 58 | 59 | if (Test-Path -Path $fileDestination) { 60 | Write-LogAndHost -Message ("File download successful. File saved to '{0}'" -f $fileDestination) -LogId $LogId -ForegroundColor Green 61 | } 62 | else { 63 | Write-LogAndHost -Message ("The download was interrupted or an error occured moving the file to '{0}'" -f $Uri) -LogId $LogId -Severity 3 64 | } 65 | } 66 | catch { 67 | 68 | # Error downloading the file 69 | Write-LogAndHost -Message ("Error downloading file '{0}'" -f $Uri) -LogId $LogId -Severity 3 70 | 71 | throw 72 | } 73 | } 74 | else { 75 | Write-LogAndHost -Message ("URL Does not exists or the website is down. Status Code '{0}'" -f $statusCode) -LogId $LogId -Severity 3 76 | 77 | throw 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /Private/Get-InstallCommand.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 11/11/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-InstallCommand.ps1 7 | 8 | .Description 9 | Function to build command line for use with the content prep tool 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the pipeline 14 | 15 | .PARAMETER InstallTech 16 | The setup file to be used for packaging. Normally the .msi, .exe or .ps1 file used to install the application 17 | 18 | .PARAMETER SetupFile 19 | The setup file to be used for packaging. Normally the .msi, .exe or .ps1 file used to install the application 20 | #> 21 | function Get-InstallCommand { 22 | 23 | param( 24 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 25 | [string]$LogId = $($MyInvocation.MyCommand).Name, 26 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, HelpMessage = 'The setup file to be used for packaging. Normally the .msi, .exe or .ps1 file used to install the application')] 27 | [string]$InstallTech, 28 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1, HelpMessage = 'The setup file to be used for packaging. Normally the .msi, .exe or .ps1 file used to install the application')] 29 | [string]$SetupFile 30 | 31 | ) 32 | process { 33 | Write-LogAndHost -Message "Function: Get-InstallCommand was called" -Log "Main.log" -ForegroundColor Cyan 34 | 35 | # Search the Install Command line for the installer type 36 | Write-LogAndHost -Message ("'{0}' installer type was detected" -f $InstallTech) -LogId $LogId -ForegroundColor Green 37 | 38 | # Build the command to be used with the Win32 Content Prep Tool 39 | $right = ($SetupFile -split [regex]::Escape("$InstallTech"))[0] 40 | $rightMod = ($right -split '["'']')[-1] 41 | $fileName = $rightMod.TrimStart("\", ".", "`"", "'", " ") 42 | $command = $fileName + $InstallTech 43 | 44 | # Verbose and log the result 45 | Write-LogAndHost -Message "Extracting the SetupFile Name for the Microsoft Win32 Content Prep Tool from the Install Command..." -LogId $LogId -ForegroundColor Cyan 46 | Write-LogAndHost -Message ("The setupfile to pass to Win32ContentPrepTool is '{0}'" -f $command) -LogId $LogId -ForegroundColor Green 47 | 48 | return $command 49 | } 50 | } -------------------------------------------------------------------------------- /Private/Get-IntuneWinEncryptionDetails.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 11/11/2023 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-IntuneWinEncryptionDetails.ps1 7 | 8 | .Description 9 | Function to get extract the .intunewin bin file for encryption details from the XML 10 | 11 | .Parameter FilePath 12 | The path to the .intunewin bin file to extract the XML from 13 | 14 | .PARAMETER LogId 15 | The component (script name) passed as LogID to the 'Write-Log' function. 16 | This parameter is built from the line number of the call from the function up the pipeline 17 | #> 18 | 19 | function Get-IntuneWinEncryptionInfo { 20 | [CmdletBinding()] 21 | param( 22 | [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'The path to the .intunewin bin file to extract the XML from')] 23 | [string]$FilePath 24 | ) 25 | 26 | try { 27 | 28 | # Open the .intunewin archive 29 | $binFile = [System.IO.Compression.ZipFile]::OpenRead($FilePath) 30 | 31 | # Locate the IntunePackage.intunewin file inside the archive 32 | $intunePackageEntry = $binFile.Entries | Where-Object { $_.Name -eq "IntunePackage.intunewin" } 33 | 34 | if ($intunePackageEntry) { 35 | 36 | # Create a "temp" folder in the $FilePath directory 37 | $tempDir = Join-Path -Path (Split-Path -Path $FilePath) -ChildPath "extracted" 38 | 39 | if (-not (Test-Path -Path $tempDir)) { 40 | New-Item -Path $tempDir -ItemType Directory -Force | Out-Null 41 | } 42 | 43 | # Extract IntunePackage.intunewin to the "temp" folder 44 | $tempPath = Join-Path -Path $tempDir -ChildPath "IntunePackage.intunewin" 45 | [System.IO.Compression.ZipFileExtensions]::ExtractToFile($intunePackageEntry, $tempPath, $true) 46 | Write-LogAndHost -Message ("Successfully extracted encrypted IntunePackage.intunewin to '{0}'" -f $tempPath) -LogId $LogId -ForegroundColor Green 47 | } 48 | else { 49 | Write-LogAndHost -Message ("IntunePackage.intunewin not found in the .intunewin archive at '{0}'" -f $FilePath) -LogId $LogId 50 | 51 | throw 52 | } 53 | 54 | # Locate the metadata.xml file inside the archive 55 | $xml = $binFile.Entries | Where-Object { $_.Name -like "Detection.xml" } 56 | 57 | if ([string]::IsNullOrEmpty($xml) -eq $false) { 58 | 59 | # Open the metadata.xml file 60 | $laserBeams = $xml.Open() 61 | 62 | # Read the XML content 63 | $beamReader = New-Object -TypeName "System.IO.StreamReader" -ArgumentList $laserBeams 64 | $xmlMeta = [xml]($beamReader.ReadToEnd()) 65 | 66 | # Extract application information 67 | $contentApplicationInfo = [ordered]@{ 68 | name = $xmlMeta.ApplicationInfo.Name 69 | unencryptedContentSize = $xmlMeta.ApplicationInfo.UnencryptedContentSize 70 | fileName = $xmlMeta.ApplicationInfo.FileName 71 | setupFile = $xmlMeta.ApplicationInfo.SetupFile 72 | } 73 | 74 | # Extract encryption details 75 | $contentEncryptionData = [ordered]@{ 76 | encryptionKey = $xmlMeta.ApplicationInfo.EncryptionInfo.EncryptionKey 77 | macKey = $xmlMeta.ApplicationInfo.EncryptionInfo.MacKey 78 | initializationVector = $xmlMeta.ApplicationInfo.EncryptionInfo.InitializationVector 79 | mac = $xmlMeta.ApplicationInfo.EncryptionInfo.Mac 80 | profileIdentifier = $xmlMeta.ApplicationInfo.EncryptionInfo.ProfileIdentifier 81 | fileDigest = $xmlMeta.ApplicationInfo.EncryptionInfo.FileDigest 82 | fileDigestAlgorithm = $xmlMeta.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm 83 | } 84 | 85 | # Close and dispose objects to preserve memory 86 | $laserBeams.Close() 87 | $beamReader.Close() 88 | } 89 | else { 90 | Write-LogAndHost -Message "metadata.xml not found in the .intunewin archive." -LogId $LogId -Severity 3 91 | 92 | throw 93 | } 94 | 95 | # Dispose of the archive 96 | $binFile.Dispose() 97 | } 98 | catch { 99 | Write-LogAndHost -Message ("Error extracting metadata from the .intunewin file: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 100 | 101 | throw 102 | } 103 | 104 | # Return the intunewin encryption details 105 | if (-not [string]::IsNullOrEmpty($contentEncryptionData)) { 106 | Write-LogAndHost -Message ("Application info details: {0}" -f ($contentApplicationInfo | ConvertTo-Json -Compress)) -LogId $LogId -ForegroundColor Green 107 | Write-LogAndHost -Message ("Encryption details: {0}" -f ($contentEncryptionData | ConvertTo-Json -Compress)) -LogId $LogId -ForegroundColor Green 108 | 109 | return @{ 110 | encryptionDetails = ($contentEncryptionData | ConvertTo-Json -Compress) 111 | contentApplicationInfo = ($contentApplicationInfo | ConvertTo-Json -Compress) 112 | intuneWinPath = $tempPath 113 | } 114 | } else { 115 | Write-LogAndHost -Message "No encryption details found in the .intunewin archive." -LogId $LogId -Severity 3 116 | 117 | return $false 118 | } 119 | } -------------------------------------------------------------------------------- /Private/Get-IntuneWinInfo.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 09/06/2024 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-IntuneWinInfo.ps1 7 | 8 | .Description 9 | Function to get metadata from a .intunewin file 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the pipeline 14 | 15 | .PARAMETER SetupFile 16 | The .intunewin file to get metadata from 17 | 18 | #> 19 | function Get-IntuneWinInfo { 20 | param( 21 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 22 | [string]$LogId = $($MyInvocation.MyCommand).Name, 23 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The setup file to be used for packaging. Normally the .msi, .exe or .ps1 file used to install the application')] 24 | [string]$SetupFile 25 | ) 26 | begin { 27 | 28 | Write-LogAndHost -Message "Function: Get-IntuneWinInfo was called" -Log "Main.log" -ForegroundColor Cyan 29 | 30 | try { 31 | 32 | # Explicitly load the System.IO.Compression.FileSystem assembly if the PS version is 5 or lower 33 | if ($PSVersionTable.PSVersion.Major -lt 6) { 34 | Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction Stop 35 | } 36 | } 37 | catch { 38 | Write-LogAndHost -Message "Failed to load System.IO.Compression.FileSystem" -LogId $LogId -Severity 3 39 | 40 | throw 41 | } 42 | } 43 | process { 44 | 45 | $intuneWinInfoArray = [ordered]@{} 46 | $compressedContent = [System.IO.Compression.ZipFile]::OpenRead($SetupFile) 47 | 48 | # Find the entry for Detection.xml in the MetaData folder 49 | $xmlEntry = $compressedContent.Entries | Where-Object { $_.FullName -match "MetaData/Detection.xml" } 50 | 51 | if (-not [string]::IsNullOrEmpty($xmlEntry)) { 52 | 53 | # Open the XML entry and read its content 54 | $stream = $xmlEntry.Open() 55 | $reader = New-Object System.IO.StreamReader($stream) 56 | $xmlContent = [xml]$reader.ReadToEnd() 57 | $reader.Close() 58 | $stream.Close() 59 | } 60 | else { 61 | Write-LogAndHost -Message ("Detection.xml not found in the .intunewin archive at '{0}'" -f $SetupFile) -LogId $LogId -Severity 3 62 | throw 63 | } 64 | 65 | # Add the required metadata to the array 66 | $intuneWinInfoArray['FileName'] = $xmlContent.ApplicationInfo.FileName 67 | $intuneWinInfoArray['SetupFile'] = $xmlContent.ApplicationInfo.SetupFile 68 | $intuneWinInfoArray['UnencryptedContentSize'] = $xmlContent.ApplicationInfo.UnencryptedContentSize 69 | 70 | # Close the compressed content 71 | $compressedContent.Dispose() 72 | 73 | return $intuneWinInfoArray 74 | } 75 | } -------------------------------------------------------------------------------- /Private/Get-LocalDetectionMethods.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 17/02/2024 4 | Update on: 04/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-LocalDetectionMethods.ps1 7 | 8 | .Description 9 | Function to get the local detection methods from the detection methods xml object 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER XMLObject 16 | The XML content object to extract the detection methods from 17 | #> 18 | 19 | function Get-DetectionMethod { 20 | param ( 21 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 22 | [string]$LogId = $($MyInvocation.MyCommand).Name, 23 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The local detection methods XML content')] 24 | [object]$XMLObject 25 | ) 26 | begin { 27 | # Convert the XMLObject to an XML document 28 | [xml]$xmlDocument = $XMLObject 29 | 30 | # Create a namespace manager for the XML document 31 | $namespaceManager = New-Object System.Xml.XmlNamespaceManager($xmlDocument.NameTable) 32 | $namespaceManager.AddNamespace("def", "http://schemas.microsoft.com/SystemCenterConfigurationManager/2009/AppMgmtDigest") 33 | } 34 | process { 35 | # Create an empty array to store the settings 36 | $settings = @() 37 | 38 | # Helper function to iterate through the XML nodes and find SettingReferences 39 | function Find-SettingReferences { 40 | param ( 41 | [System.Xml.XmlNode]$node, 42 | [ref]$rules 43 | ) 44 | 45 | # If the node is a SettingReference, add it to the rules 46 | if ($node.Name -eq 'SettingReference') { 47 | $logicalName = $node.SettingLogicalName 48 | 49 | if ($logicalName) { 50 | 51 | if (-not $rules.Value.ContainsKey($logicalName)) { 52 | $rules.Value[$logicalName] = @() 53 | } 54 | 55 | $parentExpression = $node.ParentNode.ParentNode 56 | $ruleDetail = @{ 57 | Operator = $parentExpression.Operator 58 | ConstantValue = $parentExpression.Operands.ConstantValue.Value 59 | ConstantDataType = $parentExpression.Operands.ConstantValue.DataType 60 | } 61 | $rules.Value[$logicalName] += $ruleDetail 62 | } 63 | } 64 | # If the node is an Operands or Expression node, iterate through its child nodes to get SettingReferences 65 | elseif ($node.Name -eq 'Operands' -or $node.Name -eq 'Expression') { 66 | foreach ($childNode in $node.ChildNodes) { 67 | 68 | Find-SettingReferences -Node $childNode -Rules ([ref]$rules.Value) 69 | } 70 | } 71 | } 72 | 73 | # Pre-process rules to match settings 74 | $rules = @{} 75 | 76 | # Find SettingReferences in the first level of the EnhancedDetectionMethod 77 | if ($xmlDocument.EnhancedDetectionMethod.Rule.Expression.Operands.Expression) { 78 | $xmlDocument.EnhancedDetectionMethod.Rule.Expression.Operands.Expression | ForEach-Object { 79 | Find-SettingReferences -Node $_ -Rules ([ref]$rules) 80 | } 81 | } 82 | 83 | # Find SettingReferences if a child operands node doesn't exist 84 | elseif ($xmlDocument.EnhancedDetectionMethod.Rule.Expression) { 85 | $xmlDocument.EnhancedDetectionMethod.Rule.Expression | ForEach-Object { 86 | Find-SettingReferences -Node $_ -Rules ([ref]$rules) 87 | } 88 | } 89 | 90 | # Create an array to store the settings 91 | $settingsNodes = $xmlDocument.DocumentElement.SelectNodes("//def:Settings/*", $namespaceManager) 92 | foreach ($node in $settingsNodes) { 93 | $logicalName = $node.LogicalName 94 | 95 | $setting = [PSCustomObject]@{ 96 | Type = $node.LocalName 97 | LogicalName = $logicalName 98 | } 99 | 100 | # Populate specific properties based on the type of setting 101 | switch ($node.LocalName) { 102 | "SimpleSetting" { 103 | $setting | Add-Member -NotePropertyName "DataType" -NotePropertyValue $node.DataType 104 | $setting | Add-Member -NotePropertyName "Is64Bit" -NotePropertyValue $node.RegistryDiscoverySource.Is64Bit 105 | $setting | Add-Member -NotePropertyName "Depth" -NotePropertyValue $node.RegistryDiscoverySource.Depth 106 | $setting | Add-Member -NotePropertyName "Hive" -NotePropertyValue $node.RegistryDiscoverySource.Hive 107 | $setting | Add-Member -NotePropertyName "Key" -NotePropertyValue $node.RegistryDiscoverySource.Key 108 | $setting | Add-Member -NotePropertyName "ValueName" -NotePropertyValue $node.RegistryDiscoverySource.ValueName 109 | } 110 | "File" { 111 | $setting | Add-Member -NotePropertyName "Is64Bit" -NotePropertyValue $node.Is64Bit 112 | $setting | Add-Member -NotePropertyName "Path" -NotePropertyValue $node.Path 113 | $setting | Add-Member -NotePropertyName "Filter" -NotePropertyValue $node.Filter 114 | } 115 | "MSI" { 116 | $setting | Add-Member -NotePropertyName "ProductCode" -NotePropertyValue $node.ProductCode 117 | } 118 | } 119 | 120 | # Dynamically add rules as additional properties 121 | if ($rules.ContainsKey($logicalName)) { 122 | foreach ($rule in $rules[$logicalName]) { 123 | $setting | Add-Member -NotePropertyName "Rules_Operator" -NotePropertyValue $rule.Operator 124 | $setting | Add-Member -NotePropertyName "Rules_ConstantValue" -NotePropertyValue $rule.ConstantValue 125 | $setting | Add-Member -NotePropertyName "Rules_ConstantDataType" -NotePropertyValue $rule.ConstantDataType 126 | } 127 | } 128 | 129 | # Add the setting to the settings array 130 | $settings += $setting 131 | } 132 | 133 | return $settings 134 | } 135 | } -------------------------------------------------------------------------------- /Private/Get-SasUri.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 09/06/2024 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Get-SasUri.ps1 7 | 8 | .Description 9 | Function to get a SAS URI for uploading content to Azure Blob storage 10 | 11 | .PARAMETER Win32AppId 12 | The ID of the Win32 app to upload content for 13 | 14 | .PARAMETER ContentRequest 15 | The content request JSONobject containing version information 16 | 17 | .PARAMETER MaxWaitTime 18 | The maximum wait time in seconds until the SAS URI is available. Default is 300 seconds 19 | 20 | .PARAMETER MaxRetries 21 | The maximum number of retries. Default is 10 22 | 23 | .PARAMETER LogId 24 | The component (script name) passed as LogID to the Write-Log function 25 | 26 | #> 27 | function Get-SasUri { 28 | param( 29 | [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'The ID of the Win32 app')] 30 | [string]$Win32AppId, 31 | 32 | [Parameter(Mandatory = $true, Position = 1, HelpMessage = 'The content request object')] 33 | [string]$ContentRequest, 34 | 35 | [Parameter(Mandatory = $false, Position = 2, HelpMessage = 'The maximum wait time in seconds until the SAS URI is available.')] 36 | [int]$MaxWaitTime = 300, 37 | 38 | [Parameter(Mandatory = $false, Position = 3, HelpMessage = 'The maximum number of retries.')] 39 | [int]$MaxRetries = 10, 40 | 41 | [Parameter(Mandatory = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 42 | [string]$LogId = $($MyInvocation.MyCommand).Name 43 | ) 44 | 45 | begin { 46 | Write-LogAndHost -Message "Function: Get-SasUri was called" -LogId $LogId -ForegroundColor Cyan 47 | } 48 | 49 | process { 50 | 51 | try { 52 | 53 | # Create new content request for Win32app 54 | Write-LogAndHost -Message "Creating request for content version" -LogId $LogId -ForegroundColor Cyan 55 | $contentVersionRequest = Invoke-MgGraphRequestCustom -Method POST -Resource ("deviceAppManagement/mobileApps/{0}/microsoft.graph.win32LobApp/contentVersions" -f $Win32AppId) -Body "{}" 56 | 57 | if ($contentVersionRequest.Id) { 58 | Write-LogAndHost -Message ("Content Version to use is '{0}'. " -f $contentVersionRequest.id) -LogId $LogId -ForegroundColor Green 59 | } 60 | 61 | # Build the Uri's 62 | Write-LogAndHost -Message ("Committing content information for '{0}'. JSON Body: {1}" -f $Win32AppId, $contentRequest) -LogId $LogId -ForegroundColor Cyan 63 | $buildContentRequestUri = "deviceAppManagement/mobileApps/{0}/microsoft.graph.Win32LobApp/contentVersions/{1}/files" -f $Win32AppId, $contentVersionRequest.id 64 | 65 | # Create the content request 66 | $contentRequestUri = Invoke-MgGraphRequestCustom -Method POST -Resource $buildContentRequestUri -Body $contentRequest 67 | $probeContentRequestUri = "deviceAppManagement/mobileApps/{0}/microsoft.graph.Win32LobApp/contentVersions/{1}/files/{2}" -f $Win32AppId, $contentVersionRequest.id, $contentRequestUri.id 68 | 69 | # Let's wait for the conetnt landing zone to be ready 70 | $tries = 0 71 | $startTime = Get-Date 72 | do { 73 | try { 74 | 75 | # Get the content ready state 76 | $contentReady = Invoke-MgGraphRequestCustom -Method 'GET' -Resource $probeContentRequestUri 77 | $tries++ 78 | 79 | if ($contentReady.uploadState -eq 'azureStorageUriRequestSuccess') { 80 | Write-LogAndHost -Message ("Probe content request Sas Uri attempt {0}/{1}" -f $tries, $MaxRetries) -LogId $LogId -ForegroundColor Cyan 81 | Write-LogAndHost -Message ("Sas Uri state is '{0}'" -f $contentReady.uploadState) -LogId $LogId -ForegroundColor Green 82 | 83 | break 84 | } else { 85 | Write-LogAndHost -Message ("Probe content request Sas Uri attempt {0}/{1}" -f $tries, $MaxRetries) -LogId $LogId -Severity 2 86 | Write-LogAndHost -Message ("Sas Uri state is '{0}'" -f $contentReady.uploadState) -LogId $LogId -Severity 2 87 | } 88 | } 89 | catch { 90 | Write-LogAndHost -Message ("Error during probe content request: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 91 | } 92 | 93 | Start-Sleep -Seconds 3 94 | } until ($contentReady.uploadState -eq 'azureStorageUriRequestSuccess' -or [int]((Get-Date) - $startTime).TotalSeconds -gt $MaxWaitTime -or $tries -ge $MaxRetries) 95 | 96 | # Output the SAS Uri information to the console and log 97 | $contentReadyOutput = @{} 98 | $contentReadyOutputJson = @() 99 | 100 | foreach ($property in $contentReady.GetEnumerator() | Sort-Object -Property Key) { 101 | 102 | # Skip the manifest property (It's too long to output!) 103 | if ($property.Key -ne 'manifest') { 104 | $contentReadyOutputJson += [PSCustomObject]@{Key = $property.Key; Value = $property.Value } 105 | $contentReadyOutput[$property.Key] = $property.Value 106 | } 107 | } 108 | 109 | # Make sure we have a Sas Uri - sometimes null is returned 110 | if ($contentReadyOutput["azureStorageUri"]) { 111 | Write-Host ("{0}" -f ($contentReadyOutputJson | ConvertTo-Json -Depth 5 -Compress)) -ForegroundColor Green 112 | Write-Log -Message ("{0}" -f ($contentReadyOutput | ConvertTo-Json -Depth 5 -Compress)) -LogId $LogId 113 | 114 | return @{ 115 | contentReady = $contentReady 116 | contentVersion = $contentVersionRequest.id 117 | contentRequestId = $contentRequestUri.id 118 | } 119 | } else { 120 | Write-LogAndHost -Message "azureStorageUri is null" -LogId $LogId -Severity 3 121 | } 122 | } 123 | catch { 124 | Write-LogAndHost -Message ("Failed to get Sas Uri: {0}" -f $_) -LogId $LogId -Severity 3 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /Private/Get-ScriptEnd.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 21/10/2023 4 | Updated on: 29/12/2024 5 | Created by: Ben Whitmore 6 | Filename: Get-ScriptEnd.ps1 7 | 8 | .Description 9 | Function to exit script 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER ErrorMessage 16 | The error message passed to the 'Get-ScriptEnd' function 17 | #> 18 | function Get-ScriptEnd { 19 | [CmdletBinding()] 20 | param ( 21 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 22 | [string]$LogId = $($MyInvocation.MyCommand).Name, 23 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, Position = 0, HelpMessage = "The error message passed to the 'Get-ScriptEnd' function")] 24 | [string]$ErrorMessage 25 | ) 26 | process { 27 | if ($ErrorMessage) { 28 | Write-LogAndHost -Message $ErrorMessage -LogId $LogId -Severity 3 29 | } 30 | } 31 | end { 32 | 33 | if (Test-Path -Path $PSScriptRoot ) { 34 | Set-Location -Path $PSScriptRoot 35 | } 36 | else { 37 | Write-LogAndHost -Message "Failed to set location to $PSScriptRoot" -LogId $LogId -Severity 3 38 | } 39 | 40 | # If connected to Microsoft Graph, disconnect unless the reason the script is ending is due to a failed connection 41 | if ($ErrorMessage -notlike "*Failed to connect to Microsoft Graph*") { 42 | 43 | if (Test-MgConnection) { 44 | $userInput = Read-Host -Prompt "Do you want to disconnect from Microsoft Graph? (y) or [n]" 45 | 46 | if ($userInput -eq '') { 47 | $userInput = 'n' 48 | } 49 | switch ($userInput.ToLower()) { 50 | "n" { 51 | Write-LogAndHost -Message "Leaving Microsoft Graph session open" -LogId $LogId -ForegroundColor Cyan 52 | Get-MgContext 53 | break 54 | } 55 | "y" { 56 | Write-Host "Disconnecting from Microsoft Graph" -ForegroundColor Cyan 57 | Write-Log -Message "Disconnecting from Microsoft Graph" -LogId $LogId 58 | Disconnect-MgGraph | Out-Null 59 | } 60 | default { 61 | Write-Host "Invalid input. Please type 'y' or 'n'." -ForegroundColor Yellow 62 | } 63 | } 64 | } 65 | else { 66 | Write-LogAndHost "No active Microsoft Graph connection found" -LogId $LogId -Severity 2 67 | } 68 | } 69 | 70 | Write-LogAndHost -Message "## The Win32AppMigrationTool Script has Finished ##" -LogId $LogId -ForegroundColor Gray 71 | 72 | Exit 0 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Private/Initialize-Module.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 30/12/2024 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Initialize-Module.ps1 7 | 8 | .Description 9 | Function to install and import required PowerShell modules 10 | 11 | .PARAMETER Modules 12 | Array of module names to install/import 13 | 14 | .PARAMETER PackageProvider 15 | Package provider required for module installation 16 | 17 | .PARAMETER ModuleScope 18 | Scope for module installation (CurrentUser/AllUsers) 19 | 20 | .PARAMETER LogId 21 | Component name for logging 22 | #> 23 | function Initialize-Module { 24 | [CmdletBinding()] 25 | param( 26 | [Parameter(Mandatory = $true, HelpMessage = 'Array of module names to install')] 27 | [array]$Modules, 28 | 29 | [Parameter(Mandatory = $false, HelpMessage = 'Package provider required for module installation')] 30 | [string]$PackageProvider = "NuGet", 31 | 32 | [Parameter(Mandatory = $false, HelpMessage = 'Scope for module installation')] 33 | [ValidateSet('CurrentUser', 'AllUsers')] 34 | [string]$ModuleScope = "CurrentUser", 35 | 36 | [Parameter(Mandatory = $false, HelpMessage = 'Component name for logging')] 37 | [string]$LogId = $($MyInvocation.MyCommand).Name 38 | ) 39 | 40 | begin { 41 | Write-LogAndHost -Message ("Function: Initialize-Module was called for module(s): {0}" -f ($Modules -join ', ')) -LogId $LogId -ForegroundColor Cyan 42 | } 43 | 44 | process { 45 | 46 | try { 47 | 48 | # Check PackageProvider 49 | if (-not (Get-PackageProvider -ListAvailable -Name $PackageProvider)) { 50 | Write-LogAndHost -Message ("PackageProvider not found. Installing '{0}'" -f $PackageProvider) -LogId $LogId -ForegroundColor Cyan 51 | Install-PackageProvider -Name $PackageProvider -ForceBootstrap -Confirm:$false 52 | } 53 | 54 | # Process each module 55 | foreach ($Module in $Modules) { 56 | 57 | if (-not (Get-Module -ListAvailable -Name $Module)) { 58 | Write-LogAndHost -Message ("Installing module '{0}' in scope '{1}'" -f $Module, $ModuleScope) -LogId $LogId -ForegroundColor Cyan 59 | Install-Module -Name $Module -Scope $ModuleScope -AllowClobber -Force -Confirm:$false 60 | } 61 | 62 | if (-not (Get-Module -Name $Module)) { 63 | Write-LogAndHost -Message ("Importing module '{0}'" -f $Module) -LogId $LogId -ForegroundColor Cyan 64 | 65 | try { 66 | 67 | # Import the module 68 | Import-Module $Module 69 | } 70 | catch { 71 | Write-LogAndHost -Message ("Error importing module '{0}': {1}" -f $Module, $_.Exception.Message) -LogId $LogId -Severity 3 72 | 73 | break 74 | } 75 | } 76 | else { 77 | Write-LogAndHost -Message ("Module '{0}' already imported" -f $Module) -LogId $LogId -ForegroundColor Green 78 | } 79 | } 80 | } 81 | catch { 82 | Write-LogAndHost -Message ("Module installation failed: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 83 | 84 | throw 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /Private/Invoke-IntuneContentCommit.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Commits the uploaded Win32 content to Intune 4 | 5 | Created on: 30/12/2024 6 | Updated on: 01/01/2025 7 | Created by: Ben Whitmore 8 | Filename: Invoke-IntuneContentCommit.ps1 9 | 10 | .DESCRIPTION 11 | This function commits the uploaded Win32 content to Intune by creating a commit request and waiting for processing. 12 | 13 | .PARAMETER Win32AppId 14 | The ID of the Win32 app 15 | 16 | .PARAMETER ContentVersion 17 | The ID of the content version 18 | 19 | .PARAMETER ContentRequestId 20 | The ID of the content request 21 | 22 | .PARAMETER EncryptionInfo 23 | The encryption information for the file 24 | 25 | .PARAMETER RetryCount 26 | The number of times to retry the commit request if it fails 27 | 28 | .PARAMETER RetryDelay 29 | The number of seconds to delay between retries 30 | 31 | .PARAMETER LogId 32 | Component name for logging 33 | 34 | .EXAMPLE 35 | Invoke-IntuneContentCommit -Win32AppId $Win32AppId -ContentVersion $ContentVersion -ContentRequestId $ContentRequestId -EncryptionInfo $EncryptionInfo -LogId "CommitWin32Content" 36 | #> 37 | function Invoke-IntuneContentCommit { 38 | param ( 39 | [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'The ID of the Win32 app')] 40 | [string]$Win32AppId, 41 | 42 | [Parameter(Mandatory = $true, Position = 1, HelpMessage = 'The content version ID')] 43 | [string]$ContentVersion, 44 | 45 | [Parameter(Mandatory = $true, Position = 2, HelpMessage = 'The ID of the content request')] 46 | [string]$ContentRequestId, 47 | 48 | [Parameter(Mandatory = $true, Position = 3, HelpMessage = 'The encryption information for the file')] 49 | [string]$EncryptionInfo, 50 | 51 | [Parameter(Mandatory = $false, Position = 4, HelpMessage = 'The number of times to retry the commit request if it fails')] 52 | [int]$RetryCount = 10, 53 | 54 | [Parameter(Mandatory = $false, Position = 5, HelpMessage = 'The number of seconds to delay between retries')] 55 | [int]$RetryDelay = 5, 56 | 57 | [Parameter(Mandatory = $false, HelpMessage = 'Component name for logging')] 58 | [string]$LogId = $($MyInvocation.MyCommand).Name 59 | ) 60 | 61 | process { 62 | 63 | try { 64 | 65 | # Prepare the JSON for the commit request 66 | $EncryptionInfoObject = $EncryptionInfo | ConvertFrom-Json 67 | $json = @{ 68 | "fileEncryptionInfo" = $EncryptionInfoObject 69 | } 70 | $commitJSONEntry = $json | ConvertTo-Json -Compress 71 | 72 | # Resource Uri for the commit request 73 | $commitUri = "deviceAppManagement/mobileApps/{0}/microsoft.graph.win32LobApp/contentVersions/{1}/files/{2}" -f $Win32AppId, $ContentVersion, $ContentRequestId 74 | 75 | # Commit the content 76 | try { 77 | $commitPostResponse = Invoke-MgGraphRequestCustom -Method POST -Resource "$($commitUri)/commit" -Body $commitJSONEntry 78 | Write-LogAndHost -Message ("Commit request sent successfully to {0}:" -f $commitUri) -LogId $LogId -ForegroundColor Green 79 | Write-LogAndHost -Message ("Commit Body: {0}" -f $commitJSONEntry) -LogId $LogId -ForegroundColor Green 80 | } 81 | catch { 82 | Write-LogAndHost -Message ("Failed to commit content to {0}: {1}" -f $commitUri, $_.Exception.Message) -LogId $LogId -Severity 3 83 | 84 | throw 85 | } 86 | 87 | # Check upload state 88 | $success = $false 89 | $attempt = 1 90 | 91 | do { 92 | try { 93 | 94 | # Make the GET request to check upload state 95 | $statusResponse = Invoke-MgGraphRequestCustom -Method GET -Resource $commitUri 96 | 97 | # Check if the upload state is acceptable 98 | if (($statusResponse.uploadState) -eq "commitFileSuccess" ) { 99 | Write-LogAndHost -Message ("Upload state is '{0}'." -f ($statusResponse.uploadState)) -LogId $LogId -ForegroundColor Green 100 | $success = $true 101 | } 102 | elseif (($statusResponse.uploadState) -eq "commitFileFailed" ) { 103 | $contentReadyOutput = [ordered]@{} 104 | 105 | foreach ($property in $statusResponse.GetEnumerator() | Sort-Object -Property Key) { 106 | 107 | # Skip the manifest property (it's too long to output!) 108 | if ($property.Key -ne 'manifest') { 109 | $contentReadyOutput[$property.Key] = $property.Value 110 | } 111 | } 112 | $contentReadyOutputJson = $contentReadyOutput | ConvertTo-Json -Compress 113 | Write-Log -Message ("Upload state is '{0}'. The commit failed. The response was: {1}" -f ($statusResponse.uploadState), $contentReadyOutputJson) -LogId $LogId -Severity 2 114 | $success = $false 115 | 116 | break 117 | } 118 | else { 119 | Write-LogAndHost -Message ("Attempt {0}/{1}. Upload state is '{2}'. Waiting for commit..." -f $attempt, $RetryCount, ($statusResponse.uploadState)) -LogId $LogId -Severity 2 120 | Start-Sleep -Seconds $RetryDelay 121 | } 122 | } 123 | catch { 124 | 125 | # Log error and decide whether to retry 126 | Write-LogAndHost -Message ("Attempt {0}/{1} failed. Error: {2}" -f $attempt, $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 2 127 | Write-LogAndHost -Message ("Attempt {0}/{1} failed. Retrying in {2} seconds." -f $attempt, $RetryCount, $RetryDelay) -LogId $LogId -Severity 2 128 | Start-Sleep -Seconds $RetryDelay 129 | } 130 | 131 | # Increment attempt counter only after each iteration 132 | $attempt++ 133 | 134 | } while (-not $success -and $attempt -le $RetryCount) 135 | 136 | if ($success) { 137 | Write-LogAndHost -Message "Commit operation completed successfully." -LogId $LogId -ForegroundColor Green 138 | 139 | # Now update the app with the committed content version 140 | Write-LogAndHost -Message ("Updating committed content version for app {0} to {1}..." -f $Win32Appid, $ContentVersion) -LogId $LogId -ForegroundColor Cyan 141 | 142 | $updateAppUri = "deviceAppManagement/mobileApps/$($Win32AppId)" 143 | $updateData = @{ 144 | "@odata.type" = "#microsoft.graph.Win32LobApp" 145 | committedContentVersion = $contentVersion 146 | } 147 | 148 | $updateDataJson = $updateData | ConvertTo-Json -Compress 149 | Write-LogAndHost -Message ("Committed content version data: {0}" -f $updateDataJson) -LogId $LogId -ForegroundColor Green 150 | 151 | try { 152 | $updateResponse = Invoke-MgGraphRequestCustom -Method PATCH -Resource $updateAppUri -Body $updateDataJson 153 | Write-LogAndHost -Message ("Successfully updated the content version in Intune for '{0}'" -f $Win32AppId) -LogId $LogId -ForegroundColor Green 154 | 155 | return $true 156 | } 157 | catch { 158 | Write-LogAndHost -Message ("Failed to update app {0} with committed content version: {1}" -f $Win32AppId, $_.Exception.Message) -LogId $LogId -Severity 3 159 | 160 | return $false 161 | } 162 | 163 | } 164 | } 165 | catch { 166 | Write-LogAndHost -Message ("Commit operation failed: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 167 | 168 | return $false 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Private/Invoke-MgGraphRequestCustom.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 03/04/2024 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Invoke-MgGraphRequest.ps1 7 | 8 | .Description 9 | Function to invoke a request to the Microsoft Graph API using the Microsoft.Graph.Authentication module from the Microsoft Graph SDK 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER GraphUrl 16 | Graph Url to use 17 | 18 | .PARAMETER Method 19 | The HTTP method to use for the request 20 | 21 | .PARAMETER Endpoint 22 | The endpoint to use for the request 23 | 24 | .PARAMETER Resource 25 | The resource to use for the request 26 | 27 | .PARAMETER Body 28 | The body of the request 29 | 30 | .PARAMETER OutputBody 31 | If True, output body of the request to console and log 32 | 33 | .PARAMETER ContentType 34 | The content type for the PATCH or POST request 35 | #> 36 | function Invoke-MgGraphRequestCustom { 37 | param ( 38 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 39 | [string]$LogId = $($MyInvocation.MyCommand).Name, 40 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'Graph Url to use')] 41 | [string]$GraphUrl = 'https://graph.microsoft.com', 42 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, HelpMessage = 'The HTTP method to use for the request')] 43 | [ValidateSet("GET", "POST", "PATCH", "DELETE")] 44 | [string]$Method, 45 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'The endpoint to use for the request')] 46 | [string]$Endpoint = 'beta', 47 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, HelpMessage = 'The resource to use for the request')] 48 | [string]$Resource, 49 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'The body of the request')] 50 | [string]$Body, 51 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'If True, output body of the request to console and log. Useful for debugging')] 52 | [bool]$OutputBody = $false, 53 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'The content type for the PATCH or POST request')] 54 | [string]$ContentType = "application/json" 55 | ) 56 | 57 | 58 | try { 59 | 60 | # First check if we already have a valid connection with required scopes 61 | if (Test-MgConnection) { 62 | 63 | # Build the Uri 64 | $graphUri = "$($GraphUrl)/$($Endpoint)/$($Resource)" 65 | if ($Body -and $OutputBody) { 66 | Write-LogAndHost -Message ("Building Uri for Graph request. Method: '{0}', Uri: '{1}', Body: '{2}'" -f $Method, $GraphUri, $Body) -LogId $LogId -ForegroundColor Cyan 67 | } 68 | else { 69 | Write-LogAndHost -Message ("Building Uri for Graph request. Method: '{0}', Uri: '{1}'" -f $Method, $GraphUri) -LogId $LogId -ForegroundColor Cyan 70 | } 71 | 72 | # Call Graph API and get JSON response 73 | switch ($Method) { 74 | "GET" { 75 | $graphResult = Invoke-MgGraphRequest -Uri $GraphUri -Method $Method -ErrorAction Stop 76 | } 77 | "POST" { 78 | $graphResult = Invoke-MgGraphRequest -Uri $GraphUri -Method $Method -Body $Body -ContentType $ContentType -ErrorAction Stop 79 | } 80 | "PATCH" { 81 | $graphResult = Invoke-MgGraphRequest -Uri $GraphUri -Method $Method -Body $Body -ContentType $ContentType -ErrorAction Stop 82 | } 83 | "DELETE" { 84 | $graphResult = Invoke-MgGraphRequest -Uri $GraphUri -Method $Method -ErrorAction Stop 85 | } 86 | } 87 | return $graphResult 88 | } 89 | else { 90 | Get-ScriptEnd -ErrorMessage "No valid Microsoft Graph connection found. Please call Connect-MgGraphCustom first or pass at least the TenantId and ClientId parameters to New-Win32app" -LogId $LogId 91 | } 92 | } 93 | catch { 94 | Write-LogAndHost -Message $_ -LogId $LogId -Severity 3 95 | 96 | throw 97 | } 98 | } -------------------------------------------------------------------------------- /Private/Invoke-StorageUpload.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Uploads content to Azure Blob Storage using Az.Storage module 4 | 5 | Created on: 28/12/2024 6 | Updated on: 01/01/2025 7 | Created by: Ben Whitmore 8 | Filename: Invoke-StorageUpload.ps1 9 | 10 | .Description 11 | Uses Az.Storage module to upload content to Azure Blob Storage with retry logic and progress tracking 12 | 13 | .PARAMETER Uri 14 | The Azure Storage SAS URI for upload 15 | 16 | .PARAMETER FilePath 17 | Path to the file to upload 18 | 19 | .PARAMETER FileSize 20 | The size of the encrypted file 21 | 22 | .PARAMETER ContentVersion 23 | The content version ID 24 | 25 | .PARAMETER ContentRequestId 26 | 27 | .PARAMETER ContentRequest 28 | The content request object 29 | 30 | .PARAMETER Win32AppId 31 | The ID of the Win32 app 32 | 33 | .PARAMETER RetryCount 34 | Number of retry attempts (default: 3) 35 | 36 | .PARAMETER RetryDelay 37 | Seconds between retries (default: 5) 38 | 39 | .PARAMETER LogId 40 | Component name for logging 41 | #> 42 | function Invoke-StorageUpload { 43 | [CmdletBinding()] 44 | param( 45 | [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'The Azure Storage SAS URI for upload')] 46 | [string]$Uri, 47 | 48 | [Parameter(Mandatory = $true, Position = 1, HelpMessage = 'Path to the file to upload')] 49 | [string]$FilePath, 50 | 51 | [Parameter(Mandatory = $true, Position = 2, HelpMessage = 'The size of the encrypted file')] 52 | [string]$FileSize, 53 | 54 | [Parameter(Mandatory = $true, Position = 3, HelpMessage = 'The content version ID')] 55 | [string]$ContentVersion, 56 | 57 | [Parameter(Mandatory = $true, Position = 4, HelpMessage = 'The ID of the content request')] 58 | [string]$ContentRequestId, 59 | 60 | [Parameter(Mandatory = $true, Position = 5, HelpMessage = 'The ID of the content request')] 61 | [object]$ContentRequest, 62 | 63 | [Parameter(Mandatory = $true, Position = 6, HelpMessage = 'The ID of the Win32 app')] 64 | [string]$Win32AppId, 65 | 66 | [Parameter(Mandatory = $false, Position = 7, HelpMessage = 'Number of retry attempts (default: 10)')] 67 | [int]$RetryCount = 10, 68 | 69 | [Parameter(Mandatory = $false, Position = 8, HelpMessage = 'Seconds between retries (default: 5)')] 70 | [int]$RetryDelay = 5, 71 | 72 | [Parameter(Mandatory = $false, HelpMessage = 'Component name for logging')] 73 | [string]$LogId = $($MyInvocation.MyCommand).Name 74 | ) 75 | 76 | begin { 77 | 78 | Write-LogAndHost -Message "Function: Invoke-StorageUpload was called" -LogId $LogId -ForegroundColor Cyan 79 | 80 | # Check for required module 81 | Initialize-Module -Modules @('Az.Storage') 82 | 83 | try { 84 | 85 | $sasUri = [System.Uri]::new($Uri) 86 | 87 | # Get container (second segment) 88 | $container = $sasUri.AbsolutePath.Split('/')[1] 89 | 90 | # Get full blob path (all segments after container) 91 | $blobPath = $sasUri.AbsolutePath.Substring($container.Length + 2) 92 | 93 | # Get SAS token 94 | $sasToken = $sasUri.Query.TrimStart('?') 95 | 96 | # LLog the results 97 | $uploadInfo = [PSCustomObject]@{ 98 | Container = $container 99 | BlobPath = $blobPath 100 | SasToken = $sasToken 101 | } 102 | 103 | Write-Log -Message ($uploadInfo | ConvertTo-Json -Compress) -LogId $LogId 104 | Write-Host ($uploadInfo | ConvertTo-Json -Compress) -ForegroundColor Green 105 | } 106 | catch { 107 | Write-LogAndHost -Message ("Error parsing Sas Uri: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 108 | 109 | throw 110 | } 111 | } 112 | 113 | process { 114 | 115 | try { 116 | 117 | # Upload content file information to Win32 app 118 | Write-LogAndHost -Message ("Adding Content information to '{0}'" -f $Win32AppId) -LogId $LogId -ForegroundColor Cyan 119 | Write-LogAndHost -Message ("Starting upload of '{0}' to Azure Storage using Set-AzStorageBlobContent" -f $FilePath) -LogId $LogId -ForegroundColor Cyan 120 | 121 | $attempt = 1 122 | $success = $false 123 | 124 | # Initialize block size (e.g., 4 MB) 125 | $blockSize = 4 * 1024 * 1024 126 | 127 | $fileSize = (Get-Item $FilePath).Length 128 | $blocks = [Math]::Ceiling($fileSize / $blockSize) 129 | 130 | do { 131 | try { 132 | # Create a context for the storage account 133 | $context = New-AzStorageContext -SasToken $sasToken -StorageAccountName $sasUri.Host.Split('.')[0] 134 | $blobClient = [Microsoft.Azure.Storage.Blob.CloudBlockBlob]::new($sasUri) 135 | 136 | # Initialize upload parameters 137 | $fileStream = [System.IO.File]::OpenRead($FilePath) 138 | $buffer = New-Object Byte[] $blockSize 139 | $blockIds = New-Object 'System.Collections.Generic.List[System.String]' 140 | 141 | Write-LogAndHost -Message ("Uploading file in chunks of {0} bytes" -f $blockSize) -LogId $LogId -ForegroundColor Cyan 142 | 143 | try { 144 | $i = 0 145 | while (($bytesRead = $fileStream.Read($buffer, 0, $buffer.Length)) -gt 0) { 146 | $blockId = [Guid]::NewGuid().ToString() 147 | $encodedBlockId = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($blockId)) 148 | $blockIds.Add($encodedBlockId) 149 | 150 | # Create a memory stream for the current chunk 151 | $memoryStream = New-Object System.IO.MemoryStream 152 | $memoryStream.Write($buffer, 0, $bytesRead) 153 | $memoryStream.Position = 0 154 | 155 | # Upload the chunk 156 | $blobClient.PutBlock($encodedBlockId, $memoryStream, $null) 157 | 158 | # Dispose of the memory stream 159 | $memoryStream.Dispose() 160 | $i++ 161 | Write-LogAndHost -Message ("Uploading block {0} of {1}" -f $i, $blocks) -LogId $LogId -ForegroundColor Cyan 162 | } 163 | 164 | # Commit the blocks 165 | $blobClient.PutBlockList($blockIds) 166 | Write-LogAndHost -Message "Upload reported as completed successfully" -LogId $LogId -ForegroundColor Green 167 | $success = $true 168 | } 169 | finally { 170 | $fileStream.Dispose() 171 | } 172 | } 173 | catch { 174 | if ($attempt -ge $RetryCount) { 175 | Write-LogAndHost -Message ("Upload failed after {0} attempts: {1}" -f $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 3 176 | 177 | throw 178 | } 179 | 180 | Write-LogAndHost -Message ("Attempt {0}/{1} failed. Retrying... Error: {2}" -f $attempt, $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 2 181 | Start-Sleep -Seconds $RetryDelay 182 | } 183 | $attempt++ 184 | } while (-not $success -and $attempt -le $RetryCount) 185 | 186 | 187 | 188 | # Verify upload success 189 | if ($PSVersionTable.PSVersion.Major -ge 7) { 190 | 191 | # Verify upload success 192 | Write-LogAndHost -Message "We have a PowerShell 7+ session! We can attempt to verify the upload success..." -LogId $LogId -ForegroundColor Cyan 193 | 194 | # Delay to allow Azure to update blob properties 195 | Start-Sleep -Seconds $RetryDelay 196 | $blob = Get-AzStorageBlob -Context $context -Container $container -Blob $blobPath 197 | 198 | if ($blob) { 199 | $blobInfo = [PSCustomObject]@{ 200 | Name = $blob.Name 201 | BlobType = $blob.BlobType 202 | Length = $blob.Length 203 | Uri = $blob.ICloudBlob.Uri.AbsoluteUri 204 | LastModified = $blob.ICloudBlob.Properties.LastModified 205 | ContentType = $blob.ICloudBlob.Properties.ContentType 206 | } 207 | $json = $blobInfo | ConvertTo-Json -Compress 208 | Write-LogAndHost -Message ("Blob info: {0}" -f $json) -LogId $LogId -ForegroundColor Green 209 | } 210 | else { 211 | Write-LogAndHost -Message "Blob not found in the specified container and path." -LogId $LogId -Severity 3 212 | 213 | throw 214 | } 215 | 216 | $attempt = 1 217 | $success = $false 218 | 219 | do { 220 | try { 221 | 222 | # Get blob size and compare 223 | if ($blob) { 224 | $blob.ICloudBlob.FetchAttributes() 225 | $FileSize = (Get-Item $FilePath).Length 226 | $blobSize = $blob.ICloudBlob.Properties.Length 227 | 228 | Write-LogAndHost -Message ("Comparing file size: Local file size is {0} bytes, Blob file size is {1} bytes" -f $FileSize, $blobSize) -LogId $LogId -ForegroundColor Cyan 229 | 230 | if ($FileSize -eq $blobSize) { 231 | Write-LogAndHost -Message "Upload verification successful: File sizes match" -LogId $LogId -ForegroundColor Green 232 | $success = $true 233 | } 234 | else { 235 | 236 | # Upload verification failed, sizes do not match 237 | Write-LogAndHost -Message "Upload verification failed: File sizes do not match" -LogId $LogId -Severity 3 238 | 239 | break 240 | } 241 | } 242 | else { 243 | 244 | # Blob not found 245 | Write-LogAndHost -Message "Blob not found in the specified container and path." -LogId $LogId -Severity 3 246 | 247 | break 248 | } 249 | } 250 | catch { 251 | if ($attempt -ge $RetryCount) { 252 | Write-LogAndHost -Message ("Upload verification failed after {0} attempts: {1}" -f $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 3 253 | 254 | throw 255 | } 256 | 257 | Write-LogAndHost -Message ("Attempt {0}/{1} failed. Retrying..." -f $attempt, $RetryCount) -LogId $LogId -Severity 2 258 | Start-Sleep -Seconds $RetryDelay 259 | $attempt++ 260 | } 261 | } while (-not $success -and $attempt -le $RetryCount) 262 | } 263 | else { 264 | Write-LogAndHost -Message "Skipping upload verification because PowerShell version is less than 7" -LogId $LogId -ForegroundColor Cyan 265 | 266 | # Assume success because we can't verify withour PowerShell 7+ for the Get-AzStorageBlob cmdlet 267 | $success = $true 268 | } 269 | } 270 | catch { 271 | Write-LogAndHost -Message ("Upload failed: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 272 | 273 | throw 274 | } 275 | 276 | if ($success -eq $true) { 277 | 278 | # Verify upload state 279 | Write-LogAndHost -Message "Verifying upload state..." -LogId $LogId -ForegroundColor Cyan 280 | 281 | $attempt = 1 282 | $success = $false 283 | 284 | do { 285 | try { 286 | 287 | # Construct the status URI 288 | $statusUri = "deviceAppManagement/mobileApps/{0}/microsoft.graph.win32LobApp/contentVersions/{1}/files/{2}" -f $Win32AppId, $ContentVersion, $ContentRequestId 289 | 290 | # Make the GET request to check upload state 291 | $statusResponse = Invoke-MgGraphRequestCustom -Method GET -Resource $statusUri 292 | 293 | # Check if the upload state is acceptable 294 | if (($statusResponse.uploadState) -eq "azureStorageUriRequestSuccess" ) { 295 | Write-LogAndHost -Message ("Upload state is '{0}'." -f ($statusResponse.uploadState)) -LogId $LogId -ForegroundColor Green 296 | $success = $true 297 | } 298 | else { 299 | Write-LogAndHost -Message ("Upload state is '{0}'. Waiting..." -f ($statusResponse.uploadState)) -LogId $LogId -Severity 2 300 | Start-Sleep -Seconds $RetryDelay 301 | } 302 | } 303 | catch { 304 | 305 | # Log error and decide whether to retry 306 | Write-LogAndHost -Message ("Attempt {0}/{1} failed. Error: {2}" -f $attempt, $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 3 307 | Write-LogAndHost -Message ("Attempt {0}/{1} failed. Retrying in {2} seconds." -f $attempt, $RetryCount, $RetryDelay) -LogId $LogId -Severity 2 308 | Start-Sleep -Seconds $RetryDelay 309 | } 310 | 311 | # Increment attempt counter only after each iteration 312 | $attempt++ 313 | 314 | } while (-not $success -and $attempt -le $RetryCount) 315 | 316 | if ($success -eq $true) { 317 | Write-LogAndHost -Message "Upload completed successfully" -LogId $LogId -ForegroundColor Green 318 | 319 | return $true 320 | } 321 | else { 322 | Write-LogAndHost -Message "Upload verification failed" -LogId $LogId -Severity 3 323 | 324 | return $false 325 | } 326 | } 327 | } 328 | } -------------------------------------------------------------------------------- /Private/New-FolderToCreate.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 27/10/2023 4 | Updated on: 04/01/2025 5 | Created by: Ben Whitmore 6 | Filename: New-FolderToCreate.ps1 7 | 8 | .Description 9 | Function to create a folder 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the pipeline 14 | 15 | .PARAMETER Root 16 | The root folder to create the folder(s) in 17 | 18 | .PARAMETER Folders 19 | The folder(s) to create 20 | #> 21 | function New-FolderToCreate { 22 | param( 23 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 24 | [string]$LogId = $($MyInvocation.MyCommand).Name, 25 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, HelpMessage = 'The root folder to create the folder(s) in')] 26 | [String]$Root, 27 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1, HelpMessage = 'The folder(s) to create')] 28 | [String[]]$FolderNames 29 | ) 30 | begin { 31 | 32 | Write-Host 'Function: New-FolderToCreate was called' -ForegroundColor Cyan 33 | } 34 | process { 35 | foreach ($folder in $FolderNames) { 36 | 37 | # Create Folders 38 | $folderToCreate = Join-Path -Path $Root -ChildPath $folder 39 | 40 | if (-not (Test-Path -Path $folderToCreate)) { 41 | Write-Host ("Creating Folder '{0}'..." -f $folderToCreate) -ForegroundColor Cyan 42 | 43 | try { 44 | 45 | # Create the folder 46 | New-Item -Path $folderToCreate -ItemType Directory -Force -ErrorAction Stop | Out-Null 47 | Write-Host ("Folder '{0}' was created succesfully" -f $folderToCreate) -ForegroundColor Green 48 | } 49 | catch { 50 | Write-Host ("Couldn't create '{0}' folder" -f $folderToCreate) -Severity 3 51 | Get-ScriptEnd -LogId $LogId -Message $_.Exception.Message 52 | } 53 | } 54 | else { 55 | Write-Host ("Folder '{0}' already exists. Skipping folder creation" -f $folderToCreate) -ForegroundColor Yellow 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /Private/New-IntuneDetection.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 17/03/2024 4 | Update on: 08/01/2025 5 | Created by: Ben Whitmore 6 | Filename: New-IntuneDetection.ps1 7 | 8 | .Description 9 | Function to get the local detection methods from the detection methods xml object 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the 14 | 15 | .PARAMETER LocalSettings 16 | The local detection method settings array to convert to JSON 17 | 18 | .PARAMETER Script 19 | The local detection method script to prepare 20 | #> 21 | 22 | function New-IntuneDetectionMethod { 23 | param ( 24 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = "The component (script name) passed as LogID to the 'Write-Log' function")] 25 | [string]$LogId = $($MyInvocation.MyCommand).Name, 26 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The local detection method settings array to convert to JSON', ParameterSetName = 'Methods')] 27 | [object]$LocalSettings, 28 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The local detection method script to prepare', ParameterSetName = 'Methods')] 29 | [object]$Script 30 | ) 31 | begin { 32 | 33 | # Helper functions to create the JSON objects 34 | # Function to convert empty strings to $null in JSON 35 | function Convert-EmptyStringToNullInJson { 36 | param ( 37 | [Parameter(Mandatory = $true)] 38 | [string]$Json 39 | ) 40 | 41 | # Convert the JSON to objects 42 | $objects = $Json | ConvertFrom-Json 43 | 44 | function Update-Object { 45 | param ( 46 | $Object 47 | ) 48 | 49 | # Check key values for empty strings and convert to $null 50 | foreach ($property in $Object.PSObject.Properties) { 51 | if ($property.Value -is [string] -and $property.Value -eq '') { 52 | $property.Value = $null 53 | } 54 | elseif ($property.Value -is [PSCustomObject] -or $property.Value -is [array]) { 55 | Update-Object -Object $property.Value 56 | } 57 | } 58 | } 59 | 60 | # Process each object 61 | foreach ($obj in $objects) { 62 | Update-Object -Object $obj 63 | } 64 | 65 | # Return the new JSON 66 | $newJson = $objects | ConvertTo-Json -Depth 5 67 | return $newJson 68 | } 69 | 70 | # Registry detection method 71 | function Add-SimpleSetting { 72 | param( 73 | [string]$is64Bit, 74 | [string]$keyPath, 75 | [string]$valueName, 76 | [string]$operator, 77 | [string]$version, 78 | [string]$detectionType, 79 | [string]$detectionValue 80 | ) 81 | 82 | # Prepare 64bit check 83 | if ($is64Bit -eq 'true') { 84 | $check32BitOn64System = [bool]$false 85 | } 86 | else { 87 | $check32BitOn64System = [bool]$true 88 | } 89 | 90 | # Set the detection type 91 | $detectionType = $detectionType.ToLower() 92 | 93 | # Prepare operands for the Intune detection method 94 | switch ($operator) { 95 | 'Equals' { 96 | $operator = 'equal' 97 | } 98 | 'NotEquals' { 99 | $operator = 'notEqual' 100 | } 101 | 'GreaterThan' { 102 | $operator = 'greaterThan' 103 | } 104 | 'GreaterEquals' { 105 | $operator = 'greaterThanOrEqual' 106 | } 107 | 'LessThan' { 108 | $operator = 'lessThan' 109 | } 110 | 'LessEquals' { 111 | $operator = 'lessThanOrEqual' 112 | } 113 | 'Match' { 114 | $operator = 'match' 115 | } 116 | 'NotMatch' { 117 | $operator = 'notMatch' 118 | } 119 | 'Contains' { 120 | $operator = 'contains' 121 | } 122 | 'NotContains' { 123 | $operator = 'notContains' 124 | } 125 | 'BeginsWith' { 126 | $operator = 'beginsWith' 127 | } 128 | 'EndsWith' { 129 | $operator = 'endsWith' 130 | } 131 | } 132 | 133 | # Prepare detection types for the Intune detection method 134 | switch ($detectionType) { 135 | 'Int64' { 136 | $detectionType = 'integer' 137 | } 138 | } 139 | 140 | $object = [PSCustomObject]@{ 141 | '@odata.type' = '#microsoft.graph.win32LobAppRegistryDetection' 142 | 'check32BitOn64System' = $check32BitOn64System 143 | 'detectionType' = $detectionType 144 | 'detectionValue' = $detectionValue 145 | 'keyPath' = $keyPath 146 | 'operator' = $operator 147 | 'valueName' = $valueName 148 | } 149 | return $object 150 | } 151 | 152 | # File or Folder detection method 153 | function Add-File { 154 | param( 155 | [string]$is64Bit, 156 | [string]$detectionType, 157 | [string]$detectionValue, 158 | [string]$fileOrFolderName, 159 | [string]$operator, 160 | [string]$path 161 | ) 162 | 163 | # Prepare 64bit check 164 | if ($is64Bit -eq 'true') { 165 | $check32BitOn64System = [bool]$false 166 | } 167 | else { 168 | $check32BitOn64System = [bool]$true 169 | } 170 | 171 | # Set the detection type 172 | $detectionType = $detectionType.ToLower() 173 | 174 | # Prepare operands for the Intune detection method 175 | switch ($operator) { 176 | 'Equals' { 177 | $operator = 'equal' 178 | } 179 | 'NotEquals' { 180 | $operator = 'notEqual' 181 | } 182 | 'GreaterThan' { 183 | $operator = 'greaterThan' 184 | } 185 | 'GreaterEquals' { 186 | $operator = 'greaterThanOrEqual' 187 | } 188 | 'LessThan' { 189 | $operator = 'lessThan' 190 | } 191 | 'LessEquals' { 192 | $operator = 'lessThanOrEqual' 193 | } 194 | 'Match' { 195 | $operator = 'match' 196 | } 197 | 'NotMatch' { 198 | $operator = 'notMatch' 199 | } 200 | 'Contains' { 201 | $operator = 'contains' 202 | } 203 | 'NotContains' { 204 | $operator = 'notContains' 205 | } 206 | 'BeginsWith' { 207 | $operator = 'beginsWith' 208 | } 209 | 'EndsWith' { 210 | $operator = 'endsWith' 211 | } 212 | } 213 | 214 | # Check detection types 215 | if ($operator -eq 'notEquals' -and $detectionValue -eq 0 -and $detectionType -eq 'int64') { 216 | $operator = 'notConfigured' 217 | $detectionType = 'exists' 218 | $detectionValue = $null 219 | } 220 | 221 | $object = [PSCustomObject]@{ 222 | '@odata.type' = '#microsoft.graph.win32LobAppFileSystemDetection' 223 | 'check32BitOn64System' = $check32BitOn64System 224 | 'detectionType' = $detectionType 225 | 'detectionValue' = $detectionValue 226 | 'fileOrFolderName' = $fileOrFolderName 227 | 'operator' = $operator 228 | 'path' = $path 229 | 230 | } 231 | return $object 232 | } 233 | 234 | # MSI detection method 235 | function Add-MSI { 236 | param( 237 | [string]$productCode, 238 | [string]$productVersion, 239 | [string]$productVersionOperator 240 | ) 241 | 242 | # Prepare operands for the Intune detection method 243 | switch ($productVersionOperator) { 244 | 'Equals' { 245 | $productVersionOperator = 'equal' 246 | } 247 | 'NotEquals' { 248 | $productVersionOperator = 'notEqual' 249 | } 250 | 'GreaterThan' { 251 | $productVersionOperator = 'greaterThan' 252 | } 253 | 'GreaterEquals' { 254 | $productVersionOperator = 'greaterThanOrEqual' 255 | } 256 | 'LessThan' { 257 | $productVersionOperator = 'lessThan' 258 | } 259 | 'LessEquals' { 260 | $productVersionOperator = 'lessThanOrEqual' 261 | } 262 | } 263 | 264 | # Check if the product version is not configured 265 | if ( -not $productVersion -and -not $productVersionOperator) { 266 | $productVersion = $null 267 | $productVersionOperator = 'notConfigured' 268 | } 269 | 270 | $object = [PSCustomObject]@{ 271 | '@odata.type' = '#microsoft.graph.win32LobAppProductCodeDetection' 272 | 'productCode' = $productCode 273 | 'productVersion' = $productVersion 274 | 'productVersionOperator' = $productVersionOperator 275 | } 276 | return $object 277 | } 278 | } 279 | process { 280 | if ($PSCmdlet.ParameterSetName -eq 'Methods') { 281 | if ($PSBoundParameters.Keys.Count -gt 1) { 282 | Write-LogAndHost -Message 'Only one parameter is allowed in parameter set "Methods". Choose either "LocalSettings" or "Script"' -LogId $LogId -Severity 3 283 | return 284 | } 285 | else { 286 | if ($PSBoundParameters['LocalSettings']) { 287 | $jsonArray = @() 288 | 289 | foreach ($setting in $LocalSettings) { 290 | switch ($setting.Type) { 291 | 'SimpleSetting' { 292 | 293 | # Create the registry key path 294 | $regPath = Join-Path -Path $setting.Hive -ChildPath $setting.Key -ErrorAction SilentlyContinue 295 | 296 | # Add the registry detection object 297 | $jsonArray += Add-SimpleSetting ` 298 | -is64Bit $setting.is64Bit ` 299 | -keyPath $regPath ` 300 | -valueName $setting.ValueName ` 301 | -operator $setting.Rules_Operator ` 302 | -detectionType $setting.Rules_ConstantDataType ` 303 | -detectionValue $setting.Rules_ConstantValue 304 | } 305 | 'File' { 306 | $jsonArray += Add-File ` 307 | -is64Bit $setting.is64Bit ` 308 | -detectionType $setting.Rules_ConstantDataType ` 309 | -detectionValue $setting.Rules_ConstantValue ` 310 | -fileOrFolderName $setting.Filter ` 311 | -operator $setting.Rules_Operator ` 312 | -path $setting.Path 313 | } 314 | 'MSI' { 315 | $jsonArray += Add-MSI ` 316 | -productCode $setting.ProductCode ` 317 | -ProductVersion $setting.Rules_ConstantValue ` 318 | -ProductVersionOperator $setting.Rules_Operator 319 | } 320 | default { 321 | Write-LogAndHost -Message "Unhandled detection type: $($setting.Type)" -LogId $LogId -Severity 3 322 | } 323 | } 324 | } 325 | 326 | # Convert to JSON 327 | $json = $jsonArray | ConvertTo-Json -Depth 5 328 | Convert-EmptyStringToNullInJson -Json $json 329 | } 330 | else { 331 | Write-LogAndHost -Message 'No settings were passed to the function' -LogId $LogId -Severity 3 332 | return 333 | } 334 | } 335 | } 336 | } 337 | } -------------------------------------------------------------------------------- /Private/New-IntuneFramework.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 24/03/2024 4 | Updated on: 08/01/2025 5 | Created by: Ben Whitmore 6 | Filename: New-Win32appFramework.ps1 7 | 8 | .Description 9 | Function to create a Win32 app JSON framework 10 | Parameter descriptions reference https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add 11 | 12 | .PARAMETER LogId 13 | The component (script name) passed as LogID to the 'Write-Log' function. 14 | This parameter is built from the line number of the call from the function up the pipeline 15 | 16 | .PARAMETER Name 17 | Enter the name of the app as it appears in the company portal. Make sure all app names 18 | that you use are unique. If the same app name exists twice, only one of the apps appears 19 | in the company portal 20 | 21 | .PARAMETER Description 22 | Enter the description of the app. The description appears in the company portal 23 | 24 | .PARAMETER Publisher 25 | Enter the name of the publisher of the app 26 | 27 | .PARAMETER AppVersion 28 | The version of the app 29 | 30 | .PARAMETER InformationURL 31 | The URL of a website that contains information about this app. The URL 32 | appears in the company portal 33 | 34 | .PARAMETER PrivacyURL 35 | The URL of a website that contains privacy information for this app. 36 | 37 | .PARAMETER Notes 38 | Enter any notes that you want to associate with this app 39 | 40 | .PARAMETER LargeIcon 41 | The base64 value of the icon of the app 42 | 43 | .PARAMETER Path 44 | Path to the Win32apps folder 45 | 46 | .PARAMETER FileName 47 | The name of the Win32app filename value 48 | 49 | .PARAMETER SetupFile 50 | The name of the setup file 51 | 52 | .PARAMETER InstallCommandLine 53 | The install command line for the app 54 | 55 | .PARAMETER UninstallCommandLine 56 | The uninstall command line for the app 57 | 58 | .PARAMETER UninstallCommandLineFallback 59 | The uninstall command line for the app if one is not provided. We set this to the InstallCommandLine by default. You could use "cmd /c" (which does nothing) 60 | 61 | .PARAMETER DetectionMethodJson 62 | The JSON body for the detection method of the app 63 | 64 | .PARAMETER DetectionScript 65 | The base64 value of the detection method script for the app 66 | 67 | .PARAMETER DetectionScriptType 68 | The detection method type for the app 69 | 70 | .PARAMETER DetectionMethodMSI 71 | The MSI product information for detection 72 | 73 | .PARAMETER MinimumOSArchitecture 74 | The minimum operating system architecture required for the app 75 | 76 | .PARAMETER MinimumOSVersion 77 | The minimum operating system version required for the app 78 | 79 | .PARAMETER InstallExperience 80 | System or User 81 | 82 | .PARAMETER RestartBehavior 83 | The default restart behavior for the app 84 | 85 | .PARAMETER MaxExecutionTime 86 | The maximum time (in minutes) that the app is expected to take to execute 87 | 88 | .PARAMETER AllowAvailableUninstall 89 | When creating the Win32App, allow the user to uninstall the app if it is available in the Company Portal 90 | 91 | .PARAMETER ReturnCodeMap 92 | Hash table of default return codes 93 | 94 | #> 95 | function New-IntuneWinFramework { 96 | param( 97 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 98 | [string]$LogId = $($MyInvocation.MyCommand).Name, 99 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = "The unique app name as it appears in the company portal")] 100 | [string]$Name, 101 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = "The app description for the company portal")] 102 | [string]$Description, 103 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 2, HelpMessage = "The publisher name of the app")] 104 | [string]$Publisher, 105 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 3, HelpMessage = "The app version of the app")] 106 | [string]$AppVersion, 107 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 4, HelpMessage = "The information URL of the app")] 108 | [string]$InformationURL, 109 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 5, HelpMessage = "The privacy URL of the app")] 110 | [string]$PrivacyURL, 111 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 6, HelpMessage = "The notes associated with the app")] 112 | [string]$Notes, 113 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 7, HelpMessage = "The base64 value of the icon of the app")] 114 | [string]$LargeIcon, 115 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 8, HelpMessage = "Path to the Win32apps folder")] 116 | [string]$Path, 117 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 9, HelpMessage = "The win32app filename value")] 118 | [string]$FileName, 119 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 10, HelpMessage = "The name of the setupfile")] 120 | [string]$SetupFile, 121 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 11, HelpMessage = "The install command line for the app")] 122 | [string]$InstallCommandLine, 123 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 12, HelpMessage = "The uninstall command line for the app")] 124 | [string]$UninstallCommandLine, 125 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 13, HelpMessage = "The uninstall command line for the app")] 126 | [string]$UninstallCommandLineFallback = $InstallCommandLine, 127 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 14, HelpMessage = "The JSON body for the detection method of the app")] 128 | [string]$DetectionMethodJson, 129 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 15, HelpMessage = "The base64 value of the detection method script for the app")] 130 | [string]$DetectionScript, 131 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 16, HelpMessage = "The MSI product information for detection")] 132 | [object]$DetectionMethodMSI, 133 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 17, HelpMessage = "The minimum operating system architecture required for the app")] 134 | [string]$MinimumOSArchitecture = 'x64,x86', 135 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 18, HelpMessage = "The minimum operating system version required for the app")] 136 | [string]$MinimumOSVersion = "Windows10_21H2", 137 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 19, HelpMessage = "System or User")] 138 | [ValidateSet('System', 'User')] 139 | [string]$InstallExperience, 140 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 20, HelpMessage = "The mdefault restart behavior for the app")] 141 | [ValidateSet("allow", "basedOnReturnCode", "suppress", "force")] 142 | [string]$RestartBehavior = "basedOnReturnCode", 143 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 21, HelpMessage = "The maximum time (in minutes) that the app is expected to take to execute")] 144 | [int]$MaxExecutionTime = 60, 145 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, Position = 22, HelpMessage = "When creating the Win32App, allow the user to uninstall the app if it is available in the Company Portal")] 146 | [bool]$AllowAvailableUninstall, 147 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, Position = 23, HelpMessage = "Json string of default return codes")] 148 | [string]$ReturnCodes = '{"0": "success","1707": "success","3010": "softReboot","1641": "hardReboot","1618": "retry"}' 149 | ) 150 | 151 | begin { 152 | 153 | Write-LogAndHost -Message "Function: New-Win32appFramework was called" -LogId $LogId -ForegroundColor Cyan 154 | Write-LogAndHost -Message ("Processing JSON body for '{0}'" -f $Name) -LogId $LogId -ForegroundColor Cyan 155 | 156 | # Convert the JSON to an array of objects 157 | [object]$parsedJson = $ReturnCodes | ConvertFrom-Json 158 | [object]$returnCodesOrdered = foreach ($property in $parsedJson.PSObject.Properties) { 159 | [PSCustomObject]@{ 160 | returnCode = [int]$property.Name 161 | type = [string]$property.Value 162 | } 163 | } 164 | } 165 | 166 | process { 167 | 168 | # Create the JSON body for the Win32 app 169 | $body = [ordered]@{ 170 | '@odata.type' = "#microsoft.graph.win32LobApp" 171 | displayName = $Name 172 | description = $Description 173 | publisher = $Publisher 174 | displayVersion = $AppVersion 175 | informationUrl = $InformationURL 176 | privacyInformationUrl = $PrivacyURL 177 | notes = $Notes 178 | installExperience = [ordered]@{ 179 | '@odata.type' = "#microsoft.graph.win32LobAppInstallExperience" 180 | "runAsAccount" = $InstallExperience 181 | "deviceRestartBehavior" = $RestartBehavior 182 | "maxRunTimeInMinutes" = $MaxExecutionTime 183 | } 184 | largeIcon = [ordered]@{ 185 | '@odata.type' = "#microsoft.graph.mimeContent" 186 | type = "image/png" 187 | value = $LargeIcon 188 | } 189 | fileName = $FileName 190 | setupFilePath = $SetupFile 191 | installCommandLine = $deploymentType.InstallCommandLine 192 | uninstallCommandLine = if ([string]::IsNullOrEmpty($deploymentType.UninstallCommandLine)) { $UninstallCommandLineFallback } else { $deploymentType.UninstallCommandLine } 193 | minimumSupportedWindowsRelease = $MinimumOSVersion 194 | applicableArchitectures = $MinimumOSArchitecture 195 | } 196 | 197 | # Allow available uninstall 198 | if ($AllowAvailableUninstall) { 199 | $body['allowAvailableUninstall'] = $true 200 | } 201 | 202 | # Add returns codes 203 | $body['returnCodes'] = $returnCodesOrdered 204 | 205 | # Detection method (rules) for the Win32 app 206 | $transformedRules = @() 207 | 208 | if ($PSBoundParameters.ContainsKey('DetectionMethodJson')) { 209 | Write-LogAndHost -Message "Using JSON method for building detection" -LogId $LogId -ForegroundColor Cyan 210 | $jsonObject = $DetectionMethodJson | ConvertFrom-Json 211 | 212 | # Transform the detection rules from the JSON object 213 | foreach ($rule in $jsonObject) { 214 | 215 | # File system detection 216 | if ($rule.'@odata.type' -eq "#microsoft.graph.win32LobAppFileSystemDetection") { 217 | $transformedRules += [ordered]@{ 218 | '@odata.type' = "microsoft.graph.win32LobAppFileSystemRule" 219 | ruleType = 'detection' 220 | check32BitOn64System = $rule.check32BitOn64System 221 | operationType = $rule.detectionType 222 | comparisonValue = $rule.detectionValue 223 | fileOrFolderName = $rule.fileOrFolderName 224 | operator = $rule.operator 225 | path = $rule.path 226 | } 227 | } 228 | # Registry detection 229 | elseif ($rule.'@odata.type' -eq "#microsoft.graph.win32LobAppRegistryDetection") { 230 | $transformedRules += [ordered]@{ 231 | '@odata.type' = "microsoft.graph.win32LobAppRegistryRule" 232 | ruleType = 'detection' 233 | check32BitOn64System = $rule.check32BitOn64System 234 | operationType = $rule.detectionType 235 | comparisonValue = $rule.detectionValue 236 | keyPath = $rule.keyPath 237 | operator = $rule.operator 238 | valueName = $rule.valueName 239 | } 240 | } 241 | # MSI Detection 242 | elseif ($rule.'@odata.type' -eq "#microsoft.graph.win32LobAppProductCodeDetection") { 243 | $transformedRules += [ordered]@{ 244 | '@odata.type' = "#microsoft.graph.win32LobAppProductCodeRule" 245 | productCode = $rule.productCode 246 | productVersion = $rule.productVersion 247 | productVersionOperator = $rule.productVersionOperator 248 | } 249 | } 250 | } 251 | } 252 | elseif ($PSBoundParameters.ContainsKey('DetectionScript')) { 253 | Write-LogAndHost -Message "Using script method for building detection" -LogId $LogId -ForegroundColor Cyan 254 | $transformedRules += [ordered]@{ 255 | '@odata.type' = "#microsoft.graph.win32LobAppPowerShellScriptRule" 256 | 'scriptContent' = $DetectionScript 257 | 'enforceSignatureCheck' = $false 258 | 'runAs32Bit' = $false 259 | } 260 | } 261 | 262 | 263 | $body['rules'] = $transformedRules 264 | 265 | # Install experience 266 | $body['installExperience'] = @{ 267 | "@odata.type" = "microsoft.graph.win32LobAppInstallExperience" 268 | runAsAccount = $InstallExperience 269 | } 270 | 271 | # Create JSON body 272 | $json = $body | ConvertTo-Json -Depth 5 -Compress 273 | 274 | # Write-Host JSON body but exclude largeIcon and ScriptContent values 275 | $jsonObject = $body 276 | 277 | if ($jsonObject.Contains("largeIcon")) { 278 | $jsonObject["largeIcon"] = "Base64IcondDataPresentButOmittedFromOutputDuetoSize" 279 | } 280 | if ($jsonObject.Contains("rules") -and $jsonObject["rules"][0].Contains("scriptContent")) { 281 | $jsonObject["rules"][0]["scriptContent"] = "Base64ScriptDataPresentButOmittedFromOutputDuetoSize" 282 | } 283 | 284 | Write-LogAndHost -Message "JSON body created" -LogId $LogId -ForegroundColor Cyan 285 | $jsonOutput = $jsonObject | ConvertTo-Json -Depth 5 -Compress 286 | Write-LogAndHost -Message ("'{0}'" -f $jsonOutput) -LogId $LogId -ForegroundColor Green 287 | 288 | # Write the JSON body to a file 289 | $jsonFile = Join-Path -Path $Path -ChildPath "Win32appBody.json" 290 | Write-LogAndHost -Message ("Writing JSON body to '{0}'" -f $jsonFile) -LogId $LogId -ForegroundColor Cyan -NewLine 291 | 292 | try { 293 | 294 | # Write the JSON body to the file 295 | [System.IO.File]::WriteAllText($jsonFile, $json, [System.Text.Encoding]::UTF8) 296 | Write-LogAndHost -Message ("Successfully wrote JSON body to '{0}'" -f $jsonFile) -LogId $LogId -ForegroundColor Green 297 | 298 | return $json 299 | } 300 | catch { 301 | Write-LogAndHost -Message ("Failed to write JSON body to '{0}'" -f $jsonFile) -LogId $LogId -Severity 3 302 | 303 | return $false 304 | } 305 | } 306 | } -------------------------------------------------------------------------------- /Private/New-IntuneWin.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 11/11/2023 4 | Updated on: 04/01/2025 5 | Created by: Ben Whitmore 6 | Filename: New-IntuneWin.ps1 7 | 8 | .Description 9 | Function to create a .intunewin file 10 | 11 | .PARAMETER LogId 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the pipeline 14 | 15 | .PARAMETER ContentFolder 16 | The folder containing the content to be packaged 17 | 18 | .PARAMETER OutputFolder 19 | The folder to output the .intunewin file to 20 | 21 | .PARAMETER SetupFile 22 | The setup file to be used for packaging. Normally the .msi, .exe or .ps1 file used to install the application 23 | 24 | .PARAMETER OverrideIntuneWin32FileName 25 | Override intunewin filename. Default is the name calcualted from the install command line 26 | #> 27 | function New-IntuneWin { 28 | param( 29 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 30 | [string]$LogId = $($MyInvocation.MyCommand).Name, 31 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The folder containing the content to be packaged')] 32 | [string]$ContentFolder, 33 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The folder to output the .intunewin file to')] 34 | [string]$OutputFolder, 35 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 2, HelpMessage = 'The setup file to be used for packaging. Normally the .msi, .exe or .ps1 file used to install the application')] 36 | [string]$SetupFile, 37 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 3, HelpMessage = 'Override intunewin filename. Default is the name calcualted from the install command line')] 38 | [string]$OverrideIntuneWin32FileName 39 | ) 40 | begin { 41 | Write-Log -Message "Function: New-IntuneWin was called" -Log "Main.log" 42 | } 43 | process { 44 | 45 | # Search the Install Command line for other the installer type 46 | if ($SetupFile -match "powershell" -and $SetupFile -match "\.ps1") { 47 | Write-LogAndHost -Message "PowerShell script detected" -LogId $LogId -ForegroundColor Cyan 48 | $commandToUse = Get-InstallCommand -InstallTech '.ps1' -SetupFile $SetupFile 49 | } 50 | elseif ($SetupFile -match "\.exe" -and $SetupFile -notmatch "msiexec" -and $SetupFile -notmatch "cscript" -and $SetupFile -notmatch "wscript") { 51 | Write-LogAndHost -Message "Executable detected" -LogId $LogId -ForegroundColor Cyan 52 | $commandToUse = Get-InstallCommand -InstallTech '.exe' -SetupFile $SetupFile 53 | } 54 | elseif ($SetupFile -match "\.msi") { 55 | Write-LogAndHost -Message "MSI detected" -LogId $LogId -ForegroundColor Cyan 56 | $commandToUse = Get-InstallCommand -InstallTech '.msi' -SetupFile $SetupFile 57 | } 58 | elseif ($SetupFile -match "\.vbs") { 59 | Write-LogAndHost -Message "VBScript detected" -LogId $LogId -ForegroundColor Cyan 60 | $commandToUse = Get-InstallCommand -InstallTech '.vbs' -SetupFile $SetupFile 61 | } 62 | elseif ($SetupFile -match "\.cmd") { 63 | Write-LogAndHost -Message "CMD script detected" -LogId $LogId -ForegroundColor Cyan 64 | $commandToUse = Get-InstallCommand -InstallTech '.cmd' -SetupFile $SetupFile 65 | } 66 | elseif ($SetupFile -match "\.bat") { 67 | Write-LogAndHost -Message "Batch script detected" -LogId $LogId -ForegroundColor Cyan 68 | $commandToUse = Get-InstallCommand -InstallTech '.bat' -SetupFile $SetupFile 69 | } 70 | elseif ($SetupFile -match "\.js") { 71 | Write-LogAndHost -Message "JavaScript detected" -LogId $LogId -ForegroundColor Cyan 72 | $commandToUse = Get-InstallCommand -InstallTech '.js' -SetupFile $SetupFile 73 | } 74 | elseif ($SetupFile -match "\.exe" -and $SetupFile -match "msiexec") { 75 | Write-LogAndHost -Message "Executable detected but command line contains msiexec (hmmmm - not sure what to do here. Will use .exe)" -LogId $LogId -ForegroundColor Cyan 76 | $commandToUse = Get-InstallCommand -InstallTech '.exe' -SetupFile $SetupFile 77 | } 78 | else { 79 | # Handle the default case if none of the conditions match 80 | Write-LogAndHost "No matching extension found." -LogId $LogId -Severity 3 81 | Get-ScriptEnd -LogId $LogId 82 | } 83 | 84 | Write-LogAndHost -Message ("Building IntuneWinAppUtil.exe execution string: '{0}' -s '{1}' -c '{2}' -o '{3}'" -f "$workingFolder_Root\ContentPrepTool\IntuneWinAppUtil.exe", $commandToUse, $ContentFolder, $OutputFolder) -LogId $LogId -ForegroundColor Cyan 85 | 86 | # Try running the content prep tool to build the intunewin 87 | try { 88 | $arguments = @( 89 | '-s' 90 | "`"$commandToUse`"" 91 | '-c' 92 | "`"$ContentFolder`"" 93 | '-o' 94 | "`"$OutputFolder`"" 95 | '-q' 96 | ) 97 | Start-Process -FilePath (Join-Path -Path "$workingFolder_Root\ContentPrepTool" -ChildPath "IntuneWinAppUtil.exe") -ArgumentList $arguments -Wait 98 | 99 | } 100 | catch { 101 | Write-LogAndHost -Message ("An error was encountered when attempting to create a intunewin file at '{0}'" -f $OutputFolder) -LogId $LogId -Severity 3 102 | Get-ScriptEnd -LogId $LogId -Message $_.Exception.Message 103 | } 104 | 105 | # Check if the intunewin file was created 106 | $fileToCheck = $commandToUse -replace '\.[^.]*$', '.intunewin' 107 | 108 | if (Test-Path -Path "$OutputFolder\$fileToCheck" ) { 109 | Write-LogAndHost -Message ("Successfully created intunewin file '{0}' at '{1}'" -f $fileToCheck, $OutputFolder) -LogId $LogId -ForegroundColor Green 110 | 111 | # Override the intunewin filename if requested. We can't rename this during the creation of the file so let's rename it now 112 | if ($OverrideIntuneWin32FileName) { 113 | 114 | Write-LogAndHost -Message ("The 'OverrideIntuneWin32FileName' parameter was passed. Renaming intunewin file '{0}' to '{1}.intunewin'" -f $fileToCheck, $OverrideIntuneWin32FileName) -LogId $LogId -ForegroundColor Cyan 115 | 116 | try { 117 | 118 | # Check if the file already exists and delete it so the rename operation does not fail 119 | if (Test-Path -Path "$OutputFolder\$OverrideIntuneWin32FileName.intunewin") { 120 | Write-LogAndHost -Message ("The file '{0}' already exists. Deleting the existing file before renaming" -f "$OutputFolder\$OverrideIntuneWin32FileName.intunewin") -LogId $LogId -Severity 2 121 | Remove-Item -Path "$OutputFolder\$OverrideIntuneWin32FileName.intunewin" -Force -ErrorAction Stop 122 | } 123 | 124 | Rename-Item -Path "$OutputFolder\$fileToCheck" -NewName "$OverrideIntuneWin32FileName.intunewin" -ErrorAction Stop 125 | Write-LogAndHost -Message ("Successfully renamed intunewin file '{0}' to '{1}.intunewin'" -f $fileToCheck, $OveideIntuneWin32FileName) -LogId $LogId -ForegroundColor Green 126 | } 127 | catch { 128 | Write-LogAndHost -Message ("An error was encountered when attempting to rename intunewin file '{0}' to '{1}.intunewin'" -f $fileToCheck, $OverrideIntuneWin32FileName) -LogId $LogId -Severity 3 129 | Write-Log -Message $_.Exception.Message -LogId $LogId -Severity 3 130 | 131 | throw 132 | } 133 | } 134 | } 135 | else { 136 | Write-LogAndHost -Message ("The content prep tool ran succesfully but failed to create an intunewin file at '{0}'" -f $OutputFolder) -LogId $LogId -Severity 3 137 | Write-Log -Message $_.Exception.Message -LogId $LogId -Severity 3 138 | 139 | throw 140 | } 141 | } 142 | } -------------------------------------------------------------------------------- /Private/New-IntuneWinContentRequest.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 09/06/2024 4 | Updated on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: New-IntuneWinContentRequest.ps1 7 | 8 | .Description 9 | Function to create a new content request for an IntuneWin package 10 | Properties for content request can be found at https://learn.microsoft.com/en-us/graph/api/resources/intune-apps-mobileappcontentfile?view=graph-rest-1.0 11 | 12 | .PARAMETER LogId 13 | The component (script name) passed as LogID to the 'Write-Log' function. 14 | This parameter is built from the line number of the call from the function up the pipeline 15 | 16 | .PARAMETER ContentVersion 17 | The version of the content to be uploaded 18 | 19 | .PARAMETER Name 20 | The intunewin file name 21 | 22 | .PARAMETER SizeEncrypted 23 | The compressed size of the content to be uploaded 24 | 25 | .PARAMETER SizeUnencrypted 26 | The uncompressed size of the content to be uploaded 27 | 28 | .PARAMETER IsDependency 29 | Is this content a dependency 30 | 31 | #> 32 | function New-IntuneWinContentRequest { 33 | param( 34 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The intunewin file name')] 35 | [string]$Name, 36 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The compressed size of the content to be uploaded')] 37 | [int64]$SizeEncrypted, 38 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 2, HelpMessage = 'The uncompressed size of the content to be uploaded')] 39 | [int64]$SizeUnencrypted, 40 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 4, HelpMessage = 'Is this content a dependency')] 41 | [bool]$IsDependency = $false, 42 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 43 | [string]$LogId = $($MyInvocation.MyCommand).Name 44 | ) 45 | begin { 46 | 47 | Write-LogAndHost -Message "Function: new-IntuneWinContentRequest was called" -Log "Main.log" -ForegroundColor Cyan 48 | 49 | } 50 | process { 51 | 52 | # Create the IntuneWinContentRequest Json 53 | try { 54 | $intuneWinContentRequest = [ordered]@{ 55 | "@odata.type" = "#microsoft.graph.mobileAppContentFile" 56 | "name" = $Name 57 | "size" = $SizeUnencrypted 58 | "sizeEncrypted" = $SizeEncrypted 59 | "isDependency" = $IsDependency 60 | "manifest" = $null 61 | } 62 | } 63 | catch { 64 | Write-LogAndHost -Message "An error occurred while creating the IntuneWinContentRequest" -LogId $LogId -Severity 3 65 | 66 | return $false 67 | } 68 | 69 | Write-LogAndHost -Message ("{0}" -f ($intuneWinContentRequest | ConvertTo-Json -Depth 5 -Compress) ) -LogId $LogId -ForegroundColor Green 70 | 71 | return $intuneWinContentRequest 72 | } 73 | } -------------------------------------------------------------------------------- /Private/New-VerboseRegion.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 26/10/2023 4 | Updated on: 03/01/2025 5 | Created by: Ben Whitmore 6 | Filename: New-VerboseRegion.ps1 7 | 8 | .Description 9 | Write verbose messages 10 | 11 | .PARAMETER LogID 12 | The component (script name) passed as LogID to the 'Write-Log' function. 13 | This parameter is built from the line number of the call from the function up the pipeline 14 | 15 | .PARAMETER Messages 16 | The message to write 17 | 18 | .PARAMETER ForegroundColor 19 | The colour of the message to write 20 | #> 21 | function New-VerboseRegion { 22 | [CmdletBinding()] 23 | param ( 24 | [Parameter(Mandatory = $false, ValuefromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function')] 25 | [string]$LogId = $($MyInvocation.MyCommand).Name, 26 | [Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The message to write')] 27 | [String]$Message, 28 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 1, HelpMessage = 'The colour of the message to write')] 29 | [String]$ForegroundColor = 'White' 30 | ) 31 | Write-Log -Message "--------------------------------------------" 32 | Write-Log -Message ("{0}..." -f $Message ) 33 | Write-Log -Message "--------------------------------------------" 34 | Write-Host '' 35 | Write-Host '--------------------------------------------' -ForegroundColor $ForegroundColor 36 | Write-Host ("{0}..." -f $Message) -ForegroundColor $ForegroundColor 37 | Write-Host '--------------------------------------------' -ForegroundColor $ForegroundColor 38 | Write-Host '' 39 | } -------------------------------------------------------------------------------- /Private/Write-Log.ps1: -------------------------------------------------------------------------------- 1 | 2 | <# 3 | .Synopsis 4 | Created on: 26/10/2023 5 | Created by: Ben Whitmore 6 | Filename: Write-Log.ps1 7 | 8 | .Description 9 | Function to write to a log file 10 | 11 | .PARAMETER Message 12 | The message to write to the log file 13 | 14 | .PARAMETER LogFolder 15 | The location of the log file to write to 16 | 17 | .PARAMETER Log 18 | The name of the log file to write to 19 | 20 | .PARAMETER Severity 21 | 1 = Information (default severity) 22 | 2 = Warning 23 | 3 = Error 24 | 25 | .PARAMETER Component 26 | The component (script name) passed as LogID to the 'Write-Log' function 27 | This parameter is built from the line number of the call from the function up the pipeline 28 | 29 | .PARAMETER ResetLogFile 30 | If specified, the log file will be reset 31 | #> 32 | function Write-Log { 33 | [CmdletBinding()] 34 | param( 35 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, HelpMessage = 'Message to write to the log file')] 36 | [AllowEmptyString()] 37 | [String]$Message, 38 | [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 1, HelpMessage = 'Location of the log file to write to')] 39 | [String]$LogFolder = "$workingFolder_Root\Logs", #$workingFolder is defined as a Global parameter in the main script 40 | [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 2, HelpMessage = 'Name of the log file to write to. Main is the default log file')] 41 | [String]$Log = 'Main.log', 42 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'LogId name of the script of the calling function')] 43 | [String]$LogId = $($MyInvocation.MyCommand).Name, 44 | [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 3, HelpMessage = 'Severity of the log entry 1-3')] 45 | [ValidateSet(1, 2, 3)] 46 | [string]$Severity = 1, 47 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, HelpMessage = 'The component (script name) passed as LogID to the Write-Log function including line number of invociation')] 48 | [string]$Component = [string]::Format('{0}:{1}', $logID, $($MyInvocation.ScriptLineNumber)), 49 | [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 4, HelpMessage = 'If specified, the log file will be reset')] 50 | [Switch]$ResetLogFile 51 | ) 52 | 53 | Begin { 54 | $dateTime = Get-Date 55 | $date = $dateTime.ToString("MM-dd-yyyy", [Globalization.CultureInfo]::InvariantCulture) 56 | $time = $dateTime.ToString("HH:mm:ss.ffffff", [Globalization.CultureInfo]::InvariantCulture) 57 | $logToWrite = Join-Path -Path $LogFolder -ChildPath $Log 58 | } 59 | 60 | Process { 61 | if ($PSBoundParameters.ContainsKey('ResetLogFile')) { 62 | try { 63 | 64 | # Check if the logfile exists. We only need to reset it if it already exists 65 | if (Test-Path -Path $logToWrite) { 66 | 67 | # Create a StreamWriter instance and open the file for writing 68 | $streamWriter = New-Object -TypeName System.IO.StreamWriter -ArgumentList $logToWrite 69 | 70 | # Write an empty string to the file without the append parameter 71 | $streamWriter.Write("") 72 | 73 | # Close the StreamWriter, which also flushes the content to the file 74 | $streamWriter.Close() 75 | Write-Host ("Log file '{0}' wiped" -f $logToWrite) -ForegroundColor Yellow 76 | } 77 | else { 78 | Write-Host ("Log file not found at '{0}'. Not restting log file" -f $logToWrite) -ForegroundColor Yellow 79 | } 80 | } 81 | catch { 82 | Write-Error -Message ("Unable to wipe log file. Error message: {0}" -f $_.Exception.Message) 83 | throw 84 | } 85 | } 86 | 87 | try { 88 | 89 | # Extract log object and construct format for log line entry 90 | foreach ($messageLine in $Message) { 91 | $logDetail = [string]::Format('', $messageLine, $time, $date, $Component, $Context, $Severity, $PID) 92 | 93 | # Attempt log write 94 | try { 95 | $streamWriter = New-Object -TypeName System.IO.StreamWriter -ArgumentList $logToWrite, 'Append' 96 | $streamWriter.WriteLine($logDetail) 97 | $streamWriter.Close() 98 | } 99 | catch { 100 | Write-Error -Message ("Unable to append log entry to '{0}' file. Error message: {1}" -f $logToWrite, $_.Exception.Message) 101 | throw 102 | } 103 | } 104 | } 105 | catch [System.Exception] { 106 | Write-Warning -Message ("Unable to append log entry to '{0}' file" -f $logToWrite) 107 | throw 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /Private/Write-LogAndHost.ps1: -------------------------------------------------------------------------------- 1 | 2 | <# 3 | .Synopsis 4 | Created on: 01/01/2025 5 | Created by: Ben Whitmore 6 | Filename: Write-LogAndHost.ps1 7 | 8 | .Description 9 | Function to write to a log file and host 10 | 11 | .PARAMETER Message 12 | The message to write to the log file 13 | 14 | .PARAMETER LogId 15 | The component (script name) passed as LogID to the 'Write-Log' function 16 | This parameter is built from the line number of the call from the function up the pipeline 17 | 18 | .PARAMETER ForegroundColor 19 | The foreground color for Write-Host 20 | 21 | .PARAMETER NewLine 22 | Create this message on a new line 23 | 24 | .PARAMETER Severity 25 | 1 = Information (default severity) 26 | 2 = Warning 27 | 3 = Error 28 | #> 29 | 30 | function Write-LogAndHost { 31 | [CmdletBinding()] 32 | param( 33 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, HelpMessage = 'Message to write to the log file and host')] 34 | [String]$Message, 35 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1, HelpMessage = 'LogId name of the script of the calling function')] 36 | [String]$LogId, 37 | [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 2, HelpMessage = 'Foreground color for Write-Host')] 38 | [String]$ForegroundColor = "White", 39 | [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 4, HelpMessage = 'Create this message on a new line')] 40 | [Switch]$NewLine, 41 | [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 3, HelpMessage = 'Severity of the log entry 1-3')] 42 | [ValidateSet(1, 2, 3)] 43 | [int]$Severity = 1 44 | ) 45 | 46 | begin { 47 | 48 | # Set the ForegroundColor based on the severity if not already specified 49 | switch ($Severity) { 50 | 2 { 51 | if (-not $PSBoundParameters.ContainsKey('ForegroundColor')) { 52 | $ForegroundColor = "Yellow" 53 | } 54 | } 55 | 3 { 56 | if (-not $PSBoundParameters.ContainsKey('ForegroundColor')) { 57 | $ForegroundColor = "Red" 58 | } 59 | } 60 | } 61 | } 62 | 63 | process { 64 | 65 | # Call Write-Log function to write the log 66 | Write-Log -Message $Message -LogId $LogId -Severity $Severity 67 | 68 | # Write the message to the host 69 | if ($PSBoundParameters.ContainsKey('NewLine')) { 70 | Write-Host "`n$Message" -ForegroundColor $ForegroundColor 71 | } 72 | else { 73 | Write-Host $Message -ForegroundColor $ForegroundColor 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /Public/Connect-MgGraphCustom.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 28/12/2024 4 | Created by: Ben Whitmore 5 | Filename: Connect-MgGraphCustom.ps1 6 | 7 | .Description 8 | Function to connect to Microsoft Graph using various authentication methods 9 | 10 | .PARAMETER LogID 11 | The component (script name) passed as LogID to the 'Write-Log' function 12 | 13 | .PARAMETER ModuleName 14 | Module Name to use to connect to Graph. Default is Microsoft.Graph.Authentication 15 | 16 | .PARAMETER PackageProvider 17 | Package Provider. If not specified, the default value NuGet is used 18 | 19 | .PARAMETER ModuleScope 20 | Module Scope. If not specified, the default value is used for CurrentUser 21 | 22 | .PARAMETER TenantId 23 | Tenant Id or name to connect to. This parameter is mandatory for obtaining a connection 24 | 25 | .PARAMETER ClientId 26 | Client Id (App Registration) to connect to. This parameter is mandatory for obtaining a connection 27 | 28 | .PARAMETER ClientSecret 29 | Client Secret for authentication 30 | 31 | .PARAMETER ClientCertificateThumbprint 32 | Client certificate thumbprint for authentication 33 | 34 | .PARAMETER RequiredScopes 35 | The scopes to request from the Microsoft Graph API. If not specified, the default value is used for .default 36 | 37 | .PARAMETER UseDeviceAuthentication 38 | This parameter will be used to determine if the device authentication flow should be used. If not specified, the default value is used for $false 39 | 40 | .PARAMETER Interactive 41 | This parameter will be used to determine if the interactive flow should be used. If not specified, the default value is used for $false 42 | 43 | .EXAMPLE 44 | Delegated Flow Example: 45 | Connect-MgGraphCustom -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' 46 | 47 | .EXAMPLE 48 | Client Secret Flow Example: 49 | Connect-MgGraphCustom -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' -ClientSecret 'clientsecret' 50 | 51 | .EXAMPLE 52 | Client Certificate Flow Example: 53 | Connect-MgGraphCustom -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' -ClientCertificateThumbprint '00000000000000000000000000000000' 54 | 55 | .EXAMPLE 56 | Device Authentication Flow Example: 57 | Connect-MgGraphCustom -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' -UseDeviceAuthentication 58 | #> 59 | 60 | function Connect-MgGraphCustom { 61 | [CmdletBinding(DefaultParameterSetName = 'Interactive')] 62 | param ( 63 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 0, HelpMessage = 'The component (script name) passed as LogID to the "Write-Log" function')] 64 | [string]$LogId = $($MyInvocation.MyCommand).Name, 65 | 66 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 1, HelpMessage = 'Module Name to connect to Graph. Default is Microsoft.Graph.Authentication')] 67 | [object]$ModuleNames = ('Microsoft.Graph.Authentication'), 68 | 69 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 2, HelpMessage = 'If not specified, the default value NuGet is used for PackageProvider')] 70 | [string]$PackageProvider = 'NuGet', 71 | 72 | [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', Position = 3, HelpMessage = 'Tenant Id or name to connect to')] 73 | [Parameter(Mandatory = $true, ParameterSetName = 'ClientCertificateThumbprint', Position = 3, HelpMessage = 'Tenant Id or name to connect to')] 74 | [Parameter(Mandatory = $true, ParameterSetName = 'UseDeviceAuthentication', Position = 3, HelpMessage = 'Tenant Id or name to connect to')] 75 | [Parameter(Mandatory = $true, ParameterSetName = 'Interactive', Position = 3, HelpMessage = 'Tenant Id or name to connect to')] 76 | [string]$TenantId, 77 | 78 | [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', Position = 4, HelpMessage = 'Client Id (App Registration) to connect to')] 79 | [Parameter(Mandatory = $true, ParameterSetName = 'ClientCertificateThumbprint', Position = 4, HelpMessage = 'Client Id (App Registration) to connect to')] 80 | [Parameter(Mandatory = $true, ParameterSetName = 'UseDeviceAuthentication', Position = 4, HelpMessage = 'Client Id (App Registration) to connect to')] 81 | [Parameter(Mandatory = $true, ParameterSetName = 'Interactive', Position = 4, HelpMessage = 'Client Id (App Registration) to connect to')] 82 | [string]$ClientId, 83 | 84 | [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', Position = 5, HelpMessage = 'Client secret for authentication')] 85 | [string]$ClientSecret, 86 | 87 | [Parameter(Mandatory = $true, ParameterSetName = 'ClientCertificateThumbprint', Position = 5, HelpMessage = 'Client certificate thumbprint for authentication')] 88 | [string]$ClientCertificateThumbprint, 89 | 90 | [Parameter(Mandatory = $true, ParameterSetName = 'UseDeviceAuthentication', Position = 5, HelpMessage = 'Use device authentication for Microsoft Graph API')] 91 | [switch]$UseDeviceAuthentication, 92 | 93 | [Parameter(Mandatory = $false, Position = 6, HelpMessage = 'The scopes required for Microsoft Graph API access. Default is DeviceManagementApps.ReadWrite.All')] 94 | [string[]]$RequiredScopes = ('DeviceManagementApps.ReadWrite.All'), 95 | 96 | [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 7, HelpMessage = 'Specifies the scope for installing the module. Default is CurrentUser')] 97 | [string]$ModuleScope = 'CurrentUser' 98 | ) 99 | 100 | begin { 101 | 102 | Write-LogAndHost -Message 'Function: Connect-MgGraphCustom was called' -LogId $LogId -ForegroundColor Cyan 103 | Write-LogAndHost -Message "Resolved Parameter Set: $($PSCmdlet.ParameterSetName)" -LogId $LogId -ForegroundColor Cyan 104 | 105 | Initialize-Module -Modules $ModuleNames 106 | } 107 | 108 | process { 109 | 110 | # First check if we already have a valid connection with required scopes 111 | if (Test-MgConnection -RequiredScopes $RequiredScopes -TestScopes) { 112 | Write-LogAndHost -Message "Using existing Microsoft Graph connection" -LogId $LogId -ForegroundColor Green 113 | return 114 | } 115 | 116 | # If we don't have required scopes, set the default required scopes to create Win32 apps. This assumes the Connect-MgGraphCustom function is used outside of the New-Win32App function 117 | if (-not $RequiredScopes) { 118 | 119 | if (Test-Path variable:\global:scopes) { 120 | 121 | [string[]]$RequiredScopes = $global:scopes 122 | Write-LogAndHost -Message ("Required Scope defined already. Using existing required scopes: {0}" -f $RequiredScopes) -LogId $LogId -ForegroundColor Green 123 | } 124 | else { 125 | [string[]]$global:scopes = ('DeviceManagementApps.ReadWrite.All') 126 | [string[]]$RequiredScopes = $global:scopes 127 | Write-LogAndHost -Message ("Required Scope defined yet.Using default required scopes: {0}" -f $RequiredScopes) -LogId $LogId -ForegroundColor Green 128 | } 129 | } 130 | 131 | # Determine the authentication method based on provided parameters 132 | if ($PSCmdlet.ParameterSetName -eq 'ClientSecret') { 133 | $AuthenticationMethod = 'ClientSecret' 134 | } 135 | elseif ($PSCmdlet.ParameterSetName -eq 'ClientCertificateThumbprint') { 136 | $AuthenticationMethod = 'ClientCertificateThumbprint' 137 | } 138 | elseif ($PSCmdlet.ParameterSetName -eq 'UseDeviceAuthentication') { 139 | $AuthenticationMethod = 'UseDeviceAuthentication' 140 | } 141 | else { 142 | $AuthenticationMethod = 'Interactive' 143 | } 144 | 145 | # If we don't have a valid connection, proceed with connection based on parameters 146 | $connectMgParams = [ordered]@{ 147 | TenantId = $TenantId 148 | } 149 | 150 | switch ($AuthenticationMethod) { 151 | 'ClientSecret' { 152 | $secureClientSecret = ConvertTo-SecureString -String $ClientSecret -AsPlainText -Force 153 | $credential = New-Object System.Management.Automation.PSCredential -ArgumentList $ClientId, $secureClientSecret 154 | $connectMgParams['ClientSecretCredential'] = $credential 155 | } 156 | 'ClientCertificateThumbprint' { 157 | $connectMgParams['ClientId'] = $ClientId 158 | $connectMgParams['CertificateThumbprint'] = $ClientCertificateThumbprint 159 | } 160 | 'UseDeviceAuthentication' { 161 | $connectMgParams['ClientId'] = $ClientId 162 | $connectMgParams['UseDeviceCode'] = $true 163 | $connectMgParams['Scopes'] = $RequiredScopes 164 | } 165 | 'Interactive' { 166 | $connectMgParams['ClientId'] = $ClientId 167 | $connectMgParams['Scopes'] = $RequiredScopes -join ' ' 168 | } 169 | default { 170 | Write-LogAndHost -Message ("Unknown authentication method: {0}" -f $AuthenticationMethod) -LogId $LogId -Severity 3 171 | break 172 | } 173 | } 174 | 175 | # Convert the parameters to a string for logging 176 | $connectMgParamsString = 'Connect-MgGraph ' + ($connectMgParams.Keys | ForEach-Object { '-{0} {1}' -f $_, $connectMgParams.$_ }) -join ' ' 177 | Write-LogAndHost -Message ("Connecting to Microsoft Graph with the following parameters: {0}" -f $connectMgParamsString) -LogId $LogId -ForegroundColor Cyan 178 | 179 | try { 180 | # Explicitly pass the parameters to Connect-MgGraph 181 | if ($AuthenticationMethod -eq 'ClientSecret') { 182 | Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $connectMgParams['ClientSecretCredential'] -NoWelcome 183 | } 184 | elseif ($AuthenticationMethod -eq 'ClientCertificateThumbprint') { 185 | Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $ClientCertificateThumbprint -NoWelcome 186 | } 187 | elseif ($AuthenticationMethod -eq 'UseDeviceAuthentication') { 188 | Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -UseDeviceCode -Scopes $connectMgParams['Scopes'] -NoWelcome 189 | } 190 | else { 191 | Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -Scopes $connectMgParams['Scopes'] -NoWelcome 192 | } 193 | } 194 | catch { 195 | Write-LogAndHost -Message ("Failed to connect to Microsoft Graph: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 196 | } 197 | 198 | # Check if we have a valid connection with required scopes 199 | if (Test-MgConnection -LogId $LogId -RequiredScopes $RequiredScopes) { 200 | 201 | Write-LogAndHost -Message "Successfully connected to Microsoft Graph" -LogId $LogId -ForegroundColor Green 202 | 203 | # Get and display connection details 204 | $context = Get-MgContext 205 | if ($AuthenticationMethod -in @('ClientSecret', 'ClientCertificateThumbprint')) { 206 | Write-LogAndHost -Message ("Connected using Client Credential Flow with application: {0}" -f $context.AppName) -LogId $LogId -ForegroundColor Green 207 | } 208 | else { 209 | Write-LogAndHost -Message ("Connected using Delegated Flow as: {0}" -f $context.Account) -LogId $LogId -ForegroundColor Green 210 | } 211 | Write-LogAndHost -Message ("Scopes: {0}" -f ($context.Scopes -join ', ')) -LogId $LogId -ForegroundColor Green 212 | } 213 | else { 214 | Write-LogAndHost -Message "Failed to establish a valid connection with required scopes" -LogId $LogId -Severity 3 215 | } 216 | } 217 | } -------------------------------------------------------------------------------- /Public/Test-MgConnection.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 28/12/2024 4 | Created by: Ben Whitmore 5 | Filename: Test-MgConnection.ps1 6 | 7 | .Description 8 | Function to test Microsoft Graph connection status and required scopes 9 | 10 | .PARAMETER LogID 11 | The component (script name) passed as LogID to the 'Write-Log' function 12 | 13 | .PARAMETER RequiredScopes 14 | Array of scopes that should be present in the connection 15 | 16 | .PARAMETER TestScopes 17 | Switch to test the scopes of the connection 18 | #> 19 | function Test-MgConnection { 20 | [CmdletBinding()] 21 | param ( 22 | [Parameter(Mandatory = $false, HelpMessage = 'LogId name of the script of the calling function')] 23 | [string]$LogId = $($MyInvocation.MyCommand).Name, 24 | [Parameter(Mandatory = $false, HelpMessage = 'Scopes required for Microsoft Graph API access')] 25 | [string[]]$RequiredScopes, 26 | [Parameter(Mandatory = $false, HelpMessage = 'Test if scopes are defined')] 27 | [switch]$TestScopes 28 | ) 29 | 30 | # If we don't have required scopes, set the default required scopes to create Win32 apps. This assumes the Connect-MgGraphCustom function is used outside of the New-Win32App function 31 | if (-not $RequiredScopes -and $TestScopes) { 32 | if (Test-Path variable:\global:scopes) { 33 | $RequiredScopes = $global:scopes 34 | Write-LogAndHost -Message ("Required Scopes are defined already in global variable. Using existing required scopes: {0}" -f ($RequiredScopes -join ', ')) -LogId $LogId -ForegroundColor Green 35 | } 36 | } 37 | elseif (-not $RequiredScopes -and -not $TestScopes) { 38 | $global:scopes = @('DeviceManagementApps.ReadWrite.All') 39 | $RequiredScopes = $global:scopes 40 | } 41 | 42 | try { 43 | # Check if we have an active connection 44 | $context = Get-MgContext -ErrorAction Stop 45 | 46 | if (-not $context) { 47 | Write-LogAndHost -Message "No active Microsoft Graph connection found" -LogId $LogId -Severity 2 -ForegroundColor Yellow 48 | return $false 49 | } 50 | 51 | # Check if the required scopes are in the scopes of the active connection 52 | $scopes = $context.Scopes 53 | $missingScopes = $RequiredScopes | Where-Object { $scopes -notcontains $_ } 54 | 55 | if ($missingScopes) { 56 | Write-LogAndHost -Message ("Missing required scopes: {0}" -f ($missingScopes -join ', ')) -LogId $LogId -Severity 2 -ForegroundColor Yellow 57 | 58 | return $false 59 | } 60 | 61 | # If we get here, we have a valid connection with the required scopes 62 | return $true 63 | } 64 | catch { 65 | Write-LogAndHost -Message ("Error while checking Microsoft Graph connection: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3 -ForegroundColor Red 66 | 67 | return $false 68 | } 69 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Win32 App Migration Tool 3 | ![PowerShell Gallery](https://img.shields.io/powershellgallery/dt/Win32AppMigrationTool?color=green&label=PowerShell%20Gallery%20Downloads&logo=powershell) 4 | ![License: Non-Commercial](https://img.shields.io/badge/License-Non--Commercial-purple.svg) 5 | ![PowerShell](https://img.shields.io/badge/PowerShell-v7%2B-yellow?logo=powershell) 6 | ![PowerShell](https://img.shields.io/badge/PowerShell-v5.1-blue?logo=powershell) 7 | 8 | ![Tool Preview](https://byteben.com/bb/Downloads/GitHub/MigTool3-AppSelection.jpg) 9 | 10 | --- 11 | 12 | ## 📝 Synopsis 13 | 14 | The **Win32AppMigrationTool** is designed to inventory **ConfigMgr Applications** and Deployment Types, build .intunewin files, and create win32 apps in the Intune. 15 | 16 | --- 17 | 18 | ## 🔐 License 19 | 20 | - **Non-Commercial Use**: This software is provided for non-commercial use only. Commercial use is prohibited without prior written consent from the author. 21 | 22 | - **Modifications**: You are permitted to modify or fork the code for personal use only. However, you may not distribute the original or modified versions of the code, share it to add additional functionality, or incorporate it into commercial products. 23 | 24 | This project is licensed under the [Non-Commercial License](License). You may not reproduce, distribute, or use any part of this code for commercial purposes. For detailed terms and conditions, please refer to the [License](License) file in this directory. 25 | 26 | --- 27 | 28 | ## ⚖️ Legal Disclaimer 29 | 30 | The provided PowerShell script is shared with the community as-is. The author and any co-author(s) make no warranties or guarantees regarding its functionality, reliability, or suitability for any specific purpose. 31 | 32 | This script may require modifications to fit your specific environment or requirements. It is strongly recommended to test the script in a non-production environment before using it in a live or critical system. 33 | 34 | The author and co-author(s) shall not be held responsible for any damages, losses, or unintended effects resulting from the use of this script. By using this script, you assume all associated risks and responsibilities. 35 | 36 | --- 37 | 38 | ## 👩‍💻 Development Status 39 | 40 | **STATUS: Preview** 41 | The Win32App Migration Tool is in Preview. 42 | 43 | ## 📋 Requirements 44 | 45 | - **Configuration Manager Console** The console must be installed on the machine you are running the Win32App Migration Tool from. The following path should resolve true: $ENV:SMS_ADMIN_UI_PATH 46 | - **Local Administrator** The default Working folder is $ENV:SystemDrive\Win32AppMigrationTool. You will need permissions to create this directory on the System Drive 47 | - **Roles** Permission to run the Configuration Manager cmdlet **Get-CMApplication** 48 | - **Content Folder Permission** Read permissions to the content source for the Deployment Types that will be exported 49 | - **PowerShell 5.1** PowerShell 7 is recommended to ensure Blob files are verificed after chunking to Azure Storage and to correctly analyzing certain ConfigMgr detection types 50 | - **Internet Access** to download the Win32 Content Prep Tool 51 | - **Microsoft.Graph.Authentication Module** Install-Module -Name Microsoft.Graph.Authentication (This is installed as part of the Win32AppMigrationTool Module if the -CreateApps parameter is passed) 52 | - **Az.Storage Module** Install-Module -Name Az.Storage (This is installed as part of the Win32AppMigrationTool Module if the -CreateApps parameter is passed) 53 | - **Entra ID App Registration** The Client App must have a redirect URI configured for **http://localhost**. This is required for `Connect-MgGraph` to work correctly 54 | 55 | ## 👏 Quick Start 56 | 57 | **1. Install-Module Win32AppMigrationTool** 58 | **2. New-Win32App** -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" 59 | **3. Use Information from the CSVs to build a Win32 app in Intune** 60 | 61 | ``` 62 | New-Win32App -ProviderMachineName -AppName 63 | ``` 64 | 65 | ## 🔢 Order of Operations 66 | 67 | The current release of the Win32 App Migration Tool will do the following:- 68 | 69 | - Download the Win32 app Content Prep Tool to %WorkingDirectory%\ContentPrepTool 70 | - Export .intunewin files to %WorkingDirectory%\Win32Apps\\ 71 | - Export Application Details to %WorkingDirectory%\Details\Applications.csv 72 | - Export Deployment Type Details to %WorkingDirectory%\Details\DeploymentTypes.csv 73 | - Export Content Details to %WorkingDirectory%\Details\Content.csv (If -DownloadContent parameter passed) 74 | - Copy Select Deployment Type Content to %WorkingDirectory%\Content\ 75 | - Export Application icons(s) to %WorkingDirectory%\Icons 76 | - Build Win32 app JSON payload body for Intune 77 | - Convert detection to JSON for Intune 78 | - Extract the IntunePackage.intunewin for upload 79 | - Get encryption information for Intunewin 80 | - Request Content Upload Uri from Intune 81 | - Upload file in chunks to Intune 82 | - Commit file to Intune 83 | - Log events to %WorkingDirectory%\Logs\Main.log 84 | 85 | ### 1. Environment Setup 86 | 87 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool1-Env.jpg) 88 | 89 | ### 2. Authentication 90 | 91 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool2-Authentication.jpg) 92 | 93 | ### 3. Application Selection 94 | 95 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool3-AppSelection.jpg) 96 | 97 | ### 4. Application Details Export 98 | 99 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool4-AppDetails.jpg) 100 | 101 | ### 5. Deployment Type Details Export 102 | 103 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool5-DeploymentTypeDetails.jpg) 104 | 105 | ### 6. Content Details Export 106 | 107 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool6-ContentDetails.jpg) 108 | 109 | ### 7. Content Download 110 | 111 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool7-ContentCopy.jpg) 112 | 113 | ### 8. CSV Export of Application Details 114 | 115 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool8-CSVExport.jpg) 116 | 117 | ### 9. Icon Export 118 | 119 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool9-IconExport.jpg) 120 | 121 | ### 10. Create an Intunewin 122 | 123 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool10-CreateIntunewinjpg.jpg) 124 | 125 | ### 11. Create Win32App JSON Body 126 | 127 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool11-CreateWin32AppJson.jpg) 128 | 129 | ### 12. Create the WIn32 app in Intune 130 | 131 | ![alt text](https://byteben.com/bb/Downloads/GitHub/MigTool12-CreateWin32App.jpg) 132 | 133 | 134 | ## ⚠️ Important Information 135 | 136 | _**// Please use the tool with caution and test in your lab (dont be the person who tests in production). I accept no responsibility for loss or damage as a result of using these scripts //**_ 137 | 138 | ## Troubleshooting 139 | 140 | **Main.log** in the **%WorkingFolder%\Logs** folder contains a detailed verbose output of the solution 141 | 142 | ![alt text](https://byteben.com/bb/Downloads/GitHub/Main-Log.jpg) 143 | 144 | ## ↘️ Parameters 145 | 146 | ### -SiteCode 147 | 148 | The Site Code of the ConfigMgr Site. The Site Code must be only 3 alphanumeric characters. This is an optional parameter. If not passed, the script will attempt to get the Site Code automatically from WMI. If the Site Code cannot be determined, the script will exit with an error message after 3 invalid attempts. 149 | 150 | ```yaml 151 | Type: String 152 | Parameter Sets: (All) 153 | 154 | Required: False 155 | Position: 0 156 | Default value: None 157 | Accept pipeline input: False 158 | Accept wildcard characters: False 159 | Validate Pattern: ^[a-zA-Z0-9]{3}$')] 160 | ``` 161 | 162 | ### -ProviderMachineName 163 | 164 | Server name that has an SMS Provider site system role 165 | 166 | ```yaml 167 | Type: String 168 | Parameter Sets: (All) 169 | 170 | Required: True 171 | Position: 1 172 | Default value: None 173 | Accept pipeline input: False 174 | Accept wildcard characters: False 175 | ``` 176 | 177 | ### -Parameter AppName 178 | 179 | Pass an app name to search for matching applications in ConfigMgr. You can use * as a wildcard e.g. "Microsoft*" or "\*Reader" 180 | 181 | ```yaml 182 | Type: String 183 | Parameter Sets: (All) 184 | 185 | Required: True 186 | Position: 2 187 | Default value: None 188 | Accept pipeline input: False 189 | Accept wildcard characters: True 190 | ``` 191 | 192 | ### -Parameter WorkingFolder 193 | 194 | The working folder for the Win32AppMigration Tool. Care should be given when specifying the working folder because downloaded content can increase the working folder size considerably 195 | 196 | ```yaml 197 | Type: String 198 | Parameter Sets: (All) 199 | 200 | Required: False 201 | Position: 3 202 | Default value: C:\Win32AppMigrationTool 203 | Accept pipeline input: False 204 | Accept wildcard characters: False 205 | ``` 206 | 207 | ### -Parameter ExcludeFilter 208 | 209 | Pass this parameter to exclude specific apps from the results. Accepts wildcards e.g. "Microsoft*"' 210 | 211 | ```yaml 212 | Type: String 213 | Parameter Sets: (All) 214 | 215 | Required: False 216 | Position: 4 217 | Default value: 218 | Accept pipeline input: False 219 | Accept wildcard characters: True 220 | ``` 221 | 222 | ### -Parameter Win32ContentPrepToolUri 223 | 224 | URI for Win32 Content Prep Tool 225 | 226 | ```yaml 227 | Type: String 228 | Parameter Sets: (All) 229 | 230 | Required: False 231 | Position: 5 232 | Default value: https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/raw/master/IntuneWinAppUtil.exe 233 | Accept pipeline input: False 234 | Accept wildcard characters: False 235 | ``` 236 | 237 | ### -Parameter OverrideIntuneWin32FileName 238 | 239 | Override intunewin filename. Default is the name calcualted from the install command line 240 | 241 | ```yaml 242 | Type: String 243 | Parameter Sets: (All) 244 | 245 | Required: False 246 | Position: 6 247 | Default value: 248 | Accept pipeline input: False 249 | Accept wildcard characters: False 250 | ``` 251 | 252 | ### -Parameter DownloadContent 253 | 254 | DownloadContent: When passed, the content for the deployment type is saved locally to the working folder "Content" 255 | 256 | ```yaml 257 | Type: Switch 258 | Parameter Sets: (All) 259 | 260 | Required: False 261 | Position: 262 | Default value: 263 | Accept pipeline input: False 264 | Accept wildcard characters: False 265 | ``` 266 | 267 | ### -Parameter ExportIcon 268 | 269 | When passed, the Application icon is decoded from base64 and saved to the '$WorkingFolder\Icons' folder 270 | 271 | ```yaml 272 | Type: Switch 273 | Parameter Sets: (All) 274 | 275 | Required: False 276 | Position: 277 | Default value: 278 | Accept pipeline input: False 279 | Accept wildcard characters: False 280 | ``` 281 | 282 | ### -Parameter PackageApps 283 | 284 | Pass this parameter to package selected apps in the .intunewin format 285 | 286 | ```yaml 287 | Type: Switch 288 | Parameter Sets: (All) 289 | 290 | Required: False 291 | Position: 292 | Default value: 293 | Accept pipeline input: False 294 | Accept wildcard characters: False 295 | ``` 296 | 297 | ### -Parameter CreateApps 298 | 299 | Pass this parameter to create the Win32apps in Intune 300 | 301 | ```yaml 302 | Type: Switch 303 | Parameter Sets: (All) 304 | 305 | Required: False 306 | Position: 307 | Default value: 308 | Accept pipeline input: False 309 | Accept wildcard characters: False 310 | ``` 311 | 312 | ### -Parameter ResetLog 313 | 314 | Pass this parameter to reset the log file 315 | 316 | ```yaml 317 | Type: Switch 318 | Parameter Sets: (All) 319 | 320 | Required: False 321 | Position: 322 | Default value: 323 | Accept pipeline input: False 324 | Accept wildcard characters: False 325 | ``` 326 | 327 | ### -Parameter ExcludePMP 328 | 329 | Pass this parameter to exclude apps created by PMPC from the results. Filter is applied to Application "Comments". string can be modified in Get-AppList Function 330 | 331 | ```yaml 332 | Type: Switch 333 | Parameter Sets: (All) 334 | 335 | Required: False 336 | Position: 337 | Default value: 338 | Accept pipeline input: False 339 | Accept wildcard characters: False 340 | ``` 341 | 342 | ### -Parameter NoOgv 343 | 344 | When passed, the Out-Gridview is suppressed and the value entered for $AppName will be searched using Get-CMApplication -Fast 345 | 346 | ```yaml 347 | Type: Switch 348 | Parameter Sets: (All) 349 | 350 | Required: False 351 | Position: 352 | Default value: 353 | Accept pipeline input: False 354 | Accept wildcard characters: False 355 | ``` 356 | 357 | ### -Parameter AllowAvailableUninstall 358 | 359 | When passed, the AvailableUninstall value will be set to True when creating the Win32App 360 | 361 | ```yaml 362 | Type: Boolean 363 | Parameter Sets: (All) 364 | 365 | Required: False 366 | Position: 367 | Default value: 368 | Accept pipeline input: False 369 | Accept wildcard characters: False 370 | ``` 371 | 372 | ### -TenantId 373 | 374 | Tenant Id or name to connect to. 375 | 376 | ```yaml 377 | Type: String 378 | Parameter Sets: ClientSecret, ClientCertificateThumbprint, UseDeviceAuthentication, Interactive 379 | 380 | Required: True 381 | Position: 382 | Default value: 383 | Accept pipeline input: False 384 | Accept wildcard characters: False 385 | ``` 386 | 387 | ### -ClientId 388 | 389 | Client Id or name to connect to. Please create an App Registration in EntraId and avoid using the Microsoft Graph Command Line Tools client app. 390 | The client app must have the following API permission: DeviceManagementApps.ReadWrite.All. 391 | 392 | ```yaml 393 | Type: String 394 | Parameter Sets: ClientSecret, ClientCertificateThumbprint, UseDeviceAuthentication, Interactive 395 | 396 | Required: True 397 | Position: 398 | Default value: 399 | Accept pipeline input: False 400 | Accept wildcard characters: False 401 | ``` 402 | 403 | ### -ClientSecret 404 | 405 | If using a Client Secret for authentication, pass the secret here. 406 | Certificates are recommended for production environments. 407 | 408 | ```yaml 409 | Type: String 410 | Parameter Sets: ClientSecret 411 | 412 | Required: True 413 | Position: 414 | Default value: 415 | Accept pipeline input: False 416 | Accept wildcard characters: False 417 | ``` 418 | 419 | ### -ClientCertificateThumbprint 420 | 421 | Certificates are recommended for production environments. Pass the thumbprint of the certificate to use for authentication to the client app. 422 | You must have the private key and the public key must have been uploaded to the App Registration in Entra Id. 423 | 424 | ```yaml 425 | Type: String 426 | Parameter Sets: ClientCertificateThumbprint 427 | 428 | Required: True 429 | Position: 430 | Default value: 431 | Accept pipeline input: False 432 | Accept wildcard characters: False 433 | ``` 434 | 435 | ### -UseDeviceAuthentication 436 | 437 | Generates a token using device authentication. 438 | 439 | ```yaml 440 | Type: Switch 441 | Parameter Sets: UseDeviceAuthentication 442 | 443 | Required: True 444 | Position: 445 | Default value: 446 | Accept pipeline input: False 447 | Accept wildcard characters: False 448 | ``` 449 | 450 | ## Examples 451 | 452 | The following examples will export information from ConfigMgr but not create the Win32Apps in Intune. 453 | 454 | ``` 455 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" 456 | ``` 457 | ``` 458 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -DownloadContent 459 | ``` 460 | ``` 461 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -ExportLogo 462 | ``` 463 | ``` 464 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -ExportLogo -PackageApps 465 | ``` 466 | ``` 467 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -ExportLogo -PackageApps -ResetLog 468 | ``` 469 | ``` 470 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -ExportLogo -PackageApps -ResetLog -NoOGV 471 | ``` 472 | ``` 473 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -ExportLogo -PackageApps -ResetLog -ExcludePMPC 474 | ``` 475 | ``` 476 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -ExportLogo -PackageApps -ResetLog -ExcludePMPC -ExcludeFilter "Microsoft*" 477 | ``` 478 | ``` 479 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -ExportLogo -PackageApps -CreateApps -ResetLog -ExcludePMPC 480 | ``` 481 | 482 | ## Examples 2 483 | 484 | The following examples will export information from ConfigMgr and will create the Win32Apps in Intune. 485 | 486 | ``` 487 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -CreateApps ExportLogo -PackageApps -ResetLog -TenantId "yourtenant.onmicrosoft.com" -ClientId "yourclientid" -ClientSecret "yourclientsecret" 488 | ``` 489 | ``` 490 | New-Win32App -ProviderMachineName "SCCM1.byteben.com" -AppName "Microsoft Edge Chromium *" -CreateApps ExportLogo -PackageApps -ResetLog -TenantId "yourtenant.onmicrosoft.com" -ClientId "yourclientid" -ClientCertificateThumbprint "yourclientcertthumbprint" 491 | ``` 492 | ``` 493 | -------------------------------------------------------------------------------- /Release_Notes.md: -------------------------------------------------------------------------------- 1 | # Win32App Migration Tool - Release Notes 2 | 3 | ## 3.0.05 - Preview - 08/01/2025 4 | 5 | ✅ Fixed how we were handling scopes for Interactive authentication in Connect-MgGraphCustom function 6 | ✅ Updated README.md to indicate the requirement of the redirect URI for the Connect-MgGraphCustom function (should be http://localhost) 7 | ✅ Fixed MSI rule detection method in New-IntuneDetection function 8 | 9 | ## 3.0.04 - Preview - 08/01/2025 10 | 11 | ✅ Reverted to single app selection while investigating an issue with multiple app selection 12 | ✅ Updated License terms to Non-Commercial 13 | 14 | ## 3.0.03 - Preview - 05/01/2025 15 | 16 | ✅ Fixed unexpecetd -Message output from the New-FolderToCreate function 17 | ✅ Removed unnecessary PSBoundParameters tests from the New-IntuneDetection function 18 | ✅ Fixed an issue with Find-SettingReferences if a child operands node doesn't exist in Get-LocalDetectionMethods function. We now test if the node exists before attempting to access it 19 | ✅ Fixed an issue with New-IntuneDetection function where we didnt pass the correct datatype and value for a local setting detection method in some cases 20 | ✅ Fixed an issue with New-IntuneDetection function where we were not converting Int64 datatype to integer for the Win32 app JSON detection method 21 | ✅ Fixed an issue with New-IntuneDetection function where we were not converting the operator 'Equals' to 'equal' for the Win32 app JSON detection method 22 | ✅ Improved error handling in New-IntuneWin if we cant find the extension of the install command 23 | ✅ We now select installer technoilogy .exe if the command line finds a -match "\.exe" -and -match "msiexec". This is to handle the case where the install command is an EXE but the command line contains msiexec (strange - I agree) 24 | ✅ Fixed an issue with how we get the name for the .intunewin filename from the install command line. We now escape the extension type for splitting [regex]::Escape("$InstallTech") 25 | ✅ Fixed an issue where no value was passsed if the ConfigMgr deplopyment type did not have an uninstall command. This is mandatory for the Win32 app JSON 26 | 27 | ## 3.0.02 - Preview - 03/01/2025 28 | 29 | ✅ Fixed styling issue in New-FolderToCreate function 30 | ✅ Added support for MSI detection methods 31 | ✅ Fixed 32 | 33 | ## 3.0.01 - Preview - 01/01/2025 34 | 35 | ✅ Removed dependency on MSAL.PS and replaced with Microsoft.Graph.Authentication 36 | ✅ New Module Connect-MgGraphCustom to authenticate to the Microsoft Graph API 37 | ✅ New Module Test-MgGraphConnection to test the connection to the Microsoft Graph API and required scopes 38 | ✅ New Module Get-IntuneWinEncryptionDetails to get the encryption details of a .intunewin file 39 | ✅ New-Module Get-IntuneWinInfo to get metadata of a .intunewin file to build the Win32 app JSON 40 | ✅ New-Module Get-SasUri to generate a Sas Uri for uploading content to Azure Storage 41 | ✅ New-Module Initialize-Module to handle module initialization like Microsoft.Graph.Authentication and Az.Storage 42 | ✅ New-Module Invoke-IntuneContentCommit to commit content to Intune after uploading to Azure Storage 43 | ✅ New-Module Invoke-MgGraphRequestCustom to make CRUD requests to the Microsoft Graph API 44 | ✅ New-Module Invoke-StorageUpload to upload content to Azure Storage using the Az.Storage module 45 | ✅ New-Module New-IntuneDetection to create detection method JSON for a Win32 app 46 | ✅ New-Module New-IntuneFramework to create the body for requesting a Win32 app 47 | ✅ New-Module New-IntuneWinContentRequest to create a content request blob for commital of a Win32 app content 48 | ✅ New-Module Write-LogAndHost to write to both the log and console host thus removing duplication of the same Write-Log and Write-Host commands 49 | ✅ Fixed an issue where we were not handling the difference indicators test correctly when comparing the source and destination folders 50 | ✅ Improvement to when we download the Win32ContentPrepTool. We now only download if the packageapps parameter is passed and it has been more than 30 days since the last tool download 51 | ✅ Improvement to Get-ScriptEnd. We now test if there is an active session with Get-MgContext and offer to disconnect or leave the session connected 52 | ✅ Improvement in the Win32 app JSON creation. We now handle -AllowAvailableInstall ($true/$false) 53 | ✅ Improvement in the Win32 app JSON creation. Return Codes are now added in the default, expected, order 54 | 55 | ## 2.0.50 - BETA - 03/04/2024 56 | 57 | ✅ New Branch for 2.0.50 58 | ✅ Fixed a regex bug in New-IntuneWin.ps1 where the name of the .intunewin was not passed correctly if it contained multiple periods 59 | ✅ Renamed Connect-Graph module to Get-AuthToken. Using MSAL.PS so we can get the access token 60 | ✅ New Module Get-ClientCertificate to get the x509 blob from either the CurrentUser or LocalMachine for authentication 61 | ✅ New Module Invoke-GraphRequest to make Graph API requests 62 | ✅ New Module New-FailedMigration to log failed migrations in a global array 63 | ✅ New Module Get-FailedMigration to check for failed migration reasons 64 | ✅ New Module New-Win32AppFramework to create the body for requesting a Win32App 65 | ✅ New Module Get-NewIntuneWinContentRequest to create content for Win32app 66 | ✅ New Module Get-IntuneWinInfo to read metadata from an .intunewin file 67 | 68 | ## 2.0.20 - BETA - 23/03/2024 69 | 70 | ✅ Export Detection Method to file in the working folder 'DetectionMethods' folder 71 | ✅ Export Detection Method supporting information to Details\DeploymentTypes.csv 72 | ✅ Fix an incorrectly passed parameter for Get-ScriptEnd in Get-DeploymentTypeInfo.ps1 73 | ✅ Fix a bug where base64 icon data causes cmtrace to not parse the log line correctly. We now omit icondata from being logged 74 | ✅ Fix a bug where an incorrect parameter was passed when testing the SMS Provider connection 75 | ✅ New module Connect-Graph (in development) 76 | ✅ Fixed an issue outputting Detection Method Scripts to file. We now use the .NET method which is much more reliable than Out-File 77 | ✅ Fixed a bug where return $applicationTypes was not outside the ForEach loop and only returned a single application even if multiple were selected 78 | ✅ Fixed 79 | ✅ Fixed 80 | ✅ Updated Licence terms to GNU GENERAL PUBLIC LICENSE 81 | ✅ New module Get-LocalDetectionMethods to extract local detection methods (file/reg/msi) when detection is not a script 82 | ✅ New module New-IntuneDetectionMethod to create json for detection methods 83 | ✅ Fixed an issue where icon export was attempted even if the icon was not present 84 | 85 | ## 2.0.19 - BETA - 16/12/2023 86 | 87 | ✅ Fixed 88 | ✅ Fixed 89 | 90 | ## 2.0.18 - BETA - 25/11/2023 91 | 92 | ✅ Fixed 93 | ✅ Fixed 94 | ✅ Fixed 95 | 96 | ## 2.0.17 - BETA - 12/11/2023 97 | 98 | ✅ Faster code 99 | ✅ Complete refactor of code 100 | ✅ Better error handling 101 | ✅ Improved file structure so its easier to find app content 102 | ✅ Retain CSV data each run 103 | ✅ Option to rename intunewin to a custom name 104 | ✅ We now track uninstall content if its different from the install content 105 | ✅ Compare files on copy to ensure we grab all the correct source content 106 | ✅ Progress on file copy (useful for when dealing with larger files) 107 | ✅ Reduce the number of times we call Get-CMApplication to speed run-time 108 | ✅ Application, Deployment Type and Content details stored in different arrays. We call them multiple times and its quicker to split this out 109 | ✅ Improved Logging - we now support log entry that is compatible with cmtrace 110 | ✅ Single instance storage of icons 111 | ✅ Win32ContentPrep tool is always re-downloaded at run-time if the packageapps parameter is passed 112 | ✅ Added option to exclude specific apps using a filter 113 | 114 | ## 1.103.12.01 - BETA - 12/03/2022 115 | 116 | ✅ Added UTF8 Encoding for CSV Exports 117 | ✅ Added option to exclude PMPC apps 118 | ✅ Added option to exclude specific apps using a filter 119 | 120 | ## 1.08.29.02 - BETA - 29/08/2021 121 | 122 | ✅ Fixed an issue where logos were not being exported 123 | ✅ Fixed an issue where the Localized Display Name was not outputed correctly 124 | 125 | ## 1.08.29.01 - BETA - 29/08/2021 126 | 127 | ✅ Default to not copy content locally. 128 | ✅ Use -DownloadContent switch to copy content to local working folder 129 | ✅ Fixed an issue when the source content folder has a space in the path 130 | 131 | ## 1.03.27.02 - BETA - 27/03/2021 132 | 133 | ✅ Fixed a grammar issue when creating the Working Folders 134 | 135 | ## 1.03.25.01 - BETA - 25/03/2021 136 | 137 | ✅ Removed duplicate name in message for successful .intunewin creation 138 | ✅ Added a new switch "-NoOGV" which will suppress the Out-Grid view. Thanks @philschwan 139 | ✅ Fixed an issue where the -ResetLog parameter was not working 140 | 141 | ## 1.03.23.01 - BETA - 23/03/2021 142 | 143 | ✅ Error handling improved when connecting to the Site Server and passing a Null app name 144 | 145 | ## 1.03.22.01 - BETA - 22/03/2021 146 | 147 | ✅ Updates Manifest to only export New-Win32App Function 148 | 149 | ## 1.03.21.04 - BETA - 21/03/2021 150 | 151 | ✅ Fixed RootModule issue in psm1 152 | 153 | ## 1.03.21.03 - BETA - 21/03/2021 154 | 155 | ✅ Fixed Function error for New-Win32App 156 | 157 | ## 1.03.21.01 - BETA - 21/03/2021 158 | 159 | ✅ Added to PSGallery and converted to Module 160 | 161 | ## 1.03.20.01 - BETA - 20/03/2021 162 | 163 | ✅ Added support for .vbs script installers 164 | ✅ Fixed logic error for string matching 165 | 166 | ## 1.03.19.01 - BETA - 19/03/2021 167 | 168 | ✅ Added Function Get-ScriptEnd 169 | 170 | ## 1.03.18.03 - BETA - 18/03/2021 171 | 172 | ✅ Fixed an issue where Intunewin SetupFile was being detected as an .exe when msiexec was present in the install command 173 | 174 | ## 1.03.18.02 - BETA - 18/03/2021 175 | 176 | ✅ Removed the character " from SetupFile command when an install command is wrapped in double quotes 177 | 178 | ## 1.03.18.01 - BETA - 18/03/2021 179 | 180 | ✅ Robocopy for content now padding Source and Destination variables if content path has white space 181 | ✅ Deployment Type Count was failing from the SDMPackageXML. Using the measure tool to check if Deployment Types exist for an Application 182 | ✅ Removed " from SetupFile command if install commands are in double quotes 183 | 184 | ## 1.03.18 - BETA - 18/03/2021 185 | 186 | ✅ Release for Testing 187 | ✅ Logging Added 188 | 189 | ## 1.0 - DEV - 14/03/2021 190 | 191 | ✅ DEV Release 192 | -------------------------------------------------------------------------------- /Win32AppMigrationTool.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | 3 | # Script module or binary module file associated with this manifest. 4 | RootModule = 'Win32AppMigrationTool.psm1' 5 | 6 | # Version number of this module. 7 | ModuleVersion = '3.0.05' 8 | 9 | # Supported PSEditions 10 | # CompatiblePSEditions = @() 11 | 12 | # ID used to uniquely identify this module 13 | GUID = '0d9d68b0-02da-4209-b1e2-7fa64124b45f' 14 | 15 | # Author of this module 16 | Author = 'Ben Whitmore' 17 | 18 | # Company or vendor of this module 19 | CompanyName = 'Byteben' 20 | 21 | # Copyright statement for this module 22 | Copyright = '(c) Ben Whitmore. All rights reserved.' 23 | 24 | # Description of the functionality provided by this module 25 | Description = 'Win32AppMigrationTool is designed to export the Application and Deployment Data from ConfigMgr to firstly create an .intunewin file and secondly publish the Win32App to Intune' 26 | 27 | # Minimum version of the Windows PowerShell engine required by this module 28 | PowerShellVersion = '5.1' 29 | 30 | # Name of the Windows PowerShell host required by this module 31 | # PowerShellHostName = '' 32 | 33 | # Minimum version of the Windows PowerShell host required by this module 34 | # PowerShellHostVersion = '' 35 | 36 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 37 | # DotNetFrameworkVersion = '' 38 | 39 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 40 | # CLRVersion = '' 41 | 42 | # Processor architecture (None, X86, Amd64) required by this module 43 | # ProcessorArchitecture = '' 44 | 45 | # Modules that must be imported into the global environment prior to importing this module 46 | # RequiredModules = @() 47 | 48 | # Assemblies that must be loaded prior to importing this module 49 | # RequiredAssemblies = @() 50 | 51 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 52 | # ScriptsToProcess = @() 53 | 54 | # Type files (.ps1xml) to be loaded when importing this module 55 | # TypesToProcess = @() 56 | 57 | # Format files (.ps1xml) to be loaded when importing this module 58 | # FormatsToProcess = @() 59 | 60 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 61 | # NestedModules = @() 62 | 63 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 64 | FunctionsToExport = @('New-Win32App', 'Test-MgConnection', 'Connect-MgGraphCustom') 65 | 66 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 67 | CmdletsToExport = @() 68 | 69 | # Variables to export from this module 70 | VariablesToExport = '*' 71 | 72 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 73 | AliasesToExport = @() 74 | 75 | # DSC resources to export from this module 76 | # DscResourcesToExport = @() 77 | 78 | # List of all modules packaged with this module 79 | ModuleList = @() 80 | 81 | # List of all files packaged with this module 82 | FileList = @() 83 | 84 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 85 | PrivateData = @{ 86 | 87 | PSData = @{ 88 | 89 | # Tags applied to this module. These help with module discovery in online galleries. 90 | Tags = @('Win32AppMigrationTool', 'New-Win32App', 'Intune', 'Migrate') 91 | 92 | #Prerelease = 'beta' 93 | 94 | # A URL to the license for this module. 95 | # LicenseUri = '' 96 | 97 | # A URL to the main website for this project. 98 | ProjectUri = 'https://github.com/byteben/Win32App-Migration-Tool' 99 | 100 | # A URL to an icon representing this module. 101 | # IconUri = '' 102 | 103 | # ReleaseNotes of this module 104 | # ReleaseNotes = '' 105 | 106 | # External dependent modules of this module 107 | # ExternalModuleDependencies = '$ENV:SMS_ADMIN_UI_PATH\..\ConfigurationManager.psd1' 108 | 109 | } # End of PSData hashtable 110 | 111 | } # End of PrivateData hashtable 112 | 113 | # HelpInfo URI of this module 114 | HelpInfoURI = 'https://msendpointmgr.com/2021/03/27/automatically-migrate-applications-from-configmgr-to-intune-with-the-win32app-migration-tool/' 115 | 116 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 117 | # DefaultCommandPrefix = '' 118 | 119 | } 120 | -------------------------------------------------------------------------------- /Win32AppMigrationTool.psm1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Created on: 16/12/2023 4 | Created by: Ben Whitmore 5 | Filename: Win32AppMigrationTool.psm1 6 | 7 | .Description 8 | Win32App Packaging Tool Function Import 9 | #> 10 | 11 | $PublicFunctions = @( Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -Recurse -ErrorAction SilentlyContinue ) 12 | $PrivateFunctions = @( Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -Recurse -ErrorAction SilentlyContinue ) 13 | 14 | foreach ($GetFunction in @($PublicFunctions + $PrivateFunctions)) { 15 | try { 16 | . $GetFunction.FullName 17 | } 18 | catch { 19 | Write-Error -Message ("Failed to import function '{0}'" -f $GetFunction.FullName) 20 | throw 21 | } 22 | } --------------------------------------------------------------------------------