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:
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.
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.
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.
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);
}
}
}
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.
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:
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();
wmiService = new ManagementObject("Win32_Service.Name='Screen Monitor'");
wmiService.Get();
object o = wmiService["StartMode"];
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);
if (_ckStartService.Checked)
{
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
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!