Click here to Skip to main content
15,879,184 members
Articles / Programming Languages / C#

Remote Access .NET CF Devices

Rate me:
Please Sign up or sign in to vote.
4.94/5 (16 votes)
8 Jan 2010CDDL18 min read 35.9K   3.2K   60   7
Implementing remote access to .NET enabled devices.

Contents

Introduction

This is a simple TCP client/server application which I wrote because we needed a simple way to connect to different kinds of wireless mobile scanners through our company network. All the code is in .NET 2.0, using C#.

You can use the TCP server on a mobile device and the TCP client on a PC. The PC client comes in two flavors: you can use the command line client which enables you to write some sort of script, or you can use the Windows graphical interface, which is, of course, nicer and easier to use. There is also a PC TCP server, so if you really want, you can connect from one PC to another, but there are a lot of better and free programs that can do that. This one is really best suited if you need to connect from your PC to any .NET enabled mobile device. You can manipulate the file system on the mobile device and get screen pictures. There is also a very basic option to remotely control a mobile device, but it is really, really basic.

In this article, you can see/learn how this is built, and you can adopt it and develop new functionalities to better suit your needs.

Background

As a developer, I develop programs on many platforms, and one of them is .NET CF (on Motorola/Symbol mobile scanners). We have stores all around the country, and every one of them has mobile scanners which are used in various applications.

I needed a simple way to connect to those scanners and retrieve from them some data, or the other way around - send some data to them. As .NET CF is quite limited in some advanced - Windows based - protocols, I decided to go with the classical TCP client/server architecture.

This decision was quite good because it meant that I could write .NET TCP clients and .NET TCP servers on any .NET enabled device; so, I was able to test everything on a PC and then port it to .NET CF.

Project assumptions

These assumptions are very important because the decisions I have made through the development cycle are because of them:

  1. .NET CF device (mobile scanner) will serve clients (so they will be hosts).
  2. Clients will be Windows based PCs.
  3. Expected tasks are:
    • Get some data from a scanner (folder/file information, whole file, screen picture).
    • Send some data to a scanner (files).
  4. The system must be upgradable in runtime, so it is expected that the users will have requirements for some sort of specialized tasks, such as:
    • latest log files,
    • running processes info,
    • installed applications info,
    • wireless settings info,
    • etc...
  5. Keep it simple.

Communication

Because the whole point of this project is exchanging information between a TCP client (Windows PC) and a TCP server (mobile device), communication is the core stone of the whole project.

As usual, the client starts to communicate with the server by sending some information to it. Everything that is required is, of course, again, the destination address (URL), the communication port, and the information itself. When the server receives the message, it sends the response back to the client, and drops the connection, or keeps the connection alive for faster re-communication.

As seen by this short introduction, the only thing that is left undefined is the message itself. So, we have to define the messages (information) between the client and the server. I decided to go with one message for all purposes. The message itself can, of course, carry different information, but the structure is the same.

Message structure

BytesLength (in bytes)Info
0...01Prefix
1...11Version
2...54Number of segments
Segment1Information segment 1 (defined by the segment structure)
Segment2Information segment 2 (defined by the segment structure)
......
SegmentNInformation segment N (defined by the segment structure)

Segment structure

BytesLength (in bytes)Info
0...34Length of segment in bytes
4...41Type of segment
5...Defined in the first 4 bytesData

To fully define the message structure, we need further (detailed) definitions:

  1. What is the prefix of the message? Right now, it is big x; so 'X'.
  2. What is the version, and how is it written? Currently, the version is 2, and it is written as '2' (ASCII 49).
  3. A message has 4 bytes that define the number of segments in the message, and each segment defines its own length. So, are these 4 bytes in little-endian or big-endian? It doesn't matter because the assumption is that we work only in the .NET environment, so .NET takes care of converting int to byte[] and vice-versa on both sides of the conversation.
  4. What is the 'type' of a segment? The type is enum, and is used for the basic description of the segment. The segment can be XML, binary data, text data, compressed data...

Message content in detail

Every message has at least one segment, and it is always XML data describing the purpose of the whole message. All the other segments are only additional data needed by the message itself.

In my first version of this software, every message was XML, with actions describing what the server has to do, or XML with information about what the server has done. This worked very well, and it was easy to understand and manipulate. But, the problem was that a lot of messages were some sort of binary data manipulation - send file, get file, get screen picture, or something like that. So, in my first version, all this binary data was kept inside XML as CODE64 encrypted binary data. It was OK, and it worked, but it was quite inefficient because the server had to encode binary data to CODE64 on one end and the client had to decode it on the other end. But, even this wouldn't be a problem because transformation is quite fast. The real problem was the size. Every binary data encoded in CODE64 is around twice bigger, and this means bigger messages, which translates to slower communication over wireless (don't forget that we are talking about wireless scanners!).

So in version 2, I decided to stay with XML, which has a lot of advantages, but whenever there is a need for binary data, a new segment is created, and only the pointer to that segment is left in the XML - this is the whole point of segments.

It is time to dive into code, and here is the Message and Segment Interface:

C#
public interface ISegment
{
    int Length { get; }
    SegmentType Type { get; }
    byte[] Data { get; }
    byte[] UnCompressData { get; }
    XmlDocument XmlDocument { get; }
    void ReadFromStream(Stream stream);
    void SendToStream(Stream stream);
    bool Compress();
    void SaveToFile(string fileName, bool createOnly);
    void LoadFromFile(string fileName, bool compress);
}

public interface IMessage
{
    int Version { get; }
    void ReadFromStream(Stream stream);
    void SendToStream(Stream stream);
    IMessage Execute(ExecuteTime executeTime);
    byte[] GetJobData(XmlElement xmlJob, bool uncompress);
    XmlDocument XmlDocument { get; }
    List<ISegment> Segments { get; }
}

The XML, or the message (if we look at it that way), is composed of different jobs that the server has to do. We can look at a job as some kind of action that has to be executed to get the job done. And, every job has some attributes, optional or not.

This is an example of a message with some jobs:

XML
<?xml version='1.0' encoding='windows-1250'?>
<jobs>
  <job action = 'getPluginsData'/>  
  <job action = 'reloadPlugins'/>  
  <job action      = 'dir' 
       folder      = '\windows' 
       recursive   = '1' 
       compress    = '0' 
       filePattern = '*.exe'/>
  <job action   = 'putFile' 
       inFile   = 'c:\temp\test1.txt' 
       outFile  = '\application data\test.txt' compress='1'/>
  <job action   = 'deleteFile' 
       file     = 'test.txt'/>
  <job action   = 'createFolder' 
       folder   = '\windows\test'/>
  <job action   = 'capture' 
       format   = 'bmp' 
       compress = '1' 
       outFile  = 'd:\temp\picture1.bmp'/>
</jobs>

Plug-ins

As seen in assumptions, we need a mechanism to upgrade the software. By upgrade, I mean adding functionality without reprogramming the server or client; and this is best accomplished by plug-ins. So, the server and client have to know how to install and use plug-ins.

Plug-ins are quite simple because all they need is some simple information about:

  • name,
  • author,
  • version, and
  • list of actions.

List of actions is used as a trigger to initialize the right plug-in. As you can imagine, every plug-in is designed to execute some jobs (actions), and this is the list of them.

So the idea is simple; whenever you add a new plug-in, the program remembers its list of exposed actions. Later, when such an action is needed, the program just calls this plug-in and asks it to execute the selected action.

C#
public interface IBasicPluginData
{
    string Name { get; }
    string Version { get; }
    string Author { get; }
}

public interface IPlugin
{
    IBasicPluginData BasicData { get; }
    IList<string> Actions { get; }

    ExecuteAction GetExecuteFunction(ExecuteTime executeTime, string action);
}

I will explain the purpose of the GetExecuteFunction method later.

Message flow

I am talking about message flow because message flows from the user interface to the client to the server, and then back from the server to the client and finally, to the user interface. To see how this works, it is best to just look at some examples.

First, I will talk about a simple action, 'getPicture'. As the name implies, the action takes the screen picture on the mobile device and brings it back to the user. Then, I will show getFile (get a file from the mobile device), and finally, I will show putFile (put the selected file to the mobile device).

getPicture flow:

  1. User interface: user demands picture ==> getPicture
  2. TCP client: forwards getPicture action
  3. TCP server: executes getPicture and puts data into one of the segments
  4. TCP client: forwards data to client
  5. User interface: shows the picture

getFile flow:

  1. User interface: user demands a file ==> getFile
  2. TCP client: forwards the getFile action
  3. TCP server: executes getFile and puts data into one of the segments
  4. TCP client: saves the file on the local file system
  5. User interface: notifies the user about the result

putFile flow:

  1. User interface: user wants to send a file to a mobile device ==> putFile
  2. TCP client: reads the file from the local file system and saves the data to one of the segments
  3. TCP server: saves data (from segment) to local file
  4. TCP client: forwards the result
  5. User interface: notifies the user about the result

What can we learn from these three examples? From them, I have learned that on the client side, each action can have an impact before we call the server and after the server has executed its part of the job. Of course, on the server, all we need is a function to execute the action. If we look at that from the plug-in perspective, it is clear that the client plug-in must have two functions: 'beforeServer' and 'afterServer', and the server plug-in just needs one function: 'executeAction'.

Image 1

Using plug-ins

Well, plug-ins are just C# modules that are dynamically loaded. Each dynamically loaded module is checked for class attributes, and if the correct class is found within a module, then this class is used as the plug-in for some action. And, because each plug-in class must implement the IPlugin interface, we can use its GetExecuteFunction method to get the correct function for the selected action.

By now, I have also revealed the purpose of the GetExecuteFunction method and the ExecuteTime enum. You can also see the definition of the ExecuteAction delegate, which, of course, just receives some message as input, processes the message, and returns the result as an output message.

C#
public enum ExecuteTime
{
    BeforeServer,
    OnServer,
    AfterServer
}
  
public delegate void ExecuteAction(IAction inAction, 
                     ref IAction outAction);

So, if I want to execute the getFile command on the server, I need a function:

C#
correctAction = GetExecuteFunction(ExecuteTime.OnServer, "getFile");

All that we now need is some attributes to identify the correct classes in some optional module:

C#
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class TCPClientPluginAttribute : Attribute
{
}

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class TCPServerPluginAttribute : Attribute
{
}

TCP server

Well, by now, we have all the things in place, except the core, that is, the TCP server and the TCP client. You can find some examples of TCP servers around the internet. And, this one is no exception. It is just a classical TCP server that accepts clients and serves them. The main function is just a loop which accepts the clients and processes their messages.

C#
private void ListenForClients()
{
    this.tcpListener.Start();

    try
    {
      while (true && !this.stopWorking)
      {
        TcpClient client = this.tcpListener.AcceptTcpClient();

        JobExecuter executer = 
          new JobExecuter(this, client, this.doEndConnection);
        Thread clientThread = 
          new Thread(new ThreadStart(executer.HandleClient));
        clientThread.Start();
      }
    }
    catch (Exception ex)
    {
      System.Diagnostics.Debug.WriteLine(ex.Message);
    }
}

As seen from the code, the interesting part is hidden in the HandleClient method, which is also quite easy and straightforward.

C#
public void HandleClient()
{
    NetworkStream clientStream = this.client.GetStream();

    try
    {
      try
      {
        EventArgs e = new EventArgs();
        while (!this.endConnectionEvent(this, e))
        {
          try
          {
            this.client.Client.SetSocketOption(SocketOptionLevel.Socket, 
                        SocketOptionName.ReceiveTimeout, 5 * 1000);
            this.client.Client.Blocking = true;

            IMessage inMessage = new Message(clientStream);
            IMessage outMessage = inMessage.Execute(ExecuteTime.OnServer);
            outMessage.SendToStream(clientStream);
          }
          catch (Exception ex)
          {
            if (ex.InnerException is SocketException)
            {
              SocketException se = ex.InnerException as SocketException;
              if (se.ErrorCode != 10060)
                throw;
            }
            else
              throw;
          }
        }
      }
      catch (Exception)
      {
      }
    }
    finally
    {
      this.client.Close();
    }
}

The function is just a big loop which loops while the connection is alive. All the work is done in three lines of code:

  • Get the message
  • Execute (process) the message
  • Send the message back to the client

The rest of the code is there because of the TCP protocol. The first two lines set the socket to blocking mode and set the timeout to 5 secs. Blocking mode is much easier to understand and much easier to program than non-blocking mode. Well, the only drawback is, of course, word blocking, which means that the conversation blocks the rest of the program. But, as you can read in many publications, this drawback is quite easy to overcome by using threads and timeouts. Back to the code. As mentioned above, the first two lines set the socket, and the exception part just checks if the timeout exception (10060) has occurred.

TCP client

The client is responsible to make connections and to execute the pre- and post- server actions:

C#
public static IMessage Execute(string ip, int port, IMessage jobs)
{
  IMessage first = jobs.Execute(ExecuteTime.BeforeServer);
  IMessage second = ExecuteTCP(ip, port, first);
  IMessage third = second.Execute(ExecuteTime.AfterServer);

  return third;
}

private static IMessage ExecuteTCP(string ip, int port, IMessage message)
{
  try
  {
    NetworkStream stream = Connection.Inst(ip, port).Stream;

    message.SendToStream(stream);
    return new Message(stream);
  }
  catch (Exception ex)
  {
    if (ex.InnerException is SocketException)
    {
      SocketException socEx = ex.InnerException as SocketException;
      if (socEx.ErrorCode == 10053)
      {
        if (Connection.Inst(ip, port).TryReconect())
          return ExecuteTCP(ip, port, message);
      }
    }

    return Message.ErrorMessage(ex.Message);
  }
}

Writing plug-ins

To make this really useful, the extra knowledge is hidden inside plug-ins, and this section will show you how to write a plug-in. As described above, there are two kinds of plug-ins: one for the server and the other for the client. From the programmer's perspective, they are the same, the class structure, the message, helper functions... Everything is the same, well, except the attribute by which the system can differentiate one from another and the number of implemented functions that they must provide. On the server side, we need only one function for each action, and on the client side, we need two (pre- and post- server). Below, you can see the implementation of the getFile action.

TCPServer plug-in example

C#
[TCPServerPluginAttribute]
public class FileSystem : Plugin
{
    public override IBasicPluginData Description()
    {
      return new BasicPluginData("File System Server", 
                 "1.0 beta", "Matjaz Prtenjak");
    }

    public FileSystem()
    {
      addServerAction("getFile", this.ExecuteGetFile);
    }

    public void ExecuteGetFile(IAction action, ref IAction outAction)
    {
      string inFile = CommonUtils.GetAttribute(action.Job, 
                                     "inFile") ?? string.Empty;
      if (inFile.Length == 0) throw new Exception("inFile not specified");

      if (!File.Exists(inFile))
        throw new Exception(string.Format("{0} does not exists", inFile));

      bool compress = CommonUtils.IsAttrSet(action.Job, "compress");
      outAction.Segment = new Segment(inFile, compress);
    }
}

We are defining the FileSystem class, which is an extension of the base class plug-in. By using TCPServerPluginAttribute, we are marking it a server-side plug-in. First, we need to override a method description which returns the short description of the plug-in; then, we define the constructor, and in there, we specify which action is this plug-in capable of executing and which method does the job. In our example, we are defining the getFile action, and the method responsible for it is ExecuteGetFile.

In the ExecuteGetFile method, we first search for the 'inFile' attribute which specifies the name of the file that we want to get. If there are none, then an exception is thrown, which also happens if the specified file does not exist. After that, we just read the file contents in the next segment and compress the data if the user wishes that.

TCPClient plug-in example

C#
[TCPClientPluginAttribute]
public class FileSystem : Plugin
{
    public override IBasicPluginData Description()
    {
      return new BasicPluginData("File System Client", 
                 "1.0 beta", "Matjaž Prtenjak");
    }

    public FileSystem()
    {
      addClientAction("getFile", this. , this.ExecuteGetFileAfter);
      addClientAction("deleteFile", Action.NoAction, Action.NoAction);
    }
    
    public void ExecuteGetFileBefore(IAction action, ref IAction outAction)
    {
      string outFile = CommonUtils.GetAttribute(
                          action.Job, "outFile") ?? string.Empty;
      if ((outFile.Length != 0) && 
          (CommonUtils.IsAttrSet(action.Job, "createOnly")))
      {
        if (File.Exists(outFile))
          throw new Exception(string.Format("{0} already exists", outFile));
      }
    }

    public void ExecuteGetFileAfter(IAction action, ref IAction outAction)
    {
      string outFile = CommonUtils.GetAttribute(action.Job, "outFile") ?? 
                                                string.Empty;
      if (outFile.Length != 0) 
      {
        action.Segment.SaveToFile(outFile, 
           CommonUtils.IsAttrSet(action.Job, "createOnly"));
        action.Segment = null;
        CommonUtils.AddElement(outAction.Job, "value", "OK");
      }
    }
}

Again, FileSystem extends Plugin and marks itself as a client plug-in. The Description method returns basic info about the plug-in. The constructor defines elements for two actions (the second 'deleteFile' is here just as an example). The first action - the action that we are interested in, getFile - uses ExecuteGetFileBefore as a method which will be executed before the server, and ExecuteGetFileAfter which will be executed after the server. Here, you can also see the definition for the deleteFile action which has nothing to do on the client - there is no need to execute any code before or after the server.

Before we send the getFile action to the server, we need to check if the user has specified outFile as the file on the local system which will hold the file from the mobile device. If outFile is specified and the user has also specified that only new files can be created, but the file already exists, an exception is thrown. After the server has done its part, we just have to check (again) if there is an outFile attribute, and if we find it, we save the data to this file.

Program

External libraries

For compression, the program uses the free, publicly available library, SharpZipLib, but you don't need to go to its website because the library is already present in the source code.

Source code

The source code is organised in two solutions. A .NET 2.0 C# solution for PC (TCP_WIN_APP), and a .NET CF 2.0 C# solution for mobile devices (TCP_CF_APP).

On the PC side, you get a TCP server and TCP clients; on the mobile side, you only get (out of the box) a TCP server.

TCP_WIN_APP - PC side

PluginManager is the main module with the core functions used by both the server and the client. TCPClient is a module which implements the TCP client, and can be used by different end-user programs. TCPClientCMD and TCPClientWIN are such end-user programs, and as the names imply, the first one is used in command-line mode and the second one is a classical Windows application. TCPServer is, of course, the TCP server.

Besides these five projects, there are also two plug-ins to show you how easy it is to write a plug-in. Each of the two plug-ins come in two flavors - one for the server and one for the client. So, there are the FileSystemClient and FileSystemServer projects which implement the basic file system functions. The others are RemoteDesktopClient and RemoteDesktopServer, which implement the basic Remote Desktop functionalities.

TCP_CF_APP - mobile side

As the code is written such that no changes are required on the CF side, all the projects in the CF solution use links to the source code in the .NET PC side. And, as I never needed mobile devices to be clients, I haven't implemented any client software for mobile devices. So on the mobile side, you get PluginManager, TCPServer, FileSystemServer, and RemoteDesktpServer, all of them serving the same purpose as on the PC side.

Executable programs

Executables are written in such a way that all the main stuff goes to one folder and all plug-ins go to a subfolder named plugins. All the plug-ins are automatically loaded at the start of the program. If new plug-ins are added while the program is running, plug-ins can be reloaded on user request.

TCPServer executables are complete on both the PC and mobile side.

My primary usage is through a TCP command line client, so this one is also complete on the PC side. Using this command line tool is easy. All you have to prepare is an XML file with actions you want to execute on the server (you can see examples of such XML files in the article section, 'Message content in detail').

But on the PC side, you also get a Windows program. The motivation for this part came because sometimes I need to see the screen of the mobile device, and this is easier through a Windows program than by saving the picture in a file and displaying it with some external picture viewing software. Every other functionality is implemented, but it is really only to show you how to do it. You can browse the file system on the server and you can create and delete folders (right mouse click!). You can also copy files on the server (by dragging files from the Explorer to the right side of this program), and you can download files from the server to the PC by selecting them in the program and using the right mouse menu to download. As you can see, uploading is implemented by drag/drop, and downloading is implemented by selecting files and using a menu command - Download. So, you can see that this program needs some improvements.

What can you learn by examining the code?

By examining the code, you can learn quite a few things:

  1. How to write simple, yet, working TCP servers
  2. How to write easy, yet, working TCP clients
  3. How to communicate between clients and a server
  4. How to add plug-ins to your programs
  5. How to add compression/decompression to your programs (using the SharpZipLib library)
  6. Etc.

Actions implemented in the examples

As you have read in the article, every action is an XML tag. As such, it can have different required or optional parameters. That means that an action attribute is required (it defines the action). On the other hand, there is also an info attribute, which is optional, and by setting the info attribute, the user gets data about the time needed by the server and client to execute the action.

If not specified otherwise, attributes can be 'set' by setting their value to '1', 'YES', 'OK', 'TRUE', or 'DA' ('DA' is Slovenian word for yes); any other value or absence of the attribute means 'not set'.

Some actions can have a compress attribute, which means that data returned by the action will be compressed when send over the line (the air) and automatically decompressed on the client (or server, if the client sends compressed data).

Actions implemented in the examples are as follows:

XML
<job action='getPluginsData'
     info='0'/>

Returns short info about all loaded plug-ins.

XML
<job action='reloadPlugins'
     info='0'/>

Reloads all server and client plug-ins.

XML
<job action='dir'
     folder='<requred>'
     info='0'
     foldersOnly='0'
     recursive='0'
     compress='0'
     filePattern='*.*'/>

Displays a list of files and subfolders in a folder (foldersOnly - list only folders, recursive - also list all the sub folders, compress - compress data (data is automatically compressed if exceeding 1 KB), filePattern - type of files wanted).

XML
<job action='getFile'
     inFile='<requred>'
     info='0'
     outFile=''
     compress='0'
     createOnly='0'/>

Returns a file from the server (inFile - source file (on server), outFile - destination file (on client), createOnly - action will succeed only if outFile does not exist yet).

XML
<job action='putFile'
     inFile='c:\test\test.xml'
     outFile='c:\druga.txt'
     info='0'
     compress='0'
     createOnly='0'/>

Same as getFile, only in the other direction (from client to server).

XML
<job action='deleteFile'
     file='c:\test\test.xml'
     info='0'/>

Deletes the file from the server.

XML
<job action='createFolder'
     folder='c:\test'
     info='0'/>

Creates a folder on the server.

XML
<job action='removeFolder'
     folder='c:\test'
     info='0'
     recursive='0'/>

Deletes the folder from the server.

XML
<job action='capture'
     info='0'
     format='bmp'
     compress='0'
     outFile='c:\picture.bmp'
     createOnly='0'/>

Captures the server screen (format - 'BMP', 'JPG', 'GIF', or 'PNG').

XML
<job action='mouseClick'
     x='1000'
     y='3456'
     info='0'
     duration='500'
     dblClick='0'/>

Executes a mouse click (or double click) on the required coordinates. Coordinates are on an imaginary canvas sized 65535 x 65535, so real coordinates need to be translated to this imaginary canvas!

XML
<job action='sendKeys'
     keys='123xyz'
     info='0'/>

Sends keys to the currently running application on the server.

Extending capabilities

You can extend the capabilities by writing custom plug-ins.

License

This article, along with any associated source code and files, is licensed under The Common Development and Distribution License (CDDL)


Written By
Software Developer (Senior) MERKUR D.D.
Slovenia Slovenia
I am a software developer in one of the largest retailer in our country. My job is (beside else) also in setting some standards in SW development throughout our company.

I have a M.Sc. in computer science and 20 years of experience. I have written 2 books in Slovenian language and both are sold out (the first about C++ language and the second about VBA language)...

Comments and Discussions

 
GeneralMissingMethodException Pin
wimdevriendt28-Mar-11 8:13
wimdevriendt28-Mar-11 8:13 
GeneralRe: MissingMethodException Pin
wimdevriendt28-Mar-11 8:27
wimdevriendt28-Mar-11 8:27 
GeneralMy vote of 5 Pin
Amir Mehrabi-Jorshari25-Oct-10 10:41
Amir Mehrabi-Jorshari25-Oct-10 10:41 
GeneralA small question Pin
Tomas Brennan9-Jan-10 13:57
Tomas Brennan9-Jan-10 13:57 
GeneralRe: A small question Pin
Matjaž Prtenjak10-Jan-10 3:53
Matjaž Prtenjak10-Jan-10 3:53 
GeneralRe: A small question Pin
lepipele11-Jan-10 10:15
lepipele11-Jan-10 10:15 
GeneralRe: A small question Pin
Luka12-Jan-10 12:23
Luka12-Jan-10 12:23 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.