Real time NSTask output to NSTextView with Swift
Asked Answered
H

4

13

I'm using an NSTask to run rsync, and I'd like the status to show up in the text view of a scroll view inside a window. Right now I have this:

let pipe = NSPipe()
task2.standardOutput = pipe
task2.launch()

let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output: String = NSString(data: data, encoding: NSASCIIStringEncoding)! as String

textView.string = output

And that get's me the some of the statistics about the transfer, but I'd like to get the output in real time, like what get's printed out when I run the app in Xcode, and put it into the text view. Is there a way to do this?

Halden answered 9/4, 2015 at 20:55 Comment(0)
C
25

(See Patrick F.'s answer for an update to Swift 3/4.)

You can read asynchronously from a pipe, using notifications. Here is a simple example demonstrating how it works, hopefully that helps you to get started:

let task = NSTask()
task.launchPath = "/bin/sh"
task.arguments = ["-c", "echo 1 ; sleep 1 ; echo 2 ; sleep 1 ; echo 3 ; sleep 1 ; echo 4"]

let pipe = NSPipe()
task.standardOutput = pipe
let outHandle = pipe.fileHandleForReading
outHandle.waitForDataInBackgroundAndNotify()

var obs1 : NSObjectProtocol!
obs1 = NSNotificationCenter.defaultCenter().addObserverForName(NSFileHandleDataAvailableNotification,
    object: outHandle, queue: nil) {  notification -> Void in
        let data = outHandle.availableData
        if data.length > 0 {
            if let str = NSString(data: data, encoding: NSUTF8StringEncoding) {
                print("got output: \(str)")
            }
            outHandle.waitForDataInBackgroundAndNotify()
        } else {
            print("EOF on stdout from process")
            NSNotificationCenter.defaultCenter().removeObserver(obs1)
        }
}

var obs2 : NSObjectProtocol!
obs2 = NSNotificationCenter.defaultCenter().addObserverForName(NSTaskDidTerminateNotification,
    object: task, queue: nil) { notification -> Void in
        print("terminated")
        NSNotificationCenter.defaultCenter().removeObserver(obs2)
}

task.launch()

Instead of print("got output: \(str)") you can append the received string to your text view.

The above code assumes that a runloop is active (which is the case in a default Cocoa application).

Clapper answered 9/4, 2015 at 21:25 Comment(6)
I got a compilation error with this code stating that error: variable 'obs1' captured by a closure before being initialized (and same for 'obs2'). I moved the removeObserver line out of the closure (and after the waitUntilExit call). Very new to swift, so apologies for ignorance, but .. why?Cardon
@ticktock: You are right! That error did not occur in my command-line test app, but it does occur if the code is put into a method. Declaring obs1/2 as "implicitly unwrapped optional" NSProtocol! solves the problem, see updated answer. – Thanks for the feedback!Clapper
Sometimes I notice that using notifications occasionally fails to get all the data written to the pipe -- very annoying. @Sciurine solution seems more reliable (and simpler).Mooney
@BenStock: Thank you for the edit suggestion. However, a Swift 3 version was already posted as another answer, linking to that one seems more appropriate to me.Clapper
hi,Thank you for your answer, what -c mean?Raybourne
@Karim: "-c" is an option for /bin/sh (and many other shells, like bash) to execute the commands given in a string on the command line, e.g. /bin/sh -c " cmd1 ; cmd2 ".Clapper
S
42

Since macOS 10.7, there's also the readabilityHandler property on NSPipe which you can use to set a callback for when new data is available:

let task = NSTask()

task.launchPath = "/bin/sh"
task.arguments = ["-c", "echo 1 ; sleep 1 ; echo 2 ; sleep 1 ; echo 3 ; sleep 1 ; echo 4"]

let pipe = NSPipe()
task.standardOutput = pipe
let outHandle = pipe.fileHandleForReading

outHandle.readabilityHandler = { pipe in
    if let line = String(data: pipe.availableData, encoding: .utf8) {
        // Update your view with the new text here
        print("New ouput: \(line)")
    } else {
        print("Error decoding data: \(pipe.availableData)")
    }
}
    
task.launch()

I'm surprised nobody mentioned this, as it's a lot simpler.

Sciurine answered 29/6, 2016 at 1:2 Comment(3)
developer.apple.com/documentation/foundation/nsfilehandle/…Mooney
That is simpler -- I imagine no one mentions it because its hard to find in the docs.Mooney
thank you very much for outHandle.readabilityHandler you saved my day!Shennashensi
C
25

(See Patrick F.'s answer for an update to Swift 3/4.)

You can read asynchronously from a pipe, using notifications. Here is a simple example demonstrating how it works, hopefully that helps you to get started:

let task = NSTask()
task.launchPath = "/bin/sh"
task.arguments = ["-c", "echo 1 ; sleep 1 ; echo 2 ; sleep 1 ; echo 3 ; sleep 1 ; echo 4"]

let pipe = NSPipe()
task.standardOutput = pipe
let outHandle = pipe.fileHandleForReading
outHandle.waitForDataInBackgroundAndNotify()

var obs1 : NSObjectProtocol!
obs1 = NSNotificationCenter.defaultCenter().addObserverForName(NSFileHandleDataAvailableNotification,
    object: outHandle, queue: nil) {  notification -> Void in
        let data = outHandle.availableData
        if data.length > 0 {
            if let str = NSString(data: data, encoding: NSUTF8StringEncoding) {
                print("got output: \(str)")
            }
            outHandle.waitForDataInBackgroundAndNotify()
        } else {
            print("EOF on stdout from process")
            NSNotificationCenter.defaultCenter().removeObserver(obs1)
        }
}

var obs2 : NSObjectProtocol!
obs2 = NSNotificationCenter.defaultCenter().addObserverForName(NSTaskDidTerminateNotification,
    object: task, queue: nil) { notification -> Void in
        print("terminated")
        NSNotificationCenter.defaultCenter().removeObserver(obs2)
}

task.launch()

Instead of print("got output: \(str)") you can append the received string to your text view.

The above code assumes that a runloop is active (which is the case in a default Cocoa application).

Clapper answered 9/4, 2015 at 21:25 Comment(6)
I got a compilation error with this code stating that error: variable 'obs1' captured by a closure before being initialized (and same for 'obs2'). I moved the removeObserver line out of the closure (and after the waitUntilExit call). Very new to swift, so apologies for ignorance, but .. why?Cardon
@ticktock: You are right! That error did not occur in my command-line test app, but it does occur if the code is put into a method. Declaring obs1/2 as "implicitly unwrapped optional" NSProtocol! solves the problem, see updated answer. – Thanks for the feedback!Clapper
Sometimes I notice that using notifications occasionally fails to get all the data written to the pipe -- very annoying. @Sciurine solution seems more reliable (and simpler).Mooney
@BenStock: Thank you for the edit suggestion. However, a Swift 3 version was already posted as another answer, linking to that one seems more appropriate to me.Clapper
hi,Thank you for your answer, what -c mean?Raybourne
@Karim: "-c" is an option for /bin/sh (and many other shells, like bash) to execute the commands given in a string on the command line, e.g. /bin/sh -c " cmd1 ; cmd2 ".Clapper
M
8

This is the update version of Martin's answer above for the latest version of Swift.

    let task = Process()
    task.launchPath = "/bin/sh"
    task.arguments = ["-c", "echo 1 ; sleep 1 ; echo 2 ; sleep 1 ; echo 3 ; sleep 1 ; echo 4"]

    let pipe = Pipe()
    task.standardOutput = pipe
    let outHandle = pipe.fileHandleForReading
    outHandle.waitForDataInBackgroundAndNotify()

    var obs1 : NSObjectProtocol!
    obs1 = NotificationCenter.default.addObserver(forName: NSNotification.Name.NSFileHandleDataAvailable,
       object: outHandle, queue: nil) {  notification -> Void in
        let data = outHandle.availableData
        if data.count > 0 {
            if let str = NSString(data: data, encoding: String.Encoding.utf8.rawValue) {
                print("got output: \(str)")
            }
            outHandle.waitForDataInBackgroundAndNotify()
        } else {
            print("EOF on stdout from process")
            NotificationCenter.default.removeObserver(obs1)
        }
    }

    var obs2 : NSObjectProtocol!
    obs2 = NotificationCenter.default.addObserver(forName: Process.didTerminateNotification,
               object: task, queue: nil) { notification -> Void in
                print("terminated")
                NotificationCenter.default.removeObserver(obs2)
        }
    task.launch()
Muchness answered 20/8, 2017 at 12:5 Comment(1)
Is there any way to react to certain conditions here? ie. If the command line were to return File already exists. Overwrite? [y/N]; is it at all possible to respond in turn with Y, within the same process?Duodecillion
O
3

I have an answer which I believe is more clean than the notification approach, based on a readabilityHandler. Here it is, in Swift 5:

class ProcessViewController: NSViewController {

     var executeCommandProcess: Process!

     func executeProcess() {

     DispatchQueue.global().async {


           self.executeCommandProcess = Process()
           let pipe = Pipe()

           self.executeCommandProcess.standardOutput = pipe
           self.executeCommandProcess.launchPath = ""
           self.executeCommandProcess.arguments = []
           var bigOutputString: String = ""

           pipe.fileHandleForReading.readabilityHandler = { (fileHandle) -> Void in
               let availableData = fileHandle.availableData
               let newOutput = String.init(data: availableData, encoding: .utf8)
               bigOutputString.append(newOutput!)
               print("\(newOutput!)")
               // Display the new output appropriately in a NSTextView for example

           }

           self.executeCommandProcess.launch()
           self.executeCommandProcess.waitUntilExit()

           DispatchQueue.main.async {
                // End of the Process, give feedback to the user.
           }

     }
   }

}

Please note that the Process has to be a property, because in the above example, given that the command is executed in background, the process would be deallocated immediately if it was a local variable. Thanks for your attention.

Ochone answered 3/2, 2020 at 18:5 Comment(1)
This code seems to work great, and the DispatchQueue.main.async gets called upon completion; however, the process will print newlines to the console indefinitely after this. Attempting to incorporate self.executeCommandProcess.terminate() doesn't seem to have any affect. Any ideas?Duodecillion

© 2022 - 2024 — McMap. All rights reserved.