There are a lot of resources available describing how to use Powershell with WPF for creating a GUI. However I found there was not much example code that shows clearly how to put all the pieces together. To have a GUI that is usable it needs to be multi-threaded, and that can get complicated as the best way to do that in Powershell is with Runspaces.

This post will cover the simplest configuration up to the most complex; a single thread GUI, two threads and multiple threads.

We won't look at creation of the WPF XAML files as the focus here is on the Powershell code, but all that's required is Visual Studio Community and to start a "WPF App" project. The source files for creating the example GUI are available here as a reference.

The following articles have a more in-depth look at Runspaces and using WPF with Powershell:

An Example GUI

The example is a simple GUI that takes a valid Powershell command as an input and executes it using the Invoke-Expression cmdlet. With this we can easily choose a command that has a long execution time like Test-Connection.

To run any of the examples make sure the .ps1 and .xaml files are in the same directory and execute the .ps1 script file, Powershell 3.0 or greater is required.

A single thread

With a single thread the code is straightforward, but we do need to remove a couple of attributes from the XAML file at runtime. These are used by Visual Studio when designing, and to bind a class when working with C#. An event handler is created that will execute the Invoke-Expression cmdlet when the "Run" button is clicked.

Clicking the button we can see the limitation of a single thread, the whole GUI is unresponsive until Invoke-Expression has completed execution.

# load the required assemblies and get the XML document to build the GUI
Add-Type -AssemblyName 'PresentationCore', 'PresentationFramework'
[Xml]$WpfXml = Get-Content -Path 'GUI.xaml'

# remove attributes from XML that cause problems with initializing the XAML object in Powershell
$WpfXml.Window.RemoveAttribute('x:Class')
$WpfXml.Window.RemoveAttribute('mc:Ignorable')
# initialize the XML Namespaces so they can be used later if required
$WpfNs = New-Object -TypeName Xml.XmlNamespaceManager -ArgumentList $WpfXml.NameTable
$WpfNs.AddNamespace('x', $WpfXml.DocumentElement.x)
$WpfNs.AddNamespace('d', $WpfXml.DocumentElement.d)
$WpfNs.AddNamespace('mc', $WpfXml.DocumentElement.mc)

# initialize the main window object from the XAML file
$Window = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $WpfXml))

$Gui = @{}
foreach($Node in $WpfXml.SelectNodes('//*[@x:Name]', $WpfNs))
{
    # get all the XML elements that have an x:Name attribute, these will be controls we want to interact with
    $Gui.Add($Node.Name, $Window.FindName($Node.Name))
}

# set an example value for the "Command" text box
$Gui.CommandText.Text = 'Test-Connection -ComputerName 8.8.8.8 -Count 5'

# handle the click event for the "Run" button
$Gui.RunButton.add_click({
    # get the string from the "Command" text box, this will be the command that is run
    $Command = $Gui.CommandText.Text
    $Output = (Invoke-Expression -Command $Command) | Out-String
    # display the output of the command by updating the output text box 
    $Gui.OutputText.AppendText($Output)
})

# display the GUI
$Window.ShowDialog() | Out-Null

Download GUI.ps1

Two threads with a Runspace

Using a Runspace to create the second Powershell session/thread makes the code a bit more complex. Initializing the GUI is the same, but now we also have to work with the two sessions. Most importantly a thread-safe Hashtable ($Sync) is used pass data between the main session running the GUI code, and the second session which has the Invoke-Expression cmdlet executed inside it.

We also have to use a Dispatcher when updating GUI objects that are owned by a different session. For example when Run is clicked the button is disabled while the command is being executed. This is the second session updating a GUI object owned by the main session.

# load the required assemblies and get the XML document to build the GUI
Add-Type -AssemblyName 'PresentationCore', 'PresentationFramework'
[Xml]$WpfXml = Get-Content -Path 'RunspaceGUI.xaml'

# remove attributes from XML that cause problems with initializing the XAML object in Powershell
$WpfXml.Window.RemoveAttribute('x:Class')
$WpfXml.Window.RemoveAttribute('mc:Ignorable')
# initialize the XML Namespaces so they can be used later if required
$WpfNs = New-Object -TypeName Xml.XmlNamespaceManager -ArgumentList $WpfXml.NameTable
$WpfNs.AddNamespace('x', $WpfXml.DocumentElement.x)
$WpfNs.AddNamespace('d', $WpfXml.DocumentElement.d)
$WpfNs.AddNamespace('mc', $WpfXml.DocumentElement.mc)

# create a thread-safe Hashtable to pass data between the Powershell sessions/threads
$Sync = [Hashtable]::Synchronized(@{})
$Sync.Window = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $WpfXml))

# add a "sync" item to reference the GUI control objects to make accessing them easier
$Sync.Gui = @{}
foreach($Node in $WpfXml.SelectNodes('//*[@x:Name]', $WpfNs))
{
    # get all the XML elements that have an x:Name attribute, these will be controls we want to interact with
    $Sync.Gui.Add($Node.Name, $Sync.Window.FindName($Node.Name))
}

# set an example value for the "Command" text box
$Sync.Gui.CommandText.Text = 'Test-Connection -ComputerName 8.8.8.8 -Count 5'

# create the runspace and pass the $Sync variable through
$Runspace = [RunspaceFactory]::CreateRunspace()
$Runspace.ApartmentState = [Threading.ApartmentState]::STA
$Runspace.Open()
$Runspace.SessionStateProxy.SetVariable('Sync',$Sync)

# handle the click event for the "Run" button
$Sync.Gui.RunButton.add_click({
    # create the extra Powershell session and add the script block to execute
    $global:Session = [PowerShell]::Create().AddScript({
        # make the $Error variable available to the parent Powershell session for debugging
        $Sync.Error = $Error
        # to access objects owned by the parent Powershell session a Dispatcher must be used
        $Sync.Window.Dispatcher.Invoke([Action]{
            # make $Command available outside this Dispatcher call to the rest of the script block
            $script:Command = $Sync.Gui.CommandText.Text
            $Sync.Gui.RunButton.IsEnabled = $false
            $Sync.Gui.OutputStatusText.Content = 'Running'
        })
        # by executing the command in this session the GUI owned by the parent session will remain responsive
        $Output = (Invoke-Expression -Command $Command) | Out-String

        # now the command has executed the GUI can be updated again 
        $Sync.Window.Dispatcher.Invoke([Action]{
            $Sync.Gui.OutputText.Text = $Output
            $Sync.Gui.OutputStatusText.Content = 'Waiting'
            $Sync.Gui.RunButton.IsEnabled = $true
        })
    }, $true) # set the "useLocalScope" parameter for executing the script block

    # execute the code in this session
    $Session.Runspace = $Runspace
    $global:Handle = $Session.BeginInvoke()
})

# check if a command is still running when exiting the GUI
$Sync.Window.add_closing({
    if ($Session -ne $null -and $Handle.IsCompleted -eq $false)
    {
        [Windows.MessageBox]::Show('A command is still running.')
        # the event object is automatically passed through as $_
        $_.Cancel = $true
    }
})

# close the runspace cleanly when exiting the GUI
$Sync.Window.add_closed({
    if ($Session -ne $null) {$Session.EndInvoke($Handle)}
    $Runspace.Close()
})

# display the GUI
$Sync.Window.ShowDialog() | Out-Null

Download RunspaceGUI.ps1

Multiple threads with a Runspace Pool

The Runspace Pool is created in a very similar way to a Runspace. The $Sync Hashtable is passed through and the Run button click event executes our command in the extra session. However using a Runspace Pool means that we can easily create extra sessions, so multiple commands can be executed simultaneously. In this example $MaxRunspaces is set to 3, when 3 all Runspaces have an active session any further new sessions will wait until a Runspace is available.

The Refresh button added in this example can be used to see how many commands are queued for execution.

Having multiple sessions means we need to manage them, this is done with the $Sync.Jobs array which allows the state of a Runspace to be checked, which – for example – can be used to ensure all sessions have finished executing before closing the GUI.

# load the required assemblies and get the XML document to build the GUI
Add-Type -AssemblyName 'PresentationCore', 'PresentationFramework'
[Xml]$WpfXml = Get-Content -Path 'RunspacePoolGUI.xaml'

# remove attributes from XML that cause problems with initializing the XAML object in Powershell
$WpfXml.Window.RemoveAttribute('x:Class')
$WpfXml.Window.RemoveAttribute('mc:Ignorable')
# initialize the XML Namespaces so they can be used later if required
$WpfNs = New-Object -TypeName Xml.XmlNamespaceManager -ArgumentList $WpfXml.NameTable
$WpfNs.AddNamespace('x', $WpfXml.DocumentElement.x)
$WpfNs.AddNamespace('d', $WpfXml.DocumentElement.d)
$WpfNs.AddNamespace('mc', $WpfXml.DocumentElement.mc)

# create a thread-safe Hashtable to pass data between the Powershell sessions/threads
$Sync = [Hashtable]::Synchronized(@{})
$Sync.Window = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $WpfXml))

# add a "sync" item to reference the GUI control objects to make accessing them easier
$Sync.Gui = @{}
foreach($Node in $WpfXml.SelectNodes('//*[@x:Name]', $WpfNs))
{
    # get all the XML elements that have an x:Name attribute, these will be controls we want to interact with
    $Sync.Gui.Add($Node.Name, $Sync.Window.FindName($Node.Name))
}

# set an example value for the "Command" text box
$Sync.Gui.CommandText.Text = 'Test-Connection -ComputerName 8.8.8.8 -Count 5'

# create the runspace pool and pass the $Sync variable through
$SessionVariable = New-Object 'Management.Automation.Runspaces.SessionStateVariableEntry' `
    -ArgumentList 'Sync', $Sync, ''
$SessionState = [Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
$SessionState.Variables.Add($SessionVariable)
$MaxThreads = 3
$RunspacePool = [RunspaceFactory]::CreateRunspacePool(1, $MaxThreads, $SessionState, $Host)
$RunspacePool.ApartmentState = [Threading.ApartmentState]::STA
$RunspacePool.Open()

# create a "Jobs" array to track the created runspaces
$Sync.Jobs = [System.Collections.ArrayList]@()

# handle the click event for the "Run" button
$Sync.Gui.RunButton.add_click({
    # create the extra Powershell session and add the script block to execute
    $Session = [PowerShell]::Create().AddScript({
        # to access objects owned by the parent Powershell session a Dispatcher must be used
        $Sync.Window.Dispatcher.Invoke([Action]{
            # make $Command available outside this Dispatcher call to the rest of the script block
            $script:Command = $Sync.Gui.CommandText.Text
        })

        # by executing the command in this session the GUI owned by the parent session will remain responsive
        $Output = (Invoke-Expression -Command $Command) | Out-String

        # display the output of the command in the text block
        $Sync.Window.Dispatcher.Invoke([Action]{
            $Sync.Gui.OutputText.AppendText($Output)
        })
    }, $true) # set the "useLocalScope" parameter for executing the script block

    # execute the code in this session
    $Session.RunspacePool = $RunspacePool
    $Handle = $Session.BeginInvoke()
    $Sync.Jobs.Add([PSCustomObject]@{
        'Session' = $Session
        'Handle' = $Handle
    })
})

# update the command queue count
$Sync.Gui.RefreshButton.add_click({
    $Queue = 0
    foreach($Job in $Sync.Jobs)
    {
        $Queue += if ($Job.Handle.IsCompleted -eq $false) { 1 } else { 0 }
    }
    $Sync.Gui.OutputQueueText.Content = $Queue
})

# check if a job is still running when exiting the GUI
$Sync.Window.add_closing({
    $Queue = 0
    foreach($Job in $Sync.Jobs)
    {
        $Queue += if ($Job.Handle.IsCompleted -eq $false) { 1 } else { 0 }
    }

    if ($Queue -gt 0)
    {
        [Windows.MessageBox]::Show('A command is still running.')
        # the event object is automatically passed through as $_
        $_.Cancel = $true
    }
})

# close the runspaces cleanly when exiting the GUI
$Sync.Window.add_closed({
    foreach($Job in $Sync.Jobs)
    {
        if ($Job.Session.IsCompleted -eq $true)
        {
            $Job.Session.EndInvoke($Job.Handle)
        }
        $RunspacePool.Close()
    } 
})

# display the GUI
$Sync.Window.ShowDialog() | Out-Null

Download RunspacePoolGUI.ps1

Debugging Runspaces

To do simple debugging for a Runspace we can't just use Write-Host or check the value of a variable on the command line because there is no terminal attached to the Runspace session. The $Sync Hashtable could be used to pass through a variable or [Windows.MessageBox]::Show() to display a value, but ideally we want to see the state of the whole script when investigating more complex problems. To do this we can attach the Powershell debugger to a Runspace.

We will need two Powershell consoles, one to run the script and another to attach the debugger to the process. The Wait-Debugger and Enter-PsHostProcess cmdlets are available from Powershell 5.0.

  1. Add the Wait-Debugger cmdlet inside the script block of the RunButton.add_click() event to create a breakpoint inside the Runspace.

  2. Open the first Powershell console and run Get-Process | ?{$_.ProcessName -like '*powershell*'}, make a note of the process ID.

  3. Now start the script and click "Run", it will pause execution at the breakpoint ready for debugging.

  4. Open a second console and run Enter-PSHostProcess -Id <process id>, this enters into an interactive session with the process where we can run Get-Runspace and look for the one with Availability as InBreakpoint

  5. Run Debug-Runspace -Id <runspace id> to attach the debugger to the Runspace. Details on how to use the Powershell debugger are on the about_debugger help page.

  6. When debugging is ended run Exit-PSHostProcess to disconnect from the process.


Comments

comments powered by Disqus