Configure Your Scripts With a GUI!

Making a GUI in PowerShell is a relatively easy process. GUIs can really come in handy, too! You can use them in scripts to guide execution a specific way, and even ease people into using PowerShell scripts that may otherwise stray away from anything command-line. 

One of the coolest uses I've found for GUIs in PowerShell, is using them for script configuration. You run the script, set a parameter to true, and boom you have a GUI open that allows you to change and configure parts of the script the next time it executes. 

If you're on a team, and you don't want people to have to edit your scripts (actually, if you don't want YOU to even have to edit your scripts), this is the way to go.

Enough blabbing already, let's get to it!

In this post

Prerequisites

  • PowerShell 3.0+ (for this example)
  • Visual Studio Community (or above)
  • Script created in folder that contains the following subfolders:
    • Output
    • Input
      • Example:

A note about Write-Host

I use Write-Host in all my example code for one reason: readability. When you're actually scripting and writing code, you'll want to be sure to use something more versatile like Write-Verbose. Even better, use some logging functions to output to a log file and/or console.

Create a GUI in PowerShell

The GUI creation method I use involved using Visual Studio Community, and outlining the application in the XAML GUI editor. Then I use what I learned from Stephen Owen on his site, Foxdeploy. As this post will not be a how-to on creating GUIs in general, if you're interested in the behind the scenes on what will be happening here when it comes to the GUI, I highly recommend reading through his series, here: https://foxdeploy.com/2015/04/10/part-i-creating-powershell-guis-in-minutes-using-visual-studio-a-new-hope/

Creating Our GUI

The first thing we'll want to do is create our GUI. I will use Visual Studio Community for this.

1. Create new C# WPF Application

2. Design GUI via tools provided by Visual Studio

3. Copy the XAML generated by Visual Studio into PowerShell

Code so far

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

}

#Call function to open the form
Invoke-GUI

We can run the code now, and verify our GUI pops up as it should!

Now to get to work on making things happen!

Configuration File Creation

Let's add the following code to the top of our script:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

This gives us a parameter we can set later that will allow us to pop up the GUI (or not), and we setup some paths the script can use based on where it is run from. We also set the path to the configuration file we'll be creating and using.

Current folder structure:

Function for generating configuration file

Now let's create a function that will serve two purposes:

  • Accept input as the configuration file contents to export
  • Generate a base config if we don't pass any content to export
    • This is nice for first time script execution, and we can include some defaults that we'll expand upon later
function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

Next we'll add in an if statement that will:

  • Create a config file if it doesn't exist (and import it)
  • OR import a config file if it does exist
#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

Full example code so far:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

}

function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

Execution of script with no configuration file:

We can take a peek in the input folder now to verify if it's there, and look at the contents.

Using the Configuration Data

Now that we have the configuration data available, and imported, we can use it in our script.

First, let's take a look at what the config data looks like after it is imported into PowerShell via Import-Clixml.

We have our data handy, and we can confirm that the OUList is indeed an array (as we'd want it to be), if we pipe $config to Get-Member.

With this data available to us, we can add some logic to our script to use it.

First, we'll add an if statement to the script that will determine if we want to launch the configuration GUI, or actually run through the script logic.

Code:

#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

#Script logic
if ($configScript) { #Begin if to see if $configScript is set to true

    #If it's true, run this function to launch the GUI
    Invoke-GUI
    
} else { #Begin if/else for script exeuction (non-config)

    #Simple example for using the OUList defined in the config file
    ForEach ($ou in $script:configData.OUList) { #Begin foreach loop for OU actions

        Write-Host "Performing action on [$ou]!" -ForegroundColor Green -BackgroundColor Black

    } #End foreach loop for OU actions

    #Create some test users
    $userList = Invoke-UserDiscovery

    #Take actions on each user and store results in $processedUsers
    $processedUsers = $userList | Invoke-UserAction
    
    #Create file name for data export
    $outputFileName = ("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv" -f (Get-Date))
    
    #Export processed users various data types
    $processedUsers | Export-Csv -Path $outputFileName -NoTypeInformation
    $processedUsers | Export-Clixml -Path ($outputFileName -replace 'csv','xml')

    Write-Host "File exported to [$outputDir]!"

    #Take a look at the array
    $processedUsers | Format-Table

} #End if/else for script actions (non-config)

In the code above, I have referenced a few different  functions well need for the script to run correctly.  They are Invoke-UserDiscovery and Invoke-UserAction.  

Here is the code for those functions:

function Invoke-UserDiscovery { #Begin function Invoke-UserDiscovery
    [cmdletbinding()]
    param()

    #Create empty arrayList object
    [System.Collections.ArrayList]$userList = @()

    #Create users and add them to array
    $testUser2 = [PSCustomObject]@{

        DisplayName   = 'Mike Jones'
        UserName      = 'jonesm'
        LastLogon     = (Get-Date).AddDays(-35) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }

    $testUser1 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-24) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }    

    $testUser3 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-10) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }  

    $testUser4 = [PSCustomObject]@{

        DisplayName   = 'This WontWork'
        UserName      = 'wontworkt'        
        LastLogon     = $null 
        OU            = Get-Random -inputObject $script:configData.OUList

    }
    
    $testUser5 = [PSCustomObject]@{

        DisplayName   = 'This AlsoWontWork'
        UserName      = 'alsowontworkt'        
        LastLogon     = 'this many!'
        OU            = Get-Random -inputObject $script:configData.OUList

    }        

    #Add users to arraylist
    $userList.Add($testUser1) | Out-Null
    $userList.Add($testUser2) | Out-Null
    $userList.Add($testUser3) | Out-Null
    $userList.Add($testUser4) | Out-Null
    $userList.Add($testUser5) | Out-Null

    #Return list
    Return $userList

} #End function Invoke-UserDiscovery

function Invoke-UserAction { #Begin function Invoke-UserAction
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeline
        )]
        $usersToProcess
    )

    Begin { #Begin begin block for Invoke-UserAction

        #Create array to store results in
        [System.Collections.ArrayList]$processedArray = @()

        Write-Host `n"User processing started!"`n -ForegroundColor Black -BackgroundColor Green

    } #End begin block for Invoke-UserAction

    Process { #Begin process block for function Invoke-UserAction

        foreach ($user in $usersToProcess) { #Begin user foreach loop
        
            #Set variables to null so they are not set by the last iteration
            $lastLogonDays = $null
            $userAction    = $null
            
            $notes         = 'N/A'

            #Some error handling for getting the last logon days
            Try {

                #Set value based on calculation using the LastLogon value of the user
                $lastLogonDays = ((Get-Date) - $user.LastLogon).Days 

            }

            Catch {
            
                #Capture message into variable $errorMessage, and set other variables accordingly
                $errorMessage  = $_.Exception.Message
                $lastLogonDays = $null
                $notes         = $errorMessage 

                Write-Host `n"Error while calculating last logon days [$errorMessage]"`n -ForegroundColor Red -BackgroundColor DarkBlue

            }

            Write-Host `n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."

            #Switch statement to switch out the value of $lastLogonDays
            Switch ($lastLogonDays) { #Begin action switch

                #This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file
                {$_ -lt $script:configData.DisableDays -and $_ -ge $script:configData.WarnDays} { #Begin actions for warning

                    $userAction = 'Warn'

                    Write-Host "Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays - $lastLogonDays)] days!"`n

                    Break

                } #End actions for warning

                #This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file
                {$_ -ge $script:configData.DisableDays} { #Begin actions for disable

                    $userAction = 'Disable'

                    Write-Host "[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays - $script:ConfigData.DisableDays)] days past the threshold!"`n

                    Break

                } #End actions for disable

                {$_ -eq $null} { #Begin actions for a null value

                    $userAction = 'Error'

                    Write-Host "Something went wrong, no value specified for last logon days!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    Break

                } #End actions for a null value

                #Adding a default to catch other values
                default { #Begin default actions

                    $userAction = 'None'                    
                    Write-Host "$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n

                } #Begin default actions

            } #End action switch

            #Create object to store in array
            $processedObject = [PSCustomObject]@{
                
                DisplayName   = $user.DisplayName
                UserName      = $user.UserName
                OU            = $user.OU
                LastLogon     = $user.LastLogon
                LastLogonDays = $lastLogonDays
                Action        = $userAction                
                Notes         = $notes                

            }

            #Add object to array of processed users
            $processedArray.Add($processedObject) | Out-Null

        } #End user foreach loop

    } #End process block for function Invoke-UserAction

    End { #Begin end block for Invoke-UserAction

        Write-Host `n"User processing ended!"`n -ForegroundColor Black -BackgroundColor Green
        
        #Return array
        Return $processedArray

    } #End end block for Invoke-UserAction

} #End function Invoke-UserAction

Full code so far:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

function Invoke-UserDiscovery { #Begin function Invoke-UserDiscovery
    [cmdletbinding()]
    param()

    #Create empty arrayList object
    [System.Collections.ArrayList]$userList = @()

    #Create users and add them to array
    $testUser2 = [PSCustomObject]@{

        DisplayName   = 'Mike Jones'
        UserName      = 'jonesm'
        LastLogon     = (Get-Date).AddDays(-35) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }

    $testUser1 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-24) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }    

    $testUser3 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-10) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }  

    $testUser4 = [PSCustomObject]@{

        DisplayName   = 'This WontWork'
        UserName      = 'wontworkt'        
        LastLogon     = $null 
        OU            = Get-Random -inputObject $script:configData.OUList

    }
    
    $testUser5 = [PSCustomObject]@{

        DisplayName   = 'This AlsoWontWork'
        UserName      = 'alsowontworkt'        
        LastLogon     = 'this many!'
        OU            = Get-Random -inputObject $script:configData.OUList

    }        

    #Add users to arraylist
    $userList.Add($testUser1) | Out-Null
    $userList.Add($testUser2) | Out-Null
    $userList.Add($testUser3) | Out-Null
    $userList.Add($testUser4) | Out-Null
    $userList.Add($testUser5) | Out-Null

    #Return list
    Return $userList

} #End function Invoke-UserDiscovery

function Invoke-UserAction { #Begin function Invoke-UserAction
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeline
        )]
        $usersToProcess
    )

    Begin { #Begin begin block for Invoke-UserAction

        #Create array to store results in
        [System.Collections.ArrayList]$processedArray = @()

        Write-Host `n"User processing started!"`n -ForegroundColor Black -BackgroundColor Green

    } #End begin block for Invoke-UserAction

    Process { #Begin process block for function Invoke-UserAction

        foreach ($user in $usersToProcess) { #Begin user foreach loop
        
            #Set variables to null so they are not set by the last iteration
            $lastLogonDays = $null
            $userAction    = $null
            
            $notes         = 'N/A'

            #Some error handling for getting the last logon days
            Try {

                #Set value based on calculation using the LastLogon value of the user
                $lastLogonDays = ((Get-Date) - $user.LastLogon).Days 

            }

            Catch {
            
                #Capture message into variable $errorMessage, and set other variables accordingly
                $errorMessage  = $_.Exception.Message
                $lastLogonDays = $null
                $notes         = $errorMessage 

                Write-Host `n"Error while calculating last logon days [$errorMessage]"`n -ForegroundColor Red -BackgroundColor DarkBlue

            }

            Write-Host `n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."

            #Switch statement to switch out the value of $lastLogonDays
            Switch ($lastLogonDays) { #Begin action switch

                #This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file
                {$_ -lt $script:configData.DisableDays -and $_ -ge $script:configData.WarnDays} { #Begin actions for warning

                    $userAction = 'Warn'

                    Write-Host "Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays - $lastLogonDays)] days!"`n

                    Break

                } #End actions for warning

                #This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file
                {$_ -ge $script:configData.DisableDays} { #Begin actions for disable

                    $userAction = 'Disable'

                    Write-Host "[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays - $script:ConfigData.DisableDays)] days past the threshold!"`n

                    Break

                } #End actions for disable

                {$_ -eq $null} { #Begin actions for a null value

                    $userAction = 'Error'

                    Write-Host "Something went wrong, no value specified for last logon days!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    Break

                } #End actions for a null value

                #Adding a default to catch other values
                default { #Begin default actions

                    $userAction = 'None'                    
                    Write-Host "$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n

                } #Begin default actions

            } #End action switch

            #Create object to store in array
            $processedObject = [PSCustomObject]@{
                
                DisplayName   = $user.DisplayName
                UserName      = $user.UserName
                OU            = $user.OU
                LastLogon     = $user.LastLogon
                LastLogonDays = $lastLogonDays
                Action        = $userAction                
                Notes         = $notes                

            }

            #Add object to array of processed users
            $processedArray.Add($processedObject) | Out-Null

        } #End user foreach loop

    } #End process block for function Invoke-UserAction

    End { #Begin end block for Invoke-UserAction

        Write-Host `n"User processing ended!"`n -ForegroundColor Black -BackgroundColor Green
        
        #Return array
        Return $processedArray

    } #End end block for Invoke-UserAction

} #End function Invoke-UserAction

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

} #End function Invoke-GUI

function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

#Script logic
if ($configScript) { #Begin if to see if $configScript is set to true

    #If it's true, run this function to launch the GUI
    Invoke-GUI
    
} else { #Begin if/else for script exeuction (non-config)

    #Simple example for using the OUList defined in the config file
    ForEach ($ou in $script:configData.OUList) { #Begin foreach loop for OU actions

        Write-Host "Performing action on [$ou]!" -ForegroundColor Green -BackgroundColor Black

    } #End foreach loop for OU actions

    #Create some test users
    $userList = Invoke-UserDiscovery

    #Take actions on each user and store results in $processedUsers
    $processedUsers = $userList | Invoke-UserAction
    
    #Create file name for data export
    $outputFileName = ("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv" -f (Get-Date))
    
    #Export processed users various data types
    $processedUsers | Export-Csv -Path $outputFileName -NoTypeInformation
    $processedUsers | Export-Clixml -Path ($outputFileName -replace 'csv','xml')

    Write-Host "File exported to [$outputDir]!"

    #Take a look at the array
    $processedUsers | Format-Table

} #End if/else for script actions (non-config)

Overview of what will happen:

  • If $configScript is set to $false, the else statement will execute
  • An example of using config data will be displayed for each of the OUs stored
  • We generate a list of users with some last logon values, and random OUs from the OU list (Invoke-UserDiscovery)
  • We then go through the users and process them, by piping the user array to Invoke-UserAction
  • We store those results in $processedUsers, and then export them to a CSV and Clixml file in the script's output directory
  • Finally, we pipe the results to Format-Table, to review in the console

Let's run the script as is, and see what happens!

You can see that it performed all the steps above, as we would expect it to.

The output directory now contains two files:

CSV File Contents:

Clixml File Contents:

Adding a Modifiable Configuration

Now onto the ability to modify and save the configuration via the GUI we created!

Normally, I stray away from creating functions within functions...

...but in this case I like the way it creates a clean and understandable layout. 

We're going to focus entirely on the function we created earlier, Invoke-GUI.

Here is the new code:

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    #We technically don't need these, but they may come in handy later if you want to pop up message boxes, etc
    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    #Input XAML here
    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Setup the form    
    function Invoke-FormSetup { #Begin function Invoke-FormSetup

        #Here we set the default states of the objects that represent the buttons/fields
        $WPFbtnEdit.IsEnabled          = $true
        $WPFbtnSave.IsEnabled          = $false
        $WPFtxtBoxWarnLow.IsEnabled    = $false
        $WPFtxtBoxDisableLow.IsEnabled = $false
        $WPFtxtBoxOUList.IsEnabled     = $false
        $WPFbtnExceptions.IsEnabled    = $false

        #We will use the current values we imported from the script scoped variable configData
        $WPFtxtBoxWarnLow.Text    = $script:configData.WarnDays
        $WPFtxtBoxDisableLow.Text = $script:configData.DisableDays
        $WPFtxtBoxOUList.Text     = $script:configData.OUList | Out-String

    } #End function Invoke-FormSetup

    function Invoke-FormSaveData { #Begin function Invoke-FormSaveData

        #This function will perform the action to save the form data
        
        #We setup the variables based on the current values of the form
        $warnDays     = [int]$WPFtxtBoxWarnLow.Text 
        $disableDays  = [int]$WPFtxtBoxDisableLow.Text
        $ouList       = ($WPFtxtBoxOUList.Text | Out-String).Trim() -split '[\r\n]' | Where-Object {$_ -ne ''}

        #This object will contain the current configuration we would like to export
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #We then pass the configuration to the function we created earlier that will export the options we pass in
        Invoke-ConfigurationGeneration -configurationOptions $configurationOptions

        #Then we re-import the config file after it is exported via the function above
        $script:configData = Import-Clixml -Path $configFile

        #Finally we revert the GUI to the original state, which will also reflect the lastest configuration that we just exported
        Invoke-FormSetup

    } #End function Invoke-FormSaveData

    #Now we perform actions using the functions we created, as well as code that runs when buttons are clicked

    #Run form setup on launch
    Invoke-FormSetup

    #Button actions
    $WPFbtnEdit.Add_Click{ #Begin edit button actions

        #This will 'open up' the form and allow fields to be edited
        $WPFbtnExceptions.IsEnabled    = $true
        $WPFbtnSave.IsEnabled          = $true
        $WPFtxtBoxWarnLow.IsEnabled    = $true
        $WPFtxtBoxDisableLow.IsEnabled = $true
        $WPFtxtBoxOUList.IsEnabled     = $true
        $WPFbtnExceptions.IsEnabled    = $true

    } #End edit button actions

    $WPFbtnSave.Add_Click{ #Begin save button actions

        #The save button calls the Invoke-FormSaveData function
        Invoke-FormSaveData

    } #End save button actions

    #Show the form
    $form.showDialog() | Out-Null

} #End function Invoke-GUI

Overview:

  • We add a function to save the data when the save button is clicked (Invoke-FormSaveData)
  • We add a function that resets the data on the form and displays the current configuration (Invoke-FormSetup)
  • We add some code that executes when the Edit and Save buttons are clicked

Notes:

The XAML has also been modified a bit (one line of it), to allow us to use the enter key, as well as to add a scrollbar (if needed).

The line that represents the OU list has had the following appended to it: 

AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"

Full line:

<TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>

It was a bit tricky at first when I was learning how to save the OU text box value as an array, here is the code that made it happen:

$ouList = ($WPFtxtBoxOUList.Text | Out-String).Trim() -split '[\r\n]' | Where-Object {$_ -ne ''}

This bit of code allows you to split the text box into an array, and filter out any blank entries.

Running the script to configure the GUI

Now let's set the value of config parameter to $true when we run the script. This will launch the configuration GUI.

configrun.PNG

Time to test it out!

First, I'll simply add another OU.

I'll need to click Edit, and then add the OU:

Now when I click Save it should lock the form again, and display a message in the console.

configrun.PNG

To verify it worked, we can run the script without flagging the parameter to $true.

Here are the current results:

The IT OU has been added, and used!

We can also test changing the warning and disable thresholds, to see how that affects our users and their actions.

Run script in config mode:

I'll set the warning threshold to 10 days, and the disable threshold to 15 days:

Let's run the script again, and check out the results!

It worked! You now have a script that you can modify when you need to with a GUI.

Create Configuration Shortcut

We can create a shortcut that when used, will launch the script with $configScript set to $true, and launch the configuration GUI.

To do this we will need to go to the script directory, and create a new shortcut.

Then, we will set the location to C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe.

After that, we give it a name that makes sense.

Now we need to edit the properties of the shortcut to change a couple things.

First, you'll want to append this to the target: -noprofile -command "& '.\script.ps1' -configScript:$true"

Full Target Value: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -noprofile -command "& '.\script.ps1' -configScript:$true"

Finally, clear out anything in Start in:, and click OK.

You should now be able to use the shortcut to configure the script!

Code on GitHub

All of the code for this script is placed on GitHub, you can check it out here.

You can download the contents as a ZIP file by going to 'Clone or download', and then selecting 'Download ZIP'.

If you download the code, and would like to run it as it using the shortcut or console, you'll need to unblock script.ps1 first.

To do this, right click script.ps1, and go to Properties

Now you'll want to check 'unblock' , and click Apply/OK.

unblock2.PNG

Using the code:

You can then testing out the code by opening PowerShell, and browsing to where you extracted the contents. You can then run .\script.ps1.

cd C:\users\thegn\Desktop\code\
.\script.ps1

You can actually launch the configuration shortcut from here too!

& '.\Configure Script.lnk'

I'll change the warning threshold to 9 days and re-run the script to verify it is working.

Now to run the script and see if everyone at least gets a warning:

There you go, it worked! Please feel free to take whatever pieces of the code you need, and make them work to your heart's desire.

What Next?

  • Apply the logic learned here to scripts you want to have the ability to easily edit on the fly
  • Learn more, and dive in by getting that exceptions button to do... well anything!
  • Dive deeper into PowerShell GUI creation

[top]