Pete Hinchley: Creating a Windows Form using PowerShell Runspaces

I recently wrote a PowerShell script that used the Windows Forms .NET assembly to create a graphical user interface. The form included a button that initiated a long-running task. While the task was executing, I wanted to ensure the form remained responsive to user input, and I also wanted to update a status field on the form with the progress of the executing task.

Now, by default, any graphical interface created via PowerShell will run in a single-threaded apartment; which basically means that the application will "block", or become unresponsive, while it is waiting for a task to complete. The best way of overcoming this restriction is to leverage .NET runspaces (each runspace provides a separate execution context for a PowerShell pipeline).

The following code provides a simplified example of how I used runspaces to manage the execution of a long running task. The script creates a form with a button and a text label. The button initiates a script block that counts to 20 over a period of 20 seconds (the button is intentionally disabled during this period). The window remains responsive throughout, and the label is incrementally refreshed to show the value of the counter.

[Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null
Add-Type -AssemblyName System.Windows.Forms

# for talking across runspaces.
$sync = [Hashtable]::Synchronized(@{})

# long running task.
$counter = {
  $count = [PowerShell]::Create().AddScript({
    $sync.button.Enabled = $false

    for ($i = 0; $i -le 20; $i++) {
      $sync.label.Text = $i
      start-sleep -seconds 1
    }

    $sync.button.Enabled = $true
  })

  $runspace = [RunspaceFactory]::CreateRunspace()
  $runspace.ApartmentState = "STA"
  $runspace.ThreadOptions = "ReuseThread"
  $runspace.Open()
  $runspace.SessionStateProxy.SetVariable("sync", $sync)

  $count.Runspace = $runspace
  $count.BeginInvoke()
}

# create the form.
$form = New-Object Windows.Forms.Form
$form.ClientSize = New-Object Drawing.Size(200, 60)
$form.Text = "Counter"
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedSingle"
$form.MaximizeBox = $false

# create the button.
$button = New-Object Windows.Forms.Button
$button.Location = New-Object Drawing.Point(10, 10)
$button.Width = 180
$button.Text = "Start Counting"
$button.Add_Click($counter)

# create the label.
$label = New-Object Windows.Forms.Label
$label.Location = New-Object Drawing.Point(10, 38)
$label.Width = 100
$label.Text = 0

# add controls to the form.
$sync.button = $button
$sync.label = $label
$form.Controls.AddRange(@($sync.button, $sync.label))

# show the form.
[Windows.Forms.Application]::Run($form)