#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using T3.Core.IO; using T3.Core.Logging; using T3.Core.Resource.Assets; using T3.Core.Settings; using T3.Core.Utils; namespace T3.Core.Resource; /// /// Handles events for loaded resources files and forwards them to /// public sealed class ResourceFileWatcher : IDisposable { public ResourceFileWatcher(string watchedFolder) { Directory.CreateDirectory(watchedFolder); _watchedDirectory = watchedFolder; // Watch unconditionally (not only while file hooks exist): external changes to the // assets folder must keep the AssetRegistry and asset library in sync. _fsWatcher = CreateFsWatcher(); ResourcePackageManager.RegisterWatcher(this); } public void Dispose() { DisposeFileWatcher(ref _fsWatcher); _fileChangeActions.Clear(); ResourcePackageManager.UnregisterWatcher(this); } internal void AddFileHook(string filepath, FileWatcherAction action) { if (string.IsNullOrWhiteSpace(filepath)) return; ArgumentNullException.ThrowIfNull(action); if (!FilepathStartsWith(filepath,_watchedDirectory)) { Log.Error($"Cannot watch file outside of watched directory: \"{filepath}\" is not in \"{_watchedDirectory}\""); return; } if (!_fileChangeActions.TryGetValue(filepath, out var existingActions)) { existingActions = new List(); _fileChangeActions.TryAdd(filepath, existingActions); } existingActions.Add(action); } private bool FilepathStartsWith(string path, string start) { if (path.Length < start.Length) return false; for (var index = 0; index < start.Length; index++) { var cA = path[index]; var cB = start[index]; if (cA == cB) continue; if ((cA == '/' || cA == '\\') && (cB == '/' || cB == '\\')) continue; return false; } return true; } internal void RemoveFileHook(string absolutePathFs, FileWatcherAction onResourceChanged) { if (!_fileChangeActions.TryGetValue(absolutePathFs, out var actions)) return; actions.Remove(onResourceChanged); if (actions.Count != 0) return; _fileChangeActions.Remove(absolutePathFs, out _); } internal void RaiseQueuedFileChanges() { var currentTime = DateTime.UtcNow.Ticks; const long thresholdTicks = 394 * TimeSpan.TicksPerMillisecond; lock (_eventLock) { if (_newFileEvents.Count == 0) return; var fileEvents = _newFileEvents.OrderBy(x => x.Value.TimeTicks).ToArray(); FileKey previous = default; foreach (var (fileKey, details) in fileEvents) { if (currentTime - details.TimeTicks < thresholdTicks) break; _newFileEvents.Remove(fileKey); if (fileKey == previous) continue; previous = fileKey; var path = details.Args.FullPath.ToForwardSlashes(); // 1. Synchronize the AssetRegistry if (fileKey.IsRename && details.Args is RenamedEventArgs renamedArgs) { var renamedArgsOldFullPath = renamedArgs.OldFullPath.ToForwardSlashes(); if (_fileChangeActions.Remove(renamedArgsOldFullPath, out var actions1)) { if (_fileChangeActions.TryGetValue(path, out var previousActions)) { previousActions.AddRange(actions1); } else { _fileChangeActions.TryAdd(path, actions1); } } FileRenamed?.Invoke(renamedArgsOldFullPath, renamedArgs.FullPath); FileStateChangeCounter++; } else if (fileKey.ChangeType == WatcherChangeTypes.Deleted) { FileDeleted?.Invoke(this, path); FileStateChangeCounter++; } else if (fileKey.ChangeType == WatcherChangeTypes.Created) { // Only register if it's a file we care about if (!FileLocations.IgnoredFiles.Contains(Path.GetFileName(path))) { // This will invoke an AssetRegistry Update for editable projects FileCreated?.Invoke(this, path); } FileStateChangeCounter++; } // 2. Dispatch hooks to Resources/Operators if (_fileChangeActions.TryGetValue(path, out var actions)) { foreach (var action in actions) { _queuedActions.Enqueue(new FileWatchQueuedAction(details.Args.ChangeType, path, action)); } FileStateChangeCounter++; } } } // Process the actions outside the lock while (_queuedActions.TryDequeue(out var queuedAction)) { try { queuedAction.Action(queuedAction.ChangeType, queuedAction.FullPath); } catch (Exception exception) { Log.Error($"Error in file change action: {exception}"); } } } private void OnFileCreated(object sender, FileSystemEventArgs e) { if(CoreSettings.Config.LogFileEvents) Log.Debug($"FileEvent(create): {e.FullPath}"); // FileCreated is raised from the debounced queue in RaiseQueuedFileChanges, // on the main thread and with IgnoredFiles filtered out. OnFileChanged(this, e); } private void OnFileChanged(object sender, FileSystemEventArgs e) { if(CoreSettings.Config.LogFileEvents) Log.Debug($"FileEvent(change) [{e.ChangeType}] {e.FullPath}"); e.FullPath.ToForwardSlashesUnsafe(); var isRenamed = false; if (e is RenamedEventArgs renamedArgs) { renamedArgs.OldFullPath.ToForwardSlashesUnsafe(); isRenamed = true; } var fileKey = new FileKey(e.FullPath, e.ChangeType, isRenamed); lock (_eventLock) { _newFileEvents[fileKey] = new FileWatchDetails(DateTime.UtcNow.Ticks, e); FileStateChangeCounter++; } } private void OnError(object sender, ErrorEventArgs e) { if(CoreSettings.Config.LogFileEvents) Log.Error($"FileEvent(error): {e.GetException()}"); DisposeFileWatcher(ref _fsWatcher); _fsWatcher = CreateFsWatcher(); } private FileSystemWatcher CreateFsWatcher() { var fsWatcher = new FileSystemWatcher(_watchedDirectory) { IncludeSubdirectories = true, EnableRaisingEvents = true, NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName }; fsWatcher.Changed += OnFileChanged; fsWatcher.Renamed += OnFileChanged; fsWatcher.Created += OnFileCreated; fsWatcher.Deleted += OnFileDeleted; fsWatcher.Error += OnError; return fsWatcher; } private void OnFileDeleted(object sender, FileSystemEventArgs e) { OnFileChanged(sender, e); if(CoreSettings.Config.LogFileEvents) Log.Debug($"FileEvent(delete): {e.FullPath}"); } private static void DisposeFileWatcher(ref FileSystemWatcher? watcher) { if (watcher == null) return; watcher.EnableRaisingEvents = false; watcher.Dispose(); watcher = null; } private record struct FileKey(string Path, WatcherChangeTypes ChangeType, bool IsRename); private record struct FileWatchDetails(long TimeTicks, FileSystemEventArgs Args); private record struct FileWatchQueuedAction(WatcherChangeTypes ChangeType, string FullPath, FileWatcherAction Action); private readonly Dictionary _newFileEvents = new(); private readonly Lock _eventLock = new(); private readonly string _watchedDirectory; private FileSystemWatcher? _fsWatcher; private readonly ConcurrentDictionary> _fileChangeActions = new(); private readonly Queue _queuedActions = new(); public event EventHandler? FileCreated; public event EventHandler? FileDeleted; public event Action? FileRenamed; // OldPath, NewPath /// /// This is incremented on every file change event and can be used for cache invalidation (e.g. for complex FileLists) /// public static int FileStateChangeCounter { get; set; } } internal delegate void FileWatcherAction(WatcherChangeTypes changeTypes, string absolutePath); internal static class WatcherChangeTypesExtensions { public static bool WasDeleted(this WatcherChangeTypes changeTypes) { return (changeTypes & WatcherChangeTypes.Deleted) == WatcherChangeTypes.Deleted; } public static bool WasMoved(this WatcherChangeTypes changeTypes) { return (changeTypes & WatcherChangeTypes.Renamed) == WatcherChangeTypes.Renamed; } public static bool WasCreated(this WatcherChangeTypes changeTypes) { return (changeTypes & WatcherChangeTypes.Created) == WatcherChangeTypes.Created; } public static bool WasChanged(this WatcherChangeTypes changeTypes) { return (changeTypes & WatcherChangeTypes.Changed) == WatcherChangeTypes.Changed; } }