From Shell Script to Task
Perhaps the most useful command in AppleScript is do shell script: it gives the scripter access to a greatly expanded toolbox. Sadly, there is a bug when it is used in AppleScriptObjC projects: if someone presses the escape key while it is being called, the command being called will immediately exit with a non-zero status.
Fortunately there is another way to do the same thing in AppleScriptObjC, using what are called tasks. There are limitations — we cannot change the privileges under which the shell scripts will be executed — and there are advantages — we can have the shell scripts work in the background, and even deal with continual output.
Using tasks is more complicated, especially if you want to run things in the background, but a simple handler I explain here should handle most typical uses of do shell script. And while that may be the quickest solution when porting projects, don’t forget that Cocoa provides its own methods for doing many of the jobs you might otherwise use shell scripting for.
Let’s start with the simple case, where you just want to call a shell script and you are not interested in a result. Perhaps the equivalent of:
do shell script "sleep 1"
The class you need to deal with is NSTask, and its tasks run independently of the application. It has a class method launchedTaskWithLaunchPath:arguments: that will create a task and launch it. The two arguments are the path to the command (unlike do shell script, you need the full path), plus a list of arguments, in order. So your code would look like this:
tell current application's NSTask to set theTask to ¬
launchedTaskWithLaunchPath_arguments_("/bin/sleep", {"0.5"})
Notice that the arguments need to be text. What is not obvious here is that you don’t need to quote arguments; if you need to provide a path, for example, you do not (actually, must not) provide the quoted form, and you can’t use the ~ shortcut for the home folder.
But by itself, this statement is pointless with sleep: it launches sleep as a separate process, and then continues on. If we want to wait until the task is finished, we would add:
tell theTask to waitUntilExit()
But many times when you use do shell script you want a result returned, and in that case it is a bit more involved. You need to enlist the aid of two other classes, NSPipe and NSFileHandle, and you need to create and launch tasks differently.
Let’s take the script statement:
do shell script "ls -p /"
This will list the files at the root of the startup disk, with slashes after the names of those that are directories.
First, you have to create a pipe, using NSPipe’s class method pipe. One end of the pipe will be connected to the task to capture the task’s standard output, and your application will read from the other end of the pipe via an object called a file handle. You get this file handle by calling the fileHandleForReading instance method on the pipe, and it provides a way to read from the pipe as if it were a file. So you start with:
tell current application's NSPipe to set outPipe to pipe()
set outFileHandle to outPipe's fileHandleForReading()
Now the task can be created, this time using alloc and init:
tell current application's NSTask to set theTask to alloc()'s init()
The task’s properties are set and it is launched:
tell theTask
setLaunchPath_("/bin/ls")
setArguments_({"-p", "/"})
setStandardOutput_(outPipe)
|launch|()
end tell
The result can then be read from the file handle as data, and converted to a string using NSString’s initWithData:encoding: method for converting data:
tell outFileHandle to set theData to readDataToEndOfFile()
set theResult to current application's NSString's alloc()'s initWithData_encoding_(theData, current application's NSUTF8StringEncoding)
You could then check to see that there was no error:
if theTask's terminationStatus() as integer is not 0 then set theResult to "There was an error"
One of the things do shell script does for us is return standard output if the process runs fine, but if not it returns standard error’s output as the error message. If you want to get the output from standard error using a task, you need a separate pipe and file handle. So a handler for running a task might look like this:
on runATask_withArguments_(thePath, theArgList)
-- create pipe for standard output, and get reading file handle from it
tell current application's NSPipe to set outPipe to pipe()
-- create pipe for standard error, and get reading file handle from it
set outFileHandle to outPipe's fileHandleForReading()
tell current application's NSPipe to set errPipe to pipe()
set errFileHandle to errPipe's fileHandleForReading()
-- make task and launch it
tell current application's NSTask to set theTask to alloc()'s init()
tell theTask
setLaunchPath_(thePath)
setArguments_(theArgList)
setStandardOutput_(outPipe)
setStandardError_(errPipe)
|launch|()
end tell
-- read standard output and standard error; result is data
tell outFileHandle to set outData to readDataToEndOfFile()
tell errFileHandle to set errData to readDataToEndOfFile()
-- make sure it's finished
tell theTask to waitUntilExit()
-- check if all went OK
if (theTask's terminationStatus()) as integer = 0 then
set didSucceed to true
else
set didSucceed to false
set outData to errData
end if
-- convert data to string
set theString to current application's NSString's alloc()'s initWithData_encoding_(outData, current application's NSUTF8StringEncoding)
return {didSucceed, theString}
end runATask_withArguments_
And code like this:
set theCode to (do shell script "find /Applications/Utilities -iname 'Apple*.*'")
could then instead be done like this:
set {didSucceed, theResult} to¬
runATask_withArguments_("/usr/bin/find", {"/Applications/Utilities", "-iname", "Apple*.*"})
Using such a handler is not a lot more work than using do shell script, although it does require knowing the full path, and perhaps avoiding ingrained habits of quoting. But what about situations where you use pipes to send the output of one shell command to the input of another? It can be done, but it means setting up separate tasks sharing paths, and it all starts to be a bit of an undertaking. You might find yourself wondering why you left the comfort of do shell script after all.
Shell to the Rescue
Fortunately there is a simple solution. This involves calling the /bin/sh shell itself, with the -c argument, followed by the actual command as a second argument. This tells /bin/sh to execute the second argument for us. Put another way, these two commands return the same result:
set x to do shell script "ls ~/Desktop"
set y to do shell script "/bin/sh -c 'ls ~/Desktop'"
x = y --> true
So how does this seeming extra level of complexity help simplify things? It means our whole original shell script becomes a single parameter, and we do not need to know the path to the command, for starters. But where it really helps is when we use pipes and multiple shell commands, like this:
set x to do shell script "ls -F ~ | egrep -i 's/$'"
By doing the equivalent of:
set x to do shell script "/bin/sh -c 'ls -F ~ | egrep -i 's/$''"
we are dealing with one task and one variable argument, rather than several tasks and perhaps many more arguments. And we can wrap it in a handler that takes as its main argument a string that is exactly the same as we would use for the do shell script equivalent — that makes porting scripts much easier, with nothing else to learn.
We can easily build such a handler, and at the same time have it deal with one other issue: by default, do shell script changes paragraph endings from linefeeds to returns and deletes the trailing return, so our handler should offer the same option.
Here is such a handler, doing the same thing as the call to do shell script above:
set {theFlag, theResult} to doShellScript_alteringLineEndings_("ls -F ~ | egrep -i 's/$'", true)
on doShellScript_alteringLineEndings_(theCommand, alterEndings)
-- create pipe for standard output, and get reading file handle from it
tell current application's NSPipe to set outPipe to pipe()
set outFileHandle to outPipe's fileHandleForReading()
-- create pipe for standard error, and get reading file handle from it
tell current application's NSPipe to set errPipe to pipe()
set errFileHandle to errPipe's fileHandleForReading()
-- make task and launch it
tell current application's NSTask to set theTask to alloc()'s init()
tell theTask
setLaunchPath_("/bin/sh")
setArguments_({"-c", theCommand})
setStandardOutput_(outPipe)
setStandardError_(errPipe)
-- the following line is needed or logging ceases at this point
setStandardInput_(current application's NSPipe's pipe())
|launch|()
end tell
-- read standard output and standard error; result is data
tell outFileHandle to set outData to readDataToEndOfFile()
tell errFileHandle to set errData to readDataToEndOfFile()
-- make sure it's finished
tell theTask to waitUntilExit()
-- check if all went OK
if (theTask's terminationStatus()) as integer = 0 then
set didSucceed to true
else
set didSucceed to false
set outData to errData
end if
-- convert data to string
set theString to current application's NSString's alloc()'s initWithData_encoding_(outData, current application's NSUTF8StringEncoding)
if alterEndings then
-- match what do shell script does; change LFs to CRs and delete last character
-- (not a perfect match; do shell script also strips existing CRs before LFs;
-- I think this result is more faithful to the request.)
set theString to theString's stringByReplacingOccurrencesOfString_withString_(linefeed, return)
set theString to theString's substringToIndex_(((theString's |length|()) as integer) - 1)
end if
return {didSucceed, theString}
end doShellScript_alteringLineEndings_
NSTask is capable of considerably more than this: you can run tasks in the background and have them send notifications when they have finished, so you can do other work in the meantime. Instead of telling the file handle to readDataToEndOfFile, you can tell it to readToEndOfFileInBackgroundAndNotify. By registering as an observer of the NSFileHandleReadToEndOfFileCompletionNotification notification, a handler in your script will be triggered when the task is complete.
Here is a sample of both a handler that will run the shell script in the background, plus another, dataIsReady_, which will be called when the command: has terminated:
doShellScriptInBackground_callback_("find /Applications -iname 'AppleS*.*'", "dataIsReady:")
on doShellScriptInBackground_callback_(theCommand, selectorName)
-- create pipe for standard output, and get reading file handle from it
tell current application's NSPipe to set outPipe to pipe()
set outFileHandle to outPipe's fileHandleForReading()
-- make task and launch it
tell current application's NSTask to set theTask to alloc()'s init()
tell theTask
setLaunchPath_("/bin/sh")
setArguments_({"-c", theCommand})
setStandardOutput_(outPipe)
-- the following line is needed or logging ceases at this point
setStandardInput_(current application's NSPipe's pipe())
end tell
-- add observer for notification
tell current application's NSNotificationCenter to set nc to defaultCenter()
tell nc to addObserver_selector_name_object_(me, selectorName, current application's NSFileHandleReadToEndOfFileCompletionNotification, outFileHandle)
-- launch task
tell theTask to |launch|()
-- tell file handler do its stuff in the background
tell outFileHandle to readToEndOfFileInBackgroundAndNotify()
end doShellScriptInBackground_callback_
on dataIsReady_(notif)
-- remove the observer
tell current application's NSNotificationCenter to set nc to defaultCenter()
tell nc to removeObserver_name_object_(me, current application's NSFileHandleReadToEndOfFileCompletionNotification, missing value)
-- get the data from notification's userInfo dictionary
set theData to notif's userInfo()'s valueForKey_("NSFileHandleNotificationDataItem")
-- make it into a string
set theResult to current application's NSString's alloc()'s initWithData_encoding_(theData, current application's NSUTF8StringEncoding)
-- do something wih the result
log theResult
end dataIsReady_
This takes more than a minute to run on my Mac, during which time the interface of the application is fully responsive and other code can be running.
You can go further and perform progressive reads in the background as data becomes available. It’s fairly complex to set up, but it opens up a lot of new options.
But if all you want to do is replace some calls to do shell script, it is also worth looking to see if what you want to do can by a Cocoa method.