using PleaseIgnore.IntelMap.Properties; using System; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.Contracts; using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; namespace PleaseIgnore.IntelMap { /// /// Monitors the log directory for new log entries to a specific /// channel. /// /// /// When running, makes use of the /// to perform background operations. The /// following sequence of events are processed: /// /// /// /// Called after the initial call to /// . Responsible for initializing the /// internal operations and searching the log directory for /// possible active logs. Will not be called by subsequent /// calls to unless an intervening call /// to has been made. /// /// /// /// Generated by an internal instance of /// to notify us of new files /// being created within the log directory, allowing us to /// switch to newer log files. /// /// /// /// Generated by an internal instance of /// to notify us of changes /// to files in the log directory, allowing us to rescan and /// reopen log files appropriately. Unfortunately, due to /// performance optimizations in Windows, change notifications /// are often sent when the file is closed, not when /// new data has been written to the file. /// /// /// /// Generated periodically by the /// to allow us to rescan the log /// files. Has primary responsibility for generating /// events. /// /// /// /// Called after the initial call to /// . Destroys all internal tracking /// structures. Will be not be called by subsequent calls /// to unless an intervening call to /// has been made. /// /// /// Internal and /// members are provided for the benefit of user testing. Redefinition may /// lead to behavior defects or loss of thread safety. /// /// [DefaultEvent("IntelReported"), DefaultProperty("Name")] public class IntelChannel : Component { // Internal members should be referenced by any other class within // PleaseIgnore.IntelMap. They are made internal purely for the // benefit of implementing unit tests. // Regular Expression used to break apart each entry in the log file. private static readonly Regex Parser = new Regex( "^\uFEFF?" + @"\[\s*(\d{4})\.(\d{2})\.(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s*\](.*)$", RegexOptions.CultureInvariant); // Regular Expression used to extract the timestamp from the filename. private static readonly Regex FilenameParser = new Regex( @"_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})\.txt$", RegexOptions.CultureInvariant); // Default directory to find EVE logs private static readonly string defaultLogDirectory = System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "EVE", "logs", "Chatlogs"); // The timer period to use when scheduling the timer private const int timerPeriod = 5000; // The default value for expireLog private const string defaultExpireLog = "00:30:00"; // Synchronization primitive internal readonly object syncRoot = new object(); // Raises the periodic timer for log scanning private readonly Timer logTimer; // The local file system watcher object private FileSystemWatcher watcher; // The channel file name stub [ContractPublicPropertyName("Name")] private string channelFileName; // The path to search for EVE chat logs [ContractPublicPropertyName("Path")] private string logDirectory = defaultLogDirectory; // The current component status [ContractPublicPropertyName("Status")] private volatile IntelStatus status; // The currently processed log file private StreamReader reader; // The last time we parsed a log entry from the current log private DateTime lastEntry; // The time to wait before calling a log file "dead" [ContractPublicPropertyName("LogExpiration")] private TimeSpan expireLog = TimeSpan.Parse( defaultExpireLog, CultureInfo.InvariantCulture); // true if the timer is currently running private bool timerEnabled; /// /// Initializes a new instance of the class. /// public IntelChannel() : this(null, null) { } /// /// Initializes a new instance of the class /// with the specified . /// /// /// The initial value for . /// public IntelChannel(string name) : this(name, null) { } /// /// Initializes a new instance of the class /// with the specified . /// /// /// Optional parent . /// public IntelChannel(IContainer container) : this(null, container) { } /// /// Initializes a new instance of the class /// with the specified and . /// /// /// The initial value for . /// /// /// Optional parent . /// public IntelChannel(string name, IContainer container) { this.channelFileName = name; this.logTimer = new Timer(this.timer_Callback); if (container != null) { container.Add(this); } } /// ~IntelChannel() { this.Dispose(false); } /// /// Occurs when a new log entry has been read from the chat logs. /// public event EventHandler IntelReported; /// /// Occurs when a property value changes. /// public event PropertyChangedEventHandler PropertyChanged; /// /// Gets the default directory to search for EVE chat logs. /// public static string DefaultPath { get { Contract.Ensures(!String.IsNullOrEmpty(Contract.Result())); return defaultLogDirectory; } } /// /// Gets or sets the directory to search for log files. /// public string Path { get { Contract.Ensures(!String.IsNullOrEmpty(Contract.Result())); return this.logDirectory; } set { Contract.Requires(!String.IsNullOrEmpty(value)); lock (this.syncRoot) { if (value != this.logDirectory) { this.logDirectory = value; if (this.watcher != null) { try { this.watcher.Path = value; this.ScanFiles(); } catch (ArgumentException) { this.watcher.Dispose(); this.watcher = null; this.Status = IntelStatus.InvalidPath; } } this.OnPropertyChanged(new PropertyChangedEventArgs("Path")); } } } } /// /// Gets or sets the channel name of this /// /// /// The channel name cannot be modified when /// is . /// [DefaultValue((string)null)] public string Name { get { Contract.Ensures(!String.IsNullOrEmpty(Contract.Result()) || !this.IsRunning); var channelName = this.channelFileName; var site = this.Site; if (!String.IsNullOrEmpty(channelName)) { return channelName; } else if (site != null) { return site.Name; } else { return null; } } set { lock (this.syncRoot) { if (this.IsRunning) { throw new InvalidOperationException(); } this.channelFileName = value; } } } /// /// Gets the number of reports that have been made by this /// . /// public int IntelCount { get; private set; } /// /// Gets the log file currently being observed for new intel. /// public FileInfo LogFile { get; private set; } /// /// Gets the current operational status of the /// object. /// public virtual IntelStatus Status { get { return this.status; } private set { Contract.Ensures(Status == value); if (this.status != value) { this.status = value; this.UpdateTimer(); this.OnPropertyChanged(new PropertyChangedEventArgs("Status")); } } } /// /// Gets a value indicating whether the /// is currently running and watching for log entries. /// public bool IsRunning { get { return this.Status.IsRunning(); } } /// /// Gets or sets the time between log entries before declaring /// that the channel is no longer being monitored. /// [DefaultValue(typeof(TimeSpan), defaultExpireLog)] public TimeSpan LogExpiration { get { Contract.Ensures(Contract.Result() > TimeSpan.Zero); return this.expireLog; } set { Contract.Requires( value > TimeSpan.Zero, "value"); Contract.Ensures(LogExpiration == value); lock (this) { if (this.expireLog != value) { this.expireLog = value; this.OnPropertyChanged(new PropertyChangedEventArgs("LogExpiration")); } } } } /// /// Initiate the acquisition of log entries from the EVE chat logs. This /// method enables events. /// public virtual void Start() { Contract.Requires( Status != IntelStatus.Disposed, null); Contract.Requires( !String.IsNullOrEmpty(Name)); Contract.Ensures(Status != IntelStatus.Stopped); Contract.Ensures(IsRunning); lock (this.syncRoot) { if (this.status == IntelStatus.Stopped) { this.Status = IntelStatus.Starting; this.channelFileName = this.Name; this.OnStart(); } } } /// /// Stops the from providing location data and events. /// public virtual void Stop() { Contract.Ensures((Status == IntelStatus.Stopped) || (Status == IntelStatus.Disposed)); Contract.Ensures(!IsRunning); lock (this.syncRoot) { if ((this.status != IntelStatus.Stopped) || (this.status == IntelStatus.Disposed)) { this.Status = IntelStatus.Stopping; this.OnStop(); this.UpdateTimer(); } } } /// protected override void Dispose(bool disposing) { Contract.Ensures(Status == IntelStatus.Disposed); if (disposing) { lock (this.syncRoot) { if (this.status != IntelStatus.Disposed) { // Normal shutdown this.Stop(); this.Status = IntelStatus.Disposed; // Dispose child objects this.logTimer.Dispose(); // Clear any lingering object references this.IntelReported = null; this.PropertyChanged = null; } } } else { // We really can't touch anything safely this.status = IntelStatus.Disposed; } base.Dispose(disposing); } /// public override string ToString() { return String.Format( CultureInfo.CurrentCulture, Resources.IntelChannel_ToString, this.GetType().Name, this.Name ?? Resources.IntelChannel_NoName, this.Status); } /// /// Creates the instance of used /// to monitor the file system. /// /// /// An instance of that will be /// used to monitor the directory for the creation/modification /// of log files. /// /// /// will be called from within /// synchronized members so should not attempt to perform any /// additional synchronization itself. /// protected virtual FileSystemWatcher CreateFileSystemWatcher() { Contract.Ensures(Contract.Result() != null); var watcher = new FileSystemWatcher(); watcher.BeginInit(); watcher.Changed += this.watcher_Changed; watcher.Created += this.watcher_Created; watcher.EnableRaisingEvents = true; watcher.Filter = this.Name + "_*.txt"; watcher.IncludeSubdirectories = false; watcher.NotifyFilter = NotifyFilters.Size | NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; watcher.Path = this.Path; watcher.EndInit(); return watcher; } /// /// Raises the event. /// /// /// Arguments of the event being raised. /// /// /// will be called from within /// synchronized members so should not attempt to perform any /// additional synchronization itself. /// internal protected void OnIntelReported(IntelEventArgs e) { Contract.Requires(e != null, "e"); ThreadPool.QueueUserWorkItem(delegate(object state) { Contract.Requires(state is IntelEventArgs); var handler = this.IntelReported; if (handler != null) { handler(this, (IntelEventArgs)state); } }, e); this.lastEntry = DateTime.UtcNow; ++this.IntelCount; this.OnPropertyChanged(new PropertyChangedEventArgs("IntelCount")); } /// /// Raises the event. /// /// /// Arguments of the event being raised. /// /// /// will be called from within /// synchronized members so should not attempt to perform any /// additional synchronization itself. /// internal protected void OnPropertyChanged(PropertyChangedEventArgs e) { Contract.Requires(e != null, "e"); Debug.Assert(String.IsNullOrEmpty(e.PropertyName) || (GetType().GetProperty(e.PropertyName) != null)); ThreadPool.QueueUserWorkItem(delegate(object state) { Contract.Requires(state is PropertyChangedEventArgs); var handler = this.PropertyChanged; if (handler != null) { handler(this, (PropertyChangedEventArgs)state); } }, e); } /// /// Called after has been called. /// /// /// will be called with synchronized /// access to the object state. /// protected virtual void OnStart() { Contract.Requires( Status == IntelStatus.Starting); Contract.Ensures((Status == IntelStatus.Active) || (Status == IntelStatus.Waiting) || (Status == IntelStatus.InvalidPath)); // Create the file system watcher object try { this.watcher = this.CreateFileSystemWatcher(); } catch(ArgumentException) { this.Status = IntelStatus.InvalidPath; } // Open the log file with the latest timestamp in its filename this.ScanFiles(); } /// /// Called when a new log file is created for the channel we /// are monitoring. /// /// /// Instance of describing the /// new file. /// /// /// will be called with synchronized /// access to the object state. /// protected virtual void OnFileCreated(FileSystemEventArgs e) { Contract.Requires(e != null, "e"); // Assume it's going to be a better file....for now OpenFile(new FileInfo(e.FullPath)); } /// /// Called when a log file associated with the channel we are /// monitoring has been modified. /// /// /// Instance of describing the /// modified file. /// /// /// will be called with synchronized /// access to the object state. /// protected virtual void OnFileChanged(FileSystemEventArgs e) { Contract.Requires(e != null, "e"); // Only process this message if we have nothing else to go on if (this.reader == null) { this.OpenFile(new FileInfo(e.FullPath)); } } /// /// Called every couple of seconds to sweep the log file for /// new entries. /// /// /// will be called with synchronized /// access to the object state. /// protected virtual void OnTick() { if (this.watcher == null) { // Try (again) to create the watcher object try { this.watcher = this.CreateFileSystemWatcher(); this.Status = IntelStatus.Waiting; this.ScanFiles(); } catch (ArgumentException) { // Still doesn't seem to exist } } if (this.reader != null) { // Read new log entries from the current log try { string line; while ((line = reader.ReadLine()) != null) { Trace.WriteLine("R " + line, IntelExtensions.WebTraceCategory); var match = Parser.Match(line); if (match.Success) { var e = new IntelEventArgs( this.Name, new DateTime( match.Groups[1].ToInt32(), match.Groups[2].ToInt32(), match.Groups[3].ToInt32(), match.Groups[4].ToInt32(), match.Groups[5].ToInt32(), match.Groups[6].ToInt32(), DateTimeKind.Utc), match.Groups[7].Value); this.OnIntelReported(e); } } } catch (IOException) { this.CloseFile(); } // Close the log if it has been idle for too long if (this.lastEntry + this.expireLog < DateTime.UtcNow) { this.CloseFile(); } } } /// /// Called after has been called. /// /// /// will be called with synchronized /// access to the object state. /// protected virtual void OnStop() { Contract.Requires( Status == IntelStatus.Stopping); Contract.Ensures(Status == IntelStatus.Stopped); if (this.reader != null) { this.reader.Close(); this.reader = null; } if (this.LogFile != null) { this.LogFile = null; this.OnPropertyChanged(new PropertyChangedEventArgs("LogFile")); } this.Status = IntelStatus.Stopped; } /// /// Rescans the active directory looking for valid log files /// /// /// if we were able to open a log file; /// otherwise, . /// protected bool ScanFiles() { try { var downtime = IntelExtensions.LastDowntime; var file = new DirectoryInfo(this.Path) .GetFiles(this.Name + "_*.txt", SearchOption.TopDirectoryOnly) .Select(x => new { File = x, Match = FilenameParser.Match(x.Name) }) .Where(x => x.Match.Success) .Select(x => new { File = x.File, Timestamp = new DateTime( x.Match.Groups[1].ToInt32(), x.Match.Groups[2].ToInt32(), x.Match.Groups[3].ToInt32(), x.Match.Groups[4].ToInt32(), x.Match.Groups[5].ToInt32(), x.Match.Groups[6].ToInt32(), DateTimeKind.Utc) }) .Where(x => x.Timestamp > downtime) .OrderByDescending(x => x.Timestamp) .FirstOrDefault(x => this.OpenFile(x.File)); if (file == null) { this.CloseFile(); } return file != null; } catch (IOException) { return false; } } /// /// Closes the existing log file and opens a new log file. /// /// /// The new log file to track. /// /// /// if we were able to open the file; /// otherwise, . /// internal protected bool OpenFile(FileInfo fileInfo) { Contract.Requires(fileInfo != null, "fileInfo"); //Contract.Ensures(Status == IntelChannelStatus.Active); var oldStatus = this.status; var oldFile = this.LogFile; // Close the existing file (if any) if (this.reader != null) { try { this.reader.Close(); } catch (IOException) { } finally { this.reader = null; } } // Clear the status (defer raising PropertyChanged) this.LogFile = null; this.status = (this.watcher != null) ? IntelStatus.Waiting : IntelStatus.InvalidPath; // Try to open the file stream FileStream stream = null; try { stream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); stream.Seek(0, SeekOrigin.End); // XXX: We rely upon StreamReader's BOM detection. EVE seems // to generate Little Endian UTF-16 log files. We could hard // code that, but we don't know if that would cause other // problems. this.reader = new StreamReader(stream, true); this.status = IntelStatus.Active; this.LogFile = fileInfo; this.lastEntry = DateTime.UtcNow; } catch (IOException) { // Don't leak FileStream references if (stream != null) { try { stream.Close(); } catch (IOException) { } } } // Raise any deferred PropertyChanged events if (this.status != oldStatus) { this.OnPropertyChanged(new PropertyChangedEventArgs("Status")); } if (this.LogFile != oldFile) { this.OnPropertyChanged(new PropertyChangedEventArgs("LogFile")); } // Success if we opened a reader this.UpdateTimer(); return (this.reader != null); } /// /// Closes the existing log file /// protected void CloseFile() { Contract.Ensures((Status == IntelStatus.Waiting) || (Status == IntelStatus.InvalidPath)); if (this.reader != null) { try { this.reader.Close(); } catch (IOException) { } finally { this.reader = null; } } if (this.LogFile != null) { this.LogFile = null; this.OnPropertyChanged(new PropertyChangedEventArgs("LogFile")); } this.Status = (this.watcher != null) ? IntelStatus.Waiting : IntelStatus.InvalidPath; } /// /// Updates the timer for /// private void UpdateTimer() { switch (this.status) { case IntelStatus.Active: case IntelStatus.InvalidPath: // Operations that require us to ping the filesystem regularly if (!this.timerEnabled) { this.logTimer.Change(timerPeriod, timerPeriod); this.timerEnabled = true; } break; case IntelStatus.Disposed: // The timer object is no longer valid break; default: // Operations when we are not actively monitoring the filesystem if (this.timerEnabled) { this.logTimer.Change(Timeout.Infinite, Timeout.Infinite); this.timerEnabled = false; } break; } } /// /// Handler for event. /// private void watcher_Created(object sender, FileSystemEventArgs e) { Contract.Requires(e != null); ThreadPool.QueueUserWorkItem(delegate(object state) { Contract.Requires(state is FileSystemEventArgs); lock (this.syncRoot) { if (this.IsRunning) { this.OnFileCreated((FileSystemEventArgs)e); } } }, e); } /// /// Handler for event. /// private void watcher_Changed(object sender, FileSystemEventArgs e) { Contract.Requires(e != null); ThreadPool.QueueUserWorkItem(delegate(object state) { Contract.Requires(state is FileSystemEventArgs); lock (this.syncRoot) { if (this.IsRunning) { this.OnFileChanged((FileSystemEventArgs)e); } } }, e); } /// /// Handler for the callback /// private void timer_Callback(object state) { lock (this.syncRoot) { if (this.IsRunning) { this.OnTick(); } } } [ContractInvariantMethod] private void ObjectInvariant() { Contract.Invariant(!String.IsNullOrEmpty(this.channelFileName) || !this.IsRunning); Contract.Invariant(!String.IsNullOrEmpty(this.logDirectory)); Contract.Invariant(this.IntelCount >= 0); } } }