Skip to content

Latest commit

 

History

History
207 lines (123 loc) · 14.5 KB

2014-07-11-plugins.md

File metadata and controls

207 lines (123 loc) · 14.5 KB
title category date author tags
Plugins
14
2014-07-11 09:00:00
name url
Gus Mueller
article

Plugins are a great way to add functionality to your app after it has already shipped. Applications on the Macintosh have a long history of supporting plugins. One example is Adobe Photoshop, which first supported plugins way back in 1991 with version 2.0.

Back in the good old days (I’m talking about pre OS X), it used to be a bit of a mess to add support for loading executable code into your application at runtime. But these days, with the help of NSBundle and a bit of forward thinking on your part, it has never been easier.

Bundles and Interfaces

If you open up Xcode 5 and start to create a new project, you'll see categories under the OS X section called "Application Plug-in" and "System Plug-in." From Screen Savers to Image Units, there are a total of 12 different templates for writing app-specific plugins in Xcode. And if you click on the "Framework & Library" section, you'll see an entry there named "Bundle." It's a very simple project, and it's what I'll be exploring today, along with adding support for loading a bundle in a modified version of TextEdit.

Note: Apple denotes these as "plug-ins," while the rest of the world believes this is silly and instead uses the term "plugins." When writing anything that will show up in the UI, I think it's best to stick to what the platform uses, for the sake of consistency. You'll be seeing "plug-ins" for the app UI, but in code and throughout this article, I'll use the term "plugin" (and I'll also use "bundle" and "plugin" interchangeably at times).

What is a bundle? If you create a new project in Xcode from the bundle template, you'll see that it's not much. When built, you'll get a folder that is built much like applications are — there's a Contents folder with the familiar Info.plist and Resource folder. And if you add a new class to your project, you'll find a MacOS folder with an executable file in it. The one thing you don't have, however, is a main() function in your project. That's going to be handled by the host application.

Adding Plugin Support to TextEdit

I'm going to show you two techniques for adding plugin support to your application. The first technique is going to show the minimum amount of work that is needed to add plugin support to an application, and hopefully also illustrate how dead simple it is to do.

The second technique is going to be a bit more complicated, but it'll show a reasonable way to add support to an app without locking yourself into a specific implementation in the future.

The project files for this article are on GitHub so that you can follow along better.

Scanning for Bundles in TextEdit

Make sure to open up TextEdit.xcodeproj from the "01 TextEdit" folder, and follow along with the code it contains.

There are three easy parts to this modified version of TextEdit: scanning for bundles, loading a bundle, and adding a UI to call the bundle.

Open up Controller.m and you'll find the -(void)loadPlugins method (which is called from applicationDidFinishLaunching:).

The loadPlugins method adds a new NSMenuItem right after the View menu, which is where your plugins are going to be called from (normally you'd do this in MainMenu.xib and hook up outlets, but you’re being lazy today). Then make and grab a path to your plugins folder (which lives in ~/Library/Application Support/Text Edit/Plug-Ins/), and scan it:

 NSString *pluginsFolder = [self pluginsFolder];
 NSFileManager *fm = [NSFileManager defaultManager];

 NSError *outErr;
 for (NSString *item in [fm contentsOfDirectoryAtPath:pluginsFolder error:&outErr]) {

     if (![item hasSuffix:@".bundle"]) {
         continue;
     }

     NSString *bundlePath = [pluginsFolder stringByAppendingPathComponent:item];

     NSBundle *b = [NSBundle bundleWithPath:bundlePath];

     if (!b) {
         NSLog(@"Could not make a bundle from %@", bundlePath);
         continue;
     }

     id <TextEditPlugin> plugin = [[b principalClass] new];

     NSMenuItem *item = [pluginsMenu addItemWithTitle:[plugin menuItemTitle] action:@selector(pluginMenuItemCalledAction:) keyEquivalent:@""];

     [item setRepresentedObject:plugin];

 }

So far, this looks pretty easy. You scan the plugins folder, make sure things kind of look like a .bundle (you don't want to try and load .DS_Store files, for instance), use NSBundle to load up what you've found (which does the heavy lifting), and then instantiate a class from the bundle.

You'll notice a reference to a TextEditPlugin protocol. A quick look in TextEditMisc.h finds this:

 @protocol TextEditPlugin <NSObject>
 - (NSString*)menuItemTitle;
 - (void)actionCalledWithTextView:(NSTextView*)textView inDocument:(id)document;
 @end

What this says is that you're expecting your instantiated class to respond to these two methods. You could even verify what the class does with some more code (and that's generally a smart idea) — but you’re keeping things as simple as possible right now.

OK, what's this principalClass method that you're calling on the bundle? When you make a bundle, you can have one or more classes in it, and you're going to need some way to let TextEdit know which class to instantiate from that bundle. To help the host app out, you can add a key of NSPrincipalClass to the bundle's Info.plist file, and the value is going to be the name of the class that implements the plugin methods. [NSBundle principalClass] is a shortcut for looking up and creating a class from the NSPrincipalClass value.

Moving on: add a new menu item under the Plug-Ins menu, set its action to pluginMenuItemCalledAction:, and set its represented object to the class you instantiated.

Notice that you didn't set a target to the menu item. If a menu item has a nil target, then it'll look through the responder chain for the first object that implements the pluginMenuItemCalledAction: method, and if it can't find it, the menu item will be disabled.

For this example, the best place to implement the pluginMenuItemCalledAction: method is going to be in the Document's window controller class. Open up DocumentWindowController.m, and scroll down to pluginMenuItemCalledAction:

- (void)pluginMenuItemCalledAction:(id)sender {
    id <TextEditPlugin>plugin = [sender representedObject];
    [plugin actionCalledWithTextView:[self firstTextView] inDocument:[self document]];
}

This is pretty self-explanatory. Grab the plugin instance that you stuffed into the menu's represented object, call actionCalledWithTextView:inDocument: (which was defined in the protocol), and go on your merry way.

Take a Look at the Plugin

Open up the "01 MarkYellow" project and take a look around. This is a standard project made in Xcode (via the OS X ▸ Framework & Library ▸ Bundle template), with a single class added to it: "TEMarkYellow."

If you open up MarkYellow-Info.plist, you'll see your NSPrincipalClass set to TEMarkYellow, as described earlier.

Next, open up TEMarkYellow.m, and you'll see the methods that are defined in the protocol. One returns the name of your plugin, which goes under the menu, and the more interesting one (actionCalledWithTextView:inDocument:) does a relatively simple task: it takes the currently selected text and changes its background to yellow:

- (void)actionCalledWithTextView:(NSTextView*)textView inDocument:(id)document {
    if ([textView selectedRange].length) {
       
        NSMutableAttributedString *ats = [[[textView textStorage] attributedSubstringFromRange:[textView selectedRange]] mutableCopy];

        [ats addAttribute:NSBackgroundColorAttributeName value:[NSColor yellowColor] range:NSMakeRange(0, [ats length])];

        // By asking the text view if you can change the text first, it will automatically do the right thing to enable undoing of attribute changes
        if ([textView shouldChangeTextInRange:[textView selectedRange] replacementString:[ats string]]) {
            [[textView textStorage] replaceCharactersInRange:[textView selectedRange] withAttributedString:ats];
            [textView didChangeText];
        }
    }
}

Make sure to run the TextEdit project once (which will create the Plug-Ins folder), then build the MarkYellow project, drop the "MarkYellow.bundle" file it produced into your ~/Library/Application Support/Text Edit/Plug-Ins/ folder, and relaunch your hacked version of TextEdit.

That wasn't so bad. Scan, load, and insert in a menu. Then, when your menu item is used, just pass on some values to the plugin. Try it out, and be amazed when your selected text gets a yellow background when the Plug-Ins ▸ Mark Selected Text Yellow is used.

This is awesome. This is also extremely fragile and not progressive at all.

So close the two projects, throw them away, and maybe try to forget them.

OK, How Can You Make This Better?

What are the problems with the above approach?

  • Only a single 'action' can live in a bundle. That's horribly inconvenient for plugin authors. Wouldn't it be easier if you could have more functionality in a bundle, with the ability to have more menu items?
  • It's not a forward-looking way of doing things. Hardwiring in a specific method name on a plugin class locks things down quite a bit. Instead, try re-engineering things so that the plugin drives things a bit more.

This time, you’re going to begin by inspecting the bundle. Open up "02 MarkYellow" and its associated xcodeproj file. Navigate to TEMarkYellow.m, and you'll instantly see that you've got a bunch more code in here, but it also does a lot more.

Instead of implementing a method that says the plugin name, implement a pluginDidLoad: method which passes along an interface as an argument, and which you can use to tell TextEdit what your action names are and what selectors to use when calling them, as well as a user object which will aid in storing any state associated with the particular text action.

And instead of only one action, you now have three: one for turning your text yellow, one for turning your text blue, and one that runs selected text as AppleScript. And this class only implements two methods for performing the actions by taking advantage of the userObject when registering.

This approach is so much more flexible than the first method described. However, it adds more complexity on the application side.

Add More Complexity to TextEdit

Open up "02 TextEdit" and take a look at Controller.m. It doesn't do much anymore, but it does set things up in applicationDidFinishLaunching: for a new class, called "PluginManager." Open up PluginManager.m next, and navigate down to -loadPlugins.

This is almost the same as -loadPlugins in the previous version, except now instead of adding a menu item in the for loop, just instantiate the principalClass from the bundle and call pluginDidLoad: — which then does the driving of TextEdit.

If you take a look at -addPluginsMenuWithTitle:…, you'll see where the menu items are created. And instead of stuffing the instance into the menu items' representedObject, you instantiate a helper class (PluginTarget), which holds a reference to the text action and friends. Stuff the PluginTarget instance in the menu item for use later on.

However, the selector set on the menu item is still pluginMenuItemCalledAction: — open up that method in DocumentWindowController.m and see what is happening there:

- (void)pluginMenuItemCalledAction:(id)sender {
    
    PluginTarget *p = [sender representedObject];

    NSMethodSignature *ms = [[p target] methodSignatureForSelector:[p action]];
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:ms];
    
    NSTextView *tv = [self firstTextView];
    id document = [self document];
    id userObject = [p userObject];
    
    [invocation setTarget:[p target]];
    [invocation setSelector:[p action]];
    [invocation setArgument:&tv atIndex:2];
    [invocation setArgument:&document atIndex:3];
    [invocation setArgument:&userObject atIndex:4];
    [invocation invoke];
}

This version is a bit more complicated than the previous implementation, but only because you have to deal with more information. Build up an NSInvocation, set the arguments, and call it on the instance from the plugin.

This is more work on the host side, but way more flexible for plugin authors.

Where to Go from Here

One neat thing about this new interface is that you could then write a plugin which would load up other custom-made plugins. Imagine that you want to add the ability for your users to write plugins in JavaScript. A plugin for that would scan a folder for .js files when pluginDidLoad: is called, and then add an entry with addPluginsMenuWithTitle:… for each .js file. Then, when called, it could set things up with JavaScriptCore and execute a script. The same thing could be done with Python, Ruby, or Lua (and I've actually done this in the past).

A Final Note on Security

"Plugins make security people twitch" - Anonymous

So the elephant in the room here is security. When you're loading executable bundles into your process, you're basically saying: "Here are the keys to my house. Make sure to turn off the lights when you are done. Please don't drink all the milk. And whatever you do, keep the fire pits outside." You're trusting whomever wrote the plugin to do the right thing, and that might not always happen.

What could go wrong? Well, a badly written plugin could take up all the available memory, keep the CPUs pegged at 100% continuously, and crash a lot (ahm, Flash). Maybe someone wrote a plugin that looks like it does nice things, but a month after a customer installs it, a bit of code triggers to send off your Contacts database to a third party. I could go on, but you get the idea.

What can be done about this? Well, you could also design your plugin API around the idea of running everything in separate address space (which would solve the crash problems, and possibly the memory and CPU problems), and force the plugins to be sandboxed (and if you check for the right permissions, it will make sure your Contacts database isn't read). I can think of a number of ways to do this off the top of my head, but the best solution is probably to use Apple's XPC.

I'll leave that exploration up to the reader, but you should always keep security in the back of your mind when dealing with plugins. Of course, placing plugins in a sandbox or another process removes most of the fun and adds a bit of work to your end as well, so maybe it isn't a big deal for your application.