Saturday, March 15, 2008

Creating a menu-based SIMBL plugin


There are a hundred tutorials on creating nib-based Cocoa applications. And there's at least one good write-up on how to make a Cocoa bundle that can be loaded by SIMBL. SIMBL has taken on increased importance with the release of Leopard, because InputManagers can no longer be installed in user folders. They must be installed at the system level in /Library/InputManagers. However, if you or your admin install just SIMBL at the system level, SIMBL acts as a fine-grained meta-manager, loading SIMBL-compatible plugins on a per-user and per-application basis.

Normally Cocoa bundles don't include nibs, but you can add them. The CULater wiki doesn't go so far as to guide you thru adding a nib to your Cocoa bundle, or how to attach the nib-generated menus to your target application. Since we can't edit the nibs of the target application, we have to install our nib contents programatically using Objective-C.

So that's what we're going to do today. I'm going to assume you already have SIMBL installed on your computer, and that you are at least novice-level with XCode and Cocoa. I specifically cover the differences between Interface Builder 2 and Interface Builder 3, mainly to point out how much IB3 has been improved.

1) Open XCode and start a new Cocoa Bundle. Let's call it "George".

2) Before we create any classes, we'll use Interface Builder to make the menu. The menu is going to have a single "About..." menu entry. A little later when we load the bundle, we'll insert our menu into the menubar programatically, since we can't edit the nib file of the target application.

When you create a Cocoa application in XCode, XCode automatically creates your first nib file in your project for you. But since we created a bundle, our project doesnt have any nibs yet.

2a) In XCode 3, go to File->New File and make a new Cocoa nib file. Be sure to create an "empty nib" file not an "application nib" file. Just call it "Menu.nib". Doubleclick the nib file in XCode to open it in Interface Builder.

2b) In XCode 2, you can't create an empty nib from within XCode. Open Interface Builder directly, and choose an empty Cocoa nib as your starting point.



After building the menu (step 3 below) save your new nib file in your project directory as "Menu.nib". It will ask you if it should add the nib to the project, and if it should add the nib to the target "George". Answer yes to both.

3) Drag an NSMenu from the IB palette to the IB main window (the window that contains "File's Owner" and "First Responder".) Then (XCode 3 only) doubleclick it to create an editable menu on your screen. Delete all but one menu item, and rename that one to "About..." Save your work then switch back to XCode.

4) Time to create the class which will contain our menu callback and, in this case, also bootstrap our plugin. Create a new Objective-C class file called "GeorgeController". Be sure to also create the header file. Since we're going to add the menu programatically, we need to create a variable that will be our handle on the menu. That goes in GeorgeController.h:

@interface GeorgeController : NSObject {
    IBOutlet NSMenu* topMenu;
}

@end

Now in GeorgeController.m, add a typical dealloc method, and an almost typical init method:

#import "GeorgeController.h"

@implementation GeorgeController

- (id) init {
    self = [super init];
    if (! self)
        return nil;

    [NSBundle loadNibNamed: @"Menu.nib" owner: self];
    return self;
}

- (void) dealloc {
    [super dealloc];
}

@end

In the init method, we're loading the nib file we just created.

One more thing before we switch back to Interface Builder - let's write the callback that we're going to attach to the "About..." item:

- (IBAction) orderFrontAboutPanel: (id) sender {
    NSImage* icon = [[NSWorkspace sharedWorkspace] iconForFileType: @"bundle"];

    [icon setSize: NSMakeSize(128, 128)];
    NSDictionary* options;
    options = [NSDictionary dictionaryWithObjectsAndKeys:
        @"George", @"ApplicationName",
        icon, @"ApplicationIcon",
        @"0.01", @"Version",
        @"", @"ApplicationVersion",
        @"Copyright (c) 2008 __MyCompanyName__", @"Copyright",
        nil];
    [NSApp orderFrontStandardAboutPanelWithOptions: options];
}

and add the method signature to the .h file:

@interface GeorgeController : NSObject {
    IBOutlet NSMenu* topMenu;
}

- (IBAction) orderFrontAboutPanel: (id) sender;

@end

Make sure to save both files so IB can see your changes.

One thing to note here is that our plugin can access the NSApp instance of our target application - here we call on NSApp to create an About popup window. A little further down we'll use NSApp to get at the application's menubar. Your plugin will probably call methods of NSApplication to initiate most of its interactions with the target app.

5a) Now go back to Interface Builder. If you're using IB3, click on "File's Owner", then open the Inspector and go to the Identity pane. Under "Class Identity" select GeorgeController. If GeorgeController is not listed, you may need to go to File->Read Class Files... and navigate to GeorgeController.h in your project, then try again.

Now that IB knows that GeorgeController is the File's Owner, you should be able to choose Connections from the Inspector and see topMenu under Outlets and orderFrontAboutPanel: under Received Actions. Drag from topMenu to the titlebar of your IB menu under development, and then drag from orderFrontAboutPanel: to the About... menu item.



Here's a gotcha: if your header file contains syntax errors, IB won't be able to parse it but also won't tell you that it can't. Like a naughty puppy, it will just quietly not do what you want it to do. If IB seems to be defying you, try compiling your project and see if you have any syntax errors.

5b) If you have Interface Builder 2, click on "File's Owner" and then choose Custom Class from the Inspector. If GeorgeController is not listed as an option, you need to actually drag GeorgeController.h from XCode's main window to IB's main window. Set GeorgeController as the custom class for File's Owner. Now choose Connections from the Inspector, then Ctrl-drag from File's Owner to the titlebar of your IB menu under development, then click Connect in the Inspector.

Next Ctrl-drag from the About... menu item to the File's Owner object. If the Inspector says "No actions in GeorgeController", click on the Classes tab in the IB main window, then select "Read GeorgeController.h" from the Classes menu in the menubar, then try the Ctrl-drag again. When you see orderFrontAboutPanel: in the Inspector, hit Connect. XCode 3 may be looking pretty attractive by now. :)



6) Next we're going to use the awakeFromNib method as our opportunity to attach our menu to the target application's menubar. You don't call awakeFromNib from your code - when a nib is loaded, awakeFromNib is automatically called on the instance of the class associated with File's Owner. So define an awakeFromNib like this:

- (void) awakeFromNib {
    NSMenuItem* item;

    item = [[NSMenuItem alloc] init];
    [item setSubmenu: topMenu];

    [topMenu setTitle: @"George"];

    [[NSApp mainMenu] addItem: item];
    [item release];
}

Note that we finally use the IBOutlet topMenu, this one moment is the whole purpose of his existence. Also, we take advantage of NSApp again to give us the mainMenu of the target application so we can manipulate it.

7) Now we're ready to do the work specific to SIMBL. Our class needs to define a class method - not an instance method! - called load which takes no arguments and returns void. SIMBL always looks for a load method in the NSPrincipalClass (we'll get to that) and that's where we bootstrap our plugin. In our case, there's not much to do.

+ (void) load {
    [[self alloc] init];
}

When inside a class method, "self" does not represent an instance, it represents the class itself. So we alloc and init a new controller instance, our init calls loadNibNamed:, which causes awakeFromNib to be called, which causes our menu to be attached to the menubar, which worries the cat that killed the rat that ate the grain that sat in the house that Jack built.

8) Finally, we edit the Info.plist. Before it calls our load method, SIMBL looks in the Info.plist of our bundle to see (a) which application(s) we want to plug into and (2) which class in our bundle contains the load method that we want SIMBL to call. In this example our bundle has only one class, but in a real project you'll have more.

This next part is straight from the CULater wiki.

If you just doubleclick the Info.plist in XCode, it opens in the XCode editor. If you'd rather, you can rightclick and Open With Finder, which will bring up the Property List Editor. Either way, set the NSPrincipalClass to "GeorgeController", and then create a new array key called SIMBLTargetApplications. Since this is an array, you can create multiple entries and have your plugin be loaded into multiple applications. In our example, we're just going to have one array entry, telling SIMBL that George should be loaded into Apple Mail:

    <key>NSPrincipalClass</key>
    <string>GeorgeController</string>
    <key>SIMBLTargetApplications</key>
    <array>
        <dict>
            <key>BundleIdentifier</key>
            <string>com.apple.mail</string>
            <key>MaxBundleVersion</key>
            <string>*</string>
            <key>MinBundleVersion</key>
            <string>*</string>
        </dict>
    </array>

How to know the BundleIdentifier and BundleVersion? Don't try to guess at it, cause they're not necessarily related to the application's name or displayed version. You need to manually inspect the application's own Info.plist. Go to the application in the Finder, right-click, and choose "Show Packge Contents". Go into the Contents folder and open the Info.plist that you find there. Look for the CFBundleIdentifier (not CFBundleName!) and CFBundleVersion (not CFBundleShortVersionString!) Although, SIMBL will allow you to use wildcards in the versions as you can see in our example.

9) You're ready to build. Hit Build and deal with any errors. If you've followed the example code exactly it shouldn't generate any warnings either. Right-click the George.bundle in the XCode main window, select Reveal in Finder, and drag the bundle to your ~/Library/Application Support/SIMBL/Plugins directory. Start your target application (Apple Mail if you followed the example Info.plist) and you should see a "George" submenu in the menubar. Congratulations! You're ready to build out our example into a plugin that actually does, you know, stuff.

Credits:
Mike Solomon for the Cocoa Reverse Engineering page on the CULater wiki.
I figured out most of what wasn't in the wiki by picking apart the source code to GreaseKit. GreaseKit is Copyright (c) 2007 KATO Kazuyoshi.

4 comments:

Henning said...

Excellent! This is exactly the kind of tutorial I've been looking for. I'll boot up Xcode and give it a try. (Any tips on creating new windows, not just menus?)

Adrian Hosey said...

Thanks Henning. Glad it was useful for you. The CULater wiki is good, but not quite pedantic enough for FNGs like me, you know?

Apple suggests creating a seperate nib file for each kind of window:

http://developer.apple.com/documentation/DeveloperTools/Conceptual/IB_UserGuide/AdvancedTechniques/chapter_9_section_9.html#//apple_ref/doc/uid/TP40005344-CH10-SW2

and use loadNibNamed: to load and reload the nib, creating new instances as needed:

http://lists.apple.com/archives/cocoa-dev/2006/Feb/msg00202.html

Memory management of the objects created by the nib is a concern. Tread carefully:

http://lists.apple.com/archives/cocoa-dev/2003/Sep/msg01023.html

It's nice to see that names like Uli Kusterer once asked the same questions that I do, and now 5 years later he's a featured guest on Late Night Cocoa. Gives me hope!

I'm going to write a part 2 about SIMBL, focused on using tools like class-dump and gdb to pick apart the API of the target application, just as soon as I figure it out...

Henning said...

Awesome. Thanks again. I've actually been busy up until now, but I'm going to try this right now.

Do you know how to debug SIMBL plugins?

Henning said...

The menu works great. Now I need to work on that floaty window. Thanks again.