PowerRemoteDesktop_Viewer.psm1

<#-------------------------------------------------------------------------------
 
    Power Remote Desktop
    Version 1.0 beta 2
    REL: January 2022.
 
    In loving memory of my father.
    Thanks for all you've done.
    you will remain in my heart forever.
 
    .Developer
        Jean-Pierre LESUEUR (@DarkCoderSc)
        https://www.twitter.com/darkcodersc
        https://github.com/DarkCoderSc
        www.phrozen.io
        jplesueur@phrozen.io
        PHROZEN
 
    .License
        Apache License
        Version 2.0, January 2004
        http://www.apache.org/licenses/
 
    .Why
        - Prove PowerShell is as "PowerFul" as compiled language.
        - Improve my PowerShell skills.
        - Because Remote Desktop Powershell Scripts doesn't exists so far.
 
    .Important
        This PowerShell Application is not yet marked as Stable / Final. It is not recommended to use
        it in a production environment at this time.
        Wait for final 1.0 version.
 
    .Disclaimer
        We are doing our best to prepare the content of this app. However, PHROZEN SASU cannot
        warranty the expressions and suggestions of the contents, as well as its accuracy.
        In addition, to the extent permitted by the law, PHROZEN SASU shall not be responsible
        for any losses and/or damages due to the usage of the information on our app.
 
        By using our app, you hereby consent to our disclaimer and agree to its terms.
 
        Any links contained in our app may lead to external sites are provided for
        convenience only. Any information or statements that appeared in these sites
        or app are not sponsored, endorsed, or otherwise approved by PHROZEN SASU.
        For these external sites, SubSeven Legacy cannot be held liable for the
        availability of, or the content located on or through it.
        Plus, any losses or damages occurred from using these contents or the internet
        generally.
 
    .Todo
        - [EASY] Do a deep investigation about SecureString and if it applies to current project (to protect password)
        - [EASY] Support Password Protected external Certificates.
        - [EASY] Server Fingerprint Authentication.
        - [EASY] Mutual Authentication for SSL/TLS (Client Certificate).
        - [EASY] Synchronize Cursor State.
        - [EASY] Synchronize Clipboard.
        - [MEDIUM] Keep-Alive system to implement Read / Write Timeout.
        - [MEDIUM] Improve Virtual Keyboard.
        - [MEDIUM] Server Concurrency.
        - [MEDIUM] Listen for local/remote screen resolution update event.
        - [MEDIUM] Multiple Monitor Support.
        - [MEDIUM] Improve HDPI Scaling / Quality.
        - [MEDIUM+] Motion Update for Desktop Streaming (Only send and update changing parts of desktop).
 
-------------------------------------------------------------------------------#>


Add-Type -Assembly System.Windows.Forms

$global:PowerRemoteDesktopVersion = "1.0.beta.2"

function Write-Banner 
{
    <#
        .SYNOPSIS
            Output cool information about current PowerShell module to terminal.
    #>


    Write-Host ""
    Write-Host "Power Remote Desktop - Version " -NoNewLine
    Write-Host $global:PowerRemoteDesktopVersion -ForegroundColor Cyan
    Write-Host "Jean-Pierre LESUEUR (" -NoNewLine
    Write-Host "@DarkCoderSc" -NoNewLine -ForegroundColor Green
    Write-Host ") " -NoNewLine
    Write-Host "#" -NoNewLine -ForegroundColor Blue
    Write-Host "#" -NoNewLine -ForegroundColor White
    Write-Host "#" -ForegroundColor Red
    Write-Host "https://" -NoNewLine -ForegroundColor Green
    Write-Host "www.github.com/darkcodersc"
    Write-Host "https://" -NoNewLine -ForegroundColor Green
    Write-Host "www.phrozen.io" 
    Write-Host ""
    Write-Host "License: Apache License (Version 2.0, January 2004)"
    Write-Host "https://" -NoNewLine -ForegroundColor Green
    Write-Host "www.apache.org/licenses/"
    Write-Host ""
}

function Get-SHA512FromString
{
    <#
        .SYNOPSIS
            Return the SHA512 value from string.
 
        .PARAMETER String
            A String to hash.
 
        .EXAMPLE
            Get-SHA512FromString -String "Hello, World"
    #>

    param (
        [Parameter(Mandatory=$True)]
        [string] $String
    )

    $buffer = [IO.MemoryStream]::new([byte[]][char[]]$String)

    return (Get-FileHash -InputStream $buffer -Algorithm SHA512).Hash
}

function Resolve-AuthenticationChallenge
{
    <#
        .SYNOPSIS
            Algorithm to solve the server challenge during password authentication.
 
        .PARAMETER Password
            Registered password string for server authentication.
 
        .PARAMETER Candidate
            Random string used to solve the challenge. This string is public and is set across network by server.
            Each time a new connection is requested to server, a new candidate is generated.
 
        .EXAMPLE
            Resolve-AuthenticationChallenge -Password "s3cr3t!" -Candidate "rKcjdh154@]=Ldc"
    #>

    param (        
       [Parameter(Mandatory=$True)]
       [string] $Password, 

       [Parameter(Mandatory=$True)]
       [string] $Candidate
    )

    $solution = -join($Candidate, ":", $Password)

    for ([int] $i = 0; $i -le 1000; $i++)
    {
        $solution = Get-SHA512FromString -String $solution
    }

    return $solution
}

$global:VirtualDesktopUpdaterScriptBlock = {   
    <#
        .SYNOPSIS
            Threaded code block to receive updates of remote desktop and update Virtual Desktop Form.
 
            This code is expected to be run inside a new PowerShell Runspace.
 
        .PARAMETER syncHash.Client
            A ClientIO Class instance for handling desktop updates.
 
        .PARAMETER syncHash.Param.RequireResize
            Tell if desktop image needs to be resized to fit viewer screen constrainsts.
 
        .PARAMETER syncHash.Param.VirtualDesktopWidth
            The integer value representing remote screen width.
 
        .PARAMETER syncHash.Param.VirtualDesktopHeight
            The integer value representing remote screen height.
 
        .PARAMETER syncHash.Param.VirtualDesktopForm
            Virtual Desktop Object containing both Form and PaintBox.
 
        .PARAMETER syncHash.Param.TransportMode
            Define desktop image transport mode: Raw or Base64. This value is defined by server following
            its options.
    #>


    enum TransportMode {
        Raw = 1
        Base64 = 2
    }

    function Invoke-SmoothResize
    {
        <#
            .SYNOPSIS
                Output a resized version of input bitmap. The resize quality is quite fair.
                 
            .PARAMETER OriginalImage
                Input bitmap to resize.
 
            .PARAMETER NewWidth
                Define the width of new bitmap version.
 
            .PARAMETER NewHeight
                Define the height of new bitmap version.
 
            .EXAMPLE
                Invoke-SmoothResize -OriginalImage $myImage -NewWidth 1920 -NewHeight 1024
        #>

        param (
            [Parameter(Mandatory=$true)]
            [System.Drawing.Bitmap] $OriginalImage,

            [Parameter(Mandatory=$true)]
            [int] $NewWidth,

            [Parameter(Mandatory=$true)]
            [int] $NewHeight
        )
        try
        {    
            $bitmap = New-Object -TypeName System.Drawing.Bitmap -ArgumentList $NewWidth, $NewHeight

            $resizedImage = [System.Drawing.Graphics]::FromImage($bitmap)

            $resizedImage.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
            $resizedImage.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
            $resizedImage.InterpolationMode =  [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
            $resizedImage.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality 
            
            $resizedImage.DrawImage($OriginalImage, 0, 0, $bitmap.Width, $bitmap.Height)

            return $bitmap
        }
        finally
        {
            if ($OriginalImage)
            {
                $OriginalImage.Dispose()
            }

            if ($resizedImage)
            {
                $resizedImage.Dispose()
            }
        }
    }

    try
    {       
        $packetSize = 4096

        while ($true)
        {                   
            $stream = New-Object System.IO.MemoryStream
            try
            {      
                switch ([TransportMode] $syncHash.Param.TransportMode)         
                {
                    "Raw"
                    {                         
                        $buffer = New-Object -TypeName byte[] -ArgumentList 4 # SizeOf(Int32)

                        $syncHash.Client.SSLStream.Read($buffer, 0, $buffer.Length)

                        [int32] $totalBufferSize = [BitConverter]::ToInt32($buffer, 0)                

                        $stream.SetLength($totalBufferSize)

                        $stream.position = 0

                        $totalBytesRead = 0

                        $buffer = New-Object -TypeName Byte[] -ArgumentList $packetSize
                        do
                        {
                            $bufferSize = $totalBufferSize - $totalBytesRead
                            if ($bufferSize -gt $packetSize)
                            {
                                $bufferSize = $packetSize
                            }    
                            else
                            {
                                # Save some memory operations for creating objects.
                                # Usually, bellow code is call when last chunk is being sent.
                                $buffer = New-Object -TypeName byte[] -ArgumentList $bufferSize
                            }                

                            $syncHash.Client.SSLStream.Read($buffer, 0, $bufferSize)                    

                            $stream.Write($buffer, 0, $buffer.Length) | Out-Null

                            $totalBytesRead += $bufferSize
                        } until ($totalBytesRead -eq $totalBufferSize)
                    }

                    "Base64"
                    {
                        [byte[]] $buffer = [System.Convert]::FromBase64String(($syncHash.Client.Reader.ReadLine()))

                        $stream.Write($buffer, 0, $buffer.Length)   
                    }
                }                    

                $stream.Position = 0                                                                

                if ($syncHash.Param.RequireResize)
                {
                   #$image = [System.Drawing.Image]::FromStream($stream)

                   $bitmap = New-Object -TypeName System.Drawing.Bitmap -ArgumentList $stream

                   $syncHash.Param.VirtualDesktopForm.Picture.Image = Invoke-SmoothResize -OriginalImage $bitmap -NewWidth $syncHash.Param.VirtualDesktopWidth -NewHeight $syncHash.Param.VirtualDesktopHeight                   
                }
                else
                {                    
                    $syncHash.Param.VirtualDesktopForm.Picture.Image = [System.Drawing.Image]::FromStream($stream)                                                          
                }
            }
            catch 
            {
                $syncHash.Param.host.UI.WriteLine($_)
                break
            }            
            finally
            {
                $stream.Close()
            }
                
        }
    }
    finally
    {
        $syncHash.Param.VirtualDesktopForm.Form.Close()
    }
}

class ClientIO {
    <#
        .SYNOPSIS
            Extended version of TcpClient that automatically creates and releases
            required streams with other useful methods.
 
            Supports SSL/TLS.
    #>


    [string] $RemoteAddress
    [int] $RemotePort
    [bool] $TLSv1_3

    [System.Net.Sockets.TcpClient] $Client = $null
    [System.Net.Security.SslStream] $SSLStream = $null
    [System.IO.StreamWriter] $Writer = $null
    [System.IO.StreamReader] $Reader = $null

    ClientIO(
        <#
            .SYNOPSIS
                Class constructor.
 
            .PARAMETER RemoteAddress
                IP/HOST of remote server.
 
            .PARAMETER RemotePort
                Remote server port.
 
            .PARAMETER TLSv1_3
                Define whether or not SSL/TLS v1.3 must be used.
        #>

        [string] $RemoteAddress = "127.0.0.1",
        [int] $RemotePort = 2801,
        [bool] $TLSv1_3 = $false
    ) {
        $this.RemoteAddress = $RemoteAddress
        $this.RemotePort = $RemotePort
        $this.TLSv1_3 = $TLSv1_3
    }

    [void]Connect() {
        <#
            .SYNOPSIS
                Open a new connection to remote server.
                Create required streams and open a new secure connection with peer.
        #>

        Write-Verbose "Connect: ""$($this.RemoteAddress):$($this.RemotePort)..."""

        $this.Client = New-Object System.Net.Sockets.TcpClient($this.RemoteAddress, $this.RemotePort)

        Write-Verbose "Connected."

        if ($this.TLSv1_3)
        {
            $TLSVersion = [System.Security.Authentication.SslProtocols]::TLS13
        }
        else {
            $TLSVersion = [System.Security.Authentication.SslProtocols]::TLS12
        }  

        Write-Verbose "Establish an encrypted tunnel using: ${TLSVersion}..."

        $this.SSLStream = New-object System.Net.Security.SslStream(
            $this.Client.GetStream(),
            $false,
            {
                    param(
                        $Sendr,
                        $Certificate,
                        $Chain,
                        $Policy
                ) 

                # TODO: Certificate Validation
                return $true
            }
        )              

        $this.SSLStream.AuthenticateAsClient(
            "PowerRemoteDesktop",
            $null,
            $TLSVersion,
            $null
        )

        if (-not $this.SSLStream.IsEncrypted)
        {
            throw "Could not establish a secure communication channel with remote server."
        }

        $this.Writer = New-Object System.IO.StreamWriter($this.SSLStream)
        $this.Writer.AutoFlush = $true

        $this.Reader = New-Object System.IO.StreamReader($this.SSLStream) 

        Write-Verbose "Encrypted tunnel opened and ready for use."               
    }

    [void]Authentify([string] $Password) {
        <#
            .SYNOPSIS
                Handle authentication process with remote server.
 
            .PARAMETER Password
                Password used for authentication with server.
 
            .EXAMPLE
                .Authentify("s3cr3t!")
        #>


        Write-Verbose "Authentify with remote server (Challenged-Based Authentication)..."

        $candidate = $this.Reader.ReadLine()                        

        $challengeSolution = Resolve-AuthenticationChallenge -Candidate $candidate -Password $Password   

        Write-Verbose "@Challenge:"
        Write-Verbose "Candidate: ""${candidate}"""
        Write-Verbose "Solution: ""${challengeSolution}"""
        Write-Verbose "---"            

        $this.Writer.WriteLine($challengeSolution)

        $result = $this.Reader.ReadLine()
        if ($result -eq "OK.")
        {
            Write-Verbose "Solution accepted. Authentication success."                
        }            
        else 
        {
            throw "Solution declined. Authentication failed."                
        }
    
    }

    [void]Hello([string] $SessionId) {
        <#
            .SYNOPSIS
                This method must be called after Password-Authentication to finalise an established
                connection with server.
 
            .PARAMETER SessionId
                A String containing the Session Id.
        #>


        Write-Verbose "Say Hello..."

        $this.Writer.WriteLine($SessionId)

        $result = $this.Reader.ReadLine()
        if ($result -eq "HELLO.")
        {
            Write-Verbose "Server Hello back."
        }            
        else 
        {
            throw "Could not finalise connection with remote server. Session Id is wrong or was terminated."
        }
    }
    
    [PSCustomObject]Hello(){
        <#
            .SYNOPSIS
                This method must be called after Password-Authentication to finalise an established
                connection with server.
 
            .DESCRIPTION
                This method is called when no session is already present. Server will send several informations
                including a new session id the store.
 
                TODO: Instead of PSCustomObject, create a specific class ?
        #>

        
        Write-Verbose "Say Hello..."

        $jsonObject = $this.Reader.ReadLine()

        Write-Verbose "@SessionInformation:"
        Write-Verbose $jsonObject
        Write-Verbose "---"

        $sessionInformation = $jsonObject | ConvertFrom-Json
        if (
            (-not ($sessionInformation.PSobject.Properties.name -match "MachineName")) -or
            (-not ($sessionInformation.PSobject.Properties.name -match "Username")) -or
            (-not ($sessionInformation.PSobject.Properties.name -match "WindowsVersion")) -or                      
            (-not ($sessionInformation.PSobject.Properties.name -match "SessionId")) -or
            (-not ($sessionInformation.PSobject.Properties.name -match "TransportMode")) -or
            (-not ($sessionInformation.PSobject.Properties.name -match "Version")) -or
            (-not ($sessionInformation.PSobject.Properties.name -match "ScreenInformation"))
        )
        {
            throw "Invalid session information data."
        }   
        
        if ($sessionInformation.Version -ne $global:PowerRemoteDesktopVersion)
        {
            throw "Server and Viewer version mismatch.`r`n`
            Local: ""${global:PowerRemoteDesktopVersion}""`r`n`
            Remote: ""$($sessionInformation.Version)""`r`n`
            You cannot use two different version between Viewer and Server."

        }

        return $sessionInformation
    }

    [void]Close() {
        <#
            .SYNOPSIS
                Release Streams and Connections.
        #>

        if ($this.Writer)
        {
            $this.Writer.Close()
        }

        if ($this.Reader)
        {
            $this.Reader.Close()
        }

        if ($this.SSLStream)
        {
            $this.SSLStream.Close()
        }

        if ($this.Client)
        {            
            $this.Client.Close()
        }
    }
}

class ViewerSession
{
    <#
        .SYNOPSIS
            Viewer Session Class
 
        .DESCRIPTION
            Contains methods to handle from A to Z the Power Remote Desktop Protocol.
    #>


    [PSCustomObject] $SessionInformation = $null
    [string] $ServerAddress = "127.0.0.1"
    [string] $ServerPort = 2801
    [string] $Password = ""
    [bool] $TLSv1_3 = $false        

    [ClientIO] $ClientDesktop = $null
    [ClientIO] $ClientControl = $null

    ViewerSession(        
        [string] $ServerAddress,
        [int] $ServerPort,
        [string] $Password,
        [bool] $TLSv1_3
    )    
    {
        <#
            .SYNOPSIS
                Create a new viewer session object.
 
            .DESCRIPTION
                This object will contain session information including active connection
                objects (ClientIO)
 
            .PARAMETER ServerAddress
            Remote Server Address.
 
            .PARAMETER ServerPort
                Remote Server Port.
 
            .PARAMETER Password
                Password used during server authentication.
 
            .PARAMETER TLSv1_3
                Define whether or not client must use SSL/TLS v1.3 to communicate with remote server.
                Recommended if possible.
        #>


        # TODO: Check if ServerAddress is a valid host.
        
        # Or: System.Management.Automation.Runspaces.MaxPort (High(Word))
        if ($ServerPort -lt 0 -and $ServerPort -gt 65535)
        {
            throw "Invalid TCP Port (0-65535)"
        }

        $this.ServerAddress = $ServerAddress
        $this.ServerPort = $ServerPort 
        $this.Password = $Password
        $this.TLSv1_3 = $TLSv1_3           
    }

    [void] OpenSession() {
        <#
            .SYNOPSIS
                Establish a new complete session with remote server.
 
            .DESCRIPTION
                This method handle both session handshake and Password-Authentication.
        #>
        
        Write-Verbose "Open new session with remote server: ""$($this.ServerAddress):$($this.ServerPort)""..."

        if ($this.SessionInformation)
        {
            throw "An session already exists. Close existing session first."
        }

        Write-Verbose "Establish first contact with remote server..."

        $this.ClientDesktop = [ClientIO]::New($this.ServerAddress, $this.ServerPort, $this.TLSv1_3)
        try
        {
            $this.ClientDesktop.Connect()        

            $this.ClientDesktop.Authentify($this.Password)

            $this.SessionInformation = $this.ClientDesktop.Hello()

            Write-Verbose "Open secondary tunnel for input control..."

            $this.ClientControl = [ClientIO]::new($this.ServerAddress, $this.ServerPort, $this.TLSv1_3) 
            $this.ClientControl.Connect()    
            
            $this.ClientControl.Authentify($this.Password)

            $this.ClientControl.Hello($this.SessionInformation.SessionId)

            Write-Verbose "New session successfully established with remote server."
            Write-Verbose "Session Id: $($this.SessionInformation.SessionId)"
        }
        catch
        {            
            $this.CloseSession()

            throw "Open Session Error. Detail: ""$($_)"""
        }        
    }

    [void] CloseSession() {
        <#
            .SYNOPSIS
                Close an existing session with remote server.
                Terminate active connections and reset session informations.
        #>


        Write-Verbose "Close existing session..."

        if ($this.ClientDesktop)
        {
            $this.ClientDesktop.Close()
        }

        if ($this.ClientControl)
        {
            $this.ClientControl.Close()
        }        

        $this.ClientDesktop = $null
        $this.ClientControl = $null
        
        $this.SessionInformation = $null
        
        Write-Verbose "Session closed."
    }

}

function New-VirtualDesktopForm
{
    <#
        .SYNOPSIS
            Create new WinForms Components to handle Virtual Desktop.
 
        .DESCRIPTION
            This function first create a new Windows Form then create a new child component (PaintBox)
            to display remote desktop frames.
 
            It returns a PowerShell object containing both Form and PaintBox.
 
        .PARAMETER Width
            Width of new form.
 
        .PARAMETER Height
            Height of new form.
 
        .PARAMETER Caption
            Caption of new form.
 
        .EXAMPLE
            New-VirtualDesktopForm -Caption "New Desktop Form" -Width 1200 -Height 800
    #>

    param (
        [int] $Width = 1200,
        [int] $Height = 800,
        [string] $Caption = "PowerRemoteDesktop Viewer"        
    )        

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

    $form.Width = $Width
    $form.Height = $Height
    $form.BackColor = [System.Drawing.Color]::Black
    $form.Text = $Caption
    $form.KeyPreview = $true # Necessary to capture keystrokes.
    $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedSingle
    $form.MaximizeBox = $false    

    $pictureBox = New-Object System.Windows.Forms.PictureBox
    $pictureBox.Dock = [System.Windows.Forms.DockStyle]::Fill    

    $form.Controls.Add($pictureBox)    

    return New-Object PSCustomObject -Property @{
        Form = $form
        Picture = $pictureBox
    }
}

function New-RunSpace
{
    <#
        .SYNOPSIS
            Create a new PowerShell Runspace.
 
        .DESCRIPTION
            Notice: the $host variable is used for debugging purpose to write on caller PowerShell
            Terminal.
 
        .PARAMETER Client
            A ClientIO object containing an active connection with a remote server.
 
        .PARAMETER ScriptBlock
            A PowerShell block of code to be evaluated on the new Runspace.
 
        .PARAMETER Param
            Optional extra parameters to be attached to Runspace.
 
        .EXAMPLE
            New-RunSpace -Client $newClient -ScriptBlock { Start-Sleep -Seconds 10 }
    #>


    param(
        [Parameter(Mandatory=$True)]
        [ClientIO] $Client,

        [Parameter(Mandatory=$True)]
        [ScriptBlock] $ScriptBlock,

        [PSCustomObject] $Param = $null
    )   

    $syncHash = [HashTable]::Synchronized(@{})
    $syncHash.Client = $Client
    $syncHash.host = $host # For debugging purpose

    if ($Param)
    {
        $syncHash.Param = $Param
    }

    $runspace = [RunspaceFactory]::CreateRunspace()
    $runspace.ThreadOptions = "ReuseThread"
    $runspace.ApartmentState = "STA"
    $runspace.Open()                   

    $runspace.SessionStateProxy.SetVariable("syncHash", $syncHash) 

    $powershell = [PowerShell]::Create().AddScript($ScriptBlock)

    $powershell.Runspace = $runspace

    $asyncResult = $powershell.BeginInvoke()

    return New-Object PSCustomObject -Property @{
        Runspace = $runspace
        PowerShell = $powershell
        AsyncResult = $asyncResult
    }
}

function Invoke-RemoteDesktopViewer
{
    <#
        .SYNOPSIS
            Open a new Remote Desktop Session to remote Server.
 
        .PARAMETER ServerAddress
            Remote Server Address.
 
        .PARAMETER ServerPort
            Remote Server Port.
 
        .PARAMETER DisableInputControl
            If set, this option disables control events on form (Mouse Clicks, Moves and Keyboard)
            This option is generally set to true during development when connecting to local machine to avoid funny
            things.
 
        .PARAMETER Password
            Password used during server authentication.
 
        .PARAMETER TLSv1_3
            Define whether or not client must use SSL/TLS v1.3 to communicate with remote server.
            Recommended if possible.
 
        .PARAMETER DisableVerbosity
            Disable verbosity (not recommended)
 
        .EXAMPLE
            Invoke-RemoteDesktopViewer -ServerAddress "192.168.0.10" -ServerPort "2801" -Password "s3cr3t!"
            Invoke-RemoteDesktopViewer -ServerAddress "127.0.0.1" -ServerPort "2801" -Password "Just4TestingLocally!"
 
    #>

    param (        
        [string] $ServerAddress = "127.0.0.1",
        [int] $ServerPort = 2801,
        [switch] $DisableInputControl,
        [switch] $TLSv1_3,
            
        [Parameter(Mandatory=$true)]
        [string] $Password,

        [switch] $DisableVerbosity
    )

    $oldErrorActionPreference = $ErrorActionPreference
    $oldVerbosePreference = $VerbosePreference
    try
    {
        $ErrorActionPreference = "stop"

        if (-not $DisableVerbosity)
        {
            $VerbosePreference = "continue"
        }
        else 
        {
            $VerbosePreference = "SilentlyContinue"
        }

        $VerbosePreference = "continue"

        Write-Banner 
                
        Write-Verbose "Server address: ""${ServerAddress}:${ServerPort}"""
        
        $session = [ViewerSession]::New($ServerAddress, $ServerPort, $Password, $TLSv1_3)
        try
        {
            $session.OpenSession()

            Write-Verbose "Create WinForms Environment..."

            $virtualDesktopForm = New-VirtualDesktopForm            

            $virtualDesktopForm.Form.Text = [string]::Format(
                "Power Remote Desktop: {0}/{1} - {2}", 
                $session.SessionInformation.Username,
                $session.SessionInformation.MachineName,
                $session.SessionInformation.WindowsVersion
            )

            # Prepare Virtual Desktop
            $locationResolutionInformation = [System.Windows.Forms.Screen]::PrimaryScreen

            $screenRect = $virtualDesktopForm.Form.RectangleToScreen($virtualDesktopForm.Form.ClientRectangle)
            $captionHeight = $screenRect.Top - $virtualDesktopForm.Form.Top

            $requireResize = (
                ($locationResolutionInformation.WorkingArea.Width -le $session.SessionInformation.ScreenInformation.Width) -or
                (($locationResolutionInformation.WorkingArea.Height - $captionHeight) -le $session.SessionInformation.ScreenInformation.Height)            
            )

            $virtualDesktopWidth = 0
            $virtualDesktopHeight = 0

            $resizeRatio = 80

            if ($requireResize)
            {            
                $virtualDesktopWidth = [math]::Round(($session.SessionInformation.ScreenInformation.Width * $resizeRatio) / 100)
                $virtualDesktopHeight = [math]::Round(($session.SessionInformation.ScreenInformation.Height * $resizeRatio) / 100)            
            }
            else
            {
                $virtualDesktopWidth = $session.SessionInformation.ScreenInformation.Width
                $virtualDesktopHeight = $session.SessionInformation.ScreenInformation.Height
            }

            # Size Virtual Desktop Form Window
            $virtualDesktopForm.Form.ClientSize = [System.Drawing.Size]::new($virtualDesktopWidth, $virtualDesktopHeight) 

            # Center Virtual Desktop Form
            $virtualDesktopForm.Form.Location = [System.Drawing.Point]::new(
                (($locationResolutionInformation.WorkingArea.Width - $virtualDesktopForm.Form.Width) / 2),
                (($locationResolutionInformation.WorkingArea.Height - $virtualDesktopForm.Form.Height) / 2)
            )            

            # WinForms Events (If enabled, I recommend to disable control when testing on local machine to avoid funny things)
            if (-not $DisableInputControl)                   
            {
                enum InputCommand {
                    Keyboard = 0x1
                    MouseClickMove = 0x2
                    MouseWheel = 0x3
                }

                enum MouseState {
                    Up = 0x1
                    Down = 0x2
                    Move = 0x3
                }

                function New-MouseCommand
                {
                    <#
                        .SYNOPSIS
                            Generate a new mouse command object to be sent to server.
                            This command is used to simulate mouse move and clicks.
 
                        .PARAMETER X
                            The position of mouse in horizontal axis.
 
                        .PARAMETER Y
                            The position of mouse in vertical axis.
 
                        .PARAMETER Type
                            The type of mouse event (Example: Move, Click)
 
                        .PARAMETER Button
                            The pressed button on mouse (Example: Left, Right, Middle)
 
                        .EXAMPLE
                            New-MouseCommand -X 10 -Y 35 -Type "Up" -Button "Left"
                            New-MouseCommand -X 10 -Y 35 -Type "Down" -Button "Left"
                            New-MouseCommand -X 100 -Y 325 -Type "Move"
                    #>

                    param (
                        [Parameter(Mandatory=$true)]
                        [int] $X,
                        [Parameter(Mandatory=$true)]
                        [int] $Y,        
                        [Parameter(Mandatory=$true)]
                        [MouseState] $Type,

                        [string] $Button = "None"
                    )

                    return New-Object PSCustomObject -Property @{
                        Id = [int][InputCommand]::MouseClickMove
                        X = $X
                        Y = $Y
                        Button = $Button
                        Type = [int]$Type 
                    }
                }

                function New-KeyboardCommand
                {
                    <#
                        .SYNOPSIS
                            Generate a new keyboard command object to be sent to server.
                            This command is used to simulate keyboard strokes.
 
                        .PARAMETER Keys
                            Plain text keys to be simulated on remote computer.
 
                        .TODO
                            Supports more complex keys (ARROWS ETC...)
 
                        .EXAMPLE
                            New-KeyboardCommand -Keys "Hello, World"
                            New-KeyboardCommand -Keys "t"
                    #>

                    param (
                        [Parameter(Mandatory=$true)]
                        [string] $Keys
                    )

                    return New-Object PSCustomObject -Property @{
                        Id = [int][InputCommand]::Keyboard
                        Keys = $Keys
                    }
                }

                function Send-VirtualMouse
                {
                    <#
                        .SYNOPSIS
                            Transform the virtual mouse (the one in Virtual Desktop Form) coordinates to real remote desktop
                            screen coordinates (especially when incomming desktop frames are resized)
 
                            When command is generated, it is immediately sent to remote server.
 
                        .PARAMETER X
                            The position of virtual mouse in horizontal axis.
 
                        .PARAMETER Y
                            The position of virtual mouse in vertical axis.
 
                        .PARAMETER Type
                            The type of mouse event (Example: Move, Click)
 
                        .PARAMETER Button
                            The pressed button on mouse (Example: Left, Right, Middle)
 
                        .EXAMPLE
                           Send-VirtualMouse -X 10 -Y 20 -Type "Move"
                    #>

                    param (
                        [Parameter(Mandatory=$True)]
                        [int] $X,                        
                        [Parameter(Mandatory=$True)]
                        [int] $Y,
                        [Parameter(Mandatory=$True)]
                        [MouseState] $Type,

                        [string] $Button = ""
                    )

                    if ($requireResize)
                    {
                        $X = ($X * 100) / $resizeRatio
                        $Y = ($Y * 100) / $resizeRatio
                    }
      
                    $X += $session.SessionInformation.ScreenInformation.X
                    $Y += $session.SessionInformation.ScreenInformation.Y

                    $command = (New-MouseCommand -X $X -Y $Y -Button $Button -Type $Type)                    

                    $session.ClientControl.Writer.WriteLine(($command | ConvertTo-Json -Compress))                    
                }

                function Send-VirtualKeyboard
                {
                    <#
                        .SYNOPSIS
                            Send to remote server key strokes to simulate.
 
                        .PARAMETER KeyChain
                            A string representing character(s) to simulate remotely.
 
                        .EXAMPLE
                            Send-VirtualKeyboard -KeyChain "Hello, World"
                            Send-VirtualKeyboard -KeyChain "{LEFT}"
                    #>

                    param (
                        [Parameter(Mandatory=$True)]
                        [string] $KeyChain
                    )

                    $command = (New-KeyboardCommand -Keys $KeyChain)                                

                    $session.ClientControl.Writer.WriteLine(($command  | ConvertTo-Json -Compress)) 
                }

                $virtualDesktopForm.Form.Add_KeyPress(
                    { 
                        if ($_.KeyChar)
                        {
                            $String = [string]$_.KeyChar

                            $String = $String.Replace("{", "{{}")
                            $String = $String.Replace("}", "{}}")

                            $String = $String.Replace("+", "{+}")
                            $String = $String.Replace("^", "{^}")
                            $String = $String.Replace("%", "{%}")
                            $String = $String.Replace("~", "{%}")
                            $String = $String.Replace("(", "{()}")
                            $String = $String.Replace(")", "{)}")
                            $String = $String.Replace("[", "{[]}")
                            $String = $String.Replace("]", "{]}")    

                            Send-VirtualKeyboard -KeyChain $String                   
                        }                        
                    }
                )       

                $virtualDesktopForm.Form.Add_KeyDown(
                    {                       
                        $result = ""
                        switch ($_.KeyValue)
                        {
                            # F Keys
                            112 { $result = "{F1}" }
                            113 { $result = "{F2}" }
                            114 { $result = "{F3}" }
                            115 { $result = "{F4}" }
                            116 { $result = "{F5}" }
                            117 { $result = "{F6}" }
                            118 { $result = "{F7}" }
                            119 { $result = "{F8}" }
                            120 { $result = "{F9}" }
                            121 { $result = "{F10}" }
                            122 { $result = "{F11}" }
                            123 { $result = "{F12}" }
                            124 { $result = "{F13}" }
                            125 { $result = "{F14}" }
                            126 { $result = "{F15}" }
                            127 { $result = "{F16}" }

                            # Arrows
                            37 { $result = "{LEFT}" }
                            38 { $result = "{UP}" }
                            39 { $result = "{RIGHT}" }
                            40 { $result = "{DOWN}" }

                            # Misc
                            92 { $result = "{WIN}" }
                            27 { $result = "{ESC}"}
                            33 { $result = "{PGUP}" }
                            34 { $result = "{PGDW}" }
                            36 { $result = "{HOME}" }
                            46 { $result = "{DELETE}" }
                            35 { $result = "{END}" }

                            # Add other keys bellow
                        }
                        
                        if ($result)
                        {
                            Write-Verbose $result
                            Send-VirtualKeyboard -KeyChain $result
                        }
                    }
                )                        

                $virtualDesktopForm.Picture.Add_MouseDown(
                    {                         
                        Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type "Down"
                    }
                )

                $virtualDesktopForm.Picture.Add_MouseUp(
                    { 
                        Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type "Up"
                    }
                )

                $virtualDesktopForm.Picture.Add_MouseMove(
                    { 
                        Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type "Move"
                    }
                )          

                $virtualDesktopForm.Picture.Add_MouseWheel(
                    {
                        $command = New-Object PSCustomObject -Property @{
                            Id = [int][InputCommand]::MouseWheel
                            Delta = $_.Delta
                        }

                        $session.ClientControl.Writer.WriteLine(($command | ConvertTo-Json -Compress))
                    }
                )  
            }

            Write-Verbose "Create runspace for desktop streaming..."            

            $param = New-Object -TypeName PSCustomObject -Property @{
                VirtualDesktopForm = $virtualDesktopForm                            
                VirtualDesktopWidth = $virtualDesktopWidth 
                VirtualDesktopHeight = $virtualDesktopHeight
                RequireResize = $requireResize
                TransportMode = $session.SessionInformation.TransportMode
            }

            $newRunspace = (New-RunSpace -Client $session.ClientDesktop -ScriptBlock $global:VirtualDesktopUpdaterScriptBlock -Param $param)  

            Write-Verbose "Done. Showing Virtual Desktop Form."                       

            $virtualDesktopForm.Form.ShowDialog() | Out-Null                         
        }
        finally
        {    
            Write-Verbose "Free environement."

            if ($session)
            {
                $session.CloseSession()

                $session = $null
            }            

            if ($newRunspace) 
            {         
                $newRunspace.PowerShell.EndInvoke($newRunspace.AsyncResult) | Out-Null                    
                $newRunspace.PowerShell.Runspace.Dispose()                                      
                $newRunspace.PowerShell.Dispose()  
            } 

            if ($param.VirtualDesktopForm)
            {            
                $param.VirtualDesktopForm.Form.Dispose()
            }                                    
        }           
    }
    finally
    {    
        $ErrorActionPreference = $oldErrorActionPreference   
        $VerbosePreference = $oldVerbosePreference        
    } 
}

try {  
    Export-ModuleMember -Function Invoke-RemoteDesktopViewer
} catch {}