DispatchSource: Detecting changes in files and folders in Swift

DispatchSource: Detecting changes in files and folders in Swift

Debug Log View

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.

Debug Log View

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.