PowerShell Studio: Creating Responsive Forms
- Details
- Written by David Corrales
- Last Updated: 20 April 2016
- Created: 16 May 2012
- Hits: 44029
When working with GUIs you may have noticed that the Form can freeze when running long scripts. Previously I discussed how to make your loops more responsive in this article, but not every long script comes in the form of a loop. If you truly want to make your forms responsive, you will need to move these slow scripts into another thread and in the PowerShell world this means using jobs.
For those of you who aren’t familiar with PowerShell Jobs, they allow you to run scripts while freeing up the console to perform other tasks. In this case it will free up the GUI and allow it to respond to user input. This article will not cover the ins and outs of jobs and it expects the user has some basic knowledge of the jobs mechanism in PowerShell. For your convenience we will list the cmdlets that are directly related to jobs. Please refer to the MSDN PowerShell Jobs help page for more information.
Job Cmdlets:
Starts a background job on a local computer.
Gets the background jobs that were started in the current session.
Gets the results of background jobs.
Stops a background job.
Deletes a background job.
Suppresses the command prompt until one or all jobs are complete.
Caveats when using Jobs and Forms
There are two caveats you need to keep in mind when using jobs within a Form.
1. Never access or modify form controls directly from within a job. If you need to update a form control or show progress, use the Receive-Job cmdlet to gather any necessary information from the job first and then update the control from the main script. The form controls do not allow themselves to be accessed from a different thread (i.e., the job).
2. Don’t use Register-ObjectEvent cmdlet. To determine if a job is complete you will need to check the status of the job. If you try to register an event handler for the job’s StateChanged event using Register-ObjectEvent, you find that it will not seem to trigger while the form is displayed, unless you call the [System.Windows.Forms.Application]::DoEvents() method mentioned in the Creating Responsive Loops article.
For example:
Register-ObjectEvent -InputObject $Job -EventName StateChanged ` -Action { Write-Host 'State Changed' $form1.WindowState = 'Minimized' #Will not work #Handle the events so the message will display [System.Windows.Forms.Application]::DoEvents() }
Even with this work around you cannot access the form controls directly. Therefore, you will need to use a Timer control to check the job’s status periodically.
Creating a Form that utilizes Jobs
Now that we covered the caveats, we can now begin to modify our forms so that it can handle jobs.
Version 3.0.3 of PowerShell Studio has a Control Set called “Button – Start Job”. If you look at the control set it inserts a button and a timer. The timer checks the status of a job that is created when the button is pressed.
Button Click Event:
The button creates a job, starts the timer and uses the tag property of the timer to track it.
$buttonStart_Click={ #TODO: Set a script block or specify a script path $jobScript = { for($i = 0; $i -lt 50; $i++){ Start-Sleep -Milliseconds 100 } } try { $job = Start-Job -ScriptBlock $jobScript #$job = Start-Job -FilePath <script path> $buttonStart.Enabled = $false $buttonStart.ImageIndex = 0 $timerCheckJob.Tag = $job $timerCheckJob.Start() } catch [Exception] { Write-Debug $_ #Stop the Timer $buttonStart.ImageIndex = -1 $buttonStart.Enabled = $true $timerCheckJob.Tag = $null $timerCheckJob.Stop() } }
Timer Tick Event:
The Timer checks its Tag property, which contains the job object and checks the job’s State property to see if it is still running. If the job is complete, it stops the timer and enables the button, otherwise it continue to animate the button.
$timerCheckJob_Tick={ #Check if the process stopped if($timerCheckJob.Tag -ne $null) { if($timerCheckJob.Tag.State -ne 'Running') { #Stop the Timer $buttonStart.ImageIndex = -1 $buttonStart.Enabled = $true $timerCheckJob.Tag = $null $timerCheckJob.Stop() } else { if($buttonStart.ImageIndex -lt $buttonStart.ImageList.Images.Count - 1) { $buttonStart.ImageIndex += 1 } else { $buttonStart.ImageIndex = 0 } } } }
As you can see this works well with a single job and if you need multiple jobs to run at the same time then you have to create multiple timers.
Creating a new Job Tracker Framework
Let’s expand on this idea and create a system that can scale and that only requires a single timer.
First we need a list that the system can use to track the current jobs. It is defined as follows:
$JobTrackerList = New-Object System.Collections.ArrayList
Next I created functions to interface with the JobTracker Framework.
The first function is Add-JobTracker. This function creates and adds a new job to the Job Tracker. It allows you specify a script block that the job will run and optional script block that will be called when the job is completed and another when the timer performs an update.
function Add-JobTracker { <# .SYNOPSIS Add a new job to the JobTracker and starts the timer. .DESCRIPTION Add a new job to the JobTracker and starts the timer. .PARAMETER Name The name to assign to the Job .PARAMETER JobScript The script block that the Job will be performing. Important: Do not access form controls from this script block. .PARAMETER ArgumentList The arguments to pass to the job .PARAMETER CompleteScript The script block that will be called when the job is complete. The job is passed as an argument. The Job argument is null when the job fails. .PARAMETER UpdateScript The script block that will be called each time the timer ticks. The job is passed as an argument. Use this to get the Job's progress. .EXAMPLE Job-Begin -Name "JobName" ` -JobScript { Param($Argument1)#Pass any arguments using the ArgumentList parameter #Important: Do not access form controls from this script block. Get-WmiObject Win32_Process -Namespace "root\CIMV2" }` -CompletedScript { Param($Job) $results = Receive-Job -Job $Job }` -UpdateScript { Param($Job) #$results = Receive-Job -Job $Job -Keep } .LINK #> Param( [ValidateNotNull()] [Parameter(Mandatory=$true)] [string]$Name, [ValidateNotNull()] [Parameter(Mandatory=$true)] [ScriptBlock]$JobScript, $ArgumentList = $null, [ScriptBlock]$CompletedScript, [ScriptBlock]$UpdateScript) #Start the Job $job = Start-Job -Name $Name -ScriptBlock $JobScript -ArgumentList $ArgumentList if($job -ne $null) { #Create a Custom Object to keep track of the Job & Script Blocks $psObject = New-Object System.Management.Automation.PSObject Add-Member -InputObject $psObject -MemberType 'NoteProperty' -Name Job -Value $job Add-Member -InputObject $psObject -MemberType 'NoteProperty' -Name CompleteScript -Value $CompletedScript Add-Member -InputObject $psObject -MemberType 'NoteProperty' -Name UpdateScript -Value $UpdateScript [void]$JobTrackerList.Add($psObject) #Start the Timer if(-not $timerJobTracker.Enabled) { $timerJobTracker.Start() } } elseif($CompletedScript -ne $null) { #Failed Invoke-Command -ScriptBlock $CompletedScript -ArgumentList $null } }
A custom PSObject is used to keep track of the job and script blocks. Afterwards the PSObject is added to the Job Tracker list. Note: The corresponding job is passed as an argument to the CompletedScript and UpdateScript script blocks. This allows the user to use the Receive-Job cmdlet to access the full or partial results of a job.
Next we created a function called Update-JobTracker, which the timer uses to check the status of all the jobs in the Job Tracker List. If the job is complete, it will then call the job’s corresponding CompletedScript script block. Otherwise if the job is still running, it will then call the corresponding UpdateScript script block. If all the jobs are completed, then the function will stop the timer.
function Update-JobTracker { <# .SYNOPSIS Checks the status of each job on the list. #> #Poll the jobs for status updates $timerJobTracker.Stop() #Freeze the Timer for($index =0; $index -lt $JobTrackerList.Count; $index++) { $psObject = $JobTrackerList[$index] if($psObject -ne $null) { if($psObject.Job -ne $null) { if($psObject.Job.State -ne "Running") { #Call the Complete Script Block if($psObject.CompleteScript -ne $null) { #$results = Receive-Job -Job $psObject.Job Invoke-Command -ScriptBlock $psObject.CompleteScript -ArgumentList $psObject.Job } $JobTrackerList.RemoveAt($index) Remove-Job -Job $psObject.Job $index-- #Step back so we don't skip a job } elseif($psObject.UpdateScript -ne $null) { #Call the Update Script Block Invoke-Command -ScriptBlock $psObject.UpdateScript -ArgumentList $psObject.Job } } } else { $JobTrackerList.RemoveAt($index) $index-- #Step back so we don't skip a job } } if($JobTrackerList.Count -gt 0) { $timerJobTracker.Start()#Resume the timer } }
We update the timer tick event to call the update Function from our Timer Tick event:
$timerJobTracker_Tick={ Update-JobTracker }
Note: You can modify the Timer’s Interval property to slow down or speed up the amount of time the timer waits before checking the progress of the pending jobs again.
The final function of our Job Tracker Framework is the Stop-JobTracker function. This function will stop all pending jobs and remove them from the Job Tracker list. The function will also stop the timer.
Note: You can call the Stop-JobTracker function in response to the user closing the form. This ensures there are no pending jobs running after the fact.
function Stop-JobTracker
{
<#
.SYNOPSIS
Stops and removes all Jobs from the list.
#>
#Stop the timer
$timerJobTracker.Stop()
#Remove all the jobs
while($JobTrackerList.Count-gt 0)
{
$job = $JobTrackerList[0].Job
$JobTrackerList.RemoveAt(0)
Stop-Job $job
Remove-Job $job
}
}
Now that we have a new framework, we can revise our initial Start Job button’s click event as follows:
$buttonStartJob_Click={ $buttonStartJob.Enabled = $false #Create a New Job using the Job Tracker Add-JobTracker -Name "JobName" ` -JobScript { #-------------------------------------------------- #TODO: Set a script block #Important: Do not access form controls from this script block. Param($Argument1)#Pass any arguments using the ArgumentList parameter for($i = 0; $i -lt 50; $i++){ Start-Sleep -Milliseconds 100 } #-------------------------------------------------- }` -CompletedScript { Param($Job) #Enable the Button $buttonStartJob.ImageIndex = -1 $buttonStartJob.Enabled = $true }` -UpdateScript { Param($Job) #$results = Receive-Job -Job $Job -Keep #Animate the Button if($buttonStartJob.ImageIndex -lt $buttonStartJob.ImageList.Images.Count - 1) { $buttonStartJob.ImageIndex += 1 } else { $buttonStartJob.ImageIndex = 0 } } }
All of the script is now contained within a single location. What’s more we can use the Add-JobTracker function to create multiple jobs using various sources as triggers without having to create multiple timers or add any other special considerations.
Displaying Progress with the Job Tracker:
If you want to display partial data or the Job’s progress, you should use the Receive-Job cmdlet during the update script block.
If you want to use a progress bar, you might consider having your job script output the percentage of progress.
For example:
-JobScript { for($i = 0; $i -lt 100; $i++) { #Do some work Start-Sleep -Milliseconds 100 #Output Progress $i + 1 } }
Now you can use the Receive-Job cmdlet to check the last value and use it to set the progress bar.
-UpdateScript { Param($Job) $results = Receive-Job -Job $Job | Select-Object -Last 1 if($results -is [int]) { $progressbar1.Value = $results } }
And update the CompleteScript scriptblock to show the progress at 100%:
-CompletedScript { Param($Job) $progressbar1.Value = 100 $buttonStartJob.Enabled = $true }
If you return results as well as the progress status then you might want to use the Receive-Job’s Keep parameter to ensure the data is all there. You will also need to make sure you ignore the progress information when processing the final results. You can use any number of techniques to differentiate this information. For example, you can use strings that start with “P: ” to determine it is a progress indicator. Of course this also then means you will need to parse and filter this information when receiving job results. The sky is the limit as to how you wish to handle these situations.
Job Tracker Control Set!
The good news is you needn’t worry about copying and pasting the framework in to each of your forms. I created an easy to use Control Set that will insert the framework into your existing form. In addition, I updated the “Button – Start Job” control set to utilize the new Job Tracker framework. The beauty of it is that it will no longer insert duplicate timers, since it is now handled by the Job Tracker Control Set.
These control sets are already in PowerShell Studio .
You can download the sample Job Progress bar form.
For licensed customers, use the forum associated with your product in our Product Support Forums for Registered Customers.
For users of trial versions, please post in our Former and Future Customers - Questions forum.