Click here to Skip to main content
13,836,160 members
Click here to Skip to main content
Add your own
alternative version


76 bookmarked
Posted 26 Jan 2004

Building Rich COMponents with Attributed ATL

, 26 Jan 2004
Rate this:
Please Sign up or sign in to vote.
Tutorial article about how to build components with rich functionality using attributed ATL.


There are tons of articles and sample code on how to do something with ATL. Usually they teach how to only add feature to your component and you have to dig a lot of tutorials in order to build something rich-featured.

In this article, I try to cover how to create COM server, expose it to scripting languages, make it an event source, add a VB-style collection to the object and add an ability to your object to report errors.

I didn't set a goal to cover all questions on COM or attributed ATL in this article, so don't expect to find here explanations of every attribute or COM basics. Refer to MSDN for more detailed information. This article is just a quick walk-through on things that will make your COM object more friendly to other programmers.

Get Ready

We will be learning by example. The example is very simple — Windows® Services Manager. The services manager itself will be a COM object we'll write in C++ using attributed ATL, and there will also be a set of scripts in VBScript that will allow us to control services in batch mode.


Coclasses in the Project

When creating a program, we should think about which classes we will have. Here, we'll have the Manager itself, Services collection and Service. We are talking about COM, so they will be our coclasses.

ServicesMgr coclass will provide the user with a set of operations on Services collection and services identified by name. Services collection will provide the user with abilities to iterate services using foreach statement. Service coclass will represent a single service.

Creating a project

To start with ATL project, run Visual Studio .NET IDE and select File/New/Project... command. Select Visual C++ Projects/ATL/ATL Project and enter a name. In this tutorial, I'll use the name "ServicesManager".

Don't change any options in ATL Project Wizard. Let it be Attributed and Dynamic-link library. Click Finish — we're done!

Now we have a dummy COM object. It can be compiled, but does nothing yet.

Open ServicesManager.cpp file. Note [module...] lines there. This is an attribute. It defines the library block. This means that we have DllMain, DllRegisterServer and DllUnregisterServer functions without writing any line of code.

Adding coclasses

Let's add our coclasses to the project.

Right click on ServicesManager project in Solution Explorer and select Add/Add Class. Then select ATL/ATL Simple Object in Add Class - ServicesManager window. Enter ServicesMgr as a name in ATL Simple Object Wizard. Leave all options on the next page as is. Note Dual Interface option is selected. This will help us to provide the functionality of ServicesMgr both to languages like C++ that use VTBL binding of methods and scripting languages that use IDispatch interface to communicate with objects.

Click Finish in wizard's window and get all needed code for our ServicesMgr coclass.

Now, find IServicesMgr interface declaration in ServicesMgr.h file and add the following attributes to this interface: oleautomation, hidden and nonextensible, so it will look like this:

<PRE lang=mc++>[ object, uuid("2543548B-EFFB-4CB4-B2ED-9D3931A2527D"), dual, oleautomation, nonextensible, hidden, helpstring("IServicesMgr Interface"), pointer_default(unique) ] __interface IServicesMgr : IDispatch { };

Adding these attributes to the interface will make it compatible with OLE automation, hidden in user-oriented object browsers (just to save user's time) and will disallow the user to populate this interface with properties or methods at run-time.

Make another note: we do all stuff right in our C++ code. We don't bother with IDL and other things.

Repeat the steps above to add coclasses smServices (we cannot use Services name, because it is a name of some system namespace) and smService. Add attribute noncreatable to both smServices and smService coclasses. This will prevent the user from creation of these objects.

Adding Functionality

Let's add Start() and Stop() methods to our ServicesMgr coclass. Right click on IServiceMrg node in Class View and select Add/Add Method. Set method name to Start and add a BSTR [in] parameter with name ServiceName. Do the same to add Stop() method. You should get the following code:

<PRE lang=mc++>__interface IServicesMgr : IDispatch { [id(1), helpstring("method Start")] HRESULT Start([in] BSTR ServiceName); [id(2), helpstring("method Stop")] HRESULT Stop([in] BSTR ServiceName); };

The wizard will also add proper declarations to the coclass and provide you with default implementation of this method. Edit helpstring attributes to give more helpful hint for the user.

Note the id attribute near each method. It sets the dispatch ID of the method. By using this attribute, we don't need to write any dispatching stuff by hand — everything will be done by the compiler.

To simplify testing, "implement" these methods that way:

<PRE lang=mc++>STDMETHODIMP CServicesMgr::Start(BSTR ServiceName) { Beep(400, 100); return S_OK; } STDMETHODIMP CServicesMgr::Stop(BSTR ServiceName) { Beep(1000, 100); return S_OK; }

Build the project and run the following script to test it:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")


MsgBox "Started!"


If you did everything right, then you'll hear a beep, then see the message box and then hear a beep again.

Note, that we testing our object with script, so it's exposed to scripting languages. Note also that we did it with minimum effort by using dual attribute, deriving our interface from IDispatch and using id attribute for the methods.

Starting and stopping services using their names is a good deal, but how's the user expected to know these names? We should provide him with the ability to iterate names of services in order to obtain all available names.

Services Coclass

According to "Building COM Components That Take Full Advantage of Visual Basic and Scripting" article, we should implement an interface with 2 methods and 1 property — _NewEnum() method, property Item and Count() method. These methods have special dispatch ID codes, so the caller will know what to expect from them. Note an underscored _NewEnum() method. This means the method won't be visible for the user.

So, our IsmServices should have these methods and property:

<PRE lang=mc++>[ object, uuid("5BB63796-959D-412D-B94C-30B3EB8D97F1"), dual, oleautomation, hidden, nonextensible, helpstring("IsmServices Interface"), pointer_default(unique) ] __interface IsmServices : IDispatch { [propget, id(DISPID_VALUE), helpstring("Returns a service referred by name or index")] HRESULT Item([in] VARIANT Index, [out, retval] IsmService** ppVal); [id(1), helpstring("Returns number of services")] HRESULT Count([out,retval] LONG* plCount); [id(DISPID_NEWENUM), helpstring("method _NewEnum")] HRESULT _NewEnum([out,retval] IUnknown** ppUnk); };

Note that property Item and method _NewEnum() use special DISPID identifiers. This is important.

We decided that coclass smServices will perform services enumeration, but on the other hand, coclass ServicesMgr that will provide Services as property, has methods for starting and stopping services. Then it's a good idea to delegate Start() and Stop() methods to smServices. But this will lead us to a bit tricky declaration of smServices coclass.

Now smServices implements IsmServices interface. Remove this declaration and replace it with the following:

<PRE lang=mc++>class ATL_NO_VTABLE CsmServices : public IDispatchImpl<IsmServices> { BEGIN_COM_MAP(CsmServices) COM_INTERFACE_ENTRY(IDispatch) COM_INTERFACE_ENTRY(IsmServices) END_COM_MAP() ... };

This will provide us with the default implementation of IDispatch interface and expose both IDispatch and IsmServices interfaces to the client.

Now we are able to instantiate smServices coclass ourselves by using this construction (this will implement IUnknown interface for smServices):

<PRE lang=mc++>CComObject<CsmServices> Services;

Repeat these steps for smService coclass.

Then make a typedef and add a declaration to ServicesMgr coclass:

<PRE lang=mc++>typedef CComObject<CsmServices> CServices; class ATL_NO_VTABLE CServicesMgr : public IServicesMgr { private: CServices *m_pServices; public: CServicesMgr() { if (SUCCEEDED(CServices::CreateInstance(&m_pServices))) m_pServices->AddRef(); } void FinalRelease() { if (m_pServices) m_pServices->Release(); } ... };

And finally add Services property to ServicesMgr coclass:

<PRE lang=mc++>__interface IServicesMgr : IDispatch { [id(1), helpstring("method Start")] HRESULT Start([in] BSTR ServiceName); [id(2), helpstring("method Stop")] HRESULT Stop([in] BSTR ServiceName); [propget, id(3), helpstring("Collection of available services")] HRESULT Services([out, retval] IsmServices** ppVal); };

Now add EnumServices() method to our smServices coclass (not in interface!):

<PRE lang=mc++>typedef std::vector<_Service> _Services; class ATL_NO_VTABLE CsmServices : public IDispatchImpl<IsmServices> { ... private: _Services m_Services; public: STDMETHOD(EnumServices)(); ... }; STDMETHODIMP CsmServices::EnumServices() { // Populate m_Services here return S_OK; }

And implement get_Services() method of ServicesMgr:

<PRE lang=mc++>STDMETHODIMP CServicesMgr::get_Services(IsmServices** ppVal) { if (m_pServices) { // Make sure we enumerated services HRESULT hr = m_pServices->EnumServices(); if (SUCCEEDED(hr)) return m_pServices->QueryInterface(ppVal); else return hr; } return E_FAIL; }

We populated CsmServices coclass with methods without touching the interface that will be used by clients for enumeration. Clients won't need to call EnumServices() directly.

Now add Start() and Stop() methods to smServices coclass using the same way and move their implementation from ServicesMrg coclass.

Enabling Collection Iteration

In order to support collection iteration behavior (For Each ... Next), we should implement _NewEnum() method of CsmServices. The method should return a new object enumerating the collection. This object should implement IEnumVARIANT interface.

Let's create CsmServicesEnum class. This class will copy the list of services from CsmServices and will give the user an ability to iterate it. List of services should be copied because if the user will run two enumerations simultaneously, we'll need to handle them independently.

Add a new ATL Simple Object to the project. Name it smServicesEnum. It doesn't need a custom interface, so remove IsmServicesEnum interface declaration and change the declaration of CsmServicesEnum class and populate it with IEnumVARIANT interface methods:

<PRE lang=mc++>class ATL_NO_VTABLE CsmServicesEnum : public CComObjectRoot , IEnumVARIANT { BEGIN_COM_MAP(CsmServicesEnum) COM_INTERFACE_ENTRY(IEnumVARIANT) END_COM_MAP() ... public: STDMETHOD(Next)(unsigned long celt, VARIANT *rgvar, unsigned long *pceltFetched); STDMETHOD(Skip)(unsigned long celt); STDMETHOD(Reset)(); STDMETHOD(Clone)(IEnumVARIANT **ppenum); };

And don't forget to add typedef to be able to instantiate the object:

<PRE lang=mc++>typedef CComObject<CsmServicesEnum> CServicesEnum;

Next() method will fetch celt elements of the collection, Skip() will skip a number of items, Reset() method will reset enumeration state to initial, and Clone() method should create a copy of the current state of enumeration.

Our enumerator must hold a copy of services and the current state of enumeration:

<PRE lang=mc++>class ATL_NO_VTABLE CsmServicesEnum : public CComObjectRoot , IEnumVARIANT { ... private: _Services m_Services; int m_Idx; public: CsmServicesEnum() : m_Idx(0) { } void CloneServices(const _Services *pServices) { m_Services.assign(pServices->begin(), pServices->end()); m_Idx = 0; } ... };

Then _NewEnum() method of smServices will look like this:

<PRE lang=mc++>STDMETHODIMP CsmServices::_NewEnum(IUnknown** ppUnk) { CServicesEnum *pEnum; CServicesEnum::CreateInstance(&pEnum); pEnum->AddRef(); pEnum->CloneServices(&m_Services); HRESULT hr = pEnum->QueryInterface(ppUnk); pEnum->Release(); return hr; }

Now we can implement methods of our enumerator.

<PRE lang=mc++>STDMETHODIMP CsmServicesEnum::Next(unsigned long celt, VARIANT *rgvar, unsigned long *pceltFetched) { if (pceltFetched) *pceltFetched = 0; if (!rgvar) return E_INVALIDARG; for (int i = 0; i < celt; i++) VariantInit(&rgvar[i]); unsigned long fetched = 0; while (m_Idx < m_Services.size() && fetched < celt) { rgvar[fetched].vt = VT_DISPATCH; // Create and initialize service objects CService *pService; CService::CreateInstance(&pService); pService->AddRef(); pService->Init(m_Services[m_Idx]); HRESULT hr = pService->QueryInterface(&rgvar[fetched].pdispVal); pService->Release(); if (FAILED(hr)) break; m_Idx++; fetched++; } if (pceltFetched) *pceltFetched = fetched; return (celt == fetched) ? S_OK : S_FALSE; } STDMETHODIMP CsmServicesEnum::Skip(unsigned long celt) { unsigned long i = 0; while (m_Idx < m_Services.size() && i < celt) { m_Idx++; i++; } return (celt == i) ? S_OK : S_FALSE; } STDMETHODIMP CsmServicesEnum::Reset() { m_Idx = 0; return S_OK; } STDMETHODIMP CsmServicesEnum::Clone(IEnumVARIANT **ppenum) { CServicesEnum *pEnum; CServicesEnum::CreateInstance(&pEnum); pEnum->AddRef(); pEnum->CloneServices(&m_Services); HRESULT hr = pEnum->QueryInterface(ppenum); pEnum->Release(); return hr; }

In order to test our enumerator, implement Name and DisplayName properties of smService coclass.

<PRE lang=mc++>STDMETHODIMP CsmService::get_Name(BSTR* pVal) { *pVal = m_Service.Name.AllocSysString(); return S_OK; } STDMETHODIMP CsmService::get_DisplayName(BSTR* pVal) { *pVal = m_Service.DisplayName.AllocSysString(); return S_OK; }

Now we can write a simple test script:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")

WScript.Echo Mgr.Services.Count

Dim Service
For Each Service In Mgr.Services
  WScript.Echo Service.DisplayName

Only one thing left to complete with collections support. This is the Item property.

<PRE lang=mc++>STDMETHODIMP CsmServices::get_Item(VARIANT Index, IsmService** ppVal) { _Service svc; *ppVal = 0; if (VT_BSTR == Index.vt) { // Reference by string handle CString SvcHandle(Index); if (!GetService(SvcHandle, &svc)) return E_FAIL; } else if (Index.vt & (VT_BYREF | VT_VARIANT)) { // Reference by VARIANT (Dim i; For i = 0 to x Next; in VBScript) LONG i = Index.pvarVal->lVal; if (!GetService(i, &svc)) return E_FAIL; } else { // Reference by integer index LONG i = V_I4(&Index); if (!GetService(i, &svc)) return E_FAIL; } // Create service CService *pService; CService::CreateInstance(&pService); pService->AddRef(); pService->Init(svc); HRESULT hr = pService->QueryInterface(ppVal); pService->Release(); return hr; }

The code above uses overloaded function GetService(). This function searches for service record using either integer index or service handle. Refer to smServices.cpp for details.

Now we can write the following code to work with our collection:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")

For i = 0 To Mgr.Services.Count - 1
    WScript.Echo Mgr.Services.Item(i).Name

Congratulations, we added collections support to our COM object. You can use similar technique to add another collection.

Reporting Errors

What if the user specified invalid service handle or index value? What if there're some problems with service manager on our machine? The right solution is to add to our object an ability to report errors.

To report errors, our objects should implement ISupportErrorInfo interface and use SetErrorInfo function to supply information about an error to the caller.

First of all, we'll write an error-reporting function that will handle all deals with SetErrorInfo function for us and will return a special result code.

<PRE lang=mc++>template<class ErrorSource> HRESULT ReportError(ErrorSource* pes, ULONG ErrCode, UINT ResourceId = -1) { ICreateErrorInfo *pCrErrInfo; IErrorInfo *pErrInfo; if (SUCCEEDED(CreateErrorInfo(&pCrErrInfo))) { // Set all needed information for Err object in VB or active scripting CString Descr; if (-1 != ResourceId) Descr.LoadString(ResourceId); pCrErrInfo->SetDescription(Descr.AllocSysString()); pCrErrInfo->SetGUID(__uuidof(ErrorSource)); CString Source = typeid(ErrorSource).name(); pCrErrInfo->SetSource(Source.AllocSysString()); if (SUCCEEDED(pCrErrInfo->QueryInterface(IID_IErrorInfo, reinterpret_cast<void**>(&pErrInfo)))) { // Set error information for current thread SetErrorInfo(0, pErrInfo); pErrInfo->Release(); } pCrErrInfo->Release(); } // Report error via result code return MAKE_HRESULT(1, FACILITY_ITF, ErrCode); }

This is a template function. It will use type information to deduct interface GUID of the source and source type name (this could be obtained with Err.Source). It can also load error description from resources.

In order to implement ISupportErrorInfo interface, we'll use support_error_info attribute. Actually this is all we need to do.

<PRE lang=mc++>[ ... support_error_info("IServicesMgr"), ... ] class ATL_NO_VTABLE CServicesMgr; // ... [ ... support_error_info("IsmService"), ... ] class ATL_NO_VTABLE CsmService; // ... [ ... support_error_info("IsmServices"), ... ] class ATL_NO_VTABLE CsmServices;

Now, let's define error codes and how we'll return them.

For ServicesMgr, erroneous situation is when smServices couldn't be instantiated. Add the following to the code:

<PRE lang=mc++>class ATL_NO_VTABLE CServicesMgr : public IServicesMgr { ... private: enum { errNoServices = 0x100 }; ... }; STDMETHODIMP CServicesMgr::Start(BSTR ServiceName) { if (m_pServices) { CString SvcName(ServiceName); return m_pServices->Start(SvcName); } else return ReportError(this, errNoServices); } STDMETHODIMP CServicesMgr::Stop(BSTR ServiceName) { if (m_pServices) { CString SvcName(ServiceName); return m_pServices->Stop(SvcName); } else return ReportError(this, errNoServices); }

For smServices, erroneous situation is when services couldn't be enumerated, user specified invalid service handle or index, or service couldn't be stopped or started:

<PRE lang=mc++>class ATL_NO_VTABLE CsmServices : public IDispatchImpl<IsmServices> { ... private: enum { errCannotEnumServices = 0x200, errCannotStart, errCannotStop, errInvalidIndex, errInvalidHandle, errCannotOpenServiceManager, errCannotEnumerateServices, errOutOfMemory, errCannotOpenService, errCannotQueryStatus, errOperationFailed }; ... };

Then CsmServices::get_Item() will look like this:

<PRE lang=mc++>STDMETHODIMP CsmServices::get_Item(VARIANT Index, IsmService** ppVal) { _Service svc; *ppVal = 0; if (VT_BSTR == Index.vt) { // Reference by string handle CString SvcHandle(Index); if (!GetService(SvcHandle, &svc)) return ReportError(this, errInvalidHandle); } else if (Index.vt & (VT_BYREF | VT_VARIANT)) { // Reference by VARIANT (Dim i; For i = 0 to x Next; in VBScript) LONG i = Index.pvarVal->lVal; if (!GetService(i, &svc)) return ReportError(this, errInvalidIndex); } else { // Reference by integer index LONG i = V_I4(&Index); if (!GetService(i, &svc)) return ReportError(this, errInvalidIndex); } // Create service CService *pService; CService::CreateInstance(&pService); pService->AddRef(); pService->Init(svc); HRESULT hr = pService->QueryInterface(ppVal); pService->Release(); return hr; }

We can test error reporting with this script:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr")


On Error Resume Next

WScript.Echo Mgr.Services.Item("qwe").Name  '   "qwe" doesn't exist

MsgBox Err.Source
MsgBox Err.Number
MsgBox Err.Description

Firing Events

The last thing we'll add to our services manager is an ability to notify the client with events. We'll add ServiceOperationProgress() event to notify the client about lengthy starting or stopping of service.

First, we create a brand new event interface:

<PRE lang=mc++>// Service operation progress codes [ export, helpstring("Operation progress codes") ] enum ServiceProgress { spContinuePending = SERVICE_CONTINUE_PENDING, spPausePending = SERVICE_PAUSE_PENDING, spPaused = SERVICE_PAUSED, spRunning = SERVICE_RUNNING, spStartPending = SERVICE_START_PENDING, spStopPending = SERVICE_STOP_PENDING, spStopped = SERVICE_STOPPED }; // IServicesMgrEvents [ dispinterface, nonextensible, hidden, uuid("A51F19F7-9AF5-4753-9B6F-52FC89D69B18"), helpstring("ServicesMgr events") ] __interface IServicesMgrEvents { [id(1), helpstring("Notifies about lenghtly operation on service")] HRESULT ServiceOperationProgress(ServiceProgress ProgressCode); };

Note that we also added an enumeration that will be visible for users in VB.NET, so they could use special value names instead of numbers.

Now specify IServicesMgrEvents interface as event interface in ServicesMrg coclass using <CODE lang=mc++>__event __interface keyword. ServicesMrg coclass also must be marked with <CODE lang=mc++>event_source("com") attribute. To fire ServiceOperationProgress() event, we should use <CODE lang=mc++>__raise keyword.

<PRE lang=mc++>[ ... event_source("com"), ... ] class ATL_NO_VTABLE CServicesMgr : public IServicesMgr { ... __event __interface IServicesMgrEvents; void Fire_ServiceOperationProgress(ServiceProgress Code) { __raise ServiceOperationProgress(Code); } ... };

After doing all this stuff, we can easily notify a client with service status by calling Fire_ServiceOperationProgress() method.

<PRE lang=mc++>HRESULT CsmServices::WaitPendingService(SC_HANDLE hService, DWORD dwPendingState, DWORD dwAwaitingState) { // ... while (dwPendingState == ServiceStatus.dwCurrentState) { // ... if (m_pMgr) m_pMgr->Fire_ServiceOperationProgress (static_cast<ServiceProgress>(ServiceStatus.dwCurrentState)); // ... } // ... }

To test events handling, we'll use the following script:

Set Mgr = WScript.CreateObject("ServicesManager.ServicesMgr", "Mgr_")


Sub Mgr_ServiceOperationProgress(ProgressCode)
    WScript.Echo ProgressCode
End Sub

Better run this script using cscript.exe, not wscript.exe, so the output will be done in stdout.

Points of Interest

You can find more about handling events in scripts by reading "Scripting Events" article in MSDN (Andrew Clinick, 2001). This was really an interesting thing for me.

There's also a great article "Building COM Components That Take Full Advantage of Visual Basic and Scripting" (by Ivo Salmre, 1998, MSDN). In this article, you'll find basic information about the features your COM server needs to be seamlessly used in C++, VB and VBScript languages.

If you want to debug similar objects, then just write a script in VBScript, set cscript.exe as debugging command and path to the script as command arguments. Then place breakpoints where needed and run the project. This is the easiest way to debug such COM objects.


Version 1.0 so far.


This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here


About the Author

Alex Kolesnichenko
Software Developer (Senior)
United States United States
Started professional career in software development back in 2000, in Ukraine. Founder and owner of a boutique software company called Software. Worked for 6 years at w2bi, Inc in New Jersey USA, currently work in a large multinational company based in Redmond, WA.

My buzzwords at the moment: .NET, C#, ASP.NET, MVC, LINQ, TypeScript, JavaScript, AngularJS, HTML, JSON, services.

Still buzzing: C++, Win32, ATL, MFC, SQL, WinForms, WebForms, EF, Sockets, TCP/IP, Remoting.

You may also be interested in...


Comments and Discussions

GeneralFInalRelease never called Pin
krfvgew010-Dec-09 11:13
memberkrfvgew010-Dec-09 11:13 
GeneralRe: FInalRelease never called Pin
Vitaly Tomilov3-May-10 8:16
memberVitaly Tomilov3-May-10 8:16 
GeneralEvents in Internet Explorer Pin
krssagar16-Aug-05 13:35
memberkrssagar16-Aug-05 13:35 
GeneralFire events Pin
Anonymous12-Jun-05 20:20
memberAnonymous12-Jun-05 20:20 
GeneralRe: Fire events Pin
mcreej23-Aug-05 5:36
membermcreej23-Aug-05 5:36 
General[out,retval]NULL crashes in VBScript Pin
krssagar17-May-05 6:19
memberkrssagar17-May-05 6:19 
GeneralNice article Pin
aleksey->25-Apr-05 11:20
memberaleksey->25-Apr-05 11:20 
GeneralRe: Nice article Pin
krssagar22-Jul-05 6:39
memberkrssagar22-Jul-05 6:39 
General.NET Pin
eranation19-Apr-04 15:11
membereranation19-Apr-04 15:11 
GeneralRe: .NET Pin
twask20-Apr-04 3:25
membertwask20-Apr-04 3:25 
GeneralThanks Pin
Jaroslav Klumpler23-Feb-04 8:47
memberJaroslav Klumpler23-Feb-04 8:47 
GeneralRe: Thanks Pin
twask23-Feb-04 9:08
membertwask23-Feb-04 9:08 

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.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web06 | 2.8.190114.1 | Last Updated 27 Jan 2004
Article Copyright 2004 by Alex Kolesnichenko
Everything else Copyright © CodeProject, 1999-2019
Layout: fixed | fluid