PSWatchdog.psm1
|
# PSWatchdog.psm1 # A modern PowerShell module for monitoring file system events # Provides a seamless, Python-like experience for file monitoring #region Module-Level Variables # Hashtable to store all active file watchers in the current session # Key: Watcher ID (GUID), Value: PSCustomObject with watcher details $script:ActiveWatchers = @{} # Counter for generating unique watcher IDs $script:WatcherIdCounter = 0 #endregion Module-Level Variables #region Helper Functions <# .SYNOPSIS Generates a unique ID for a new file watcher. #> function Get-WatcherId { $script:WatcherIdCounter++ return "Watcher_{0}_{1}" -f $script:WatcherIdCounter, [Guid]::NewGuid().ToString("N").Substring(0, 8) } <# .SYNOPSIS Converts .NET FileSystemEventArgs to a clean PowerShell object. .PARAMETER EventArgs The .NET event arguments from FileSystemWatcher. #> function ConvertTo-PowerShellEvent { param( [System.IO.FileSystemEventArgs]$EventArgs ) return [PSCustomObject]@{ FullPath = $EventArgs.FullPath Name = $EventArgs.Name ChangeType = $EventArgs.ChangeType.ToString() TimeStamp = [DateTime]::Now } } <# .SYNOPSIS Converts .NET RenamedEventArgs to a clean PowerShell object. .PARAMETER EventArgs The .NET event arguments from FileSystemWatcher. #> function ConvertTo-PowerShellRenamedEvent { param( [System.IO.RenamedEventArgs]$EventArgs ) return [PSCustomObject]@{ FullPath = $EventArgs.FullPath Name = $EventArgs.Name ChangeType = $EventArgs.ChangeType.ToString() OldFullPath = $EventArgs.OldFullPath OldName = $EventArgs.OldName TimeStamp = [DateTime]::Now } } <# .SYNOPSIS Creates a hashtable with watcher info that can be passed to background jobs. #> function New-WatcherMessageData { param( [string]$WatcherId, [scriptblock]$Action ) # Convert scriptblock to string for serialization $actionString = $Action.ToString() return @{ WatcherId = $WatcherId ActionString = $actionString } } #endregion Helper Functions #region Cmdlets function Start-FileWatcher { <# .SYNOPSIS Starts monitoring a file system path for changes. .DESCRIPTION Creates a FileSystemWatcher to monitor Create, Modify, Delete, and Rename events on a specified path. The watcher runs asynchronously and fires a custom action when specified events occur. .PARAMETER Path The path to monitor. This parameter is mandatory. .PARAMETER Filter The file filter to monitor (e.g., *.txt, *.*). Defaults to *.*. .PARAMETER IncludeSubdirectories If specified, monitors subdirectories as well. .PARAMETER ChangeType An array of event types to monitor: Created, Changed, Deleted, Renamed. Defaults to all event types. .PARAMETER Action A ScriptBlock to execute when an event fires. The script block receives a custom PSObject with FullPath, ChangeType, and TimeStamp properties. .EXAMPLE Start-FileWatcher -Path "C:\Logs" -Filter "*.log" -Action { param($event) Write-Host "File $($event.Name) was $($event.ChangeType)" } .EXAMPLE Start-FileWatcher -Path "C:\Data" -IncludeSubdirectories -ChangeType Created,Changed #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateScript({ Test-Path $_ -PathType Container })] [string]$Path, [Parameter(Position = 1)] [string]$Filter = "*.*", [Parameter(Position = 2)] [switch]$IncludeSubdirectories, [Parameter(Position = 3)] [ValidateSet("Created", "Changed", "Deleted", "Renamed")] [string[]]$ChangeType = @("Created", "Changed", "Deleted", "Renamed"), [Parameter(Position = 4)] [scriptblock]$Action ) begin { # Resolve the path to an absolute path $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } process { try { # Create the FileSystemWatcher instance # The InternalBufferSize is set to 65536 bytes (64KB) to prevent buffer overflow # during bulk file operations - a common .NET pitfall $watcher = [System.IO.FileSystemWatcher]::new() # Set watcher properties $watcher.Path = $Path $watcher.Filter = $Filter $watcher.IncludeSubdirectories = $IncludeSubdirectories.IsPresent $watcher.NotifyFilter = [System.IO.NotifyFilters]::FileName -bor [System.IO.NotifyFilters]::DirectoryName -bor [System.IO.NotifyFilters]::LastWrite -bor [System.IO.NotifyFilters]::Size # IMPORTANT: Increase InternalBufferSize to prevent crashes during bulk operations # Default is 8192 bytes (8KB), which is too small for many scenarios # Setting to 65536 (64KB) handles thousands of files more reliably $watcher.InternalBufferSize = 65536 # Enable raising events $watcher.EnableRaisingEvents = $true # Generate a unique ID for this watcher $watcherId = Get-WatcherId # Store event subscriptions for cleanup $eventSubscriptions = @() # Create message data that includes the action string (for passing to background jobs) $messageData = @{ WatcherId = $watcherId ActionString = if ($Action) { $Action.ToString() } else { $null } } # Wire up events based on ChangeType parameter # Using Register-ObjectEvent to subscribe to .NET events foreach ($type in $ChangeType) { switch ($type) { "Created" { $createdEvent = Register-ObjectEvent -InputObject $watcher ` -EventName "Created" ` -SourceIdentifier "${watcherId}_Created" ` -MessageData $messageData ` -SupportEvent ` -Action { param($sender, $eventArgs) # Convert to clean PowerShell object $psEvent = [PSCustomObject]@{ FullPath = $eventArgs.FullPath Name = $eventArgs.Name ChangeType = $eventArgs.ChangeType.ToString() TimeStamp = [DateTime]::Now } # Get action from message data and execute $actionString = $event.MessageData.ActionString if ($actionString) { $action = [scriptblock]::Create($actionString) & $action $psEvent } } $eventSubscriptions += $createdEvent Write-Verbose "Registered Created event handler" } "Changed" { $changedEvent = Register-ObjectEvent -InputObject $watcher ` -EventName "Changed" ` -SourceIdentifier "${watcherId}_Changed" ` -MessageData $messageData ` -SupportEvent ` -Action { param($sender, $eventArgs) # Convert to clean PowerShell object $psEvent = [PSCustomObject]@{ FullPath = $eventArgs.FullPath Name = $eventArgs.Name ChangeType = $eventArgs.ChangeType.ToString() TimeStamp = [DateTime]::Now } # Get action from message data and execute $actionString = $event.MessageData.ActionString if ($actionString) { $action = [scriptblock]::Create($actionString) & $action $psEvent } } $eventSubscriptions += $changedEvent Write-Verbose "Registered Changed event handler" } "Deleted" { $deletedEvent = Register-ObjectEvent -InputObject $watcher ` -EventName "Deleted" ` -SourceIdentifier "${watcherId}_Deleted" ` -MessageData $messageData ` -SupportEvent ` -Action { param($sender, $eventArgs) # Convert to clean PowerShell object $psEvent = [PSCustomObject]@{ FullPath = $eventArgs.FullPath Name = $eventArgs.Name ChangeType = $eventArgs.ChangeType.ToString() TimeStamp = [DateTime]::Now } # Get action from message data and execute $actionString = $event.MessageData.ActionString if ($actionString) { $action = [scriptblock]::Create($actionString) & $action $psEvent } } $eventSubscriptions += $deletedEvent Write-Verbose "Registered Deleted event handler" } "Renamed" { $renamedEvent = Register-ObjectEvent -InputObject $watcher ` -EventName "Renamed" ` -SourceIdentifier "${watcherId}_Renamed" ` -MessageData $messageData ` -SupportEvent ` -Action { param($sender, $eventArgs) # Convert to clean PowerShell object (with Old* properties for rename) $psEvent = [PSCustomObject]@{ FullPath = $eventArgs.FullPath Name = $eventArgs.Name ChangeType = $eventArgs.ChangeType.ToString() OldFullPath = $eventArgs.OldFullPath OldName = $eventArgs.OldName TimeStamp = [DateTime]::Now } # Get action from message data and execute $actionString = $event.MessageData.ActionString if ($actionString) { $action = [scriptblock]::Create($actionString) & $action $psEvent } } $eventSubscriptions += $renamedEvent Write-Verbose "Registered Renamed event handler" } } } # Create the tracking object to return to the user $watcherObject = [PSCustomObject]@{ Id = $watcherId Path = $Path Filter = $Filter IncludeSubdirectories = $IncludeSubdirectories.IsPresent ChangeType = $ChangeType Action = $Action Watcher = $watcher EventSubscriptions = $eventSubscriptions CreatedAt = [DateTime]::Now } # Add the watcher to the module-level hashtable for tracking # This allows Get-FileWatcher to enumerate all active watchers # and Stop-FileWatcher to find and dispose of specific watchers $script:ActiveWatchers[$watcherId] = $watcherObject # Return the tracking object to the user # The user can store this and use the Id or Path to stop the watcher later return $watcherObject } catch { # Clean up any partial registration on failure if ($watcher) { $watcher.EnableRaisingEvents = $false $watcher.Dispose() } foreach ($sub in $eventSubscriptions) { if ($sub) { Unregister-Event -SourceIdentifier $sub.Name -ErrorAction SilentlyContinue Remove-Job -Id $sub.Id -ErrorAction SilentlyContinue } } throw "Failed to start file watcher: $_" } } end { } } function Get-FileWatcher { <# .SYNOPSIS Gets all active file watchers in the current session. .DESCRIPTION Returns a list of all file watchers that were created by Start-FileWatcher and are still active in the current PowerShell session. .EXAMPLE Get-FileWatcher .EXAMPLE Get-FileWatcher | Format-List #> [CmdletBinding()] param() process { # Create a snapshot of the hashtable keys to avoid enumeration issues $watcherIds = @($script:ActiveWatchers.Keys) $results = @() if ($watcherIds.Count -gt 0) { foreach ($id in $watcherIds) { # Skip if watcher was removed between iterations if (-not $script:ActiveWatchers.ContainsKey($id)) { continue } $watcher = $script:ActiveWatchers[$id] $results += [PSCustomObject]@{ Id = $watcher.Id Path = $watcher.Path Filter = $watcher.Filter IncludeSubdirectories = $watcher.IncludeSubdirectories ChangeType = $watcher.ChangeType CreatedAt = $watcher.CreatedAt } } } # Always return an array (empty or with results) return $results } } function Stop-FileWatcher { <# .SYNOPSIS Stops an active file watcher. .DESCRIPTION Stops and cleans up a file watcher that was started with Start-FileWatcher. This properly disposes of the FileSystemWatcher, unregisters events, and removes associated jobs to prevent memory leaks. .PARAMETER Id The ID of the watcher to stop (returned by Start-FileWatcher). .PARAMETER Path The path of the watcher to stop (the path that was being monitored). .EXAMPLE Stop-FileWatcher -Id "Watcher_1_abc12345" .EXAMPLE $watcher = Start-FileWatcher -Path "C:\Logs" Stop-FileWatcher -Id $watcher.Id .EXAMPLE Stop-FileWatcher -Path "C:\Logs" #> [CmdletBinding()] param( [Parameter(Mandatory = $true, ParameterSetName = "ById", ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string]$Id, [Parameter(Mandatory = $true, ParameterSetName = "ByPath", ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string]$Path ) process { $watcherToStop = $null $watcherIdToStop = $null # Find the watcher based on the parameter set if ($PSCmdlet.ParameterSetName -eq "ById") { # Look up by ID if ($script:ActiveWatchers.ContainsKey($Id)) { $watcherToStop = $script:ActiveWatchers[$Id] $watcherIdToStop = $Id Write-Verbose "Found watcher with ID: $Id" } else { Write-Error "No active watcher found with ID: $Id. Use Get-FileWatcher to see all active watchers." return } } elseif ($PSCmdlet.ParameterSetName -eq "ByPath") { # Resolve the path to absolute form $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) # Create snapshot of keys to avoid enumeration issues $watcherIds = @($script:ActiveWatchers.Keys) # Look up by path - find first matching watcher foreach ($id in $watcherIds) { if ($script:ActiveWatchers.ContainsKey($id) -and $script:ActiveWatchers[$id].Path -eq $resolvedPath) { $watcherToStop = $script:ActiveWatchers[$id] $watcherIdToStop = $id Write-Verbose "Found watcher for path: $resolvedPath" break } } if (-not $watcherToStop) { Write-Error "No active watcher found monitoring path: $Path. Use Get-FileWatcher to see all active watchers." return } } # Safety check if (-not $watcherToStop) { Write-Error "Unable to locate watcher to stop." return } try { # Step 1: Disable the watcher to stop new events if ($watcherToStop.Watcher) { $watcherToStop.Watcher.EnableRaisingEvents = $false Write-Verbose "Disabled event raising on watcher" } # Step 2: Unregister all event subscriptions # This disconnects the event handlers from the .NET objects if ($watcherToStop.EventSubscriptions) { foreach ($subscription in $watcherToStop.EventSubscriptions) { if ($subscription) { # Unregister the event Unregister-Event -SourceIdentifier $subscription.Name -ErrorAction SilentlyContinue Write-Verbose "Unregistered event: $($subscription.Name)" # Remove the associated job # Jobs are created by Register-ObjectEvent -Action Remove-Job -Id $subscription.Id -Force -ErrorAction SilentlyContinue Write-Verbose "Removed job: $($subscription.Id)" } } } # Step 3: Properly dispose of the FileSystemWatcher # This is critical to prevent memory leaks # Calling Dispose() releases all unmanaged resources if ($watcherToStop.Watcher) { $watcherToStop.Watcher.Dispose() Write-Verbose "Disposed FileSystemWatcher" } # Step 4: Remove from the active watchers hashtable $script:ActiveWatchers.Remove($watcherIdToStop) Write-Verbose "Removed watcher from active watchers list" # Return a success message object return [PSCustomObject]@{ Id = $watcherIdToStop Path = $watcherToStop.Path Status = "Stopped" Message = "File watcher successfully stopped and cleaned up." } } catch { Write-Error "Failed to stop file watcher: $_" } } } function Stop-AllFileWatchers { <# .SYNOPSIS Stops all active file watchers. .DESCRIPTION Convenience function to stop all file watchers that were created by Start-FileWatcher in the current session. .EXAMPLE Stop-AllFileWatchers #> [CmdletBinding()] param() process { $stoppedCount = 0 # Get all active watcher IDs as a snapshot $watcherIds = @($script:ActiveWatchers.Keys) if ($watcherIds.Count -eq 0) { Write-Verbose "No active watchers to stop" return [PSCustomObject]@{ Count = 0 Status = "No watchers were active" } } # Stop each watcher foreach ($id in $watcherIds) { # Skip if already removed if (-not $script:ActiveWatchers.ContainsKey($id)) { continue } try { $watcher = $script:ActiveWatchers[$id] # Disable events if ($watcher.Watcher) { $watcher.Watcher.EnableRaisingEvents = $false } # Unregister events and remove jobs if ($watcher.EventSubscriptions) { foreach ($subscription in $watcher.EventSubscriptions) { if ($subscription) { Unregister-Event -SourceIdentifier $subscription.Name -ErrorAction SilentlyContinue Remove-Job -Id $subscription.Id -Force -ErrorAction SilentlyContinue } } } # Dispose watcher if ($watcher.Watcher) { $watcher.Watcher.Dispose() } # Remove from hashtable $script:ActiveWatchers.Remove($id) $stoppedCount++ Write-Verbose "Stopped watcher: $id" } catch { Write-Warning "Failed to stop watcher $id : $_" } } return [PSCustomObject]@{ Count = $stoppedCount Status = if ($stoppedCount -gt 0) { "All watchers stopped" } else { "No watchers stopped" } } } } #endregion Cmdlets #region Module Export # Export module members Export-ModuleMember -Function Start-FileWatcher, Get-FileWatcher, Stop-FileWatcher, Stop-AllFileWatchers #endregion Module Export |