diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 1eea57e..c014174 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -5,4 +5,4 @@ patreon: d2phap
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
-custom: ["https://donorbox.org/imageglass", "https://www.paypal.me/d2phap"]
+custom: ["https://donate.stripe.com/6oE15Kab3740du828a", "https://www.paypal.me/d2phap"]
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..783009a
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,32 @@
+# This workflow will build a .NET project
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
+
+name: .NET
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+defaults:
+ run:
+ working-directory: Source
+
+jobs:
+ build:
+
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: 8.0.x
+ - name: Restore dependencies
+ run: dotnet restore
+ - name: Build
+ run: dotnet build --no-restore
+ - name: Test
+ run: dotnet test --no-build --verbosity normal
diff --git a/LICENSE b/LICENSE
index e693ab6..0447c45 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
-MIT License
+MIT License
-Copyright (c) 2022 Duong Dieu Phap
+Copyright (c) 2018-2026 Duong Dieu Phap
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 67c3169..7a5c4be 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# FileWatcherEx for Windows
+# D2Phap.FileWatcherEx for Windows
A wrapper of `System.IO.FileSystemWatcher` to standardize the events and avoid false change notifications. It has been being used in [ImageGlass - A lightweight, versatile image viewer](https://github.com/d2phap/ImageGlass) project.
This project is based on the *VSCode FileWatcher*: https://github.com/Microsoft/vscode-filewatcher-windows
@@ -7,28 +7,28 @@ This project is based on the *VSCode FileWatcher*: https://github.com/Microsoft/
## Resource links
-- Nuget package: [https://www.nuget.org/packages/FileWatcherEx](https://www.nuget.org/packages/FileWatcherEx/)
+- Nuget package: [https://www.nuget.org/packages/D2Phap.FileWatcherEx](https://www.nuget.org/packages/D2Phap.FileWatcherEx/)
- Project url: [https://github.com/d2phap/FileWatcherEx](https://github.com/d2phap/FileWatcherEx)
- Website: [https://imageglass.org](https://imageglass.org)
## Features
- Standardizes the events of `System.IO.FileSystemWatcher`.
- No false change notifications when a file system item is created, deleted, changed or renamed.
-- Supports .NET 6.0, 7.0.
+- Supports .NET 8.0, 10.0
## Installation
Run the command:
```bash
# Nuget package
-Install-Package FileWatcherEx
+Install-Package D2Phap.FileWatcherEx
```
## Usage
See Demo project for full details!
```cs
-using FileWatcherEx;
+using D2Phap.FileWatcherEx;
var _fw = new FileSystemWatcherEx(@"C:\path\to\watch");
@@ -63,6 +63,5 @@ void FW_OnRenamed(object sender, FileChangedEvent e)
- [GitHub sponsor](https://github.com/sponsors/d2phap)
- [Patreon](https://www.patreon.com/d2phap)
- [PayPal](https://www.paypal.me/d2phap)
-- [Wire Transfers](https://donorbox.org/imageglass)
Thanks for your gratitude and finance help!
diff --git a/Source/FileWatcherEx/FileWatcherEx.csproj b/Source/D2Phap.FileWatcherEx/D2Phap.FileWatcherEx.csproj
similarity index 69%
rename from Source/FileWatcherEx/FileWatcherEx.csproj
rename to Source/D2Phap.FileWatcherEx/D2Phap.FileWatcherEx.csproj
index e755cd5..bf555e3 100644
--- a/Source/FileWatcherEx/FileWatcherEx.csproj
+++ b/Source/D2Phap.FileWatcherEx/D2Phap.FileWatcherEx.csproj
@@ -1,10 +1,10 @@
- net6.0;net7.0
+ net8.0;net10.0
enable
enable
- Copyright © 2018-2023 Duong Dieu Phap
+ Copyright © 2018-2026 Duong Dieu Phap
https://github.com/d2phap/FileWatcherEx
README.md
https://github.com/d2phap/FileWatcherEx
@@ -13,16 +13,28 @@
A wrapper of FileSystemWatcher to standardize the events and avoid false change notifications, used in ImageGlass project (https://imageglass.org). This project is based on the VSCode FileWatcher: https://github.com/Microsoft/vscode-filewatcher-windows.
True
$(Version)
- 2.2.0
- MIT
+ 3.0.0
See https://github.com/d2phap/FileWatcherEx/releases
d2phap
- FileWatcherEx - A file system watcher
+ D2Phap.FileWatcherEx - A file system watcher
True
snupkg
+ LICENSE
+ False
+ latest
+
+ true
+ true
+ true
+ false
+ latest
+
+ True
+ \
+
True
\
diff --git a/Source/FileWatcherEx/FileEvents.cs b/Source/D2Phap.FileWatcherEx/FileEvents.cs
similarity index 93%
rename from Source/FileWatcherEx/FileEvents.cs
rename to Source/D2Phap.FileWatcherEx/FileEvents.cs
index 1ce5d5d..871dcbb 100644
--- a/Source/FileWatcherEx/FileEvents.cs
+++ b/Source/D2Phap.FileWatcherEx/FileEvents.cs
@@ -1,4 +1,4 @@
-namespace FileWatcherEx;
+namespace D2Phap.FileWatcherEx;
public enum ChangeType
{
diff --git a/Source/FileWatcherEx/FileSystemWatcherEx.cs b/Source/D2Phap.FileWatcherEx/FileSystemWatcherEx.cs
similarity index 76%
rename from Source/FileWatcherEx/FileSystemWatcherEx.cs
rename to Source/D2Phap.FileWatcherEx/FileSystemWatcherEx.cs
index 8bc5e02..e382dd3 100644
--- a/Source/FileWatcherEx/FileSystemWatcherEx.cs
+++ b/Source/D2Phap.FileWatcherEx/FileSystemWatcherEx.cs
@@ -1,30 +1,41 @@
+using D2Phap.FileWatcherEx.Helpers;
using System.Collections.Concurrent;
using System.ComponentModel;
-namespace FileWatcherEx;
+namespace D2Phap.FileWatcherEx;
+
///
/// A wrapper of to standardize the events
/// and avoid false change notifications.
///
-public class FileSystemWatcherEx : IDisposable
+///
+/// Optional Action to log out library internals
+public class FileSystemWatcherEx(string folderPath = "", Action? logger = null) : IDisposable, IFileSystemWatcherEx
{
-
#region Private Properties
private Thread? _thread;
private EventProcessor? _processor;
- private readonly BlockingCollection _fileEventQueue = new();
+ private readonly BlockingCollection _fileEventQueue = [];
- private FileWatcher? _watcher;
- private FileSystemWatcher? _fsw;
+ private SymlinkAwareFileWatcher? _watcher;
+ private Func? _fswFactory;
+ private readonly Action _logger = logger ?? (_ => { });
// Define the cancellation token.
private CancellationTokenSource? _cancelSource;
- #endregion
+ // allow injection of FileSystemWatcherWrapper
+ internal Func FileSystemWatcherFactory
+ {
+ // default to production FileSystemWatcherWrapper (which wrapped the native FileSystemWatcher)
+ get { return _fswFactory ?? (() => new FileSystemWatcherWrapper()); }
+ set => _fswFactory = value;
+ }
+ #endregion
#region Public Properties
@@ -32,13 +43,13 @@ public class FileSystemWatcherEx : IDisposable
///
/// Gets or sets the path of the directory to watch.
///
- public string FolderPath { get; set; } = "";
+ public string FolderPath { get; set; } = folderPath;
///
/// Gets the collection of all the filters used to determine what files are monitored in a directory.
///
- public System.Collections.ObjectModel.Collection Filters { get; } = new();
+ public System.Collections.ObjectModel.Collection Filters { get; } = [];
///
@@ -79,7 +90,6 @@ public string Filter
#endregion
-
#region Public Events
///
@@ -125,22 +135,13 @@ public string Filter
- ///
- /// Initialize new instance of
- ///
- ///
- public FileSystemWatcherEx(string folderPath = "")
- {
- FolderPath = folderPath;
- }
-
-
///
/// Start watching files
///
public void Start()
{
if (!Directory.Exists(FolderPath)) return;
+ Stop();
_processor = new EventProcessor((e) =>
@@ -155,15 +156,13 @@ void InvokeChangedEvent(object? sender, FileChangedEvent fileEvent)
{
if (SynchronizingObject != null && SynchronizingObject.InvokeRequired)
{
- SynchronizingObject.Invoke(new Action
public void Stop()
{
- if (_fsw != null)
- {
- _fsw.EnableRaisingEvents = false;
- _fsw.Dispose();
- }
-
_watcher?.Dispose();
+ _watcher = null;
// stop the thread
- _cancelSource?.Cancel();
_cancelSource?.Dispose();
+ _cancelSource = null;
}
@@ -301,14 +291,12 @@ public void Stop()
///
public void Dispose()
{
- _fsw?.Dispose();
- _watcher?.Dispose();
- _cancelSource?.Dispose();
+ Stop();
+
GC.SuppressFinalize(this);
}
-
private void Thread_DoingWork(CancellationToken cancelToken)
{
while (true)
@@ -327,6 +315,5 @@ private void Thread_DoingWork(CancellationToken cancelToken)
}
}
}
-
-
}
+
diff --git a/Source/D2Phap.FileWatcherEx/Helpers/EventNormalizer.cs b/Source/D2Phap.FileWatcherEx/Helpers/EventNormalizer.cs
new file mode 100644
index 0000000..77fc675
--- /dev/null
+++ b/Source/D2Phap.FileWatcherEx/Helpers/EventNormalizer.cs
@@ -0,0 +1,170 @@
+namespace D2Phap.FileWatcherEx.Helpers;
+
+///
+/// Tries to fix the real life oddities of the underlying FileSystemWatcher class.
+/// The code here got refactored from the original Microsoft sources.
+/// For real scenario, see EventNormalizerTest.cs
+///
+internal class EventNormalizer
+{
+ private readonly FileEventRepository _eventRepo = new();
+
+ internal IEnumerable Normalize(FileChangedEvent[] events)
+ {
+ NormalizeDuplicates(events);
+ return FilterDeleted(_eventRepo.Events());
+ }
+
+ private void NormalizeDuplicates(FileChangedEvent[] events)
+ {
+ foreach (var newEvent in events)
+ {
+ var oldEvent = _eventRepo.Find(newEvent.FullPath);
+ // original file event from which we renamed, only applicable for RENAMED event
+ var renameFromEvent = newEvent.ChangeType == ChangeType.RENAMED
+ ? _eventRepo.Find(newEvent.OldFullPath)
+ : null;
+
+ switch (newEvent.ChangeType)
+ {
+ // CREATED followed by CHANGED => CREATED
+ case ChangeType.CHANGED when oldEvent?.ChangeType == ChangeType.CREATED:
+ // Do nothing
+ break;
+
+ // CREATED followed by DELETED => remove
+ case ChangeType.DELETED when oldEvent?.ChangeType == ChangeType.CREATED:
+ _eventRepo.Remove(oldEvent);
+ break;
+
+ // DELETED followed by CREATED => CHANGED
+ case ChangeType.CREATED when oldEvent?.ChangeType == ChangeType.DELETED:
+ oldEvent.ChangeType = ChangeType.CHANGED;
+ break;
+
+ // Scenario:
+ // - file foo is created
+ // - file bar is deleted
+ // - now foo is renamed to the just deleted bar
+ // - this results into a bar changed event
+ case ChangeType.RENAMED when oldEvent?.ChangeType == ChangeType.DELETED && renameFromEvent?.ChangeType == ChangeType.CREATED:
+ newEvent.ChangeType = ChangeType.CHANGED;
+ newEvent.OldFullPath = null;
+ _eventRepo.AddOrUpdate(newEvent);
+
+ // Remove data about the CREATED file
+ _eventRepo.Remove(renameFromEvent);
+ break;
+
+ // rename from CREATED file, all other cases
+ case ChangeType.RENAMED when renameFromEvent?.ChangeType == ChangeType.CREATED:
+ newEvent.ChangeType = ChangeType.CREATED;
+ newEvent.OldFullPath = null;
+ _eventRepo.AddOrUpdate(newEvent);
+
+ // Remove data about the CREATED file
+ _eventRepo.Remove(renameFromEvent);
+ break;
+
+ case ChangeType.RENAMED when renameFromEvent?.ChangeType == ChangeType.RENAMED:
+ newEvent.OldFullPath = renameFromEvent.OldFullPath;
+ _eventRepo.AddOrUpdate(newEvent);
+
+ // Remove data about the RENAMED file
+ _eventRepo.Remove(renameFromEvent);
+ break;
+
+ // the LOG event is not coming from the filesystem, hence it is ignored.
+ // ideally, LOG would disappear completely but unfortunately it is part of the public API of this lib
+ case ChangeType.LOG:
+ // ignore
+ break;
+
+ default:
+ _eventRepo.AddOrUpdate(newEvent);
+ break;
+ }
+ }
+ }
+
+ // This algorithm will remove all DELETE events up to the root folder
+ // that got deleted if any. This ensures that we are not producing
+ // DELETE events for each file inside a folder that gets deleted.
+ //
+ // 1.) split ADD/CHANGE and DELETED events
+ // 2.) sort short deleted paths to the top
+ // 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case
+ internal static IEnumerable FilterDeleted(IEnumerable eventsWithoutDuplicates)
+ {
+ // Handle deletes
+ var deletedPaths = new List();
+ return eventsWithoutDuplicates
+ .Select((e, n) => new KeyValuePair(n, e)) // store original position value
+ .OrderBy(e => e.Value.FullPath.Length) // shortest path first
+ .Where(e => IsParent(e.Value, deletedPaths))
+ .OrderBy(e => e.Key) // restore original position
+ .Select(e => e.Value);
+ }
+
+ internal static bool IsParent(FileChangedEvent e, List deletedPaths)
+ {
+ if (e.ChangeType == ChangeType.DELETED)
+ {
+ if (deletedPaths.Any(d => IsParent(e.FullPath, d)))
+ {
+ return false; // DELETE is ignored if parent is deleted already
+ }
+
+ // otherwise mark as deleted
+ deletedPaths.Add(e.FullPath);
+ }
+
+ return true;
+ }
+
+
+ internal static bool IsParent(string path, string candidatePath)
+ {
+ // if exists, remove trailing "\" for both paths
+ candidatePath = candidatePath.TrimEnd('\\');
+ path = path.TrimEnd('\\');
+ return path.StartsWith(candidatePath + '\\', StringComparison.Ordinal);
+ }
+
+
+ private class FileEventRepository
+ {
+ private readonly Dictionary _mapPathToEvents = [];
+
+ public void AddOrUpdate(FileChangedEvent newEvent)
+ {
+ if (_mapPathToEvents.TryGetValue(newEvent.FullPath, out var oldEvent))
+ {
+ // update existing
+ oldEvent.ChangeType = newEvent.ChangeType;
+ oldEvent.OldFullPath = newEvent.OldFullPath;
+ }
+ else
+ {
+ // add
+ _mapPathToEvents[newEvent.FullPath] = newEvent;
+ }
+ }
+
+ public void Remove(FileChangedEvent ev)
+ {
+ _mapPathToEvents.Remove(ev.FullPath);
+ }
+
+ public FileChangedEvent? Find(string? path)
+ {
+ _mapPathToEvents.TryGetValue(path ?? "", out var oldEvent);
+ return oldEvent;
+ }
+
+ public List Events()
+ {
+ return [.. _mapPathToEvents.Values];
+ }
+ }
+}
diff --git a/Source/D2Phap.FileWatcherEx/Helpers/EventProcessor.cs b/Source/D2Phap.FileWatcherEx/Helpers/EventProcessor.cs
new file mode 100644
index 0000000..e661f29
--- /dev/null
+++ b/Source/D2Phap.FileWatcherEx/Helpers/EventProcessor.cs
@@ -0,0 +1,101 @@
+/*---------------------------------------------------------
+ * Copyright (C) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------*/
+
+namespace D2Phap.FileWatcherEx.Helpers;
+
+internal class EventProcessor(Action onEvent, Action onLogging)
+{
+ ///
+ /// Aggregate and only emit events when changes have stopped for this duration (in ms)
+ ///
+ private const int EventDelay = 50;
+
+ ///
+ /// Warn after certain time span of event spam
+ ///
+ private readonly TimeSpan _eventSpamWarningThreshold = TimeSpan.FromMinutes(1);
+
+#if NET9_0_OR_GREATER
+ private readonly Lock _lock = new();
+#else
+ private readonly object _lock = new();
+#endif
+ private Task? _delayTask = null;
+
+ private readonly List _events = [];
+ private readonly Action _handleEvent = onEvent;
+ private readonly Action _logger = onLogging;
+
+ private long _lastEventTime = 0;
+ private long _delayStarted = 0;
+
+ private long _spamCheckStartTime = 0;
+ private bool _spamWarningLogged = false;
+
+
+ public void ProcessEvent(FileChangedEvent fileEvent)
+ {
+ lock (_lock)
+ {
+ var now = DateTime.Now.Ticks;
+ WarnForSpam(fileEvent, now);
+
+ // Add into our queue
+ _events.Add(fileEvent);
+ _lastEventTime = now;
+
+ // Process queue after delay
+ if (_delayTask == null)
+ {
+ // Start function after delay
+ _delayStarted = _lastEventTime;
+ _delayTask = Task.Delay(EventDelay).ContinueWith(HandleEventsFunc);
+ }
+ }
+ }
+
+ private void HandleEventsFunc(Task _)
+ {
+ lock (_lock)
+ {
+ // Check if another event has been received in the meantime
+ if (_delayStarted == _lastEventTime)
+ {
+ // Normalize and handle
+ var normalized = new EventNormalizer().Normalize([.. _events]);
+ foreach (var ev in normalized)
+ {
+ _handleEvent(ev);
+ }
+
+ // Reset
+ _events.Clear();
+ _delayTask = null;
+ }
+
+ // Otherwise we have received a new event while this task was
+ // delayed and we reschedule it.
+ else
+ {
+ _delayStarted = _lastEventTime;
+ _delayTask = Task.Delay(EventDelay).ContinueWith(HandleEventsFunc);
+ }
+ }
+ }
+
+ private void WarnForSpam(FileChangedEvent fileEvent, long now)
+ {
+ if (_events.Count == 0)
+ {
+ _spamWarningLogged = false;
+ _spamCheckStartTime = now;
+ }
+ else if (!_spamWarningLogged && _spamCheckStartTime + _eventSpamWarningThreshold.Ticks < now)
+ {
+ _spamWarningLogged = true;
+ _logger($"Warning: Watcher is busy catching up with {_events.Count} file changes " +
+ $"in {_eventSpamWarningThreshold.TotalSeconds} seconds. Latest path is '{fileEvent.FullPath}'");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/D2Phap.FileWatcherEx/Helpers/FileSystemWatcherWrapper.cs b/Source/D2Phap.FileWatcherEx/Helpers/FileSystemWatcherWrapper.cs
new file mode 100644
index 0000000..5fa8b82
--- /dev/null
+++ b/Source/D2Phap.FileWatcherEx/Helpers/FileSystemWatcherWrapper.cs
@@ -0,0 +1,39 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+
+namespace D2Phap.FileWatcherEx.Helpers;
+
+///
+/// Interface around .NET FileSystemWatcher to be able to replace it with a fake implementation
+///
+public interface IFileSystemWatcherWrapper
+{
+ string Path { get; set; }
+
+ Collection Filters { get; }
+ bool IncludeSubdirectories { get; set; }
+ bool EnableRaisingEvents { get; set; }
+ NotifyFilters NotifyFilter { get; set; }
+
+ event FileSystemEventHandler Created;
+ event FileSystemEventHandler Deleted;
+ event FileSystemEventHandler Changed;
+ event RenamedEventHandler Renamed;
+ event ErrorEventHandler Error;
+
+ int InternalBufferSize { get; set; }
+
+ public ISynchronizeInvoke? SynchronizingObject { get; set; }
+
+ void Dispose();
+}
+
+///
+/// Production implementation of IFileSystemWrapper interface.
+/// Backed by the existing FileSystemWatcher
+///
+public class FileSystemWatcherWrapper : FileSystemWatcher, IFileSystemWatcherWrapper
+{
+ // intentionally empty
+}
+
diff --git a/Source/D2Phap.FileWatcherEx/Helpers/SymlinkAwareFileWatcher.cs b/Source/D2Phap.FileWatcherEx/Helpers/SymlinkAwareFileWatcher.cs
new file mode 100644
index 0000000..033bdec
--- /dev/null
+++ b/Source/D2Phap.FileWatcherEx/Helpers/SymlinkAwareFileWatcher.cs
@@ -0,0 +1,231 @@
+/*---------------------------------------------------------
+ * Copyright (C) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------*/
+
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+
+namespace D2Phap.FileWatcherEx.Helpers;
+
+
+///
+/// Create new instance of .
+/// Object creation follows this order:
+///
+/// - 1) create new instance
+/// - 2) set properties (optional)
+/// - 3) call (mandatory)
+///
+///
+/// Full folder path to watcher
+/// onEvent callback
+/// onError callback
+/// how to create a FileSystemWatcher
+/// logging callback
+internal class SymlinkAwareFileWatcher(string path,
+ Action onEvent,
+ Action onError,
+ Func watcherFactory,
+ Action logger) : IDisposable
+{
+ private readonly string _watchPath = path;
+ private readonly Action? _eventCallback = onEvent;
+ private readonly Action? _onError = onError;
+ private Func? _getFileAttributesFunc;
+ private Func? _getDirectoryInfosFunc;
+ private readonly Func _watcherFactory = watcherFactory;
+ private readonly Action _logger = logger;
+
+
+ internal Func GetFileAttributesFunc
+ {
+ get => _getFileAttributesFunc ?? File.GetAttributes;
+ set => _getFileAttributesFunc = value;
+ }
+
+ internal Func GetDirectoryInfosFunc
+ {
+ get
+ {
+ static DirectoryInfo[] DefaultFunc(string p) => new DirectoryInfo(p).GetDirectories();
+ return _getDirectoryInfosFunc ?? DefaultFunc;
+ }
+ set => _getDirectoryInfosFunc = value;
+ }
+
+ internal Dictionary FileWatchers { get; } = [];
+
+ // defaults from:
+ // https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher.notifyfilter?view=net-7.0#property-value
+ public NotifyFilters NotifyFilter { get; set; } = NotifyFilters.LastWrite
+ | NotifyFilters.FileName
+ | NotifyFilters.DirectoryName;
+ public bool EnableRaisingEvents { get; set; }
+
+ public bool IncludeSubdirectories { get; set; }
+
+ public Collection Filters { get; } = [];
+
+ public ISynchronizeInvoke? SynchronizingObject { get; set; }
+
+
+ public void Init()
+ {
+ RegisterFileWatcher(_watchPath);
+ RegisterAdditionalFileWatchersForSymLinkDirs(_watchPath);
+ }
+
+ private void RegisterFileWatcher(string path)
+ {
+ _logger($"Registering file watcher for {path}");
+ var fileWatcher = _watcherFactory();
+ SetFileWatcherProperties(fileWatcher, path);
+ RegisterFileWatcherEventHandlers(fileWatcher);
+
+ FileWatchers.Add(path, fileWatcher);
+ }
+
+ private void SetFileWatcherProperties(IFileSystemWatcherWrapper fileWatcher, string path)
+ {
+ fileWatcher.Path = path;
+ fileWatcher.NotifyFilter = NotifyFilter;
+ fileWatcher.IncludeSubdirectories = IncludeSubdirectories;
+ fileWatcher.EnableRaisingEvents = EnableRaisingEvents;
+ Filters.ToList().ForEach(fileWatcher.Filters.Add);
+
+ // currently the sync object is only registered for the root file watcher.
+ // this preserves the old behaviour
+ if (IsRootPath(path))
+ {
+ fileWatcher.SynchronizingObject = SynchronizingObject;
+ }
+
+ //changing this to a higher value can lead into issues when watching UNC drives
+ fileWatcher.InternalBufferSize = 32768;
+ }
+
+
+ private bool IsRootPath(string path)
+ {
+ return _watchPath == path;
+ }
+
+ private void RegisterFileWatcherEventHandlers(IFileSystemWatcherWrapper fileWatcher)
+ {
+ fileWatcher.Created += (_, e) => ProcessEvent(e, ChangeType.CREATED);
+ fileWatcher.Changed += (_, e) => ProcessEvent(e, ChangeType.CHANGED);
+ fileWatcher.Deleted += (_, e) => ProcessEvent(e, ChangeType.DELETED);
+ fileWatcher.Renamed += (_, e) => ProcessRenamedEvent(e);
+ fileWatcher.Error += (_, e) => _onError?.Invoke(e);
+
+ // extra measures to handle symbolic link directories
+ fileWatcher.Created += (_, e) => TryRegisterFileWatcherForSymbolicLinkDir(e.FullPath);
+ fileWatcher.Deleted += UnregisterFileWatcherForSymbolicLinkDir;
+ }
+
+ ///
+ /// Recursively find sym link dir and register them.
+ /// Background: the native filewatcher does not follow symlinks so they need to be treated separately.
+ ///
+ private void RegisterAdditionalFileWatchersForSymLinkDirs(string path)
+ {
+ TryRegisterFileWatcherForSymbolicLinkDir(path);
+
+ if (!IncludeSubdirectories || !Directory.Exists(path))
+ {
+ return;
+ }
+
+ foreach (var dirInfo in GetDirectoryInfosFunc(path))
+ {
+ RegisterAdditionalFileWatchersForSymLinkDirs(dirInfo.FullName);
+ }
+ }
+
+
+ ///
+ /// Process event for type = [CHANGED; DELETED; CREATED]
+ ///
+ private void ProcessEvent(FileSystemEventArgs e, ChangeType changeType)
+ {
+ _eventCallback?.Invoke(new()
+ {
+ ChangeType = changeType,
+ FullPath = e.FullPath,
+ });
+ }
+
+
+ private void ProcessRenamedEvent(RenamedEventArgs e)
+ {
+ _eventCallback?.Invoke(new()
+ {
+ ChangeType = ChangeType.RENAMED,
+ FullPath = e.FullPath,
+ OldFullPath = e.OldFullPath,
+ });
+ }
+
+
+ ///
+ /// Safely register a file watcher for a symbolic link directory. Used at startup as well as callback on file creation.
+ ///
+ ///
+ internal void TryRegisterFileWatcherForSymbolicLinkDir(string path)
+ {
+ try
+ {
+ if (IsSymbolicLinkDirectory(path) && IncludeSubdirectories && !FileWatchers.ContainsKey(path))
+ {
+ _logger($"Directory {path} is a symbolic link dir. Will register additional file watcher.");
+ RegisterFileWatcher(path);
+ }
+ }
+ catch (Exception ex)
+ {
+ // IG Issue #405: throws exception on Windows 10
+ // for "c:\users\user\application data" folder and sub-folders.
+ _logger($"Error registering file system watcher for directory '{path}'. Error was: {ex.Message}");
+ }
+ }
+
+
+ ///
+ /// Cleanup filewatcher if a symbolic link dir is deleted
+ ///
+ internal void UnregisterFileWatcherForSymbolicLinkDir(object? _, FileSystemEventArgs e)
+ {
+ if (FileWatchers.TryGetValue(e.FullPath, out IFileSystemWatcherWrapper? value))
+ {
+ value.Dispose();
+ FileWatchers.Remove(e.FullPath);
+ }
+ }
+
+
+ private bool IsSymbolicLinkDirectory(string path)
+ {
+ var attrs = GetFileAttributesFunc(path);
+ return attrs.HasFlag(FileAttributes.Directory)
+ && attrs.HasFlag(FileAttributes.ReparsePoint);
+ }
+
+ // for testing
+ internal List GetFileWatchers()
+ {
+ return [.. FileWatchers.Values];
+ }
+
+
+ ///
+ /// Stop raising events and Dispose all filewatchers
+ ///
+ public void Dispose()
+ {
+ foreach (var watcher in FileWatchers.Select(pair => pair.Value))
+ {
+ watcher.EnableRaisingEvents = false;
+ watcher.Dispose();
+ }
+ }
+}
diff --git a/Source/D2Phap.FileWatcherEx/IFileSystemWatcherEx.cs b/Source/D2Phap.FileWatcherEx/IFileSystemWatcherEx.cs
new file mode 100644
index 0000000..b794171
--- /dev/null
+++ b/Source/D2Phap.FileWatcherEx/IFileSystemWatcherEx.cs
@@ -0,0 +1,85 @@
+using System.ComponentModel;
+
+namespace D2Phap.FileWatcherEx;
+
+public interface IFileSystemWatcherEx
+{
+ ///
+ /// Gets or sets the path of the directory to watch.
+ ///
+ string FolderPath { get; set; }
+
+ ///
+ /// Gets the collection of all the filters used to determine what files are monitored in a directory.
+ ///
+ System.Collections.ObjectModel.Collection Filters { get; }
+
+ ///
+ /// Gets or sets the filter string used to determine what files are monitored in a directory.
+ ///
+ string Filter { get; set; }
+
+ ///
+ /// Gets or sets the type of changes to watch for.
+ /// The default is the bitwise OR combination of
+ /// ,
+ /// ,
+ /// and .
+ ///
+ NotifyFilters NotifyFilter { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether subdirectories within the specified path should be monitored.
+ ///
+ bool IncludeSubdirectories { get; set; }
+
+ ///
+ /// Gets or sets the object used to marshal the event handler calls issued as a result of a directory change.
+ ///
+ ISynchronizeInvoke? SynchronizingObject { get; set; }
+
+ ///
+ /// Occurs when a file or directory in the specified
+ /// is changed.
+ ///
+ event FileSystemWatcherEx.DelegateOnChanged? OnChanged;
+
+ ///
+ /// Occurs when a file or directory in the specified
+ /// is deleted.
+ ///
+ event FileSystemWatcherEx.DelegateOnDeleted? OnDeleted;
+
+ ///
+ /// Occurs when a file or directory in the specified
+ /// is created.
+ ///
+ event FileSystemWatcherEx.DelegateOnCreated? OnCreated;
+
+ ///
+ /// Occurs when a file or directory in the specified
+ /// is renamed.
+ ///
+ event FileSystemWatcherEx.DelegateOnRenamed? OnRenamed;
+
+ ///
+ /// Occurs when the instance of is unable to continue
+ /// monitoring changes or when the internal buffer overflows.
+ ///
+ event FileSystemWatcherEx.DelegateOnError? OnError;
+
+ ///
+ /// Start watching files
+ ///
+ void Start();
+
+ ///
+ /// Stop watching files
+ ///
+ void Stop();
+
+ ///
+ /// Dispose the FileWatcherEx instance
+ ///
+ void Dispose();
+}
diff --git a/Source/Demo/Demo.WinForms.csproj b/Source/Demo/Demo.WinForms.csproj
index 592a245..100d947 100644
--- a/Source/Demo/Demo.WinForms.csproj
+++ b/Source/Demo/Demo.WinForms.csproj
@@ -2,14 +2,14 @@
WinExe
- net6.0-windows
+ net10.0-windows
enable
true
enable
-
+
\ No newline at end of file
diff --git a/Source/Demo/Form1.Designer.cs b/Source/Demo/Form1.Designer.cs
index 797ed49..5046d93 100644
--- a/Source/Demo/Form1.Designer.cs
+++ b/Source/Demo/Form1.Designer.cs
@@ -74,7 +74,7 @@ private void InitializeComponent()
this.txtPath.Name = "txtPath";
this.txtPath.Size = new System.Drawing.Size(508, 57);
this.txtPath.TabIndex = 2;
- this.txtPath.Text = "C:\\Users\\d2pha\\Desktop\\New folder";
+ this.txtPath.Text = "C:\\";
//
// btnStart
//
diff --git a/Source/Demo/Form1.cs b/Source/Demo/Form1.cs
index bb5f772..5f1388b 100644
--- a/Source/Demo/Form1.cs
+++ b/Source/Demo/Form1.cs
@@ -1,111 +1,120 @@
+using D2Phap.FileWatcherEx;
-using FileWatcherEx;
+namespace Demo;
-namespace Demo
+public partial class Form1 : Form
{
- public partial class Form1 : Form
- {
- private FileSystemWatcherEx _fw = new();
+ private FileSystemWatcherEx _fw = new();
- public Form1()
- {
- InitializeComponent();
- }
+ public Form1()
+ {
+ InitializeComponent();
+ }
- private void BtnStart_Click(object sender, EventArgs e)
- {
- _fw = new FileSystemWatcherEx(txtPath.Text.Trim());
+ private void BtnStart_Click(object sender, EventArgs e)
+ {
+ _fw = new FileSystemWatcherEx(txtPath.Text.Trim(), FW_OnLog);
- _fw.OnRenamed += FW_OnRenamed;
- _fw.OnCreated += FW_OnCreated;
- _fw.OnDeleted += FW_OnDeleted;
- _fw.OnChanged += FW_OnChanged;
- _fw.OnError += FW_OnError;
+ _fw.OnRenamed += FW_OnRenamed;
+ _fw.OnCreated += FW_OnCreated;
+ _fw.OnDeleted += FW_OnDeleted;
+ _fw.OnChanged += FW_OnChanged;
+ _fw.OnError += FW_OnError;
- _fw.SynchronizingObject = this;
+ _fw.SynchronizingObject = this;
+ _fw.IncludeSubdirectories = true;
+ try
+ {
_fw.Start();
- btnStart.Enabled = false;
+ btnStart.Enabled = true;
btnSelectFolder.Enabled = false;
txtPath.Enabled = false;
btnStop.Enabled = true;
}
-
- private void FW_OnError(object? sender, ErrorEventArgs e)
+ catch (Exception ex)
{
- if (txtConsole.InvokeRequired)
- {
- txtConsole.Invoke(FW_OnError, sender, e);
- }
- else
- {
- txtConsole.Text += "[ERROR]: " + e.GetException().Message + "\r\n";
- }
- }
-
- private void FW_OnChanged(object? sender, FileChangedEvent e)
- {
- txtConsole.Text += string.Format("[cha] {0} | {1}",
- Enum.GetName(typeof(ChangeType), e.ChangeType),
- e.FullPath) + "\r\n";
+ MessageBox.Show(ex.Message);
}
+ }
- private void FW_OnDeleted(object? sender, FileChangedEvent e)
+ private void FW_OnError(object? sender, ErrorEventArgs e)
+ {
+ if (txtConsole.InvokeRequired)
{
- txtConsole.Text += string.Format("[del] {0} | {1}",
- Enum.GetName(typeof(ChangeType), e.ChangeType),
- e.FullPath) + "\r\n";
+ txtConsole.Invoke(FW_OnError, sender, e);
}
-
- private void FW_OnCreated(object? sender, FileChangedEvent e)
+ else
{
- txtConsole.Text += string.Format("[cre] {0} | {1}",
- Enum.GetName(typeof(ChangeType), e.ChangeType),
- e.FullPath) + "\r\n";
+ txtConsole.Text += "[ERROR]: " + e.GetException().Message + "\r\n";
}
+ }
- private void FW_OnRenamed(object? sender, FileChangedEvent e)
- {
- txtConsole.Text += string.Format("[ren] {0} | {1} ----> {2}",
- Enum.GetName(typeof(ChangeType), e.ChangeType),
- e.OldFullPath,
- e.FullPath) + "\r\n";
- }
+ private void FW_OnChanged(object? sender, FileChangedEvent e)
+ {
+ txtConsole.Text += string.Format("[cha] {0} | {1}",
+ Enum.GetName(e.ChangeType),
+ e.FullPath) + "\r\n";
+ }
+ private void FW_OnDeleted(object? sender, FileChangedEvent e)
+ {
+ txtConsole.Text += string.Format("[del] {0} | {1}",
+ Enum.GetName(e.ChangeType),
+ e.FullPath) + "\r\n";
+ }
+ private void FW_OnCreated(object? sender, FileChangedEvent e)
+ {
+ txtConsole.Text += string.Format("[cre] {0} | {1}",
+ Enum.GetName(e.ChangeType),
+ e.FullPath) + "\r\n";
+ }
- private void BtnStop_Click(object sender, EventArgs e)
- {
- _fw.Stop();
+ private void FW_OnRenamed(object? sender, FileChangedEvent e)
+ {
+ txtConsole.Text += string.Format("[ren] {0} | {1} ----> {2}",
+ Enum.GetName(e.ChangeType),
+ e.OldFullPath,
+ e.FullPath) + "\r\n";
+ }
- btnStart.Enabled = true;
- btnSelectFolder.Enabled = true;
- txtPath.Enabled = true;
- btnStop.Enabled = false;
- }
+ private void FW_OnLog(string value)
+ {
+ txtConsole.Text += $@"[log] {value}" + "\r\n";
+ }
+ private void BtnStop_Click(object sender, EventArgs e)
+ {
+ _fw.Stop();
- private void BtnSelectFolder_Click(object sender, EventArgs e)
- {
- var fb = new FolderBrowserDialog();
+ btnStart.Enabled = true;
+ btnSelectFolder.Enabled = true;
+ txtPath.Enabled = true;
+ btnStop.Enabled = false;
+ btnStop.Enabled = true;
+ }
- if (fb.ShowDialog() == DialogResult.OK)
- {
- txtPath.Text = fb.SelectedPath;
+ private void BtnSelectFolder_Click(object sender, EventArgs e)
+ {
+ var fb = new FolderBrowserDialog();
- _fw.Stop();
- _fw.Dispose();
- }
- }
- private void Form1_FormClosing(object sender, FormClosingEventArgs e)
+ if (fb.ShowDialog() == DialogResult.OK)
{
+ txtPath.Text = fb.SelectedPath;
+
_fw.Stop();
_fw.Dispose();
}
}
+
+ private void Form1_FormClosing(object sender, FormClosingEventArgs e)
+ {
+ _fw.Dispose();
+ }
}
\ No newline at end of file
diff --git a/Source/Demo/README.md b/Source/Demo/README.md
new file mode 100644
index 0000000..bff6a63
--- /dev/null
+++ b/Source/Demo/README.md
@@ -0,0 +1,9 @@
+Manual Testing
+--------------
+
+This little application is helpful for interactive testing of the library.
+
+Symlinks
+--------
+To create a symlink directory on Windows, use `mklink`.
+Example: `mklink /D my-symbolic-link c:\temp\target-directory`
diff --git a/Source/FileSystemEventRecorder/FileSystemEventRecorder.cs b/Source/FileSystemEventRecorder/FileSystemEventRecorder.cs
new file mode 100644
index 0000000..be5fe3e
--- /dev/null
+++ b/Source/FileSystemEventRecorder/FileSystemEventRecorder.cs
@@ -0,0 +1,150 @@
+using CsvHelper;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Globalization;
+
+namespace FileSystemEventRecorder;
+
+// event received from C# FileSystemWatcher
+internal record EventRecord(
+ string FullPath,
+ string EventName,
+ string? OldFullPath, // only provided by "rename" event
+ long NowInTicks
+);
+
+// post processed. Calculated before closing program.
+// data is written to CSV already in the format FileSystemEventArgs requires it (separate dir + filename)
+internal record EventRecordWithDiff(
+ string Directory,
+ string FileName,
+ string EventName,
+ string? OldFileName,
+ long DiffInTicks, // ticks between passed by from the previous event to now
+ double DiffInMilliseconds // milliseconds between previous event and now.
+);
+
+///
+/// Command line tool to capture the raw events of the native FileSystemWatcher class in a CSV file
+///
+public static class FileSystemEventRecords
+{
+ private static readonly ConcurrentQueue EventRecords = new();
+
+ public static void Main(string[] args)
+ {
+ var (watchedDirectory, csvOutputFile) = ProcessArguments(args);
+
+ var watcher = new FileSystemWatcher
+ {
+ Path = watchedDirectory,
+ IncludeSubdirectories = true,
+ NotifyFilter = NotifyFilters.LastWrite
+ | NotifyFilters.FileName
+ | NotifyFilters.DirectoryName
+ };
+
+ watcher.Created += (_, ev) =>
+ EventRecords.Enqueue(new EventRecord(ev.FullPath, "created", null, Stopwatch.GetTimestamp()));
+ watcher.Deleted += (_, ev) =>
+ EventRecords.Enqueue(new EventRecord(ev.FullPath, "deleted", null, Stopwatch.GetTimestamp()));
+
+ watcher.Changed += (_, ev) =>
+ EventRecords.Enqueue(new EventRecord(ev.FullPath, "changed", null, Stopwatch.GetTimestamp()));
+ watcher.Renamed += (_, ev) =>
+ EventRecords.Enqueue(new EventRecord(ev.FullPath, "renamed", ev.OldFullPath, Stopwatch.GetTimestamp()));
+ watcher.Error += (_, ev) =>
+ {
+ EventRecords.Enqueue(new EventRecord("", "error", null, Stopwatch.GetTimestamp()));
+ Console.WriteLine($"Error: {ev.GetException()}");
+ };
+
+ // taken from existing code
+ watcher.InternalBufferSize = 32768;
+ watcher.EnableRaisingEvents = true;
+
+ Console.WriteLine($"Recording. Now go ahead and perform the desired file system activities in {watchedDirectory}. " +
+ "Press CTRL + C to stop the recording.");
+ Console.CancelKeyPress += (_, _) =>
+ {
+ Console.WriteLine("Exiting.");
+ ProcessQueueAndWriteToDisk(csvOutputFile);
+ Environment.Exit(0);
+ };
+
+ while (true)
+ {
+ Thread.Sleep(200);
+ }
+ }
+
+ private static (string, string) ProcessArguments(string[] args)
+ {
+ if (args.Length < 2)
+ {
+ Console.WriteLine("Usage: dotnet run [directory to be watched] [output csv file]");
+ Environment.Exit(1);
+ }
+
+ return (args[0], args[1]);
+ }
+
+ private static void ProcessQueueAndWriteToDisk(string csvOutputFile)
+ {
+ if (EventRecords.IsEmpty)
+ {
+ Console.WriteLine("Detected no file system events. Nothing is written.");
+ }
+ else
+ {
+ Console.WriteLine($"Recorded {EventRecords.Count} file system events.");
+ var records = MapToDiffTicks;
+
+ Console.WriteLine($"Writing CSV to {csvOutputFile}.");
+ using (var writer = new StreamWriter(csvOutputFile))
+ using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
+ {
+ csv.WriteRecords(records);
+ }
+
+ Console.WriteLine("Done.");
+ }
+ }
+
+ // post-process queue. Calculate difference between previous and current event
+ private static IEnumerable MapToDiffTicks
+ {
+ get
+ {
+ List eventsWithDiffs = [];
+ long previousTicks = 0;
+
+ foreach (var eventRecord in EventRecords)
+ {
+ var diff = previousTicks switch
+ {
+ 0 => 0, // first run
+ _ => eventRecord.NowInTicks - previousTicks
+ };
+
+ previousTicks = eventRecord.NowInTicks;
+ double diffInMilliseconds = Convert.ToInt64(new TimeSpan(diff).TotalMilliseconds);
+
+ var directory = Path.GetDirectoryName(eventRecord.FullPath) ?? "";
+ var fileName = Path.GetFileName(eventRecord.FullPath);
+ var oldFileName = Path.GetFileName(eventRecord.OldFullPath);
+
+ var record = new EventRecordWithDiff(
+ directory,
+ fileName,
+ eventRecord.EventName,
+ oldFileName,
+ diff,
+ diffInMilliseconds);
+ eventsWithDiffs.Add(record);
+ }
+
+ return eventsWithDiffs;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/FileSystemEventRecorder/FileSystemEventRecorder.csproj b/Source/FileSystemEventRecorder/FileSystemEventRecorder.csproj
new file mode 100644
index 0000000..9e11035
--- /dev/null
+++ b/Source/FileSystemEventRecorder/FileSystemEventRecorder.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/FileSystemEventRecorder/README.md b/Source/FileSystemEventRecorder/README.md
new file mode 100644
index 0000000..80b2f40
--- /dev/null
+++ b/Source/FileSystemEventRecorder/README.md
@@ -0,0 +1,17 @@
+# File System Event Recorder
+
+Command line tool to capture the raw events of [FileSystemWatcher](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher)
+in a CSV file. The CSV file can than be used to write integration tests against *FileWatcherEx*.
+
+Usage:
+````sh
+dotnet run C:\temp\fwtest\ C:\temp\fwevents.csv
+````
+
+Example output:
+````csv
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
+C:\temp\fwtest,b.txt,renamed,a.txt,1265338,127
+C:\temp\fwtest,b.txt,deleted,,6660690,666
+````
\ No newline at end of file
diff --git a/Source/FileWatcherEx.sln b/Source/FileWatcherEx.sln
deleted file mode 100644
index 42f195a..0000000
--- a/Source/FileWatcherEx.sln
+++ /dev/null
@@ -1,37 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.1.31911.260
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileWatcherEx", "FileWatcherEx\FileWatcherEx.csproj", "{CF81727D-B6EC-4202-8E78-087C5D5EABF3}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo.WinForms", "Demo\Demo.WinForms.csproj", "{F973F462-7769-433C-AEC1-17AF2902ED2D}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileWatcherExTests", "FileWatcherExTests\FileWatcherExTests.csproj", "{1C0CA67C-369E-4258-B661-2C545B50A6FF}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Release|Any CPU.Build.0 = Release|Any CPU
- {F973F462-7769-433C-AEC1-17AF2902ED2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {F973F462-7769-433C-AEC1-17AF2902ED2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {F973F462-7769-433C-AEC1-17AF2902ED2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {F973F462-7769-433C-AEC1-17AF2902ED2D}.Release|Any CPU.Build.0 = Release|Any CPU
- {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {7A615E75-F056-422F-952E-9C0CD34E477B}
- EndGlobalSection
-EndGlobal
diff --git a/Source/FileWatcherEx.slnx b/Source/FileWatcherEx.slnx
new file mode 100644
index 0000000..868af94
--- /dev/null
+++ b/Source/FileWatcherEx.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/Source/FileWatcherEx/Helpers/EventProcessor.cs b/Source/FileWatcherEx/Helpers/EventProcessor.cs
deleted file mode 100644
index 2836f7a..0000000
--- a/Source/FileWatcherEx/Helpers/EventProcessor.cs
+++ /dev/null
@@ -1,236 +0,0 @@
-/*---------------------------------------------------------
- * Copyright (C) Microsoft Corporation. All rights reserved.
- *--------------------------------------------------------*/
-
-namespace FileWatcherEx;
-
-internal class EventProcessor
-{
-
- ///
- /// Aggregate and only emit events when changes have stopped for this duration (in ms)
- ///
- private static readonly int EVENT_DELAY = 50;
-
- ///
- /// Warn after certain time span of event spam (in ticks)
- ///
- private static readonly int EVENT_SPAM_WARNING_THRESHOLD = 60 * 1000 * 10000;
-
- private readonly System.Object LOCK = new();
- private Task? delayTask = null;
-
- private readonly List events = new();
- private readonly Action handleEvent;
-
- private readonly Action logger;
-
- private long lastEventTime = 0;
- private long delayStarted = 0;
-
- private long spamCheckStartTime = 0;
- private bool spamWarningLogged = false;
-
-
- internal static IEnumerable NormalizeEvents(FileChangedEvent[] events)
- {
- var mapPathToEvents = new Dictionary();
- var eventsWithoutDuplicates = new List();
-
- // Normalize duplicates
- foreach (var newEvent in events)
- {
- mapPathToEvents.TryGetValue(newEvent.FullPath, out var oldEvent); // Try get event from newEvent.FullPath
-
- if (oldEvent != null && oldEvent.ChangeType == ChangeType.CREATED && newEvent.ChangeType == ChangeType.DELETED)
- { // CREATED + DELETED => remove
- mapPathToEvents.Remove(oldEvent.FullPath);
- eventsWithoutDuplicates.Remove(oldEvent);
- }
- else
- if (oldEvent != null && oldEvent.ChangeType == ChangeType.DELETED && newEvent.ChangeType == ChangeType.CREATED)
- { // DELETED + CREATED => CHANGED
- oldEvent.ChangeType = ChangeType.CHANGED;
- }
- else
- if (oldEvent != null && oldEvent.ChangeType == ChangeType.CREATED && newEvent.ChangeType == ChangeType.CHANGED)
- { // CREATED + CHANGED => CREATED
- // Do nothing
- }
- else
- { // Otherwise
-
- if (newEvent.ChangeType == ChangeType.RENAMED)
- { // If + RENAMED
- do
- {
- mapPathToEvents.TryGetValue(newEvent.OldFullPath!, out var renameFromEvent); // Try get event from newEvent.OldFullPath
-
- if (renameFromEvent != null && renameFromEvent.ChangeType == ChangeType.CREATED)
- { // If rename from CREATED file
- // Remove data about the CREATED file
- mapPathToEvents.Remove(renameFromEvent.FullPath);
- eventsWithoutDuplicates.Remove(renameFromEvent);
- // Handle new event as CREATED
- newEvent.ChangeType = ChangeType.CREATED;
- newEvent.OldFullPath = null;
-
- if (oldEvent != null && oldEvent.ChangeType == ChangeType.DELETED)
- { // DELETED + CREATED => CHANGED
- newEvent.ChangeType = ChangeType.CHANGED;
- }
- }
- else
- if (renameFromEvent != null && renameFromEvent.ChangeType == ChangeType.RENAMED)
- { // If rename from RENAMED file
- // Remove data about the RENAMED file
- mapPathToEvents.Remove(renameFromEvent.FullPath);
- eventsWithoutDuplicates.Remove(renameFromEvent);
- // Change OldFullPath
- newEvent.OldFullPath = renameFromEvent.OldFullPath;
- // Check again
- continue;
- }
- else
- { // Otherwise
- // Do nothing
- //mapPathToEvents.TryGetValue(newEvent.OldFullPath, out oldEvent); // Try get event from newEvent.OldFullPath
- }
- } while (false);
- }
-
- if (oldEvent != null)
- { // If old event exists
- // Replace old event data with data from the new event
- oldEvent.ChangeType = newEvent.ChangeType;
- oldEvent.OldFullPath = newEvent.OldFullPath;
- }
- else
- { // If old event is not exist
- // Add new event
- mapPathToEvents.Add(newEvent.FullPath, newEvent);
- eventsWithoutDuplicates.Add(newEvent);
- }
- }
- }
-
-
- return FilterDeleted(eventsWithoutDuplicates);
- }
-
- // This algorithm will remove all DELETE events up to the root folder
- // that got deleted if any. This ensures that we are not producing
- // DELETE events for each file inside a folder that gets deleted.
- //
- // 1.) split ADD/CHANGE and DELETED events
- // 2.) sort short deleted paths to the top
- // 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case
- internal static IEnumerable FilterDeleted(IEnumerable eventsWithoutDuplicates)
- {
- // Handle deletes
- var deletedPaths = new List();
- return eventsWithoutDuplicates
- .Select((e, n) => new KeyValuePair(n, e)) // store original position value
- .OrderBy(e => e.Value.FullPath.Length) // shortest path first
- .Where(e => IsParent(e.Value, deletedPaths))
- .OrderBy(e => e.Key) // restore original position
- .Select(e => e.Value);
- }
-
- internal static bool IsParent(FileChangedEvent e, List deletedPaths)
- {
- if (e.ChangeType == ChangeType.DELETED)
- {
- if (deletedPaths.Any(d => IsParent(e.FullPath, d)))
- {
- return false; // DELETE is ignored if parent is deleted already
- }
-
- // otherwise mark as deleted
- deletedPaths.Add(e.FullPath);
- }
-
- return true;
- }
-
-
- internal static bool IsParent(string p, string candidate)
- {
- return p.IndexOf(candidate + '\\') == 0;
- }
-
-
-
-
- public EventProcessor(Action onEvent, Action onLogging)
- {
- handleEvent = onEvent;
- logger = onLogging;
- }
-
-
- public void ProcessEvent(FileChangedEvent fileEvent)
- {
- lock (LOCK)
- {
- var now = DateTime.Now.Ticks;
-
- // Check for spam
- if (events.Count == 0)
- {
- spamWarningLogged = false;
- spamCheckStartTime = now;
- }
- else if (!spamWarningLogged && spamCheckStartTime + EVENT_SPAM_WARNING_THRESHOLD < now)
- {
- spamWarningLogged = true;
- logger(string.Format("Warning: Watcher is busy catching up with {0} file changes in 60 seconds. Latest path is '{1}'", events.Count, fileEvent.FullPath));
- }
-
- // Add into our queue
- events.Add(fileEvent);
- lastEventTime = now;
-
- // Process queue after delay
- if (delayTask == null)
- {
- // Create function to buffer events
- void func(Task value)
- {
- lock (LOCK)
- {
- // Check if another event has been received in the meantime
- if (delayStarted == lastEventTime)
- {
- // Normalize and handle
- var normalized = NormalizeEvents(events.ToArray());
- foreach (var e in normalized)
- {
- handleEvent(e);
- }
-
- // Reset
- events.Clear();
- delayTask = null;
- }
-
- // Otherwise we have received a new event while this task was
- // delayed and we reschedule it.
- else
- {
- delayStarted = lastEventTime;
- delayTask = Task.Delay(EVENT_DELAY).ContinueWith(func);
- }
- }
- }
-
- // Start function after delay
- delayStarted = lastEventTime;
- delayTask = Task.Delay(EVENT_DELAY).ContinueWith(func);
- }
- }
- }
-
-
-}
-
diff --git a/Source/FileWatcherEx/Helpers/FileWatcher.cs b/Source/FileWatcherEx/Helpers/FileWatcher.cs
deleted file mode 100644
index a632eec..0000000
--- a/Source/FileWatcherEx/Helpers/FileWatcher.cs
+++ /dev/null
@@ -1,224 +0,0 @@
-/*---------------------------------------------------------
- * Copyright (C) Microsoft Corporation. All rights reserved.
- *--------------------------------------------------------*/
-
-namespace FileWatcherEx;
-
-
-internal class FileWatcher : IDisposable
-{
- private string _watchPath = string.Empty;
- private Action? _eventCallback = null;
- private readonly Dictionary _fwDictionary = new();
- private Action? _onError = null;
-
-
- ///
- /// Create new instance of FileSystemWatcher
- ///
- /// Full folder path to watcher
- /// onEvent callback
- /// onError callback
- ///
- public FileSystemWatcher Create(string path, Action onEvent, Action onError)
- {
- _watchPath = path;
- _eventCallback = onEvent;
- _onError = onError;
-
- var watcher = new FileSystemWatcher
- {
- Path = _watchPath,
- IncludeSubdirectories = true,
- NotifyFilter = NotifyFilters.LastWrite
- | NotifyFilters.FileName
- | NotifyFilters.DirectoryName,
- };
-
- // Bind internal events to manipulate the possible symbolic links
- watcher.Created += new(MakeWatcher_Created);
- watcher.Deleted += new(MakeWatcher_Deleted);
-
- watcher.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED));
- watcher.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED));
- watcher.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED));
- watcher.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e));
- watcher.Error += new ErrorEventHandler((object source, ErrorEventArgs e) => onError(e));
-
- //changing this to a higher value can lead into issues when watching UNC drives
- watcher.InternalBufferSize = 32768;
- _fwDictionary.Add(path, watcher);
-
- foreach (var dirInfo in new DirectoryInfo(path).GetDirectories())
- {
- var attrs = File.GetAttributes(dirInfo.FullName);
-
- // TODO: consider skipping hidden/system folders?
- // See IG Issue #405 comment below
- // https://github.com/d2phap/ImageGlass/issues/405
- if (attrs.HasFlag(FileAttributes.Directory)
- && attrs.HasFlag(FileAttributes.ReparsePoint))
- {
- try
- {
- MakeWatcher(dirInfo.FullName);
- }
- catch
- {
- // IG Issue #405: throws exception on Windows 10
- // for "c:\users\user\application data" folder and sub-folders.
- }
- }
- }
-
- return watcher;
- }
-
-
- ///
- /// Process event for type = [CHANGED; DELETED; CREATED]
- ///
- ///
- ///
- private void ProcessEvent(FileSystemEventArgs e, ChangeType changeType)
- {
- _eventCallback?.Invoke(new()
- {
- ChangeType = changeType,
- FullPath = e.FullPath,
- });
- }
-
-
- ///
- /// Process event for type = RENAMED
- ///
- ///
- private void ProcessEvent(RenamedEventArgs e)
- {
- _eventCallback?.Invoke(new()
- {
- ChangeType = ChangeType.RENAMED,
- FullPath = e.FullPath,
- OldFullPath = e.OldFullPath,
- });
- }
-
-
- private void MakeWatcher(string path)
- {
- if (!_fwDictionary.ContainsKey(path))
- {
- var fileSystemWatcherRoot = new FileSystemWatcher
- {
- Path = path,
- IncludeSubdirectories = true,
- EnableRaisingEvents = true
- };
-
- // Bind internal events to manipulate the possible symbolic links
- fileSystemWatcherRoot.Created += new(MakeWatcher_Created);
- fileSystemWatcherRoot.Deleted += new(MakeWatcher_Deleted);
-
- fileSystemWatcherRoot.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED));
- fileSystemWatcherRoot.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED));
- fileSystemWatcherRoot.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED));
- fileSystemWatcherRoot.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e));
- fileSystemWatcherRoot.Error += new((object _, ErrorEventArgs e) => _onError?.Invoke(e));
-
- _fwDictionary.Add(path, fileSystemWatcherRoot);
- }
-
- foreach (var item in new DirectoryInfo(path).GetDirectories())
- {
- var attrs = File.GetAttributes(item.FullName);
-
- // If is a directory and symbolic link
- if (attrs.HasFlag(FileAttributes.Directory)
- && attrs.HasFlag(FileAttributes.ReparsePoint))
- {
- if (!_fwDictionary.ContainsKey(item.FullName))
- {
- var fswItem = new FileSystemWatcher
- {
- Path = item.FullName,
- IncludeSubdirectories = true,
- EnableRaisingEvents = true,
- };
-
- // Bind internal events to manipulate the possible symbolic links
- fswItem.Created += new(MakeWatcher_Created);
- fswItem.Deleted += new(MakeWatcher_Deleted);
-
- fswItem.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED));
- fswItem.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED));
- fswItem.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED));
- fswItem.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e));
- fswItem.Error += new((object _, ErrorEventArgs e) => _onError?.Invoke(e));
-
- _fwDictionary.Add(item.FullName, fswItem);
- }
-
- MakeWatcher(item.FullName);
- }
- }
- }
-
-
- private void MakeWatcher_Created(object sender, FileSystemEventArgs e)
- {
- try
- {
- var attrs = File.GetAttributes(e.FullPath);
- if (attrs.HasFlag(FileAttributes.Directory)
- && attrs.HasFlag(FileAttributes.ReparsePoint))
- {
- var watcherCreated = new FileSystemWatcher
- {
- Path = e.FullPath,
- IncludeSubdirectories = true,
- EnableRaisingEvents = true
- };
-
- // Bind internal events to manipulate the possible symbolic links
- watcherCreated.Created += new(MakeWatcher_Created);
- watcherCreated.Deleted += new(MakeWatcher_Deleted);
-
- watcherCreated.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED));
- watcherCreated.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED));
- watcherCreated.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED));
- watcherCreated.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e));
- watcherCreated.Error += new((object _, ErrorEventArgs e) => _onError?.Invoke(e));
-
- _fwDictionary.Add(e.FullPath, watcherCreated);
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("<>: " + ex.Message);
- }
- }
-
-
- private void MakeWatcher_Deleted(object sender, FileSystemEventArgs e)
- {
- // If object removed, then I will dispose and remove them from dictionary
- if (_fwDictionary.ContainsKey(e.FullPath))
- {
- _fwDictionary[e.FullPath].Dispose();
- _fwDictionary.Remove(e.FullPath);
- }
- }
-
-
- ///
- /// Dispose the instance
- ///
- public void Dispose()
- {
- foreach (var item in _fwDictionary)
- {
- item.Value.Dispose();
- }
- }
-}
diff --git a/Source/FileWatcherExTests/EventProcessorTest.cs b/Source/FileWatcherExTests/EventNormalizerTest.cs
similarity index 81%
rename from Source/FileWatcherExTests/EventProcessorTest.cs
rename to Source/FileWatcherExTests/EventNormalizerTest.cs
index 73999dc..3a0b97d 100644
--- a/Source/FileWatcherExTests/EventProcessorTest.cs
+++ b/Source/FileWatcherExTests/EventNormalizerTest.cs
@@ -1,9 +1,10 @@
+using D2Phap.FileWatcherEx;
+using D2Phap.FileWatcherEx.Helpers;
using Xunit;
-using FileWatcherEx;
namespace FileWatcherExTests;
-public class EventProcessorTest
+public class EventNormalizerTest
{
[Fact]
public void No_Input_Gives_No_Output()
@@ -142,7 +143,7 @@ public void Rename_After_Create_Gives_Changed_Event()
Assert.Null(ev.OldFullPath);
}
-[Fact]
+ [Fact]
public void Result_Suppressed_If_Delete_After_Create()
{
var events = NormalizeEvents(
@@ -162,7 +163,31 @@ public void Result_Suppressed_If_Delete_After_Create()
Assert.Empty(events);
}
-
+
+ [Fact]
+ public void Created_Event_After_Deleted_Results_Into_Changed()
+ {
+ var events = NormalizeEvents(
+ new FileChangedEvent
+ {
+ ChangeType = ChangeType.DELETED,
+ FullPath = @"c:\foo",
+ OldFullPath = null
+ },
+ new FileChangedEvent
+ {
+ ChangeType = ChangeType.CREATED,
+ FullPath = @"c:\foo",
+ OldFullPath = null
+ }
+ );
+
+ Assert.Single(events);
+ var ev = events.First();
+ Assert.Equal(ChangeType.CHANGED, ev.ChangeType);
+ Assert.Equal(@"c:\foo", ev.FullPath);
+ Assert.Null(ev.OldFullPath);
+ }
[Fact]
public void Changed_Event_After_Created_Is_Ignored()
@@ -214,7 +239,7 @@ public void Filter_Passes_Events_Through()
}
};
- var filtered = EventProcessor.FilterDeleted(events);
+ var filtered = EventNormalizer.FilterDeleted(events);
Assert.Equal(events, filtered);
}
@@ -243,26 +268,26 @@ public void Filter_Out_Deleted_Event_With_Subdirectory()
}
};
- var filtered = EventProcessor.FilterDeleted(events).ToList();
+ var filtered = EventNormalizer.FilterDeleted(events).ToList();
Assert.Equal(2, filtered.Count);
Assert.Equal(ChangeType.DELETED, filtered[0].ChangeType);
Assert.Equal(@"c:\bar", filtered[0].FullPath);
Assert.Equal(ChangeType.CREATED, filtered[1].ChangeType);
Assert.Equal(@"c:\foo", filtered[1].FullPath);
}
-
- [Fact]
- public void IsParent()
- {
- Assert.True(EventProcessor.IsParent(@"c:\a\b", @"c:"));
- Assert.True(EventProcessor.IsParent(@"c:\a\b", @"c:\a"));
- // candidate must not have backslash
- Assert.False(EventProcessor.IsParent(@"c:\a\b", @"c:\"));
- Assert.False(EventProcessor.IsParent(@"c:\a\b", @"c:\a\"));
-
- Assert.False(EventProcessor.IsParent(@"c:\", @"c:\foo"));
- Assert.False(EventProcessor.IsParent(@"c:\", @"c:\"));
+ [Theory]
+ [InlineData(@"c:\a\b", @"c:", true)]
+ [InlineData(@"c:\a\b", @"c:\a", true)]
+ [InlineData(@"c:\a\b\", @"c:", true)]
+ [InlineData(@"c:\a\b\", @"c:\a", true)]
+ [InlineData(@"c:\a\b", @"c:\", true)]
+ [InlineData(@"c:\a\b", @"c:\a\", true)]
+ [InlineData(@"c:\", @"c:\foo", false)]
+ [InlineData(@"c:\", @"c:\", false)]
+ public void Is_Parent(string path, string candidatePath, bool expectedResult)
+ {
+ Assert.Equal(expectedResult, EventNormalizer.IsParent(path, candidatePath));
}
[Fact]
@@ -274,34 +299,34 @@ public void Parent_Dir_Is_Detected()
FullPath = @"c:\foo"
};
- Assert.True(EventProcessor.IsParent(ev, new List()));
+ Assert.True(EventNormalizer.IsParent(ev, new List()));
}
-
+
[Fact]
public void Delete_Event_For_Subdirectory_Is_Detected()
{
var deletedFiles = new List();
-
+
var parentDirEvent = new FileChangedEvent
{
ChangeType = ChangeType.DELETED,
FullPath = @"c:\foo"
};
-
- Assert.True(EventProcessor.IsParent(parentDirEvent, deletedFiles));
-
+ Assert.True(EventNormalizer.IsParent(parentDirEvent, deletedFiles));
+
+
var subDirEvent = new FileChangedEvent
{
ChangeType = ChangeType.DELETED,
FullPath = @"c:\foo\bar"
};
-
- Assert.False(EventProcessor.IsParent(subDirEvent, deletedFiles));
+
+ Assert.False(EventNormalizer.IsParent(subDirEvent, deletedFiles));
}
-
+
private static List NormalizeEvents(params FileChangedEvent[] events)
{
- return EventProcessor.NormalizeEvents(events).ToList();
+ return new EventNormalizer().Normalize(events).ToList();
}
}
\ No newline at end of file
diff --git a/Source/FileWatcherExTests/FileWatcherExIntegrationTest.cs b/Source/FileWatcherExTests/FileWatcherExIntegrationTest.cs
new file mode 100644
index 0000000..faa52bf
--- /dev/null
+++ b/Source/FileWatcherExTests/FileWatcherExIntegrationTest.cs
@@ -0,0 +1,375 @@
+using D2Phap.FileWatcherEx;
+using FileWatcherExTests.Helper;
+using System.Collections.Concurrent;
+using Xunit;
+
+namespace FileWatcherExTests;
+
+///
+/// Integration/ Golden master test for FileWatcherEx
+/// Note: the scenarios where recorded in C:\temp\fwtest
+///
+public class FileWatcherExIntegrationTest : IDisposable
+{
+ private readonly ConcurrentQueue _events;
+ private readonly FileSystemWatcherEx _fileWatcher;
+ private readonly ReplayFileSystemWatcherFactory _replayFileSystemWatcherFactory;
+ private readonly TempDir _tempDir;
+
+ public FileWatcherExIntegrationTest(ITestOutputHelper testOutputHelper)
+ {
+ // setup before each test run
+ _events = new ConcurrentQueue();
+ _replayFileSystemWatcherFactory = new ReplayFileSystemWatcherFactory();
+
+ _tempDir = new TempDir();
+ _fileWatcher = new FileSystemWatcherEx(_tempDir.FullPath, testOutputHelper.WriteLine)
+ {
+ FileSystemWatcherFactory = () => _replayFileSystemWatcherFactory.Create(),
+ IncludeSubdirectories = true
+ };
+
+ _fileWatcher.OnCreated += (_, ev) => _events.Enqueue(ev);
+ _fileWatcher.OnDeleted += (_, ev) => _events.Enqueue(ev);
+ _fileWatcher.OnChanged += (_, ev) => _events.Enqueue(ev);
+ _fileWatcher.OnRenamed += (_, ev) => _events.Enqueue(ev);
+ }
+
+
+ [Fact]
+ public void Create_Single_File()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_file.csv");
+
+ Assert.Single(_events);
+ var ev = _events.First();
+ Assert.Equal(ChangeType.CREATED, ev.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev.FullPath);
+ Assert.Equal("", ev.OldFullPath);
+ }
+
+
+ [Fact]
+ public void Create_And_Remove_Single_File()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_and_remove_file.csv");
+
+ Assert.Equal(2, _events.Count);
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath);
+ Assert.Equal("", ev1.OldFullPath);
+
+ Assert.Equal(ChangeType.DELETED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.FullPath);
+ Assert.Equal("", ev2.OldFullPath);
+ }
+
+
+ [Fact]
+ public void Create_Rename_And_Remove_Single_File()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file.csv");
+
+ Assert.Equal(3, _events.Count);
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+ var ev3 = _events.ToList()[2];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath);
+
+ Assert.Equal(ChangeType.RENAMED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev2.FullPath);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.OldFullPath);
+
+ Assert.Equal(ChangeType.DELETED, ev3.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev3.FullPath);
+ Assert.Equal("", ev3.OldFullPath);
+ }
+
+
+ [Fact]
+ // filters out 2nd "changed" event
+ public void Create_Single_File_Via_WSL2()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_file_wsl2.csv");
+
+ Assert.Single(_events);
+ var ev = _events.First();
+ Assert.Equal(ChangeType.CREATED, ev.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev.FullPath);
+ Assert.Equal("", ev.OldFullPath);
+ }
+
+
+ [Fact]
+ // scenario creates "created" "changed" and "renamed" event.
+ // resulting event is just "created" with the filename taken from "renamed"
+ public void Create_And_Rename_Single_File_Via_WSL2()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_and_rename_file_wsl2.csv");
+
+ Assert.Single(_events);
+ var ev = _events.First();
+ Assert.Equal(ChangeType.CREATED, ev.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev.FullPath);
+ Assert.Null(ev.OldFullPath);
+ }
+
+
+ [Fact]
+ public void Create_Rename_And_Remove_Single_File_Via_WSL2()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file_wsl2.csv");
+ Assert.Empty(_events);
+ }
+
+
+ [Fact]
+ public void Create_Rename_And_Remove_Single_File_With_Wait_Time_Via_WSL2()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv");
+
+ Assert.Equal(3, _events.Count);
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+ var ev3 = _events.ToList()[2];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath);
+
+ Assert.Equal(ChangeType.RENAMED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev2.FullPath);
+ AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.OldFullPath);
+
+ Assert.Equal(ChangeType.DELETED, ev3.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev3.FullPath);
+ Assert.Equal("", ev3.OldFullPath);
+ }
+
+
+ [Fact]
+ public void Manually_Create_And_Rename_File_Via_Windows_Explorer()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_and_rename_file_via_explorer.csv");
+
+ Assert.Equal(2, _events.Count);
+
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev1.FullPath);
+
+ Assert.Equal(ChangeType.RENAMED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev2.FullPath);
+ AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev2.OldFullPath);
+ }
+
+ [Fact]
+ public void Manually_Create_Rename_And_Delete_File_Via_Windows_Explorer()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_rename_and_delete_file_via_explorer.csv");
+
+ Assert.Equal(3, _events.Count);
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+ var ev3 = _events.ToList()[2];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev1.FullPath);
+
+ Assert.Equal(ChangeType.RENAMED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev2.FullPath);
+ AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev2.OldFullPath);
+
+ Assert.Equal(ChangeType.DELETED, ev3.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev3.FullPath);
+ Assert.Equal("", ev3.OldFullPath);
+ }
+
+ [Fact]
+ public void Download_Image_Via_Edge_Browser()
+ {
+ StartFileWatcherAndReplay(@"scenario/download_image_via_Edge_browser.csv");
+
+ Assert.Equal(2, _events.Count);
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\test.png.crdownload", ev1.FullPath);
+
+ Assert.Equal(ChangeType.RENAMED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\test.png", ev2.FullPath);
+ AssertEqualNormalized(@"C:\temp\fwtest\test.png.crdownload", ev2.OldFullPath);
+ }
+
+ // instantly removed file is not in the events list
+ [Fact]
+ public void Create_Sub_Directory_Add_And_Remove_File()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_subdirectory_add_and_remove_file.csv");
+
+ Assert.Equal(2, _events.Count);
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev1.FullPath);
+
+ Assert.Equal(ChangeType.CHANGED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev2.FullPath);
+ Assert.Equal(@"", ev2.OldFullPath);
+ }
+
+ [Fact]
+ public void Create_Sub_Directory_Add_And_Remove_File_With_Sleep()
+ {
+ StartFileWatcherAndReplay(@"scenario/create_subdirectory_add_and_remove_file_with_sleep.csv");
+
+ Assert.Equal(4, _events.Count);
+ var ev1 = _events.ToList()[0];
+ var ev2 = _events.ToList()[1];
+ var ev3 = _events.ToList()[2];
+ var ev4 = _events.ToList()[3];
+
+ Assert.Equal(ChangeType.CREATED, ev1.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev1.FullPath);
+
+ Assert.Equal(ChangeType.CREATED, ev2.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\subdir\a.txt", ev2.FullPath);
+ Assert.Equal(@"", ev2.OldFullPath);
+
+ // TODO this could be filtered out
+ Assert.Equal(ChangeType.CHANGED, ev3.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev3.FullPath);
+ Assert.Equal(@"", ev3.OldFullPath);
+
+ Assert.Equal(ChangeType.DELETED, ev4.ChangeType);
+ AssertEqualNormalized(@"C:\temp\fwtest\subdir\a.txt", ev4.FullPath);
+ Assert.Equal(@"", ev4.OldFullPath);
+ }
+
+ [Fact]
+ public void Filter_Settings_Are_Delegated()
+ {
+ using var dir = new TempDir();
+ var watcher = new ReplayFileSystemWatcherWrapper();
+
+ var uut = new FileSystemWatcherEx(dir.FullPath)
+ {
+ FileSystemWatcherFactory = () => watcher
+ };
+ uut.Filters.Add("*.foo");
+ uut.Filters.Add("*.bar");
+
+ uut.Start();
+ Assert.Equal(new List { "*.foo", "*.bar" }, watcher.Filters);
+ }
+
+ [Fact]
+ public void Set_Filter()
+ {
+ using var dir = new TempDir();
+ var watcher = new ReplayFileSystemWatcherWrapper();
+
+ var uut = new FileSystemWatcherEx(dir.FullPath)
+ {
+ FileSystemWatcherFactory = () => watcher
+ };
+
+ // "all files" by default
+ Assert.Equal("*", uut.Filter);
+
+ uut.Filters.Add("*.foo");
+ uut.Filters.Add("*.bar");
+
+ // two filter entries
+ Assert.Equal(2, uut.Filters.Count);
+
+ // if multiple filters, only first is displayed. TODO Why ?
+ Assert.Equal("*.foo", uut.Filter);
+
+ uut.Filter = "*.baz";
+ Assert.Equal("*.baz", uut.Filter);
+ Assert.Single(uut.Filters);
+ }
+
+
+
+ [Fact(Skip = "requires real (Windows) file system")]
+ public void Simple_Real_File_System_Test()
+ {
+ ConcurrentQueue events = new();
+ var fw = new FileSystemWatcherEx(@"c:\temp\fwtest\");
+
+ fw.OnCreated += (_, ev) => events.Enqueue(ev);
+ fw.OnDeleted += (_, ev) => events.Enqueue(ev);
+ fw.OnChanged += (_, ev) => events.Enqueue(ev);
+ fw.OnRenamed += (_, ev) => events.Enqueue(ev);
+ fw.OnRenamed += (_, ev) => events.Enqueue(ev);
+
+ const string testFile = @"c:\temp\fwtest\b.txt";
+ if (File.Exists(testFile))
+ {
+ File.Delete(testFile);
+ }
+
+ _fileWatcher.StartForTesting(
+ p => FileAttributes.Normal,
+ p => []);
+ File.Create(testFile);
+ Thread.Sleep(250);
+ fw.Stop();
+
+ Assert.Single(events);
+ var ev = events.First();
+ Assert.Equal(ChangeType.CREATED, ev.ChangeType);
+ Assert.Equal(@"c:\temp\fwtest\b.txt", ev.FullPath);
+ Assert.Equal("", ev.OldFullPath);
+ }
+
+ // cleanup
+ public void Dispose()
+ {
+ _fileWatcher.Dispose();
+ _tempDir.Dispose();
+ }
+
+ private void StartFileWatcherAndReplay(string csvFile)
+ {
+ _fileWatcher.StartForTesting(
+ p => FileAttributes.Normal,
+ // only used for FullName
+ p => [new DirectoryInfo(p)]);
+ _replayFileSystemWatcherFactory.RootWatcher.Replay(csvFile);
+ _fileWatcher.Stop();
+ }
+
+ private class ReplayFileSystemWatcherFactory
+ {
+ private readonly List _wrappers = [];
+
+ public ReplayFileSystemWatcherWrapper Create()
+ {
+ var watcher = new ReplayFileSystemWatcherWrapper();
+ _wrappers.Add(watcher);
+ return watcher;
+ }
+
+ // At integration test, we're only interested in the root file watcher.
+ // This is the one which is registered first and watches the root directory.
+ public ReplayFileSystemWatcherWrapper RootWatcher => _wrappers[0];
+ }
+
+ // little hack to make the path comparision platform independent
+ private static void AssertEqualNormalized(string expected, string? actual)
+ {
+ actual = actual?.Replace("/", @"\");
+ Assert.Equal(expected, actual);
+ }
+}
diff --git a/Source/FileWatcherExTests/FileWatcherExTests.csproj b/Source/FileWatcherExTests/FileWatcherExTests.csproj
index 43d48e8..fa90e61 100644
--- a/Source/FileWatcherExTests/FileWatcherExTests.csproj
+++ b/Source/FileWatcherExTests/FileWatcherExTests.csproj
@@ -1,28 +1,36 @@
- net6.0
+ net10.0
enable
enable
-
false
-
-
-
+
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
-
+
+
+
+
+ PreserveNewest
+
+
diff --git a/Source/FileWatcherExTests/Helper/TempDir.cs b/Source/FileWatcherExTests/Helper/TempDir.cs
new file mode 100644
index 0000000..ee01bea
--- /dev/null
+++ b/Source/FileWatcherExTests/Helper/TempDir.cs
@@ -0,0 +1,32 @@
+namespace FileWatcherExTests.Helper;
+
+public class TempDir : IDisposable
+{
+ public string FullPath { get; }
+
+ public TempDir()
+ {
+ FullPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(FullPath);
+ }
+
+ public string CreateSubDir(string path)
+ {
+ var subDirPath = Path.Combine(FullPath, path);
+ Directory.CreateDirectory(subDirPath);
+ return subDirPath;
+ }
+
+ public string CreateSymlink(string target, params string[] symLink)
+ {
+ var allElements = new[] { FullPath }.Concat(symLink).ToArray();
+ var symlinkPath = Path.Combine(allElements);
+ Directory.CreateSymbolicLink(symlinkPath, target);
+ return symlinkPath;
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(FullPath, true);
+ }
+}
diff --git a/Source/FileWatcherExTests/ReplayFileSystemWatcherWrapper.cs b/Source/FileWatcherExTests/ReplayFileSystemWatcherWrapper.cs
new file mode 100644
index 0000000..39be8dc
--- /dev/null
+++ b/Source/FileWatcherExTests/ReplayFileSystemWatcherWrapper.cs
@@ -0,0 +1,92 @@
+using CsvHelper;
+using D2Phap.FileWatcherEx.Helpers;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Globalization;
+
+namespace FileWatcherExTests;
+
+internal record EventRecordWithDiff(
+ string Directory,
+ string FileName,
+ string EventName,
+ string? OldFileName,
+ long DiffInTicks,
+ double DiffInMilliseconds
+);
+
+///
+/// Allows replaying of previously recorded file system events.
+/// Used for integration testing.
+///
+public class ReplayFileSystemWatcherWrapper : IFileSystemWatcherWrapper
+{
+ private readonly Collection _filters = [];
+
+ public void Replay(string csvFile)
+ {
+ using var reader = new StreamReader(csvFile);
+ using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
+
+ var records = csv.GetRecords();
+ foreach (var record in records)
+ {
+ // introduce time gap like originally recorded
+ Thread.Sleep((int)record.DiffInMilliseconds);
+
+ switch (record.EventName)
+ {
+ case "created":
+ {
+ var ev = new FileSystemEventArgs(WatcherChangeTypes.Created, record.Directory, record.FileName);
+ Created?.Invoke(this, ev);
+ break;
+ }
+ case "deleted":
+ {
+ var ev = new FileSystemEventArgs(WatcherChangeTypes.Deleted, record.Directory, record.FileName);
+ Deleted?.Invoke(this, ev);
+ break;
+ }
+ case "changed":
+ {
+ var ev = new FileSystemEventArgs(WatcherChangeTypes.Changed, record.Directory, record.FileName);
+ Changed?.Invoke(this, ev);
+ break;
+ }
+ case "renamed":
+ {
+ var ev = new RenamedEventArgs(WatcherChangeTypes.Renamed, record.Directory, record.FileName,
+ record.OldFileName);
+ Renamed?.Invoke(this, ev);
+ break;
+ }
+ }
+ }
+ // settle down
+ Thread.Sleep(250);
+ }
+
+ public event FileSystemEventHandler? Created;
+ public event FileSystemEventHandler? Deleted;
+ public event FileSystemEventHandler? Changed;
+ public event RenamedEventHandler? Renamed;
+
+#pragma warning disable CS8618 // unused in replay implementation
+ public string Path { get; set; }
+
+ public Collection Filters => _filters;
+
+ public bool IncludeSubdirectories { get; set; }
+ public bool EnableRaisingEvents { get; set; }
+ public NotifyFilters NotifyFilter { get; set; }
+
+#pragma warning disable CS0067 // unused in replay implementation
+ public event ErrorEventHandler? Error;
+ public int InternalBufferSize { get; set; }
+ public ISynchronizeInvoke? SynchronizingObject { get; set; }
+
+ public void Dispose()
+ {
+ }
+}
\ No newline at end of file
diff --git a/Source/FileWatcherExTests/SymlinkAwareFileWatcherTest.cs b/Source/FileWatcherExTests/SymlinkAwareFileWatcherTest.cs
new file mode 100644
index 0000000..b01c39a
--- /dev/null
+++ b/Source/FileWatcherExTests/SymlinkAwareFileWatcherTest.cs
@@ -0,0 +1,258 @@
+using D2Phap.FileWatcherEx.Helpers;
+using FileWatcherExTests.Helper;
+using Moq;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using Xunit;
+
+namespace FileWatcherExTests;
+
+public class SymlinkAwareFileWatcherTest
+{
+ private readonly ITestOutputHelper _testOutputHelper;
+ private readonly List> _mocks;
+
+ public SymlinkAwareFileWatcherTest(ITestOutputHelper testOutputHelper)
+ {
+ _testOutputHelper = testOutputHelper;
+ _mocks = [];
+ }
+
+ [Fact]
+ public void Root_Watcher_Is_Created()
+ {
+ using var dir = new TempDir();
+ CreateFileWatcher(dir.FullPath);
+ AssertContainsWatcherFor(dir.FullPath);
+ }
+
+
+ // creates 2 sub-directories and 2 nested symlinks
+ // file watchers are registered for the root dir and the symlinks
+ // for the sub-directories, no extra file watchers are created
+ // since the normal file watcher already emits events for subdirs
+ //
+ // this handles the initial setup after start.
+ // for registering sym-link watchers during runtime, MakeWatcher_Created is used.
+ [Fact]
+ public void FileWatchers_For_SymLink_Dirs_Are_Created_On_Startup()
+ {
+ using var dir = new TempDir();
+
+ // {tempdir}/subdir1
+ var subdirPath1 = dir.CreateSubDir("subdir1");
+
+ // {tempdir}/subdir2
+ var subdirPath2 = dir.CreateSubDir("subdir2");
+
+ // symlink {tempdir}/sym1 to {tempdir}/subdir1
+ var symlinkPath1 = dir.CreateSymlink(symLink: "sym1", target: subdirPath1);
+
+ // symlink {tempdir}/sym1/sym2 to {tempdir}/subdir2
+ var symlinkPath2 = dir.CreateSymlink(symLink: ["sym1", "sym2"], target: subdirPath2);
+
+ CreateFileWatcher(dir.FullPath);
+
+ AssertContainsWatcherFor(dir.FullPath);
+ AssertContainsWatcherFor(symlinkPath1);
+ AssertContainsWatcherFor(symlinkPath2);
+ }
+
+ [Fact]
+ public void FileWatchers_For_SymLink_Dirs_Are_Created_During_Runtime()
+ {
+ using var dir = new TempDir();
+ var uut = CreateFileWatcher(dir.FullPath);
+
+ var subdirPath = dir.CreateSubDir("subdir");
+
+ // simulate file watcher trigger
+ uut.TryRegisterFileWatcherForSymbolicLinkDir(subdirPath);
+
+ // subdir is ignored
+ Assert.Single(uut.FileWatchers);
+ AssertContainsWatcherFor(dir.FullPath);
+
+ var symlinkPath = dir.CreateSymlink(symLink: "sym", target: subdirPath);
+
+ // simulate file watcher trigger
+ uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath);
+
+ // symlink dir is registered
+ Assert.Equal(2, uut.FileWatchers.Count);
+ AssertContainsWatcherFor(dir.FullPath);
+ AssertContainsWatcherFor(symlinkPath);
+
+ // remove the symlink again
+ Directory.Delete(symlinkPath);
+
+ // simulate file watcher trigger
+ uut.UnregisterFileWatcherForSymbolicLinkDir(null,
+ new FileSystemEventArgs(WatcherChangeTypes.Deleted, dir.FullPath, "sym"));
+
+ // sym-link file watcher is removed
+ Assert.Single(uut.FileWatchers);
+ AssertContainsWatcherFor(dir.FullPath);
+
+ uut.Dispose();
+ }
+
+ [Fact]
+ public void MakeWatcher_Create_Exceptions_Are_Silently_Ignored()
+ {
+ var uut = CreateFileWatcher("/bar");
+ uut.TryRegisterFileWatcherForSymbolicLinkDir("/not/existing/foo");
+ }
+
+ [Fact]
+ public void Properties_Are_Propagated()
+ {
+ using var dir = new TempDir();
+
+ var subDir = dir.CreateSubDir("subdir");
+
+ // symlink for detection at startup
+ dir.CreateSymlink(
+ symLink: "sym1",
+ target: subDir);
+
+ var uut = new SymlinkAwareFileWatcher(dir.FullPath,
+ _ => { },
+ _ => { },
+ WatcherFactoryWithMemory,
+ _ => { })
+ {
+ // perform settings. all, except SynchronizingObject are propagated
+ // to all registered watchers
+ NotifyFilter = NotifyFilters.LastAccess
+ };
+ uut.Filters.Add("*.foo");
+ uut.Filters.Add("*.bar");
+ uut.EnableRaisingEvents = true;
+ uut.IncludeSubdirectories = true;
+ var syncObj = new Mock().Object;
+ uut.SynchronizingObject = syncObj;
+
+ // finish object initialization
+ uut.Init();
+
+ // create symlink at runtime
+ var symlinkPath2 = dir.CreateSymlink(
+ symLink: "sym2",
+ target: subDir);
+
+ // simulate that a new symlink dir was added
+ uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath2);
+
+ // 1x root watcher, 1x sym link at startup, 1x sym link at runtime
+ Assert.Equal(3, _mocks.Count);
+ // all watchers have properties set
+ Assert.All(
+ _mocks,
+ mock =>
+ mock.VerifySet(w => w.NotifyFilter = NotifyFilters.LastAccess));
+ Assert.All(
+ _mocks,
+ mock =>
+ mock.VerifySet(w => w.EnableRaisingEvents = true));
+ Assert.All(
+ _mocks,
+ mock =>
+ mock.VerifySet(w => w.IncludeSubdirectories = true));
+ Assert.All(
+ _mocks,
+ mock => Assert.Equal(mock.Object.Filters, ["*.foo", "*.bar"]));
+
+ // sync. object is only set for root watcher
+ Assert.Collection(_mocks,
+ rootWatcherMock =>
+ {
+ rootWatcherMock.VerifySet(w => w.Path = dir.FullPath);
+ rootWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj);
+ },
+ otherWatcherMock => otherWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj, Times.Never),
+ otherWatcherMock => otherWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj, Times.Never));
+ }
+
+
+ [Fact]
+ public void When_No_SubDirs_Are_Watched_Also_No_Additional_Symlink_Watchers_Are_Registered()
+ {
+ using var dir = new TempDir();
+
+ var subDir = dir.CreateSubDir("subdir");
+
+ // symlink for detection at startup
+ dir.CreateSymlink(
+ symLink: "sym1",
+ target: subDir);
+
+ var uut = new SymlinkAwareFileWatcher(dir.FullPath,
+ _ => { },
+ _ => { },
+ WatcherFactoryWithMemory,
+ _ => { })
+ {
+ IncludeSubdirectories = false
+ };
+ uut.Init();
+
+ // create symlink at runtime
+ var symlinkPath2 = dir.CreateSymlink(
+ symLink: "sym2",
+ target: subDir);
+
+ // simulate that a new symlink dir was added
+ uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath2);
+
+ // only root watcher was registered
+ Assert.Single(_mocks);
+ }
+
+
+ private SymlinkAwareFileWatcher CreateFileWatcher(string path)
+ {
+ var fw = new SymlinkAwareFileWatcher(path,
+ _ => { },
+ _ => { },
+ WatcherFactoryWithMemory,
+ _ => { })
+ {
+ IncludeSubdirectories = true
+ };
+ fw.Init();
+ return fw;
+ }
+
+ private IFileSystemWatcherWrapper WatcherFactoryWithMemory()
+ {
+ var mock = new Mock();
+ // this did the trick to have the 'Filters' property be recorded
+ mock.SetReturnsDefault(new Collection());
+ _mocks.Add(mock);
+ return mock.Object;
+ }
+
+ private void AssertContainsWatcherFor(string path)
+ {
+ var foundMocks = (
+ from mock in _mocks
+ where HasPropertySetTo(mock, watcher => watcher.Path = path)
+ select mock)
+ .Count();
+ Assert.Equal(1, foundMocks);
+ }
+
+ private static bool HasPropertySetTo(Mock mock, Action setterExpression)
+ {
+ try
+ {
+ mock.VerifySet(setterExpression);
+ return true;
+ }
+ catch (MockException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/Source/FileWatcherExTests/scenario/README.md b/Source/FileWatcherExTests/scenario/README.md
new file mode 100644
index 0000000..ed0dfdf
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/README.md
@@ -0,0 +1,101 @@
+# Scenarios For Integration Testing
+
+For each scenario:
+- a fresh recording was started in an empty directory
+- the listed commands were executed in a separate terminal
+- the recording was stopped (CTRL + C)
+
+Then the recorded CSV files were used in integration tests using the `ReplayFileSystemWatcherWrapper.cs`.
+
+Example for starting a recording:
+````powershell
+PS C:\Projects\FileWatcherEx\Source\FileSystemEventRecorder> dotnet run C:\temp\fwtest\ C:\Projects\FileWatcherEx\Source\FileWatcherExTests\scenario\create_rename_and_remove_file_wsl2.csv
+````
+# List of Scenarios
+
+## `create_file.csv`
+````powershell
+New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File
+````
+
+## `create_and_remove_file.csv`
+````powershell
+New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File
+Remove-Item -Path 'c:\temp\fwtest\a.txt' -Recurse
+````
+
+## `create_rename_and_remove_file.csv`
+````powershell
+New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File
+Rename-Item -Path 'c:\temp\fwtest\a.txt' -NewName 'c:\temp\fwtest\b.txt'
+Remove-Item -Path 'c:\temp\fwtest\b.txt' -Recurse
+````
+
+## `create_file_wsl2.csv`
+Create file in WSL 2. On file creation, a second "changed" event is written.
+````sh
+touch /mnt/c/temp/fwtest/a.txt
+````
+
+
+## `create_and_rename_file_wsl2.csv`
+Create and rename file in WSL 2. On file creation, a second "changed" event is written.
+````sh
+touch /mnt/c/temp/fwtest/a.txt
+mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt
+````
+
+## `create_rename_and_remove_file_wsl2.csv`
+Create, rename and remove file in WSL 2. On file creation, a second "changed" event is written.
+````sh
+touch /mnt/c/temp/fwtest/a.txt
+mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt
+rm /mnt/c/temp/fwtest/b.txt
+````
+
+## `create_rename_and_remove_file_with_wait_time_wsl2.csv`
+Create, rename and remove file in WSL 2. Additionally, some wait time is added.
+````sh
+touch /mnt/c/temp/fwtest/a.txt
+sleep 1
+mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt
+sleep 1
+rm /mnt/c/temp/fwtest/b.txt
+````
+
+## `create_and_rename_file_via_explorer.csv`
+Manually create a file in the Windows explorer. Change the default name "New Text Document.txt"
+to "foo.txt".
+
+## `create_rename_and_delete_file_via_explorer.csv`
+Manually create, rename and delete a file in the Windows explorer.
+
+## `download_image_via_Edge_browser.csv`
+Download (right click, "Save image as") single image via Edge 106.0.1370.42.
+
+## `create_subdirectory_add_and_remove_file.csv`
+In WSL2, create a subdirectory, create and remove a file.
+````sh
+mkdir -p /mnt/c/temp/fwtest/subdir/
+touch /mnt/c/temp/fwtest/subdir/a.txt
+rm /mnt/c/temp/fwtest/subdir/a.txt
+````
+
+## `create_subdirectory_add_and_remove_file_with_sleep.csv`
+In WSL2, create a subdirectory, Create the file, wait for a while, then remove it.
+````sh
+mkdir -p /mnt/c/temp/fwtest/subdir/
+touch /mnt/c/temp/fwtest/subdir/a.txt
+sleep 1
+rm /mnt/c/temp/fwtest/subdir/a.txt
+````
+
+## `create_file_inside_symbolic_link_directory`
+Create subdir, create symbolic link to it and then place file inside this symbolic link dir.
+
+````powershell
+New-Item –itemType Directory -Path 'c:\temp\fwtest\subdir'
+New-Item -ItemType Junction -Path 'c:\temp\fwtest\symlink_subdir' -Target 'c:\temp\fwtest\subdir'
+New-Item -ItemType File -Path 'c:\temp\fwtest\symlink_subdir\foo.txt'
+Remove-Item -Path 'c:\temp\fwtest\symlink_subdir\foo.txt' -Recurse
+````
diff --git a/Source/FileWatcherExTests/scenario/create_and_remove_file.csv b/Source/FileWatcherExTests/scenario/create_and_remove_file.csv
new file mode 100644
index 0000000..e19d087
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_and_remove_file.csv
@@ -0,0 +1,3 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
+C:\temp\fwtest,a.txt,deleted,,8263221,826
diff --git a/Source/FileWatcherExTests/scenario/create_and_rename_file_via_explorer.csv b/Source/FileWatcherExTests/scenario/create_and_rename_file_via_explorer.csv
new file mode 100644
index 0000000..2143e79
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_and_rename_file_via_explorer.csv
@@ -0,0 +1,3 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,New Text Document.txt,created,,0,0
+C:\temp\fwtest,foo.txt,renamed,New Text Document.txt,15017686,1502
diff --git a/Source/FileWatcherExTests/scenario/create_and_rename_file_wsl2.csv b/Source/FileWatcherExTests/scenario/create_and_rename_file_wsl2.csv
new file mode 100644
index 0000000..0d92ec2
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_and_rename_file_wsl2.csv
@@ -0,0 +1,4 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
+C:\temp\fwtest,a.txt,changed,,5808,1
+C:\temp\fwtest,b.txt,renamed,a.txt,85490,9
diff --git a/Source/FileWatcherExTests/scenario/create_file.csv b/Source/FileWatcherExTests/scenario/create_file.csv
new file mode 100644
index 0000000..d5b452c
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_file.csv
@@ -0,0 +1,2 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
diff --git a/Source/FileWatcherExTests/scenario/create_file_inside_symbolic_link_directory.csv b/Source/FileWatcherExTests/scenario/create_file_inside_symbolic_link_directory.csv
new file mode 100644
index 0000000..c2fe455
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_file_inside_symbolic_link_directory.csv
@@ -0,0 +1,7 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,subdir,created,,0,0
+C:\temp\fwtest,symlink_subdir,created,,287271,29
+C:\temp\fwtest\subdir,foo.txt,created,,571620,57
+C:\temp\fwtest,subdir,changed,,7601128,760
+C:\temp\fwtest\subdir,foo.txt,deleted,,5158,1
+C:\temp\fwtest,subdir,changed,,1527420,153
diff --git a/Source/FileWatcherExTests/scenario/create_file_wsl2.csv b/Source/FileWatcherExTests/scenario/create_file_wsl2.csv
new file mode 100644
index 0000000..5484ca5
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_file_wsl2.csv
@@ -0,0 +1,3 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
+C:\temp\fwtest,a.txt,changed,,13323,1
diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_delete_file_via_explorer.csv b/Source/FileWatcherExTests/scenario/create_rename_and_delete_file_via_explorer.csv
new file mode 100644
index 0000000..c763e1c
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_rename_and_delete_file_via_explorer.csv
@@ -0,0 +1,4 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,New Text Document.txt,created,,0,0
+C:\temp\fwtest,foo.txt,renamed,New Text Document.txt,20053327,2005
+C:\temp\fwtest,foo.txt,deleted,,10305941,1031
diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_remove_file.csv b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file.csv
new file mode 100644
index 0000000..e296370
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file.csv
@@ -0,0 +1,4 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
+C:\temp\fwtest,b.txt,renamed,a.txt,1046536,105
+C:\temp\fwtest,b.txt,deleted,,4102153,410
diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv
new file mode 100644
index 0000000..153306e
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv
@@ -0,0 +1,5 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
+C:\temp\fwtest,a.txt,changed,,6333,1
+C:\temp\fwtest,b.txt,renamed,a.txt,10362431,1036
+C:\temp\fwtest,b.txt,deleted,,10592668,1059
diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_wsl2.csv b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_wsl2.csv
new file mode 100644
index 0000000..688ece8
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_wsl2.csv
@@ -0,0 +1,5 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,a.txt,created,,0,0
+C:\temp\fwtest,a.txt,changed,,6136,1
+C:\temp\fwtest,b.txt,renamed,a.txt,94817,9
+C:\temp\fwtest,b.txt,deleted,,209770,21
diff --git a/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file.csv b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file.csv
new file mode 100644
index 0000000..aa953e1
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file.csv
@@ -0,0 +1,7 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,subdir,created,,0,0
+C:\temp\fwtest\subdir,a.txt,created,,52133,5
+C:\temp\fwtest\subdir,a.txt,changed,,21171,2
+C:\temp\fwtest,subdir,changed,,132289,13
+C:\temp\fwtest\subdir,a.txt,deleted,,226731,23
+C:\temp\fwtest,subdir,changed,,2123902,212
diff --git a/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file_with_sleep.csv b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file_with_sleep.csv
new file mode 100644
index 0000000..7a9148c
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file_with_sleep.csv
@@ -0,0 +1,6 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,subdir,created,,0,0
+C:\temp\fwtest\subdir,a.txt,created,,371990,37
+C:\temp\fwtest\subdir,a.txt,changed,,78301,8
+C:\temp\fwtest,subdir,changed,,7198911,720
+C:\temp\fwtest\subdir,a.txt,deleted,,3310510,331
diff --git a/Source/FileWatcherExTests/scenario/download_image_via_Edge_browser.csv b/Source/FileWatcherExTests/scenario/download_image_via_Edge_browser.csv
new file mode 100644
index 0000000..0ad936e
--- /dev/null
+++ b/Source/FileWatcherExTests/scenario/download_image_via_Edge_browser.csv
@@ -0,0 +1,5 @@
+Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds
+C:\temp\fwtest,test.png,created,,0,0
+C:\temp\fwtest,test.png,deleted,,8470,1
+C:\temp\fwtest,test.png.crdownload,created,,3352286,335
+C:\temp\fwtest,test.png,renamed,test.png.crdownload,1451627,145