├── .github ├── FUNDING.yml └── workflows │ └── dotnet.yml ├── .gitignore ├── LICENSE ├── README.md ├── Source ├── .gitignore ├── Demo │ ├── Demo.WinForms.csproj │ ├── Form1.Designer.cs │ ├── Form1.cs │ ├── Form1.resx │ ├── Program.cs │ └── README.md ├── FileSystemEventRecorder │ ├── FileSystemEventRecorder.cs │ ├── FileSystemEventRecorder.csproj │ └── README.md ├── FileWatcherEx.sln ├── FileWatcherEx │ ├── FileEvents.cs │ ├── FileSystemWatcherEx.cs │ ├── FileWatcherEx.csproj │ ├── Helpers │ │ ├── EventNormalizer.cs │ │ ├── EventProcessor.cs │ │ ├── FileSystemWatcherWrapper.cs │ │ └── SymlinkAwareFileWatcher.cs │ └── IFileSystemWatcherEx.cs └── FileWatcherExTests │ ├── EventNormalizerTest.cs │ ├── FileWatcherExIntegrationTest.cs │ ├── FileWatcherExTests.csproj │ ├── Helper │ └── TempDir.cs │ ├── ReplayFileSystemWatcherWrapper.cs │ ├── SymlinkAwareFileWatcherTest.cs │ └── scenario │ ├── README.md │ ├── create_and_remove_file.csv │ ├── create_and_rename_file_via_explorer.csv │ ├── create_and_rename_file_wsl2.csv │ ├── create_file.csv │ ├── create_file_inside_symbolic_link_directory.csv │ ├── create_file_wsl2.csv │ ├── create_rename_and_delete_file_via_explorer.csv │ ├── create_rename_and_remove_file.csv │ ├── create_rename_and_remove_file_with_wait_time_wsl2.csv │ ├── create_rename_and_remove_file_wsl2.csv │ ├── create_subdirectory_add_and_remove_file.csv │ ├── create_subdirectory_add_and_remove_file_with_sleep.csv │ └── download_image_via_Edge_browser.csv └── nuget.ps1 /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [d2phap] 4 | patreon: d2phap 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | custom: ["https://donate.stripe.com/6oE15Kab3740du828a", "https://www.paypal.me/d2phap"] 9 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a .NET project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net 3 | 4 | name: .NET 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | defaults: 13 | run: 14 | working-directory: Source 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: windows-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Setup .NET 24 | uses: actions/setup-dotnet@v3 25 | with: 26 | dotnet-version: 8.0.x 27 | - name: Restore dependencies 28 | run: dotnet restore 29 | - name: Build 30 | run: dotnet build --no-restore 31 | - name: Test 32 | run: dotnet test --no-build --verbosity normal 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | *.suo 4 | *.user 5 | *.orig 6 | *.sdf 7 | *.opensdf 8 | ipch 9 | debug/ 10 | release/ 11 | TestResults/ 12 | packages/ 13 | .vs/ 14 | BuildResults.xml 15 | *.results.xml 16 | *.log 17 | *.wrn 18 | *.err 19 | intermediate 20 | *.userprefs 21 | !SDB/dependencies/*.dll 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Duong Dieu Phap 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FileWatcherEx for Windows 2 | 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. 3 | 4 | This project is based on the *VSCode FileWatcher*: https://github.com/Microsoft/vscode-filewatcher-windows 5 | 6 | ![Nuget](https://img.shields.io/nuget/dt/FileWatcherEx?color=%2300a8d6&logo=nuget) 7 | 8 | 9 | ## Resource links 10 | - Nuget package: [https://www.nuget.org/packages/FileWatcherEx](https://www.nuget.org/packages/FileWatcherEx/) 11 | - Project url: [https://github.com/d2phap/FileWatcherEx](https://github.com/d2phap/FileWatcherEx) 12 | - Website: [https://imageglass.org](https://imageglass.org) 13 | 14 | ## Features 15 | - Standardizes the events of `System.IO.FileSystemWatcher`. 16 | - No false change notifications when a file system item is created, deleted, changed or renamed. 17 | - Supports .NET 6.0, 7.0. 18 | 19 | ## Installation 20 | Run the command: 21 | 22 | ```bash 23 | # Nuget package 24 | Install-Package FileWatcherEx 25 | ``` 26 | 27 | ## Usage 28 | See Demo project for full details! 29 | 30 | ```cs 31 | using FileWatcherEx; 32 | 33 | 34 | var _fw = new FileSystemWatcherEx(@"C:\path\to\watch"); 35 | 36 | // event handlers 37 | _fw.OnRenamed += FW_OnRenamed; 38 | _fw.OnCreated += FW_OnCreated; 39 | _fw.OnDeleted += FW_OnDeleted; 40 | _fw.OnChanged += FW_OnChanged; 41 | _fw.OnError += FW_OnError; 42 | 43 | // thread-safe for event handlers 44 | _fw.SynchronizingObject = this; 45 | 46 | // start watching 47 | _fw.Start(); 48 | 49 | 50 | 51 | void FW_OnRenamed(object sender, FileChangedEvent e) 52 | { 53 | // do something here 54 | } 55 | ... 56 | 57 | ``` 58 | 59 | ## License 60 | [MIT](LICENSE) 61 | 62 | ## Support this project 63 | - [GitHub sponsor](https://github.com/sponsors/d2phap) 64 | - [Patreon](https://www.patreon.com/d2phap) 65 | - [PayPal](https://www.paypal.me/d2phap) 66 | - [Wire Transfers](https://donorbox.org/imageglass) 67 | 68 | Thanks for your gratitude and finance help! 69 | -------------------------------------------------------------------------------- /Source/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /Source/Demo/Demo.WinForms.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | WinExe 5 | net8.0-windows 6 | enable 7 | true 8 | enable 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Source/Demo/Form1.Designer.cs: -------------------------------------------------------------------------------- 1 | namespace Demo 2 | { 3 | partial class Form1 4 | { 5 | /// 6 | /// Required designer variable. 7 | /// 8 | private System.ComponentModel.IContainer components = null; 9 | 10 | /// 11 | /// Clean up any resources being used. 12 | /// 13 | /// true if managed resources should be disposed; otherwise, false. 14 | protected override void Dispose(bool disposing) 15 | { 16 | if (disposing && (components != null)) 17 | { 18 | components.Dispose(); 19 | } 20 | base.Dispose(disposing); 21 | } 22 | 23 | 24 | #region Windows Form Designer generated code 25 | 26 | /// 27 | /// Required method for Designer support - do not modify 28 | /// the contents of this method with the code editor. 29 | /// 30 | private void InitializeComponent() 31 | { 32 | this.txtConsole = new System.Windows.Forms.TextBox(); 33 | this.btnSelectFolder = new System.Windows.Forms.Button(); 34 | this.txtPath = new System.Windows.Forms.TextBox(); 35 | this.btnStart = new System.Windows.Forms.Button(); 36 | this.btnStop = new System.Windows.Forms.Button(); 37 | this.SuspendLayout(); 38 | // 39 | // txtConsole 40 | // 41 | this.txtConsole.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) 42 | | System.Windows.Forms.AnchorStyles.Left) 43 | | System.Windows.Forms.AnchorStyles.Right))); 44 | this.txtConsole.BackColor = System.Drawing.Color.Black; 45 | this.txtConsole.BorderStyle = System.Windows.Forms.BorderStyle.None; 46 | this.txtConsole.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); 47 | this.txtConsole.ForeColor = System.Drawing.Color.White; 48 | this.txtConsole.Location = new System.Drawing.Point(0, 0); 49 | this.txtConsole.Margin = new System.Windows.Forms.Padding(3, 4, 3, 4); 50 | this.txtConsole.Multiline = true; 51 | this.txtConsole.Name = "txtConsole"; 52 | this.txtConsole.ReadOnly = true; 53 | this.txtConsole.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; 54 | this.txtConsole.Size = new System.Drawing.Size(1174, 546); 55 | this.txtConsole.TabIndex = 0; 56 | // 57 | // btnSelectFolder 58 | // 59 | this.btnSelectFolder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); 60 | this.btnSelectFolder.Location = new System.Drawing.Point(526, 574); 61 | this.btnSelectFolder.Name = "btnSelectFolder"; 62 | this.btnSelectFolder.Size = new System.Drawing.Size(133, 57); 63 | this.btnSelectFolder.TabIndex = 1; 64 | this.btnSelectFolder.Text = "Select folder"; 65 | this.btnSelectFolder.UseVisualStyleBackColor = true; 66 | this.btnSelectFolder.Click += new System.EventHandler(this.BtnSelectFolder_Click); 67 | // 68 | // txtPath 69 | // 70 | this.txtPath.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); 71 | this.txtPath.Enabled = false; 72 | this.txtPath.Location = new System.Drawing.Point(12, 574); 73 | this.txtPath.Multiline = true; 74 | this.txtPath.Name = "txtPath"; 75 | this.txtPath.Size = new System.Drawing.Size(508, 57); 76 | this.txtPath.TabIndex = 2; 77 | this.txtPath.Text = "C:\\"; 78 | // 79 | // btnStart 80 | // 81 | this.btnStart.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); 82 | this.btnStart.Location = new System.Drawing.Point(944, 574); 83 | this.btnStart.Name = "btnStart"; 84 | this.btnStart.Size = new System.Drawing.Size(104, 57); 85 | this.btnStart.TabIndex = 3; 86 | this.btnStart.Text = "Start"; 87 | this.btnStart.UseVisualStyleBackColor = true; 88 | this.btnStart.Click += new System.EventHandler(this.BtnStart_Click); 89 | // 90 | // btnStop 91 | // 92 | this.btnStop.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); 93 | this.btnStop.Enabled = false; 94 | this.btnStop.Location = new System.Drawing.Point(1054, 574); 95 | this.btnStop.Name = "btnStop"; 96 | this.btnStop.Size = new System.Drawing.Size(104, 57); 97 | this.btnStop.TabIndex = 4; 98 | this.btnStop.Text = "Stop"; 99 | this.btnStop.UseVisualStyleBackColor = true; 100 | this.btnStop.Click += new System.EventHandler(this.BtnStop_Click); 101 | // 102 | // Form1 103 | // 104 | this.AutoScaleDimensions = new System.Drawing.SizeF(9F, 23F); 105 | this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; 106 | this.ClientSize = new System.Drawing.Size(1174, 654); 107 | this.Controls.Add(this.btnStop); 108 | this.Controls.Add(this.btnStart); 109 | this.Controls.Add(this.txtPath); 110 | this.Controls.Add(this.btnSelectFolder); 111 | this.Controls.Add(this.txtConsole); 112 | this.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); 113 | this.Margin = new System.Windows.Forms.Padding(3, 4, 3, 4); 114 | this.Name = "Form1"; 115 | this.Text = "Demo.WinForms"; 116 | this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.Form1_FormClosing); 117 | this.ResumeLayout(false); 118 | this.PerformLayout(); 119 | 120 | } 121 | 122 | #endregion 123 | 124 | private System.Windows.Forms.TextBox txtConsole; 125 | private System.Windows.Forms.Button btnSelectFolder; 126 | private System.Windows.Forms.TextBox txtPath; 127 | private System.Windows.Forms.Button btnStart; 128 | private System.Windows.Forms.Button btnStop; 129 | } 130 | } -------------------------------------------------------------------------------- /Source/Demo/Form1.cs: -------------------------------------------------------------------------------- 1 | 2 | using FileWatcherEx; 3 | 4 | namespace Demo 5 | { 6 | public partial class Form1 : Form 7 | { 8 | private FileSystemWatcherEx _fw = new(); 9 | 10 | public Form1() 11 | { 12 | InitializeComponent(); 13 | } 14 | 15 | 16 | 17 | private void BtnStart_Click(object sender, EventArgs e) 18 | { 19 | _fw = new FileSystemWatcherEx(txtPath.Text.Trim(), FW_OnLog); 20 | 21 | _fw.OnRenamed += FW_OnRenamed; 22 | _fw.OnCreated += FW_OnCreated; 23 | _fw.OnDeleted += FW_OnDeleted; 24 | _fw.OnChanged += FW_OnChanged; 25 | _fw.OnError += FW_OnError; 26 | 27 | _fw.SynchronizingObject = this; 28 | _fw.IncludeSubdirectories = true; 29 | 30 | try 31 | { 32 | _fw.Start(); 33 | 34 | btnStart.Enabled = true; 35 | btnSelectFolder.Enabled = false; 36 | txtPath.Enabled = false; 37 | btnStop.Enabled = true; 38 | } 39 | catch (Exception ex) 40 | { 41 | MessageBox.Show(ex.Message); 42 | } 43 | } 44 | 45 | private void FW_OnError(object? sender, ErrorEventArgs e) 46 | { 47 | if (txtConsole.InvokeRequired) 48 | { 49 | txtConsole.Invoke(FW_OnError, sender, e); 50 | } 51 | else 52 | { 53 | txtConsole.Text += "[ERROR]: " + e.GetException().Message + "\r\n"; 54 | } 55 | } 56 | 57 | private void FW_OnChanged(object? sender, FileChangedEvent e) 58 | { 59 | txtConsole.Text += string.Format("[cha] {0} | {1}", 60 | Enum.GetName(typeof(ChangeType), e.ChangeType), 61 | e.FullPath) + "\r\n"; 62 | } 63 | 64 | private void FW_OnDeleted(object? sender, FileChangedEvent e) 65 | { 66 | txtConsole.Text += string.Format("[del] {0} | {1}", 67 | Enum.GetName(typeof(ChangeType), e.ChangeType), 68 | e.FullPath) + "\r\n"; 69 | } 70 | 71 | private void FW_OnCreated(object? sender, FileChangedEvent e) 72 | { 73 | txtConsole.Text += string.Format("[cre] {0} | {1}", 74 | Enum.GetName(typeof(ChangeType), e.ChangeType), 75 | e.FullPath) + "\r\n"; 76 | } 77 | 78 | private void FW_OnRenamed(object? sender, FileChangedEvent e) 79 | { 80 | txtConsole.Text += string.Format("[ren] {0} | {1} ----> {2}", 81 | Enum.GetName(typeof(ChangeType), e.ChangeType), 82 | e.OldFullPath, 83 | e.FullPath) + "\r\n"; 84 | } 85 | 86 | private void FW_OnLog(string value) 87 | { 88 | txtConsole.Text += $@"[log] {value}" + "\r\n"; 89 | } 90 | 91 | private void BtnStop_Click(object sender, EventArgs e) 92 | { 93 | _fw.Stop(); 94 | 95 | btnStart.Enabled = true; 96 | btnSelectFolder.Enabled = true; 97 | txtPath.Enabled = true; 98 | btnStop.Enabled = false; 99 | btnStop.Enabled = true; 100 | } 101 | 102 | 103 | private void BtnSelectFolder_Click(object sender, EventArgs e) 104 | { 105 | var fb = new FolderBrowserDialog(); 106 | 107 | 108 | if (fb.ShowDialog() == DialogResult.OK) 109 | { 110 | txtPath.Text = fb.SelectedPath; 111 | 112 | _fw.Stop(); 113 | _fw.Dispose(); 114 | } 115 | } 116 | 117 | private void Form1_FormClosing(object sender, FormClosingEventArgs e) 118 | { 119 | _fw.Dispose(); 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /Source/Demo/Form1.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | text/microsoft-resx 50 | 51 | 52 | 2.0 53 | 54 | 55 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 56 | 57 | 58 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 59 | 60 | -------------------------------------------------------------------------------- /Source/Demo/Program.cs: -------------------------------------------------------------------------------- 1 | namespace Demo 2 | { 3 | internal static class Program 4 | { 5 | /// 6 | /// The main entry point for the application. 7 | /// 8 | [STAThread] 9 | static void Main() 10 | { 11 | ApplicationConfiguration.Initialize(); 12 | Application.Run(new Form1()); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /Source/Demo/README.md: -------------------------------------------------------------------------------- 1 | Manual Testing 2 | -------------- 3 | 4 | This little application is helpful for interactive testing of the library. 5 | 6 | Symlinks 7 | -------- 8 | To create a symlink directory on Windows, use `mklink`. 9 | Example: `mklink /D my-symbolic-link c:\temp\target-directory` 10 | -------------------------------------------------------------------------------- /Source/FileSystemEventRecorder/FileSystemEventRecorder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Diagnostics; 3 | using System.Globalization; 4 | using CsvHelper; 5 | 6 | namespace FileSystemEventRecorder; 7 | 8 | // event received from C# FileSystemWatcher 9 | internal record EventRecord( 10 | string FullPath, 11 | string EventName, 12 | string? OldFullPath, // only provided by "rename" event 13 | long NowInTicks 14 | ); 15 | 16 | // post processed. Calculated before closing program. 17 | // data is written to CSV already in the format FileSystemEventArgs requires it (separate dir + filename) 18 | internal record EventRecordWithDiff( 19 | string Directory, 20 | string FileName, 21 | string EventName, 22 | string? OldFileName, 23 | long DiffInTicks, // ticks between passed by from the previous event to now 24 | double DiffInMilliseconds // milliseconds between previous event and now. 25 | ); 26 | 27 | /// 28 | /// Command line tool to capture the raw events of the native FileSystemWatcher class in a CSV file 29 | /// 30 | public static class FileSystemEventRecords 31 | { 32 | private static readonly ConcurrentQueue EventRecords = new(); 33 | 34 | public static void Main(string[] args) 35 | { 36 | var (watchedDirectory, csvOutputFile) = ProcessArguments(args); 37 | 38 | var watcher = new FileSystemWatcher(); 39 | watcher.Path = watchedDirectory; 40 | watcher.IncludeSubdirectories = true; 41 | watcher.NotifyFilter = NotifyFilters.LastWrite 42 | | NotifyFilters.FileName 43 | | NotifyFilters.DirectoryName; 44 | 45 | watcher.Created += (_, ev) => 46 | EventRecords.Enqueue(new EventRecord(ev.FullPath, "created", null, Stopwatch.GetTimestamp())); 47 | watcher.Deleted += (_, ev) => 48 | EventRecords.Enqueue(new EventRecord(ev.FullPath, "deleted", null, Stopwatch.GetTimestamp())); 49 | 50 | watcher.Changed += (_, ev) => 51 | EventRecords.Enqueue(new EventRecord(ev.FullPath, "changed", null, Stopwatch.GetTimestamp())); 52 | watcher.Renamed += (_, ev) => 53 | EventRecords.Enqueue(new EventRecord(ev.FullPath, "renamed", ev.OldFullPath, Stopwatch.GetTimestamp())); 54 | watcher.Error += (_, ev) => 55 | { 56 | EventRecords.Enqueue(new EventRecord("", "error", null, Stopwatch.GetTimestamp())); 57 | Console.WriteLine($"Error: {ev.GetException()}"); 58 | }; 59 | 60 | // taken from existing code 61 | watcher.InternalBufferSize = 32768; 62 | watcher.EnableRaisingEvents = true; 63 | 64 | Console.WriteLine($"Recording. Now go ahead and perform the desired file system activities in {watchedDirectory}. " + 65 | "Press CTRL + C to stop the recording."); 66 | Console.CancelKeyPress += (_, _) => 67 | { 68 | Console.WriteLine("Exiting."); 69 | ProcessQueueAndWriteToDisk(csvOutputFile); 70 | Environment.Exit(0); 71 | }; 72 | 73 | while (true) 74 | { 75 | Thread.Sleep(200); 76 | } 77 | } 78 | 79 | private static (string, string) ProcessArguments(string[] args) 80 | { 81 | if (args.Length < 2) 82 | { 83 | Console.WriteLine("Usage: dotnet run [directory to be watched] [output csv file]"); 84 | Environment.Exit(1); 85 | } 86 | 87 | return (args[0], args[1]); 88 | } 89 | 90 | private static void ProcessQueueAndWriteToDisk(string csvOutputFile) 91 | { 92 | if (EventRecords.IsEmpty) 93 | { 94 | Console.WriteLine("Detected no file system events. Nothing is written."); 95 | } 96 | else 97 | { 98 | Console.WriteLine($"Recorded {EventRecords.Count} file system events."); 99 | var records = MapToDiffTicks(); 100 | 101 | Console.WriteLine($"Writing CSV to {csvOutputFile}."); 102 | using (var writer = new StreamWriter(csvOutputFile)) 103 | using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture)) 104 | { 105 | csv.WriteRecords(records); 106 | } 107 | 108 | Console.WriteLine("Done."); 109 | } 110 | } 111 | 112 | // post-process queue. Calculate difference between previous and current event 113 | private static IEnumerable MapToDiffTicks() 114 | { 115 | List eventsWithDiffs = new(); 116 | long previousTicks = 0; 117 | foreach (var eventRecord in EventRecords) 118 | { 119 | var diff = previousTicks switch 120 | { 121 | 0 => 0, // first run 122 | _ => eventRecord.NowInTicks - previousTicks 123 | }; 124 | 125 | previousTicks = eventRecord.NowInTicks; 126 | double diffInMilliseconds = Convert.ToInt64(new TimeSpan(diff).TotalMilliseconds); 127 | 128 | var directory = Path.GetDirectoryName(eventRecord.FullPath) ?? ""; 129 | var fileName = Path.GetFileName(eventRecord.FullPath); 130 | var oldFileName = Path.GetFileName(eventRecord.OldFullPath); 131 | 132 | var record = new EventRecordWithDiff( 133 | directory, 134 | fileName, 135 | eventRecord.EventName, 136 | oldFileName, 137 | diff, 138 | diffInMilliseconds); 139 | eventsWithDiffs.Add(record); 140 | } 141 | 142 | return eventsWithDiffs; 143 | } 144 | } -------------------------------------------------------------------------------- /Source/FileSystemEventRecorder/FileSystemEventRecorder.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Source/FileSystemEventRecorder/README.md: -------------------------------------------------------------------------------- 1 | # File System Event Recorder 2 | 3 | Command line tool to capture the raw events of [FileSystemWatcher](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) 4 | in a CSV file. The CSV file can than be used to write integration tests against *FileWatcherEx*. 5 | 6 | Usage: 7 | ````sh 8 | dotnet run C:\temp\fwtest\ C:\temp\fwevents.csv 9 | ```` 10 | 11 | Example output: 12 | ````csv 13 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 14 | C:\temp\fwtest,a.txt,created,,0,0 15 | C:\temp\fwtest,b.txt,renamed,a.txt,1265338,127 16 | C:\temp\fwtest,b.txt,deleted,,6660690,666 17 | ```` -------------------------------------------------------------------------------- /Source/FileWatcherEx.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.31911.260 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileWatcherEx", "FileWatcherEx\FileWatcherEx.csproj", "{CF81727D-B6EC-4202-8E78-087C5D5EABF3}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo.WinForms", "Demo\Demo.WinForms.csproj", "{F973F462-7769-433C-AEC1-17AF2902ED2D}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileWatcherExTests", "FileWatcherExTests\FileWatcherExTests.csproj", "{1C0CA67C-369E-4258-B661-2C545B50A6FF}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileSystemEventRecorder", "FileSystemEventRecorder\FileSystemEventRecorder.csproj", "{F87993D7-2487-41BD-9044-EBEB54BAD13C}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Release|Any CPU.Build.0 = Release|Any CPU 24 | {F973F462-7769-433C-AEC1-17AF2902ED2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {F973F462-7769-433C-AEC1-17AF2902ED2D}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {F973F462-7769-433C-AEC1-17AF2902ED2D}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {F973F462-7769-433C-AEC1-17AF2902ED2D}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {F87993D7-2487-41BD-9044-EBEB54BAD13C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {F87993D7-2487-41BD-9044-EBEB54BAD13C}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {F87993D7-2487-41BD-9044-EBEB54BAD13C}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {F87993D7-2487-41BD-9044-EBEB54BAD13C}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {7A615E75-F056-422F-952E-9C0CD34E477B} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /Source/FileWatcherEx/FileEvents.cs: -------------------------------------------------------------------------------- 1 | namespace FileWatcherEx; 2 | 3 | public enum ChangeType 4 | { 5 | CHANGED = 0, 6 | CREATED = 1, 7 | DELETED = 2, 8 | RENAMED = 3, 9 | LOG = 4, 10 | } 11 | 12 | 13 | public class FileChangedEvent 14 | { 15 | /// 16 | /// Type of change event 17 | /// 18 | public ChangeType ChangeType { get; set; } 19 | 20 | /// 21 | /// The full path 22 | /// 23 | public string FullPath { get; set; } = ""; 24 | 25 | /// 26 | /// The old full path (used if ChangeType = RENAMED) 27 | /// 28 | public string? OldFullPath { get; set; } = ""; 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /Source/FileWatcherEx/FileSystemWatcherEx.cs: -------------------------------------------------------------------------------- 1 |  2 | using System.Collections.Concurrent; 3 | using System.ComponentModel; 4 | using FileWatcherEx.Helpers; 5 | 6 | namespace FileWatcherEx; 7 | 8 | /// 9 | /// A wrapper of to standardize the events 10 | /// and avoid false change notifications. 11 | /// 12 | public class FileSystemWatcherEx : IDisposable, IFileSystemWatcherEx 13 | { 14 | #region Private Properties 15 | 16 | private Thread? _thread; 17 | private EventProcessor? _processor; 18 | private readonly BlockingCollection _fileEventQueue = new(); 19 | 20 | private SymlinkAwareFileWatcher? _watcher; 21 | private Func? _fswFactory; 22 | private readonly Action _logger; 23 | 24 | // Define the cancellation token. 25 | private CancellationTokenSource? _cancelSource; 26 | 27 | // allow injection of FileSystemWatcherWrapper 28 | internal Func FileSystemWatcherFactory 29 | { 30 | // default to production FileSystemWatcherWrapper (which wrapped the native FileSystemWatcher) 31 | get { return _fswFactory ?? (() => new FileSystemWatcherWrapper()); } 32 | set => _fswFactory = value; 33 | } 34 | 35 | #endregion 36 | 37 | 38 | #region Public Properties 39 | 40 | /// 41 | /// Gets or sets the path of the directory to watch. 42 | /// 43 | public string FolderPath { get; set; } = ""; 44 | 45 | 46 | /// 47 | /// Gets the collection of all the filters used to determine what files are monitored in a directory. 48 | /// 49 | public System.Collections.ObjectModel.Collection Filters { get; } = new(); 50 | 51 | 52 | /// 53 | /// Gets or sets the filter string used to determine what files are monitored in a directory. 54 | /// 55 | public string Filter 56 | { 57 | get => Filters.Count == 0 ? "*" : Filters[0]; 58 | set 59 | { 60 | Filters.Clear(); 61 | Filters.Add(value); 62 | } 63 | } 64 | 65 | 66 | /// 67 | /// Gets or sets the type of changes to watch for. 68 | /// The default is the bitwise OR combination of 69 | /// , 70 | /// , 71 | /// and . 72 | /// 73 | public NotifyFilters NotifyFilter { get; set; } = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName; 74 | 75 | 76 | /// 77 | /// Gets or sets a value indicating whether subdirectories within the specified path should be monitored. 78 | /// 79 | public bool IncludeSubdirectories { get; set; } = false; 80 | 81 | 82 | /// 83 | /// Gets or sets the object used to marshal the event handler calls issued as a result of a directory change. 84 | /// 85 | public ISynchronizeInvoke? SynchronizingObject { get; set; } 86 | 87 | #endregion 88 | 89 | 90 | #region Public Events 91 | 92 | /// 93 | /// Occurs when a file or directory in the specified 94 | /// is changed. 95 | /// 96 | public event DelegateOnChanged? OnChanged; 97 | public delegate void DelegateOnChanged(object? sender, FileChangedEvent e); 98 | 99 | 100 | /// 101 | /// Occurs when a file or directory in the specified 102 | /// is deleted. 103 | /// 104 | public event DelegateOnDeleted? OnDeleted; 105 | public delegate void DelegateOnDeleted(object? sender, FileChangedEvent e); 106 | 107 | 108 | /// 109 | /// Occurs when a file or directory in the specified 110 | /// is created. 111 | /// 112 | public event DelegateOnCreated? OnCreated; 113 | public delegate void DelegateOnCreated(object? sender, FileChangedEvent e); 114 | 115 | 116 | /// 117 | /// Occurs when a file or directory in the specified 118 | /// is renamed. 119 | /// 120 | public event DelegateOnRenamed? OnRenamed; 121 | public delegate void DelegateOnRenamed(object? sender, FileChangedEvent e); 122 | 123 | 124 | /// 125 | /// Occurs when the instance of is unable to continue 126 | /// monitoring changes or when the internal buffer overflows. 127 | /// 128 | public event DelegateOnError? OnError; 129 | public delegate void DelegateOnError(object? sender, ErrorEventArgs e); 130 | 131 | #endregion 132 | 133 | 134 | /// 135 | /// Initialize new instance of 136 | /// 137 | /// 138 | /// Optional Action to log out library internals 139 | public FileSystemWatcherEx(string folderPath = "", Action? logger = null) 140 | { 141 | FolderPath = folderPath; 142 | _logger = logger ?? (_ => {}) ; 143 | } 144 | 145 | 146 | /// 147 | /// Start watching files 148 | /// 149 | public void Start() 150 | { 151 | if (!Directory.Exists(FolderPath)) return; 152 | Stop(); 153 | 154 | 155 | _processor = new EventProcessor((e) => 156 | { 157 | switch (e.ChangeType) 158 | { 159 | case ChangeType.CHANGED: 160 | 161 | InvokeChangedEvent(SynchronizingObject, e); 162 | 163 | void InvokeChangedEvent(object? sender, FileChangedEvent fileEvent) 164 | { 165 | if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) 166 | { 167 | SynchronizingObject.Invoke(new Action(InvokeChangedEvent), new object[] { SynchronizingObject, e }); 168 | } 169 | else 170 | { 171 | OnChanged?.Invoke(SynchronizingObject, e); 172 | } 173 | } 174 | 175 | 176 | break; 177 | 178 | case ChangeType.CREATED: 179 | 180 | InvokeCreatedEvent(SynchronizingObject, e); 181 | 182 | void InvokeCreatedEvent(object? sender, FileChangedEvent fileEvent) 183 | { 184 | if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) 185 | { 186 | SynchronizingObject.Invoke(new Action(InvokeCreatedEvent), new object[] { SynchronizingObject, e }); 187 | } 188 | else 189 | { 190 | OnCreated?.Invoke(SynchronizingObject, e); 191 | } 192 | } 193 | 194 | 195 | break; 196 | 197 | case ChangeType.DELETED: 198 | 199 | InvokeDeletedEvent(SynchronizingObject, e); 200 | 201 | void InvokeDeletedEvent(object? sender, FileChangedEvent fileEvent) 202 | { 203 | if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) 204 | { 205 | SynchronizingObject.Invoke(new Action(InvokeDeletedEvent), new object[] { SynchronizingObject, e }); 206 | } 207 | else 208 | { 209 | OnDeleted?.Invoke(SynchronizingObject, e); 210 | } 211 | } 212 | 213 | 214 | break; 215 | 216 | case ChangeType.RENAMED: 217 | 218 | InvokeRenamedEvent(SynchronizingObject, e); 219 | 220 | void InvokeRenamedEvent(object? sender, FileChangedEvent fileEvent) 221 | { 222 | if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) 223 | { 224 | SynchronizingObject.Invoke(new Action(InvokeRenamedEvent), new object[] { SynchronizingObject, e }); 225 | } 226 | else 227 | { 228 | OnRenamed?.Invoke(SynchronizingObject, e); 229 | } 230 | } 231 | 232 | 233 | break; 234 | 235 | default: 236 | break; 237 | } 238 | }, (log) => 239 | { 240 | Console.WriteLine($"{Enum.GetName(typeof(ChangeType), ChangeType.LOG)} | {log}"); 241 | }); 242 | 243 | _cancelSource = new CancellationTokenSource(); 244 | _thread = new Thread(() => Thread_DoingWork(_cancelSource.Token)) 245 | { 246 | // this ensures the thread does not block the process from terminating! 247 | IsBackground = true 248 | }; 249 | 250 | _thread.Start(); 251 | 252 | 253 | // Log each event in our special format to output queue 254 | void OnEvent(FileChangedEvent e) 255 | { 256 | _fileEventQueue.Add(e); 257 | } 258 | 259 | 260 | void OnError(ErrorEventArgs e) 261 | { 262 | if (e != null) 263 | { 264 | this.OnError?.Invoke(this, e); 265 | } 266 | } 267 | 268 | _watcher = new SymlinkAwareFileWatcher(FolderPath, OnEvent, OnError, FileSystemWatcherFactory, _logger) 269 | { 270 | NotifyFilter = NotifyFilter, 271 | IncludeSubdirectories = IncludeSubdirectories, 272 | SynchronizingObject = SynchronizingObject, 273 | EnableRaisingEvents = true 274 | }; 275 | Filters.ToList().ForEach(_watcher.Filters.Add); 276 | _watcher.Init(); 277 | } 278 | 279 | 280 | internal void StartForTesting( 281 | Func getFileAttributesFunc, 282 | Func getDirectoryInfosFunc) 283 | { 284 | Start(); 285 | if (_watcher is null) return; 286 | _watcher.GetFileAttributesFunc = getFileAttributesFunc; 287 | _watcher.GetDirectoryInfosFunc = getDirectoryInfosFunc; 288 | } 289 | 290 | 291 | /// 292 | /// Stop watching files 293 | /// 294 | public void Stop() 295 | { 296 | _watcher?.Dispose(); 297 | _watcher = null; 298 | 299 | // stop the thread 300 | _cancelSource?.Dispose(); 301 | _cancelSource = null; 302 | } 303 | 304 | 305 | /// 306 | /// Dispose the FileWatcherEx instance 307 | /// 308 | public void Dispose() 309 | { 310 | Stop(); 311 | 312 | GC.SuppressFinalize(this); 313 | } 314 | 315 | 316 | private void Thread_DoingWork(CancellationToken cancelToken) 317 | { 318 | while (true) 319 | { 320 | if (cancelToken.IsCancellationRequested) 321 | return; 322 | 323 | try 324 | { 325 | var e = _fileEventQueue.Take(cancelToken); 326 | _processor?.ProcessEvent(e); 327 | } 328 | catch (OperationCanceledException) 329 | { 330 | return; 331 | } 332 | } 333 | } 334 | } 335 | 336 | -------------------------------------------------------------------------------- /Source/FileWatcherEx/FileWatcherEx.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0;net7.0;net8.0 5 | enable 6 | enable 7 | Copyright © 2018-2023 Duong Dieu Phap 8 | https://github.com/d2phap/FileWatcherEx 9 | README.md 10 | https://github.com/d2phap/FileWatcherEx 11 | git 12 | filewatcher, filesystemwatcher, io, filesystemevent, monitor, file-watcher, file-monitoring, realtime, file-systems, file-system-events, monitor-file-system, fs, fsevents 13 | 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. 14 | True 15 | $(Version) 16 | 2.6.0 17 | See https://github.com/d2phap/FileWatcherEx/releases 18 | d2phap 19 | FileWatcherEx - A file system watcher 20 | True 21 | snupkg 22 | LICENSE 23 | False 24 | 25 | 26 | 27 | 28 | True 29 | \ 30 | 31 | 32 | True 33 | \ 34 | 35 | 36 | 37 | 38 | 39 | <_Parameter1>FileWatcherExTests 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Source/FileWatcherEx/Helpers/EventNormalizer.cs: -------------------------------------------------------------------------------- 1 | using static FileWatcherEx.ChangeType; 2 | 3 | namespace FileWatcherEx.Helpers; 4 | 5 | /// 6 | /// Tries to fix the real life oddities of the underlying FileSystemWatcher class. 7 | /// The code here got refactored from the original Microsoft sources. 8 | /// For real scenario, see EventNormalizerTest.cs 9 | /// 10 | internal class EventNormalizer 11 | { 12 | private readonly FileEventRepository _eventRepo = new(); 13 | 14 | internal IEnumerable Normalize(FileChangedEvent[] events) 15 | { 16 | NormalizeDuplicates(events); 17 | return FilterDeleted(_eventRepo.Events()); 18 | } 19 | 20 | private void NormalizeDuplicates(FileChangedEvent[] events) 21 | { 22 | foreach (var newEvent in events) 23 | { 24 | var oldEvent = _eventRepo.Find(newEvent.FullPath); 25 | // original file event from which we renamed, only applicable for RENAMED event 26 | var renameFromEvent = newEvent.ChangeType == RENAMED 27 | ? _eventRepo.Find(newEvent.OldFullPath) 28 | : null; 29 | 30 | switch (newEvent.ChangeType) 31 | { 32 | // CREATED followed by CHANGED => CREATED 33 | case CHANGED when oldEvent?.ChangeType == CREATED: 34 | // Do nothing 35 | break; 36 | 37 | // CREATED followed by DELETED => remove 38 | case DELETED when oldEvent?.ChangeType == CREATED: 39 | _eventRepo.Remove(oldEvent); 40 | break; 41 | 42 | // DELETED followed by CREATED => CHANGED 43 | case CREATED when oldEvent?.ChangeType == DELETED: 44 | oldEvent.ChangeType = CHANGED; 45 | break; 46 | 47 | // Scenario: 48 | // - file foo is created 49 | // - file bar is deleted 50 | // - now foo is renamed to the just deleted bar 51 | // - this results into a bar changed event 52 | case RENAMED when oldEvent?.ChangeType == DELETED && renameFromEvent?.ChangeType == CREATED: 53 | newEvent.ChangeType = CHANGED; 54 | newEvent.OldFullPath = null; 55 | _eventRepo.AddOrUpdate(newEvent); 56 | 57 | // Remove data about the CREATED file 58 | _eventRepo.Remove(renameFromEvent); 59 | break; 60 | 61 | // rename from CREATED file, all other cases 62 | case RENAMED when renameFromEvent?.ChangeType == CREATED: 63 | newEvent.ChangeType = CREATED; 64 | newEvent.OldFullPath = null; 65 | _eventRepo.AddOrUpdate(newEvent); 66 | 67 | // Remove data about the CREATED file 68 | _eventRepo.Remove(renameFromEvent); 69 | break; 70 | 71 | case RENAMED when renameFromEvent?.ChangeType == RENAMED: 72 | newEvent.OldFullPath = renameFromEvent.OldFullPath; 73 | _eventRepo.AddOrUpdate(newEvent); 74 | 75 | // Remove data about the RENAMED file 76 | _eventRepo.Remove(renameFromEvent); 77 | break; 78 | 79 | // the LOG event is not coming from the filesystem, hence it is ignored. 80 | // ideally, LOG would disappear completely but unfortunately it is part of the public API of this lib 81 | case LOG: 82 | // ignore 83 | break; 84 | 85 | default: 86 | _eventRepo.AddOrUpdate(newEvent); 87 | break; 88 | } 89 | } 90 | } 91 | 92 | // This algorithm will remove all DELETE events up to the root folder 93 | // that got deleted if any. This ensures that we are not producing 94 | // DELETE events for each file inside a folder that gets deleted. 95 | // 96 | // 1.) split ADD/CHANGE and DELETED events 97 | // 2.) sort short deleted paths to the top 98 | // 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case 99 | internal static IEnumerable FilterDeleted(IEnumerable eventsWithoutDuplicates) 100 | { 101 | // Handle deletes 102 | var deletedPaths = new List(); 103 | return eventsWithoutDuplicates 104 | .Select((e, n) => new KeyValuePair(n, e)) // store original position value 105 | .OrderBy(e => e.Value.FullPath.Length) // shortest path first 106 | .Where(e => IsParent(e.Value, deletedPaths)) 107 | .OrderBy(e => e.Key) // restore original position 108 | .Select(e => e.Value); 109 | } 110 | 111 | internal static bool IsParent(FileChangedEvent e, List deletedPaths) 112 | { 113 | if (e.ChangeType == DELETED) 114 | { 115 | if (deletedPaths.Any(d => IsParent(e.FullPath, d))) 116 | { 117 | return false; // DELETE is ignored if parent is deleted already 118 | } 119 | 120 | // otherwise mark as deleted 121 | deletedPaths.Add(e.FullPath); 122 | } 123 | 124 | return true; 125 | } 126 | 127 | 128 | internal static bool IsParent(string path, string candidatePath) 129 | { 130 | // if exists, remove trailing "\" for both paths 131 | candidatePath = candidatePath.TrimEnd('\\'); 132 | path = path.TrimEnd('\\'); 133 | return path.IndexOf(candidatePath + '\\', StringComparison.Ordinal) == 0; 134 | } 135 | 136 | 137 | private class FileEventRepository 138 | { 139 | private readonly Dictionary _mapPathToEvents = new(); 140 | 141 | public void AddOrUpdate(FileChangedEvent newEvent) 142 | { 143 | if (_mapPathToEvents.TryGetValue(newEvent.FullPath, out var oldEvent)) 144 | { 145 | // update existing 146 | oldEvent.ChangeType = newEvent.ChangeType; 147 | oldEvent.OldFullPath = newEvent.OldFullPath; 148 | } 149 | else 150 | { 151 | // add 152 | _mapPathToEvents[newEvent.FullPath] = newEvent; 153 | } 154 | } 155 | 156 | public void Remove(FileChangedEvent ev) 157 | { 158 | _mapPathToEvents.Remove(ev.FullPath); 159 | } 160 | 161 | public FileChangedEvent? Find(string? path) 162 | { 163 | _mapPathToEvents.TryGetValue(path ?? "", out var oldEvent); 164 | return oldEvent; 165 | } 166 | 167 | public List Events() 168 | { 169 | return _mapPathToEvents.Values.ToList(); 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Source/FileWatcherEx/Helpers/EventProcessor.cs: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | namespace FileWatcherEx.Helpers; 6 | 7 | internal class EventProcessor 8 | { 9 | /// 10 | /// Aggregate and only emit events when changes have stopped for this duration (in ms) 11 | /// 12 | private const int EventDelay = 50; 13 | 14 | /// 15 | /// Warn after certain time span of event spam 16 | /// 17 | private readonly TimeSpan _eventSpamWarningThreshold = TimeSpan.FromMinutes(1); 18 | 19 | private readonly object _lock = new(); 20 | private Task? _delayTask = null; 21 | 22 | private readonly List _events = new(); 23 | private readonly Action _handleEvent; 24 | 25 | private readonly Action _logger; 26 | 27 | private long _lastEventTime = 0; 28 | private long _delayStarted = 0; 29 | 30 | private long _spamCheckStartTime = 0; 31 | private bool _spamWarningLogged = false; 32 | 33 | public EventProcessor(Action onEvent, Action onLogging) 34 | { 35 | _handleEvent = onEvent; 36 | _logger = onLogging; 37 | } 38 | 39 | 40 | public void ProcessEvent(FileChangedEvent fileEvent) 41 | { 42 | lock (_lock) 43 | { 44 | var now = DateTime.Now.Ticks; 45 | WarnForSpam(fileEvent, now); 46 | 47 | // Add into our queue 48 | _events.Add(fileEvent); 49 | _lastEventTime = now; 50 | 51 | // Process queue after delay 52 | if (_delayTask == null) 53 | { 54 | // Start function after delay 55 | _delayStarted = _lastEventTime; 56 | _delayTask = Task.Delay(EventDelay).ContinueWith(HandleEventsFunc); 57 | } 58 | } 59 | } 60 | 61 | private void HandleEventsFunc(Task _) 62 | { 63 | lock (_lock) 64 | { 65 | // Check if another event has been received in the meantime 66 | if (_delayStarted == _lastEventTime) 67 | { 68 | // Normalize and handle 69 | var normalized = new EventNormalizer().Normalize(_events.ToArray()); 70 | foreach (var ev in normalized) 71 | { 72 | _handleEvent(ev); 73 | } 74 | 75 | // Reset 76 | _events.Clear(); 77 | _delayTask = null; 78 | } 79 | 80 | // Otherwise we have received a new event while this task was 81 | // delayed and we reschedule it. 82 | else 83 | { 84 | _delayStarted = _lastEventTime; 85 | _delayTask = Task.Delay(EventDelay).ContinueWith(HandleEventsFunc); 86 | } 87 | } 88 | } 89 | 90 | private void WarnForSpam(FileChangedEvent fileEvent, long now) 91 | { 92 | if (_events.Count == 0) 93 | { 94 | _spamWarningLogged = false; 95 | _spamCheckStartTime = now; 96 | } 97 | else if (! _spamWarningLogged && _spamCheckStartTime + _eventSpamWarningThreshold.Ticks < now) 98 | { 99 | _spamWarningLogged = true; 100 | _logger($"Warning: Watcher is busy catching up with {_events.Count} file changes " + 101 | $"in {_eventSpamWarningThreshold.TotalSeconds} seconds. Latest path is '{fileEvent.FullPath}'"); 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /Source/FileWatcherEx/Helpers/FileSystemWatcherWrapper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.ComponentModel; 3 | 4 | namespace FileWatcherEx; 5 | 6 | /// 7 | /// Interface around .NET FileSystemWatcher to be able to replace it with a fake implementation 8 | /// 9 | public interface IFileSystemWatcherWrapper 10 | { 11 | string Path { get; set; } 12 | 13 | Collection Filters { get; } 14 | bool IncludeSubdirectories { get; set; } 15 | bool EnableRaisingEvents { get; set; } 16 | NotifyFilters NotifyFilter { get; set; } 17 | 18 | event FileSystemEventHandler Created; 19 | event FileSystemEventHandler Deleted; 20 | event FileSystemEventHandler Changed; 21 | event RenamedEventHandler Renamed; 22 | event ErrorEventHandler Error; 23 | 24 | int InternalBufferSize { get; set; } 25 | 26 | public ISynchronizeInvoke? SynchronizingObject { get; set; } 27 | 28 | void Dispose(); 29 | } 30 | 31 | /// 32 | /// Production implementation of IFileSystemWrapper interface. 33 | /// Backed by the existing FileSystemWatcher 34 | /// 35 | public class FileSystemWatcherWrapper : FileSystemWatcher, IFileSystemWatcherWrapper 36 | { 37 | // intentionally empty 38 | } 39 | 40 | -------------------------------------------------------------------------------- /Source/FileWatcherEx/Helpers/SymlinkAwareFileWatcher.cs: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------- 2 | * Copyright (C) Microsoft Corporation. All rights reserved. 3 | *--------------------------------------------------------*/ 4 | 5 | using System.Collections.ObjectModel; 6 | using System.ComponentModel; 7 | 8 | namespace FileWatcherEx.Helpers; 9 | 10 | internal class SymlinkAwareFileWatcher : IDisposable 11 | { 12 | private readonly string _watchPath; 13 | private readonly Action? _eventCallback; 14 | private readonly Action? _onError; 15 | private Func? _getFileAttributesFunc; 16 | private Func? _getDirectoryInfosFunc; 17 | private readonly Func _watcherFactory; 18 | private readonly Action _logger; 19 | 20 | 21 | internal Func GetFileAttributesFunc 22 | { 23 | get => _getFileAttributesFunc ?? File.GetAttributes; 24 | set => _getFileAttributesFunc = value; 25 | } 26 | 27 | internal Func GetDirectoryInfosFunc 28 | { 29 | get 30 | { 31 | DirectoryInfo[] DefaultFunc(string p) => new DirectoryInfo(p).GetDirectories(); 32 | return _getDirectoryInfosFunc ?? DefaultFunc; 33 | } 34 | set => _getDirectoryInfosFunc = value; 35 | } 36 | 37 | internal Dictionary FileWatchers { get; } = new(); 38 | 39 | // defaults from: 40 | // https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher.notifyfilter?view=net-7.0#property-value 41 | public NotifyFilters NotifyFilter { get; set; } = NotifyFilters.LastWrite 42 | | NotifyFilters.FileName 43 | | NotifyFilters.DirectoryName; 44 | public bool EnableRaisingEvents { get; set; } 45 | 46 | public bool IncludeSubdirectories { get; set; } 47 | 48 | public Collection Filters { get; } = new(); 49 | 50 | public ISynchronizeInvoke? SynchronizingObject { get; set; } 51 | 52 | 53 | /// 54 | /// Create new instance of . 55 | /// Object creation follows this order: 56 | /// 57 | /// 1) create new instance 58 | /// 2) set properties (optional) 59 | /// 3) call init() (mandatory) 60 | /// 61 | /// 62 | /// Full folder path to watcher 63 | /// onEvent callback 64 | /// onError callback 65 | /// how to create a FileSystemWatcher 66 | /// logging callback 67 | public SymlinkAwareFileWatcher(string path, Action onEvent, Action onError, 68 | Func watcherFactory, Action logger) 69 | { 70 | _watchPath = path; 71 | _eventCallback = onEvent; 72 | _onError = onError; 73 | _watcherFactory = watcherFactory; 74 | _logger = logger; 75 | } 76 | 77 | public void Init() 78 | { 79 | RegisterFileWatcher(_watchPath); 80 | RegisterAdditionalFileWatchersForSymLinkDirs(_watchPath); 81 | } 82 | 83 | private void RegisterFileWatcher(string path) 84 | { 85 | _logger($"Registering file watcher for {path}"); 86 | var fileWatcher = _watcherFactory(); 87 | SetFileWatcherProperties(fileWatcher, path); 88 | RegisterFileWatcherEventHandlers(fileWatcher); 89 | 90 | FileWatchers.Add(path, fileWatcher); 91 | } 92 | 93 | private void SetFileWatcherProperties(IFileSystemWatcherWrapper fileWatcher, string path) 94 | { 95 | fileWatcher.Path = path; 96 | fileWatcher.NotifyFilter = NotifyFilter; 97 | fileWatcher.IncludeSubdirectories = IncludeSubdirectories; 98 | fileWatcher.EnableRaisingEvents = EnableRaisingEvents; 99 | Filters.ToList().ForEach(filter => fileWatcher.Filters.Add(filter)); 100 | 101 | // currently the sync object is only registered for the root file watcher. 102 | // this preserves the old behaviour 103 | if (IsRootPath(path)) 104 | { 105 | fileWatcher.SynchronizingObject = SynchronizingObject; 106 | } 107 | 108 | //changing this to a higher value can lead into issues when watching UNC drives 109 | fileWatcher.InternalBufferSize = 32768; 110 | } 111 | 112 | 113 | private bool IsRootPath(string path) 114 | { 115 | return _watchPath == path; 116 | } 117 | 118 | private void RegisterFileWatcherEventHandlers(IFileSystemWatcherWrapper fileWatcher) 119 | { 120 | fileWatcher.Created += (_, e) => ProcessEvent(e, ChangeType.CREATED); 121 | fileWatcher.Changed += (_, e) => ProcessEvent(e, ChangeType.CHANGED); 122 | fileWatcher.Deleted += (_, e) => ProcessEvent(e, ChangeType.DELETED); 123 | fileWatcher.Renamed += (_, e) => ProcessRenamedEvent(e); 124 | fileWatcher.Error += (_, e) => _onError?.Invoke(e); 125 | 126 | // extra measures to handle symbolic link directories 127 | fileWatcher.Created += (_, e) => TryRegisterFileWatcherForSymbolicLinkDir(e.FullPath); 128 | fileWatcher.Deleted += UnregisterFileWatcherForSymbolicLinkDir; 129 | } 130 | 131 | /// 132 | /// Recursively find sym link dir and register them. 133 | /// Background: the native filewatcher does not follow symlinks so they need to be treated separately. 134 | /// 135 | private void RegisterAdditionalFileWatchersForSymLinkDirs(string path) 136 | { 137 | TryRegisterFileWatcherForSymbolicLinkDir(path); 138 | 139 | if (!IncludeSubdirectories || !Directory.Exists(path)) 140 | { 141 | return; 142 | } 143 | 144 | foreach (var dirInfo in GetDirectoryInfosFunc(path)) 145 | { 146 | RegisterAdditionalFileWatchersForSymLinkDirs(dirInfo.FullName); 147 | } 148 | } 149 | 150 | 151 | /// 152 | /// Process event for type = [CHANGED; DELETED; CREATED] 153 | /// 154 | private void ProcessEvent(FileSystemEventArgs e, ChangeType changeType) 155 | { 156 | _eventCallback?.Invoke(new() 157 | { 158 | ChangeType = changeType, 159 | FullPath = e.FullPath, 160 | }); 161 | } 162 | 163 | 164 | private void ProcessRenamedEvent(RenamedEventArgs e) 165 | { 166 | _eventCallback?.Invoke(new() 167 | { 168 | ChangeType = ChangeType.RENAMED, 169 | FullPath = e.FullPath, 170 | OldFullPath = e.OldFullPath, 171 | }); 172 | } 173 | 174 | 175 | /// 176 | /// Safely register a file watcher for a symbolic link directory. Used at startup as well as callback on file creation. 177 | /// 178 | /// 179 | internal void TryRegisterFileWatcherForSymbolicLinkDir(string path) 180 | { 181 | try 182 | { 183 | if (IsSymbolicLinkDirectory(path) && IncludeSubdirectories && !FileWatchers.ContainsKey(path)) 184 | { 185 | _logger($"Directory {path} is a symbolic link dir. Will register additional file watcher."); 186 | RegisterFileWatcher(path); 187 | } 188 | } 189 | catch (Exception ex) 190 | { 191 | // IG Issue #405: throws exception on Windows 10 192 | // for "c:\users\user\application data" folder and sub-folders. 193 | _logger($"Error registering file system watcher for directory '{path}'. Error was: {ex.Message}"); 194 | } 195 | } 196 | 197 | 198 | /// 199 | /// Cleanup filewatcher if a symbolic link dir is deleted 200 | /// 201 | internal void UnregisterFileWatcherForSymbolicLinkDir(object? _, FileSystemEventArgs e) 202 | { 203 | if (FileWatchers.ContainsKey(e.FullPath)) 204 | { 205 | FileWatchers[e.FullPath].Dispose(); 206 | FileWatchers.Remove(e.FullPath); 207 | } 208 | } 209 | 210 | 211 | private bool IsSymbolicLinkDirectory(string path) 212 | { 213 | var attrs = GetFileAttributesFunc(path); 214 | return attrs.HasFlag(FileAttributes.Directory) 215 | && attrs.HasFlag(FileAttributes.ReparsePoint); 216 | } 217 | 218 | // for testing 219 | internal List GetFileWatchers() 220 | { 221 | return FileWatchers.Values.ToList(); 222 | } 223 | 224 | 225 | /// 226 | /// Stop raising events and Dispose all filewatchers 227 | /// 228 | public void Dispose() 229 | { 230 | foreach (var watcher in FileWatchers.Select(pair => pair.Value)) 231 | { 232 | watcher.EnableRaisingEvents = false; 233 | watcher.Dispose(); 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /Source/FileWatcherEx/IFileSystemWatcherEx.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace FileWatcherEx; 4 | 5 | public interface IFileSystemWatcherEx 6 | { 7 | /// 8 | /// Gets or sets the path of the directory to watch. 9 | /// 10 | string FolderPath { get; set; } 11 | 12 | /// 13 | /// Gets the collection of all the filters used to determine what files are monitored in a directory. 14 | /// 15 | System.Collections.ObjectModel.Collection Filters { get; } 16 | 17 | /// 18 | /// Gets or sets the filter string used to determine what files are monitored in a directory. 19 | /// 20 | string Filter { get; set; } 21 | 22 | /// 23 | /// Gets or sets the type of changes to watch for. 24 | /// The default is the bitwise OR combination of 25 | /// , 26 | /// , 27 | /// and . 28 | /// 29 | NotifyFilters NotifyFilter { get; set; } 30 | 31 | /// 32 | /// Gets or sets a value indicating whether subdirectories within the specified path should be monitored. 33 | /// 34 | bool IncludeSubdirectories { get; set; } 35 | 36 | /// 37 | /// Gets or sets the object used to marshal the event handler calls issued as a result of a directory change. 38 | /// 39 | ISynchronizeInvoke? SynchronizingObject { get; set; } 40 | 41 | /// 42 | /// Occurs when a file or directory in the specified 43 | /// is changed. 44 | /// 45 | event FileSystemWatcherEx.DelegateOnChanged? OnChanged; 46 | 47 | /// 48 | /// Occurs when a file or directory in the specified 49 | /// is deleted. 50 | /// 51 | event FileSystemWatcherEx.DelegateOnDeleted? OnDeleted; 52 | 53 | /// 54 | /// Occurs when a file or directory in the specified 55 | /// is created. 56 | /// 57 | event FileSystemWatcherEx.DelegateOnCreated? OnCreated; 58 | 59 | /// 60 | /// Occurs when a file or directory in the specified 61 | /// is renamed. 62 | /// 63 | event FileSystemWatcherEx.DelegateOnRenamed? OnRenamed; 64 | 65 | /// 66 | /// Occurs when the instance of is unable to continue 67 | /// monitoring changes or when the internal buffer overflows. 68 | /// 69 | event FileSystemWatcherEx.DelegateOnError? OnError; 70 | 71 | /// 72 | /// Start watching files 73 | /// 74 | void Start(); 75 | 76 | /// 77 | /// Stop watching files 78 | /// 79 | void Stop(); 80 | 81 | /// 82 | /// Dispose the FileWatcherEx instance 83 | /// 84 | void Dispose(); 85 | } 86 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/EventNormalizerTest.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | using FileWatcherEx; 3 | using FileWatcherEx.Helpers; 4 | 5 | namespace FileWatcherExTests; 6 | 7 | public class EventNormalizerTest 8 | { 9 | [Fact] 10 | public void No_Input_Gives_No_Output() 11 | { 12 | var events = NormalizeEvents(Array.Empty()); 13 | Assert.Empty(events); 14 | } 15 | 16 | [Fact] 17 | public void Single_Event_Is_Passed_Through() 18 | { 19 | var events = NormalizeEvents( 20 | new FileChangedEvent() 21 | { 22 | ChangeType = ChangeType.CREATED, 23 | FullPath = @"c:\foo", 24 | OldFullPath = @"c:\bar" 25 | } 26 | ); 27 | 28 | Assert.Single(events); 29 | var ev = events.First(); 30 | Assert.Equal(ChangeType.CREATED, ev.ChangeType); 31 | Assert.Equal(@"c:\foo", ev.FullPath); 32 | Assert.Equal(@"c:\bar", ev.OldFullPath); 33 | } 34 | 35 | [Fact] 36 | public void On_Duplicate_Events_The_Latest_Is_Taken() 37 | { 38 | var events = NormalizeEvents( 39 | new FileChangedEvent 40 | { 41 | ChangeType = ChangeType.CREATED, 42 | FullPath = @"c:\foo", 43 | OldFullPath = null 44 | }, 45 | new FileChangedEvent 46 | { 47 | ChangeType = ChangeType.RENAMED, // differs 48 | FullPath = @"c:\foo", 49 | OldFullPath = @"c:\bar" // differs as well 50 | } 51 | ); 52 | 53 | Assert.Single(events); 54 | var ev = events.First(); 55 | Assert.Equal(ChangeType.RENAMED, ev.ChangeType); 56 | Assert.Equal(@"c:\foo", ev.FullPath); 57 | Assert.Equal(@"c:\bar", ev.OldFullPath); 58 | } 59 | 60 | [Fact] 61 | public void On_Consecutive_Renaming_The_Events_Are_Merged() 62 | { 63 | var events = NormalizeEvents( 64 | new FileChangedEvent 65 | { 66 | ChangeType = ChangeType.RENAMED, 67 | FullPath = @"c:\foo", 68 | OldFullPath = @"c:\bar" 69 | }, 70 | new FileChangedEvent 71 | { 72 | ChangeType = ChangeType.RENAMED, 73 | FullPath = @"c:\bazz", 74 | OldFullPath = @"c:\foo" // refers to previous renamedEvent1.FullPath 75 | } 76 | ); 77 | Assert.Single(events); 78 | var ev = events.First(); 79 | Assert.Equal(ChangeType.RENAMED, ev.ChangeType); 80 | Assert.Equal(@"c:\bazz", ev.FullPath); 81 | Assert.Equal(@"c:\bar", ev.OldFullPath); 82 | } 83 | 84 | [Fact] 85 | public void Rename_After_Create_Gives_Created_Event_With_Updated_Path() 86 | { 87 | var events = NormalizeEvents( 88 | new FileChangedEvent 89 | { 90 | ChangeType = ChangeType.CREATED, 91 | FullPath = @"c:\foo", 92 | OldFullPath = @"c:\bar" 93 | }, 94 | new FileChangedEvent 95 | { 96 | ChangeType = ChangeType.RENAMED, 97 | FullPath = @"c:\bar", 98 | OldFullPath = @"c:\foo" // refers to previous createdEvent.FullPath 99 | } 100 | ); 101 | 102 | Assert.Single(events); 103 | var ev = events.First(); 104 | Assert.Equal(ChangeType.CREATED, ev.ChangeType); 105 | Assert.Equal(@"c:\bar", ev.FullPath); 106 | Assert.Null(ev.OldFullPath); 107 | } 108 | 109 | [Fact] 110 | // This is a complex case, originally extracted by using test coverage. 111 | // Scenario: 112 | // - file foo is created 113 | // - file bar is deleted 114 | // - now foo is renamed to the just deleted bar 115 | // - this results into a bar changed event 116 | public void Rename_After_Create_Gives_Changed_Event() 117 | { 118 | var events = NormalizeEvents( 119 | new FileChangedEvent 120 | { 121 | ChangeType = ChangeType.CREATED, 122 | FullPath = @"c:\foo", 123 | OldFullPath = null 124 | }, 125 | new FileChangedEvent 126 | { 127 | ChangeType = ChangeType.DELETED, 128 | FullPath = @"c:\bar", 129 | OldFullPath = null 130 | }, 131 | new FileChangedEvent 132 | { 133 | ChangeType = ChangeType.RENAMED, 134 | FullPath = @"c:\bar", 135 | OldFullPath = @"c:\foo" 136 | } 137 | ); 138 | 139 | Assert.Single(events); 140 | var ev = events.First(); 141 | Assert.Equal(ChangeType.CHANGED, ev.ChangeType); 142 | Assert.Equal(@"c:\bar", ev.FullPath); 143 | Assert.Null(ev.OldFullPath); 144 | } 145 | 146 | [Fact] 147 | public void Result_Suppressed_If_Delete_After_Create() 148 | { 149 | var events = NormalizeEvents( 150 | new FileChangedEvent 151 | { 152 | ChangeType = ChangeType.CREATED, 153 | FullPath = @"c:\foo", 154 | OldFullPath = null 155 | }, 156 | new FileChangedEvent 157 | { 158 | ChangeType = ChangeType.DELETED, 159 | FullPath = @"c:\foo", 160 | OldFullPath = null 161 | } 162 | ); 163 | 164 | Assert.Empty(events); 165 | } 166 | 167 | [Fact] 168 | public void Created_Event_After_Deleted_Results_Into_Changed() 169 | { 170 | var events = NormalizeEvents( 171 | new FileChangedEvent 172 | { 173 | ChangeType = ChangeType.DELETED, 174 | FullPath = @"c:\foo", 175 | OldFullPath = null 176 | }, 177 | new FileChangedEvent 178 | { 179 | ChangeType = ChangeType.CREATED, 180 | FullPath = @"c:\foo", 181 | OldFullPath = null 182 | } 183 | ); 184 | 185 | Assert.Single(events); 186 | var ev = events.First(); 187 | Assert.Equal(ChangeType.CHANGED, ev.ChangeType); 188 | Assert.Equal(@"c:\foo", ev.FullPath); 189 | Assert.Null(ev.OldFullPath); 190 | } 191 | 192 | [Fact] 193 | public void Changed_Event_After_Created_Is_Ignored() 194 | { 195 | var events = NormalizeEvents( 196 | new FileChangedEvent 197 | { 198 | ChangeType = ChangeType.CREATED, 199 | FullPath = @"c:\foo", 200 | OldFullPath = null 201 | }, 202 | new FileChangedEvent 203 | { 204 | ChangeType = ChangeType.CHANGED, 205 | FullPath = @"c:\foo", 206 | OldFullPath = null 207 | } 208 | ); 209 | 210 | Assert.Single(events); 211 | var ev = events.First(); 212 | Assert.Equal(ChangeType.CREATED, ev.ChangeType); 213 | Assert.Equal(@"c:\foo", ev.FullPath); 214 | Assert.Null(ev.OldFullPath); 215 | } 216 | 217 | [Fact] 218 | public void Filter_Passes_Events_Through() 219 | { 220 | var events = new List 221 | { 222 | new() 223 | { 224 | ChangeType = ChangeType.CREATED, 225 | FullPath = @"c:\foo", 226 | OldFullPath = null 227 | }, 228 | new() 229 | { 230 | ChangeType = ChangeType.CHANGED, 231 | FullPath = @"c:\bar", 232 | OldFullPath = null 233 | }, 234 | new() 235 | { 236 | ChangeType = ChangeType.DELETED, 237 | FullPath = @"c:\bazz", 238 | OldFullPath = null 239 | } 240 | }; 241 | 242 | var filtered = EventNormalizer.FilterDeleted(events); 243 | Assert.Equal(events, filtered); 244 | } 245 | 246 | [Fact] 247 | public void Filter_Out_Deleted_Event_With_Subdirectory() 248 | { 249 | var events = new List 250 | { 251 | new() 252 | { 253 | ChangeType = ChangeType.DELETED, 254 | FullPath = @"c:\bar", 255 | OldFullPath = null 256 | }, 257 | new() 258 | { 259 | ChangeType = ChangeType.CREATED, 260 | FullPath = @"c:\foo", 261 | OldFullPath = null 262 | }, 263 | new() 264 | { 265 | ChangeType = ChangeType.DELETED, 266 | FullPath = @"c:\bar\sub", 267 | OldFullPath = null 268 | } 269 | }; 270 | 271 | var filtered = EventNormalizer.FilterDeleted(events).ToList(); 272 | Assert.Equal(2, filtered.Count); 273 | Assert.Equal(ChangeType.DELETED, filtered[0].ChangeType); 274 | Assert.Equal(@"c:\bar", filtered[0].FullPath); 275 | Assert.Equal(ChangeType.CREATED, filtered[1].ChangeType); 276 | Assert.Equal(@"c:\foo", filtered[1].FullPath); 277 | } 278 | 279 | [Theory] 280 | [InlineData(@"c:\a\b", @"c:", true)] 281 | [InlineData(@"c:\a\b", @"c:\a", true)] 282 | [InlineData(@"c:\a\b\", @"c:", true)] 283 | [InlineData(@"c:\a\b\", @"c:\a", true)] 284 | [InlineData(@"c:\a\b", @"c:\", true)] 285 | [InlineData(@"c:\a\b", @"c:\a\", true)] 286 | [InlineData(@"c:\", @"c:\foo", false)] 287 | [InlineData(@"c:\", @"c:\", false)] 288 | public void Is_Parent(string path, string candidatePath, bool expectedResult) 289 | { 290 | Assert.Equal(expectedResult, EventNormalizer.IsParent(path, candidatePath)); 291 | } 292 | 293 | [Fact] 294 | public void Parent_Dir_Is_Detected() 295 | { 296 | var ev = new FileChangedEvent 297 | { 298 | ChangeType = ChangeType.DELETED, 299 | FullPath = @"c:\foo" 300 | }; 301 | 302 | Assert.True(EventNormalizer.IsParent(ev, new List())); 303 | } 304 | 305 | [Fact] 306 | public void Delete_Event_For_Subdirectory_Is_Detected() 307 | { 308 | var deletedFiles = new List(); 309 | 310 | var parentDirEvent = new FileChangedEvent 311 | { 312 | ChangeType = ChangeType.DELETED, 313 | FullPath = @"c:\foo" 314 | }; 315 | 316 | Assert.True(EventNormalizer.IsParent(parentDirEvent, deletedFiles)); 317 | 318 | 319 | var subDirEvent = new FileChangedEvent 320 | { 321 | ChangeType = ChangeType.DELETED, 322 | FullPath = @"c:\foo\bar" 323 | }; 324 | 325 | Assert.False(EventNormalizer.IsParent(subDirEvent, deletedFiles)); 326 | } 327 | 328 | private static List NormalizeEvents(params FileChangedEvent[] events) 329 | { 330 | return new EventNormalizer().Normalize(events).ToList(); 331 | } 332 | } -------------------------------------------------------------------------------- /Source/FileWatcherExTests/FileWatcherExIntegrationTest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using FileWatcherEx; 3 | using FileWatcherExTests.Helper; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace FileWatcherExTests; 8 | 9 | /// 10 | /// Integration/ Golden master test for FileWatcherEx 11 | /// Note: the scenarios where recorded in C:\temp\fwtest 12 | /// 13 | public class FileWatcherExIntegrationTest : IDisposable 14 | { 15 | private readonly ConcurrentQueue _events; 16 | private readonly FileSystemWatcherEx _fileWatcher; 17 | private readonly ReplayFileSystemWatcherFactory _replayFileSystemWatcherFactory; 18 | private readonly TempDir _tempDir; 19 | 20 | public FileWatcherExIntegrationTest(ITestOutputHelper testOutputHelper) 21 | { 22 | // setup before each test run 23 | _events = new ConcurrentQueue(); 24 | _replayFileSystemWatcherFactory = new ReplayFileSystemWatcherFactory(); 25 | 26 | _tempDir = new TempDir(); 27 | _fileWatcher = new FileSystemWatcherEx(_tempDir.FullPath, testOutputHelper.WriteLine); 28 | _fileWatcher.FileSystemWatcherFactory = () => _replayFileSystemWatcherFactory.Create(); 29 | _fileWatcher.IncludeSubdirectories = true; 30 | 31 | _fileWatcher.OnCreated += (_, ev) => _events.Enqueue(ev); 32 | _fileWatcher.OnDeleted += (_, ev) => _events.Enqueue(ev); 33 | _fileWatcher.OnChanged += (_, ev) => _events.Enqueue(ev); 34 | _fileWatcher.OnRenamed += (_, ev) => _events.Enqueue(ev); 35 | } 36 | 37 | 38 | [Fact] 39 | public void Create_Single_File() 40 | { 41 | StartFileWatcherAndReplay(@"scenario/create_file.csv"); 42 | 43 | Assert.Single(_events); 44 | var ev = _events.First(); 45 | Assert.Equal(ChangeType.CREATED, ev.ChangeType); 46 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev.FullPath); 47 | Assert.Equal("", ev.OldFullPath); 48 | } 49 | 50 | 51 | [Fact] 52 | public void Create_And_Remove_Single_File() 53 | { 54 | StartFileWatcherAndReplay(@"scenario/create_and_remove_file.csv"); 55 | 56 | Assert.Equal(2, _events.Count); 57 | var ev1 = _events.ToList()[0]; 58 | var ev2 = _events.ToList()[1]; 59 | 60 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 61 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath); 62 | Assert.Equal("", ev1.OldFullPath); 63 | 64 | Assert.Equal(ChangeType.DELETED, ev2.ChangeType); 65 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.FullPath); 66 | Assert.Equal("", ev2.OldFullPath); 67 | } 68 | 69 | 70 | [Fact] 71 | public void Create_Rename_And_Remove_Single_File() 72 | { 73 | StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file.csv"); 74 | 75 | Assert.Equal(3, _events.Count); 76 | var ev1 = _events.ToList()[0]; 77 | var ev2 = _events.ToList()[1]; 78 | var ev3 = _events.ToList()[2]; 79 | 80 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 81 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath); 82 | 83 | Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); 84 | AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev2.FullPath); 85 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.OldFullPath); 86 | 87 | Assert.Equal(ChangeType.DELETED, ev3.ChangeType); 88 | AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev3.FullPath); 89 | Assert.Equal("", ev3.OldFullPath); 90 | } 91 | 92 | 93 | [Fact] 94 | // filters out 2nd "changed" event 95 | public void Create_Single_File_Via_WSL2() 96 | { 97 | StartFileWatcherAndReplay(@"scenario/create_file_wsl2.csv"); 98 | 99 | Assert.Single(_events); 100 | var ev = _events.First(); 101 | Assert.Equal(ChangeType.CREATED, ev.ChangeType); 102 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev.FullPath); 103 | Assert.Equal("", ev.OldFullPath); 104 | } 105 | 106 | 107 | [Fact] 108 | // scenario creates "created" "changed" and "renamed" event. 109 | // resulting event is just "created" with the filename taken from "renamed" 110 | public void Create_And_Rename_Single_File_Via_WSL2() 111 | { 112 | StartFileWatcherAndReplay(@"scenario/create_and_rename_file_wsl2.csv"); 113 | 114 | Assert.Single(_events); 115 | var ev = _events.First(); 116 | Assert.Equal(ChangeType.CREATED, ev.ChangeType); 117 | AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev.FullPath); 118 | Assert.Null(ev.OldFullPath); 119 | } 120 | 121 | 122 | [Fact] 123 | public void Create_Rename_And_Remove_Single_File_Via_WSL2() 124 | { 125 | StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file_wsl2.csv"); 126 | Assert.Empty(_events); 127 | } 128 | 129 | 130 | [Fact] 131 | public void Create_Rename_And_Remove_Single_File_With_Wait_Time_Via_WSL2() 132 | { 133 | StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv"); 134 | 135 | Assert.Equal(3, _events.Count); 136 | var ev1 = _events.ToList()[0]; 137 | var ev2 = _events.ToList()[1]; 138 | var ev3 = _events.ToList()[2]; 139 | 140 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 141 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath); 142 | 143 | Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); 144 | AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev2.FullPath); 145 | AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.OldFullPath); 146 | 147 | Assert.Equal(ChangeType.DELETED, ev3.ChangeType); 148 | AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev3.FullPath); 149 | Assert.Equal("", ev3.OldFullPath); 150 | } 151 | 152 | 153 | [Fact] 154 | public void Manually_Create_And_Rename_File_Via_Windows_Explorer() 155 | { 156 | StartFileWatcherAndReplay(@"scenario/create_and_rename_file_via_explorer.csv"); 157 | 158 | Assert.Equal(2, _events.Count); 159 | 160 | var ev1 = _events.ToList()[0]; 161 | var ev2 = _events.ToList()[1]; 162 | 163 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 164 | AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev1.FullPath); 165 | 166 | Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); 167 | AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev2.FullPath); 168 | AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev2.OldFullPath); 169 | } 170 | 171 | [Fact] 172 | public void Manually_Create_Rename_And_Delete_File_Via_Windows_Explorer() 173 | { 174 | StartFileWatcherAndReplay(@"scenario/create_rename_and_delete_file_via_explorer.csv"); 175 | 176 | Assert.Equal(3, _events.Count); 177 | var ev1 = _events.ToList()[0]; 178 | var ev2 = _events.ToList()[1]; 179 | var ev3 = _events.ToList()[2]; 180 | 181 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 182 | AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev1.FullPath); 183 | 184 | Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); 185 | AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev2.FullPath); 186 | AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev2.OldFullPath); 187 | 188 | Assert.Equal(ChangeType.DELETED, ev3.ChangeType); 189 | AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev3.FullPath); 190 | Assert.Equal("", ev3.OldFullPath); 191 | } 192 | 193 | [Fact] 194 | public void Download_Image_Via_Edge_Browser() 195 | { 196 | StartFileWatcherAndReplay(@"scenario/download_image_via_Edge_browser.csv"); 197 | 198 | Assert.Equal(2, _events.Count); 199 | var ev1 = _events.ToList()[0]; 200 | var ev2 = _events.ToList()[1]; 201 | 202 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 203 | AssertEqualNormalized(@"C:\temp\fwtest\test.png.crdownload", ev1.FullPath); 204 | 205 | Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); 206 | AssertEqualNormalized(@"C:\temp\fwtest\test.png", ev2.FullPath); 207 | AssertEqualNormalized(@"C:\temp\fwtest\test.png.crdownload", ev2.OldFullPath); 208 | } 209 | 210 | // instantly removed file is not in the events list 211 | [Fact] 212 | public void Create_Sub_Directory_Add_And_Remove_File() 213 | { 214 | StartFileWatcherAndReplay(@"scenario/create_subdirectory_add_and_remove_file.csv"); 215 | 216 | Assert.Equal(2, _events.Count); 217 | var ev1 = _events.ToList()[0]; 218 | var ev2 = _events.ToList()[1]; 219 | 220 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 221 | AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev1.FullPath); 222 | 223 | Assert.Equal(ChangeType.CHANGED, ev2.ChangeType); 224 | AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev2.FullPath); 225 | Assert.Equal(@"", ev2.OldFullPath); 226 | } 227 | 228 | [Fact] 229 | public void Create_Sub_Directory_Add_And_Remove_File_With_Sleep() 230 | { 231 | StartFileWatcherAndReplay(@"scenario/create_subdirectory_add_and_remove_file_with_sleep.csv"); 232 | 233 | Assert.Equal(4, _events.Count); 234 | var ev1 = _events.ToList()[0]; 235 | var ev2 = _events.ToList()[1]; 236 | var ev3 = _events.ToList()[2]; 237 | var ev4 = _events.ToList()[3]; 238 | 239 | Assert.Equal(ChangeType.CREATED, ev1.ChangeType); 240 | AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev1.FullPath); 241 | 242 | Assert.Equal(ChangeType.CREATED, ev2.ChangeType); 243 | AssertEqualNormalized(@"C:\temp\fwtest\subdir\a.txt", ev2.FullPath); 244 | Assert.Equal(@"", ev2.OldFullPath); 245 | 246 | // TODO this could be filtered out 247 | Assert.Equal(ChangeType.CHANGED, ev3.ChangeType); 248 | AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev3.FullPath); 249 | Assert.Equal(@"", ev3.OldFullPath); 250 | 251 | Assert.Equal(ChangeType.DELETED, ev4.ChangeType); 252 | AssertEqualNormalized(@"C:\temp\fwtest\subdir\a.txt", ev4.FullPath); 253 | Assert.Equal(@"", ev4.OldFullPath); 254 | } 255 | 256 | [Fact] 257 | public void Filter_Settings_Are_Delegated() 258 | { 259 | using var dir = new TempDir(); 260 | var watcher = new ReplayFileSystemWatcherWrapper(); 261 | 262 | var uut = new FileSystemWatcherEx(dir.FullPath); 263 | uut.FileSystemWatcherFactory = () => watcher; 264 | uut.Filters.Add("*.foo"); 265 | uut.Filters.Add("*.bar"); 266 | 267 | uut.Start(); 268 | Assert.Equal(new List{"*.foo", "*.bar"}, watcher.Filters); 269 | } 270 | 271 | [Fact] 272 | public void Set_Filter() 273 | { 274 | using var dir = new TempDir(); 275 | var watcher = new ReplayFileSystemWatcherWrapper(); 276 | 277 | var uut = new FileSystemWatcherEx(dir.FullPath); 278 | uut.FileSystemWatcherFactory = () => watcher; 279 | 280 | // "all files" by default 281 | Assert.Equal("*", uut.Filter); 282 | 283 | uut.Filters.Add("*.foo"); 284 | uut.Filters.Add("*.bar"); 285 | 286 | // two filter entries 287 | Assert.Equal(2, uut.Filters.Count); 288 | 289 | // if multiple filters, only first is displayed. TODO Why ? 290 | Assert.Equal("*.foo", uut.Filter); 291 | 292 | uut.Filter = "*.baz"; 293 | Assert.Equal("*.baz", uut.Filter); 294 | Assert.Single(uut.Filters); 295 | } 296 | 297 | 298 | 299 | [Fact(Skip = "requires real (Windows) file system")] 300 | public void Simple_Real_File_System_Test() 301 | { 302 | ConcurrentQueue events = new(); 303 | var fw = new FileSystemWatcherEx(@"c:\temp\fwtest\"); 304 | 305 | fw.OnCreated += (_, ev) => events.Enqueue(ev); 306 | fw.OnDeleted += (_, ev) => events.Enqueue(ev); 307 | fw.OnChanged += (_, ev) => events.Enqueue(ev); 308 | fw.OnRenamed += (_, ev) => events.Enqueue(ev); 309 | fw.OnRenamed += (_, ev) => events.Enqueue(ev); 310 | 311 | const string testFile = @"c:\temp\fwtest\b.txt"; 312 | if (File.Exists(testFile)) 313 | { 314 | File.Delete(testFile); 315 | } 316 | 317 | _fileWatcher.StartForTesting( 318 | p => FileAttributes.Normal, 319 | p => Array.Empty()); 320 | File.Create(testFile); 321 | Thread.Sleep(250); 322 | fw.Stop(); 323 | 324 | Assert.Single(events); 325 | var ev = events.First(); 326 | Assert.Equal(ChangeType.CREATED, ev.ChangeType); 327 | Assert.Equal(@"c:\temp\fwtest\b.txt", ev.FullPath); 328 | Assert.Equal("", ev.OldFullPath); 329 | } 330 | 331 | // cleanup 332 | public void Dispose() 333 | { 334 | _fileWatcher.Dispose(); 335 | _tempDir.Dispose(); 336 | } 337 | 338 | private void StartFileWatcherAndReplay(string csvFile) 339 | { 340 | _fileWatcher.StartForTesting( 341 | p => FileAttributes.Normal, 342 | // only used for FullName 343 | p => new[] { new DirectoryInfo(p)}); 344 | _replayFileSystemWatcherFactory.RootWatcher.Replay(csvFile); 345 | _fileWatcher.Stop(); 346 | } 347 | 348 | private class ReplayFileSystemWatcherFactory 349 | { 350 | private readonly List _wrappers = new(); 351 | 352 | public ReplayFileSystemWatcherWrapper Create() 353 | { 354 | var watcher = new ReplayFileSystemWatcherWrapper(); 355 | _wrappers.Add(watcher); 356 | return watcher; 357 | } 358 | 359 | // At integration test, we're only interested in the root file watcher. 360 | // This is the one which is registered first and watches the root directory. 361 | public ReplayFileSystemWatcherWrapper RootWatcher => _wrappers[0]; 362 | } 363 | 364 | // little hack to make the path comparision platform independent 365 | private static void AssertEqualNormalized(string expected, string? actual) 366 | { 367 | actual = actual?.Replace("/", @"\"); 368 | Assert.Equal(expected, actual); 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/FileWatcherExTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | PreserveNewest 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/Helper/TempDir.cs: -------------------------------------------------------------------------------- 1 | namespace FileWatcherExTests.Helper; 2 | 3 | public class TempDir : IDisposable 4 | { 5 | public string FullPath { get; } 6 | 7 | public TempDir() 8 | { 9 | FullPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); 10 | Directory.CreateDirectory(FullPath); 11 | } 12 | 13 | public string CreateSubDir(string path) 14 | { 15 | var subDirPath = Path.Combine(FullPath, path); 16 | Directory.CreateDirectory(subDirPath); 17 | return subDirPath; 18 | } 19 | 20 | public string CreateSymlink(string target, params string[] symLink) 21 | { 22 | var allElements = new[] { FullPath }.Concat(symLink).ToArray(); 23 | var symlinkPath = Path.Combine(allElements); 24 | Directory.CreateSymbolicLink(symlinkPath, target); 25 | return symlinkPath; 26 | } 27 | 28 | public void Dispose() 29 | { 30 | Directory.Delete(FullPath, true); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/ReplayFileSystemWatcherWrapper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.ComponentModel; 3 | using System.Globalization; 4 | using CsvHelper; 5 | using FileWatcherEx; 6 | 7 | namespace FileWatcherExTests; 8 | 9 | internal record EventRecordWithDiff( 10 | string Directory, 11 | string FileName, 12 | string EventName, 13 | string? OldFileName, 14 | long DiffInTicks, 15 | double DiffInMilliseconds 16 | ); 17 | 18 | /// 19 | /// Allows replaying of previously recorded file system events. 20 | /// Used for integration testing. 21 | /// 22 | public class ReplayFileSystemWatcherWrapper : IFileSystemWatcherWrapper 23 | { 24 | private Collection _filters = new(); 25 | 26 | public void Replay(string csvFile) 27 | { 28 | using var reader = new StreamReader(csvFile); 29 | using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); 30 | 31 | var records = csv.GetRecords(); 32 | foreach (var record in records) 33 | { 34 | // introduce time gap like originally recorded 35 | Thread.Sleep((int)record.DiffInMilliseconds); 36 | 37 | switch (record.EventName) 38 | { 39 | case "created": 40 | { 41 | var ev = new FileSystemEventArgs(WatcherChangeTypes.Created, record.Directory, record.FileName); 42 | Created?.Invoke(this, ev); 43 | break; 44 | } 45 | case "deleted": 46 | { 47 | var ev = new FileSystemEventArgs(WatcherChangeTypes.Deleted, record.Directory, record.FileName); 48 | Deleted?.Invoke(this, ev); 49 | break; 50 | } 51 | case "changed": 52 | { 53 | var ev = new FileSystemEventArgs(WatcherChangeTypes.Changed, record.Directory, record.FileName); 54 | Changed?.Invoke(this, ev); 55 | break; 56 | } 57 | case "renamed": 58 | { 59 | var ev = new RenamedEventArgs(WatcherChangeTypes.Renamed, record.Directory, record.FileName, 60 | record.OldFileName); 61 | Renamed?.Invoke(this, ev); 62 | break; 63 | } 64 | } 65 | } 66 | // settle down 67 | Thread.Sleep(250); 68 | } 69 | 70 | public event FileSystemEventHandler? Created; 71 | public event FileSystemEventHandler? Deleted; 72 | public event FileSystemEventHandler? Changed; 73 | public event RenamedEventHandler? Renamed; 74 | 75 | #pragma warning disable CS8618 // unused in replay implementation 76 | public string Path { get; set; } 77 | 78 | public Collection Filters => _filters; 79 | 80 | public bool IncludeSubdirectories { get; set; } 81 | public bool EnableRaisingEvents { get; set; } 82 | public NotifyFilters NotifyFilter { get; set; } 83 | 84 | #pragma warning disable CS0067 // unused in replay implementation 85 | public event ErrorEventHandler? Error; 86 | public int InternalBufferSize { get; set; } 87 | public ISynchronizeInvoke? SynchronizingObject { get; set; } 88 | 89 | public void Dispose() 90 | { 91 | } 92 | } -------------------------------------------------------------------------------- /Source/FileWatcherExTests/SymlinkAwareFileWatcherTest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.ComponentModel; 3 | using FileWatcherEx; 4 | using FileWatcherEx.Helpers; 5 | using FileWatcherExTests.Helper; 6 | using Moq; 7 | using Xunit; 8 | using Xunit.Abstractions; 9 | 10 | namespace FileWatcherExTests; 11 | 12 | public class SymlinkAwareFileWatcherTest 13 | { 14 | private readonly ITestOutputHelper _testOutputHelper; 15 | private readonly List> _mocks; 16 | 17 | public SymlinkAwareFileWatcherTest(ITestOutputHelper testOutputHelper) 18 | { 19 | _testOutputHelper = testOutputHelper; 20 | _mocks = new List>(); 21 | } 22 | 23 | [Fact] 24 | public void Root_Watcher_Is_Created() 25 | { 26 | using var dir = new TempDir(); 27 | CreateFileWatcher(dir.FullPath); 28 | AssertContainsWatcherFor(dir.FullPath); 29 | } 30 | 31 | 32 | // creates 2 sub-directories and 2 nested symlinks 33 | // file watchers are registered for the root dir and the symlinks 34 | // for the sub-directories, no extra file watchers are created 35 | // since the normal file watcher already emits events for subdirs 36 | // 37 | // this handles the initial setup after start. 38 | // for registering sym-link watchers during runtime, MakeWatcher_Created is used. 39 | [Fact] 40 | public void FileWatchers_For_SymLink_Dirs_Are_Created_On_Startup() 41 | { 42 | using var dir = new TempDir(); 43 | 44 | // {tempdir}/subdir1 45 | var subdirPath1 = dir.CreateSubDir("subdir1"); 46 | 47 | // {tempdir}/subdir2 48 | var subdirPath2 = dir.CreateSubDir("subdir2"); 49 | 50 | // symlink {tempdir}/sym1 to {tempdir}/subdir1 51 | var symlinkPath1 = dir.CreateSymlink(symLink: "sym1", target: subdirPath1); 52 | 53 | // symlink {tempdir}/sym1/sym2 to {tempdir}/subdir2 54 | var symlinkPath2 = dir.CreateSymlink(symLink: new []{"sym1", "sym2"}, target: subdirPath2); 55 | 56 | CreateFileWatcher(dir.FullPath); 57 | 58 | AssertContainsWatcherFor(dir.FullPath); 59 | AssertContainsWatcherFor(symlinkPath1); 60 | AssertContainsWatcherFor(symlinkPath2); 61 | } 62 | 63 | [Fact] 64 | public void FileWatchers_For_SymLink_Dirs_Are_Created_During_Runtime() 65 | { 66 | using var dir = new TempDir(); 67 | var uut = CreateFileWatcher(dir.FullPath); 68 | 69 | var subdirPath = dir.CreateSubDir("subdir"); 70 | 71 | // simulate file watcher trigger 72 | uut.TryRegisterFileWatcherForSymbolicLinkDir(subdirPath); 73 | 74 | // subdir is ignored 75 | Assert.Single(uut.FileWatchers); 76 | AssertContainsWatcherFor(dir.FullPath); 77 | 78 | var symlinkPath = dir.CreateSymlink(symLink: "sym", target: subdirPath); 79 | 80 | // simulate file watcher trigger 81 | uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath); 82 | 83 | // symlink dir is registered 84 | Assert.Equal(2, uut.FileWatchers.Count); 85 | AssertContainsWatcherFor(dir.FullPath); 86 | AssertContainsWatcherFor(symlinkPath); 87 | 88 | // remove the symlink again 89 | Directory.Delete(symlinkPath); 90 | 91 | // simulate file watcher trigger 92 | uut.UnregisterFileWatcherForSymbolicLinkDir(null, 93 | new FileSystemEventArgs(WatcherChangeTypes.Deleted, dir.FullPath, "sym")); 94 | 95 | // sym-link file watcher is removed 96 | Assert.Single(uut.FileWatchers); 97 | AssertContainsWatcherFor(dir.FullPath); 98 | 99 | uut.Dispose(); 100 | } 101 | 102 | [Fact] 103 | public void MakeWatcher_Create_Exceptions_Are_Silently_Ignored() 104 | { 105 | var uut = CreateFileWatcher("/bar"); 106 | uut.TryRegisterFileWatcherForSymbolicLinkDir("/not/existing/foo"); 107 | } 108 | 109 | [Fact] 110 | public void Properties_Are_Propagated() 111 | { 112 | using var dir = new TempDir(); 113 | 114 | var subDir = dir.CreateSubDir("subdir"); 115 | 116 | // symlink for detection at startup 117 | dir.CreateSymlink( 118 | symLink: "sym1", 119 | target: subDir); 120 | 121 | var uut = new SymlinkAwareFileWatcher(dir.FullPath, 122 | _ => { }, 123 | _ => { }, 124 | WatcherFactoryWithMemory, 125 | _ => { }); 126 | 127 | // perform settings. all, except SynchronizingObject are propagated 128 | // to all registered watchers 129 | uut.NotifyFilter = NotifyFilters.LastAccess; 130 | uut.Filters.Add("*.foo"); 131 | uut.Filters.Add("*.bar"); 132 | uut.EnableRaisingEvents = true; 133 | uut.IncludeSubdirectories = true; 134 | var syncObj = new Mock().Object; 135 | uut.SynchronizingObject = syncObj; 136 | 137 | // finish object initialization 138 | uut.Init(); 139 | 140 | // create symlink at runtime 141 | var symlinkPath2 = dir.CreateSymlink( 142 | symLink: "sym2", 143 | target: subDir); 144 | 145 | // simulate that a new symlink dir was added 146 | uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath2); 147 | 148 | // 1x root watcher, 1x sym link at startup, 1x sym link at runtime 149 | Assert.Equal(3, _mocks.Count); 150 | // all watchers have properties set 151 | Assert.All( 152 | _mocks, 153 | mock => 154 | mock.VerifySet(w => w.NotifyFilter = NotifyFilters.LastAccess)); 155 | Assert.All( 156 | _mocks, 157 | mock => 158 | mock.VerifySet(w => w.EnableRaisingEvents = true)); 159 | Assert.All( 160 | _mocks, 161 | mock => 162 | mock.VerifySet(w => w.IncludeSubdirectories = true)); 163 | Assert.All( 164 | _mocks, 165 | mock => Assert.Equal(mock.Object.Filters, new Collection { "*.foo", "*.bar" })); 166 | 167 | // sync. object is only set for root watcher 168 | Assert.Collection(_mocks, 169 | rootWatcherMock => 170 | { 171 | rootWatcherMock.VerifySet(w => w.Path = dir.FullPath); 172 | rootWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj); 173 | }, 174 | otherWatcherMock => otherWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj, Times.Never), 175 | otherWatcherMock => otherWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj, Times.Never)); 176 | } 177 | 178 | 179 | [Fact] 180 | public void When_No_SubDirs_Are_Watched_Also_No_Additional_Symlink_Watchers_Are_Registered() 181 | { 182 | using var dir = new TempDir(); 183 | 184 | var subDir = dir.CreateSubDir("subdir"); 185 | 186 | // symlink for detection at startup 187 | dir.CreateSymlink( 188 | symLink: "sym1", 189 | target: subDir); 190 | 191 | var uut = new SymlinkAwareFileWatcher(dir.FullPath, 192 | _ => { }, 193 | _ => { }, 194 | WatcherFactoryWithMemory, 195 | _ => { }); 196 | 197 | uut.IncludeSubdirectories = false; 198 | uut.Init(); 199 | 200 | // create symlink at runtime 201 | var symlinkPath2 = dir.CreateSymlink( 202 | symLink: "sym2", 203 | target: subDir); 204 | 205 | // simulate that a new symlink dir was added 206 | uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath2); 207 | 208 | // only root watcher was registered 209 | Assert.Single(_mocks); 210 | } 211 | 212 | 213 | private SymlinkAwareFileWatcher CreateFileWatcher(string path) 214 | { 215 | var fw = new SymlinkAwareFileWatcher(path, 216 | _ => { }, 217 | _ => { }, 218 | WatcherFactoryWithMemory, 219 | _ => { }); 220 | fw.IncludeSubdirectories = true; 221 | fw.Init(); 222 | return fw; 223 | } 224 | 225 | private IFileSystemWatcherWrapper WatcherFactoryWithMemory() 226 | { 227 | var mock = new Mock(); 228 | // this did the trick to have the 'Filters' property be recorded 229 | mock.SetReturnsDefault(new Collection()); 230 | _mocks.Add(mock); 231 | return mock.Object; 232 | } 233 | 234 | private void AssertContainsWatcherFor(string path) 235 | { 236 | var foundMocks = ( 237 | from mock in _mocks 238 | where HasPropertySetTo(mock, watcher => watcher.Path = path) 239 | select mock) 240 | .Count(); 241 | Assert.Equal(1, foundMocks); 242 | } 243 | 244 | private static bool HasPropertySetTo(Mock mock, Action setterExpression) 245 | { 246 | try 247 | { 248 | mock.VerifySet(setterExpression); 249 | return true; 250 | } 251 | catch (MockException) 252 | { 253 | return false; 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/README.md: -------------------------------------------------------------------------------- 1 | # Scenarios For Integration Testing 2 | 3 | For each scenario: 4 | - a fresh recording was started in an empty directory 5 | - the listed commands were executed in a separate terminal 6 | - the recording was stopped (CTRL + C) 7 | 8 | Then the recorded CSV files were used in integration tests using the `ReplayFileSystemWatcherWrapper.cs`. 9 | 10 | Example for starting a recording: 11 | ````powershell 12 | PS C:\Projects\FileWatcherEx\Source\FileSystemEventRecorder> dotnet run C:\temp\fwtest\ C:\Projects\FileWatcherEx\Source\FileWatcherExTests\scenario\create_rename_and_remove_file_wsl2.csv 13 | ```` 14 | # List of Scenarios 15 | 16 | ## `create_file.csv` 17 | ````powershell 18 | New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File 19 | ```` 20 | 21 | ## `create_and_remove_file.csv` 22 | ````powershell 23 | New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File 24 | Remove-Item -Path 'c:\temp\fwtest\a.txt' -Recurse 25 | ```` 26 | 27 | ## `create_rename_and_remove_file.csv` 28 | ````powershell 29 | New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File 30 | Rename-Item -Path 'c:\temp\fwtest\a.txt' -NewName 'c:\temp\fwtest\b.txt' 31 | Remove-Item -Path 'c:\temp\fwtest\b.txt' -Recurse 32 | ```` 33 | 34 | ## `create_file_wsl2.csv` 35 | Create file in WSL 2. On file creation, a second "changed" event is written. 36 | ````sh 37 | touch /mnt/c/temp/fwtest/a.txt 38 | ```` 39 | 40 | 41 | ## `create_and_rename_file_wsl2.csv` 42 | Create and rename file in WSL 2. On file creation, a second "changed" event is written. 43 | ````sh 44 | touch /mnt/c/temp/fwtest/a.txt 45 | mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt 46 | ```` 47 | 48 | ## `create_rename_and_remove_file_wsl2.csv` 49 | Create, rename and remove file in WSL 2. On file creation, a second "changed" event is written. 50 | ````sh 51 | touch /mnt/c/temp/fwtest/a.txt 52 | mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt 53 | rm /mnt/c/temp/fwtest/b.txt 54 | ```` 55 | 56 | ## `create_rename_and_remove_file_with_wait_time_wsl2.csv` 57 | Create, rename and remove file in WSL 2. Additionally, some wait time is added. 58 | ````sh 59 | touch /mnt/c/temp/fwtest/a.txt 60 | sleep 1 61 | mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt 62 | sleep 1 63 | rm /mnt/c/temp/fwtest/b.txt 64 | ```` 65 | 66 | ## `create_and_rename_file_via_explorer.csv` 67 | Manually create a file in the Windows explorer. Change the default name "New Text Document.txt" 68 | to "foo.txt". 69 | 70 | ## `create_rename_and_delete_file_via_explorer.csv` 71 | Manually create, rename and delete a file in the Windows explorer. 72 | 73 | ## `download_image_via_Edge_browser.csv` 74 | Download (right click, "Save image as") single image via Edge 106.0.1370.42. 75 | 76 | ## `create_subdirectory_add_and_remove_file.csv` 77 | In WSL2, create a subdirectory, create and remove a file. 78 | ````sh 79 | mkdir -p /mnt/c/temp/fwtest/subdir/ 80 | touch /mnt/c/temp/fwtest/subdir/a.txt 81 | rm /mnt/c/temp/fwtest/subdir/a.txt 82 | ```` 83 | 84 | ## `create_subdirectory_add_and_remove_file_with_sleep.csv` 85 | In WSL2, create a subdirectory, Create the file, wait for a while, then remove it. 86 | ````sh 87 | mkdir -p /mnt/c/temp/fwtest/subdir/ 88 | touch /mnt/c/temp/fwtest/subdir/a.txt 89 | sleep 1 90 | rm /mnt/c/temp/fwtest/subdir/a.txt 91 | ```` 92 | 93 | ## `create_file_inside_symbolic_link_directory` 94 | Create subdir, create symbolic link to it and then place file inside this symbolic link dir. 95 | 96 | ````powershell 97 | New-Item –itemType Directory -Path 'c:\temp\fwtest\subdir' 98 | New-Item -ItemType Junction -Path 'c:\temp\fwtest\symlink_subdir' -Target 'c:\temp\fwtest\subdir' 99 | New-Item -ItemType File -Path 'c:\temp\fwtest\symlink_subdir\foo.txt' 100 | Remove-Item -Path 'c:\temp\fwtest\symlink_subdir\foo.txt' -Recurse 101 | ```` 102 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_and_remove_file.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,a.txt,created,,0,0 3 | C:\temp\fwtest,a.txt,deleted,,8263221,826 4 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_and_rename_file_via_explorer.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,New Text Document.txt,created,,0,0 3 | C:\temp\fwtest,foo.txt,renamed,New Text Document.txt,15017686,1502 4 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_and_rename_file_wsl2.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,a.txt,created,,0,0 3 | C:\temp\fwtest,a.txt,changed,,5808,1 4 | C:\temp\fwtest,b.txt,renamed,a.txt,85490,9 5 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_file.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,a.txt,created,,0,0 3 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_file_inside_symbolic_link_directory.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,subdir,created,,0,0 3 | C:\temp\fwtest,symlink_subdir,created,,287271,29 4 | C:\temp\fwtest\subdir,foo.txt,created,,571620,57 5 | C:\temp\fwtest,subdir,changed,,7601128,760 6 | C:\temp\fwtest\subdir,foo.txt,deleted,,5158,1 7 | C:\temp\fwtest,subdir,changed,,1527420,153 8 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_file_wsl2.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,a.txt,created,,0,0 3 | C:\temp\fwtest,a.txt,changed,,13323,1 4 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_rename_and_delete_file_via_explorer.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,New Text Document.txt,created,,0,0 3 | C:\temp\fwtest,foo.txt,renamed,New Text Document.txt,20053327,2005 4 | C:\temp\fwtest,foo.txt,deleted,,10305941,1031 5 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_rename_and_remove_file.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,a.txt,created,,0,0 3 | C:\temp\fwtest,b.txt,renamed,a.txt,1046536,105 4 | C:\temp\fwtest,b.txt,deleted,,4102153,410 5 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,a.txt,created,,0,0 3 | C:\temp\fwtest,a.txt,changed,,6333,1 4 | C:\temp\fwtest,b.txt,renamed,a.txt,10362431,1036 5 | C:\temp\fwtest,b.txt,deleted,,10592668,1059 6 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_rename_and_remove_file_wsl2.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,a.txt,created,,0,0 3 | C:\temp\fwtest,a.txt,changed,,6136,1 4 | C:\temp\fwtest,b.txt,renamed,a.txt,94817,9 5 | C:\temp\fwtest,b.txt,deleted,,209770,21 6 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,subdir,created,,0,0 3 | C:\temp\fwtest\subdir,a.txt,created,,52133,5 4 | C:\temp\fwtest\subdir,a.txt,changed,,21171,2 5 | C:\temp\fwtest,subdir,changed,,132289,13 6 | C:\temp\fwtest\subdir,a.txt,deleted,,226731,23 7 | C:\temp\fwtest,subdir,changed,,2123902,212 8 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file_with_sleep.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,subdir,created,,0,0 3 | C:\temp\fwtest\subdir,a.txt,created,,371990,37 4 | C:\temp\fwtest\subdir,a.txt,changed,,78301,8 5 | C:\temp\fwtest,subdir,changed,,7198911,720 6 | C:\temp\fwtest\subdir,a.txt,deleted,,3310510,331 7 | -------------------------------------------------------------------------------- /Source/FileWatcherExTests/scenario/download_image_via_Edge_browser.csv: -------------------------------------------------------------------------------- 1 | Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds 2 | C:\temp\fwtest,test.png,created,,0,0 3 | C:\temp\fwtest,test.png,deleted,,8470,1 4 | C:\temp\fwtest,test.png.crdownload,created,,3352286,335 5 | C:\temp\fwtest,test.png,renamed,test.png.crdownload,1451627,145 6 | -------------------------------------------------------------------------------- /nuget.ps1: -------------------------------------------------------------------------------- 1 | 2 | dotnet nuget push "Source\FileWatcherEx\bin\Release\FileWatcherEx.2.1.0.nupkg" --api-key YOUR_GITHUB_PAT --source "github" 3 | 4 | 5 | PAUSE --------------------------------------------------------------------------------