Click here to Skip to main content
Click here to Skip to main content

Authoring and consuming classic COM components with WRL

By , 16 Sep 2013
Rate this:
Please Sign up or sign in to vote.

Overview

Windows 8 has introduced a new class of applications, called Windows Store applications. These applications do not run on the desktop, but in a new environment called Windows Runtime (also known as WinRT). The Windows Runtime is often described in different words: "platform-homogenous application architecture", "runtime", "layer", "object model", "set of libraries", etc. It is essentially a new runtime engine that lives alongside Win32 and is made of layers (services, devices, media, communication, etc. and of course the UI). Its APIs are written in C++ and are designed to be asynchronous. They are essentially COM-based, though not all classic COM features are available (for instance IDispatch). Windows Runtime components and applications can be built with a variety of languages and technologies: C++, C#, VB.NET, JavaScript/HTML/CSS. In C++, components can be authored and consumed either using the C++ Component Extensions (aka C++/CX) - that are language extensions similar to C++/CLI - or using the Windows Runtime C++ Template Library (aka WRL) - that is a template library with low-level support including reference counting or testing HRESULT values. An important difference between C++/CLI and WRL is that the former represents COM HRESULTs as exceptions while the latter uses HRESULT values and does not throw exceptions.

It is however less known that WRL can be used for authoring and consuming classic COM components for desktop applications. WRL is often compared to ATL (Active Template Library) with whom it shares concepts such as factories, explicit registration of interfaces, modules, etc. However, WRL is a lightweight library designed mainly for supporting the needs of Windows Runtime components. It does not support COM features such as: IDispatch (or dual interfaces), OLE embedding, ActiveX controls, aggregation, tear-off interfaces, connection points, stock implementations or COM+. If you need to use any of these feature you should still use ATL for development.

WRL provides several types that represent basic COM concepts. They are available in several headers included in <wrl.h> (under the namespace Microsoft::WRL). These include ComPtr<T> (a smart pointer type that represents the interface T), RuntimeClass (represents an instantiated class that inherits one or more interfaces, implements IUnknown's methods and helps manage the overall reference count of the module) and Module (a collection of objects) that I will use in the further paragraphs for creating and consuming some classic COM components.

Problem

I the first part of this article I will implement a COM server that provides appliances objects. An appliance can be a dish washer, a microwave oven, a radio or a TV set. All these appliances have common functionality such as turning on and off. Therefore, they will all implement a common interface called IAppliance that defines two methods: TurnOn and TurnOff. Some of these appliances however are smarter than others and support additional features such as remote controlling. These appliances will also implement an interface called ISmartAppliances that defines two methods: RemoteTurnOn and RemoteTurnOff. I will walk you through step-by-step from defining the interfaces to providing self-registration for the server. In the second part of the article I will create a client that instantiates appliances and works with them.

Creating a COM server with WRL

Let's start by creating a new Visual Studio blank solution and then add a Win32 project of type DLL. We'll call this project AppliancesServer.

The first thing to do is defining the interfaces mentioned earlier. There are different ways to do this. It is possible to define them as regular abstract classes decorated with __declspec(uuid()) to associated a GUID with them, or we can use an IDL file, as you may be used to from ATL development. I will use this approach as I expect it to be familiar to most COM developers. So let's add a file called Appliances.idl with the following content:

import "oaidl.idl";
import "ocidl.idl";

[uuid(D0A11BFA-77D9-4073-B5AB-835EAE0B53EC)]
interface IAppliance : IUnknown
{
   HRESULT TurnOn();
   HRESULT TurnOff();
}

[uuid(D59DA186-20E7-47BF-931B-2AA9178424D7)]
interface ISmartAppliance : IUnknown
{
   HRESULT RemoteTurnOn();
   HRESULT RemoteTurnOff();
}

[uuid(313CD489-E503-43CE-880F-4B8D64DD3D9E)]
library ApplianceLibrary
{
   [uuid(ABD628DC-EC82-4751-99F8-A68219B196CA)]
   coclass TVSet
   {
      [default] interface IAppliance;
      interface ISmartAppliance;
   }
}

When you build the project the .idl file is compiled with midl.exe compiler that produces several files (notice they are not automatically added to your project):

  • Appliances_h.h: the header file containing type definitions and function declarations for all the interfaces defined in the IDL
  • Appliances_i.c: defines the GUIDs for all interfaces and coclasses from the IDL file
  • Appliances_p.c: the proxy/stub file with surrogate entry points for the client and server
  • dlldata.c: defines the necessary entities for creating a proxy/stub DLL

For the purpose of this article we will only use the Appliances_h.h header and ignore the rest.

Having the interfaces defined we can start creating appliances that implement this interfaces. Let's add another file to the project, called Appliances.cpp. Before defining an appliance class we must include the Appliances_h.h header generated earlier, and the <wrl.h> for the Windows Runtime C++ Template Library. I will define a single appliance object called TVSet. This is a smart appliance and implements both IAppliance and ISmartAppliance. The content of the file looks like this:

#include "Appliances_h.h"

#include <wrl.h>
using namespace Microsoft::WRL;

class TVSet : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IAppliance, ISmartAppliance>
{
public:
   virtual HRESULT __stdcall TurnOn() override
   {
      OutputDebugString(L"TV was turned on\n");
      return S_OK;
   }

   virtual HRESULT __stdcall TurnOff() override
   {
      OutputDebugString(L"TV was turned off\n");
      return S_OK;
   }
   
   virtual HRESULT __stdcall RemoteTurnOn() override
   {
      OutputDebugString(L"TV was turned on with remote control\n");
      return S_OK;
   }

   virtual HRESULT __stdcall RemoteTurnOff() override
   {
      OutputDebugString(L"TV was turned off with remote control\n");
      return S_OK;
   }
};

CoCreatableClass(TVSet);

There are several things to note here:

  • TVSet is derived from RuntimeClass that provides core COM components support such as implementing AddRef, Release and QueryInterface (the methods from IUnknown). This is a template class. The first argument is an unsigned integer specifying class flags and to indicate this is a classic COM component we must supply RuntimeClassFlags<ClassicCom>. The other type parameters (up to nine in this current implementation) are interfaces this runtime class implements.
  • The implementation of the IAppliance and ISmartAppliance interfaces is kept to a minimum by just displaying a text in the output window of the debugger.
  • CoCreatableClass(TVSet) macro defines a class factory for the TVSet class. This class factory will instantiate TVSet objects when requested by the COM server clients. WRL defines a SimpleClassFactory template class that basically implements the IClassFactory interface methods, CreateInstance and LockServer (in addition to the IUnknown methods, since IClassFactory, like any COM interface is derived from IUnknown).

The next step is defining and implementing the actual exports from the COM server. The minimum exports that are necessary are:

  • DllRegisterServer: the entry point for the COM server to add to the Windows Registry the necessary entries for its supported classes and class objects. When you execute regsvr32.exe passing a module it locates and calls this function from the module.
  • DllUnregisterServer: the entry point for the COM server to remove from the Windows Registry the entries previously added by DllRegisterServer. When you execute regsvr32.exe /u passing a module it locates and calls this function from the module.
  • DllGetClassObject: retrieves the class object (factory) indicated by the provided CLSID. This is called from CoGetClassObject used to instantiate a class object or from CoCreateInstance/CoCreateInstanceEx used to create a single COM object (locally or remotely).
  • DllCanUnloadNow: determines whether the DLL is still managing objects. If its total reference count on objects is 0 then the DLL is free to the unloaded by the caller from memory.

To define these exports add a module definition file to the project (call it Appliances.def) and define the following entries (note that PRIVATE here means the functions should not be visible in a .lib file, so a client that statically links with a .lib file may not see and call such a function).

EXPORTS
DllGetClassObject          PRIVATE
DllRegisterServer          PRIVATE
DllUnregisterServer        PRIVATE
DllCanUnloadNow            PRIVATE

Implementation of these exports is done in DllMain.cpp. As mentioned in the entry paragraphs, WRL provides a class Module that represents a COM server module. Though its functionality is kept to a minimum and some methods are actually not implemented, it does provide two methods to help implementing DllGetClassObject and DllCanUnloadNow. These methods are GetClassObject - that retrieves class objects that implement IClassFactory, and GetObjectCount - that retrieves the number of objects managed by the module. Thus, the implementation of these exports may simply look like this:

STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, _COM_Outptr_ void** ppv)
{
    return Module<InProc>::GetModule().GetClassObject(rclsid, riid, ppv);
}

STDAPI DllCanUnloadNow()
{
    return Module<InProc>::GetModule().Terminate() ? S_OK : S_FALSE;
}

Unfortunately, the Module class does not support self-registration so the implementation of the DllRegisterServer and DllUnregisterServer would require more work. To keep the functionality grouped together and have a cleaner implementation for the exports I would like to define a module class that would handle these operations internally and the export functions just call them. You can go ahead and add a new class to the project, called AppliancesModule. Its declaration looks like this:

class AppliancesModule
{
   // additional private methods
public:
   HRESULT RegisterServer();
   HRESULT UnregisterServer();
   HRESULT GetClassObject(REFCLSID rclsid, REFIID riid, void** ppv);
   HRESULT CanUnloadNow();
};

In DllMain.cpp we define a static object of this type and implement the exports to use it.

#include <windows.h>
#include "AppliancesModule.h"

static AppliancesModule s_Module;

HRESULT __stdcall DllRegisterServer()
{
   return s_Module.RegisterServer();
}

HRESULT __stdcall DllUnregisterServer()
{
   return s_Module.UnregisterServer();
}

HRESULT __stdcall DllGetClassObject(REFCLSID rclsid, REFIID riid, void** ppv)
{
   return s_Module.GetClassObject(rclsid, riid, ppv);
}

HRESULT __stdcall DllCanUnloadNow()
{
   return s_Module.CanUnloadNow();
}

BOOL __stdcall DllMain(HINSTANCE module, DWORD reason, void*)
{
   if(reason == DLL_PROCESS_ATTACH)
   {
      DisableThreadLibraryCalls(module);
   }

   return TRUE;
}

It should be clear at this point how GetClassObject and CanUnloadNow can be implemented. When it comes to RegisterServer and UnregisterServer the things get more complicated. However, I will try to keep the details to a minimum, since they are basically beyond the scope of the article. A class can be instantiated from a client as long as there are several entries to the Windows Registry under Software\Classes\CLSID key in the HKEY_LOCAL_MACHINE hive.

For each managed object, the module must create a key with the CLSID of the class and provide its class name. The sub-key InProcServer32 is essential, because under it we must define the path to the module and the threading model. Other sub-keys, such as TypeLib and Version are optional and the runtime can function without them. I will define a class called RegistryEntry that provides all these information for a single class. A static array of such entries will be used by the module to add and remove keys and values to the Registry.

struct RegistryEntry
{
   wchar_t const *      Guid;
   wchar_t const *      Name;
   wchar_t const *      ThreadingModel;
   wchar_t const *      TypelibGuid;
   wchar_t const *      Version;
};

Here is the implementation of the ApplicationModule class.

#include "AppliancesModule.h"
#include "Registration.h"

#include <string>

#include <KtmW32.h>
#pragma comment(lib, "KtmW32")

#include <wrl\module.h>
using namespace Microsoft::WRL;

EXTERN_C IMAGE_DOS_HEADER __ImageBase;

static RegistryEntry s_regTable [] = {
   {
      L"{ABD628DC-EC82-4751-99F8-A68219B196CA}", 
      L"TVSet", 
      L"Apartment", 
      L"{313CD489-E503-43CE-880F-4B8D64DD3D9E}", 
      L"1.0"
   },
};

class registry_handle
{
   HKEY handle;

public:
   registry_handle(HKEY const & key): handle(key)
   {
   }

   registry_handle(registry_handle&& rh)
   {
      handle = rh.handle;
      rh.handle = nullptr;
   }

   ~registry_handle()
   {
      if(handle != nullptr)
         RegCloseKey(handle);
   }

   registry_handle& operator=(registry_handle&& rh)
   {
      if(this != &rh)
      {
         if(handle != nullptr)
            RegCloseKey(handle);

         handle = rh.handle;
         rh.handle = nullptr;
      }

      return *this;
   }

   HKEY* get() throw()
   {
      return &handle;
   }

   operator HKEY() const 
   {
      return handle;
   }
};

registry_handle RegistryCreateKey(wchar_t const * keyPath, HANDLE hTransaction)
{
   registry_handle hKey = nullptr;

   auto result = ::RegCreateKeyTransacted(
      HKEY_LOCAL_MACHINE,
      keyPath,
      0,
      nullptr,
      REG_OPTION_NON_VOLATILE,
      KEY_WRITE,
      nullptr,
      hKey.get(),
      nullptr,
      hTransaction,
      nullptr);

   if (ERROR_SUCCESS != result)
   {
      SetLastError(result);
      hKey = nullptr;
   }

   return const_cast<registry_handle&&>(hKey);
}

bool RegistryCreateNameValue(HKEY hKey, wchar_t const * name, wchar_t const * value)
{
   auto result = ::RegSetValueEx(
      hKey,
      name,
      0,
      REG_SZ,
      reinterpret_cast<BYTE const*>(value),
      static_cast<DWORD>(sizeof(wchar_t)*(wcslen(value) + 1)));

   if (ERROR_SUCCESS != result)
   {
      ::SetLastError(result);
      return false;
   }

   return true;
}

bool RegistryDeleteTree(wchar_t const * keyPath, HANDLE hTransaction)
{
   registry_handle hKey = nullptr;

   auto result = ::RegOpenKeyTransacted(
      HKEY_LOCAL_MACHINE,
      keyPath,
      0,
      DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE | KEY_SET_VALUE,
      hKey.get(),
      hTransaction,
      nullptr);

   if (ERROR_SUCCESS != result && ERROR_FILE_NOT_FOUND != result)
   {
      SetLastError(result);
      return false;
   }

   if(ERROR_SUCCESS == result)
   {
      result = ::RegDeleteTree(hKey, nullptr);

      if (ERROR_SUCCESS != result)
      {
         RegCloseKey(hKey);
         ::SetLastError(result);
         return false;
      }
   }

   RegCloseKey(hKey);

   return true;
}

bool AppliancesModule::Unregister(HANDLE hTransaction)
{
   for(auto const & entry : s_regTable)
   {
      if(!RegistryDeleteTree((std::wstring(L"Software\\Classes\\CLSID\\") + entry.Guid).data(), 
                              hTransaction))
         return false;
   }

   return true;
}

bool AppliancesModule::Register(HANDLE hTransaction)
{
   if(!Unregister(hTransaction))
      return false;

   wchar_t filename[MAX_PATH] = { 0 };
   auto const length = ::GetModuleFileName(
      reinterpret_cast<HMODULE>(&__ImageBase), 
      filename, 
      _countof(filename));

   if(length == 0)
      return false;

   for(auto const & entry : s_regTable)
   {
      auto keyPath = std::wstring(L"Software\\Classes\\CLSID\\") + entry.Guid;

      registry_handle hKey = RegistryCreateKey(keyPath.data(), hTransaction);
      if(hKey == nullptr)
         return false;

      if(!RegistryCreateNameValue(hKey, nullptr, entry.Name))
         return false;

      registry_handle hKey2 = RegistryCreateKey((keyPath + L"\\InProcServer32").data(), hTransaction);
      if(hKey2 == nullptr)
         return false;

      if(!RegistryCreateNameValue(hKey2, nullptr, filename))
         return false;

      if(!RegistryCreateNameValue(hKey2, L"ThreadingModel", entry.ThreadingModel))
         return false;

      if(entry.TypelibGuid != nullptr)
      {
         registry_handle hKey3 = RegistryCreateKey((keyPath + L"\\TypeLib").data(), hTransaction);
         if(hKey3 == nullptr)
            return false;

         if(!RegistryCreateNameValue(hKey3, nullptr, entry.TypelibGuid))
            return false;
      }

      if(entry.Version != nullptr)
      {
         registry_handle hKey3 = RegistryCreateKey((keyPath + L"\\Version").data(), hTransaction);
         if(hKey3 == nullptr)
            return false;

         if(!RegistryCreateNameValue(hKey3, nullptr, entry.Version))
            return false;
      }
   }

   return true;
}

HRESULT AppliancesModule::RegisterServer()
{
   HANDLE hTransaction = ::CreateTransaction(
      nullptr,                      // security attributes
      nullptr,                      // reserved
      TRANSACTION_DO_NOT_PROMOTE,   // options
      0,                            // isolation level (reserved)
      0,                            // isolation flags (reserved)
      INFINITE,                     // timeout
      nullptr                       // transaction description
      );   

   if(INVALID_HANDLE_VALUE == hTransaction)
   {
      auto lastError = ::GetLastError();
      ::CloseHandle(hTransaction);
      return HRESULT_FROM_WIN32(lastError);
   }

   if(!Register(hTransaction))
   {
      auto lastError = ::GetLastError();
      ::CloseHandle(hTransaction);
      return HRESULT_FROM_WIN32(lastError);
   }

   if(!::CommitTransaction(hTransaction))
   {
      auto lastError = ::GetLastError();
      ::CloseHandle(hTransaction);
      return HRESULT_FROM_WIN32(lastError);
   }

   ::CloseHandle(hTransaction);

   return S_OK;
}

HRESULT AppliancesModule::UnregisterServer()
{
   HANDLE hTransaction = ::CreateTransaction(
      nullptr,                      // security attributes
      nullptr,                      // reserved
      TRANSACTION_DO_NOT_PROMOTE,   // options
      0,                            // isolation level (reserved)
      0,                            // isolation flags (reserved)
      INFINITE,                     // timeout
      nullptr                       // transaction description
      );

   if(INVALID_HANDLE_VALUE == hTransaction)
   {
      auto lastError = ::GetLastError();
      ::CloseHandle(hTransaction);
      return HRESULT_FROM_WIN32(lastError);
   }

   if(!Unregister(hTransaction))
   {
      auto lastError = ::GetLastError();
      ::CloseHandle(hTransaction);
      return HRESULT_FROM_WIN32(lastError);
   }

   if(!::CommitTransaction(hTransaction))
   {
      auto lastError = ::GetLastError();
      ::CloseHandle(hTransaction);
      return HRESULT_FROM_WIN32(lastError);
   }

   ::CloseHandle(hTransaction);

   return S_OK;
}

HRESULT AppliancesModule::GetClassObject(REFCLSID rclsid, REFIID riid, void** ppv)
{
   return Module<InProc>::GetModule().GetClassObject(rclsid, riid, ppv);
}

HRESULT AppliancesModule::CanUnloadNow()
{
   return Module<InProc>::GetModule().Terminate() ? S_OK : S_FALSE;
}
</registry_handle&&>

The registration function first tries to clean up the registry by calling the un-registration and then goes ahead and for each entry in the static array it adds the required (and some optional) keys and values to the registry. The un-registration does a simple key tree deletion (that removes all the sub-keys and their values from the Registry, but does not remove the key itself, which is not important for us). However, the management of the Registry entries is performed in a transactional manner, so that if something goes wrong there are no left overs in the Registry. CreateTransaction function creates a transaction object that can be later used with RegCreateKeyTransacted, RegOpenKeyTransacted or RegDeleteKeyTransacted. After successfully adding or removing the entries to or from the Windows Registry, the transaction is committed by calling CommitTranscation. Should anything fail the transaction is rolled back by simply calling CloseHandle (that performs the roll back on uncommitted transaction handles). 

Update: the registry_handle is a smart handle for registry key handles, that automatically closes the handle it wraps when the object goes out of scope.   

With all this in place the server is ready. We can compile the project and run regsvr32.exe AppliancesServer.dll from a console running elevated As Administrator.

Consuming COM objects with WRL

Consuming the appliance object creating above, or any other COM object for that matter, is made easy with another template class from the Windows Runtime C++ Template Library, the ComPtr<T> class. This is a smart pointer that wraps a raw pointer to a COM interface and performs internal housekeeping such as maintaining the reference count.

The constructor allows to create an empty object or an object from a specified raw pointer to a COM interface (and there are also copy, move and conversion constructors). It is possible to detach the ComPtr object from the COM interface it wraps by calling Detach or attach the ComPtr object to a new COM interface by calling Attach (if the object is already associated with a COM interface it first detaches from it before attaching to the new one). It is also possible to swap the wrapped COM interface with the COM interface of another ComPtr by calling Swap. To retrieve the raw pointer to the wrapped COM interface use the Get method. To retrieve the address of the member that contains the pointer to the COM interface call GetAddressOf. A similar method, ReleaseAndGetAddressOf first releases the COM interface and then returns the address of the member that contains the (null in this case) pointer to the COM interface. To copy either the current or a specified interface that the referred COM object implements use CopyTo, As or AsIID.

To see how this works I will create a ComPtr<IAppliance> to manage a pointer to the IAppliance interface and use it to turn on the appliance. Then, I will create a ComPtr<ISmartAppliance> to manage a pointer to the ISmartAppliance for the same object and use it to remotely turn off the appliance. Before that however add a Win32 console application to the solution and call it AppliancesClient.

There are two things we must not forget to include: the Appliances_h.h header with the definitions of the COM interfaces for the appliances COM server and <wrl.h> for the Windows Runtime C++ Template Library.

#include "..\AppliancesServer\Appliances_h.h"

#include <wrl.h>
using namespace Microsoft::WRL;

We cannot use the COM library until we initialize the COM runtime. To simplify the calling to CoInitializeEx and CoUninitialize I will create a wrapper class called RuntimeContext that initializes the COM runtime in the constructor and un-initializes it in the destructor. In the main() function I will declare an object of this context class to handle the runtime initialization.

class RuntimeContext
{
   HRESULT hr;
public:
   explicit RuntimeContext(DWORD const flags)
   {
      hr = ::CoInitializeEx(nullptr, flags);
   }

   ~RuntimeContext()
   {
      if(hr == S_OK)
         ::CoUninitialize();
   }

   operator HRESULT() const 
   {
      return hr;
   }
};

The main method of the client application is relatively simple: initialize the runtime, create an empty ComPtr<IAppliance> object and then create the TVSet object and associate the retrieved pointer to the IAppliance interface with the smart pointer. Using the smart pointer we can turn on the appliance. Then we define another empty smart pointer but for the ISmartAppliance interface, and associate a pointer to this interface for the existing TVSet object. Using this second smart pointer we can turn off the appliance remotely.

int main()
{
   RuntimeContext runtime(COINIT_APARTMENTTHREADED);
   if(S_OK != runtime)
      return -1;

   ComPtr<IAppliance> tvset;
   auto hr = ::CoCreateInstance(
      __uuidof(TVSet),
      nullptr,
      CLSCTX_INPROC_SERVER,
      __uuidof(tvset),
      reinterpret_cast<void**>(tvset.GetAddressOf()));

   if(hr == S_OK)
   {
      tvset->TurnOn();

      ComPtr<ISmartAppliance> smarttv;
      hr = tvset.As(&smarttv);

      smarttv->RemoteTurnOff();
   }

   return 0;
}

Notice this line:

hr = tvset.As(&smarttv);

The same result can be achieved with a call to method CopyTo. I prefer the first though, because is less typing and I find it simpler to read.

hr = tvset.CopyTo(smarttv.GetAddressOf());

If you run the client application without a debugger attached there will be no message printed in the console because the implementation of the appliance methods use OutputDebugString that prints a string in the debugger's output window. If you run with the debugger attached you will see the following texts in the output window.

TV was turned on
TV was turned off with remote control

Conclusions

The purpose of this article is to show the Windows Runtime C++ Template Library (or WRL in short) can be used for authoring and consuming not only Windows Runtime components, but also classic COM components for desktop applications. The library provides several classes (some of them shown in this article) to enable developers in both creating and consuming COM objects (whether they are Windows Runtime or classic components). Notice however the library is actually built for the Windows Runtime and does not support all classic COM scenarios. Features such as dual interfaces (IDispatch), aggregation, connection points or ActiveX controls are not supported. If your COM components require any of these features you should use ATL.

License

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

About the Author

Marius Bancila
Software Developer (Senior) Visma Software
Romania Romania
Marius Bancila is a Microsoft MVP for VC++. He works as a software developer for Visma, a Norwegian-based company. He is mainly focused on building desktop applications with VC++ and VC#. He keeps a blog at http://www.mariusbancila.ro/blog, focused on Windows programming. He is the co-founder of codexpert.ro, a community for Romanian C++ programmers.
Follow on   Twitter

Comments and Discussions

 
GeneralNice article PinmvpEspen Harlinn17-Sep-13 2:31 

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

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

| Advertise | Privacy | Mobile
Web04 | 2.8.140415.2 | Last Updated 17 Sep 2013
Article Copyright 2013 by Marius Bancila
Everything else Copyright © CodeProject, 1999-2014
Terms of Use
Layout: fixed | fluid