|
|||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||
|
Announcements
Want a new Job?
Chapters
Services
Feature Zones
|
IntroductionAs I was experimenting with the media center functionality of Windows Vista RC2 one afternoon, I realized that it would be great to have a service running that would automatically transcode recordings from Microsoft's heavyweight DVR-MS format to a more svelte WMV file. Well, a utility (DVRMSToolbox) already exists to handle this scenario. But what if you wanted to add more functionality, like reorganizing the recording by automatically renaming it and copying it to another directory? Thus was born the idea for the Directory Watcher, a generic service that watches directories contained in the configuration file for fileysystem events and then runs specified applications in response to those events. BackgroundThe Directory Watcher service makes use of several .NET features and programming concepts to do its job, the first and foremost of which is the The However, what we need in this application is a resource-based semaphore: instead of restricting access to a section of code to a single thread, we want Finally, we make use of framework's ability to runtime-compile code in order to provide a measure of scripting support. When trying to do something extremely simple, like send an email to someone, to handle a filesystem event it's often a PITA to compile and maintain an entirely separate executable for the task. So, the service allows small snippets of .NET code to be specified that will handle events (they must meet certain criteria that will be covered later). We make use of the classes in the Implementation detailsConfigurationOne of my favorite improvements in .NET 2.0 are the dramatically improved configuration classes: <?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="watchInformation"
type="DirectoryWatcher.WatchInformation, DirectoryWatcher"/>
</configSections>
<appSettings>
<add key="maxConcurrentProcesses" value="20"/>
</appSettings>
<watchInformation>
<directoriesToWatch>
<directoryToWatch path="c:\Cygwin\var\spool\exim\pickup">
<fileSetsToWatch>
<fileSetToWatch>
<eventsToWatch>
<eventToWatch type="Created"/>
</eventsToWatch>
<programsToExecute>
<programToExecute path="c:\Cygwin\bin\exim-4.52-2.exe"
arguments="-odf -t" redirectFileToStdin="true"/>
<programToExecute path="c:\Cygwin\bin\rm.exe"
arguments=""{P}""/>
</programsToExecute>
</fileSetToWatch>
</fileSetsToWatch>
</directoryToWatch>
</directoriesToWatch>
</watchInformation>
</configuration>
All of the data contained therein is referenced through classes that inherit from public class WatchInformation : ConfigurationSection
{
/// <summary>
/// Collection of directories that we are to watch for filesystem changes.
/// </summary>
[ConfigurationProperty("directoriesToWatch", IsRequired = true)]
public DirectoryToWatchCollection DirectoriesToWatch
{
get
{
return (DirectoryToWatchCollection)base["directoriesToWatch"];
}
}
}
By flagging the However, there is definitely a gotcha with regards to the configuration classes: they don't provide a direct way for you to store configuration information for a property in a text node instead of an attribute. For instance, when you look at a typical line in the config file, <programToExecute path="c:\Cygwin\bin\exim-4.52-2.exe" arguments="-odf -t"
redirectFileToStdin="true"/>
you see that values for each property (of the public class ProgramCode : ConfigurationElement
{
/// <summary>
/// The text of the actual code that we are to compile.
/// </summary>
private string text = null;
/// <summary>
/// Line number in the configuration file for this element
/// (used for possible exception messages).
/// </summary>
private int lineNumber = 0;
/// <summary>
/// Path to the configuration file in which this element resides
/// (used for possible exception messages).
/// </summary>
private string fileName = "";
/// <summary>
/// Assembly that results when we compile the code.
/// </summary>
private Assembly assembly = null;
/// <summary>
/// The language for this code snippet.
/// </summary>
[ConfigurationProperty("language", IsRequired = true)]
public ProgramLanguage Language
{
get
{
return (ProgramLanguage)base["language"];
}
}
/// <summary>
/// Text representing the actual code.
/// </summary>
public string Text
{
get
{
return text;
}
}
/// <summary>
/// Collection of referenced assemblies for this snippet of code.
/// </summary>
[ConfigurationProperty("referencedAssemblies")]
public ReferencedAssemblyCollection ReferencedAssemblies
{
get
{
return (ReferencedAssemblyCollection)base["referencedAssemblies"];
}
}
/// <summary>
/// Assembly representing the compiled results of the code.
/// </summary>
public Assembly Assembly
{
get
{
// Omitted for brevity's sake, we'll cover this later
}
}
/// <summary>
/// Handler for the case where we encounter an unrecognized element
/// while attempting to deserialize the class from XML; deals with
/// "custom" properties, specifically the Text property, whose value is set
/// in an element node instead of an attribute.
/// </summary>
/// <param name="elementName">
/// Name of the unrecognized element.
/// </param>
/// <param name="reader">
/// Reader object that is involved in the deserialization.
/// </param>
/// <returns>
/// True if we actually recognize the element, false otherwise.
/// </returns>
protected override bool OnDeserializeUnrecognizedElement(string elementName,
XmlReader reader)
{
if (elementName == "text")
{
text = reader.ReadString();
reader.Read();
return true;
}
return base.OnDeserializeUnrecognizedElement(elementName, reader);
}
/// <summary>
/// Instantiates the object using data stored in XML;
/// records the filename and line number
/// (for use later in possible exceptions) and then calls the base method.
/// </summary>
/// <param name="reader">
/// Reader object that is involved in the deserialization.
/// </param>
/// <param name="serializeCollectionKey">
/// True to serialize only the collection key properties, false otherwise.
/// </param>
protected override void DeserializeElement(XmlReader reader,
bool serializeCollectionKey)
{
lineNumber = ((IConfigErrorInfo)reader).LineNumber;
fileName = ((IConfigErrorInfo)reader).Filename;
base.DeserializeElement(reader, serializeCollectionKey);
}
/// <summary>
/// Called after the deserialization process is complete;
/// validates the object's data.
/// </summary>
protected override void PostDeserialize()
{
if (text == null)
throw new ConfigurationErrorsException
("\"text\" is a required element.", fileName, lineNumber);
base.PostDeserialize();
}
}
First, you see that the ServiceStartup logicThe code in the service's protected override void OnStart(string[] args)
{
WriteToEventLog(EventLogEntryType.Information,
"Starting up the Directory Watcher service.");
try
{
// Get the section from the configuration file that contains the
// directories/file sets that we are to watch
watchInformation =
(WatchInformation)ConfigurationManager.GetSection("watchInformation");
// Get the maximum number of concurrent processes that can be active
// (if specified)
if (ConfigurationManager.AppSettings["maxConcurrentProcesses"] != null)
maxConcurrentProcesses =
Convert.ToUInt32(ConfigurationManager.AppSettings
["maxConcurrentProcesses"]);
WriteToEventLog(EventLogEntryType.Information,
"Using a concurrent process count of {0}.",
maxConcurrentProcesses);
// Instantiate the regulation semaphore to enforce the maximum process
// count
executionRegulator = new CountingSemaphore(maxConcurrentProcesses);
foreach (DirectoryToWatch directoryToWatch in
watchInformation.DirectoriesToWatch)
{
foreach (FileSetToWatch fileSetToWatch in
directoryToWatch.FileSetsToWatch)
{
// If we're using any runtime-compiled code, validate each assembly
// that was generated to make sure that only one class exists in it
// that implements the IFileSystemEventHandler interface
foreach (ProgramToExecute programToExecute in
fileSetToWatch.ProgramsToExecute)
{
if (programToExecute.Code.Text != null)
ValidateAssembly(programToExecute.Code.Assembly);
}
// Create and instantiate an individual FileSystemWatcher for
// each wildcard file set and a single FileSystemWatcher object
// to handle all regular expression file sets
if (fileSetToWatch.MatchExpressionType ==
MatchExpressionType.Wildcard ||
watchers[directoryToWatch.Path][""] == null)
{
FileSystemWatcher watcher =
new FileSystemWatcher(directoryToWatch.Path);
// Set the filter to the match expression for wildcard file
// sets, and blank (to capture changes for all files and do
// the actual matching in the event handler) for regular
// expression file sets
watcher.Filter =
(fileSetToWatch.MatchExpressionType ==
MatchExpressionType.Wildcard ?
fileSetToWatch.MatchExpression : "");
// Attach handlers to the various filesystem events that we're
// supposed to watch for
if (fileSetToWatch.EventsToWatch["All"] != null)
{
watcher.Changed += new FileSystemEventHandler(watcher_OnChanged);
watcher.Created += new FileSystemEventHandler(watcher_OnChanged);
watcher.Deleted += new FileSystemEventHandler(watcher_OnChanged);
watcher.Renamed += new RenamedEventHandler(watcher_OnChanged);
}
else
{
foreach (EventToWatch eventToWatch in
fileSetToWatch.EventsToWatch)
{
if (eventToWatch.Type == WatcherChangeTypes.Changed)
watcher.Changed +=
new FileSystemEventHandler(watcher_OnChanged);
else if (eventToWatch.Type == WatcherChangeTypes.Created)
watcher.Created +=
new FileSystemEventHandler(watcher_OnChanged);
else if (eventToWatch.Type == WatcherChangeTypes.Deleted)
watcher.Deleted +=
new FileSystemEventHandler(watcher_OnChanged);
else if (eventToWatch.Type == WatcherChangeTypes.Renamed)
watcher.Renamed +=
new RenamedEventHandler(watcher_OnChanged);
}
}
// Create a new dictionary entry for this directory path if
// it doesn't exist already
if (!watchers.ContainsKey(directoryToWatch.Path))
watchers[directoryToWatch.Path] =
new Dictionary<string, FileSystemWatcher>();
// Add the watcher to the list for this directory and
// enable it
watchers[directoryToWatch.Path][watcher.Filter] = watcher;
watcher.EnableRaisingEvents = true;
}
WriteToEventLog(EventLogEntryType.Information,
"Added watcher for the path \"{0}\"" +
"and the {1} \"{2}\".",
directoryToWatch.Path,
(fileSetToWatch.MatchExpressionType ==
MatchExpressionType.Wildcard ?
"wildcard expression" : "regular expression"),
fileSetToWatch.MatchExpression);
}
}
}
// Log any exceptions that occur during startup
catch (Exception exception)
{
WriteToEventLog(EventLogEntryType.Error,
"Exception occurred while starting the service." +
"\n\nType: {0}\nMessage:{1}",
exception.GetType().FullName, exception.Message);
throw;
}
base.OnStart(args);
}
We start by getting access to the configuration data in the App.config file by using .NET's built-in watchInformation = (WatchInformation)ConfigurationManager.GetSection
("watchInformation");
For each file set, we then check its handler programs and if any are represented by runtime compiled code, then we do a validation of the assembly that was generated to make sure that it meets our requirements. The validation functions that are involved are as follows: protected static void ValidateAssembly(Assembly assembly)
{
FindEventHandlerType(assembly);
}
protected static Type FindEventHandlerType(Assembly assembly)
{
Type eventHandlerType = null;
foreach (Type type in assembly.GetTypes())
{
if (type.GetInterface("IFileSystemEventHandler") != null)
{
// If we've already found a qualifying type, then throw an exception
if (eventHandlerType != null)
throw new ArgumentException(
String.Format("Multiple classes implementing " +
"IFileSystemEventHandler were found in {0}.",
assembly.FullName));
eventHandlerType = type;
}
}
// If no qualifying types were found, then throw an exception
if (eventHandlerType == null)
throw new ArgumentException(
String.Format("No classes implementing IFileSystemEventHandler " +
"were found in {0}.", assembly.FullName));
return eventHandlerType;
}
It just does a simple check of the assembly's types and makes sure that one, and only one, class (the main handler class) implements the public interface IFileSystemEventHandler
{
/// <summary>
/// Handler function that is called whenever a filesystem event occurs.
/// </summary>
/// <param name="e">
/// Arguments (file name, directory, event type, etc.) associated with
/// the event.
/// </param>
void OnFileSystemEvent(FileSystemEventArgs e);
}
It's then a simple matter of iterating over each directory, iterating over each file set contained within the directory, and creating Runtime compilationSupport for runtime-compiled code in the service is accomplished through the use of the public Assembly Assembly
{
get
{
// If the code has not already been compiled, do so now
if (assembly == null)
{
CodeDomProvider codeProvider = null;
// Get the proper code provider based on the code's language
if (Language == ProgramLanguage.CSharp)
codeProvider = new CSharpCodeProvider();
else if (Language == ProgramLanguage.VisualBasic)
codeProvider = new VBCodeProvider();
CompilerParameters compilerParameters = new CompilerParameters();
// Set the compiler options so that we don't generate an assembly
// on disk and we create a library assembly that does not contain
// debug information
compilerParameters.GenerateExecutable = false;
compilerParameters.GenerateInMemory = true;
compilerParameters.IncludeDebugInformation = false;
compilerParameters.CompilerOptions = "/target:library /optimize";
compilerParameters.ReferencedAssemblies.Add("System.dll");
compilerParameters.ReferencedAssemblies.Add(
AppDomain.CurrentDomain.BaseDirectory +
"\\DirectoryWatcher.exe");
// Add any assembly references (besides System.dll and
// DirectoryWatcher.exe, which everyone gets) specified for this
// code
foreach (ReferencedAssembly referencedAssembly in
ReferencedAssemblies)
compilerParameters.ReferencedAssemblies.Add(
referencedAssembly.Name);
// Generate the assembly
CompilerResults results =
codeProvider.CompileAssemblyFromSource(
compilerParameters, text);
// Check the return code and throw an exception if the compilation failed
if (results.NativeCompilerReturnValue != 0)
throw new CompilationException(results.Errors);
assembly = results.CompiledAssembly;
}
return assembly;
}
}
The <programToExecute>
<code language="CSharp">
<text>
<![CDATA[
using System;
using System.IO;
using DirectoryWatcher;
public class NotifyClass : IFileSystemEventHandler
{
public NotifyClass()
{
}
public void OnFileSystemEvent(FileSystemEventArgs e)
{
string tempFileName = Path.GetTempFileName();
StreamWriter writer = new StreamWriter(tempFileName);
writer.WriteLine("To: lstratman@gmail.com");
writer.WriteLine("From: lstratman@gmail.com");
writer.WriteLine("Subject: File change notification");
writer.WriteLine("");
writer.WriteLine(e.FullPath + " has changed.");
writer.Close();
File.Move(tempFileName,
"c:\\Cygwin\\var\\spool\\exim\\" +
pickup\\email.txt");
}
}
]]>
</text>
</code>
</programToExecute>
Event handling logicWhen a filesystem event is detected, a handler function is invoked that is responsible for running the necessary applications to respond to the event: protected void watcher_OnChanged(object source, FileSystemEventArgs e)
{
FileSystemWatcher watcher = (FileSystemWatcher)source;
DirectoryToWatch directoryToWatch =
watchInformation.DirectoriesToWatch[watcher.Path];
// If this watcher is a wildcard watcher, grab its programs from the
// configuration section
if (watcher.Filter != "")
{
ProgramToExecuteCollection programsToExecute =
directoryToWatch.FileSetsToWatch[watcher.Filter].ProgramsToExecute;
AddProgramsToQueue(programsToExecute, e.FullPath, e.ChangeType);
}
// Otherwise, go through the list of regular expression file sets for this
// directory, see if any of them match the file that was modified, and, if
// they do, get their programs from the configuration section
else
{
foreach (FileSetToWatch fileSetToWatch in
directoryToWatch.FileSetsToWatch)
{
if (fileSetToWatch.MatchExpressionType ==
MatchExpressionType.RegularExpression &&
fileSetToWatch.MatchRegex.IsMatch(e.Name))
AddProgramsToQueue(fileSetToWatch.ProgramsToExecute, e.FullPath,
e.ChangeType);
}
}
}
Again, it's pretty straightforward: if the source protected void AddProgramsToQueue(ProgramToExecuteCollection programsToExecute,
string filePath,
WatcherChangeTypes eventType)
{
List<ExecutionInstance> executionInstances =
new List<ExecutionInstance>();
Thread executionThread =
new Thread(new ParameterizedThreadStart(RunPrograms));
// Loop through each program and create an ExecutionInstance object for it
foreach (ProgramToExecute programToExecute in programsToExecute)
{
ExecutionInstance executionInstance;
// If we're running a pre-compiled application, create the necessary
// ProcessStartInfo object
if (programToExecute.Code.Text == null)
{
ProcessStartInfo startInfo =
new ProcessStartInfo(programToExecute.Path);
FileInfo fileInfo = new FileInfo(filePath);
startInfo.Arguments =
programToExecute.Arguments.Replace("{P}", filePath);
startInfo.Arguments =
startInfo.Arguments.Replace("{F}", fileInfo.Name);
startInfo.Arguments =
startInfo.Arguments.Replace("{E}", fileInfo.Extension);
startInfo.Arguments =
startInfo.Arguments.Replace("{D}", fileInfo.DirectoryName);
if (fileInfo.Extension != "")
startInfo.Arguments =
startInfo.Arguments.Replace("{f}", fileInfo.Name.Substring(0,
fileInfo.Name.Length -
fileInfo.Extension.Length - 1));
else
startInfo.Arguments =
startInfo.Arguments.Replace("{f}", fileInfo.Name);
startInfo.UseShellExecute = false;
startInfo.RedirectStandardInput =
programToExecute.RedirectFileToStdin;
executionInstance =
new ExecutionInstance(startInfo, eventType, filePath,
programToExecute.RedirectFileToStdin);
}
// Otherwise, we're using runtime-compiled code and we need to create
// an instance of the class that implements IFileSystemEventHandler
else
{
IFileSystemEventHandler eventHandler =
CreateEventHandlerInstance(programToExecute.Code.Assembly);
executionInstance = new ExecutionInstance(eventHandler, eventType,
filePath);
}
executionInstances.Add(executionInstance);
}
// Start the thread that will execute the programs
executionThread.Start(executionInstances);
}
This function is responsible for taking a list of programs to execute and then spinning off a worker thread to execute those programs in sequence. The fact that an event can invoke more than one program is one reason why this must be handled in a separate thread: using the example of the SMTP pickup directory from the configuration section, we run one command to send the email and another to clean the message file up from the pickup directory. We obviously don't want the cleanup command run until the message sending command finishes, so we use a thread to start a program, wait until it completes, start the next program, and repeat until we reach the end of the list. Another reason for spinning off a thread is that we want the calling event handler function to return as quickly as possible: if the event handler actually blocked waiting for the handling programs to run, then it's possible for the public static void RunPrograms(object source)
{
List<ExecutionInstance> executionInstances =
(List<ExecutionInstance>)source;
// If we're watching for a create event, we first try to open the file in
// exclusive mode; this is to account for the "long copy" scenario where
// the create event is fired when the copy first starts, but we need to
// wait until the copy completes before we begin our processing
if (executionInstances[0].EventType == WatcherChangeTypes.Created)
{
FileStream fileStream = null;
while (fileStream == null)
{
try
{
fileStream =
File.Open(executionInstances[0].FilePath, FileMode.Open,
FileAccess.Read, FileShare.None);
}
// Catch the IOException that will be thrown when we fail to open
// the file in exclusive mode
catch (IOException exception)
{
string warningTrap = exception.Message;
Thread.Sleep(1000);
}
// Log any other unhandled exceptions that are thrown
catch (Exception exception)
{
WriteToEventLog(EventLogEntryType.Error,
"Unhandled exception occurred while waiting for " +
"\"{0}\" to become available." +
"\n\nType: {1}\nMessage:{2}",
executionInstances[0].FilePath,
exception.GetType().FullName,
exception.Message);
}
}
fileStream.Close();
}
// Claim a resource from the counting semaphore and enter the critical
// section
executionRegulator.P();
foreach (ExecutionInstance executionInstance in executionInstances)
{
try
{
// If we're running a pre-compiled application, start the
// process
if (executionInstance.EventHandler == null)
{
WriteToEventLog(EventLogEntryType.Information,
"Running program in response to event for " +
"{3} being {4}:\n\"{0}\"{1}{2}.",
executionInstance.StartInfo.FileName,
(executionInstance.StartInfo.Arguments != "" ?
" " + executionInstance.StartInfo.Arguments :
""),
(executionInstance.RedirectFileToStdin ?
" < \"" + executionInstance.FilePath +
"\"" : ""),
executionInstance.FilePath,
executionInstance.EventType.ToString().ToLower());
Process executionProcess =
Process.Start(executionInstance.StartInfo);
// If we're redirecting the file to the standard input stream, open
// it up and read its contents into the stream in 1 KB chunks
if (executionInstance.RedirectFileToStdin)
{
BinaryReader binaryReader =
new BinaryReader(File.Open(executionInstance.FilePath,
FileMode.Open));
BinaryWriter binaryWriter =
new BinaryWriter(executionProcess.StandardInput.BaseStream);
byte[] buffer = new byte[1024];
int readSize = binaryReader.Read(buffer, 0, buffer.Length);
while (readSize != 0)
{
binaryWriter.Write(buffer, 0, readSize);
readSize = binaryReader.Read(buffer, 0, buffer.Length);
}
binaryReader.Close();
binaryWriter.Close();
}
// Wait for the process to exit and then clean it up
executionProcess.WaitForExit();
executionProcess.Close();
}
// Otherwise, invoke the OnFileSystemEvent() method for the
// handler class defined in the runtime-compiled code
else
{
WriteToEventLog(EventLogEntryType.Information,
"Invoking {0}.OnFileSystemEvent() in " +
"response to event for {1} being {2}.",
executionInstance.EventHandler.GetType().Name,
executionInstance.FilePath,
executionInstance.EventType.ToString().ToLower());
FileInfo fileInfo = new FileInfo(executionInstance.FilePath);
FileSystemEventArgs eventArguments =
new FileSystemEventArgs(executionInstance.EventType,
fileInfo.DirectoryName,
fileInfo.Name);
executionInstance.EventHandler.OnFileSystemEvent(
eventArguments);
}
}
// Log any exceptions that occur while running the program
catch (Exception exception)
{
WriteToEventLog(EventLogEntryType.Error,
"Exception occurred while running \"{0}\"." +
"\n\nType: {1}\nMessage:{2}",
executionInstance.StartInfo.FileName,
exception.GetType().FullName, exception.Message);
}
}
// Release the resource and exit the critical section
executionRegulator.V();
}
The first thing it does is a trick to account for the "long copy" scenario. If a file is being created, but is being copied from somewhere, it could take a while if the file is large or if the transport protocol is slow. In either case, we don't want to start our handler applications until the file finishes copying, but the create event is fired when the copy operation first starts. So, we try to open the file with an exclusive lock, which will throw an // Claim a resource from the counting semaphore and enter the critical section
executionRegulator.P();
If there are resources available, then the call will return immediately and we will continue executing. Otherwise, the call will block until a resource becomes available. This gives us a way to control the number of running processes in an environment where the churn on a directory may be very high. Once a resource is claimed, we branch depending on what type of program (pre- or runtime-compiled) this is. If it's a pre-compiled program, we start the process and, if we're supposed to redirect the file to the process' standard input, we open a handle to the file and read it into the standard input stream. If it's a runtime compiled program, we create a // Release the resource and exit the critical section
executionRegulator.V();
CountingSemaphoreThe implementation of the public class CountingSemaphore
{
/// <summary>
/// Resource limit for the semaphore
/// </summary>
private uint count;
/// <summary>
/// Default constructor; resource count is 1, meaning the class will act
/// like a standard, binary semaphore.
/// </summary>
public CountingSemaphore() : this(1)
{
}
/// <summary>
/// Constructor that allows you to set the number of resources that should
/// be available for consumption.
/// </summary>
/// <param name="count">Number of resources that should be available
/// for consumption</param>
public CountingSemaphore(uint count)
{
this.count = count;
}
/// <summary>
/// Function that should be called when leaving the critical section; frees
/// up one resource.
/// </summary>
public void AddOne()
{
V();
}
/// <summary>
/// Function that should be called when entering a critical section; claims
/// one resource or waits if no resources are available.
/// </summary>
public void WaitOne()
{
P();
}
/// <summary>
/// Function that should be called when entering a critical section; claims
/// one resource or waits if no resources are available.
/// </summary>
public void P()
{
lock(this)
{
while (count <= 0)
Monitor.Wait(this, Timeout.Infinite);
count--;
}
}
/// <summary>
/// Function that should be called when leaving the critical section;
/// frees up one resource.
/// </summary>
public void V()
{
lock(this)
{
count++;
Monitor.Pulse(this);
}
}
}
ErrataBe sure to alter the account that the service runs under according to your needs. When watching folders only on the local machine, you can typically leave it as the Local System account, unless the ACLs on a directory explicitly exclude the SYSTEM user in which case you'll have to switch to a user with rights to that directory. When watching folders on a network share, however, you need to switch the account to one with local network access: on Windows XP and 2003 you can use the Network Service account (provided that the share is open to everyone), and on Windows 2000 you can use a domain account. However please note that, per the comments by Jeffrey Walton, this service (specifically the History
| ||||||||||||||||||||||||||||||||||||||