DispatchSource: Detecting changes in files and folders in Swift
The DispatchSource
family of types from GCD contains a series of objects that are capable of monitoring OS-related events. In this article, we'll see how to use DispatchSource
to monitor a log file and create a debug-only view that shows the app's latest logs.
Context: File logging in Swift
While every app will print debug logs to the developer console, it's good practice to save these logs somewhere. While OSLog
automatically saves your logs to the system, I find that maintaining your own log file (like MyApp-2020-11-24T14:23:42.log
) is an additional good practice. If your app receives a bug report from an external beta tester, you may find retrieving and inspecting your own log file easier than teaching that user how to extract and send their OSLogs
. For example, if you have your own log files, you can add a debug gesture that automatically dumps these logs somewhere.
Regardless of how you generate these logs, you can save them in two main ways. The most common way to write a file is to write all of the contents at once using String.write(to:)
:
var logs = ["Logged in!", "Logged out!"]
logs.joined(separator: "\n").write(to: logsPath, atomically: false, encoding: .utf8)
This is fine if you're writing all your logs at once when your app is going to close, but if you plan to continuously add content to a file, you should use FileHandler
:
let fileHandler = try FileHandle(forWritingTo: logsPath)
func addToFile(log: String) throws {
fileHandler.seekToEndOfFile() // Move the cursor to the end of the file
fileHandler.write(log.data(using: .utf8)!)
}
In the end, the difference between these two methods is that the first one is overwriting the file, while the second one is more similar to a text editor in terms that you're modifying an existing file.
Monitoring file changes
Monitoring changes in the file system is done by attaching a DispatchSource
object to the file/folder in question and registering which events we'd like to be notified of. Note though that a DispatchSource
is not necessarily restricted to file system events -- they are capable of monitoring many types of OS-related events, which include timers, processes, UNIX signals and more things that are meant to be used in macOS instead of iOS itself.
In this article, however, we're only going to monitor file events. To show how the process works, we are going to detect changes in a log file and display these changes in the app's UI.
If you have something akin to an internal employee beta of your app, a feature like this can be very useful. If someone finds a bug, they can open this feature and potentially determine the cause of the issue on the fly without needing a developer to boot Xcode and run an actual debug build.
The first step to monitor file changes is to abstract all of it. Let's start with a new FileMonitor
class:
final class FileMonitor {
let url: URL
let fileHandle: FileHandle
let source: DispatchSourceFileSystemObject
init(url: URL) throws {
self.url = url
...
}
}
To create a DispatchSource
that monitors the file system, we'll call the makeFileSystemObjectSource
factory to get a new DispatchSourceFileSystemObject
:
source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: ...,
eventMask: ...,
queue: ...
)
To fill these arguments, let's describe what each of them represents.
fileDescriptor
is an Int32
that represents a file descriptor pointing to the file/folder we want to monitor. Sounds crazy right? Don't worry! The same FileHandle
type used to write the logs can provide this information.
For eventMask
, we should pass the event types that we want to be notified of. The enum of possibilities includes many cases like .rename
, .delete
, .write
and .extend
, and for monitoring changes in files, the correct one to use depends on how you're writing to that file. If you're overwriting the file by calling String.write(to:)
, you should use .write
, but if you're modifying the file with FileHandle
, you should use .extend
instead. For this tutorial, we'll use the latter.
Finally, the queue
argument is the dispatch queue in which the events should be dispatched. For simplicity, we'll use the main queue.
self.fileHandle = try FileHandle(forReadingFrom: url)
source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileHandle.fileDescriptor,
eventMask: .extend,
queue: DispatchQueue.main
)
In order to receive event notifications, we must pass an eventHandler
to the dispatch source. This might seem weird since you'd normally use a delegate object for this, but the reason it works like this is probably that this is a very old Objective-C API.
source.setEventHandler {
let event = self.source.data
self.process(event: event)
}
When the event handler is triggered, the data
property of the dispatch source will contain the set of events that were dispatched.
Additionally, we must provide a way to safely shutdown the dispatch source. We do this by assigning a cancelHandler
that closes the FileHandle
whenever the source is canceled, and by adding a deinit
call to our class that cancels it.
//init()...
source.setCancelHandler {
try? self.fileHandle.close()
}
}
deinit {
source.cancel()
}
To process the events, we'll use the following method:
func process(event: DispatchSource.FileSystemEvent) {
guard event.contains(.extend) else {
return
}
let newData = self.fileHandle.readDataToEndOfFile()
let string = String(data: newData, encoding: .utf8)!
print("Detected: \(string)")
}
When readDataToEndOfFile()
is called, the file handle will return everything between the column it's currently pointing at and the end of the file. This also makes it point to the end of the file, making it a great way of fetching the changes in the file. When another event is received, the file handle will already be positioned to read the newest changes.
If the concept of pointers here makes you confused, think of FileHandle
like a cursor in a text editor. When we call readDataToEndOfFile()
, we're copying everything that was added and moving the cursor to the end of it.
While the guard
is going to be useless for this example, it's important to notice that FileSystemEvent
is an OptionSet
. As you can monitor and receive multiple event types to/from your dispatch source, the idea is that you should always check which events were received so you can call the correct logic for it.
To test all of this, we need to set up two final things. First, as we're not interested in reading what's already logged, we should move the file handler's pointer to the end of the file as soon as we create it. Finally, to wrap it up, we can start the dispatch source by calling source.resume()
.
fileHandle.seekToEndOfFile()
source.resume()
Here's a simple ViewController
that you can use to test this:
class ViewController: UIViewController {
// Make sure to edit this path to your real Desktop.
static let logPath = URL(fileURLWithPath: "/Users/swiftrocks/Desktop/logTester.log")
override func viewDidLoad() {
// Create the file
try! "".write(to: Self.logPath, atomically: true, encoding: .utf8)
// Monitor the file
let monitor = try! FileMonitor(url: ViewController.logPath)
// Write something to the file
let fileHandle = try! FileHandle(forWritingTo: Self.logPath)
fileHandle.seekToEndOfFile() // Make sure we're writing at the end of the file!
fileHandle.write("Woo! SwiftRocks.".data(using: .utf8)!)
}
}
After running this app, you should see "Detected: Woo! SwiftRocks."
in the console, plus anything else you add to that file later on!
Why doesn't it work when I edit the file in an editor?
If you try to test this by opening a text editor, adding some text and saving the file, you'll see that it may not work. The reason is that editors like Xcode don't actually modify the file -- instead, they act on copies of it. When you save it, they delete the original file and replace it with the copy they were maintaining. You can confirm that this is the case by registering events like .delete
and .link
to your dispatch source and see how they get triggered when you save the file. If you're doing this for a macOS app, one way to support text editors would be to register these cases and cancel/reboot the dispatch source when a new file is linked.
Wrapping up: Getting it ready for our debug feature
Because making our monitor print what was just logged to a file makes no sense, we can modify our FileMonitor
to work with a delegate. Here's the full FileMonitor
:
protocol FileMonitorDelegate: AnyObject {
func didReceive(changes: String)
}
final class FileMonitor {
let url: URL
let fileHandle: FileHandle
let source: DispatchSourceFileSystemObject
weak var delegate: FileMonitorDelegate?
init(url: URL) throws {
self.url = url
self.fileHandle = try FileHandle(forReadingFrom: url)
source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileHandle.fileDescriptor,
eventMask: .extend,
queue: DispatchQueue.main
)
source.setEventHandler {
let event = self.source.data
self.process(event: event)
}
source.setCancelHandler {
try? self.fileHandle.close()
}
fileHandle.seekToEndOfFile()
source.resume()
}
deinit {
source.cancel()
}
func process(event: DispatchSource.FileSystemEvent) {
guard event.contains(.extend) else {
return
}
let newData = self.fileHandle.readDataToEndOfFile()
let string = String(data: newData, encoding: .utf8)!
self.delegate?.didReceive(changes: string)
}
}
From here, creating a view that displays the latest logs like in the example picture is just a matter of creating a new FileMonitor
and setting the feature as the delegate.
You can make a feature like this without file logging/monitoring, but adding it to the mix would allow you to isolate the feature's logic from the actual logging mechanics. For something that's meant to be only used when debugging, that can be very nice in terms of architecture.