Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / Win32

Monitoring desktop windows from a Windows service

4.72/5 (17 votes)
2 Jan 2008CPOL4 min read 4   7.2K  
Capture and save desktop windows from a Windows service.

Screenshot - history.png

Introduction

Sometimes, you might want to monitor the desktop of a computer without looking at it all the time. It could be that you are monitoring some child surfing online, or just checking on the status of a running application that does not have a good log. If you are not interested in the internals of how this application works, you can just install the MSI provided and skip to the 'Using the Application' section. If you are interested about the internals of taking window snapshots from a service, you have to look at the code and see the tricks to make this happen. There are three assemblies for this article: a Windows service, a UI viewer/manager, and a shared library that does the real capture. You have to register the Windows service yourself by running installserv.bat if you choose to build it from the source code.

Background

In one of my previous articles, I was capturing windows into image files. One of the drawbacks was the fact that it had to pop the specific window to capture it. Later, I found that it is possible to capture a window without bringing it to the foreground, and even hide it when restoring from an icon. You might loose some details on certain controls like the checked list box, but that's a small price to pay.

Enabling a Windows service to interact with the desktop

Since a Windows service is usually UI-less, you have to allow it to interact with the desktop. You can do this manually in the Services Console in the Registry and risk not synching with the Service Controller, or better yet using WMI in the AfterInstall event, like shown below:

C#
private void ServiceMonitorInstaller_AfterInstall(object sender, InstallEventArgs e)
{
    ManagementObject wmiService = null;
    ManagementBaseObject InParam = null;
    try
    {
        wmiService = new ManagementObject(string.Format("Win32_Service.Name='{0}'", 
                         ServiceMonitorInstaller.ServiceName));
        InParam = wmiService.GetMethodParameters("Change");
        InParam["DesktopInteract"] = true;
        wmiService.InvokeMethod("Change", InParam, null);
    }
    finally
    {
        if (InParam != null)
            InParam.Dispose();
        if (wmiService != null)
            wmiService.Dispose();
    }
}

Sometimes, using the system account to allow desktop interaction is not possible, and you might have to allow it programmatically by impersonating the interactive user.

Another way to interact with the desktop

To allow a convenient way of using the default desktop, I've created the ImpersonateInteractiveUser class that uses its constructor and Dispose method to switch the UI context and optionally the Windows impersonation. The Windows impersonation is just an extra that you can use for setting the bimpersonate parameter to true, and the current user security token is retrieved from an existing UI process like Explorer. The code to achieve this is presented below, but you might want to check the entire class, since the code is quite complex.

C#
//.ctor
public ImpersonateInteractiveUser(Process proc, bool bimpersonate)
{
    _bimpersonate = bimpersonate;
    ImpersonateUsingProcess(proc);
}

private void ImpersonateUsingProcess(Process proc)
{
    IntPtr hToken = IntPtr.Zero;
    Win32API.RevertToSelf();
    if (Win32API.OpenProcessToken(proc.Handle, 
             TokenPrivilege.TOKEN_ALL_ACCESS, ref hToken) != 0)
    {
        try
        {
            SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
            sa.Length = Marshal.SizeOf(sa);
            bool result = Win32API.DuplicateTokenEx(hToken, 
              Win32API.GENERIC_ALL_ACCESS, ref sa,
             (int)SECURITY_IMPERSONATION_LEVEL.SecurityIdentification, 
             (int)TOKEN_TYPE.TokenPrimary, ref _userTokenHandle);
            if (IntPtr.Zero == _userTokenHandle)
            {

                Win32Exception ex = new Win32Exception(Marshal.GetLastWin32Error());
                throw new ApplicationException(string.Format("Can't duplicate" + 
                      " the token for {0}:\n{1}", proc.ProcessName, ex.Message), ex);
            }

            if (!ImpersonateDesktop())
            {
                Win32Exception ex = new Win32Exception(Marshal.GetLastWin32Error());
                throw new ApplicationException(ex.Message, ex);
            }
        }
        finally
        {
            Win32API.CloseHandle(hToken);
        }
    }
    else
    {
        string s = String.Format("OpenProcess Failed {0}, privilege not held", 
                                 Marshal.GetLastWin32Error());
        throw new Exception(s);
    }
}

bool ImpersonateDesktop()
{
    _hSaveWinSta = Win32API.GetProcessWindowStation();
    if (_hSaveWinSta == IntPtr.Zero)
        return false;
    _hSaveDesktop = Win32API.GetThreadDesktop(Win32API.GetCurrentThreadId());
    if (_hSaveDesktop == IntPtr.Zero)
        return false;
    if (_bimpersonate)
    {
        WindowsIdentity newId = new WindowsIdentity(_userTokenHandle);
        _impersonatedUser = newId.Impersonate();
    }
    _hWinSta = Win32API.OpenWindowStation("WinSta0", false, 
                                          Win32API.MAXIMUM_ALLOWED);
    if (_hWinSta == IntPtr.Zero)
        return false;
    if (!Win32API.SetProcessWindowStation(_hWinSta))
        return false;
    _hDesktop = Win32API.OpenDesktop("Default", 0, true, Win32API.MAXIMUM_ALLOWED);
    if (_hDesktop == IntPtr.Zero)
    {
        Win32API.SetProcessWindowStation(_hSaveWinSta);
        Win32API.CloseWindowStation(_hWinSta);
        return false;
    }
    if (!Win32API.SetThreadDesktop(_hDesktop))
        return false;
    return true;
}

Unfortunately, sometimes certain UI APIs like SetWindowLong won't work when run from a service, but they are fine when run from a regular user process. I don't know if this is a Windows bug or some "by design" feature, but the methods described above won't help. To get around this limitation, I had to make sure that they get called only from a process that's spawn by the service when necessary, that is when you need to pop an iconic window and make it transparent, so the user won't notice the flash.

Running a service like a regular process

When spawning the user process, I use the same assembly as the service, but with some window handles as parameters. The args.Length actually determines if it's running as a service or as a regular process. You can notice the use of the ImpersonateInteractiveUser class in a using statement.

C#
static void Main(string[] args)
{
    if (args.Length > 0)
    {
        WndList lst = new WndList(args.Length);
        foreach (string txt in args)
        {
            lst.Add(new IntPtr(Convert.ToInt32(txt)));
        }
        try
        {
            string folder = System.IO.Path.GetDirectoryName(
                               Assembly.GetExecutingAssembly().Location);
            ScreenMonitorLib.SnapShot snp = 
              new ScreenMonitorLib.SnapShot(folder, ScreenMonitor._interval);
            Process proc = Process.GetProcessesByName("explorer")[0];
            using (ImpersonateInteractiveUser imptst = 
                    new ImpersonateInteractiveUser(proc, false))
            {
                snp.SaveSnapShots(lst);
            }
        }
        catch (Exception ex)
        {

            EventLog.WriteEntry("Screen Monitor", 
              string.Format("exception in user proc:{0}\n at {1}", 
              ex.Message,ex.StackTrace), EventLogEntryType.Error, 1, 1);
        }
        return;
    }
    else
    {
        ScreenMonitor sm = new ScreenMonitor();
        ServiceBase[] ServicesToRun;
        ServicesToRun = new ServiceBase[] { sm };
        ServiceBase.Run(ServicesToRun);
    }
}

Storing a hash from an image file

Since the saving of the window capture is described in my previous article, I had to find a way to index the image files. I took advantage of the MD5CryptoServiceProvider that provides a 16 byte array that can end up in a GUID that's stored in a data set.

C#
private void PersistCapture(IntPtr hWnd, Bitmap bitmap, 
             bool isIconic, SnapShotDS.WndSettingsRow rowSettings)
{
    using (MemoryStream ms = new MemoryStream())
    {
        bitmap.Save(ms, ImageFormat.Jpeg);
        MD5 md5 = new MD5CryptoServiceProvider();
        md5.Initialize();
        ms.Position = 0;
        byte[] result = md5.ComputeHash(ms);
        Guid guid = new Guid(result);
        int len = _tblSnapShots.Select(string.Format("{0} = '{1}'", 
                  _tblSnapShots.FileNameColumn.ColumnName, guid.ToString())).Length;
        string path = System.IO.Path.Combine(_folder, guid.ToString() + ".jpg");
        if (len == 0 || !File.Exists(path))
        {
            using (FileStream fs = File.OpenWrite(path))
            {
                ms.WriteTo(fs);
            }
        }

// code ommited for brevity…..

}

Managing service events

In order to manage the events from OnSessionChange and OnStop, I added two Windows events: _terminate to check for stopping the service and create a timeout interval, and _desktopUnLocked to handle session changes as shown below.

C#
try
{
    StartNewDesktopSession();
    string folder = System.IO.Path.GetDirectoryName(
             Assembly.GetExecutingAssembly().Location) + "\\";
    ScreenMonitorLib.SnapShot snp = new ScreenMonitorLib.SnapShot(folder, 
                                          _interval, this.ServiceHandle);
    bool bexit = false;
    do
    {
        bexit = _terminate.WaitOne(_interval, false);
        _desktopUnLocked.WaitOne();
        if(_blocked)
        {
            _blocked = false;
            continue;
        }
        WndList lst = snp.GetDesktopWindows(_imptst.HDesktop);
        snp.SaveAllSnapShots(lst);
    }
    while (!bexit);
}
catch (ApplicationException ex)
{
    EventLog.WriteEntry("Screen Monitor", 
       string.Format("ApplicationException: {0}\n at {1}", 
       ex.Message, ex.InnerException.TargetSite), 
       EventLogEntryType.Error, 1, 1);
}
catch (Exception ex)
{
    EventLog.WriteEntry("Screen Monitor", 
             string.Format("exception in thread at: {0}:{1}", 
             ex.TargetSite.Name, ex.Message), 
             EventLogEntryType.Error, 1, 1);
}
finally
{
    if (_imptst != null)
        _imptst.Dispose();
}

Setting the service start mode and other parameters

The service persists the historic information stored in a table in data.xml, and also loads some settings from the settings.xml created by the SnapShotmanager.exe. Another trick noteworthy is setting the service start mode and starting it using WMI, like shown below:

C#
//code omitted for brevity….

ManagementObject wmiService = null;
ManagementBaseObject InParam = null;
try
{
    ConnectionOptions coOptions = new ConnectionOptions();
    coOptions.Impersonation = ImpersonationLevel.Impersonate;
    ManagementScope mgmtScope = 
      new System.Management.ManagementScope(@"root\CIMV2", coOptions);
    mgmtScope.Connect();
    // Query WMI for additional information about this service.



    wmiService = new ManagementObject("Win32_Service.Name='Screen Monitor'");
    wmiService.Get();
    object o = wmiService["StartMode"];//"Auto" or "Disabled"



    InParam = wmiService.GetMethodParameters("ChangeStartMode");
    string start = _cbxStartupType.Text;
    InParam["StartMode"] = start;
    ManagementBaseObject outParams = 
      wmiService.InvokeMethod("ChangeStartMode", InParam, null);
      uint ret = (uint)(outParams.Properties["ReturnValue"].Value);
    if (ret != 0)
        MessageBox.Show(this, "Error", 
                   string.Format("Failed to set the Start mode, error code: {0}", ret), 
                   MessageBoxButtons.OK, MessageBoxIcon.Warning);

    //bad parent process

    if (_ckStartService.Checked)
    {
        // Start service



        outParams = wmiService.InvokeMethod("StartService", null, null);
        ret = (uint)(outParams.Properties["ReturnValue"].Value);
        if (ret != 0)
            MessageBox.Show(this, "Error", 
                 string.Format("Failed to start the service with error code: {0}", ret), 
                 MessageBoxButtons.OK, MessageBoxIcon.Warning);
    }
}
catch (System.Management.ManagementException ex)
{
    MessageBox.Show(this, "The service might not be installed on this computer. or\n" + 
                    ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
catch (Exception ex)
{
    MessageBox.Show(this, ex.Message, "Error", 
                    MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
    if (InParam != null)
        InParam.Dispose();
    if (wmiService != null)
        wmiService.Dispose();
}

Using the application

Screenshot - settings.png

On the Set Up tab, you have to select the application(s) to track and when to capture it. You can also specify the time interval between captures and the service start type. The service can be started from the services console or, more conveniently from the Snapshot Manager. On the Viewer tab (see the top of this page), you can view and delete the history and the associated image files.

History

This is version 1.0.0.0, and it has been tested on Windows XP. It handles the Lock/Unlock desktop, LogOn/LogOff, but it does not work with the fast switch session feature. You might find some other cool things in this application like data binding to a combobox that's embedded in a data grid cell, but this is out of the current topic. As somebody pointed out, you have to run under an Administrator account to install the service or the MSI. Enjoy!

License

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