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

Manage server remote start/shutdown in the background using Windows Services and .NET Remoting

, 7 Jan 2008
Rate this:
Please Sign up or sign in to vote.
Remote control startup and shutdown of a server automatically by several clients, to have the server running only if the clients are active. Uses Wake On Lan, Windows Services, and .NET Remoting.

Contents

Introduction

At home, I'm using a server for storage and media streaming, but the 24/7 power consumption of the device was annoying me. Wake On Lan helps a bit, but very often, I forget to shut down the device. I also played a bit with power management, but it doesn't do the job as well.

I've been in the need to check out the .NET Remoting architecture for a software project, so I started this little demo project. The goal was to have the server running only if clients are active, without the need of user interaction – it has to pass the wife test Smile | :)

Architecture

There are Windows services installed on each client, waking up the server at startup, and then sending heartbeat signals until the client is shut down. A server application, also running as a Windows service, listens to all the client heartbeats, and shuts down the server if there is no heartbeat for a specific amount of time.

As the client service starts (and wakes up the server) even before the user logs on to the client machine, the server is available pretty fast.

To get all this handled easy, there are two more applications: a user interface to the client service visualizes the heartbeats and allows some manual interactions; and a separate configuration application configures the client service on the machine where it is running, and also configures the server remotely, if connected.

Apps

  • ServerRemoteControl: Windows service running on the server machine.
  • ServerRemoteClientService: Windows service running on the client machine.
  • ServerRemoteClientForm: User interface for ServerRemoteClientService.
  • ServerRemoteConfig: Configuration tool for the client and server applications.

Topics

  • Remoting: All the communication between the different applications is solved using remotely callable objects. This is client/server communication, and Windows service/UI communication as well.
  • Windows services: Client and server applications are running as Windows services, without the need to log on to a machine to get them running.
  • Application host / application boundaries: Communication between different application hosts using Remoting technologies.
  • Wake On LAN: The client application sends a WOL “Magic Packet” to wake up the server. Thanks to maxburov for his code sample.
  • Registry access: Configuration parameters and window positions are stored in the Windows Registry (HKEY_LOCAL_MACHINE\SOFTWARE\Torkelware\ServerRemote), also accessed by Windows services running under the NetworkService account.
  • Multi-threaded Windows forms: As there are separate applications acting as user interfaces, I implemented a kind of message based communication between the application logic and the user interface.
  • Task bar icons: The user interface for the client service is minimized to the System Tray, opening a window only if needed.

Application schematic

ServerRemote.jpg

Classes / Methods

MessageService

The message service is a singleton object, providing remotely callable methods for sending messages to other applications and modifying the local configuration. The messages can be received by subscribing to the appropriate event via Remoting. Even the application hosting the object accesses its methods via Remoting.

By calling the static method CreateMessageServiceServer(), the MessageService creates communication channels, registers itself as a singleton object, and returns an instance created via Remoting using Activator.GetObject().

public static MessageService CreateMessageServiceServer(MessageProtocol MsgProtocol, 
       int ServerPort, 
       string ServerUri, 
       string ApplicationName) {
       MessageService Result;

    // channels registrieren:
    BinaryServerFormatterSinkProvider ServChSinkProvider = 
        new BinaryServerFormatterSinkProvider();
    ServChSinkProvider.TypeFilterLevel = TypeFilterLevel.Full;
    BinaryClientFormatterSinkProvider ClientChSinkProvider = 
        new BinaryClientFormatterSinkProvider();
    if (MsgProtocol==MessageProtocol.http) {
        HttpServerChannel ServCh = new HttpServerChannel(
            "MessageServiceServerChannel", ServerPort, ServChSinkProvider);
        ChannelServices.RegisterChannel(ServCh);
        HttpClientChannel channel = new HttpClientChannel(
            "MessageServiceClientChannel", ClientChSinkProvider);
        ChannelServices.RegisterChannel(channel);
    } else if ((MsgProtocol==MessageProtocol.tcp)) {
        TcpServerChannel ServCh = new TcpServerChannel(
            "MessageServiceServerChannel", ServerPort, ServChSinkProvider);
        ChannelServices.RegisterChannel(ServCh);
        TcpClientChannel channel = new TcpClientChannel(
            "MessageServiceClientChannel", ClientChSinkProvider);
        ChannelServices.RegisterChannel(channel);
    } else {
        throw new ApplicationException(
            "Netzwerkprotokoll nicht implementiert: " + 
            MsgProtocol.ToString());
    }

    // Register remoting object:
    Debug.WriteLine("Registriere Remote-Objekt: " + 
          MsgProtocol.ToString() + ":" + 
          ServerPort + "/" + ServerUri);
    WellKnownServiceTypeEntry myservice = new WellKnownServiceTypeEntry(
        typeof(MessageService),
        ServerUri,
        WellKnownObjectMode.Singleton);
    RemotingConfiguration.RegisterWellKnownServiceType(myservice);

    // Server bezieht das selbst gehostete messageobjekt via remoting:
    string HostUrl = string.Format(@"{0}://{1}:{2}/{3}",
        MsgProtocol.ToString(),"localhost",ServerPort.ToString(), ServerUri);
    Debug.WriteLine("Verbinde Server zu MessageObject: " + HostUrl);
    Result = (MessageService)Activator.GetObject(
        typeof(MessageService),
        HostUrl);

    // testen, ob es sich bei dem messageobjekt um ein remotingobjekt handelt:
    if (RemotingServices.IsTransparentProxy(Result)) {
        Result.ApplicationName = ApplicationName;
        Result.ApplicationHost = System.Environment.MachineName;
    } else {
        throw new ApplicationException(
            "Fehler beim erstellen des Remoteobjekts.");
    }
    return Result;
}

The method PublishCommand() sends a text message of type "Command" to all subscribers of MessageService.TextMessageEventHandler:

public void PublishCommand(string Command, string SenderName) {
    TextMessageEventArgs e = new TextMessageEventArgs(Command);
    e.MessageType = TextMessageType.Command;
    e.SenderName = SenderName;
    this.MsgArrSync.Add(e);
    if (TextMessageEventHandler != null) {
        TextMessageEventHandler(this,e);
    }
}

RemotelyDelegatableObject

To be able to subscribe to MessageService events from another application, or maybe from a different machine, an abstract class is used to derive the callback classes from. By having the sender and receiver based on the same (abstract) class, we relieve the two applications from the need to use the exact the same version of the callback class.

public abstract class RemotelyDelegatableObject : MarshalByRefObject {
    public void TextMessageReceiver (object sender, TextMessageEventArgs e) {
        InternalTextMessageReceiver (sender, e) ;        
    }
    protected abstract void InternalTextMessageReceiver (
        object sender, TextMessageEventArgs e) ;
}

TextMessageEventArgs

There are two types of messages that can be sent: “Message” and “Command”, as defined in public enum TextMessageType {Message, Command}. The messages are encapsulated by the class “TextMessageEventArgs”, which is marked [Serializable], and simply contains some private variables with the appropriate public properties to access them.

ServerRemoteControl

The server application hosts a MessageService and subscribes to the events fired by incoming text messages. In a separate thread runs a countdown which shuts the server down if it comes to zero. The countdown can be reset to its initial value by a “heartbeat”-command coming through the MessageService, and its initial value can be changed by a “updateconfiguration”-command, for example. Also, it provides the ability to attach a monitor application to monitor the countdown and the incoming heartbeats, as it is done by ServerRemoteConfig.

The StartServerApp() method creates the singleton MessageService remote object and subscribes to the message event.

private void StartServerApp() {
    // Remoteobjekte und Channels registrieren:
    MyMessageService = MessageService.CreateMessageServiceServer(
        MessageProtocol.http, 
        this.ServerPort,
        "RemoteControl",
        "Remote Control Server Application");
    ServerCallback = new MyServerCallbackClass (this) ;
    MyMessageService.TextMessageEventHandler += 
        new TextMessageHandler(ServerCallback.TextMessageReceiver);
}

The PerfLoop() method runs in a separate thread performing the countdown and, if reaching zero, shuts down the server.

private void PerfLoop() {
    while (true) {
        lock (this) {
            if (this.CountDown--==0) {
                AppendToTextBox(System.DateTime.Now.ToString() + ": Shutdown!");
                PerformServerShutdown();
            }
        }
        UIMessage("SetControlValue", "lblCountDown" + 
                  ":" + CountDown.ToString());
        PublishMonitorCommand(
            "CountDown:" + CountDown.ToString(), Environment.MachineName);
        Thread.Sleep(1000);
    }
}

PerformServerShutdown() simply creates a new process and executes the configured shutdown command.

public void PerformServerShutdown() {
    Process myProcess = new Process();
    myProcess.StartInfo.FileName = this.ShutDownCommand;
    myProcess.StartInfo.Arguments = this.ShutDownParams;
    myProcess.Start();
} 

The Server_ReceiveTextMessage() method executes the commands, or simply shows the text messages if a user interface is attached.

public void Server_ReceiveTextMessage(object sender, TextMessageEventArgs e) {
    if (e.MessageType==TextMessageType.Command) {
        string[] MsgArr = e.Message.Split(":"[0]);
        if (MsgArr[0].ToLower().Equals("heartbeat")) {
            lock (this) {
                this.CountDown = this.CountDownStartValue;
            }
            UIMessage("ShowHeartBeat", e.SenderName);
            UIMessage("SetControlValue", 
                      "lblCountDown:" + this.CountDown.ToString());
            PublishMonitorCommand("HeartBeat", e.SenderName);
        // ... scan for other commands...
        }
    } else {
        Debug.WriteLine("Server: " + 
            e.MessageDate.ToString("yyMMdd-HH:mm:ss") + ": " + e.Message);
        AppendToTextBox(e.MessageDate.ToString("yyMMdd-HH:mm:ss") + " " + 
                        e.MessageType.ToString() + " von " + 
                        e.SenderName + ": " + e.Message);
    }
}

MyServerCallbackClass

As described earlier for RemotelyDelegatableObject, the callback class used in ServerRemoteControl.StartServerApp() is derived from the abstract class RemotelyDelegatableObject. The implementation of InternalTextMessageReceiver() now contains the code specific to this application.

class MyServerCallbackClass : RemotelyDelegatableObject {
    private RemoteControlServer _RCServer;

    private MyServerCallbackClass() {}

    public MyServerCallbackClass (RemoteControlServer RCServer) {
        this._RCServer = RCServer;
    }
    
    protected override void InternalTextMessageReceiver (
        object sender, TextMessageEventArgs e) {
        this._RCServer.Server_ReceiveTextMessage(sender, e);
    }

    public override object InitializeLifetimeService() {
        return null;
    }
}

ServerRemoteConfig

The configuration application provides direct access to the configuration parameters in the local machine's registry. With the parameters configured well, it is able to connect to the server, providing a visualization of incoming heartbeats and its countdown state, and a form for modifying the server configuration (shutdown-command, countdown start value). For this, it also hosts a MessageService object for monitoring values.

Called on startup, CreateServerMonitorMessageService() creates a MessageService for monitoring values, and subscribes to its message event, using the MyMonitorCallbackClass derived from RemotelyDelegatableObject, as described earlier.

private void CreateServerMonitorMessageService() {
    string PortStr = MyConfig.GetValue(
        ConfigKeyNames.Client_ServerPort.ToString());
    int port = int.Parse(PortStr); 
    MyMonitorMessageService = MessageService.CreateMessageServiceServer(
        MessageProtocol.http, 
        port,
        "RemoteServerMonitor",
        "Remote Control Server Monitor");

    MonitorCallback = new MyMonitorCallbackClass (this);
    MyMonitorMessageService.TextMessageEventHandler += 
        new TextMessageHandler(MonitorCallback.TextMessageReceiver);
}

With the “connect”-button pressed, btnConnectToServer_Click() is called, which creates a proxy object to call the server's MessageService and gathers some configuration data from the server. Then, the call to StartServerMonitor() causes the server to send monitor values to this application.

private void btnConnectToServer_Click(object sender, System.EventArgs e) {
    string Host = MyConfig.GetValue(ConfigKeyNames.Client_ServerHostName.ToString());
    string Port = MyConfig.GetValue(ConfigKeyNames.Client_ServerPort.ToString());
    string HostUrl = string.Format(
        @"{0}://{1}:{2}/{3}",
        MessageProtocol.http, 
        Host,
        Port, 
        "RemoteControl");
    Debug.WriteLine("Client verbindet zu Server: " + HostUrl);
    try {
        MyServerMessageService = (MessageService)Activator.GetObject(
            typeof(MessageService), 
            HostUrl);
        MyServerMessageService.PublishMessage("Config Client Verbunden", 
            System.Environment.MachineName);
        btnConnectToServer.Enabled = false;
        lblServerConnectionState.Text = "Verbunden mit " + Host + ":" + Port;
    } catch (System.Net.WebException) {
        btnConnectToServer.Enabled = true;
        lblServerConnectionState.Text = "Verbindung zu " + Host + ":" + 
            Port + " konnte nicht hergestellt werden.";
        return;
    }
    this.ServerConnected = true;
    this.tbServerCountDownSeconds.Text = MyServerMessageService.GetConfigValue(
        ConfigKeyNames.Server_CountDownSeconds.ToString());
    this.tbServerCountDownSeconds.Enabled = true;
    this.tbServerShutDownCommand.Text = MyServerMessageService.GetConfigValue(
        ConfigKeyNames.Server_ShutDownCommand.ToString());
    this.tbServerShutDownCommand.Enabled = true;
    this.tbServerShutDownParams.Text = MyServerMessageService.GetConfigValue(
        ConfigKeyNames.Server_ShutDownParams.ToString());
    this.tbServerShutDownParams.Enabled = true;

    StartServerMonitor();
}

The StartServerMonitor() method sends an “AttachMonitor”-command to the server, including the machine name and port, to where the monitor values should be delivered.

 private void StartServerMonitor() {
    string PortStr = MyConfig.GetValue(ConfigKeyNames.Client_ServerPort.ToString());
    int port = int.Parse(PortStr);
    MyServerMessageService.PublishCommand("AttachMonitor:" + 
        port.ToString(), Environment.MachineName);
}

ServerRemoteClientService

The client application hosts a singleton RemoteClient object, which provides all the functionality to wake up and send heartbeat messages to the server.

In the InitRemoteClient() method, client and server channels are created, RemoteClient is registered as a singleton object, and a proxy for remote calls to RemoteClient is created using the Activator.GetObject() with the local URI.

private void InitRemoteClient() {

    string ErrorPos = "";
    try {
        ErrorPos = "Config-objekt erstellen.";

        Config MyConfig = new Config(false);
        ErrorPos = "Config-objekt erstellt, registry auslesen.";
        string ClientServerPort_str = MyConfig.GetValue(
            ConfigKeyNames.Client_ClientServerPort.ToString());
        int ClientServerPort;
        try {
            ClientServerPort = int.Parse(ClientServerPort_str);
        } catch {
            throw new ApplicationException("Registryschlüssel [" + 
                ConfigKeyNames.Client_ClientServerPort.ToString() + 
                "] hat das falsche Format.");
        }
        string ClientServerUri = "RemoteControlClient";

        ErrorPos = "Clientchannel registrieren.";

        // client channel:
        BinaryClientFormatterSinkProvider ClientChSinkProvider = 
            new BinaryClientFormatterSinkProvider();
        HttpClientChannel channel = new HttpClientChannel(
            "SRCS_ClientChannel", ClientChSinkProvider);
        ChannelServices.RegisterChannel(channel);

        ErrorPos = "Serverchannel registrieren.";

        // server channel:
        BinaryServerFormatterSinkProvider ServChSinkProvider = 
            new BinaryServerFormatterSinkProvider();
        ServChSinkProvider.TypeFilterLevel = TypeFilterLevel.Full;
        HttpServerChannel ServCh = new HttpServerChannel(
            "SRCS_ServerChannel", ClientServerPort, ServChSinkProvider);
        ChannelServices.RegisterChannel(ServCh);

        ErrorPos = "Client-Remoteobjekt registrieren.";

        // Clientobjekt registrieren
        Debug.WriteLine("Registriere Remote-Objekt: http:" + 
            ClientServerPort + "/" + ClientServerUri);
        WellKnownServiceTypeEntry myservice = new WellKnownServiceTypeEntry(
            typeof(RemoteClient),
            ClientServerUri,
            WellKnownObjectMode.Singleton);
        RemotingConfiguration.RegisterWellKnownServiceType(myservice);

        ErrorPos = "Clientobjekt initialisiert Verbindung zu RemoteClient.";

        // zu Client-Instanz connecten:
        string HostUrl = string.Format(@"{0}://{1}:{2}/{3}", "http", 
            "localhost", ClientServerPort.ToString(), ClientServerUri);
        Debug.WriteLine("Verbinde AppHost zu RemoteClient: " + HostUrl);
        this._RCClient = (RemoteClient)Activator.GetObject(
            typeof(RemoteClient),
            HostUrl);
        if (! RemotingServices.IsTransparentProxy(this._RCClient)) {
            throw new ApplicationException("Fehler beim erstellen des Remoteobjekts.");
        }

        ErrorPos = "Clientobjekt verbindet zu RemoteClient.";

        this._RCClient.TouchMe();
    } catch (Exception ex) {
        throw new ApplicationException(
            "Fehler beim initialisieren der Clientumgebung:\r\n" + 
            ErrorPos + "\r\n" + ex.Message);
    }

}

RemoteClient

The RemoteClient object provides all the functionality to wake up and send heartbeat messages to the server. To wake up the server, a separate application will be called if there is a wake up command, or the implemented Wake On Lan method will be used if only the server's MAC address is provided, leaving the wake up command empty.

The PerformServerWakeup() method checks if an external application should be used and, if not, sends a Wake On Lan “Magic Packet” to the server MAC address.

public void PerformServerWakeup() {
    if (this._ClientWakeupCommand.Length > 0) {
        Process myProcess = new Process();
        myProcess.StartInfo.FileName = this._ClientWakeupCommand;
        myProcess.StartInfo.Arguments = this._ClientWakeupParams;
        myProcess.StartInfo.CreateNoWindow = false;
        myProcess.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
        //myProcess.StartInfo.UseShellExecute = false;
        myProcess.Start();
    } else if (this._ClientWakeupParams.Length > 0) {
        // code from maxburov (http://www.codeproject.com/KB/IP/cswol.aspx)
        string MAC_ADDRESS = this._ClientWakeupParams;
        MAC_ADDRESS = MAC_ADDRESS.Replace("-","");
        MAC_ADDRESS = MAC_ADDRESS.Replace(".","");
        MAC_ADDRESS = MAC_ADDRESS.Replace(":","");

        WOLClass client=new WOLClass();
        client.Connect(new 
            IPAddress(0xffffffff),  //255.255.255.255  i.e broadcast
            0x2fff); // port=12287 let's use this one 

        client.SetClientToBrodcastMode();

        int counter=0;
        //buffer to be send

        byte[] bytes=new byte[1024];   // more than enough :-)

        //first 6 bytes should be 0xFF

        for(int y=0;y<6;y++)
            bytes[counter++]=0xFF;
        //now repeate MAC 16 times

        for(int y=0;y<16;y++) {
            int i=0;
            for(int z=0;z<6;z++) {
                bytes[counter++]= 
                    byte.Parse(MAC_ADDRESS.Substring(i,2),
                    NumberStyles.HexNumber);
                i+=2;
            }
        }

        //now send wake up packet
        int reterned_value=client.Send(bytes,1024);
    } else {
        throw new ApplicationException("Server-Wakeup nicht möglich, " +
            "Wakeup-Parameter nicht konfiguriert.");
    }
}

HeartBeatLoop(), running in a separate thread, sends heartbeat-messages to the server. It also tries to reconnect, by calling GetServerObject(), if the heartbeat fails. Depending on the booleans _WakeupServer and _WakeupServerWhenLost, it tries to wake up the server when the first heartbeat fails, or after every failed heartbeat. The two booleans are initialized by reading the registry keys Client_WakeupMode and Client_WakeupServerWhenLost, but the configuration tool isn't able to change them in the current version.

private void HeartBeatLoop() {
    while (true) {
        if (!this._Pause) {
            SendUIMessage("ShowHeartBeat", Environment.MachineName);
            if (this.HeartBeat!=null) {
                this.HeartBeat(this, new EventArgs());
            }
            if (this.ServerMessageService!=null) {
                try {
                    this.ServerMessageService.PublishCommand(
                        "HeartBeat", System.Environment.MachineName);
                    // Kein Fehler: Verbindung ist hergestellt.
                    if (!this.Connected) { // Server war vorher nicht verbunden
                        this.Connected = true;
                        this._ServerConnectedCount++;
                        if (this.ConnectionStateChanged!=null) {
                            this.ConnectionStateChanged(this, new EventArgs());
                        }
                        SendUIMessage("EnableControl","btnShutDownServer:true");
                        SendUIMessage("EnableControl","btnWakeupServer:false");
                    
                        this._WakeupServer = false;
                    }
                } catch {
                    // Fehler: Nicht verbunden
                    if (this.Connected) { // Server war vorher verbunden
                        this.Connected = false;
                        if (this.ConnectionStateChanged!=null) {
                            this.ConnectionStateChanged(this, new EventArgs());
                        }
                        SendUIMessage("EnableControl","btnShutDownServer:false");
                        SendUIMessage("EnableControl","btnWakeupServer:true");
                    }
                    if (this._WakeupServer) { // Wakeup-modus
                        if (this._WakeupServerWhenLost | 
                            (this._ServerConnectedCount==0)) { 
                            PerformServerWakeup();
                        }
                    }
                    Debug.WriteLine("ReConnect...");
                    GetServerObject();
                }
            }
        }
        // ConnectionState in UI anzeigen:
        ShowConnectionState();

        CheckForChanges(); // Konfiguration checken

        int SleepInterval = this._HeartBeatInterval*1000;
        Thread.Sleep(SleepInterval);
    }
}

ServerRemoteClientForm

This Windows form connects to the RemoteClient hosted by ServerRemoteClientService, visualizes heartbeats and connection state, and provides buttons to manually start/shut down the server, stop sending heartbeats, and so on. The most tricky part here is the RemoteClientApplicationContext class, which is the application's entry point. It only shows a System Tray icon, a double click opens the main window.

ServerRemoteClientForm.jpg

Installation

This is the least funny part of all. As I haven't built an installer yet, the first task is to run ServerRemoteConfig.exe. The configuration tool creates HKEY_LOCAL_MACHINE\SOFTWARE\Torkelware\ServerRemote in the system Registry and sets some default values. Change the "server name" to the host name of your server. Next, the appropriate Windows service for the server and the client(s) must be installed using the framework's installutil.exe tool:

  • Server: installutil /i ServerRemoteControl.exe
  • Client: installutil /i ServerRemoteClientService.exe

The installed service should now be listed in Computer Management/Services, but not started yet. The service needs to access the Registry, so either the NetworkService account must have the permission to do so, or the service must run under a privileged user account.

  • Set permissions for NetworkService:
  • Using regedit, go to HKEY_LOCAL_MACHINE\SOFTWARE. On the Edit menu, click Permissions, and assign read permissions to the NetworkService account. Then, go to the \Torkelware sub key, and assign full access to the NetworkService account.

  • Choose a different user account:
  • In Computer Management/Services, go to the Properties dialog of the service, and change the account from NetworkService to the privileged account.

Now, you can start the service using Computer Management/Services. If the service stops immediately, have a look at the event log. ServerRemoteClientForm.exe and ServerRemoteConfig.exe can be started directly. Don't miss the Tray Icon after starting ServerRemoteClientForm Smile | :) . If you run into problems installing the clients, stop the service on the server, or it will shut down after 10 minutes (or the amount of time you have configured for countdown). Alternatively, you can use ServerRemoteConfig to reset the countdown value. If everything works fine, return to Computer Management/Services to configure the services for automatic start. On client machines, you can put a reference to ServerRemoteClientForm.exe into the autostart folder.

Finally

ServerRemoteClientService and ServerRemoteControl are designed to run as Windows services, but they both have a second entry point, which allows them to run as normal executables. This is helpful during development, just change the entry point in the project preferences. Error messages and some remarks are still in German, sorry for that. About my English, in general: please don't judge too hard Smile | :) By now, only Windows systems are supported. I thought about sending pings from the server and shutting down if no one answers, but there are streaming clients even responding to pings if they are switched off... If there is some interest in this project, I will think about it. Feel free to give suggestions.

Now, the leisure time I had while curing the aftermath of a motocross accident ends, and so this article will end, too. Have fun with my code Smile | :)

Holger!

License

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

Share

About the Author

alberich123
Software Developer (Senior) NetApps GmbH
Germany Germany
No Biography provided

Comments and Discussions

 
GeneralGood Article PinmemberMember 36823717-Feb-09 10:15 

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.140926.1 | Last Updated 8 Jan 2008
Article Copyright 2008 by alberich123
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid