FSWatcher
May 24, 2026 ยท View on GitHub
Using my apps is also a way to support me:
A high-performance, Swift-native file system watcher for macOS and iOS that provides intelligent monitoring of directory changes with minimal system resource usage.
Features
โจ Event-Driven Architecture - Uses DispatchSource and macOS FSEvents for efficient file system monitoring
๐ฏ Smart Filtering - Advanced filter chains with support for file types, sizes, and patterns
๐ Predictive Ignoring - Avoid monitoring self-generated files
๐ Recursive Monitoring - Watch entire directory trees with configurable depth, FD-safe ceilings, or a one-stream macOS FSEvents backend
โก Modern Swift - Full support for Combine, Swift Concurrency, and structured concurrency
๐ Safe Recursive Scan - Iterative stack-based scan prevents call-stack overflow on deep trees
๐ก๏ธ Thread-Safe - Designed for concurrent use across multiple threads
๐ Low Resource Usage - Minimal CPU and memory footprint
Installation
Swift Package Manager
Add FSWatcher to your project through Xcode or by adding it to your Package.swift:
dependencies: [
.package(url: "https://github.com/okooo5km/FSWatcher.git", from: "0.3.0")
]
Quick Start
Basic Usage
import FSWatcher
// Create a watcher for a directory
let watcher = try DirectoryWatcher(url: URL(fileURLWithPath: "/Users/user/Documents"))
// Set up event handler
watcher.onDirectoryChange = { url in
print("Directory changed: \\(url.path)")
}
// Start watching
watcher.start()
Filtered Watching
// Watch only image files larger than 1KB
var config = DirectoryWatcher.Configuration()
config.filterChain.add(.imageFiles)
config.filterChain.add(.fileSize(1024...))
let watcher = try DirectoryWatcher(url: watchURL, configuration: config)
watcher.onFilteredChange = { imageFiles in
print("New images: \\(imageFiles.map { \$0.lastPathComponent })")
}
Multiple Directories
let multiWatcher = MultiDirectoryWatcher()
multiWatcher.onDirectoryChange = { url in
print("Change in: \\(url.path)")
}
multiWatcher.startWatching(directories: [documentsURL, downloadsURL])
Recursive Monitoring
var options = RecursiveWatchOptions()
options.maxDepth = 5
options.excludePatterns = ["node_modules", ".git", "*.tmp"]
options.maxWatchedDirectories = 256 // FD ceiling (default: 256)
options.backend = .dispatchSource // Default; preserves FSWatcher 0.1.x behavior
let recursiveWatcher = try RecursiveDirectoryWatcher(
url: projectURL,
options: options
)
// Safe to call from the main thread โ scan runs on a background queue
recursiveWatcher.start(on: .global(qos: .utility))
Large macOS Directory Trees
For large recursive trees on macOS, opt into the FSEvents backend. It watches the root hierarchy with a single FSEvent stream instead of opening one file descriptor per subdirectory:
let options = RecursiveWatchOptions(
maxDepth: 5,
backend: .fsevents
)
let watcher = try RecursiveDirectoryWatcher(url: photosURL, options: options)
watcher.onFileChange = { event in
guard event.itemKind == .file, event.eventType != .deleted else { return }
print("Changed file: \(event.url.path)")
}
watcher.start()
RecursiveWatchOptions() still defaults to .dispatchSource for source
compatibility. Use .automatic when you want FSWatcher to pick FSEvents on
macOS and DispatchSource elsewhere. If followSymlinks is enabled, .automatic
falls back to DispatchSource because FSEvents does not follow symlinked
directories as independent recursive roots.
When the FSEvents backend is active, watchedDirectories returns the watched
root URL. maxWatchedDirectories only applies to the DispatchSource backend.
If your app only needs exact file events, disable filtered directory snapshots to avoid listing large directories on directory-level events:
var configuration = DirectoryWatcher.Configuration()
configuration.scansChangedDirectoriesForFilteredEvents = false
let watcher = try RecursiveDirectoryWatcher(
url: photosURL,
options: options,
configuration: configuration
)
watcher.onFileChange = { event in
process(event.url)
}
Stress Testing
The package includes a Swift stress runner for large recursive trees:
swift run FSWatcherStress --dirs 1000 --files-per-dir 100 --backend fsevents --max-depth 2
swift run FSWatcherStress --dirs 1000 --backend dispatch --max-watchers 256 --timeout 3
Multiple Recursive Directories
var options = RecursiveWatchOptions()
options.maxDepth = 3
options.excludePatterns = [".git", "node_modules", "*.tmp"]
let multiRecursiveWatcher = MultiRecursiveDirectoryWatcher(options: options)
multiRecursiveWatcher.onDirectoryChange = { url in
print("Change detected: \\(url.path)")
}
multiRecursiveWatcher.startWatching(directories: [
projectURL1,
projectURL2,
projectURL3
])
Advanced Features
Smart Filtering System
FSWatcher provides a powerful filtering system that can be chained together:
// Combine multiple filters
watcher.addFilter(
.fileExtensions(["swift", "m"])
.and(.fileSize(1000...))
.and(.modifiedWithin(3600))
)
// Pre-built filter types
config.filterChain.add(.imageFiles) // Images
config.filterChain.add(.videoFiles) // Videos
config.filterChain.add(.documentFiles) // Documents
config.filterChain.add(.directoriesOnly) // Directories only
Predictive Ignoring
Prevent monitoring your own output files:
// Set up a transform predictor
let predictor = FileTransformPredictor.imageCompression(suffix: "_compressed")
config.transformPredictor = predictor
// The watcher will automatically ignore predicted output files
watcher.onFilteredChange = { newImages in
for image in newImages {
compressImage(image) // Output will be automatically ignored
}
}
Combine Integration
import Combine
watcher.directoryChangePublisher
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.sink { url in
print("Debounced change: \\(url.path)")
}
.store(in: &cancellables)
Swift Concurrency
// Async start โ returns once the initial recursive scan completes
await recursiveWatcher.startAsync()
for await url in recursiveWatcher.directoryChanges {
await processChange(at: url)
}
Configuration Options
DirectoryWatcher.Configuration
var config = DirectoryWatcher.Configuration()
// Debounce interval (default: 0.5 seconds)
config.debounceInterval = 1.0
// File system events to monitor
config.eventMask = [.write, .extend, .delete, .rename]
// Processing queue
config.queue = .global(qos: .userInitiated)
// Filter chain
config.filterChain.add(.imageFiles)
// Ignore list management
config.ignoreList.addIgnorePattern("*.tmp")
// Transform prediction
config.transformPredictor = FileTransformPredictor.imageCompression()
Error Handling
watcher.onError = { error in
switch error {
case .cannotOpenDirectory(let url):
print("Cannot open: \\(url.path)")
case .insufficientPermissions(let url):
print("Permission denied: \\(url.path)")
case .directoryNotFound(let url):
print("Not found: \\(url.path)")
case .tooManyWatchers(let limit):
print("Watcher ceiling reached (\\(limit)); deeper subdirectories are not being watched")
case .failedToWatch(let url, let underlying):
print("Failed to watch \\(url.path): \\(underlying.localizedDescription)")
default:
print("Error: \\(error)")
}
}
Use Cases
Image Processing Pipeline
Perfect for building image compression tools like Zipic:
let pipeline = try ImageCompressionPipeline(
watchDirectory: URL(fileURLWithPath: "/Users/user/ToCompress"),
compressionQuality: 0.8
)
pipeline.start()
Development Tool Hot Reload
Monitor source code changes:
let projectWatcher = try RecursiveDirectoryWatcher(url: projectURL)
projectWatcher.addFilter(.fileExtensions(["swift", "js", "css"]))
projectWatcher.onFilteredChange = { changedFiles in
triggerHotReload(for: changedFiles)
}
Automatic Backup System
let backupWatcher = try DirectoryWatcher(url: documentsURL)
backupWatcher.addFilter(.modifiedWithin(300)) // Last 5 minutes
backupWatcher.onFilteredChange = { recentFiles in
performIncrementalBackup(files: recentFiles)
}
Performance Considerations
- Event-driven: Only processes actual file system events, no polling
- Debounced: Prevents excessive event handling during rapid changes
- Filtered: Process only relevant files using efficient filter chains
- Resource management: Automatic cleanup of file descriptors and resources
Thread Safety
FSWatcher is designed to be thread-safe:
- All public APIs can be called from any thread
- Internal state is protected with appropriate synchronization
- Event handlers are called on the configured dispatch queue
System Requirements
- macOS: 12.0+
- iOS: 15.0+
- Swift: 5.9+
- Xcode: 14.0+
Documentation
Examples
The Examples/ directory contains complete, runnable examples:
BasicUsage.swift- Fundamental usage patternsImageCompression.swift- Complete image processing pipelineHotReload.swift- Development tool integration
Contributing
Contributions are welcome! Please read our Contributing Guide for details.
License
FSWatcher is available under the MIT license. See the LICENSE file for more info.
Acknowledgments
FSWatcher was inspired by the successful file monitoring implementation in Zipic, a popular image compression tool for macOS. The design focuses on performance, reliability, and developer experience learned from real-world usage.
Made with โค๏ธ for the Swift community