swineone
swineone

Reputation: 2824

Verify that a helper tool is installed

I'm writing a macOS app in Swift that needs a privileged helper tool -- wish the elevation wasn't necessary, but it looks like it is.

I found this excellent example application especially tailored for this scenario. I've managed to port its code to my own app, but I'm stuck at the point where I need to check if the helper tool is installed, and if it isn't, use SMJobBless() and friends to install it.

When running the example app, if the helper tool is not installed, the app stays stuck at the following screen:

enter image description here

To be clear, from reading the code, I thought it was supposed to update the label to "Helper Installed: No" at some point, but that doesn't seem to happen.

If I click "Install Helper", this is the result.

enter image description here

From now on, unless I manually remove the helper tool, rerunning the app will display this screen with "Helper Installed: Yes".

This behavior might be OK in this example situation where the user has to manually click on the "Install Helper" button. However, in my app, I would like it to automatically request installation of the helper tool if it's not installed yet. If it's already installed, I don't want to waste the user's time requesting their password again.

I thought this would be simple enough: if the helper tool is not available, somewhere along the process of connecting to it, an error would happen, which is the trigger for me to request installation of the tool. If no errors happen, it's assumed that the tool was already installed.

Here is the hacked together code that I wrote for connecting to the helper tool via XPC:

var helperConnection: NSXPCConnection?
var xpcErrorHandler: ((Error) -> Void)?
var helper: MyServiceProtocol?

// ...

helperConnection = NSXPCConnection(machServiceName: MyServiceName, options: .privileged)
helperConnection?.remoteObjectInterface = NSXPCInterface(with: MyServiceProtocol.self)
helperConnection?.resume()

helperConnection?.interruptionHandler = {
    // Handle interruption
    NSLog("interruptionHandler()")
}

helperConnection?.invalidationHandler = {
    // Handle invalidation
    NSLog("invalidationHandler()")
}

xpcErrorHandler = { error in
   NSLog("xpcErrorHandler: \(error.localizedDescription)")
}

guard
    let errorHandler = xpcErrorHandler,
    let helperService = helperConnection?.remoteObjectProxyWithErrorHandler(errorHandler) as? MyServiceProtocol
    else {
        return
}

helper = helperService

If the helper tool is not installed, running this code produces no errors or NSLog() output. If, afterwards, I call a function via XPC (using helper?.someFunction(...)), nothing happens -- I might as well be talking to /dev/null.

Now I'm left scratching my head in search of a technique to detect if the tool is installed. The example applications's solution to the problem is to add a getVersion() method; if it returns something, "Install Helper" is grayed out and the label changes to "Helper Installed: Yes".

I thought about extending this idea a bit by writing a simple function in my tool that returns instantly, and use a timeout in the main app -- if I don't get a result until the code times out, the helper tool is likely not installed. I find this a hacky solution -- what if, for instance, the helper tool (which is launched on demand) takes a little too long to launch , say because the computer is old and the user is running something CPU-intensive?

I see other alternatives such as peeking around the file system in the expected places (/Library/PrivilegedHelperTools and /Library/LaunchDaemons), but again this solution feels unsatisfactory to me.

My question: Is there a way to unambigously detect if a privileged XPC helper tool is listening at the other end?

My environment: macOS Mojave 10.14.2, Xcode 10.1, Swift 4.2.

Upvotes: 4

Views: 2140

Answers (3)

jeff-h
jeff-h

Reputation: 2639

Background

It actually is possible to avoid a timeout waiting for the tool to respond. In fact, the erikberglund/SwiftPrivilegedHelper example you cited does print the word "No" beside the "Helper Installed" textfield if the tool is not enabled (see here), admittedly asynchronously, but virtually instantaneously.

However, I was in the unique position of having exactly the issue you described in my own implementation of a privileged helper, but with the SwiftPrivilegedHelper example fully working for me. When the tool is not installed, in the former the remoteObjectProxyWithErrorHandler error handler is never called, yet in the latter it is.

Because I had an example of your issue as well as a working example, I believe I have managed to identify the root cause:

Root cause

In my case at least, I had not cleanly uninstalled the helper tool.

At some point I had removed its plist from /Library/LaunchDaemons and the tool itself from /Library/PrivilegedHelperTools but I guess I hadn't run sudo launchctl unload /Library/LaunchDaemons/com.example.foo.plist

It was still listed when I ran sudo launchctl list, or sudo launchctl print system/com.example.foo.plist.

Under these circumstances, a call to the tool obviously doesn't succeed (as the tool is not installed) but doesn't fail (as launchctl thinks it is installed).

Solution

Proper uninstallation can be done in one of two ways:

  1. sudo launchctl remove com.example.foo (note: not system/com.example.foo)
  2. sudo launchctl unload /Library/LaunchDaemons/com.example.foo.plist (this requires the plist to still exist on disk).

If properly uninstalled, sudo launchctl print system/ca.example.foo should print:

Bad request.
Could not find service "com.example.foo" in domain for system

Quinn “The Eskimo!” suggests the following sequence, which uses the remove approach:

  1. Remove property list.
  2. Remove helper tool.
  3. Remove the job.

As soon as I cleared things up, lo and behold, my remoteObjectProxyWithErrorHandler started calling its error handler when the tool is not installed.

Upvotes: 1

James Bucanek
James Bucanek

Reputation: 3439

Since you create the helper tool, simply add an XPC message handler to report the status of your tool. When you launch, connect and send that message. If any of that fails, your tool is not correctly installed (or isn't responding).

In my code, all of my XPC services (which include my privileged helper) adopt a base protocol used to test and manipulate installations:

@protocol DDComponentInstalling /*<NSObject>*/

@required
- (void)queryBuildNumberWithReply:(void(^_Nonnull)(UInt32))reply;

@optional
- (void)didInstallComponent;
- (void)willUninstallComponent;

The queryBuildNumberWithReply: returns an integer describing the version number of the component:

- (void)queryBuildNumberWithReply:(void(^)(UInt32))reply
{
    reply(FULL_BUILD_VERSION);
}

If the message is successful, I compare the returned value with the build number constant in my application. If they don't match, the service is an older/newer version and needs to be replaced. This constant gets incremented for each public release of my product.

The code I use looks something like this:

- (BOOL)verifyServiceVersion
{
    DDConnection* connection = self.serviceConnection;
    id<DDComponentInstalling> proxy = connection.serviceProxy;  // get the proxy (will connect, as needed)
    if (proxy==nil)
        // an XPC connection could not be established or the proxy object could not be obtained
        return NO;  // assume service is not installed

    // Ask for the version number and wait for a response
    NSConditionLock* barrierLock = [[NSConditionLock alloc] initWithCondition:NO];
    __block UInt32 serviceVersion = UNKNOWN_BUILD_VERSION;
    [proxy queryBuildNumberWithReply:^(UInt32 version) {
        // Executes when service returns the build version
        [barrierLock lock];
        serviceVersion = version;
        [barrierLock unlockWithCondition:YES];  // signal to foreground thead that query is finished
        }];
    // wait for the message to reply
    [barrierLock lockWhenCondition:YES beforeDate:[NSDate dateWithTimeIntervalSinceNow:30.0];
    BOOL answer = (serviceVersion==FULL_BUILD_VERSION); // YES means helper is installed, alive, and correct version
    [barrierLock unlock];

    return answer;
}

Note that DDConnection is a utility wrapper around XPC connections, and the barrierLock trick is actually encapsulated in a shared method—so I don't end up writing this over and over—but is unwrapped here for the purposes of the demonstration.

I also have pre/post-install/upgrade issues to deal with, so all of my components implement an optional didInstallComponent and willUninstallComponent methods that I send immediately after installing a new helper, or just before I plan to uninstall or replace the installed helper.

Upvotes: 3

Robert
Robert

Reputation: 160

I would check the file system if the binary exists (in /Library/PrivilegedHelperTools and if the plist exists in /Library/LaunchDaemons). Then you might contact the XPC service and call kind of a ping function which answers if the service is up and running.

just my 2 cts,

Robert

Upvotes: 4

Related Questions