PoshChat 2 of 2: Building a Chat Client Using PowerShell

Continuing from yesterday’s article where I talked about how I wrote the code to run the chat server portion of PoshChat, this article will now go into what I did to create the client interface that connects to the server and allows you to send messages to others connected to the server.

About half of the code is setting the UI of the client while another chunk of code sets up some of the controls. The rest is where I set up the connection to the server and send/receive messages that have been relayed from the server from other clients.

As with the server, there were some requirements that I wanted to lay out before starting on this client.

Requirements:

  • Make a connection to the server
  • Allow a username and server name to be defined for connection
  • Able to actively listen for messages while still being able to send messages
  • Cleanly close connections when exiting client

With the requirements out of the way, lets take a look at some code.

$rs=[RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"
$rs.ThreadOptions = "ReuseThread"
$rs.Open()
$ps = [PowerShell]::Create()
$ps.Runspace = $rs

First thing that I am doing is setting up the runspace that will run in the background to free up the console while the UI is running. The key thing here is that I am setting the apartment state to ‘STA’ so the UI will work normally. This means that you can run the console in ‘MTA’ without worry about the UI not starting up.

$handle = $ps.AddScript({               
Add-Type –assemblyName PresentationFramework
Add-Type –assemblyName PresentationCore
Add-Type –assemblyName WindowsBase               
[xml]$xaml = @"
<Window
    xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
    x:Name='Window' Title='PoshChat' Height = '600' Width = '800' ResizeMode = 'NoResize' WindowStartupLocation = 'CenterScreen' ShowInTaskbar = 'True'>    
    <Window.Background>
        <LinearGradientBrush StartPoint='0,0' EndPoint='0,1'>
            <LinearGradientBrush.GradientStops> <GradientStop Color='#C4CBD8' Offset='0' /> <GradientStop Color='#E6EAF5' Offset='0.2' /> 
            <GradientStop Color='#CFD7E2' Offset='0.9' /> <GradientStop Color='#C4CBD8' Offset='1' /> </LinearGradientBrush.GradientStops>
        </LinearGradientBrush>
    </Window.Background>    
    <Grid ShowGridLines = 'false'>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width ='175*'> </ColumnDefinition>
            <ColumnDefinition Width ='Auto'> </ColumnDefinition>
            <ColumnDefinition Width ='75*'> </ColumnDefinition>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height = '*'/>
            <RowDefinition Height = '10'/>
            <RowDefinition Height = '80'/>
        </Grid.RowDefinitions>     
        <TextBox x:Name = 'MainMessage_txt' Grid.Column = '0' Grid.Row = '0' IsReadOnly = 'True' VerticalScrollBarVisibility='Visible'
        TextWrapping = 'Wrap'/>   
        <Label Grid.Column='1' Grid.Row = '0' Width='8' Grid.RowSpan = '3' HorizontalAlignment = 'Center' VerticalAlignment = 'Stretch'
        Background = 'LightGray'/>
        <Label Grid.Column = '0' Grid.Row = '1' Grid.ColumnSpan = '3' Background = 'LightGray'/>
        <ListView x:Name = 'OnlineUsers' Grid.Column = '2' Grid.Row = '0' />
        <StackPanel Grid.Column = '0' Grid.Row = '2' Orientation="Horizontal">
            <TextBox x:Name = 'Input_txt' Width = '500' AcceptsReturn = 'True' VerticalScrollBarVisibility='Visible' TextWrapping = 'Wrap'/>
            <Button x:Name = 'Send_btn' Width = '50' Height = '25' Content = 'Send' />
        </StackPanel>        
        <StackPanel Grid.Column = '2' Grid.Row = '2'>
            <StackPanel Orientation="Horizontal">
                <Label Content = 'UserName'/>
                <TextBox x:Name = 'username_txt' Width = '150' />
            </StackPanel>
            <StackPanel Orientation="Horizontal">
                <Label Content = 'Server' Width = '66' />
                <TextBox x:Name = 'servername_txt' Width = '150'/>
            </StackPanel>          
            <Label Height = '3' />
            <Button x:Name = 'Connect_btn' Width = '75' Height = '20' Content = 'Connect'/>
        </StackPanel>
    </Grid>
</Window>

"@
#Load XAML
$reader=(New-Object System.Xml.XmlNodeReader $xaml)
$Window=[Windows.Markup.XamlReader]::Load( $reader )

##Controls
$Global:OnlineUsers = $Window.FindName('OnlineUsers')
$SendButton = $Window.FindName('Send_btn')
$Global:ConnectButton = $Window.FindName('Connect_btn')
$Username_txt = $Window.FindName('username_txt')
$Server_txt = $Window.FindName('servername_txt')
$Inputbox_txt = $Window.FindName('Input_txt')
$Global:MainMessage = $Window.FindName('MainMessage_txt')

Here is the beginning of the scriptblock that I will supply to the runspace. The first thing I do is load of the assemblies required to run the WPF client and display the UI. I am also setting up the UI using XAML code. the first line here is where I begin adding the code to the runspace via a scriptblock. The scriptblock will not be closed up until the end of the script. The XAML code is casted to XML using the [XML] type accelerator that then gets loaded into the System.XML.XMLNodeReader. Next, I connect to the controls that I need access to later on in the code to perform a variety of things such as handling button events or reading from a text box.

##Events
#Connect
$ConnectButton.Add_Click({
    $ConnectButton.IsEnabled = $False
    
    #Get Server IP
    $Server = $Server_txt.text
    
    #Get Username
    $Global:Username = $Username_txt.text
    
    If ($Server -AND $Username) {        
        $MainMessage.text = "{0} >> Connecting to {1} as {2}`n" -f (Get-Date).ToString(),$Server,$username
        
        #Connect to server
        $Endpoint = new-object System.Net.IPEndpoint ([ipaddress]::any,$SourcePort)
        $TcpClient = [Net.Sockets.TCPClient]$endpoint    
        Try {
            $TcpClient.Connect($Server,15600)
            $Global:ServerStream = $TcpClient.GetStream()
            $data = [text.Encoding]::Ascii.GetBytes($Username)
            $ServerStream.Write($data,0,$data.length)
            $ServerStream.Flush()   
            If ($TcpClient.Connected) {       
                $Window.Title = ("{0}: Connected as {1}" -f $Window.Title,$Username)
                #Kick off a job to watch for messages from clients
                $newRunspace = [RunSpaceFactory]::CreateRunspace()
                $newRunspace.Open()
                $newRunspace.SessionStateProxy.setVariable("TcpClient", $TcpClient)
                $newRunspace.SessionStateProxy.setVariable("MessageQueue", $MessageQueue)                
                $newPowerShell = [PowerShell]::Create()
                $newPowerShell.Runspace = $newRunspace   
                $sb = {
                    #Code to kick off client connection monitor and look for incoming messages.
                    $client = $TCPClient
                    $serverstream = $Client.GetStream()
                    #While client is connected to server, check for incoming traffic
                    While ($client.Connected) {                        
                        [byte[]]$inStream = New-Object byte[] 10025
                        $buffSize = $client.ReceiveBufferSize
                        $return = $serverstream.Read($inStream, 0, $buffSize)
                        If ($return -gt 0) {
                            $Messagequeue.Enqueue([System.Text.Encoding]::ASCII.GetString($inStream[0..($return - 1)]))
                        }
                    }
                    #Shutdown the connection as connection has ended
                    $client.Client.Disconnect($True)
                    $client.Client.Close()
                    $client.Close()                      
                }
                $job = "" | Select Job, PowerShell
                $job.PowerShell = $newPowerShell
                $Job.job = $newPowerShell.AddScript($sb).BeginInvoke()
                $ClientConnection.$Username = $job             
            }
        } Catch {
            #Errors Connecting to server
            $MainMessage.text = ("Unable to connect to {0}!'nPlease try again later!" -f $RemoteServer)
            $ConnectButton.IsEnabled = $True
            $TcpClient.Close()  
            $ClientConnections.user.PowerShell.EndInvoke($ClientConnections.user.Job)
            $ClientConnections.user.PowerShell.Runspace.Close()
            $ClientConnections.user.PowerShell.Dispose()
        }
    }
})

Now we are getting into the first control event being handled. This is what controls the connect button and is actually the biggest chunk of code as it has to handle the initial connection attempt to the chat server. A validation is made to be sure that a username and server name is supplied before proceeding. Once that is done a local endpoint is created using a random port number using System.Net.IPEndpoint class. Once that has been completed, a TCP object is created and begins an attempted connection to the chat server over port 15600 (currently hard-coded, but will changed in the next release) and if a connection is successful, then spawns a new runspace that will continue to handle the connection and listen for messages from the server.

The created runspace is saved to a synced hashtable ($ClientConnections) that will be used later if the client is closed so it can gracefully close out all of the runspaces and connections.

Another key piece of code is here:

While ($client.Connected) {                        
    [byte[]]$inStream = New-Object byte[] 10025
    $buffSize = $client.ReceiveBufferSize
    $return = $serverstream.Read($inStream, 0, $buffSize)
    If ($return -gt 0) {
        $Messagequeue.Enqueue([System.Text.Encoding]::ASCII.GetString($inStream[0..($return - 1)]))
    }
}

This ensures that while the connection is active, it will constantly look for messages from the server. The Read() method will block any more action in the runspace until a message has been received on the client. If the message contains data, it will be translated from bytes and sent to the message queue.

#Send message
$SendButton.Add_Click({
    #Send message to server
    $Message = "~M{0}{1}{2}" -f $username,"~~",$Inputbox_txt.Text
    $data = [text.Encoding]::Ascii.GetBytes($Message)
    $ServerStream.Write($data,0,$data.length)
    $ServerStream.Flush()  
    $Inputbox_txt.Clear()  
})

This piece of code handles the send button event when clicked. The text from the inputbox is collected with the “~M” appended to it so can be interpreted as a message, then it gets converted into a byte[] array before being sent to the chat server.

#Load Window
$Window.Add_Loaded({
    #Used for managing the queue of messages in an orderly fashion
    $Global:MessageQueue =  [System.Collections.Queue]::Synchronized((New-Object System.collections.queue))      
    #Used for managing client connection
    $Global:ClientConnection = [hashtable]::Synchronized(@{}) 
    #Create Timer object
    $Global:timer = new-object System.Windows.Threading.DispatcherTimer 
    #Fire off every 1 seconds
    $timer.Interval = [TimeSpan]"0:0:1.00"
    #Add event per tick
    $timer.Add_Tick({    
        Write-Host ('Message Count: {0}' -f $Messagequeue.count)
        [Windows.Input.InputEventHandler]{ $Global:Window.UpdateLayout() }
        If ($Messagequeue.Count -gt 0) {
            $Message = $Messagequeue.Dequeue()
            Switch ($Message) {
                {$_.Startswith("~M")} {
                    #Message
                    $data = ($_).SubString(2)
                    $split = $data -split ("{0}" -f "~~")
                    $MainMessage.text += ("{0} >> {1}: {2}`n" -f (Get-Date).ToString(),$split[0],$split[1])
                }
                {$_.Startswith("~D")} {
                    #Disconnect
                    $MainMessage.text += ("{0} >> {1} has disconnected from the server`n" -f (Get-Date).ToString(),$_.SubString(2))
                    #Remove user from online list
                    $OnlineUsers.Items.Remove($_.SubString(2))
                }
                {$_.StartsWith("~C")} {
                    #Connect
                    $MainMessage.text += ("{0} >> {1} has connected to the server`n" -f (Get-Date).ToString(),$_.SubString(2))  
                    ##Add user to online list       
                    If ($Username -ne $_.SubString(2)) {
                        $OnlineUsers.Items.Add($_.SubString(2))   
                    }
                }
                {$_.StartsWith("~S")} {
                    #Server Shutdown
                    $MainMessage.text += ("{0} >> SERVER HAS DISCONNECTED.`n" -f (Get-Date).ToString())  
                    $TcpClient.Close()  
                    $ClientConnection.user.PowerShell.EndInvoke($ClientConnections.user.Job)
                    $ClientConnection.user.PowerShell.Runspace.Close()
                    $ClientConnection.user.PowerShell.Dispose()  
                    $ConnectButton.IsEnabled = $True   
                    $DisconnectButton.IsEnabled = $False  
                    $OnlineUsers.Items.Clear()                                       
                }                 
                {$_.StartsWith("~Z")} {
                    #List of connected users
                    $online = (($_).SubString(2) -split "~~")
                    #Add online users to window
                    $Online | ForEach {
                        $OnlineUsers.Items.Add($_)
                    }
                }
                Default {
                    $MainMessage.text += ("{0} >> {1}`n" -f (Get-Date).ToString(),$_)
                }
            }            
        } 
    })

 

This really is a two-part chunk of code as it not only handles the initial loading of the form, but also sets up a form timer that performs an action with every tick. The ‘tick’ is set for every second. Two synchronized objects are created to handle the client/server connection and to handle all of the messages from the server.

  1. MessageQueue
    1. Handles all of the message traffic from the server
  2. ClientConnection
    1. Handles the client connection with the server

Next up is creating and configuring the form timer (System.Windows.Threading.DispatcherTimer) to check the message queue and print out messages to the client based on what type of message is received from the server. The following types of messages that are accepted are:

  1. ~M
    1. Standard messages from other connected clients
  2. ~D
    1. Handles messages when other clients are disconnected from server
  3. ~C
    1. Handles messages when new clients are connected to server
  4. ~S
    1. Handles the message when the server is shutdown or closes client connection unexpectedly
  5. ~Z
    1. Handles the initial connection message sent from the server listing all of the currently connected clients.
    #Start timer
    $timer.Start()
    If (-NOT $timer.IsEnabled) {
        $Window.Close()
    }

 

This starts up the timer and if it is not enabled, then the form will close on its own to avoid any errors.

#Disconnect from server
$DisconnectButton.Add_Click({    
    $MainMessage.text += ("{0} >> Disconnecting from server: {1}`n" -f (Get-Date).ToString(),$Server)
    #Shutdown client runspace and socket
    $TcpClient.Close()  
    $ClientConnection.user.PowerShell.EndInvoke($ClientConnection.user.Job)
    $ClientConnection.user.PowerShell.Runspace.Close()
    $ClientConnection.user.PowerShell.Dispose()
    $ConnectButton.IsEnabled = $True   
    $DisconnectButton.IsEnabled = $False
    $OnlineUsers.Items.Clear()
})

Here we are handling the disconnect button event. A message is displayed on the window stating the client is disconnecting and then the socket connection is closed. Afterwards, the rest of the runspace starts shutting down and getting disposed. Finally the list of online clients is cleared and the Connect and Disconnect buttons swap availability to be enabled and disabled.

#Close Window
$Window.Add_Closed({
    $TcpClient.Close()  
    $ClientConnection.user.PowerShell.EndInvoke($ClientConnection.user.Job)
    $ClientConnection.user.PowerShell.Runspace.Close()
    $ClientConnection.user.PowerShell.Dispose()
})

Because there is a chance that the window could be closed instead of using the Disconnect button, I want to be sure to handle that event and shut everything down gracefully. The main pieces are here to shutdown the socket and the runspace.

[void]$Window.showDialog()

This is the last piece of code in the runspace scriptblock and it is very vital as it is the code that starts up the UI for the client. I use ShowDialog() because it will bring up the window in the runspaces thread instead of using Show() which will cause the UI to lockup and become un-usable.

}).BeginInvoke()

And here is the last piece of code in the script! I close out the scriptblock and then at the same begin running the runspace using BeginInvoke(). Using BeginInvoke() calls the runspace asynchronously instead of synchronously which allows the console that it is called from to be free from waiting for the runspace to finish, which would have happened had I used Invoke().

When the code is run, you see this:

. .\Start-PoshChatClient.ps1

image

A nice clean chat client that you can now use to connect to the chat server that was shown in the previous article.

image

Remember, you can download PoshChat here and if you have any feature request or find bugs, to report those here.

This entry was posted in powershell, scripts and tagged , , , . Bookmark the permalink.

Leave a comment