Click here to Skip to main content
Click here to Skip to main content
Go to top

A simple plug-in engine using Reflection

, 26 Sep 2012
Rate this:
Please Sign up or sign in to vote.
This article describes how to create and use configurable plug-ins in your application.

Introduction

In fact everyone now knows what a plug-in is. A plug-in is a set of software components that extends functionality of an application. For example, plug-ins are often used in different multimedia players to extend formats that the player can play.

Background

With the release of the .NET 4.0 platform we've got the Managed Extensibility Framework (MEF) – a quite decent extension engine.

But sometimes (though seldom) a customer limits the version of the framework used. Of course you could ask Google to search for ready solutions for implementing plug-ins. For example I've seen the Plux.NET platform for building plug-in systems but I've never tried to review it in details.

So we've got a task to create the solution that would allow your application to extend its functionality without recompilation. An approximate plan of working with plug-ins is as follows:

  • There is an application that supports adding plug-ins.
  • You have added some plug-in files to the special application folder.
  • The application must initiate loading of the plug-ins. For this purpose the application must be restarted or use an explicit method call, e.g. LoadPlugins().
  • Enjoy the extended function set!

There are many articles about plug-ins and I'll do my best to be original. After reading the article you'll learn how to create extensions that support .net configuration and how to limit permissions granted to the plug-ins code.

What's new in version 2

In the first version of the demo project attached to the article loaded plug-ins were fully trusted. That meant they were able to execute potentially dangerous or undesirable code. This was because of loading plug-ins assemblies into the same application domain as the host application. It was the easiest way but not the safest.

The second version offers other approach of loading plug-ins. All plug-ins are loaded into the separate application domain called the Sandbox. The sandbox is an isolated environment with the limited access permissions granted to the executing code.

Creating base classes for our future plug-ins

First of all, let's implement the PluginBase class – as you can understand from its name, it is the base class for our future plug-ins.

Create a new class library project in Visual Studio named PluginBase and add a file with the next code to the project:

/// <summary>
/// Base class for a plugin
/// </summary>
public abstract class PluginBase : MarshalByRefObject
{
    /// <summary>
    /// Plugin's name
    /// </summary>
    public virtual string Name { get; protected set; }

    /// <summary>
    /// Plugin's description
    /// </summary>
    public virtual string Description { get; protected set; }

    /// <summary>
    /// Plugin's configuration
    /// </summary>
    public virtual ConfigurationBase Configuration { get; set; }

    /// <summary>
    /// Default constructor
    /// </summary>
    public PluginBase()
    {
        Name = "Unnamed plugin";
        Description = "No description";
    }

    /// <summary>
    /// Does some work
    /// </summary>
    public abstract void Run();
}

Please note the PluginBase class is derived from the MarshalByRefObject class. That's why instances of the PluginBase class can be accessed across application domain boundaries.

Our simple plug-ins will contain just one clear method – Run(). You can implement any method you want. I said "clear" method because there are three properties in this class – Name, Description and Configuration.

The Run() method is abstract that's why you need to implement this method in your plug-in. Properties are just virtual so you can override them in your descendant plug-in.

It would be great to have configurable plug-ins. And it would be much greater if each plug-in's configuration would be stored in a separate section of the app.config file of the host application.

So let's do it. Here is the abstract class ConfigurationBase, it's inherited from the ConfigurationSection class (don't forget to add a reference to the System.Configuration namespace).

/// <summary>
/// Base configuration class for a plugin
/// </summary>
public abstract class ConfigurationBase : ConfigurationSection
{
    /// <summary>
    /// Opens the configuration section with the specified type and name
    /// </summary>
    /// <param name="sectionName">Configuration section's name</param>
    /// <param name="configPath">Configuration file's path</param>
    /// <returns>Instance of the configuration section's class</returns>
    public static T Open<T>(string sectionName, string configPath) where T : ConfigurationBase, new()
    {
        T instance = new T();
        if (configPath.EndsWith(".config", StringComparison.InvariantCultureIgnoreCase))
            configPath = configPath.Remove(configPath.Length - 7);
        try
        {
            Configuration config = ConfigurationManager.OpenExeConfiguration(configPath);
            /* section not found */
            if (config.GetSection(sectionName) == null)
            {
                config.Sections.Add(sectionName, instance);

                foreach (ConfigurationProperty p in instance.Properties)
                    ((T)config.GetSection(sectionName)).SetPropertyValue(p, p.DefaultValue, true);

                config.Save();
            }
            else
                /* section already exists */
                instance = (T)config.Sections[sectionName];
        }
        catch (ConfigurationErrorsException)
        {
            if (instance == null)
                instance = new T();
        }
        return instance;
    }
}

The ConfigurationBase class contains one generic method

  • public static T Open<T>(string sectionName, string configPath) where T : ConfigurationBase, new()

This static method allows you to open the application configuration file and read the configuration section of the specified type and with the specified name. There will not be configuration section in the file when you first time load the plug-in. That's why this method will add the section to the configuration file and after that you will be able to modify the configuration properties. Anyway the method will return the instance of the configuration section's class.

You may have noticed the line if (config.GetSection(sectionName) == null) is highlighted. Please take in mind that line. We'll come back to it later.

Creating the plug-in

Now it's time to create the plug-in. Open a new class library project (let's call it ShowConsolePlugin) in Visual Studio and add a reference to the previously created project that contains the PluginBase and ConfigurationBase classes. Add two files in the project.

ShowConsolePluginConfiguration.cs:
public sealed class ShowConsolePluginConfiguration : PluginBase.ConfigurationBase
{
    /// <summary>
    /// Message to show
    /// </summary>
    [ConfigurationProperty("Message", DefaultValue = "Hello from ShowConsolePlugin")]
    public string Message
    {
        get
        {
            return (String)this["Message"];
        }
        set
        {
            this["Message"] = value;
        }
    }
}

This class is inherited from the ConfigurationBase class of the PluginBase assembly and has the property Message which actually is a configuration property.

ShowConsolePlugin.cs:
public sealed class ShowConsolePlugin : PluginBase.PluginBase
{
    public ShowConsolePlugin()
    {
        Name = "ShowConsolePlugin";
        Description = "ShowConsolePlugin";
    }

    public override void Run()
    {
        Console.WriteLine(String.Format("[{0}] {1}", DateTime.Now, (Configuration as ShowConsolePluginConfiguration).Message));
    }
}

The Name and Description properties are set in the default constructor of the plug-in. Haven't you forgotten about the Configuration property of the PluginBase class? We convert its type to the ShowConsolePluginConfiguration type in the overridden method Run() which writes the Message value to the system console. Do not worry the value of the Configuration property is null at this moment. We'll take care about this later.

I've been asked how to disable plug-ins access to the Internet. Well, with the sandboxing approach you can do it along with many others security features. For demonstration purposes I've created the WebDownloadPlugin project. The process of creation of the project is the same as the previous (ShowConsolePlugin) except adding the configuration class.

WebDownloadPlugin.cs:
public sealed class WebDownloadPlugin : PluginBase.PluginBase
{
    public WebDownloadPlugin()
    {
        Name = "WebDownloadPlugin";
        Description = "WebDownloadPlugin";
    }

    public override void Run()
    {
        string html = String.Empty;
        using (var wc = new WebClient())
        {
            html = wc.DownloadString("http://codeproject.com");
        }
        Console.WriteLine(Regex.Match(html, @"<title>\s*(.+?)\s*</title>").Groups[1]);
    }
}

As you can see this plug-in downloads the main page of the CodeProject web-site and writes its title to the system console.

Creating the plug-in manager

The easiest part is over. Now we must create the plug-in manager class. It will provide the logic of loading plug-ins's files and the logic of interaction between plug-ins and the host application.

The plug-in manager class will be contained in the PluginBase assembly. We have to perform two steps before we start to write the code.

  • First, we must give a strong name to the PluginBase assembly. It's necessary for providing full trust for this assembly in the sandboxed application domain. The PluginBase project in the attached zip-file was already signed with the key file. Detailed instructions how to sign an assembly you can read here.
  • Second, the PluginBase assembly must be marked with the AllowPartiallyTrustedCallersAttribute (APTCA) attribute because of untrusted plug-ins that refer to the base classes in the trusted assembly. In the attached project it is made in the file AssemblyInfo.cs from the PluginBase/Properties directory.

Unfortunately using of the APTCA attribute will also allows a plug-in to execute any method in any class from the PluginBase assembly. That's why we need to demand permissions from the callers higher in the call stack to make sure that the caller is trusted.

It can be achieved by marking such "critical" methods with the next attribute:

[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]

Now after fixing the security exploit we can start developing the PluginManager class. It must be inherited from the MarshalByRefObject class as well as the PluginBase class.

/// <summary>
/// Plugin manager class to interact with the host application
/// </summary>
public sealed class PluginManager : MarshalByRefObject
{
    // Dictionary that contains instances of assemblies for loaded plugins
    private readonly Dictionary<Assembly, PluginBase> plugins;
    ...

There is the plugins field in the code above. It is an instance of Dictionary<Assembly, PluginBase> which is used to store plug-in classes as values and plug-in assemblies as keys.

Now let's add the method for loading plug-ins. Wait, what about constructors and the sandboxing, you'll ask? Well, some architectural decisions made in the constructor are easier to explain after seeing the next method:

/// <summary>
/// Loads a plugin
/// </summary>
/// <param name="fullName">Full path to a plugin's file</param>
/// <returns>Instance of the loaded plugin's class</returns>
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public PluginBase LoadPlugin(string fullName)
{
    Assembly pluginAssembly;
    try
    {
        new FileIOPermission(FileIOPermissionAccess.Read | FileIOPermissionAccess.PathDiscovery, fullName).Assert();
        pluginAssembly = AppDomain.CurrentDomain.Load(AssemblyName.GetAssemblyName(fullName));
    }
    catch (BadImageFormatException)
    {
        /* Skip not managed dll files */
        return null;
    }
    finally
    {
        CodeAccessPermission.RevertAssert();
    }

    var pluginType = pluginAssembly.GetTypes().FirstOrDefault(x => x.BaseType == typeof(PluginBase));
    if (pluginType == null)
        throw new InvalidOperationException("Plugin's type has not been found in the specified assembly!");
    var pluginInstance = Activator.CreateInstance(pluginType) as PluginBase;
    plugins.Add(pluginAssembly, pluginInstance);

    var pluginConfigurationType = pluginAssembly.GetTypes().FirstOrDefault(x => x.BaseType == typeof(ConfigurationBase));

    if (pluginConfigurationType != null)
    {
        string processPath = String.Empty;
        try
        {
            new SecurityPermission(SecurityPermissionFlag.UnmanagedCode).Assert();
            processPath = Process.GetCurrentProcess().MainModule.FileName;
        }
        finally
        {
            CodeAccessPermission.RevertAssert();
        }

        try
        {
            var pset = new PermissionSet(PermissionState.None);
            pset.AddPermission(new FileIOPermission(PermissionState.Unrestricted));
            pset.AddPermission(new ConfigurationPermission(PermissionState.Unrestricted));
            pset.Assert();
            
            pluginInstance.Configuration =
                typeof(ConfigurationBase)
                .GetMethod("Open")
                .MakeGenericMethod(pluginConfigurationType)
                .Invoke(null, new object[] { Path.GetFileNameWithoutExtension(fullName), processPath }) as ConfigurationBase;
        }
        finally
        {
            CodeAccessPermission.RevertAssert();
        }
    }
    return pluginInstance;
}

We use the Load method of the current application domain here to load an assembly. After loading of the assembly we search for the first class derived from the PluginBase class in it – it is the main plug-in class. Then we create an instance of the main plug-in class using the Activator.CreateInstance method and add the created instance to the Plugins dictionary with a key equal to the assembly that contains the plug-in class.

As was mentioned above we will grant the full trust to the PluginBase assembly. That's why we are free to use assertion of any needed permissions. But don't forget to remove it from the current stack frame by calling CodeAccessPermission.RevertAssert().

Let's try to load the configuration section of the plug-in in the same way. We can use the generic method Open from the ConfigurationBase class:

            pluginInstance.Configuration =
                typeof(ConfigurationBase)
                .GetMethod("Open")
                .MakeGenericMethod(pluginConfigurationType)
                .Invoke(null, new object[] { Path.GetFileNameWithoutExtension(fullName), processPath }) as ConfigurationBase;

If the plug-in assembly has a definition for a descendant of ConfigurationBase then it will be loaded. And we can use its configuration properties in the Run() method code no more worrying about null values.

Now let's modify a default constructor of the PluginManager class:

/// <summary>
/// Default constructor. Plugins will be loaded into the same application domain as the host application
/// </summary>
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public PluginManager()
{
    plugins = new Dictionary<Assembly, PluginBase>();
    // There are no any references to plugins assemblies in the project.
    // That's why the ConfigurationErrorsException will be thrown during
    // loading of the plugin's configuration. Actually this happens
    // because an assembly defined in a configuration file couldn't be resolved.
    // We need to manually resolve that assembly.
    AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
}

Do you remember the highlighted line from the ConfigurationBase class?

When you call the config.GetSection(sectionName) you implicitly deserialize the configuration section of the type specified in the configuration file. The search for this type occurs in the assembly specified in the same configuration file. How the CLR locates assemblies you can read here. The PluginBase assembly resides in the custom folder Plugins and the CLR will not find it. It is the reason why the ConfigurationErrorsException will be thrown. We can solve this problem by two ways:

  • Handle the AppDomain.AssemblyResolve event.
  • Add the next code to the configuration file:
    <runtime>
      <assemblybinding xmlns="urn:schemas-microsoft-com:asm.v1">
       <probing privatepath="The path to your plugins directory">
       </probing>
      </assemblybinding>
    </runtime>
    

Assemblies have already been loaded into the current application domain and we have already saved the assemblies references in the dictionary field plugins of the PluginManager class. That's why we'd better handle the AppDomain.AssemblyResolve event:

private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
    return plugins.Keys.FirstOrDefault(x => x.FullName == args.Name);
}

We've just "prepared" the PluginBase assembly to be loaded into the sandboxed application domain. To ensure maximum compatibility with older applications that had used the plug-in manager it would be better to write a factory method to create an instance of the new PluginManager class.

/// <summary>
/// Factory method that creates PluginManager's instance with limited permission set. Plugins will be loaded into the sandboxed application domain
/// </summary>
/// <param name="grantSet" />Permission set to grant
/// <returns>Instance of the PluginManager's class</returns>
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public static PluginManager GetInstance(PermissionSet grantSet)
{
    if (grantSet == null)
        throw new ArgumentNullException("grantSet");

    /* Grant "base" permissions */
    grantSet.AddPermission(new ReflectionPermission(ReflectionPermissionFlag.RestrictedMemberAccess));
    grantSet.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));

    /* Note that the setup information is the same, i.e. ApplicationBase is the same */
    var sandbox = AppDomain.CreateDomain("sandbox", null, AppDomain.CurrentDomain.SetupInformation, grantSet, getStrongName(Assembly.GetExecutingAssembly()));

    return Activator.CreateInstanceFrom(sandbox, typeof(PluginManager).Assembly.ManifestModule.FullyQualifiedName, typeof(PluginManager).FullName).Unwrap() as PluginManager;
}

The GetInstance method above takes one argument of the PermissionSet type. It creates an application domain with that permission set and with the same setup information first. Then it creates an instance of the PluginManager type in the sanboxed application domain, using the named assembly file and default constructor.

Also we have given full trust to the PluginBase assembly. For this purpose we needed to get its strong name. The getStrongName method gets a strong name for an assembly passed as argument (it was taken from here).

/// <summary> 
/// Get a strong name that matches the specified assembly. 
/// </summary> 
/// <exception cref="ArgumentNullException">
/// if <paramref name="assembly"/> is null 
/// </exception> 
/// <exception cref="InvalidOperationException">
/// if <paramref name="assembly"/> does not represent a strongly named assembly
/// </exception> 
/// <param name="assembly">Assembly to create a StrongName for</param>
/// <returns>A StrongName for the given assembly</returns> 
private static StrongName getStrongName(Assembly assembly)
{
    if (assembly == null)
        throw new ArgumentNullException("assembly");

    AssemblyName assemblyName = assembly.GetName();

    // Get the public key blob. 
    byte[] publicKey = assemblyName.GetPublicKey();
    if (publicKey == null || publicKey.Length == 0)
        throw new InvalidOperationException("Assembly is not strongly named");

    StrongNamePublicKeyBlob keyBlob = new StrongNamePublicKeyBlob(publicKey);

    // Return the strong name. 
    return new StrongName(keyBlob, assemblyName.Name, assemblyName.Version);
}

The sandbox is ready. All security attributes are set and untrusted plug-ins cannot call methods that require full trust. Everything seems fine but we missed one small detail.

Since both the host application domain and the sandbox application domain have the same setup information (instance of the AppDomainSetup class) the Repurposing Attack is possible on the host application. That means untrusted plug-in can load the host assembly (by calling Assembly.Load) or another assembly located in the ApplicationBase (property of the AppDomainSetup instance) folder. So be careful when granting file permissions to untrusted plug-ins. I've seen many advices not to use the same ApplicationBase properties. But in this case we'll get too much other troubles:

The sandboxed application domain will not be able to access host assemblies, i.e. cannot access PluginBase assembly. (and the ReflectionTypeLoadException exception will be thrown after getting types from plug-ins's assemblies). To fix that the PluginBase assembly should be accessible from the host application and from plug-ins simultaneously. There is an obvious workaround for this trouble - put the PluginBase assembly in the GAC. However I won't review this way in this article.

Creating the demo app

Open Visual Studio and add a new console application project named PluginDemo to the solution. Open the Program.cs file and add two constants:

internal class Program
{
    // Directory that contains plugins files
    private const string pluginDir = "Plugins";
    // Plugins files extension mask
    private const string pluginExtMask = "*.dll";
    ...

First constant is the relative path to the plug-ins directory. Second constant is an extension mask for plug-ins's files.

Now let's add a method that runs plug-ins. It iterates *.dll-files in the specified directory and loads every plug-in using an instance of the PluginManager class passed as argument.

static void RunPlugins(PluginBase.PluginManager pluginMan)
{
    var path = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), pluginDir);
    foreach (var f in new DirectoryInfo(path).GetFiles(pluginExtMask))
    {
        var plugin = pluginMan.LoadPlugin(Path.Combine(path, f.Name));
        try
        {
            plugin.Run();
            Console.WriteLine("Plugin {0} has finished work\r\n", plugin.Name);
        }
        catch (Exception ex)
        {
            Console.WriteLine("An exception occurred in the {0} plugin: {1}\r\n", plugin.Name, ex.Message);
        }
    }
}

Now modify the Main method:

static void Main(string[] args)
{
    Console.WriteLine("1. Old way of running the plugins - full trust:\r\n");
    var pluginMan = new PluginBase.PluginManager();
    RunPlugins(pluginMan);

    Console.WriteLine(Environment.NewLine);

    Console.WriteLine("2. New way of running the plugins - limited trust:\r\n");
    var pset = new PermissionSet(PermissionState.None); /* deny all */
    /* uncomment the next line to allow file operations for all the plugins  */
    // pset.AddPermission(new FileIOPermission(FileIOPermissionAccess.Read | FileIOPermissionAccess.Write, Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)));
    /* uncomment the next line to allow working of the WebDownloadPlugin */
    // pset.AddPermission(new WebPermission(PermissionState.Unrestricted));
    pluginMan = PluginBase.PluginManager.GetInstance(pset);
    RunPlugins(pluginMan);

    Console.ReadKey();
}

Now we must create the folder named "Plugins" in the PluginDemo project. Please note the name of the folder must equals to the pluginDir constant defined in a code above. Copy ShowConsolePlugin.dll and WebDownloadPlugin.dll to the folder and set the "Copy to Output Directory" property to "Copy always".

Plugin file properties

Also I recommend to add this line to all plug-ins projects post-build event:

xcopy /y $(ProjectDir)$(OutDir)$(TargetFileName) $(SolutionDir)PluginDemo\Plugins\

Make sure to set right build order of projects. All plug-ins projects must be built before the PluginDemo project.

Testing the demo app

That was all the coding fun! We can compile and run the application.

Note that I also have third plug-in here named SaveTxtPlugin. This plug-in just saves some text to the specified file. The article does not cover the process of creating SaveTxtPlugin because it is similar to that described above.

Console app window 1

Running the application will create the file PluginDemo.exe.config. This file will contain:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <configSections>
        <section name="ShowConsolePlugin" type="ShowConsolePlugin.ShowConsolePluginConfiguration, ShowConsolePlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
        <section name="SaveTxtPlugin" type="SaveTxtPlugin.SaveTxtPluginConfiguration, SaveTxtPlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
    </configSections>
    <ShowConsolePlugin Message="Hello from ShowConsolePlugin" />
    <SaveTxtPlugin FileName="textFile.txt" />
</configuration>

First let's change the Message attribute in the ShowConsolePlugin node:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <configSections>
        <section name="ShowConsolePlugin" type="ShowConsolePlugin.ShowConsolePluginConfiguration, ShowConsolePlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
        <section name="SaveTxtPlugin" type="SaveTxtPlugin.SaveTxtPluginConfiguration, SaveTxtPlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
    </configSections>
    <ShowConsolePlugin Message="Hello again!" />
    <SaveTxtPlugin FileName="textFile.txt" />
</configuration>

Second let's uncomment the next line from the Main method in the PluginDemo/Program.cs file and recompile the application:

/* uncomment the next line to allow working of the WebDownloadPlugin */
pset.AddPermission(new WebPermission(PermissionState.Unrestricted));

Start PluginDemo.exe again and you will see

Console app window 2

History

  • 26.09.2012 - Version 2. Plug-ins are now loaded into the separate application domain with limited permission set.
  • 16.06.2012 - First initial release.

License

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

Share

About the Author

Vitaly Zhukov
Software Developer
Russian Federation Russian Federation
No Biography provided

Comments and Discussions

 
QuestionIs there any security(permission) for using internet ? PinmemberXmen W.K.11-Jul-12 0:06 
AnswerRe: Is there any security(permission) for using internet ? PinmemberVitaly Zhukov28-Jul-12 3:25 
GeneralRe: Is there any security(permission) for using internet ? PinmemberXmen W.K.28-Jul-12 15:15 
GeneralRe: Is there any security(permission) for using internet ? PinmemberVitaly Zhukov26-Sep-12 9:06 
GeneralRe: Is there any security(permission) for using internet ? PinmemberXmen W.K.26-Sep-12 15:33 
thanks, will check soon

TVMU^P[[IGIOQHG^JSH`A#@`RFJ\c^JPL>;"[,*/|+&WLEZGc`AFXc!L
%^]*IRXD#@GKCQ`R\^SF_WcHbORY87֦ʻ6ϣN8ȤBcRAV\Z^&SU~%CSWQ@#2
W_AD`EPABIKRDFVS)EVLQK)JKQUFK[M`UKs*$GwU#QDXBER@CBN%
R0~53%eYrd8mt^7Z6]iTF+(EWfJ9zaK-i’TV.C\y<pŠjxsg-b$f4ia>
-----------------------------------------------
128 bit encrypted signature, crack if you can

GeneralRe: Is there any security(permission) for using internet ? PinmemberXmen W.K.26-Sep-12 16:42 
GeneralRe: Is there any security(permission) for using internet ? PinmemberXmen W.K.26-Sep-12 17:15 
GeneralRe: Is there any security(permission) for using internet ? PinmemberVitaly Zhukov26-Sep-12 20:37 

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
Web01 | 2.8.140916.1 | Last Updated 27 Sep 2012
Article Copyright 2012 by Vitaly Zhukov
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid