Files
MycLib/Src/Myc.DirectoryMonitor.pas
T

706 lines
22 KiB
ObjectPascal

unit Myc.DirectoryMonitor experimental;
interface
uses
System.SysUtils,
Myc.Core.Notifier,
Myc.TaskManager,
Myc.Signals;
type
// What kind of changes to monitor
TFileChangeKind = (
FileNameChange, // A file name has been created, renamed, or deleted.
DirNameChange, // A directory name has been created, renamed, or deleted.
AttributeChange, // File or directory attributes have changed.
SizeChange, // File size has changed.
LastWriteChange, // Last write time of a file or directory has changed.
SecurityChange // Security descriptor of a file or directory has changed.
);
TFileChangeKinds = set of TFileChangeKind;
TFileState = (
Unchanged, // The file exists and has not changed since the last check.
Deleted, // The file has been deleted since the last check.
Changed, // The file has been modified (e.g., size, write time) since the last check.
Added // The file is new since the last check.
);
// Represents a single file and its properties.
IFileInfo = interface
{$region 'private'}
function GetFullPath: string;
function GetFileName: string;
function GetExtension: string;
function GetCreationTime: TDateTime;
function GetLastWriteTime: TDateTime;
function GetSize: Int64;
{$endregion}
// The full path of the file.
property FullPath: string read GetFullPath;
// The name of the file including the extension.
property FileName: string read GetFileName;
// The extension of the file.
property Extension: string read GetExtension;
// The creation timestamp of the file.
property CreationTime: TDateTime read GetCreationTime;
// The last modification timestamp of the file.
property LastWriteTime: TDateTime read GetLastWriteTime;
// The size of the file in bytes.
property Size: Int64 read GetSize;
// Client may hook in here for change notifications.
// If ChangeNotification returns false, it will be removed from the notify list.
procedure AddChangeNotification(const ChangeNotification: TFunc<IFileInfo, TFileState, Boolean>);
end;
TSubDirectoryHandling = (
Ignore, // Only monitor the specified directory.
Recursive // Monitor the specified directory and all its subdirectories.
);
// Monitors directories for file changes.
IDirectoryMonitor = interface
{$region 'private'}
function GetFiles(const Name: String): IFileInfo;
function GetFileCount: Integer;
{$endregion}
// Retrieves a snapshot of the currently known files.
property Files[const Name: String]: IFileInfo read GetFiles;
property FileCount: Integer read GetFileCount;
// Checks the current state of a tracked file against the file system.
function CheckState(const FileInfo: IFileInfo): TFileState;
// Attempts to find an updated version of a given file info object.
function TryFindNewVersion(const OldFile: IFileInfo; out NewFile: IFileInfo): TFileState;
end;
// Factory for creating directory monitors.
IDirectoryMonitorFactory = interface
// Adds a directory to the watch list.
procedure AddDirectory(const DirPath: string; SubDirHandling: TSubDirectoryHandling);
// Adds a file extension to filter by (e.g., '.txt'). Use '*' for all files.
procedure AddExtension(const Extension: string);
// Sets the kinds of changes to monitor.
procedure SetChangeKinds(const ChangeKinds: TFileChangeKinds);
// Creates the monitor instance based on the provided configuration.
function CreateMonitor: IDirectoryMonitor;
end;
function CreateDirectoryMonitorFactory: IDirectoryMonitorFactory;
implementation
uses
Winapi.Windows,
System.Classes,
System.IOUtils,
System.Generics.Collections,
System.SyncObjs,
System.StrUtils;
const
// Buffer size for ReadDirectoryChangesW notifications
NOTIFY_BUFFER_SIZE = 65536; // 64 KB
type
// Manual definition of FILE_NOTIFY_INFORMATION as it seems to be missing
// from the project's Winapi.Windows unit.
PFileNotifyInformation = ^TFileNotifyInformation;
TFileNotifyInformation = packed record
NextEntryOffset: DWORD;
Action: DWORD;
FileNameLength: DWORD;
FileName: array[0..0] of WideChar;
end;
TDirectoryWatch = record
Directory: string;
WatchSubtree: Boolean;
end;
// Private record to hold all necessary information for monitoring a single directory.
TWatchInfo = record
Directory: string;
Handle: THandle;
Subtree: Boolean;
Overlapped: TOverlapped;
Buffer: TArray<Byte>;
end;
// Concrete implementation of IFileInfo
TFileInfo = class(TInterfacedObject, IFileInfo)
private
FFullPath: string;
FFileName: string;
FExtension: string;
FCreationTime: TDateTime;
FLastWriteTime: TDateTime;
FSize: Int64;
FNotifyList: TMycNotifyList<TFunc<IFileInfo, TFileState, Boolean>>;
function GetFullPath: string;
function GetFileName: string;
function GetExtension: string;
function GetCreationTime: TDateTime;
function GetLastWriteTime: TDateTime;
function GetSize: Int64;
public
constructor Create(const AFullPath: string; const SearchRec: TSearchRec);
procedure AddChangeNotification(const ChangeNotification: TFunc<IFileInfo, TFileState, Boolean>);
procedure NotifyChanges(NewState: TFileState);
end;
// Concrete implementation of IDirectoryMonitor
TDirectoryMonitor = class(TInterfacedObject, IDirectoryMonitor)
private
FExtensions: TArray<string>;
FChangeKinds: TFileChangeKinds;
FWatchInfos: TArray<TWatchInfo>;
FFiles: TDictionary<string, IFileInfo>;
FLock: TCriticalSection;
FThreadState: TState;
FStopEvent: THandle;
procedure InitialScan(const WatchInfo: TWatchInfo);
procedure MonitorLoop;
procedure ProcessChangeBuffer(const WatchInfo: TWatchInfo; BytesWritten: DWORD);
procedure HandleFileChange(Action: DWORD; const BasePath, FileName: string);
function TranslateChangeKinds: DWORD;
class function FileMatchesExtensions(const FileName: string; const Extensions: TArray<string>): Boolean; static;
function GetFiles(const Name: String): IFileInfo;
function GetFileCount: Integer;
public
constructor Create(const Directories: TArray<TDirectoryWatch>; const Extensions: TArray<string>; ChangeKinds: TFileChangeKinds);
destructor Destroy; override;
function CheckState(const FileInfo: IFileInfo): TFileState;
function TryFindNewVersion(const OldFile: IFileInfo; out NewFile: IFileInfo): TFileState;
end;
// Concrete implementation of IDirectoryMonitorFactory
TDirectoryMonitorFactory = class(TInterfacedObject, IDirectoryMonitorFactory)
private
FDirectories: TList<TDirectoryWatch>;
FExtensions: TList<string>;
FChangeKinds: TFileChangeKinds;
public
constructor Create;
destructor Destroy; override;
procedure AddDirectory(const DirPath: string; SubDirHandling: TSubDirectoryHandling);
procedure AddExtension(const Extension: string);
procedure SetChangeKinds(const ChangeKinds: TFileChangeKinds);
function CreateMonitor: IDirectoryMonitor;
end;
function CreateDirectoryMonitorFactory: IDirectoryMonitorFactory;
begin
Result := TDirectoryMonitorFactory.Create;
end;
{ TFileInfo }
constructor TFileInfo.Create(const AFullPath: string; const SearchRec: TSearchRec);
begin
inherited Create;
FFullPath := AFullPath;
FFileName := TPath.GetFileName(AFullPath);
FExtension := TPath.GetExtension(AFullPath);
FCreationTime := SearchRec.CreationTime;
FLastWriteTime := SearchRec.TimeStamp;
FSize := SearchRec.Size;
end;
procedure TFileInfo.AddChangeNotification(const ChangeNotification: TFunc<IFileInfo, TFileState, Boolean>);
begin
FNotifyList.Lock;
try
FNotifyList.Advise(ChangeNotification);
finally
FNotifyList.Release;
end;
end;
procedure TFileInfo.NotifyChanges(NewState: TFileState);
begin
FNotifyList.Lock;
try
FNotifyList.Notify(function(const Func: TFunc<IFileInfo, TFileState, Boolean>): Boolean begin Result := Func(Self, NewState); end);
finally
FNotifyList.Release;
end;
end;
function TFileInfo.GetCreationTime: TDateTime;
begin
Result := FCreationTime;
end;
function TFileInfo.GetExtension: string;
begin
Result := FExtension;
end;
function TFileInfo.GetFileName: string;
begin
Result := FFileName;
end;
function TFileInfo.GetFullPath: string;
begin
Result := FFullPath;
end;
function TFileInfo.GetLastWriteTime: TDateTime;
begin
Result := FLastWriteTime;
end;
function TFileInfo.GetSize: Int64;
begin
Result := FSize;
end;
{ TDirectoryMonitor }
constructor TDirectoryMonitor.Create(
const Directories: TArray<TDirectoryWatch>;
const Extensions: TArray<string>;
ChangeKinds: TFileChangeKinds
);
var
i: Integer;
watch: TDirectoryWatch;
begin
inherited Create;
FExtensions := Extensions;
FChangeKinds := ChangeKinds;
FLock := TCriticalSection.Create;
FFiles := TDictionary<string, IFileInfo>.Create;
FStopEvent := CreateEvent(nil, TRUE, FALSE, nil);
SetLength(FWatchInfos, Length(Directories));
for i := 0 to High(Directories) do
begin
watch := Directories[i];
FWatchInfos[i].Directory := watch.Directory;
FWatchInfos[i].Subtree := watch.WatchSubtree;
FWatchInfos[i].Handle :=
CreateFile(
PChar(watch.Directory),
FILE_LIST_DIRECTORY,
FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE,
nil,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS or FILE_FLAG_OVERLAPPED,
0
);
FWatchInfos[i].Overlapped.hEvent := CreateEvent(nil, TRUE, FALSE, nil);
SetLength(FWatchInfos[i].Buffer, NOTIFY_BUFFER_SIZE);
if FWatchInfos[i].Handle <> INVALID_HANDLE_VALUE then
InitialScan(FWatchInfos[i]);
end;
FThreadState := TaskManager.CreateThread(MonitorLoop);
end;
destructor TDirectoryMonitor.Destroy;
var
info: TWatchInfo;
begin
// Signal the monitoring thread to stop.
SetEvent(FStopEvent);
// Cancel all pending IO operations for the handle to ensure the thread is unblocked.
for info in FWatchInfos do
begin
if info.Handle <> INVALID_HANDLE_VALUE then
CancelIoEx(info.Handle, nil);
end;
// Wait for the thread to terminate gracefully.
TaskManager.WaitFor(FThreadState);
// After the thread has terminated, clean up all handles and resources.
for info in FWatchInfos do
begin
if info.Handle <> INVALID_HANDLE_VALUE then
CloseHandle(info.Handle);
if info.Overlapped.hEvent <> 0 then
CloseHandle(info.Overlapped.hEvent);
end;
CloseHandle(FStopEvent);
FFiles.Free;
FLock.Free;
inherited;
end;
procedure TDirectoryMonitor.InitialScan(const WatchInfo: TWatchInfo);
procedure Scan(const Dir: string);
var
filePath, subDirPath: string;
sr: TSearchRec;
begin
try
// 1. Process all files in the current directory using the modern, more robust API.
for filePath in TDirectory.GetFiles(Dir) do
begin
if FileMatchesExtensions(filePath, FExtensions) then
begin
// We still need FindFirst here, but only on a specific file,
// not for enumeration, which avoids the bug.
if System.SysUtils.FindFirst(filePath, faAnyFile, sr) = 0 then
try
FFiles.AddOrSetValue(filePath, TFileInfo.Create(filePath, sr));
finally
System.SysUtils.FindClose(sr);
end;
end;
end;
// 2. If recursive, process all subdirectories.
if WatchInfo.Subtree then
begin
for subDirPath in TDirectory.GetDirectories(Dir) do
begin
Scan(subDirPath); // Recurse into subdirectory
end;
end;
except
// Ignore exceptions from inaccessible directories (e.g., access denied)
on E: EInOutError do
begin
end;
end;
end;
begin
FLock.Enter;
try
Scan(WatchInfo.Directory);
finally
FLock.Leave;
end;
end;
procedure TDirectoryMonitor.MonitorLoop;
var
handles: TArray<THandle>;
i: Integer;
notifyFilter: DWORD;
waitResult: DWORD;
bytesRead: DWORD;
changedIndex: Integer;
success: Boolean;
begin
notifyFilter := TranslateChangeKinds();
for i := 0 to High(FWatchInfos) do
begin
if FWatchInfos[i].Handle <> INVALID_HANDLE_VALUE then
begin
ReadDirectoryChangesW(
FWatchInfos[i].Handle,
@FWatchInfos[i].Buffer[0],
Length(FWatchInfos[i].Buffer),
FWatchInfos[i].Subtree,
notifyFilter,
nil,
@FWatchInfos[i].Overlapped,
nil
);
end;
end;
SetLength(handles, Length(FWatchInfos) + 1);
handles[0] := FStopEvent;
for i := 0 to High(FWatchInfos) do
handles[i + 1] := FWatchInfos[i].Overlapped.hEvent;
while True do
begin
waitResult := WaitForMultipleObjects(Length(handles), @handles[0], FALSE, INFINITE);
if waitResult = WAIT_OBJECT_0 then
break;
if (waitResult >= WAIT_OBJECT_0 + 1) and (waitResult < WAIT_OBJECT_0 + Length(handles)) then
begin
changedIndex := waitResult - (WAIT_OBJECT_0 + 1);
success := GetOverlappedResult(FWatchInfos[changedIndex].Handle, FWatchInfos[changedIndex].Overlapped, bytesRead, FALSE);
if success then
begin
if bytesRead > 0 then
begin
ProcessChangeBuffer(FWatchInfos[changedIndex], bytesRead)
end
else
begin
InitialScan(FWatchInfos[changedIndex]);
end;
end;
ReadDirectoryChangesW(
FWatchInfos[changedIndex].Handle,
@FWatchInfos[changedIndex].Buffer[0],
Length(FWatchInfos[changedIndex].Buffer),
FWatchInfos[changedIndex].Subtree,
notifyFilter,
nil,
@FWatchInfos[changedIndex].Overlapped,
nil
);
end
else
begin
break;
end;
end;
// No cleanup logic here; the destructor is responsible.
end;
procedure TDirectoryMonitor.ProcessChangeBuffer(const WatchInfo: TWatchInfo; BytesWritten: DWORD);
var
pInfo: PFileNotifyInformation;
offset: DWORD;
fileName: string;
begin
pInfo := PFileNotifyInformation(@WatchInfo.Buffer[0]);
while True do
begin
SetString(fileName, PWideChar(@pInfo.FileName), pInfo.FileNameLength div SizeOf(WideChar));
HandleFileChange(pInfo.Action, WatchInfo.Directory, fileName);
if pInfo.NextEntryOffset = 0 then
break;
offset := pInfo.NextEntryOffset;
pInfo := PFileNotifyInformation(PByte(pInfo) + offset);
end;
end;
procedure TDirectoryMonitor.HandleFileChange(Action: DWORD; const BasePath, FileName: string);
var
fullPath: string;
sr: TSearchRec;
oldFileInfo, newFileInfo: IFileInfo;
begin
fullPath := TPath.Combine(BasePath, FileName);
if not FileMatchesExtensions(fullPath, FExtensions) then
exit;
case Action of
FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_RENAMED_NEW_NAME:
begin
if TFile.Exists(fullPath) and (System.SysUtils.FindFirst(fullPath, faAnyFile, sr) = 0) then
try
FLock.Enter;
try
newFileInfo := TFileInfo.Create(fullPath, sr);
if FFiles.TryGetValue(fullPath, oldFileInfo) then
begin
(oldFileInfo as TFileInfo).NotifyChanges(TFileState.Changed);
end
else
begin
(newFileInfo as TFileInfo).NotifyChanges(TFileState.Added);
end;
FFiles.AddOrSetValue(fullPath, newFileInfo);
finally
FLock.Leave;
end;
finally
System.SysUtils.FindClose(sr);
end;
end;
FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_OLD_NAME:
begin
FLock.Enter;
try
if FFiles.TryGetValue(fullPath, oldFileInfo) then
begin
(oldFileInfo as TFileInfo).NotifyChanges(TFileState.Deleted);
FFiles.Remove(fullPath);
end;
finally
FLock.Leave;
end;
end;
end;
end;
function TDirectoryMonitor.TranslateChangeKinds: DWORD;
begin
Result := 0;
if TFileChangeKind.FileNameChange in FChangeKinds then
Result := Result or FILE_NOTIFY_CHANGE_FILE_NAME;
if TFileChangeKind.DirNameChange in FChangeKinds then
Result := Result or FILE_NOTIFY_CHANGE_DIR_NAME;
if TFileChangeKind.AttributeChange in FChangeKinds then
Result := Result or FILE_NOTIFY_CHANGE_ATTRIBUTES;
if TFileChangeKind.SizeChange in FChangeKinds then
Result := Result or FILE_NOTIFY_CHANGE_SIZE;
if TFileChangeKind.LastWriteChange in FChangeKinds then
Result := Result or FILE_NOTIFY_CHANGE_LAST_WRITE;
if TFileChangeKind.SecurityChange in FChangeKinds then
Result := Result or FILE_NOTIFY_CHANGE_SECURITY;
end;
class function TDirectoryMonitor.FileMatchesExtensions(const FileName: string; const Extensions: TArray<string>): Boolean;
var
ext: string;
fileExt: string;
begin
if (Length(Extensions) = 0) or ((Length(Extensions) = 1) and (Extensions[0] = '*')) then
exit(true);
fileExt := TPath.GetExtension(FileName);
for ext in Extensions do
begin
if SameText(fileExt, ext) then
exit(true);
end;
Result := false;
end;
function TDirectoryMonitor.GetFiles(const Name: String): IFileInfo;
begin
FLock.Enter;
try
if not FFiles.TryGetValue(Name, Result) then
Result := nil;
finally
FLock.Leave;
end;
end;
function TDirectoryMonitor.GetFileCount: Integer;
begin
FLock.Enter;
try
Result := FFiles.Count;
finally
FLock.Leave;
end;
end;
function TDirectoryMonitor.CheckState(const FileInfo: IFileInfo): TFileState;
var
currentFileInfo: IFileInfo;
begin
if not Assigned(FileInfo) then
begin
Result := TFileState.Deleted;
exit;
end;
FLock.Enter;
try
if not FFiles.TryGetValue(FileInfo.FullPath, currentFileInfo) then
begin
Result := TFileState.Deleted;
end
else if currentFileInfo = FileInfo then
begin
Result := TFileState.Unchanged;
end
else
begin
Result := TFileState.Changed;
end;
finally
FLock.Leave;
end;
end;
function TDirectoryMonitor.TryFindNewVersion(const OldFile: IFileInfo; out NewFile: IFileInfo): TFileState;
begin
NewFile := nil;
if not Assigned(OldFile) then
begin
Result := TFileState.Deleted;
exit;
end;
FLock.Enter;
try
if FFiles.TryGetValue(OldFile.FullPath, NewFile) then
begin
if NewFile = OldFile then
Result := TFileState.Unchanged
else
Result := TFileState.Changed;
end
else
begin
Result := TFileState.Deleted;
end;
finally
FLock.Leave;
end;
end;
{ TDirectoryMonitorFactory }
constructor TDirectoryMonitorFactory.Create;
begin
inherited;
FDirectories := TList<TDirectoryWatch>.Create;
FExtensions := TList<string>.Create;
FChangeKinds :=
[TFileChangeKind.FileNameChange, TFileChangeKind.LastWriteChange, TFileChangeKind.SizeChange, TFileChangeKind.DirNameChange];
end;
destructor TDirectoryMonitorFactory.Destroy;
begin
FreeAndNil(FDirectories);
FreeAndNil(FExtensions);
inherited;
end;
procedure TDirectoryMonitorFactory.AddDirectory(const DirPath: string; SubDirHandling: TSubDirectoryHandling);
var
watch: TDirectoryWatch;
begin
watch.Directory := TPath.GetFullPath(DirPath);
watch.WatchSubtree := (SubDirHandling = TSubDirectoryHandling.Recursive);
FDirectories.Add(watch);
end;
procedure TDirectoryMonitorFactory.AddExtension(const Extension: string);
begin
FExtensions.Add(Extension);
end;
procedure TDirectoryMonitorFactory.SetChangeKinds(const ChangeKinds: TFileChangeKinds);
begin
FChangeKinds := ChangeKinds;
end;
function TDirectoryMonitorFactory.CreateMonitor: IDirectoryMonitor;
begin
Result := TDirectoryMonitor.Create(FDirectories.ToArray, FExtensions.ToArray, FChangeKinds);
FDirectories.Clear;
FExtensions.Clear;
end;
end.