6

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 to assign the preferred location for their executables, especially when they have several versions of same application in their system. As a good practice, our Cocoa application should find the PREFERRED location of the executable file it needs.

For example, there was a SVN 1.4 in Leopard default configuration at /usr/bin, and you installed a much newer version, say SVN 1.5.3 via MacPorts at /opt/local/bin. And you set your PATH using /etc/path.d or .bash_profile or .zshrc like that:

export PATH=/opt/local/bin:$PATH

So you can use the new version of svn instead of the old one from the system. It works well in any terminal environment. But not in Cocoa applications. Cocoa application, as far as I know, only has a default PATH environment like this:

export PATH="/usr/bin:/bin:/usr/sbin:/sbin"

By default it will not using the configuration in /etc/path.d, .bash_profile, .profile, .zshrc, etc.

So how exactly can we do?

p.s. We have a semi-solution here, but it cannot fully satisfied the objective for this question.

Neo
  • 447
  • 4
  • 10

5 Answers5

9

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.

Brian Webster
  • 11,915
  • 4
  • 44
  • 58
  • I think you have given a brief and clear pathway to the standard solution. I know it's not a very common stuff, but it's indeed useful in some situation. I'll try it. Thanks! – Neo Oct 17 '08 at 02:42
  • BTW, I think that in Leopard the suggested way to handle PATH is through /etc/paths.d and it'll work for any shells. But seems not many people use that :( – Neo Oct 17 '08 at 02:48
  • Make sure you check whether the shell is in /etc/shells and only run it if so. The shell can be *any* program, not just actual shells, and the human may be running your app under a special user account that has /sbin/nologin or another program as its shell. – Peter Hosey Dec 23 '08 at 05:03
  • I posted the exact code that does this a few months ago. I've now described the caveats I have discovered while using it in production. See my own answer posted to this question. – Andrey Tarantsov May 20 '13 at 23:21
8

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.

Abizern
  • 146,289
  • 39
  • 203
  • 257
6

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.

Andrey Tarantsov
  • 8,965
  • 7
  • 54
  • 58
  • This is false, it merely sets the same PATH value as obtained from `[NSProcessInfo processInfo] environment][@"PATH"]`. Remember the shell command executed (`echo $PATH`)runs under current process' environment variables. – Zdenek Nov 22 '12 at 18:48
  • @Zdenek It sure looks that way, but nope, because the login shell executes profile files that change $PATH. Ever set something in your .bash_profile? And Cocoa apps are _not_ started from within the login shell, so they don't have the results of .bash_profile/.zprofile/etc in their env. – Andrey Tarantsov May 20 '13 at 22:52
  • I found this to be almost right. But you need to add "-l" parameter to make the shell act as if it was a login shell. The PATH you get from this example is the same path as NSProcessInfo because bash inherits the environment variables from the parent (Cocoa) process - unless you provide -l" – gngrwzrd Jul 10 '14 at 17:20
  • @gngrwzrd I've just looked at my production code, and I'm using --login -i combination there (before -c). I remember it took several iterations to get to that. (Note that even without -l, bash still executes .bashrc, so the code above will see any modifications made there.) – Andrey Tarantsov Jul 11 '14 at 03:15
2

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.

1

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.

Mark Bessey
  • 19,598
  • 4
  • 47
  • 69
  • This could be a solution: ~/.MacOSX/environment.plist I also find a way to make this .plist automatically. Thanks for your info. – Neo Oct 17 '08 at 03:41
  • 1
    Some facts to clear it up: (1) The path for Finder is NOT set from the login shell. (2) There is no ‘hence’; other GUI-launched Cocoa apps are actually launched by launchd, not by Finder itself. (3) Yes, the _plist_ is the right way to handle it, but if you have a chance to explain that to the user, you might as well just ask them to locate the binary, which is probably easier for 9 out of 10 users out there. – Andrey Tarantsov May 20 '13 at 23:18