PowerShell: Multi-threading, Progress Bars and GUI Input, Part 1

UPDATE: 

A brief note from the author: This week I learned a valuable lesson about maintaining a consistent development platform. In the previous blog post, I discussed creating the function to pop up a text box for data input and having it return the values to the calling variable. When I wrote that post last week, I was using a Windows 7 VM on my laptop that I had been using for scripting, the idea being that there’s a lot of Windows 7 boxes out there still and if it works on Windows 7, well it’ll work in newer operating systems, right? Yeah, I was wrong. Over the weekend I decided to finish out the script, so I can finish blogging on it, of course, but I keep that Windows 7 VM isolated so it doesn’t have outside network access, and I really needed the script to be able to reach out and touch other machines. As a result I moved my script to my Windows 10 host, and thought I could hit the ground running. That didn’t work out so well.

When I tried to run it on my Windows 10 machine, my function wouldn’t return the array. I’ll spare you the boring details or the tragic story of how many hours I banged my head against this thing, but the short of it is that it would appear our friends at Microsoft changed a few things in how variable scoping is handled. The variable that I was assigning the data to was being instantiated local to the scriptblock in the function, and as such, didn’t exist outside the scriptblock it was called in, and so it couldn’t pass the data back to the calling variable.

To fix this, I added this at the beginning of the script, outside of the functions:

$Global:ReturnedTextBoxData = @()

This instantiates the variable $ReturnedTextBoxData as a global variable of array type, and then when I reference it in the function, I have to reference it as $Global:ReturnedTextBoxData:

$OKButton.Add_Click({$Global:ReturnedTextBoxData += @(TextToArray); $objForm.Close()})

And

return $Global:ReturnedTextBoxData

I have to reference it as the globally scoped variable. PowerShell has the ability to allow you to have variables of the same name hold different values across different scopes.  Also, you may notice that this variable, $ReturnedTextBoxData isn’t in the previous blog post. In that previous post, you’ll see that there’s a $x variable in that TextBox function. During my troubleshooting I renamed this to make it easier for me to follow the breadcrumbs in the debugger. When this series of posts is complete, it’ll be this new variable that is in the code dump.  Please feel free to go down that variable scoping rabbit hole sometime, but don’t blame me for the headache that it can cause.

Welcome back. I want to apologize up front: this blog isn’t going to be in my normal style. I started this blog wanting to demonstrate a technique that I use to multi-thread PowerShell scripts, giving lines of script, and explaining what things are doing. After beginning to write this, I found that I can’t just show that technique without showing a few other things, so I’m also going to delve into using GUI elements for input, progress bars to show the progress of your threading, and some of the things that I tend to include in my scripts.

This is going to be a multi-part series, some posts longer than others, but I want to be able to group these things together logically without overwhelming everyone. In the last part of the series, we’ll put all of these pieces together into one script that will demonstrate how it all works together and provide you with a framework you can build off of for your own scripts.

Now, enough banter, let’s jump in!

A Brief Anatomy of My PowerShell Scripts

I’m the type of person that when I really sit down to write a script, I document the heck out of it, so that I or anyone coming after me can decipher it and why I used it.

I like to start off my scripts with this template:

##————————————————————————–

##  Name………..:  <filename>.ps1

##  PURPOSE……..:  <Why did you write this script?>

##  REQUIREMENTS…:  PowerShell v2.0

##  NOTES……….:    <Any Extra Notes>

##————————————————————————–

#Requires -Version 2.0

<#

.SYNOPSIS

<Brief description>

For examples type:

Get-Help .\<filename>.ps1 -examples

.DESCRIPTION

<Describe the script>

.EXAMPLE

<Syntax for using your script>

<Description of what this example does>

.NOTES

NAME………:

AUTHOR…….:

LAST EDIT….:

CREATED……:

KNOWN ISSUES.:

#>

This is my default template to fill in the blank. It’s a living part of the script that changes through the course of development, but it will allow you to run “get-help” against your script and get information like you would a normal cmdlet. It also lets those who come after you know who wrote it, why you wrote it, and if you keep it updated as you make revisions, what known issues there are as of that version.

Just below that, I usually include this little bit:

$SCRIPT:StartupVariables=””

New-Variable -force -name StartupVariables -Value ( Get-Variable | %{$_.Name})

This creates a script scope variable called StartupVariables that takes inventory of all the variables in play at that moment. I then follow it up with this function:

Function VariableCleanup {

    Get-Variable | Where-Object {$StartupVariables -notcontains $_.Name} | % {Remove-Variable -Name “$($_.Name)” -Force -Scope “global”}

}

This creates a function that I call at the end of my scripts that will get all the variables available at the end of the script, compare it to the variables listed in $StartupVariables, and then remove the variables I created during the script. Think of it as cleaning up after yourself. I used to not worry about this, but I found that sometimes variables will persist when you don’t want them to, and can make for some interesting results. To call that function, just drop the function name at the end of the script:

VariableCleanup

If you want to test this little snippet for yourself, copy this into a text file, save as a .ps1 and run it:

#Define Script scope variable for use in Variable Cleanup Function

$SCRIPT:StartupVariables=””

New-Variable -force -name StartupVariables -Value ( Get-Variable | %{$_.Name})

#Establish Function to clear variables at end of script

Function VariableCleanup {

    Get-Variable | Where-Object {$StartupVariables -notcontains $_.Name} | % {Remove-Variable -Name “$($_.Name)” -Force -Scope “global”}

}

#clear screen for ease of viewing output

cls

#Establish test variables

$testvar = “waffles”

$testvar1 = “bacon”

$testvar2 = “cheese”

#Output positional notification to screen and output value of test variables

Write-host “Before Cleanup”

Write-Host “Value of Testvar is ” $testvar

Write-Host “Value of Testvar1 is ” $testvar1

Write-Host “Value of Testvar2 is ” $testvar2

#Write Blank line to make things look purdy

Write-host “”

VariableCleanup

#Output positional notification to screen and output value of test variables

Write-host “After cleanup”

Write-Host “Value of Testvar is ” $testvar

Write-Host “Value of Testvar1 is ” $testvar1

Write-Host “Value of Testvar2 is ” $testvar2

#Write Blank line to make things look purdy

Write-host “”

Let’s Get Functional!

The next thing we should look at is how do we put data into the script so we don’t have to hard code (or is that hard script?) things into it, like server names, or workstation names, or service names, or process names. We need to have some flexibility. I’ve tried many ways to do this: prompt for user input at the command line or read from a pre-populated text file. I’ve found, however, that there’s too much room for user error, and if you’re writing a script that is going to be used by other people, not just yourself, you need to account for that lowest common denominator of user.

Some heads up on using this script that I’ve learned from experience: if you have it prompt for input at the command line, it’s common for someone to kick off the script and not looks back at the window expecting it to just run and then two hours later it didn’t work and it’s all because it was waiting for the user to do something. If you tell it to read from a file, like c:\source\serverlist.txt , it might be too complicated for some. I’ve seen people accidentally name the file wrong or put it in the wrong place, or just fail to follow that part of the instruction.

Data input! So yeah, no more command line (or PowerShell line, in this case) prompts for input, no more read from text files, let’s pop open a text box in the user’s face and let them put stuff in there! Well, before we get to the code for that, let’s load some assemblies that will let us do that:

[void] [System.Reflection.Assembly]::LoadWithPartialName(“System.Drawing”)

[void] [System.Reflection.Assembly]::LoadWithPartialName(“System.Windows.Forms”)

For placement in the script, I drop these in just below my informational template and just above the StartupVariables part. There’s no real reason, but it’s not creating variables, just opening the assemblies for us to use.

Before we have PowerShell make those text boxes, we need a function to get the data from the text box and dump it into an array variable.

#Get data from text box and assigned to an array

function TextToArray{

       $returndata = @()

       $txtboxdata = New-Object System.IO.StringReader($objTextBox.Text)

       $Linedata = $txtboxdata.readline()

       while ($Linedata -ne $null)

           {

               $returndata += @($Linedata)

               $Linedata = $txtboxdata.readline()

           }

       $objtextbox.Clear()

       $txtboxdata.dispose()

       return $returndata

}

So very briefly, it’s a function that will be called when the OK button on the text box is clicked. When it’s called, it takes the data and reads it line by line into an array called $returndata, the value of which is then returned to whatever called it. If you’re confused now, don’t worry, we’ll revisit this part later, just know that this is what reads in the contents of the text boxes that get created with this little function:

#Function for creating the Text Boxes for Input

function Textbox {

    [CmdletBinding()]

    param(

    [Parameter(Mandatory=$true,valuefrompipeline=$true)]

    [string]$Label,

       [string]$LabelText

       )

       $objForm = New-Object System.Windows.Forms.Form

       $objForm.Text = $label

       $objForm.Size = New-Object System.Drawing.Size(300,415)

       $objForm.StartPosition = “CenterScreen”

       $x = @()

       $OKButton = New-Object System.Windows.Forms.Button

       $OKButton.Location = New-Object System.Drawing.Size(45,345)

       $OKButton.Size = New-Object System.Drawing.Size(75,23)

       $OKButton.Text = “OK”

       $OKButton.Add_Click({$x +=@(TextToArray);$objForm.Close()})

       $objForm.Controls.Add($OKButton)

       $CancelButton = New-Object System.Windows.Forms.Button

       $CancelButton.Location = New-Object System.Drawing.Size(150,345)

       $CancelButton.Size = New-Object System.Drawing.Size(90,23)

       $CancelButton.Text = “Cancel”

       $CancelButton.Add_Click({$objForm.Close()})

       $objForm.Controls.Add($CancelButton)

       $objLabel = New-Object System.Windows.Forms.Label

       $objLabel.Location = New-Object System.Drawing.Size(22,20)

       $objLabel.Size = New-Object System.Drawing.Size(280,20)

       $objLabel.Text = $LabelText

       $objForm.Controls.Add($objLabel)

       $objTextBox = New-Object System.Windows.Forms.TextBox

       $objTextBox.Location = New-Object System.Drawing.Size(10,40)

       $objTextBox.Size = New-Object System.Drawing.Size(260,300)

       $objTextBox.Multiline = $true

       $objTextBox.ScrollBars = “Vertical”

       $objForm.Controls.Add($objTextBox)

       $objForm.Topmost = $True

       $objForm.Add_Shown({$objForm.Activate()})

       [void] $objForm.ShowDialog()

       return $x

}

One of my favorite things about PowerShell is that it’s so dang robust. If you dream it, you can probably do it. I’d like to say that I am so awesome that I was just able to pull those numbers out of my hat and the text box window popped up exactly how I wanted it, but I’ll let you in on a dirty little secret: I opened Visual Studio, created a new C# project, made a text box in the visual editor and then copied the code from the backend. With minimal tweaking, it was almost a cut and paste job. By doing this, you can create all types of forms and controls and things to bring together a full GUI experience for your PowerShell scripts.

Let’s go through that function and see what’s happening there.

function Textbox {

    [CmdletBinding()]

    param(

    [Parameter(Mandatory=$true,valuefrompipeline=$true)]

    [string]$Label,

       [string]$LabelText

       )

Here we’ve created a function, called it text box, and defined what the syntax will be for calling it. So if I want to create a text box window called Server List with a caption stating “Enter the list of Servers to scan:”, then I call the function as such:

$serverlist = @(TextBox “Server List” “Enter the list of Servers to scan:”)

I’m creating a variable called $serverlist, and by having it = @() then I’m asking PowerShell to create this as an array. The stuff inside the ( ) is calling the function TextBox and passing it the parameters of “Server List” and “Enter the list of Servers to scan:”. These two parameters are then inside the function assigned to the variables of $Label and $LabelText, respectively. I could have hard set these two variables in the function, but I choose to pass them as parameters because it increases the re-usability of the function.

Here’s where those two parameters get used:

$objForm = New-Object System.Windows.Forms.Form

$objForm.Text = $label

$objForm.Size = New-Object System.Drawing.Size(300,415)

$objForm.StartPosition = “CenterScreen”

 

$objLabel = New-Object System.Windows.Forms.Label

$objLabel.Location = New-Object System.Drawing.Size(22,20)

$objLabel.Size = New-Object System.Drawing.Size(280,20)

$objLabel.Text = $LabelText

$objForm.Controls.Add($objLabel)

Each of these sections of this function are defining a specific part of the form itself, how big of a button do I want? What do I want it to say? Where is it placed on the form? How big is the text box itself? Where is the text box on the form? Where is the label? But there’s also the non-visual components in here such as what happens when you click OK? What happens when you click Cancel?

Here’s the part for the OK button:

$x = @()

$OKButton = New-Object System.Windows.Forms.Button

$OKButton.Location = New-Object System.Drawing.Size(45,345)

$OKButton.Size = New-Object System.Drawing.Size(75,23)

$OKButton.Text = “OK”

$OKButton.Add_Click({$x +=@(TextToArray);$objForm.Close()})

$objForm.Controls.Add($OKButton)

The action to perform is defined by what you put into the Add_Click method for the button object. In this case, it’s calling the TextToArray function that we created earlier, and reading the contents of the Text Box into its own variable $x, the value of which is then returned to whatever called it once the form is closed by calling the Close method of the form object.

The cancel button is rarely needed, but I found that if I don’t include it, it makes users uncomfortable because they’re so used to having two options to click on instead of just one. I’m sure someone smarter than me can explain the psychology surrounding that.

$CancelButton = New-Object System.Windows.Forms.Button

$CancelButton.Location = New-Object System.Drawing.Size(150,345)

$CancelButton.Size = New-Object System.Drawing.Size(90,23)

$CancelButton.Text = “Cancel”

$CancelButton.Add_Click({$objForm.Close()})

$objForm.Controls.Add($CancelButton)

Very literally, this creates a button labeled “Cancel” and when you click it, it calls the close method and closes the form without returning any data to the calling variable.

Let’s call that function as we stated earlier:

$serverlist = @(TextBox “Server List” “Enter the list of Servers to scan:”)

And here’s our text box window and I’ve already typed some things into it:

When I look at the value of the variable that called the function:

It took the contents of the Text Box, dumped it to an array, returned that array to the $serverlist variable, and now I’ve got a nice little list of machines to play with.

That covers it for today! Next time, we’ll write a function that we’ll be calling to do the thing that we want to against the list of machines that we give it, and we’ll start building up our script. As always, hit me up if you have any questions, comments, concerns or snide remarks. I’ll see you next time!

If you need some help with getting this set up or how to better use PowerShell to manage your environment, send us an email or give us a call at 502-240-0404!