Things I learned creating a GUI tool with PowerShell and WPF.
The snippets in this article are based on the Show-Win32AppUI tool available on GitHub.

Disclaimer

I realise PowerShell isn’t suited to creating GUI apps. The main reason to use PowerShell is supportability. Specifically, when working with Colleagues who aren’t comfortable maintaining a compiled language like C#. Its far easier to make small changes to variables or paths in a script.

Why WPF?

WPF is a more modern and flexible choice for a UI than the something like WinForms. There are many newer frameworks available, but most require a runtime on the target platform. WPF is easy to deploy as it’s built-into the .NET Framework and available by default on Windows 10/11.

Part1 - Design the app around PowerShell Runspaces

If you try to create a GUI app with a single thread, it will be unresponsive and hang whenever an action takes more than a few seconds. PowerShell runs under a single thread (STA mode) making it unsuitable for a responsive GUI app. However, creating separate Runspaces is a workaround for this problem. Runspaces are analogous to spinning up new PowerShell sessions in the background to execute discrete script blocks.

While Runspaces are effective, they also add a lot of complexity. For example:

  • Functions and variables are not shared between Runspaces by default and have to be imported when the Runspace is started.
  • You should use a thread-safe collection when updating shared variables inside a Runspace.
  • The WPF UI can’t be updated directly from a separate Runspace
  • Writing to a single file from multiple Runspaces requires a locking mechanism, such as a Mutex
  • The lifecycle of Runspaces must be managed, capturing output at completion
  • Warning, Verbose and Error streams in the Runspace are not captured by default
  • Warning, Verbose and Error streams do not appear in the console by default.

These concepts are covered in more detail below.

Sharing variables, functions and modules with a Runspace

Required modules must be specifically loaded into the InitialSessionState of the Runspace. The Runspace won’t automatically have access to modules already loaded in the parent PowerShell session.

### Example of importing modules into a new Runspace ###

$modulesToLoad=@('Microsoft.Graph.Intune','MSAL.PS') # Must be installed on the computer

$initialSessionState = [initialsessionstate]::CreateDefault()
foreach($module in $modulesToLoad){
    $initialSessionState.ImportPSModule($module)
}
$Runspace = [runspacefactory]::CreateRunspace($initialSessionState)

You can also import standalone Functions into a Runspace using the InitialSessionState. The following imports functions already loaded in the parent session, but you could also load directly from a file on disk.

### Example importing parent session functions into a new Runspace ###

$FunctionsToImport = @('Write-TxtLog','Get-APIResults')

foreach($function in $functionsToImport){
    $definition = Get-Content "Function:\$Function"
    $entry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function, $definition
    $initialSessionState.Commands.Add($entry)
}
$Runspace = [runspacefactory]::CreateRunspace($initialSessionState)

Variables can be shared with a Runspace using the SetVariable method of the SessionStateProxy class. SetVariable parameters are the variable name (without the ‘$’) and the value to set.

### Example importing parent session variables into a new Runspace ###

$VariablesToImport = @('username','password','displayName') # existing variables in the parent session

$Runspace = [runspacefactory]::CreateRunspace($initialSessionState)
$Runspace.Open()
Foreach($var in $VariablesToImport){
    $VarValue = Get-Variable -Name $Var | Select -ExpandProperty Value
    $Runspace.SessionStateProxy.SetVariable($Var,$VarValue)
}

Runspace output using a thread-safe collection

Output from a Runspace can be captured during execution using a thread-safe collection imported from the parent session. When a Runspace updates the collection the updated values are available in the parent session and any concurrent Runspaces that also import the collection. Thread safe collections usually need to be locked during update to prevent conflicts.

With a synchronised Arraylist, values added in the Runspace will immediately available to all other Runspaces and the parent session, as in following example:

### Example using locks on a thread safe collection ###

# Parent session code
$BackgroundJobs = [system.collections.arraylist]::Synchronized((New-Object System.Collections.ArrayList)) # Thread safe collection

$Runspace = [runspacefactory]::CreateRunspace($initialSessionState)
$Runspace.Open()
$Runspace.SessionStateProxy.SetVariable('BackgroundJobs',$BackgroundJobs) # Pass the variable into the RunSpace

$PSCode = [powershell]::Create().AddScript({
    # This is the Runspace script block
    try{
        [System.Threading.Monitor]::Enter($BackgroundJobs.SyncRoot) # lock
        $BackgroundJobs.Add("New item") # modify ArrayList
    }finally{
        [System.Threading.Monitor]::Exit($BackgroundJobs.SyncRoot) # unlock
    } 
},$True)

$PSCode.Runspace = $Runspace
$null = $PSCode.BeginInvoke()

There are also Queues and Stacks in .NET that automatically implement locking and don’t need the System.Threading.Monitor code in the above example e.g. a ConcurrentQueue:

### ConcurrentQueue example - doesn't need System.Threading.Monitor ###

# Parent session code
$colQueue = [System.Collections.Concurrent.ConcurrentQueue[psobject]]::new()
$colQueue.TryAdd([pscustomobject]@{ givenName = 'Bill';sn='Gates'})
$colQueue.TryAdd([pscustomobject]@{ givenName = 'Steve';sn='Jobs'})
$Runspace.SessionStateProxy.SetVariable('colQueue',$colQueue)

$PSCode = [powershell]::Create().AddScript({
    # This is the Runspace script block
    
    # No locking required when ConcurrentQueue is modified in the Runspace
    $Entry = $null
    if($colQueue.TryDequeue([ref]$Entry)) {
        Write-Output $Entry
    }
    
 },$True)   

More information on thread safe collections is available here

How to update the WPF user interface from another Runspace

If you try to modify the WPF user interface from a separate Runspace, PowerShell will throw an error indicating only the owning thread (Runspace) can update it.

UIError

The solution is to wrap the update in a Dispatcher.Invoke method as follows:

### Example updating a WPF control from another thread (Runspace) ###

# In the Runspace script block
# txt_SetupFile is a WPF text box created in the parent session of the Runspace
$UIControls.txt_SetupFile.Dispatcher.Invoke([action]{
    $UIControls.txt_SetupFile.Text = "Successfully updated from another Runspace"
},"Normal")

Writing to the same log file from separate Runspaces

Runspaces also make it more complex to write to a single log file. There is potential for a deadlock or race condition to occur. A Mutex is one way to implement the required locking:

### Example using a Mutex lock before writing to a log file ###

# In the Runspace script block
# LogMutex is an arbitrary name but must be the same when used in any Runspace and the parent session
$mtx = New-Object System.Threading.Mutex($false, "LogMutex") 

If ($mtx.WaitOne()){ # Wait until this Runspace can get a lock on the LogMutex object

    # Lock obtained. Other Runspaces are now waiting
    try{
        Add-Content -Path $logFile -Value $Message  -ErrorAction Stop
    }finally{
        [void]$mtx.ReleaseMutex() # release the lock
        $mtx.Dispose()
    } 
}

Managing Runspace lifecycle

A Runspace executes its script block asynchronously and output (if any) is available at the end. The parent session must manage Runspaces, checking for completion, processing output and ultimately disposing of them. If you don’t dispose of Runspaces they will persist until the parent PowerShell session is closed and could eat-up memory.

A Timer is a common way to manage Runspaces in an event-driven WPF script. When the Timer event fires, its script blocks checks for Runspace completion as in the example below. A thread safe collection is used to keep track of Runspaces until they are disposed of.

### Example Timer code to clean-up completed Runspaces ###


# Create a collection to track Runspaces


$PS = [powershell]::Create().AddScript($codeToRunInRunspace)
$handle = $PS.BeginInvoke() # Start the runspace

# Add the new Runspace to the RunspaceJobs collection
$RunspaceJobs = [system.collections.arraylist]::Synchronized((New-Object System.Collections.ArrayList))
try{
    [System.Threading.Monitor]::Enter($RunspaceJobs.SyncRoot) # lock
    $RunspaceJobs.Add([PSCustomObject]@{
        powerShell = $PS  # System.Management.Automation.PowerShell object
        runspace = $handle # System.Management.Automation.PowerShellAsyncResult Object
    }) | Out-Null
}finally{
    [System.Threading.Monitor]::Exit($RunspaceJobs.SyncRoot) # unlock
}

# Timer to manage Runspace lifecycle
$RunspaceCleanupTimer = New-Object System.Windows.Forms.Timer
$RunspaceCleanupTimer.Enabled = $true
$RunspaceCleanupTimer.Interval = 5000  # Timer code runs every 5 seconds
$RunspaceCleanupTimer.Add_Tick({
    # In the timer code
    Foreach($job in $Script:RunspaceJobs){

        if($job.runspace.IsCompleted -eq $True){
           
           # Capture completed Runspace output and dispose of it to free-up memory
            try{
                $RSOutput = $job.powerShell.EndInvoke($job.runspace)
                $job.powerShell.runspace.Dispose()
                
                # Remove the job from the tracking collection
                try{
                    [System.Threading.Monitor]::Enter($Script:RunspaceJobs.SyncRoot)
                    $Script:RunspaceJobs.Remove($job)
                }finally{
                    [System.Threading.Monitor]::Exit($Script:RunspaceJobs.SyncRoot)
                }

            }catch{
                Write-Host "Runspace disposal Failed '$_'"
            }
        }
    }#foreach

})#End of timer scriptblock

$RunspaceCleanupTimer.Start()

Capturing Verbose, Warning and Error streams from a Runspace

By default, the Runspace output streams are not displayed in the parent session console and are lost when the Runspace is disposed.
The output can be captured at Runspace completion using the Streams object. The modification below to the Timer script block saves the output to a log file.

#### Modified Timer code to capture additional output streams at clean-up ####
Foreach($job in $Global:BackgroundJobs){

    if($job.runspace.IsCompleted -eq $True){
        Write-TxtLog "Runspace '$($job.powershell.runspace.name)' completed..."

        # Could also include 'DEBUG' and 'Information' streams if used in your Runspaces
        $Streams = @{
            'Verbose'='VERBOSE'
            'Warning'='WARN'
            'Error'='ERROR'
        }

        Foreach($StreamType in $Streams.Keys){                        
            $StreamOutput = $job.powershell.Streams."$StreamType"  # Capture the Runspace output for each stream
            if($StreamOutput){
                $StreamOutput | Foreach-Object { Write-TxtLog $_ -indent 2 -severity $($Streams[$StreamType]) }
            }
            Remove-Variable -name 'StreamOutput' -force -ErrorAction SilentlyContinue
        }

        Write-TxtLog "Disposing of runspace..." -indent 1
        try{
            $RSOutput = $job.powerShell.EndInvoke($job.runspace)
            $job.powerShell.runspace.Dispose()
            
            # Remove the job from the tracking list
            try{
                [System.Threading.Monitor]::Enter($Global:BackgroundJobs.SyncRoot)
                $Global:BackgroundJobs.Remove($job)
            }finally{
                [System.Threading.Monitor]::Exit($Global:BackgroundJobs.SyncRoot)
            }

        }catch{
            Write-TxtLog "Failed '$_'" -indent 2 -severity ERROR
        }
    }
}#foreach

Displaying Verbose, Warning and Error Streams in the console

The approach above captures output when the Runspace code has completed. If you want feedback in the console during execution there are a couple of methods.

Firstly, the simplest option is to use the .NET Console.Writeline() method. Although this doesn’t capture the PowerShell streams, it is a simple way to provide real-time console output in the parent session.
You will need to implement your own colour-coding to distinguish between warnings or errors if needed.

### Example Runspace code to write to parent PowerShell console ###
[console]::ForegroundColor='YELLOW'
[Console]::WriteLine('Username was not found')  # Automatically writes to the parent session
[console]::ResetColor()

Alternatively, you could pass the built-in $Host variable from the parent session into the Runspace and use methods like WriteVerboseLine() as shown below.

### Example writing to parent console using the $Host variable 

# Parent session code
$Runspace = [runspacefactory]::CreateRunspace($initialSessionState)
$Runspace.Open()
$Runspace.SessionStateProxy.SetVariable('ParentHost',$Host)  # Built-in host variable passed into the Runspace as $ParentHost

# Write to the parent console from the Runspace script block
$ParentHost.ui.WriteVerboseLine("Realtime verbose output from Runspace in parent console")
$ParentHost.UI.WriteWarningLine("Realtime warning output from Runspace in parent console")
$ParentHost.UI.WriteErrorLine("Realtime error output from Runspace in parent console")

See the Show-Win32AppUI tool for an example of using these ideas together in a WPF app.

See part2 of this series for information on WPF controls, events and XAML.



This article was originally posted on Write-Verbose.com