-
Notifications
You must be signed in to change notification settings - Fork 21
Description
Ran into a few issues when trying to use the script to iterate over multiple applications, most of which I've addressed in my Pull Request. Just a couple of things which I wasn't sure if you wanted added to the script:
Check if content location exists before attempting to use IntuneWinAppUtil:
If -DownloadContent isn't specified, New-IntuneWin will attempt to pass a non-existent content location into the IntuneWinAppUtil and end the script - In my wrapper script I have added this check before running the Migration Tool:
# Checking if any of the content locations are missing
$contentLocations = Get-DeploymentContentLocations -app $app
$nonExistantLocations = (($contentLocations) | Where-Object { -not (Test-Path $_.ContentLocation) })
if ($nonExistantLocations) {
foreach ($loc in $nonExistantLocations) {
Write-Host "Unable to export $appName - $($loc.ContentLocation) does not exist!" -ForegroundColor Red
}
throw "One or more content locations missing, please see logs for more details."
}function Get-DeploymentContentLocations {
param (
$app
)
[xml]$xml = $app.SDMPackageXML
$results = @()
foreach ($dt in $xml.AppMgmtDigest.DeploymentType) {
$dtName = $dt.Title.'#text'
$contents = $dt.Installer.Contents.Content
if ($contents) {
foreach ($content in $contents) {
$location = $content.Location
if ($location) {
$results += [pscustomobject]@{
DeploymentType = $dtName
ContentLocation = $location
}
}
}
}
else {
$results += [pscustomobject]@{
DeploymentType = $dtName
ContentLocation = $null
}
}
}
return $results
}Handle invalid SCCM install/uninstall Powershell commands with custom error:
In our SCCM Server, some of the install commands are (wrongly) "install.ps1" instead of "Powershell.exe -File '.\install.ps1'", when passed into the migration tool, it fails at "No matching extension found." and ends the script - In my wrapper script I added a check in my script to confirm the Powershell command is valid before proceeding - Make it easier for me to know why my script is failing:
# Checking for invalid powershell install commands
$commands = Get-DeploymentCommands -app $app
foreach($cmd in $commands){
$installCommand = $cmd.installCommand
$UninstallCommand = $cmd.UninstallCommand
$deploymentType = $cmd.DeploymentType
if (!(Test-PowerShellInstallCommand $installCommand)) {
Write-Host "Invalid Powershell install command detected on '$appName' - '$deploymentType'. Try changing '$installCommand' to 'powershell.exe -File $('".\' + $installCommand.Replace('"', '') + '"')'" -ForegroundColor Red
throw "One or more install/uninstall commands are invalid, please see logs for more details."
}
if(!(Test-PowerShellInstallCommand $uninstallCommand)){
Write-Host "Invalid Powershell ninstall command detected on '$appName' - '$deploymentType'. Try changing '$UninstallCommand' to 'powershell.exe -File $('".\' + $UninstallCommand.Replace('"', '') + '"')'" -ForegroundColor Red
throw "One or more install/uninstall commands are invalid, please see logs for more details."
}
}function Get-DeploymentCommands {
param ($app)
[xml]$xml = $app.SDMPackageXML
$results = @()
foreach ($dt in $xml.AppMgmtDigest.DeploymentType) {
$dtName = $dt.Title.'#text'
$installCommand = $dt.Installer.InstallAction.Args.Arg | Where-Object { $_.Name -eq 'InstallCommandLine' } | Select-Object -ExpandProperty '#text'
$uninstallCommand = $dt.Installer.InstallAction.Args.Arg | Where-Object { $_.Name -eq 'UninstallCommandLine' } | Select-Object -ExpandProperty '#text'
$results += [PSCustomObject]@{
DeploymentType = $dtName
InstallCommand = $installCommand
UninstallCommand = $uninstallCommand
}
}
return $results
}
function Test-PowerShellInstallCommand {
param(
[string]$InstallCommand
)
# Trim spaces and quotes for safety
$cmd = $InstallCommand.Trim('"').Trim()
if ($cmd -match '\.ps1\b') {
if ($cmd -notmatch 'powershell(\.exe)?|-File|pwsh(\.exe)?') {
return $false # Command is just the PS1 file, won't work
} else {
return $true # Command is wrapped in PowerShell, okay
}
}
return $true # Anything else we assume is fine
}Don't Exit the script and failed apps in a separate file so users can keep track of which Apps failed
When the script fails, it exits so it does not proceed with the next export - it's a bit tricky when performing a large export since you need to restart the export if one of the apps fail
A good solution would be to write the error and app that got skipped to a log file and continue with the next export to save having to restart the script after amending each error - I handled it like this in my wrapper script so I could go back on the list of failed apps, fix them, and re-run the script only for those ones
Add a function skip apps which have already been exported
When the script exits, it has to be restarted - In my wrapper script all of the successful exports are written to a text file, so if an error is not caught by my script, when I re-run the migration tool, it prompts with the option to skip the apps in the text file (which have already been exported)
Full Wapper Script (multithreadded)
Below is my full wrapper script which:
- Prompts to for the user to do any of the following:
- Select a text file containing a list of app names to export
- Enter the name of an SCCM Folder containing the apps to export
- Manually enter the Application Name for the app to export
- Checks for a list of the already exported apps in the working directory and asks the user if they would like to continue or restart
- Loops over all of the provided apps and opens a thread to export each of the Apps
- Checks the install/uninstall command is valid and content location exists
- Creates lists of apps successfully and unsuccessfully exported
- If the above checks fail or the New-Win32App exits, it will catch the error thrown, and log the error and app that threw it
# ------------------------------------ Run as Admin ------------------------------------
function runningAsAdmin {
$user = [Security.Principal.WindowsIdentity]::GetCurrent();
(New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}
if (-Not (runningAsAdmin)) {
Start-Process powershell -verb runas -ArgumentList "$('-file') $('"'+ $PSCommandPath +'"')"
Exit
}
# --------------------------------- Import ConfigMgr -----------------------------------
Try {
Import-Module (Join-Path (Split-Path $env:SMS_ADMIN_UI_PATH) ConfigurationManager.psd1) -ErrorAction Stop
Import-Module "$PSScriptRoot\Win32App-Migration-Tool-main\Win32AppMigrationTool.psd1"
}
Catch {
Write-Output "Unable to import modules! Please fix and re-run this script."
Exit
}
# --------------------------------- Functions ------------------------------------------
function show-CheckBoxes {
param (
[string]$title = "Select Options",
[string]$groupBoxTitle = "Options",
[int]$width = 300,
[int]$formPadding = 30,
[int]$buttonSpacing = 30,
[int]$doneButtonHeight = 25,
[int]$doneButtonWidth = 75,
[array]$options
)
$height = $doneButtonHeight*2 + ($buttonSpacing * ($options.Count+1)) + $formPadding*2
$form = New-Object System.Windows.Forms.Form
$form.Text = $title
$form.StartPosition = "CenterScreen"
$groupBox = New-Object System.Windows.Forms.GroupBox
$groupBox.Text = "$groupBoxTitle"
$groupBox.Size = New-Object System.Drawing.Size(($width-($formPadding*2)), ($height-(($formPadding*2) + $doneButtonHeight*2)))
$groupBox.Location = New-Object System.Drawing.Point(($formPadding/1.4), ($formPadding/1.5))
$form.Controls.Add($groupBox)
$checkBoxArray = @()
for($i = 0; $i -lt $options.Count; $i++){
$checkBox = New-Object System.Windows.Forms.CheckBox
$checkBox.Text = $options[$i]
$checkBox.AutoSize = $true
$checkBox.Location = New-Object System.Drawing.Point(10, (($buttonSpacing*$i)+($buttonSpacing/1.5)))
$groupBox.Controls.Add($checkBox)
$checkBoxArray += $checkBox
}
$okButton = New-Object System.Windows.Forms.Button
$okButton.Text = "OK"
$okButton.Size = New-Object System.Drawing.Size($doneButtonWidth, $doneButtonHeight)
$okButton.Location = New-Object System.Drawing.Point((($width/2)-($doneButtonWidth/1.7)), ($height-($formPadding + $doneButtonHeight*2)))
$okButton.Add_Click({
if(($checkBoxArray | Where-Object {$_.Checked})){
$form.Close()
} else {
[System.Windows.Forms.MessageBox]::Show("No options selected, please select at least one.")
}
})
$form.Controls.Add($okButton)
$form.Size = New-Object System.Drawing.Size($width, $height)
$form.Add_Shown({$form.Activate()})
$form.ShowDialog() | Out-Null
return ($checkBoxArray | Where-Object {$_.Checked}).Text
}
Function Get-FileName($initialDirectory="", $title="open"){
[System.Reflection.Assembly]::LoadWithPartialName('System.windows.forms') |
Out-Null
$OpenFileDialog = New-Object System.Windows.Forms.OpenFileDialog
$OpenFileDialog.initialDirectory = $initialDirectory
$OpenFileDialog.title = $title
$OpenFileDialog.filter = "Text File (*.txt)| *txt| All files (*.*)| *.*"
$OpenFileDialog.ShowDialog() | Out-Null
$OpenFileDialog.filename
}
# ----------------------------------- Main ---------------------------------------------
Set-Location $PSScriptRoot
# Configuration
$Provider = "SERVERNAME"
$SiteCode = Get-WmiObject -ComputerName $Provider -Namespace "root\SMS" -Class "SMS_ProviderLocation" | Select-Object -ExpandProperty SiteCode
$Namespace = "ROOT\SMS\Site_$SiteCode"
$WorkDir = "C:\Win32AppMigrationTool"
$runLocation = $PSScriptRoot
$IntuneWinAppUtil = "$PSScriptRoot\IntuneWinAppUtil.exe"
$IntuneWinAppUtilDir = "$WorkDir\ContentPrepTool"
$appsInFolder = @()
$appList = @()
# Defining Log Locations
$nonExistantLocationLog = "$WorkDir\skippedExports.log"
$skippedAppsList = "$WorkDir\skippedAppsList.txt"
$processedAppsList = "$WorkDir\processedAppsList.txt"
$nonExistantLocationLogLock = New-Object object
$skippedAppsListLock = New-Object object
$processedAppsListLock = New-Object object
$selectedOptions = show-CheckBoxes -title "Select where to get apps" -groupBoxTitle "Options (can select multiple)" -options @("Custom list (txt file)", "SCCM Folder", "Individual App")
Set-Location "$($SiteCode):"
# Getting user input for application name and checking if exists in SCCM
if ($selectedOptions -contains "Individual App") {
$invalidAppCount = 0
while($true){
$enteredAppName = Read-Host "Enter application name"
if($enteredAppName -ne ""){
$app = Get-CMApplication -Name $enteredAppName -Fast -ErrorAction SilentlyContinue
} elseif ($invalidAppCount -gt 0) {
Break
}
if ($app) {
$appList += $enteredAppName
Break
} else {
Write-Host "App does not exist in SCCM try again or press enter to skip" -ForegroundColor Red
$invalidAppCount++
}
}
}
# Getting text file containing list of application names
if ($selectedOptions -contains "Custom list (txt file)") {
$appList += Get-Content (Get-FileName -title "Select application list" -initialDirectory $runLocation) | ForEach-Object { $_.Trim() }
}
# Getting user input for SCCM Application folder and checking if exists
if ($selectedOptions -contains "SCCM Folder") {
$invalidFolderCount = 0
while($true){
$FolderName = Read-Host "Enter folder name"
if($FolderName -ne ""){
# Get Tier 1 folder ID (ObjectType = 6000 for Application folders)
$query = "SELECT ContainerNodeID FROM SMS_ObjectContainerNode WHERE ObjectType='6000' AND Name='$FolderName'"
$ContainerNode = Get-WmiObject -ComputerName $Provider -Namespace $Namespace -Query $query
$FolderID = $ContainerNode.ContainerNodeID
} elseif ($invalidFolderCount -gt 0) {
Break
}
# Get all apps in folder
if ($FolderID) {
Write-Host "Getting all app ids in '$FolderName'" -ForegroundColor Green
$appsInFolder = Get-WmiObject -ComputerName $Provider -Namespace $Namespace -Class SMS_ObjectContainerItem `
| Where-Object { $_.ContainerNodeID -eq $FolderID -and $_.ObjectType -eq 6000 }
Break
} else {
Write-Host "Folder does not exist in SCCM try again or press enter to skip" -ForegroundColor Red
$invalidFolderCount++
}
}
$appList += $appsInFolder
}
# Create folder if it doesn't exist
if (-not (Test-Path -Path $IntuneWinAppUtilDir)) {
New-Item -Path $IntuneWinAppUtilDir -ItemType Directory | Out-Null
}
Write-Host "Copying IntuneWinAppUtil.exe to working directory" -ForegroundColor Green
Copy-Item -Path $IntuneWinAppUtil -Destination "$IntuneWinAppUtilDir\IntuneWinAppUtil.exe" -Force
# Remove log if it exists
if (Test-Path $nonExistantLocationLog) {
Remove-Item $nonExistantLocationLog -Force
}
# Remove log if it exists
if (Test-Path $skippedAppsList) {
Remove-Item $skippedAppsList -Force
}
# Checking if user wants to continue from last time
$processedApps = @()
if (Test-Path $processedAppsList) {
While($true){
$continue = Read-Host "Continue from last time? (y/n)"
if($continue -eq "y"){
$processedApps = Get-Content $processedAppsList
Break
} elseif($continue -eq "n"){
Remove-Item $processedAppsList -Force
Break
} else {
Write-Host "Invalid inuput" -ForegroundColor Red
}
}
}
# Initializing the Runspace Pool
$pool = [RunspaceFactory]::CreateRunspacePool(1, [int]$env:NUMBER_OF_PROCESSORS + 1)
$pool.ApartmentState = "MTA"
$pool.Open()
$runspaces = @()
# Building the multithreaded script
$scriptblock = {
Param (
$item,
$appsInFolder,
$runLocation,
$workDir,
$processedApps,
$nonExistantLocationLog,
$skippedAppsList,
$processedAppsList,
$nonExistantLocationLogLock,
$skippedAppsListLock,
$processedAppsListLock,
$provider,
$siteCode
)
function Write-ThreadLog() {
param ($message, $path, $lock)
try {
[System.Threading.Monitor]::Enter($lock)
Add-Content -Path $path -Value ("[{0}] {1}" -f (Get-Date -Format "HH:mm:ss"), $message)
}
finally {
[System.Threading.Monitor]::Exit($lock)
}
}
function Write-ToFile() {
param ($message, $path, $lock)
try {
[System.Threading.Monitor]::Enter($lock)
Add-Content -Path $path -Value ($message)
}
finally {
[System.Threading.Monitor]::Exit($lock)
}
}
function Get-DeploymentContentLocations {
param (
$app
)
[xml]$xml = $app.SDMPackageXML
$results = @()
foreach ($dt in $xml.AppMgmtDigest.DeploymentType) {
$dtName = $dt.Title.'#text'
$contents = $dt.Installer.Contents.Content
if ($contents) {
foreach ($content in $contents) {
$location = $content.Location
if ($location) {
$results += [pscustomobject]@{
DeploymentType = $dtName
ContentLocation = $location
}
}
}
}
else {
$results += [pscustomobject]@{
DeploymentType = $dtName
ContentLocation = $null
}
}
}
return $results
}
function Get-DeploymentCommands {
param ($app)
[xml]$xml = $app.SDMPackageXML
$results = @()
foreach ($dt in $xml.AppMgmtDigest.DeploymentType) {
$dtName = $dt.Title.'#text'
$installCommand = $dt.Installer.InstallAction.Args.Arg | Where-Object { $_.Name -eq 'InstallCommandLine' } | Select-Object -ExpandProperty '#text'
$uninstallCommand = $dt.Installer.InstallAction.Args.Arg | Where-Object { $_.Name -eq 'UninstallCommandLine' } | Select-Object -ExpandProperty '#text'
$results += [PSCustomObject]@{
DeploymentType = $dtName
InstallCommand = $installCommand
UninstallCommand = $uninstallCommand
}
}
return $results
}
function Test-PowerShellInstallCommand {
param(
[string]$InstallCommand
)
# Trim spaces and quotes for safety
$cmd = $InstallCommand.Trim('"').Trim()
if ($cmd -match '\.ps1\b') {
if ($cmd -notmatch 'powershell(\.exe)?|-File|pwsh(\.exe)?') {
return $false # Command is just the PS1 file, won't work
} else {
return $true # Command is wrapped in PowerShell, okay
}
}
return $true # Anything else we assume is fine
}
function Test-IsMSPInstaller {
param (
[string]$CommandLine
)
# Normalize command (remove leading/trailing spaces)
$cmd = $CommandLine.Trim()
# Match msiexec with /p and an .msp file
if ($cmd -match '\.msp') {
return [PSCustomObject]@{
IsMSP = $true
MSPFile = $matches[1]
CommandRaw = $CommandLine
}
}
else {
return [PSCustomObject]@{
IsMSP = $false
MSPFile = $null
CommandRaw = $CommandLine
}
}
}
try {
# Importing modules into this Thread
Import-Module (Join-Path (Split-Path $env:SMS_ADMIN_UI_PATH) ConfigurationManager.psd1) -ErrorAction Stop
Import-Module "$runLocation\Win32App-Migration-Tool-main\Win32AppMigrationTool.psd1"
Set-Location "$($SiteCode):"
# Getting the app based on whether we were provided the instance key or the application name
if ($appsInFolder -contains $item) { $app = Get-CMApplication -ModelName $item.InstanceKey }
else { $app = Get-CMApplication -Name $item }
$appName = $app.LocalizedDisplayName
$contentLocations = Get-DeploymentContentLocations -app $app
$commands = Get-DeploymentCommands -app $app
$exportSatisfied = $true
# Skipping apps that have already been checked
if($processedApps -contains $appName) {
[pscustomobject]@{
AppName = $appName
Success = $true
Skipped = $true
Error = $null
}
Return
}
Set-Location $runLocation # Location must not be the SCCM share for "test-path" to work
$nonExistantLocations = (($contentLocations) | Where-Object { -not (Test-Path $_.ContentLocation) })
Write-Host ""
Write-Host ""
Write-Host "Exporting $appName" -ForegroundColor Green
# Checking for invalid powershell install commands
foreach($cmd in $commands){
$installCommand = $cmd.installCommand
$UninstallCommand = $cmd.UninstallCommand
$deploymentType = $cmd.DeploymentType
if (!(Test-PowerShellInstallCommand $installCommand)) {
$message = "Invalid Powershell install command detected on '$appName' - '$deploymentType'. Try changing '$installCommand' to 'powershell.exe -File $('".\' + $installCommand.Replace('"', '') + '"')'"
Write-ThreadLog $message $nonExistantLocationLog $nonExistantLocationLogLock
$exportSatisfied = $false
throw "One or more install/uninstall commands are invalid, please see logs for more details."
}
if(!(Test-PowerShellInstallCommand $uninstallCommand)){
$message = "Invalid Powershell ninstall command detected on '$appName' - '$deploymentType'. Try changing '$UninstallCommand' to 'powershell.exe -File $('".\' + $UninstallCommand.Replace('"', '') + '"')'"
Write-ThreadLog $message $nonExistantLocationLog $nonExistantLocationLogLock
$exportSatisfied = $false
throw "One or more install/uninstall commands are invalid, please see logs for more details."
}
}
# Checking if any of the content locations are missing
if ($nonExistantLocations) {
# Logging missing locations
foreach ($loc in $nonExistantLocations) {
Write-Host "Unable to export $appName - $($loc.ContentLocation) does not exist!" -ForegroundColor Red
Write-ThreadLog "$appName - $($loc.DeploymentType) - $($loc.ContentLocation) does not exist" $nonExistantLocationLog $nonExistantLocationLogLock
}
$exportSatisfied = $false
throw "One or more content locations missing, please see logs for more details."
}
if($exportSatisfied){
New-Win32App `
-ProviderMachineName $Provider `
-SiteCode $SiteCode `
-AppName $appName `
-NoOGV `
-ResetLog `
-logFileName $appName `
-ExportIcon `
-DownloadContent `
-PackageApps `
-WorkingFolder $WorkDir `
-Win32ContentPrepToolUri "$IntuneWinAppUtilDir\IntuneWinAppUtil.exe" `
-Win32ContentPrepToolSilentMode
Write-ToFile $appName $processedAppsList $skippedAppsListLock
# Returning the status of this thread
[pscustomobject]@{
AppName = $appName
Success = $true
Skipped = $false
Error = $null
}
} else {
Write-ToFile $appName $skippedAppsList $skippedAppsListLock
}
}
catch {
# If New-Win32App tool kills this runspace (fails) the error will get logged
Write-ThreadLog "Error exporting $($appName): $($_.Exception.Message)" $nonExistantLocationLog $nonExistantLocationLogLock
Write-ToFile $appName $skippedAppsList $skippedAppsListLock
# Returning the error thrown by this thread
[pscustomobject]@{
AppName = $appName
Success = $false
Skipped = $true
Error = $($_.Exception.Message)
}
}
}
# Exporting
$iteration = 0
Write-Progress -PercentComplete 0 -Activity "Initialising Threads" -Status "0% ($totalCompletedThreads/$($appList.Count))"
foreach ($item in $appList) {
$runspace = [PowerShell]::Create()
$null = $runspace.AddScript($scriptblock)
$null = $runspace.AddArgument($item)
$null = $runspace.AddArgument($appsInFolder)
$null = $runspace.AddArgument($runLocation)
$null = $runspace.AddArgument($WorkDir)
$null = $runspace.AddArgument($processedApps)
$null = $runspace.AddArgument($nonExistantLocationLog)
$null = $runspace.AddArgument($skippedAppsList)
$null = $runspace.AddArgument($processedAppsList)
$null = $runspace.AddArgument($nonExistantLocationLogLock)
$null = $runspace.AddArgument($skippedAppsListLock)
$null = $runspace.AddArgument($processedAppsListLock)
$null = $runspace.AddArgument($Provider)
$null = $runspace.AddArgument($siteCode)
$runspace.RunspacePool = $pool
$runspaces += [PSCustomObject]@{ Pipe = $runspace; Status = $runspace.BeginInvoke() }
$iteration++
$initialisationProgress = ($iteration/$appList.Count)
Write-Progress -PercentComplete $($initialisationProgress*100) -Activity "Initialising Threads" -Status "$([Math]::Round($initialisationProgress*100))% ($totalCompletedThreads/$($appList.Count))"
}
# output right to pipeline / console
$totalCompletedThreads = 0
Write-Progress -PercentComplete 0 -Activity "Exporting Apps" -Status "0% ($totalCompletedThreads/$($appList.Count))"
while (@($runspaces | Where-Object { $_.Status -ne $null }).Count -gt 0) {
$completed = $runspaces | Where-Object { $_.Status -ne $null -and $_.Status.IsCompleted }
# Outputting return values from completed threads
foreach ($runspace in $completed) {
$totalCompletedThreads++
$progress = $totalCompletedThreads/$appList.Count
try {
$returnValue = $runspace.Pipe.EndInvoke($runspace.Status) # Any object output to the console is returned here
if($returnValue.Success -and !$returnValue.Skipped){
$exportStatusMessage = "$($returnValue.appName) completed successfully"
} elseif ($returnValue.Success -and $returnValue.Skipped) {
$exportStatusMessage = "$($returnValue.appName) skipped - Already exported"
} elseif (!$returnValue.Success) {
$exportStatusMessage = "Error thrown by $($returnValue.appName): $($returnValue.Error)"
Write-Host $exportStatusMessage -ForegroundColor Red
}
} catch {
Write-Host "Error in runspace: $_" -ForegroundColor Red
}
$runspace.Status = $null
Write-Progress -PercentComplete $($progress*100) -Activity $exportStatusMessage -Status "$([Math]::Round($progress*100))% ($totalCompletedThreads/$($appList.Count))"
}
Start-Sleep -Milliseconds 200
}
Write-Progress -Activity "Export Complete" -Completed
$pool.Close()
$pool.Dispose()
Write-Host ""
Write-Host ""
Write-Host "Finished exporting to $workDir" -ForegroundColor Green