303 lines
10 KiB
C#
303 lines
10 KiB
C#
#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;
|
|
|
|
/// <summary>
|
|
/// Handles events for loaded resources files and forwards them to
|
|
/// </summary>
|
|
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<FileWatcherAction>();
|
|
_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<FileKey, FileWatchDetails> _newFileEvents = new();
|
|
private readonly Lock _eventLock = new();
|
|
private readonly string _watchedDirectory;
|
|
private FileSystemWatcher? _fsWatcher;
|
|
|
|
private readonly ConcurrentDictionary<string, List<FileWatcherAction>> _fileChangeActions = new();
|
|
private readonly Queue<FileWatchQueuedAction> _queuedActions = new();
|
|
public event EventHandler<string>? FileCreated;
|
|
public event EventHandler<string>? FileDeleted;
|
|
public event Action<string, string>? FileRenamed; // OldPath, NewPath
|
|
|
|
/// <summary>
|
|
/// This is incremented on every file change event and can be used for cache invalidation (e.g. for complex FileLists)
|
|
/// </summary>
|
|
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;
|
|
}
|
|
} |