Skip to content

Feature suggestion: Add handling for invalid install commands and content locations #41

@XxMrTyronexX

Description

@XxMrTyronexX

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions