Created
December 24, 2017 10:06
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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