Creating a Service for Mac OS X

Monday, 2007-12-10; 00:54:00



Services in Mac OS X are often an overlooked but useful feature in Mac OS X. They provide small features that are offered up by an application or a bundle to be invoked by other applications. For example, a service could insert the current date at the insertion point, or it could take the selected paragraph and replace any standard quotation marks with smart quotes.

One of the reasons they're often overlooked is because a lot of the time when you go into the Services submenu of the current application's menu, everything is disabled. It's not obvious how one would use a service. The trick lies in selecting something before you go into the Services menu, because services are often (but not always) contextual — a service either depends on input or produces output, or both.

Services are supported by default by any Cocoa application, and Carbon applications can be set up to use services as well, so you should be able to use them from the vast majority of applications these days. It's worth a look to just fiddle around with them to see what you can do. And since many, many applications provide services, you'd be well-advised to use Service Scrubber to trim your Services menu down to just the ones you want.

Now what if you want to make a service? The easiest way is through ThisService. If you know how to create scripts in Ruby, Perl, Python, PHP, bash, sh, csh, zsh, all you need to do is create a service that reads from STDIN, outputs to STDOUT, or both. You can even just create a regular UNIX executable that reads from STDIN and/or outputs to STDOUT. AppleScript works a little differently, but you can also use ThisService with AppleScripts. Once you've created your script or executable, you just feed it to ThisService, change a few options, and ThisService creates a service for you.

But that's no fun. Here's a walkthrough of creating a service using Xcode 3; I'm going to use Objective-C and Cocoa, but you could easily adapt this method to use Carbon or just plain C. We're going to create a service that transforms XML markup to indented, easily readable markup, which is something that's really useful for me when developing TuneTagger, since MusicBrainz lookups return ugly un-indented XML. This is a "filter": it both requires input and produces output.

1. Open up Xcode and create a new project. Use the "Foundation Tool" template under the "Command Line Utility" section. I'm naming my project "XMLPrettyPrintService". This will create a file named "XMLPrettyPrintService.m" which is the start of our app.

2. Now, you'll want to create the Objective-C class which actually does the gritty work, so choose [File --> New File…], and then select the "Objective-C class" template under the Cocoa section. Name the class whatever you want — for the purposes of this tutorial, I'll use "EPXMLPrettyPrinter.m". Make sure to add it to the right project and target, and create EPXMLPrettyPrinter.h as well.

3. Here we'll create the service method that gets called in response to an invocation of your service. Paste the following code into EPXMLPrettyPrinter.m, between "@implementation EPXMLPrettyPrinter" and "@end":

- (void)prettyUpXML:(NSPasteboard *)pboard
            userData:(NSString *)userData
            error:(NSString **)error
{
    NSString *pboardString;
    NSString *newString;
    NSArray *types;
 
    types = [pboard types];
    if (![types containsObject:NSStringPboardType]) {
        *error = NSLocalizedString(@"Error: couldn't encrypt text.",
                        @"pboard couldn't give string.");
        return;
    }
    pboardString = [pboard stringForType:NSStringPboardType];
    if (!pboardString) {
        *error = NSLocalizedString(@"Error: couldn't encrypt text.",
                        @"pboard couldn't give string.");
        return;
    }

    // do something with pboardString and have
    // newString be the result

    types = [NSArray arrayWithObject:NSStringPboardType];
    [pboard declareTypes:types owner:nil];
    [pboard setString:newString forType:NSStringPboardType];
    return;
}


This code is filched directly from Apple's System Services documentation for Cocoa.

Note the name of this method — prettyUpXML:userData:error: . That first part, "prettyUpXML", can be changed to anything you want, and this part'll be important later. The rest of the method name needs to be the same as above, however.

Remember, we're implementing a filter here. If you want to only produce output with your service, discard everything before the "do something with pboardString" comment: you won't need to access the pasteboard, you'll just want to place something on it. If, instead, you only want to use the input but not produce any output, discard everything after the "do something with pboardString" comment (except, of course, the "return" line).

4. The next step is to implement the meat of your service method. In this example, we want to take pboardString, the ugly XML markup, and transform it to nicely indented, readable markup and place that markup into newString. ssp noticed a great feature in Mac OS X Tiger and later: a native Cocoa call using an NSXMLDocument object that creates pretty XML markup. It takes very few lines to accomplish what we want.

So, first, replace the "do something with pboardString" comment in EPXMLPrettyPrinter.m with the following code:

NSError *convertError = nil;
newString = [self convertToPrettiedPrint:pboardString withError:&convertError];
if (! newString) {
        *error = [convertError localizedDescription];
        return;
}


Then add this code after the prettyUpXML:userData:error: but before "@end":

- (NSString *)convertToPrettiedPrint:(NSString *)uglyString withError:(NSError **)error;
{
        NSError *XMLReadError;
        NSXMLDocument *theXMLDocument = [[NSXMLDocument alloc] initWithXMLString:uglyString options:NSXMLDocumentTidyXML error:&XMLReadError];
        if (XMLReadError) {
                *error = XMLReadError;
                return nil;
        }
        
        NSString *prettyXMLString = [theXMLDocument XMLStringWithOptions:NSXMLNodePrettyPrint];
        [theXMLDocument release];
        return prettyXMLString;
}


This code is pretty straightforward: it assumes the input string is XML, reads it into an NSXMLDocument object, and then uses that object to create a string with the pretty markup, and returns that string.

Note how if our code encounters an error — the pasteboard does not contain a string, or it can't convert the input to a string, or (from our custom code) there was an error reading the input as XML — our code simply returns without placing anything back on the pasteboard., the application that invoked the service will just present an error saying that the input was not valid:

Service Input Error

5. Of course, we need to change EPXMLPrettyPrinter.h to match the changes we just made to EPXMLPrettyPrinter.m, now. Paste in the following code between the closing curly brace and "@end" in EPXMLPrettyPrinter.h:

- (void)prettyUpXML:(NSPasteboard *)pboard
                   userData:(NSString *)userData
                  error:(NSString **)error;
- (NSString *)convertToPrettiedPrint:(NSString *)uglyString withError:(NSError **)error;


6. Go back to the file that contains the main() function where the app starts. It's going to be named your-project-name.m. Note how the project template already includes most of the framework of this file for you. (I'm amused that Leopard includes a new method called "drain" for NSAutoreleasePools. It's such an appropriate name.) What we need to do here is register your app as a service provider, and then you'll tell it to wait for input from the invoking application.

Services aren't running all the time. They're just apps or tools that quit when they're not being used. So when a different app invokes a service, it actually launches the executable for the service, performs the actions, and then quits the service. The way that Mac OS X knows that your app provides a service is through its Info.plist file, which we'll need to modify later.

Replace the comment and the hello world log line with the following code:

EPXMLPrettyPrinter *prettyPrinterInstance = [[EPPrettyPrinter alloc] init];
NSRegisterServicesProvider(prettyPrinterInstance, @"XMLPrettyPrint");

[[NSRunLoop currentRunLoop] acceptInputForMode:NSDefaultRunLoopMode
        beforeDate:[NSDate dateWithTimeIntervalSinceNow:30.0]];

[prettyPrinterInstance release];


The first line here creates the object that actually performs the actions on the input or produces output. That's from our EPXMLPrettyPrinter files that we just finished modifying. The second line registers our app as a service provider — the first argument is the object that we created in the first line, and the second is the name of the "port" for your service. You can choose whatever string you want for the port name, but it'll also be important later.

The third line tells your tool to run and wait for input. It also defines a timeout interval — if after 30 seconds, no input is provided to your tool, it'll quit. 30 seconds is the default timeout interval for services anyway, so it's a good number.

Lastly, the fourth line does memory cleanup.

7. Add the following line above the "#import <Foundation/Foundation.h>" line:

#import "EPXMLPrettyPrinter.h"


This notifies the main function that EPXMLPrettyPrinter actually an object and to what methods it responds. Since it's a custom object we created, it doesn't automatically know this.

8. We're done modifying our source code files. But if you build your project right now, it gives you errors about the symbols _NSRegisterServicesProvider and _NSStringPboardType which couldn't be found. Oooops. We forgot to add in the Cocoa framework, which we need for the NSPasteboard object.

Select the "External Frameworks and Libraries" folder, and then select [Project --> Add to Project…]. Navigate to /System/Library/Frameworks/ in the open dialog, select "Cocoa.framework" and click the "Add" button. The defaults for the next sheet are good, so just click the "Add" button again.

9. Now your project will build successfully. But it's just a plain old executable, because that's what the "Foundation Tool" template gives us. We want to create a service bundle. So select [Project --> New Target…]. Choose "Loadable Bundle" under the Cocoa section, click the "Next" button, name your new target, and then click "Finish".

10. First we need to include all the appropriate files in this new target. Select [Project --> Set Active Target --> target name from step 9]. Control-click on the "Groups & Files" source list heading, and then select "Target Membership" if it isn't already enabled. Make sure that "EPXMLPrettyPrinter.m", "XMLPrettyPrintService.m", and both "Cocoa.framework" and "Foundation.framework" have their boxes checked. If they don't, you'll get the errors as in step 8.

11. Note how when you created the bundle target, Xcode automatically created a new file called "XMLPrettyPrintService-Info.plist"? We need to modify that so that Mac OS X knows we're providing a service. Select it, and then add the following lines immediately before the "</dict>" line:


<key>NSServices</key>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Pretty Up XML</string>
</dict>
<key>NSMessage</key>
<string>prettyUpXML</string>
<key>NSPortName</key>
<string>XMLPrettyPrint</string>
<key>NSReturnTypes</key>
<string>NSStringPboardType</string>
</array>
<key>NSSendTypes</key>
<string>NSStringPboardType</string>
</array>
</dict>
</array>
<key>NSUIElement</key>
<string>1</string>

The NSServices key/value pair tells Mac OS X how the service works. The NSUIElement key/value pair tells Mac OS X not to show our app in the Dock when it gets launched, which is super-annoying.

Inside the NSServices value array, we have NSMenuItem, whose value dict tells Mac OS X the title of our menu bar item. Change this string to whatever you want, but leave the key name as "default". The NSMessage value is the first part of the method name that is called in our custom object we created in step 3. If this string doesn't match up with our method name in EPXMLPrettyPrinter, the service will not work. Similarly, the NSPortName value is the second argument we provided to the NSRegisterServicesProvider() function in step 6. This port name also needs to match up or the service won't work.

Finally, we need to declare what kind of input our services accepts and what kind of output it produces. In our case, we want it to take a string as input and produce a string as output, so we'll use NSStringPboardType.

Note that if your service is a filter (i.e.: it requires input and produces output), you need to declare both the NSReturnTypes key as well as the NSSendTypes key. If your service only requires input, you only need to declare the NSSendTypes key. If your service only produces output, you only need to declare the NSReturnTypes key.
ssp made a good point to me: because services are provided to every application, your keyboard shortcut will almost assuredly conflict with the keyboard shortcut from another application or even with keyboard shortcuts for other services. You also can't control whether apps or services down the line use your same keyboard shortcut or not. It's pretty much impossible to find a good shortcut because of this, so it's probably better not to provide one — if the user wants a keyboard shortcut for your service, they can define one for themselves in System Preferences.

(There are other keys that you can also include in your Info.plist file. In particular, NSUserData is an optional string that is provided to your method so you can know that input is coming from another application via your service. NSKeyEquivalent allows you to setup a keyboard shortcut for your service, and NSTimeout allows you to provide a timeout interval for your service other than the default of 30 seconds.)

12. One last thing to do before we build the final product. Right now, the bundle will have a ".bundle" extension, rather than the desired ".service" extension. This is easily fixed: select the bundle target (not the plain executable target), and get info on it. Switch to the "Build" tab and find "Wrapper Extension" under the Title column in the list. Double-click on this item, replace "bundle" with "service" in the sheet that pops down, and then press "OK".

13. Now we're ready to build your service. (Note that if you want your service to run on Tiger as well, you'll need to change the Target SDK to 10.4 as detailed in this entry.) Make sure the bundle target is still the active target, and build away!

There are three major annoyances that I ran into while creating this service. First off, the bundle doesn't actually work. Whenever the service is invoked, you'll get a message in the Console's system.log saying "cannot execute binary file". I have no idea why this happens. The workaround is to build the regular executable target (the original target that's created from the Xcode project template) as well as the bundle target. Then, in the Finder, reveal the contents of the bundle, and copy the built executable inside the "MacOS" folder of the bundle. (Delete the existing executable inside the "MacOS" folder first, and give the one you just copied the same name as the deleted one, of course.) This might be an Xcode 3-only issue, but if anyone has any insight into why this happens, I would greatly appreciate it.

Secondly, if you're fiddling around with your service, it can be a pain in the ass to make sure that the correct service is being invoked. That's because at least in Leopard, services can be advertised from anywhere — before, services either had to be in */Library/Services/, or provided from applications in */Applications/ . So when you build your service bundle and plop it in your ~/Library/Services/ folder and invoke it, you may actually be invoking an older version of your service if you've left them lying around.
OK, I lied. You don't actually have to log out and log in again to reset the services menu. Create a Foundation tool, link it against the Cocoa framework (not just the Foundation framework), and have it simply call NSUpdateDynamicServices(), which takes no arguments. This still isn't ideal, though, because the service menu gets updated only for apps that are launched after you call this method. So you'll still need to at least quit and relaunch your apps to use the updated version of your service.

Finally, in order to re-register services (which is necessary if you want to test newer versions of your service), you'll have to log out and log in again to force the services menu to update. Your best bet is to use a different user to test your service — copy newer versions of your service to /Users/Shared/ , log in as your other user, copy the service to ~/Library/Services/, log out and log in with that user, and then test the service.

So that's how you create a service on Mac OS X. If you need more detailed documentation about services, refer to none other than Apple's System Services documentation for Cocoa, which I also referenced above.


Technological Supernova   Tips   Older   Newer   Post a Comment