Awesome Lightweight Asynchronous Process Handling Made Easy in Swift

Photo by Arget / Unsplash

In this blog post, we'll explore an awesome Swift code snippet that demonstrates a lightweight and easy-to-use asynchronous process handler. By leveraging async/await and the Foundation framework, this code enables you to execute processes asynchronously and retrieve their output data effortlessly. Let's dive into the code and discover its powerful features step by step.

Works only inside MacOS app! We don't have access to such capabilities on iOS!

The code

import Foundation

struct ProcessHandler {
    let executableURL: URL
    let launchPath: String
    
    struct ProcessCommand {
        typealias Argument = String
        var arguments: [Argument]
    }
    
    enum ProcessError: Error {
        case failure(code: Int32, reason: String?)
    }
    
    @MainActor
    func run(process: ProcessCommand) async throws -> Data {
        try await withCheckedThrowingContinuation { continuation  in
            DispatchQueue.global(qos: .userInitiated)
                .async {
                    let task = Process()
                    let pipe = Pipe()
                    let errorPipe = Pipe()
                    
                    task.standardOutput = pipe
                    task.arguments = process.arguments
                    task.executableURL = executableURL
                    task.launchPath = launchPath
                    task.standardError = errorPipe
                    do  {
                        try task.run()
                        let data = pipe.fileHandleForReading.readDataToEndOfFile()
                        continuation.resume(returning: data)
                    } catch {
                        let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
                        let errorString = String(data: errorData, encoding: .utf8)
                        let returnCode = task.terminationStatus
                        continuation.resume(throwing: ProcessError.failure(code: returnCode, reason: errorString))
                    }
                }
        }
    }
}

// Example, like Foundation Notification.Name
extension ProcessHandler.ProcessCommand {
    static let getSimulators = ProcessHandler.ProcessCommand(arguments: ["xcrun", "simctl", "list", "-j"])
}

// simple usage case
do {
    let handler = ProcessHandler(executableURL: URL(fileURLWithPath: "/bin/zsh"), launchPath: "/usr/bin/env")
    let data = try await handler.run(process: .getSimulators)
    // make something with data!
} catch {
    print(error)
}

How it works

  1. Defining the ProcessHandler Structure: The ProcessHandler structure is the heart of the code. It simplifies process handling by encapsulating the executable URL and launch path as properties, making it straightforward to specify the process to be executed.
  2. ProcessCommand Nested Structure: Inside the ProcessHandler, we find the nested ProcessCommand structure. It simplifies the specification of process arguments by providing a clean representation for command-line argument strings. This structure empowers you to define process arguments with ease.
  3. ProcessError Enumeration: The ProcessError enumeration ensures seamless handling of process-related errors. It includes a single case, failure, which allows you to easily access the process return code (code) and an optional error message (reason) for convenient debugging and error handling.
  4. run(process:) Method: The run method is where the magic happens. With Swift's new concurrency model and the async keyword, this method provides a seamless and efficient way to execute processes asynchronously. The throws keyword signifies that it can throw an error if necessary.
    Inside the run method, we create a withCheckedThrowingContinuation, which acts as a bridge between the callback-based and async/await paradigms. This powerful construct enables us to resume execution and return values or errors once the asynchronous operation completes, making our code more readable and maintainable.
    The actual process execution takes place on a global dispatch queue with a quality of service (QoS) set to .userInitiated. This ensures that the process runs smoothly in the background without affecting the main thread's responsiveness, providing a seamless user experience.
    Within the background queue, we create a new Process instance and establish Pipe instances for capturing standard output and standard error. By configuring the process with the provided arguments, executable URL, and launch path, we set the stage for a smooth process execution.
    Next, we run the process using task.run(). If the execution succeeds, we read the output data from the standard output pipe and resume the continuation with the retrieved data. In case of an error, we read the error data from the standard error pipe, create an error message string, capture the process return code, and resume the continuation with a descriptive error object, ensuring that you can easily handle process failures.
  5. Example Extension: To demonstrate the code's simplicity, we've included an extension showcasing a real-life usage scenario. The ProcessHandler.ProcessCommand structure offers a static property, .getSimulators, which represents a set of arguments for retrieving a list of available simulators using xcrun simctl list -j. This extension allows you to easily reuse and adapt the code for various process requirements.
  6. To experience the power of this lightweight process handler, simply create an instance of ProcessHandler, providing the executable URL and launch path as parameters. Then, invoke the run method asynchronously, passing the predefined .getSimulators process. Await the result, and the output data will be conveniently stored in the data constant, ready for further processing according to your needs.

Conclusion

Congratulations! You've just explored an awesome and easy-to-use Swift code snippet for handling asynchronous processes. By leveraging async/await and the Foundation framework, this lightweight implementation simplifies process execution and data retrieval. With clear explanations and code annotations, you're now well-equipped to enhance your projects with efficient process handling capabilities. This code serves as a solid foundation for writing powerful tooling that can significantly aid iOS developers in their day-to-day tasks. Get ready to boost your productivity and streamline your development workflows with this amazing process handling code!

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland