Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

How to Write a Terminal Services Add-in in Pure C#

4.76/5 (41 votes)
31 May 2008CPOL4 min read 1   4.4K  
A sample TS add-in written in C# (both server and client side)

Introduction

Sometimes you need to write an extension to Terminal Services (if you have used it). So why don't you write it in your favorite programming language (for me, it's C#)?

Background

Why am I playing with TS? How it started? Well ...

In our firm, almost everyone (about 120+) work on TS. We have a third party ERP system installed on our servers and there is an export function using Excel automation. Now is the question: how to use this functionality without spending money on two licenses for Excel for every user (that's right, in this case, all users should have license on both the server and the workstation)? The answer is: write an application which can provide the same (or at least what they are using) automation as Excel, and can build Excel files and send them to the client. The first version I wrote is based on exporting source-code from the ERP's producent (good to have friends there), and it was sending files via SMTP to the client, and it was written in ATL/WTL. But I knew the export source-code so I knew which object from Excel they (ERP's producent) were using.

After a few months, I found a way to send files over RDP. The application worked for more than a year. ... and then they changed the export code. After reading a great article about Building COM Servers in .NET, I decided to move this application to .NET. I found a way to get to know what functions/methods and objects they were using (IExpando from .NET and COM's IDispatchEx) and how to implement them. I thought, why not move the whole project to .NET, not just the server side. Well, I tried.

So back to the topic ... this article will not be about how to write Excel-like automation stuff. But how to write server/client side add-ins in C#.

What Should You Know?

What exactly will this sample application do?

The server side:

  • picks up a text file
  • reads it
  • compresses it using System.IO.Compression
  • sends it over RDP

The client side:

  • is ready for open RDP channels
  • opens it
  • reads data
  • decompresses
  • saves as text file
  • and opens it in the default application for the TXT extension

You'll find it useful when:

  • you know how to write such an extension in pure Win32 API
  • you want to write this in C#

Using the Code

Server Side

Well, it will be easy... all we have to do is import a few functions from wtsapi32.dll and use them in our application.

C++
//The WTSVirtualChannelOpen function opens a handle
//to the server end of a specified virtual channel
HANDLE WTSVirtualChannelOpen(HANDLE hServer, DWORD SessionId,
                             LPSTR pVirtualName);

//The WTSVirtualChannelWrite function writes data
//to the server end of a virtual channel
BOOL WTSVirtualChannelWrite(HANDLE hChannelHandle, PCHAR Buffer,
                            ULONG Length, PULONG pBytesWritten);

//The WTSVirtualChannelClose function closes an open virtual channel handle.
BOOL WTSVirtualChannelClose(HANDLE hChannelHandle);

In C#, they look like this:

C#
using System;
using System.Runtime.InteropServices;

class WtsApi32
{
    [DllImport("Wtsapi32.dll")]
    public static extern IntPtr WTSVirtualChannelOpen(IntPtr server,
        int sessionId, [MarshalAs(UnmanagedType.LPStr)] string virtualName);

    [DllImport("Wtsapi32.dll", SetLastError = true)]
    public static extern bool WTSVirtualChannelWrite(IntPtr channelHandle,
           byte[] buffer, int length, ref int bytesWritten);

    [DllImport("Wtsapi32.dll", SetLastError = true)]
    public static extern bool WTSVirtualChannelRead(IntPtr channelHandle,
           byte[] buffer, int length, ref int bytesReaded);

    [DllImport("Wtsapi32.dll")]
    public static extern bool WTSVirtualChannelClose(IntPtr channelHandle);
}

Now sending the data:

C#
byte[] data = PseudoClass.GetSomeData();
//remember that VirtualName should have 7 or less signs

IntPtr mHandle = WtsApi32.WTSVirtualChannelOpen(IntPtr.Zero, -1, "TSCS");
int written = 0;
bool ret = WtsApi32.WTSVirtualChannelWrite(mHandle, data,
           data.Length, ref written);
if (!ret || written == gziped.Length)
    MessageBox.Show("Sent!", "OK", MessageBoxButtons.OK,
                    MessageBoxIcon.Information);
else
    MessageBox.Show("Bumm! Somethings gone wrong!", "Error",
                    MessageBoxButtons.OK, MessageBoxIcon.Error);
ret = WtsApi32.WTSVirtualChannelClose(mHandle);

Off we go ... data sent (well, only when we correct the setup client-side stuff).

Client Side

What sits on the client side? Well, the client side is a DLL which exports a function in the first place in the export table.

C#
BOOL VCAPITYPE VirtualChannelEntry(PCHANNEL_ENTRY_POINTS pEntryPoints);

We will also need a definition for this:

C#
typedef struct tagCHANNEL_ENTRY_POINTS {
  DWORD cbSize;
  DWORD protocolVersion;
  PVIRTUALCHANNELINIT pVirtualChannelInit;
  PVIRTUALCHANNELOPEN pVirtualChannelOpen;
  PVIRTUALCHANNELCLOSE pVirtualChannelClose;
  PVIRTUALCHANNELWRITE pVirtualChannelWrite;
} CHANNEL_ENTRY_POINTS, *PCHANNEL_ENTRY_POINTS;

typedef UINT VCAPITYPE VIRTUALCHANNELINIT(
  LPVOID FAR * ppInitHandle,
  PCHANNEL_DEF pChannel,
  INT channelCount,
  ULONG versionRequested,
  PCHANNEL_INIT_EVENT_FN pChannelInitEventProc
);

UINT VCAPITYPE VirtualChannelOpen(
  LPVOID pInitHandle,
  LPDWORD pOpenHandle,
  PCHAR pChannelName,
  PCHANNEL_OPEN_EVENT_FN pChannelOpenEventProc
);

UINT VCAPITYPE VirtualChannelClose(
  DWORD openHandle
);

UINT VCAPITYPE VirtualChannelWrite(
  DWORD openHandle,
  LPVOID pData,
  ULONG dataLength,
  LPVOID pUserData
);

OMG!!! pointer to functions.. it doesn't look good. Well, maybe, but delegates from .NET are actually functions pointers, so ... we will try to wrap all this stuff into some class.

C#
using System;
using System.Drawing;
using System.Runtime.InteropServices;
using Microsoft.Win32;
using System.Text;
using System.Threading;

namespace Win32
{
    namespace WtsApi32
    {
        public delegate ChannelReturnCodes
            VirtualChannelInitDelegate(ref IntPtr initHandle,
            ChannelDef[] channels, int channelCount, int versionRequested,
            [MarshalAs(UnmanagedType.FunctionPtr)]
            ChannelInitEventDelegate channelInitEventProc);
        public delegate ChannelReturnCodes
            VirtualChannelOpenDelegate(IntPtr initHandle, ref int openHandle,
            [MarshalAs(UnmanagedType.LPStr)] string channelName,
            [MarshalAs(UnmanagedType.FunctionPtr)]
            ChannelOpenEventDelegate channelOpenEventProc);
        public delegate ChannelReturnCodes
            VirtualChannelCloseDelegate(int openHandle);
        public delegate ChannelReturnCodes
            VirtualChannelWriteDelegate(int openHandle, byte[] data,
            uint dataLength, IntPtr userData);

        public delegate void ChannelInitEventDelegate(IntPtr initHandle,
            ChannelEvents Event,
            [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3)]
            byte[] data, int dataLength);
        public delegate void ChannelOpenEventDelegate(int openHandle,
            ChannelEvents Event,
            [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3)] byte[] data,
            int dataLength, uint totalLength, ChannelFlags dataFlags);

        [StructLayout(LayoutKind.Sequential)]
        public struct ChannelEntryPoints
        {
            public int Size;
            public int ProtocolVersion;
            [MarshalAs(UnmanagedType.FunctionPtr)]
            public VirtualChannelInitDelegate VirtualChannelInit;
            [MarshalAs(UnmanagedType.FunctionPtr)]
            public VirtualChannelOpenDelegate VirtualChannelOpen;
            [MarshalAs(UnmanagedType.FunctionPtr)]
            public VirtualChannelCloseDelegate VirtualChannelClose;
            [MarshalAs(UnmanagedType.FunctionPtr)]
            public VirtualChannelWriteDelegate VirtualChannelWrite;
        }

        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
        public struct ChannelDef
        {
            [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 8)]
            public string name;
            public ChannelOptions options;
        }

        public enum ChannelEvents
        {
            Initialized = 0,
            Connected = 1,
            V1Connected = 2,
            Disconnected = 3,
            Terminated = 4,
            DataRecived = 10,
            WriteComplete = 11,
            WriteCanceled = 12
        }

        [Flags]
        public enum ChannelFlags
        {
            First = 0x01,
            Last = 0x02,
            Only = First | Last,
            Middle = 0,
            Fail = 0x100,
            ShowProtocol = 0x10,
            Suspend = 0x20,
            Resume = 0x40
        }

        [Flags]
        public enum ChannelOptions : uint
        {
            Initialized = 0x80000000,
            EncryptRDP = 0x40000000,
            EncryptSC = 0x20000000,
            EncryptCS = 0x10000000,
            PriorityHigh = 0x08000000,
            PriorityMedium = 0x04000000,
            PriorityLow = 0x02000000,
            CompressRDP = 0x00800000,
            Compress = 0x00400000,
            ShowProtocol = 0x00200000
        }

        public enum ChannelReturnCodes
        {
            Ok = 0,
            AlreadyInitialized = 1,
            NotInitialized = 2,
            AlreadyConnected = 3,
            NotConnected = 4,
            TooManyChanels = 5,
            BadChannel = 6,
            BadChannelHandle = 7,
            NoBuffer = 8,
            BadInitHandle = 9,
            NotOpen = 10,
            BadProc = 11,
            NoMemory = 12,
            UnknownChannelName = 13,
            AlreadyOpen = 14,
            NotInVirtualchannelEntry = 15,
            NullData = 16,
            ZeroLength = 17
        }
    }
}

OK. I wasn't so bad. Now, how simple does the RDP client side DLL look in C/C++:

C++
LPHANDLE              gphChannel;
DWORD                 gdwOpenChannel;
PCHANNEL_ENTRY_POINTS gpEntryPoints;

void WINAPI VirtualChannelOpenEvent(DWORD openHandle,
     UINT event, LPVOID pdata,
     UINT32 dataLength, UINT32 totalLength,
     UINT32 dataFlags)
{
    switch(event)
    {
        case CHANNEL_EVENT_DATA_RECEIVED:
        //reading data... and reading ... and reading

        break;
    }
}

VOID VCAPITYPE VirtualChannelInitEventProc(LPVOID pInitHandle, UINT event,
                                           LPVOID pData, UINT dataLength)
{
    UINT  ui;
    switch(event)
    {
        case CHANNEL_EVENT_INITIALIZED:
            break;
        case CHANNEL_EVENT_CONNECTED:
            ui = gpEntryPoints->pVirtualChannelOpen(
                 gphChannel,&gdwOpenChannel,
                "SAMPLE", (PCHANNEL_OPEN_EVENT_FN)VirtualChannelOpenEvent);
            if (ui != CHANNEL_RC_OK)
                MessageBox(NULL,TEXT("Open of RDP virtual "
                          "channel failed"),TEXT("Sample App"),MB_OK);
            break;
        case CHANNEL_EVENT_V1_CONNECTED:
            MessageBox(NULL,TEXT("Connecting to a non Windows 2000 "
                      "Terminal Server"), TEXT("Sample App"),MB_OK);
            break;
        case CHANNEL_EVENT_DISCONNECTED:
            break;
        case CHANNEL_EVENT_TERMINATED:
            LocalFree((HLOCAL)gpEntryPoints);
            break;
        default:
            break;
    }
}

BOOL VCAPITYPE VirtualChannelEntry(PCHANNEL_ENTRY_POINTS pEntryPoints)
{
    CHANNEL_DEF cd;
    UINT        uRet;
    gpEntryPoints = (PCHANNEL_ENTRY_POINTS) LocalAlloc(LPTR,
                                    pEntryPoints->cbSize);
    CopyMemory(gpEntryPoints, pEntryPoints, pEntryPoints->cbSize);
    ZeroMemory(&cd, sizeof(CHANNEL_DEF));
    CopyMemory(cd.name, "SAMPLE", 6);
    uRet = gpEntryPoints->pVirtualChannelInit((LPVOID *)&gphChannel,
       (PCHANNEL_DEF)&cd,
        1, VIRTUAL_CHANNEL_VERSION_WIN2000,
        (PCHANNEL_INIT_EVENT_FN)VirtualChannelInitEventProc);
    if (uRet != CHANNEL_RC_OK)
    {
        MessageBox(NULL,TEXT("RDP Virtual channel Init Failed"),
                        TEXT("Sample App"),MB_OK);
        return FALSE;
    }
    if (cd.options != CHANNEL_OPTION_INITIALIZED)
        return FALSE;
    return TRUE;
}

After translation to C#:

C#
using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Win32.WtsApi32;

namespace TSAddinInCS
{
    public class TSAddinInCS
    {
        static IntPtr Channel;
        static ChannelEntryPoints entryPoints;
        static int OpenChannel = 0;
        //ATTENTION: name should have 7 or less signs
        const string ChannelName = "TSCS";
        static Unpacker unpacker = null;
        static ChannelInitEventDelegate channelInitEventDelegate =
            new ChannelInitEventDelegate(VirtualChannelInitEventProc);
        static ChannelOpenEventDelegate channelOpenEventDelegate =
            new ChannelOpenEventDelegate(VirtualChannelOpenEvent);

        [ExportDllAttribute.ExportDll("VirtualChannelEntry",
            System.Runtime.InteropServices.CallingConvention.StdCall)]
        public static bool VirtualChannelEntry(ref ChannelEntryPoints entry)
        {
            ChannelDef[] cd = new ChannelDef[1];
            cd[0] = new ChannelDef();
            entryPoints = entry;
            cd[0].name = ChannelName;
            ChannelReturnCodes ret = entryPoints.VirtualChannelInit(
                ref Channel, cd, 1, 1, channelInitEventDelegate);
            if (ret != ChannelReturnCodes.Ok)
                MessageBox.Show("TSAddinInCS: RDP Virtual channel"
                                " Init Failed.\n" + ret.ToString(),
                                "Error", MessageBoxButtons.OK,
                                 MessageBoxIcon.Error);
                return false;
            }
            return true;
        }

        public static void VirtualChannelInitEventProc(IntPtr initHandle,
            ChannelEvents Event, byte[] data, int dataLength)
        {
            switch (Event)
            {
                case ChannelEvents.Initialized:
                    break;
                case ChannelEvents.Connected:
                    ChannelReturnCodes ret = entryPoints.VirtualChannelOpen(
                        initHandle, ref OpenChannel,
                        ChannelName, channelOpenEventDelegate);
                    if (ret != ChannelReturnCodes.Ok)
                        MessageBox.Show("TSAddinInCS: Open of RDP " +
                            "virtual channel failed.\n" + ret.ToString(),
                            "Error", MessageBoxButtons.OK,
                             MessageBoxIcon.Error);
                    else
                    {
                        string servername = System.Text.Encoding.Unicode.GetString(data);
                        servername = servername.Substring(0, servername.IndexOf('\0'));
                        //do something with server name
                    }
                    break;
                case ChannelEvents.V1Connected:
                    MessageBox.Show("TSAddinInCS: Connecting" +
                        " to a non Windows 2000 Terminal Server.",
                        "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
                    break;
                case ChannelEvents.Disconnected:
                    break;
                case ChannelEvents.Terminated:
                    GC.KeepAlive(channelInitEventDelegate);
                    GC.KeepAlive(channelOpenEventDelegate);
                    GC.KeepAlive(entryPoints);
                    GC.Collect();
                    GC.WaitForPendingFinalizers();
                    break;
            }
        }

        public static void VirtualChannelOpenEvent(int openHandle,
            ChannelEvents Event, byte[] data,
            int dataLength, uint totalLength, ChannelFlags dataFlags)
        {
            switch (Event)
            {
                case ChannelEvents.DataRecived:
                    switch (dataFlags & ChannelFlags.Only)
                    {
                        case ChannelFlags.Only:
                            //we are here because first data is last too
                            //(totalLength == dataLength)
                            //...do something with data
                            break;
                        case ChannelFlags.First:
                            //first part of data arrived
                            //...do something with data
                            //(store 'coz it isn't all)
                            break;
                        case ChannelFlags.Middle:
                            //...and still arriving
                            //...do something with data
                            //(store 'coz it isn't all)
                            break;
                        case ChannelFlags.Last:
                            //...and still arriving
                            //well maybe not still this is the last part
                            //...do something with data (store)
                            //now we have full data in store
                            //we will do with it something more than storing
                            break;
                    }
                    break;
            }
        }
    }
}

Is That All?

(Isn't that a name of a U2 song? Actually - No.. We didn't export the function ... or did we?

C#
[ExportDllAttribute.ExportDll("VirtualChannelEntry",
       System.Runtime.InteropServices.CallingConvention.StdCall)]

What does it mean? Well, I have a tool which can help with exporting functions to unmanaged code. You can read about it here. This tool is also included in the source (as a binary) and "added to post-build actions".

How To Get It All To Work

OK, now we have all pieces. The sample solution has two projects, the server and the client side and the ExportDLL tool, But it won't work, hrhrhrhr. We need to set it up first. What exactly do we need to run this:

  • A server (well, it can be Windows XP) working as the terminal server
  • A workstation with a TS client (mstsc.exe)

We also need the following files:

  • TSAddinInCS.dll on the workstation
  • TSAddinInCSServer.exe on the server

Setup

Setting up the client (the server doesn't need setup) is easy:

  • Open regedit
  • Find HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\Default\AddIns
  • Add a key TSAddinInCS
  • Add a key with a new string named Name to that key
  • Change the value of this string to whole_path_to\TSAddinInCS.dll

Or just make a *.reg file with:

Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\Software\Microsoft\Terminal Server
  Client\Default\AddIns\TSAddinInCS]
"Name"="C:\\Documents and Settings\\Selvin\\Pulpit\\TSAddinInCS
        \\Client\\bin\\Debug\\TSAddinInCS.dll"

This changes C:\\Documents and Settings\\Selvin\\Pulpit\\TSAddinInCS\\Client\\bin\\Debug\\ to the proper path.

Running

Finally, we have to test it. Run mstsc.exe, connect to the server, run TSAddinInCSServer.exe on the server, hit the button, choose the file to send, and watch as this file opens on the local computer.

References

To Do List

  • Add sending files to server description

Tips & Tricks/Notes

Many people ask why mstsc.exe crashes when they are sending data to the server.

The answer lies in very small read buffers on the server side.

From MSDN:

BufferSize
    Specifies the size, in bytes, of Buffer.
    If the client set the CHANNEL_OPTION_SHOW_PROTOCOL option,
    the value of this parameter should be at least CHANNEL_PDU_LENGTH.
    If the CHANNEL_OPTION_SHOW_PROTOCOL is not used, the value of this
    parameter should be at least CHANNEL_CHUNK_LENGTH.
...

CHANNEL_CHUNK_LENGTH is defined as 1600.

History

  • 14-11-2006: First version
  • 30-05-2008: Second version
    • IntPtr data changed to byte[] data
    • GC.KeepAlive() added
    • Sending files to server added (in source)
    • Sample form and tray icon added (in source)

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)