The simple question is: how to find out the location of an executable file in a Cocoa application.
Remember that, in many Unix-like OS people use PATH environment t
Isn't the path for Finder (and hence, any GUI-launched Cocoa apps) set from your login shell? If your login shell and the shell you're using in Terminal.app aren't the same, that'd probably lead to some confusion.
This information might be helpful: http://lists.apple.com/archives/cocoa-dev/2005/Oct/msg00528.html
Apparently, the "right" way to set environment variables for GUI processes is in a hidden .plist file. I'm sure I knew this at one point, then promptly forgot it.
How likely is it that your users will have custom versions of the tool you're using (and how likely is it that your app is compatible with arbitrary versions of the tool)? If the answer is "not very", then consider using the path to the system-supplied tool by default, and giving advanced users a way to specify their own path as a preference.
Here's my implementation based on the above answers, to be called from applicationDidFinishLaunching:
// from http://cocoawithlove.com/2009/05/invoking-other-processes-in-cocoa.html
#import "NSTask+OneLineTasksWithOutput.h"
void FixUnixPath() {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^(void){
NSString *userShell = [[[NSProcessInfo processInfo] environment] objectForKey:@"SHELL"];
NSLog(@"User's shell is %@", userShell);
// avoid executing stuff like /sbin/nologin as a shell
BOOL isValidShell = NO;
for (NSString *validShell in [[NSString stringWithContentsOfFile:@"/etc/shells" encoding:NSUTF8StringEncoding error:nil] componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) {
if ([[validShell stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] isEqualToString:userShell]) {
isValidShell = YES;
break;
}
}
if (!isValidShell) {
NSLog(@"Shell %@ is not in /etc/shells, won't continue.", userShell);
return;
}
NSString *userPath = [[NSTask stringByLaunchingPath:userShell withArguments:[NSArray arrayWithObjects:@"-c", @"echo $PATH", nil] error:nil] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (userPath.length > 0 && [userPath rangeOfString:@":"].length > 0 && [userPath rangeOfString:@"/usr/bin"].length > 0) {
// BINGO!
NSLog(@"User's PATH as reported by %@ is %@", userShell, userPath);
setenv("PATH", [userPath fileSystemRepresentation], 1);
}
});
}
P.S. The reason this works is because it catches environment changes made by the shell. E.g. RVM adds PATH=$PATH:$HOME/.rvm/bin
to .bashrc on installation. Cocoa apps are launched from launchd, so they don't have these changes in their PATH.
I'm not 100% satisfied with this code, because it does not catch everything. My original intent was to handle RVM specifically, so I had to use a non-login shell here, but in practice, people randomly put PATH modification into .bashrc and .bash_profile, so it would be best to run both.
One of my users even had an interactive menu (!!!) in his shell profile, which naturally lead to this code hanging and me exporting a shell env flag just for him. :-) Adding a timeout is probably a good idea.
This also assumes that the shell is bourne-compatible and thus does not work with fish 2.0, which is getting increasingly more popular among the hacker community. (Fish considers $PATH an array, not a colon-delimited string. And it thus prints it using spaces as delimiters by default. One can probably cook up an easy fix, like running for i in $PATH; echo "PATH=$i"; end
and then only taking the lines that start with PATH=
. Filtering is a good idea on any case, because profile scripts often print something on their own.)
As a final note, this code has been an important part of a shipping app for over a year (top 10 paid developer tool on the Mac App Store for most of the year). However, I'm now implementing sandboxing and taking it out; naturally, you cannot do this trick from a sandboxed app. I'm replacing it with explicit support for RVM and friends, and reproducing their respective env changes manually.
For those wishing to use something like system Git from a sandboxed app, note that while you don't have access to read files and enumerate directories, you do have access to stat — [[NSFileManager defaultManager] fileExistsAtPath:path]
. You can use this to probe a hard-coded list of typical folders looking for your binary, and when you find the locations (like /usr/local or /opt/local or whatever), ask the user to give you access via NSOpenPanel. This won't catch every case, but will handle 90% of use cases and is the best thing you can do for your users out of the box.
The tricky part of trying to do this is the fact that the user could have their shell set to anything: sh, bash, csh, tcsh, and so on, and each shell sets up its terminal environment differently. I'm not sure if I'd go to the trouble for this myself, but if you really want to, here's the route I would take.
The first step is to figure out the user's shell. On OS X, this information is stored in Directory Services, which can be accesed either through the APIs in DirectoryService.framework or by using the dscl
command line tool. The DirectoryService API is a royal pain in the ass, so I would probably go the CLI route. In Cocoa, you can use NSTask to execute the tool with arguments to get the user's shell (I'll leave the details of this for elsewhere). The command would look something like:
dscl -plist localhost -read /Local/Default/Users/username UserShell
This will return XML text that you can interpret as a plist and transform into an NSDictionary, or you can omit the -plist
option and parse the textual output yourself.
Once you know the path to the user's shell, the next step would be to execute that shell and tell it to run the env
command to print out the user's environment. It looks like most shells accept a -c
command line option that lets you pass in a string to execute - I guess you'll just have to assume that as being the common interface for whatever shell the user has chosen.
Once you have the user's environment, you can then grab their list of paths out of that, and do the search for whatever executable you're looking for from that. Like I said, I really don't know whether this is worth the trouble, but that's the direction I would go if I were implementing this.
Related to Brian Webster's answer:
An easier way to get the User's shell is to use the NSProcessInfo class. e.g
NSDictionary *environmentDict = [[NSProcessInfo processInfo] environment];
NSString *shellString = [environmentDict objectForKey:@"SHELL"];
Which is easier than using dscl and parsing XML input.