Search code examples
swiftmacoscocoaxpcnsxpcconnection

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.


Solution

  • 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.