Click here to Skip to main content
13,001,454 members (58,827 online)
Click here to Skip to main content
Add your own
alternative version


35 bookmarked
Posted 11 Aug 2004

MSI Packages Manager

, 11 Aug 2004
Rate this:
Please Sign up or sign in to vote.
This article shows how to process one or many MSI packages just by providing a configuration file. It gives also many useful hints and tricks that can be used in other projects.

sample screenshot



By reading the article title, we think that I will present the great MSI technology (Windows Installer). In fact, that's not the subject of the article at all. This article presents an easy way to manage any MSI package before, during and after MSI processing. It has no relation with MSI custom actions and different steps in product installation process as we can see in different market products as Visual Studio setup and deployment, InstallShield Studio, Wise Installer etc.

It may happen that we cannot do all things we want in MSI packages with respect to the actions taking place before and after installation. The only thing you need to use immediately in the program presented in this article is a configuration file which I will explain in the next section.

In the same time, the article presents many useful and ready to use hints on:

  • how to create threads in UI programming and disable our UI to be closed (by Atl+F4, for example),
  • how to launch silently Win32 or console programs, and DOS commands with/without parameters,
  • how to destroy a whole directory or to delete only files with some pattern in one or many directories,
  • how to design easily a non deterministic progress bar (see the image above) when we cannot evaluate exactly the operation length in terms of time or count,
  • how to realize a gradient color brush on any rectangle of any control (see the image above) by simply calling one function.

Before I go into details, I have to give credit to some people who have contributed free source in CodeProject, and from them I took many good hints and nice ideas to construct some of the article functions:

  • Patrik Svensson for his article entitled A Gradient Static control from which I took the idea of Gradient fill.
  • Robert Edward Caldecott for his article WTL Button menu Class from which I took the idea of using Marlett font in designing non deterministic progress bar.

The idea of testing if Windows Installer is present by looking into the registry is not mine, and I don't have the author's name. In spite that this method works for all Windows OS (except Windows 95), it is more appropriate, at least for Windows 2000/XP/2003, to test if Windows Installer service is installed on the target system. The fact that the file msiexec.exe is present in System32 directory does not prove at all that Windows Installer is alive.

How to use it

MsiMgr.exe Configuration_File

Where Configuration_File is the configuration file containing the package version and description, log file path, and actions that should be processed by MsiMgr.


MsiMgr C:\MyFile.ini
MsiMgr C:\My directory\MyFile.ini

Even if the configuration file path contains spaces, don't add quotes, otherwise it doesn't work. I will explain in details in the next section the way the configuration file should be organized, and the rules should be respected in each of the included lines.

How the program is organized

Configuration file has the .INI file format. The following diagram shows the different sections and the general rule of included lines in each section.

sample screenshot

Each line in the configuration file has the following scheme except the SETTINGS section, in which we indicate the package version, description and logfile path as we will see below:


Parameters between [] are optional. Of course, the VERB and the FILE are obligatory. If FILE or CONDITION_DATA is a path containing spaces, make sure to enclose the string between two quotes".

This line means:

Apply the verb VERB to FILE+PARAMTERS only if CONDITION applied on CONDITION_DATA is true or if there is no condition, and display MESSGE_TO_DISPLAY during the line process.


Table 1: Typical line elements table


is the file to process depending on the verb value


Parameters to pass to File during the verb application


is the verb to process


is the condition


is the condition data on which condition should be applied


is the message to display on the UI during the verb execution

The tables below show the values the parameters VERB and CONDITION can have. Note that the VERB applies to FILE, and the CONDITION applies to CONDITION_DATA. Note also that neither the verb values nor the condition values are case sensitive.

Table 2: VERB values table


Delete file


Delete directory


Delete registry key


Delete registry key value


Execute file by passing parameters PARAMTERS if any


Uninstall MSI package


Install MSI package

Table 3: CONDITION values table


If file/directory exists


If file/directory not exists


If key exists. In this case, the CONDITIONS_DATA should end with backslash sign to differ from a key value.


If key not exists. In this case, the CONDITIONS_DATA should end with backslash.


If key value exists. In this case, the CONDITIONS_DATA should not end with backslash.


If key value not exists.

Here is an example of a configuration file to make the entire concept clearer. It depends on your requirements and imagination to fill the configuration file.

<FONT color=#009900>;Package version</FONT>
<FONT color=#009900>;Package description</FONT>
Description=this is install test
<FONT color=#009900>;Logs will be generated in C:\Mylog.log</FONT>
<FONT color=#009900>;Wait at most 5 minutes for processes execution</FONT>

<FONT color=#009900>;This line will delete directory C:\Test !</FONT>
c:\Test=,deldir,,,Deleting directory C:\test...
<FONT color=#009900>;This line will delete directory C:\Temp only if directory C:\Test1 exist!</FONT>
C:\Temp\=,delfile,IF_EXIST,C:\Test1, deleting all *.dll in C:\Temp directory...
<FONT color=#009900>;This line will delete directory C:\Temp1 
             only if file C:\Test2\MyFlagFile.001 exist!</FONT>
         deleting all *.dll in C:\Temp directory...
<FONT color=#009900>;This line will unregister the service whose path 
             C:\Windows\system32\MyService.exe if this one exist</FONT>
   C:\Windows\system32\MyService.exe,Unregistring the service Myservice...
<FONT color=#009900>;This line will delete the service binary 
             C:\Windows\system32\MyService.exe if this one exist</FONT>
   C:\Windows\system32\MyService.exe,deleting the service Myservice...
<FONT color=#009900>;This line will launch NotePad (works only on WinXP/2000/2003), 
                for Win9X replace cmd by Command</FONT>
cmd /C %windir%\notepad.exe=,ExecFile,,,Launching NotePad...

<FONT color=#009900>;This section and the next one apply only to MSI packages</FONT>
<FONT color=#009900>;(that's why we don't need to specify msi engine/service msiexec.exe)</FONT>
<FONT color=#009900>;This line will uninstall the product with code indicated on the left 
;by hiding the cancel button and generating the msi log in C:\MSIlog.log</FONT>
{125F0499-6759-11D5-A54F-0090278A1BB8}=/QB+! /LC:\MSIlog.log,
                   UnIns,,,Uninsall my product in progress...

<FONT color=#009900>;Install c:\Packages\MyPackage\MyMsi.msi with the MSI paramter 
                        "ALLUSERS=1 /QB" without any condition</FONT>  
c:\Packages\MyPackage\MyMsi.msi=ALLUSERS=1 /QB,INS,,,
          Installing MyMsi in prgress. Please wait... 

<FONT color=#009900>;The same rules apply to this section as those of PREINSTALL section</FONT>

Note: When you specify as a VERB ExecFile, the execution waits, by default, for the process termination at most the value of ExecTimeout specified in SETTINGS section in milliseconds. This timeout may be adjusted for each package by evaluating, in pessimistic case, the most long operation even if the target machines can have less or more CPU performance.

Using the code

The project is a Win32 MFC application. The main window contains only one child dialog. The remaining parts of the UI around the dialog are just decoration that have been accomplished using some GDI functions that I will explain later.

By making a correspondence between the tables above (Tables 1, 2, and 3 above), and the program source code, they correspond respectively to:

enum VerbFlag {
    DelFile,     //delete file
    DelDir,      //delete directory
    DelKey,      //delete reg key
    DelKeyValue, //delete reg key value
    ExecFile,    //Execute file
    UnIns,       //UnInstall Msi package
    Ins,         //Install Msi package

enum ConditionFlag{
    IF_EXIST,                 //If file exist
    IF_NOT_EXIST,             //If file not exist
    IF_KEY_EXIST,             //if key exist
    IF_KEY_NOT_EXIST,         //if key not exist
    IF_KEY_VALUE_EXIST,       //if key value exist
    IF_KEY_VALUE_NOT_EXIST,   //if key value not exist

struct InstallStruct {
    CString Data;      //file/dir/registry on which the verb is applied
    CString Parameter; //Parameters execution for Data if anny
    int Verb;          //the verb (see VerbFlag)
    int Flag;          //the flag condition (see ConditionFlag)
    CString FlagFile;  //Flag file/dir/key etc to be tested by the falg condition
    CString Message;   //Message to be displayed

The main program thread MainThread responsible for processing all sections is as follows:

//    Program Main thread 
    DWORD    ExitCode=0;
    BOOL     ret[4];

    for (int i= 0;i<4; i++) ret[i]=TRUE;

    //    We go to next section only if the current
    //    section has returned with success
    if (ret[0]==TRUE) {
        if (ret[1]==TRUE) {
            if (ret[2]==TRUE)

    CString Msg;
    if ( (ret[0]&ret[1]&ret[2]&ret[3])!=0) {
        Msg=_T("\nMSI package processing has finished with success.");
    else {
        Msg=_T("\nThe MSI package processing has failed!");
        //    Don't call MessageBox with respect to the main window
        //    (the attached main thread), it will not work!
        //    Instead, call MessageBox with respect to the dialog


    //    Destroy our unique dialog

    //    Tell to the main thread associated with
    //    our frame window that we are done

    return ExitCode;

The decoration around the dialog is made by a call to the function below:

//    Purpose:     Make a filled rectangle by a gradient color
//    hWnd:        Handle to the control on which the gradient will be made
//    FirstColor:  First gradient color
//    SecondColor: Second gradient color
//    TextOut:     Text will be drawn
//    TextColor:   Text color
//    TextFormat:  Text format
//    FontName:    font name used in the text output
//    FontSize:    font size used in the text output
//    Rect:        Rectangle on which the gradient rectangle will take place
//    Direction:   Gradient direction (Vertical:GRADIENT_FILL_RECT_V, 
//                                     Horizontal:GRADIENT_FILL_RECT_H)
//    CleanUp:     Tell that we can free the library msimg32.dll
//            (this should be TRUE only in the last call to this function)
//    For exmaples on how to call this function, see below
void    MakeGradient(HWND hWnd, COLORREF FirstColor, COLORREF SecondColor,
        CString &TextOut, COLORREF TextColor, UINT    TextFormat,
        CString &FontName, int FontSize,
        CRect &Rect, DWORD Direction, BOOL CleanUp)
    static HINSTANCE    h_msimg32;
    if (h_msimg32==NULL) h_msimg32= LoadLibrary( "msimg32.dll" );
    CDC    dc;
    HDC    hDC=GetWindowDC(hWnd);

    CFont Font;
    int nHeight = -1-MulDiv(FontSize, GetDeviceCaps(hDC, LOGPIXELSY), 72);
    int nWidth  = -1-MulDiv(FontSize, GetDeviceCaps(hDC, LOGPIXELSX), 72);
    Font.CreateFont(nHeight, nWidth,
        0, 0, FW_DONTCARE, FALSE, FALSE, 0,
        FontName );

    // Select the newly created font into
    // the device context
    CFont *pOldFont = dc.SelectObject( &Font );

    if (h_msimg32) {
               CONST PVOID,DWORD,DWORD);
        LPFNDLLFUNC1 dllfunc_GradientFill = 
               ((LPFNDLLFUNC1) GetProcAddress( h_msimg32, 
               "GradientFill" ));

        if (Rect.IsRectNull()) {
            GetClientRect(hWnd, &Rect);

        TRIVERTEX rcVertex[2];

        //    Define vertex array

        // color values from 0x0000 to 0xff00(255)


        GRADIENT_RECT rect;

        // fill the area
        dllfunc_GradientFill(dc, rcVertex, 2, &rect, 1, Direction);
        if ( (h_msimg32!=NULL) && (CleanUp==TRUE) ) {

    //    Draw text
    SetTextColor(dc, TextColor);
    SetBkMode(dc, TRANSPARENT);
    DrawText(dc, TextOut, -1, &Rect, TextFormat);

    //    Draw the contour
    Rect.DeflateRect(-1,-1, -1, -1 );

    //    Select the old font back into the device context.
    dc.SelectObject( pOldFont );


To make the progress bar, a timer is set in the InitInstance function. Then the function above is called in the main frame window procedure as a response to WM_TIMER message by using a string table and Marlett font as follows:

                         WPARAM wParam, LPARAM lParam)
    static int i;
    static CString str[22]={

    CRect        rect;

    switch (message)
        case WM_TIMER:
            // If DONE then Kill our timer and exit by destroying our window
            if (DONE==1) {
                KillTimer(hWnd, 1);
                return 0;

            //    Progress animation is done here
                 RGB(128, 128, 128), RGB(255, 255, 255),
                 str[i], RGB(119, 60, 0), 
                 DT_CENTER|DT_VCENTER, CString("Marlett"),
                 10, rect, GRADIENT_FILL_RECT_H,FALSE);

            //    Create our unique thread as long as static
            //    variable thread handle h is null
            //    It sould be done only once
            if (h==NULL)
                h=CreateThread(NULL,0,MainThread, &hWnd, NULL,&ID);

            return DefWindowProc(hWnd, message, wParam, lParam);
   return 0;

How the program processes each line in each section?

The lines are processed in the same order as they are in the configuration file. Each line at one time is represented by a global variable.

InstallStruct Install; //Global Install variable

and it is processed by following the steps:

  1. Get the FILE which will be stored in Install.Data.
  2. Get the Data value (right-hand of =), say strArr, of type CStringArray.
  3. Split strArr into Parameter, Verb, Flag (CONDITION), FlagFile (CONDITION_DATA), and Message by calling the function:
    // Split string str and return the slices in the CString array SplitArray
    // This function works even if there is nothing beteween separators such
    // as if str="toto,tata,,,mama,,"
    void SplitString(CString &str, char Sep, CStringArray &SplitArray)
  4. Get VERB, CONDITION, CONDITION_DATA, and MESSGE_TO_DISPLAY values. Since the VERB and CONDITION can have different values, two functions are used to get their values:
    int GetVerb(CStringArray strArr)
    int GetCondition(CStringArray strArr)
  5. Call the function corresponding to the current section as indicated in the table:
SECTIONCorresponding function
UNINSTALLBOOL UnInstallPackage(HWND hWnd, DWORD &ExitCode)
INSTALLBOOL InstallPackage(HWND hWnd, DWORD &ExitCode)

I will not explain how these functions work, but as I mentioned in the Introduction, I will give some intermediate functions used indirectly, in the next section. These functions may be used in any program to achieve common tasks.

How to's

  • How to create threads in UI programming and disable our UI to be closed normally (by Atl+F4 for example):
    ATOM MyRegisterClass(HINSTANCE hInstance) 
      WNDCLASSEX wcex; wcex.cbSize = sizeof(WNDCLASSEX); = CS_NOCLOSE; //This prevent the use of Alt+F4 ...........

    and see the thread creation MainThread above.

  • How to launch silently Windows or console programs and DOS commands with/without parameters:
    // Purpose:
    //   -Create a process with module name: ModuleName, 
    //                          command line: CommandLine 
    //   -and wait for the end of the process or not: WaitOrNot 
    //   -You can fix a TimeOut (milliseconds),
    //       otherwise pass -1 (INFINITE)
    //   -You can specify if the UI associeted
    //       to the process will be visible 
    //       by passing Show parameter
    //       (TRUE:Visible, FALSE:Not visible)
    //   -[OUT] ExitCode is the process exit code (very usefull)
    // Returns:    creation status (TRUE or FALSE)
    BOOL    CreateProc(char *ModuleName, char *CommandLine, BOOL WaitOrNot, 
            DWORD TimeOut, BOOL Show, DWORD &ExitCode)
        STARTUPINFO        startup_info;
        PROCESS_INFORMATION    process_info;
        BOOL        status;
        DWORD        dwexitcode = 0;
        if ( strcmp(CommandLine, "")==0 && strcmp(ModuleName, "")==0 )
            return TRUE;
        ZeroMemory(&startup_info, sizeof(STARTUPINFO));
        startup_info.cb        = sizeof ( startup_info );
        startup_info.dwFlags    = STARTF_USESHOWWINDOW; 
        startup_info.wShowWindow    = SW_SHOWNORMAL?(Show==TRUE):SW_HIDE;
        status = CreateProcess((LPTSTR)
                NULL, NULL, FALSE, 
                0 , NULL, NULL,
                &startup_info, &process_info );
        if ( status ) {
            if ( WaitOrNot ) 
                if (TimeOut>0)
                  WaitForSingleObject ( process_info.hProcess, TimeOut );
                  WaitForSingleObject ( process_info.hProcess, INFINITE );
        if (GetExitCodeProcess(process_info.hProcess, &dwexitcode) != 0) {
            ExitCode = dwexitcode;
            CloseHandle ( process_info.hThread );
            CloseHandle ( process_info.hProcess );
        if (status == 0 )    return    FALSE; 
        else return    TRUE;

    Example: If you want to execute a DOS command or a console program silently, do the following:

    //Get Command prompt (...\cmd.exe for WINNT/XP/2002/2003 
    //and for Win9X/ME)
    char *Cmd=getenv("COMSPEC");
    char cmdline[255]; 
    sprintf(cmdline,"%s /C Dir *.*/s>C:\\Out.txt",Cmd);
    DWORD ExitCode=0;
    //This line will produce the output file C:\Out.txt 
    //with all files in the current directory and all sub-directorie
    BOOL ret=CreateProc("", cmdline, TRUE /*Wait*/, 
            10000 /*10 seconds as wait 
            timeout*/, FALSE /*Hide console window*/, ExitCode);
    //...Proceed the program sequence 
    //with respect to ret and ExitCode
    //Open now with NotePad our file C:\Out.txt    
    if (GetWindowsDirectory(Windir,MAX_PATH)>0){
        char modName[MAX_PATH];
        sprintf(cmdline,"%s C:\\Out.txt",modName);
        ret=CreateProc("", cmdline, FALSE /*don't wait*/, 
                0 /*put any int number*/, 
                TRUE /*Show NotePad*/, ExitCode);
  • How to destroy a whole directory or to delete only files with some pattern, in one or many directories:

    CAUTION: Beware! Using the functions below without paying attention can be dangerous for your data. If you want to test them, specify a directory with non pertinent data.

    Deleting a directory and optionally sub-directories and root directory can be done by simply calling the function:

    //    Purpose:    delete all directory files and optionally
    //              subdirectories by recursive method
    //    Parameters:
    //      TheDir        : the directory to empty
    //      DelSubDirs    : tell to delete sub-directories or not
    //      DelRootDir    : tell to delete root (TheDir) directory or not
    //    Return:        TRUE if success
    BOOL    ClearDirEx( CString &TheDir, BOOL DelSubDirs, BOOL DelRootDir)
        CString Pattern="*.*";
        BOOL ret= ClearDir(TheDir.GetBuffer(MAX_PATH), 
                  DelSubDirs, DelRootDir, Pattern.GetBuffer(3));
        return ret;

    Deleting files having some extension in a directory and optionally in all sub-directories can be done by calling the function:

    //    Purpose:    delete all files with extension Pattern 
    //                and optionally include allsubdirectories
    //    Parameters:
    //      TheDir  : the basic directory where where files are to be deleted
    //      Pattern : delete only files with this extension pattern
    //      IncludeSubDirs : tell to delete all sub-directory files too
    //    Return:        TRUE if success
    BOOL    DeleteFilesEx( CString &TheDir, 
            CString &Pattern, BOOL IncludeSubDirs)
        BOOL ret= ClearDir(TheDir.GetBuffer(MAX_PATH), 
                  IncludeSubDirs, FALSE, Pattern.GetBuffer(3));
        return ret;

    The common function called in the two functions above in different ways is:

    BOOL ClearDir(char *TheDir, BOOL DelSubDirs, BOOL DelRootDir, char *Pattern)
        BOOL            bFound=TRUE;
        WIN32_FIND_DATA FileData;
        HANDLE            hSearch;
        static char        *CurDir=TheDir;
        char            TempDir[MAX_PATH];
        if (TheDir==NULL)    return TRUE;
        //    Of course, if we want to delete root directory then
        //    all sub-directories should be deleted too
        if (DelRootDir==TRUE) DelSubDirs=TRUE;
        sprintf(TempDir, TheDir);
        strcat(TempDir, "*.*");
        static char *revPat=_strrev(Pattern);
        hSearch = FindFirstFile((LPCTSTR)TempDir, &FileData);
        while ( bFound ) {
            if (hSearch != INVALID_HANDLE_VALUE) {
                GetFileAttributes( (LPCTSTR)FileData.cFileName );
                if ( FileData.dwFileAttributes & 
                   FILE_ATTRIBUTE_DIRECTORY ) { //Directory data
                 if ( strcmp((char*)FileData.cFileName, "." )!=0 &&
                   strcmp((char*)FileData.cFileName,"..")!=0 ) 
                 { //There is some more files
                    sprintf(TempDir, TheDir);
                    strcat(TempDir, "\\");
                    if ( ClearDir(TempDir, DelSubDirs, 
                            DelRootDir, revPat)==FALSE)
                       return FALSE;
                } else {    //file data
                   if ( SetCurrentDirectory(TheDir)!=0 ) {
                     if (SetFileAttributes((LPCTSTR)FileData.cFileName,
                        FILE_ATTRIBUTE_ARCHIVE)!=0 &&
                        FILE_ATTRIBUTE_NORMAL)!=0 ) {
                          if (strcmp(Pattern,"*.*")==0) {
                            if ( DeleteFile((LPCTSTR)FileData.cFileName)==0)
                              // cannot delete file, return FALSE
                              return FALSE;
                          else {
                            char *revFile=_strrev(FileData.cFileName);
                            if (_strnicmp(revFile,revPat,strlen(Pattern))==0){
                              if ( DeleteFile((LPCTSTR)revFile)==0)
                                return FALSE;
                bFound=FindNextFile(hSearch, &FileData);
            } else return TRUE;
        if ( (DelRootDir==TRUE) && (stricmp(TheDir,CurDir)==0) ) {
            //    We cannot remove local or network drive !!!
            if ( strlen(TheDir)>2 && IsDriveLetter(TheDir)==FALSE ) {
                if ( RemoveDirectory(TheDir)==0)
                    return FALSE;
            } else return TRUE;
        else if ( (stricmp(Pattern,"*.*")==0) && (DelSubDirs==TRUE) 
                                 && (stricmp(TheDir,CurDir)!=0) ) {
            if ( RemoveDirectory(TheDir)==0)
                return FALSE;
        return TRUE;
  • How to design easily a non deterministic progress bar (see the image above) when we cannot evaluate exactly the operation length in terms of time or count:

    The answer to this question consists of the following steps:

    1. Put on your dialog a Static control, for example. (It can be, in fact, another kind of control like Edit Box or Button, but the Static control is more appropriate.) This will be our progress bar.
    2. At the beginning of the long task (long copy, search etc.), set a timer by specifying a timer procedure if you don't already have a global window procedure related to your UI.
    3. In the timer procedure or the global one (in the WM_TIMER message handling), make a call to the function responsible for the progress bar update such as calling the function: MakeGradient(...). In this article, the call is made in the WM_TIMER message handling. The trick to display something like a progress bar is the Marlett font for this control. I have used the digit 1 (1) for the empty squares, and the g (g) character for the filled squares, moving towards in a cyclic time. In fact, you can display anything you want in other existing fonts on the target machine too; the basic idea remains the same. The common length of our strings in the table (str in the above code) can be fixed dynamically with respect to the control width and in order to almost hold the whole string.
    4. Once the operation has reached its end, kill the timer.
  • How to realize a gradient color brush (Alpha-blend) on any rectangle of any control (see the image above) by simply calling one function:

    As indicated before, all the decoration around the main frame window and the progress bar painting is done by a call to one function, namely MakeGradient(...) with many parameters to personalize the appearance of the control. This function can be called on any window (Static, button, Edit etc.) by providing a good handle to the window to get a device context handle, a rectangle on which the gradient can be done, and other information as colors, text, font name, font size etc. It's up to your imagination to use this function in your projects. Note that this function works only on Windows 98/2000 and up, since the Microsoft library msimg32.dll (Extension component for Windows GDI) is not available on Windows 95 and NT 4.0. I am at least sure that it works on Win 98/2000/XP. To see details about functions that can be called from this library, you can examine the header file WINGDI.H, they are prototyped there.


This article has shown how we can drive install/uninstall one or more MSI packages with all operations that can be done before, during, and after, by just providing a configuration file. Feel free to make any suggestion, criticism, or improvement.


Initial version - August 8, 2004.


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

Web Developer
France France
No Biography provided

You may also be interested in...

Comments and Discussions

GeneralCan't make it run on MFC 2005 Pin
oddvard2-Feb-07 4:39
memberoddvard2-Feb-07 4:39 
GeneralRe: Can't make it run on MFC 2005 Pin
JOHN112-Feb-07 22:21
memberJOHN112-Feb-07 22:21 

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 | Terms of Use | Mobile
Web01 | 2.8.170624.1 | Last Updated 12 Aug 2004
Article Copyright 2004 by JOHN11
Everything else Copyright © CodeProject, 1999-2017
Layout: fixed | fluid