Skip to content

Instantly share code, notes, and snippets.

@Jegge
Created December 24, 2017 10:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Jegge/4dd4b2700bc6826d0f5f7952fe5c208c to your computer and use it in GitHub Desktop.
Save Jegge/4dd4b2700bc6826d0f5f7952fe5c208c to your computer and use it in GitHub Desktop.
`DirectoryWatcher` can watch a directory and post a `DirectoryWatcher.didChangeNotification` notification when it's content changed.
//
// DirectoryWatcher.swift
//
// Created by Sebastian Boettcher on 16.07.17.
// Copyright © 2017 Sebastian Boettcher. All rights reserved.
//
// License: MIT
//
import Foundation
/// `DirectoryWatcher` can watch a directory and post a `DirectoryWatcher.didChangeNotification` notification when it's content changed.
class DirectoryWatcher
{
/// The name of the `Notification` that will be posted after the directory content changed.
static let didChangeNotification = Notification.Name(rawValue: "DirectoryWatcherChangedNotification")
public struct UserInfoKey {
static let Added = "Added"
static let Removed = "Removed"
}
// MARK: - Fields
private let fileCheckDelay: TimeInterval = 0.5
private let fileSystemQueue: DispatchQueue = DispatchQueue(label: "DirectoryWatcherFileSystemQueue")
private let timerQueue: DispatchQueue?
private let syncQueue: DispatchQueue?
private let timerSource: DispatchSourceTimer?
private var fileSystemSource: DispatchSourceFileSystemObject?
private var snapshot: Set<String> = []
private var added: [URL] = []
private var removed: [URL] = []
private let aggregationDelay: TimeInterval
// MARK: - Initialization
/// Initializes a `DirectoryWatcher` that will accumulate changes and fire after the last change happened `aggregationDelay` ago.
/// - Parameter aggregationDelay: The delay that will be waited after a change for other changes to follow. If set to *0* (default) then the `Notification` will be posted as soon as possible after a file got removed or after copying of a new file finished.
required init(aggregationDelay: TimeInterval = 0)
{
self.aggregationDelay = aggregationDelay
if aggregationDelay > 0 {
self.timerQueue = DispatchQueue(label: "DirectoryWatcherTimerQueue")
self.syncQueue = DispatchQueue(label: "DirectoryWatcherSyncQueue")
self.timerSource = DispatchSource.makeTimerSource(queue: self.timerQueue)
self.timerSource?.setEventHandler { [unowned self] in
self.syncQueue?.async {
self.notify(added: self.added, removed: self.removed)
self.added.removeAll()
self.removed.removeAll()
}
}
self.timerSource?.resume()
} else {
self.timerSource = nil
self.timerQueue = nil
self.syncQueue = nil
}
}
deinit
{
self.stop()
self.timerSource?.cancel()
}
// MARK: - Public Methods
/// Starts watching the directory given by the URL.
/// - Parameter directory: Specifies the URL of the directory to watch
/// - Throws: PosixErrors on various failure conditions
func start(directory: URL) throws
{
if !((try? directory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false) ?? false) {
throw POSIXError(.ENOTDIR)
}
let fd = open(directory.path, O_EVTONLY)
if fd < 0 {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))])
}
self.fileSystemSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fd, eventMask: .write, queue: self.fileSystemQueue)
if self.fileSystemSource == nil {
close(fd)
throw POSIXError(.ENOENT)
}
self.fileSystemSource?.setRegistrationHandler(handler: {
do {
self.snapshot = try self.snapshot(directoryAt: directory)
} catch {
print("DirectoryWatcher: registration: failed to snapshot directory \(directory.path): \(error)")
}
})
self.fileSystemSource?.setEventHandler(handler: {
do {
let snapshot = try self.snapshot(directoryAt: directory)
let added = snapshot.subtracting(self.snapshot)
let removed = self.snapshot.subtracting(snapshot)
self.snapshot = snapshot
for filename in removed {
self.record(removed: directory.appendingPathComponent(filename))
}
for filename in added {
self.monitor(file: directory.appendingPathComponent(filename))
}
} catch {
print("DirectoryWatcher: event: failed to snapshot directory \(directory.path): \(error)")
}
})
self.fileSystemSource?.setCancelHandler(handler: {
close(fd)
})
self.fileSystemSource?.resume()
}
/// Stops watching the directory.
func stop()
{
self.fileSystemSource?.cancel()
self.fileSystemSource = nil
}
private func snapshot(directoryAt url: URL) throws -> Set<String>
{
var result: Set<String> = []
for url in try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants, .skipsPackageDescendants]) {
let attributes = try url.resourceValues(forKeys: [.isDirectoryKey])
if let isDirectory = attributes.isDirectory, !isDirectory {
result.insert(url.lastPathComponent)
}
}
return result
}
private func monitor(file url: URL)
{
DispatchQueue.global(qos: .background).async
{
var size: off_t = 0
let fd = open(url.path, O_RDWR)
if fd == -1 {
print("DirectoryWatcher: monitor: failed to open file \(url.path): \(errno)")
return
}
while true {
usleep(useconds_t(self.fileCheckDelay * 1000000))
self.record()
var s = stat()
if fstat(fd, &s) != 0 {
print("DirectoryWatcher: monitor: failed to stat file \(url.path): \(errno)")
break
}
if size == s.st_size {
self.record(added: url)
break
}
size = s.st_size
}
close(fd)
}
}
private func record(added: URL? = nil, removed: URL? = nil)
{
if self.aggregationDelay > 0 {
self.syncQueue?.sync {
if let a = added {
self.added.append(a)
}
if let r = removed {
self.removed.append(r)
}
let delay = self.fileCheckDelay + self.aggregationDelay
self.timerSource?.schedule(deadline: .now() + delay)
}
} else if added != nil || removed != nil {
self.notify(added: added == nil ? [] : [added!], removed: removed == nil ? [] : [removed!])
}
}
private func notify(added: [URL], removed: [URL])
{
var userInfo: [AnyHashable: Any] = [:]
if added.count > 0 {
userInfo[DirectoryWatcher.UserInfoKey.Added] = added
}
if removed.count > 0 {
userInfo[DirectoryWatcher.UserInfoKey.Removed] = removed
}
NotificationCenter.default.post(name: DirectoryWatcher.didChangeNotification, object: self, userInfo: userInfo)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment