The introduction of do shell script
was a huge boon to AppleScript, and although these days AppleScriptObjC often offers alternatives — and often faster and better — it’s still indispensable for some things.
But what if it disappeared? Could we replace it using AppleScriptObjC?
I tinkered with the idea quite a while ago, but never got it quite right. Code that works in Objective-C doesn’t always work in AppleScriptObjC. But I recently returned to the project, used a different approach, and finally got there.
First I should point out that doing it this way has one very serious limitation compared with do shell script
: you can’t use it with elevated privileges. So you can’t include credentials or prompt for authorisation, or use sudo
. Otherwise, the capabilities are the same.
On the other hand, there are a couple of potential advantages: you can choose your own shell, or even call tools directly (which also lets you side-step the quoting gymnastics often required), you can have better control over line-endings (turning linefeeds to returns is mostly pointless these days, but there’s still often merit in stripping the trailing line-ending), and you have more control over what gets returned. It also offers a way to provide standard input, and an easy way to set the environment. It’s just a bit more customizable generally.
Would I use this code instead of do shell script
? Probably not, unless I wanted to take advantage of a particular feature (and after doing some more testing). But there’s something reassuring about knowing it’s possible. And as a bonus, in my limited tests it’s even mostly a bit faster (see below).
Even if it you never use it, it might be interesting to look at in terms of how to use NSTask to launch a subprocess.
There’s one main handler with lots of parameters in the code below, plus some convenience handlers to make it simpler to use. Calling the doShellScript:
handler mimics the basic do shell script
command.
use AppleScript version "2.4"
use scripting additions
use framework "Foundation"
-- simple call
set x1 to my doShellScript:"echo 'Hello world'"
-- calling tool directly instead of via /bin/sh
set x2 to my runTool:"/usr/bin/tr" withArgs:{"a", "o"} stdInput:"bad cat" envDict:(missing value) useLocale:false useReturns:false stripLast:true resultType:(text)
-- an old Nigel Garvey example revisited out of season, showing use of locale
set x3 to my doShellScriptLocalized:("echo 'x-93yß<⌘wß⌘-r7ßßq' | sed 'y?<379-x⌘qß?Np!paHe" & character id 127863 & "Y ?'")
-- do comparison
set thePath to POSIX path of (choose file)
set x4 to do shell script "cat " & quoted form of thePath
set x5 to my doShellScript:("cat " & quoted form of thePath)
return x4 = x5
-- This matches plain 'do shell script'
on doShellScript:theCommand
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:true stripLast:true resultType:(text)
end doShellScript:
-- This matches 'do shell script without altering line endings'
on doShellScriptNoAltering:theCommand
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:false stripLast:false resultType:(text)
end doShellScriptNoAltering:
-- This matches 'do shell script without altering line endings' but still strips the trailing linefeed
on doShellScriptWithLFs:theCommand
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:false stripLast:true resultType:(text)
end doShellScriptWithLFs:
--- This lets you configure line ending behavior and the result type
-- resultType: string = matches osax; data = like osax "as data"; "NSString" = NSString if UTF-8, else unmodified NSData; "NSData" = unmodified NSData; "Base-64" = unmodified data as Base-64 string
on doShellScript:theCommand useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:false useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
end doShellScript:useReturns:stripLast:resultType:
-- This lets you configure most things
-- resultType: string = matches osax; data = like osax "as data"; "NSString" = NSString if UTF-8, else unmodified NSData; "NSData" = unmodified NSData; "Base-64" = unmodified data as Base-64 string
-- useReturns and stripLast are booleans; envDict is a record/NSDictionary or missing value; stdInput is a string/NSString or missing value
-- localeValue: false or 0 = ignore; true or 1 = set LANG, LC_CTYPE, and LC_COLLATE to current locale; 3 = sets LC_ALL to current locale
on doShellScript:theCommand stdInput:theInput envDict:envDict useLocale:localeValue useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:theInput envDict:envDict useLocale:localeValue useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
end doShellScript:stdInput:envDict:useLocale:useReturns:stripLast:resultType:
-- This matches plain 'do shell script' but sets LANG, LC_CTYPE, and LC_COLLATE to current locale
on doShellScriptLocalized:theCommand
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:true stripLast:true resultType:(text)
end doShellScriptLocalized:
-- This matches 'do shell script without altering line endings' but sets LANG, LC_CTYPE, and LC_COLLATE to current locale
on doShellScriptNoAlteringLocalized:theCommand
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:false stripLast:false resultType:(text)
end doShellScriptNoAlteringLocalized:
-- This matches 'do shell script without altering line endings' but still strips the trailing linefeed and uses sets LANG, LC_CTYPE, and LC_COLLATE to current locale
on doShellScriptWithLFsLocalized:theCommand
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:false stripLast:true resultType:(text)
end doShellScriptWithLFsLocalized:
-- This lets you configure line ending behavior, and whether to return an NSString or text; sets LANG, LC_CTYPE, and LC_COLLATE to current locale
-- resultType: 1 = string, 2 = NSString, 3 = NSData, 4 = Base-64 string; useReturns and stripLast are booleans
on doShellScriptLocalized:theCommand useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
return my runTool:"/bin/sh" withArgs:{"-c", theCommand} stdInput:(missing value) envDict:(missing value) useLocale:true useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
end doShellScriptLocalized:useReturns:stripLast:resultType:
-- This is the "master" handler called by the above. You can also call it directly, to address tools directly or to use a different shell. In this case you pass the arguments as a list, and you must use the path to the tool.
-- For example: runTool:"/usr/bin/tr" withArgs:{"a", "o"} stdInput:"bad cat" envDict:(missing value) useLocale:false useReturns:false stripLast:true resultType:text
-- resultType: string = matches osax; data = like osax "as data"; "NSString" = NSString if UTF-8, else unmodified NSData; "NSData" = unmodified NSData; "Base-64" = unmodified data as Base-64 string
-- useReturns and stripLast are booleans; envDict is a record/NSDictionary or missing value; stdInput is a string/NSString or missing value
-- localeValue: false or 0 = ignore; true or 1 = set LANG, LC_CTYPE, and LC_COLLATE to current locale; 3 = sets LC_ALL to current locale
on runTool:thePath withArgs:theArgList stdInput:theInput envDict:envDict useLocale:localeValue useReturns:useReturnsFlag stripLast:stripLastFlag resultType:resultType
set envMutDict to current application's NSMutableDictionary's dictionary()
if localeValue as integer > 0 then
set localeName to current application's NSLocale's currentLocale()'s localeIdentifier()
if localeValue as integer = 1 then
envMutDict's addEntriesFromDictionary:{LANG:localeName, LC_CTYPE:localeName, LC_COLLATE:localeName}
else
envMutDict's setObject:localeName forKey:"LC_ALL"
end if
end if
if envDict is not missing value then envMutDict's addEntriesFromDictionary:envDict
-- create a pipe for standard output, then get a file handle for reading from it
set outPipe to current application's NSPipe's pipe()
set outFileHandle to outPipe's fileHandleForReading()
-- create a pipe for standard error
set errPipe to current application's NSPipe's pipe()
-- make task, set its properties
set theTask to current application's NSTask's new()
set outData to current application's NSMutableData's |data|()
theTask's setLaunchPath:thePath
theTask's setArguments:theArgList
theTask's setStandardOutput:outPipe
theTask's setStandardError:errPipe
if envMutDict's |count|() > 0 then theTask's setEnvironment:envMutDict
if theInput is missing value then
theTask's |launch|()
else
-- convert the input to data
set theInput to current application's NSString's stringWithString:theInput
set theInputData to theInput's dataUsingEncoding:(current application's NSUTF8StringEncoding)
-- create a pipe for standard input, then get a file handle for writing to it
set inputPipe to current application's NSPipe's pipe()
set inputFileHandle to inputPipe's fileHandleForWriting()
-- connect pipe to task and launch it
theTask's setStandardInput:inputPipe
theTask's |launch|()
-- write to standard input
inputFileHandle's writeData:theInputData
inputFileHandle's closeFile()
end if
-- collect standard output while it's running
repeat while theTask's isRunning() as boolean
set newData to outFileHandle's availableData()
if newData is not missing value then outData's appendData:newData
end repeat
-- check if all went OK
set taskStatus to theTask's |terminationStatus|() as integer
if taskStatus = 0 then -- all OK
set newData to outFileHandle's readDataToEndOfFile()
outFileHandle's closeFile()
if newData is not missing value then outData's appendData:newData
if outData's |length|() < 1 then
-- some tools output to standard error regardless, so check there instead
set outData to errPipe's fileHandleForReading()'s readDataToEndOfFile()
errPipe's fileHandleForReading()'s closeFile()
end if
if resultType = "NSData" then return outData's |copy|()
if resultType = data then -- convert to AS data
set theCode to current application's NSHFSTypeCodeFromFileType("'rdat'")
return (current application's NSAppleEventDescriptor's descriptorWithDescriptorType:theCode |data|:outData) as data
end if
if resultType = "Base-64" then return (outData's base64EncodedStringWithOptions:0) as text
set theString to current application's NSString's alloc()'s initWithData:outData encoding:(current application's NSUTF8StringEncoding)
if theString is missing value then -- not valid UTF-8
if resultType = text then
-- Output as MacRoman, as 'do shell script' does. Awful, but compatible...
set theString to current application's NSString's alloc()'s initWithData:outData encoding:(current application's NSMacOSRomanStringEncoding)
-- if 'altering line endings' is true, 'do shell script' also changes CRLF to LF, so to match...
if useReturnsFlag then set theString to theString's stringByReplacingOccurrencesOfString:(return & linefeed) withString:return
else -- resultType "NSString", fall back to raw data
return outData's |copy|()
end if
else if stripLastFlag then -- only if it's UTF-8
if (theString's hasSuffix:linefeed) as boolean then set theString to theString's substringToIndex:((theString's |length|()) - 1)
end if
if useReturnsFlag then set theString to theString's stringByReplacingOccurrencesOfString:linefeed withString:return
if resultType is "NSString" then return theString
return theString as text
else -- there was an error, so get the data from standardError
set errData to errPipe's fileHandleForReading()'s readDataToEndOfFile()
errPipe's fileHandleForReading()'s closeFile()
set theString to current application's NSString's alloc()'s initWithData:errData encoding:(current application's NSUTF8StringEncoding)
if theString is missing value then
-- match 'do shell script' fallback
set theString to current application's NSString's alloc()'s initWithData:outData encoding:(current application's NSUTF8StringEncoding)
end if
if theString is missing value then
error number taskStatus
else
error (theString as string) number taskStatus
end if
end if
end runTool:withArgs:stdInput:envDict:useLocale:useReturns:stripLast:resultType:
My speed tests, conducted with a simple echo 'hello world!'
repeated 1000 times, provided some interesting results. When run in a standard applet, do shell script
took about 15-30% longer, with the gap a bit bigger when run from an enhanced applet. (Of course, 15-30% of a couple of milliseconds is a seriously minuscule amount of time.) When run from the FastScripts menu, do shell script
took about twice as long. When run from Apple’s Scripts menu, though, the AppleScriptObjC code took many times longer than do shell script
. (Timings within an editor are pointless because logging skews things heavily.)