Stone Design Stone Design
News Download Buy Software About Stone
software

OS X & Cocoa Writings by Andrew C. Stone     ©1995-2003 Andrew C. Stone

Writing PlugIns for Cocoa
iPhoto to PhotoToWeb Exporter PlugIn
©2002 Andrew C. Stone All Rights Reserved

After you've been working with Cocoa for a while, you'll come to appreciate the truth behind this statement: "If it's hard to do, then you're not doing it right". By this I mean any coding solution that involves convoluted logic, going beneath the API or using undocumented methods, is probably not the right approach.

However, sometimes delving into secret API is the fastest way to get something done. Caveat Emptor: your program may break if the underlying methods change! That's why I usually admonish against using private, hidden methods.

This article will explain how to peek into the guts of an Objective C Cocoa application, iPhoto, and write a PlugIn that allows users to export their photos to the Stone Studio digital photography to web application PhotoToWeb®. iPhoto offers only limited web production facilities, whereas PhotoToWeb offers a complete suite of web design functionality, including the ability to create linked multi-level photo websites.

My design objectives were to keep the PlugIn small and very simple, including using the existing PhotoToWeb AppleScript API. This way, there are no code or file format dependencies in the PlugIn. Instead, we simply build a script that asks PhotoToWeb to build a new album with the exported photos. Also, instead of duplicating the user's photos, we just export the full path names since PhotoToWeb can build albums out of any images anywhere on the user's disk or network.

iPhoto loads the PhotoToWeb export PlugIn via Share - Export


class-dump Is Your Friend

If you want to see the inner architecture of an Objective C application, you'll need class-dump, which you can download from:

http://www.omnigroup.com/~nygard/Projects/index.html

class-dump is a utility for examining the Objective-C segment of Mach-O files. It generates the @interface and @protocol declarations for classes, categories and protocols.

Class-dump is open source, and has been an ongoing project since the early NeXT (the grandfather of OS X) days. It has had several contributors, including Eric P. Scott, Steve Nygard, James McIlree, Tom Hageman, and Carl Lindberg. It's a unix executable and has no GUI. Running it without arguments gives you the options:

class-dump 2.1.5
Usage: class-dump [-a] [-A] [-e] [-R] [-C regex] [-r] [-s] [-S] executable-file
-a show instance variable offsets
-A show implementation addresses
-e expand structure (and union) definition whenever possible
-R recursively expand @protocol <>
-C only display classes matching regular expression
-r recursively expand frameworks and fixed VM shared libraries
-s convert STR to char *
-S sort protocols, classes, and methods

You can dump all the classes of an application, or just those matching some pattern. Since we're interested in the Export methods, we'll run it like this:

class-dump -C Export /Applications/iPhoto.app/Contents/MacOSX/iPhoto

which outputs:

@protocol ExportImageProtocol
- directoryPath;
- (void)cancelExportBeforeBeginning;
- (void)cancelExport;
- (void)startExport;
- (void)clickExport;
- (void)disableControls;
- (void)enableControls;
- window;
- getImageDictionaryAtIndex:(unsigned int)fp16;
- getThumbPathAtIndex:(unsigned int)fp16;
- getImagePathAtIndex:(unsigned int)fp16;
- (char)imageIsPortraitAtIndex:(unsigned int)fp16;
- imageSelectionArray;
- (unsigned int)imageCount;
@end

@interface ExportMgr:NSObject <ExportImageProtocol>
{
ArchiveDocument *mDocument;
NSMutableArray *mExporters;
KeyMgr *mSelection;
ExportController *mExportController;
}

+ exportMgr;
- (void)releasePlugins;
- (void)setExportController:fp12;
- exportController;
- (void)setDocument:fp12;
- document;
- (void)updateDocumentSelection;
- (unsigned int)count;
- recAtIndex:(unsigned int)fp12;
- (void)scanForExporters;
- (unsigned int)imageCount;
- imageSelectionArray;
- (char)imageIsPortraitAtIndex:(unsigned int)fp12;
- getImagePathAtIndex:(unsigned int)fp12;
- getThumbPathAtIndex:(unsigned int)fp12;
- getImageDictionaryAtIndex:(unsigned int)fp12;
- getImageRecAtIndex:(unsigned int)fp12;
- (void)enableControls;
- (void)disableControls;
- window;
- (void)clickExport;
- (void)startExport;
- (void)cancelExport;
- (void)cancelExportBeforeBeginning;
- directoryPath;
- (void)_copySelection:fp12;

@end

So all we need to do to write a PlugIn is implement the methods defined in the ExportImageProtocol, add a few keys to the Info.plist file, and provide an interface file with any special controls. You can open some of the existing PlugIns inside iPhoto by control-clicking iPhoto in Finder, looking inside the Contents/PlugIns folder, and control-clicking one of the Exporter bundles.

Of course, without documentation or source, we'll have to guess at what certain methods do. ExportMgr has a class method "+exportMgr" which provides us with the ExportMgr instance. Reading over the ExportMgr class methods, we see methods for retrieving a set of photos one at a time, and getting information about that photo.

Making PlugIns

To make a loadable bundle (which is exactly what a PlugIn is), launch Project Builder and choose New Project..., select "Bundle", provide a path, and save. Create a new empty nib in InterfaceBuilder, add a CustomView (we really don't need a window, just a view containing our interface elements), and save it into your bundle project.

We only need one class, "PhotoToWebExport", but we also need to include the protocol as defined in ExportPluginProtocol, and declare that our object conforms to this protocol:

/* PhotoToWebExport.h */

#import <Cocoa/Cocoa.h>

@protocol ExportPluginProtocol
- (void)cancelExport;
- (void)unlockProgress;
- (void)lockProgress;
- (void *)progress;
- (void)performExport:fp16;
- (void)startExport:fp16;
- defaultDirectory;
- defaultFileName;
- requiredFileType;
- (void)viewWillBeDeactivated;
- (void)viewWillBeActivated;
- lastView;
- firstView;
- settingsView;
- initWithExportImageObj:fp16;
@end


#define IS_NULL(s) (!s || [s isEqualToString:@""])
#define NOT_NULL(s) (s && ![s isEqualToString:@""])

@interface PhotoToWebExport : NSObject <ExportPluginProtocol>
{
IBOutlet id settingsView;
IBOutlet id titleField;
IBOutlet id imageView;
IBOutlet id actionMatrix;
IBOutlet id finishView;
IBOutlet id firstView;

id exportManager;
ComponentInstance myComponent;
NSString *_lastDirectoryPath;
}

- (void)downloadLatest:(id)sender;

@end

#import "PhotoToWebExport.h"
#import <Carbon/Carbon.h>

@implementation PhotoToWebExport

// Designated initializer
// we'll grab the ExportMgr instance - although you could
// just use the class method + [ExportMgr exportMgr]

- initWithExportImageObj:fp16 {
self = [super init];
exportManager = fp16;

// we'll use AppleScript to talk to PhotoToWeb:
myComponent = OpenDefaultComponent(kOSAComponentType, kOSAGenericScriptingComponentSubtype);
return self;

}

// if there are photos to export, create and run an AppleScript
// to load the photos into a new PhotoToWeb album:

- (void)doExport:fp16 {
BOOL good = YES;
if ([exportManager imageCount] > 0)
[self runScript:[self scriptCommand]];
else {
NSRunAlertPanel(@"No images selected",@"No images to export",@"OK", nil, nil);
good = NO;
}

// make panel disappear - might be other way:
[[exportManager exportController] cancel:self];

// Bring the album to the front so user can make web site or thumbnails etc...
if (good) [[NSWorkspace sharedWorkspace] openFile:[exportManager directoryPath] withApplication:@"PhotoToWeb"];
}

// this is the guts of the plugin
// We'll create the AppleScript script necessary to export
// the selected photos, and attempt to grab as much information
// as the users have provided in iPhoto - such as comments, rotation
// crop information, title, etc.

- (NSString *)scriptCommand {
NSMutableString *s = [NSMutableString stringWithCapacity:2000];
unsigned i, c = [exportManager imageCount];

// for next time the user runs it, we'll have their preferred directory:
[_lastDirectoryPath release];
_lastDirectoryPath = [[[exportManager directoryPath] stringByDeletingLastPathComponent] copyWithZone:[self zone]];

[s appendString:@"tell application \"PhotoToWeb\"\r"];
[s appendString:@"\tmake new album at before front album\r"];
[s appendString:@"\ttell the front album\r"];

// this will prevent the AppleScript from timing out if there is a huge number of photos:
[s appendString:@"\t\tignoring application responses\r"];

for (i = 0; i < c; i++) {
NSString *path = [exportManager getImagePathAtIndex:i];
NSDictionary *dict = [exportManager getImageDictionaryAtIndex:i];
int rotation = [[dict objectForKey:@"Rotation"] intValue];
NSString *title = [dict objectForKey:@"Caption"];
NSString *story = [dict objectForKey:@"Annotation"]; // could be nil
NSDate *archiveDate = [dict objectForKey:@"ArchiveDate"];
NSString *date = [[NSCalendarDate dateWithTimeIntervalSinceReferenceDate:
[archiveDate timeIntervalSinceReferenceDate]] descriptionWithCalendarFormat:@"Archived: %e %B %Y"];
[s appendString:[NSString stringWithFormat:@"\t\t\tadd photos in \"%@\"\r", path]];

if (rotation != 0) {
[s appendString:[NSString stringWithFormat:@"\t\t\tset the photorotation of last photo to %d\r", rotation]];
}
if (NOT_NULL(title))
[s appendString:[NSString stringWithFormat:@"\t\t\tset the title of last photo to \"%@\"\r", title]];
if (NOT_NULL(story)) {
[s appendString:[NSString stringWithFormat:@"\t\t\tset the text contents of last photo to \"%@\"\r", NOT_NULL(date)? [NSString stringWithFormat: @"%@ %@",story,date]:story]];
} else if (NOT_NULL(date))
[s appendString:[NSString stringWithFormat:@"\t\t\tset the text contents of last photo to \"%@\"\r", date]];

}
[s appendString:@"\t\tend ignoring\r"];
[s appendString:[NSString stringWithFormat:@"\t\tsave in \"%@\"\r",[exportManager directoryPath]]];
[s appendString:@"\tend tell\r"];
[s appendString:@"end tell\r"];

return s;
}


- (void)performExport:fp16 {
// not sure how this differs from startExport but doesn't seem to be needed
}

- (void)startExport:fp16 {
[self doExport:fp16];
}

// if the plugin has been run before, use the last saved path
// Otherwise choose a standard location:

- (id) defaultDirectory {
if (NOT_NULL(_lastDirectoryPath)) return _lastDirectoryPath;
return [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"];
}


// here's a nice way to guarantee uniqueness of the album name
// concatenate the year-month-day-seconds:

- defaultFileName {
return [NSString stringWithFormat:@"%@-%d",[[NSCalendarDate calendarDate] descriptionWithCalendarFormat:@"%Y-%m-%d"], [NSDate timeIntervalSinceReferenceDate]];
}

// this is PhotoToWeb's single Album file type:
- requiredFileType {
return @"album";
}

- lastView {
return finishView;
}

- firstView {
return firstView;
}

- settingsView {
return settingsView;
}


// An action method to provide user with latest version of PhotoToWeb:
- (void)downloadLatest:(id)sender {

[[NSWorkspace sharedWorkspace] openURL:[NSURL
URLWithString:@"http://www.stone.com/NewDownload.html#PHOTO"]];
}

// I can't think of anything to do in these two methods
// In a more complex plugin, you might set default values:

- (void)viewWillBeDeactivated {
}

- (void)viewWillBeActivated {
}


// these next 4 methods are not yet flushed out because I haven't figured
// out exactly what to do with them - if you do, email me!

- (void)cancelExport {
}

- (void)unlockProgress {
}

- (void)lockProgress {
}

- (void *)progress {
return NULL; //??
}

// The AppleScript compilation methods:

#define CHECK
//fprintf(stderr,"result code = %d", ok);

// This converts an AEDesc into a corresponding NSValue.
static id aedesc_to_id(AEDesc *desc)
{
OSErr ok;

if (desc->descriptorType == typeChar)
{
NSMutableData *outBytes;
NSString *txt;

outBytes = [[NSMutableData alloc] initWithLength:AEGetDescDataSize(desc)];
ok = AEGetDescData(desc, [outBytes mutableBytes], [outBytes length]);
CHECK;

txt = [[NSString alloc] initWithData:outBytes encoding:[NSString defaultCStringEncoding]];
[outBytes release];
[txt autorelease];

return txt;
}

if (desc->descriptorType == typeSInt16)
{
SInt16 buf;

AEGetDescData(desc, &buf, sizeof(buf));

return [NSNumber numberWithShort:buf];
}

return [NSString stringWithFormat:@"[unconverted AEDesc, type=\"%c%c%c%c\"]", ((char *)&(desc->descriptorType))[0], ((char *)&(desc->descriptorType))[1], ((char *)&(desc->descriptorType))[2], ((char *)&(desc->descriptorType))[3]];

}

- (void)runScript:(NSString *)txt
{
NSData *scriptChars = [txt dataUsingEncoding:[NSString defaultCStringEncoding]];
AEDesc source, resultText;
OSAID scriptId, resultId;
OSErr ok;

// Convert the source string into an AEDesc of string type.
ok = AECreateDesc(typeChar, [scriptChars bytes], [scriptChars length], &source);
CHECK;

// Compile the source into a script.
scriptId = kOSANullScript;
ok = OSACompile(myComponent, &source, kOSAModeNull, &scriptId);
AEDisposeDesc(&source);
CHECK;

// Execute the script, using defaults for everything.
resultId = 0;
ok = OSAExecute(myComponent, scriptId, kOSANullScript, kOSAModeNull, &resultId);
CHECK;

if (ok == errOSAScriptError) {
AEDesc ernum, erstr;
id ernumobj, erstrobj;

// Extract the error number and error message from our scripting component.
ok = OSAScriptError(myComponent, kOSAErrorNumber, typeShortInteger, &ernum);
CHECK;
ok = OSAScriptError(myComponent, kOSAErrorMessage, typeChar, &erstr);
CHECK;

// Convert them to ObjC types.
ernumobj = aedesc_to_id(&ernum);
AEDisposeDesc(&ernum);
erstrobj = aedesc_to_id(&erstr);
AEDisposeDesc(&erstr);

txt = [NSString stringWithFormat:@"Error, number=%@, message=%@", ernumobj, erstrobj];
} else {
// If no error, extract the result, and convert it to a string for display

if (resultId != 0) { // apple doesn't mention that this can be 0?
ok = OSADisplay(myComponent, resultId, typeChar, kOSAModeNull, &resultText);
CHECK;

txt = aedesc_to_id(&resultText);
AEDisposeDesc(&resultText);
} else {
txt = @"[no value returned]";
}
OSADispose(myComponent, resultId);
}

ok = OSADispose(myComponent, scriptId);
CHECK;
}

@end

After you compile your PlugIn, drop it into the PlugIns folder in iPhoto, relaunch iPhoto, choose "Share" and "Export", and if all is well, you'll see your PlugIn as a tab in the Export TabView window.

Then Apple Released iPhoto 1.1

Before this article went to print, Apple released an update to iPhoto—and indeed, my Exporter bundle broke! First, I looked through the console error messages and saw that my PlugIn wasn't responding to certain messages. Then, I used class-dump to confirm that some protocols had changed.

The first problem was that my PlugIn's main settingsView didn't respond to the ExportPluginBoxProtocol protocol:

@protocol ExportPluginBoxProtocol
- (char)performKeyEquivalent:fp16;
@end

So, by looking at the nib files in the Apple-provided PlugIns, I deduced this simple NSBox subclass:

@interface P2WExportPluginBox:NSBox <ExportPluginBoxProtocol>
{
id mPlugin;
}
@end

@implementation P2WExportPluginBox
- (char)performKeyEquivalent:fp12 {
}

@end

Drop this source file into the Interface Builder class window to have it read the file. Assign the class "P2WExportPluginBox" to the NSBox containing your PlugIn controls in Interface Builder. Connect the "mPlugin" instance variable to the file's owner. Save, build, and drop into the PlugIns folder. (An interesting sidenote: variable names can reveal a lot. The coder here was probably an old Mac toolbox programmer, as indicated by the lower-case "m", as in "my", for the ivar. Objective-C coders conventionally use an underscore to prefix ivars, so the ivar name would have been "_plugin".)

Second, a few more methods were needed in the main PlugIn class to get it to work again:

@implementation PhotoToWebExport

- (NSString *)name {
return @"Stone Design's iPhoto to PhotoToWeb Exporter";
}
- (BOOL)treatSingleSelectionDifferently { return NO; }
...


Conclusion

Cocoa, especially the Objective C flavor, is the future of Mac OS X, as evidenced by the new apps coming out of Apple written almost entirely in Cocoa. Dynamic runtime loading comes free, and you can examine the classes and methods in an application using class-dump. Knowledge of Carbon is still very useful. For example, in this PlugIn we use it to compile and execute our AppleScript. It's a good idea to keep an agnostic attitude towards the various Apple API's.

Using the techniques outlined in this article, you should be able to add all sorts of functionality to the ever-increasing number of applications available for Mac OS X.


Andrew Stone is founder of Stone Design Corp <http://www.stone.com/>, author of Stone Studio™ and Create®, and divides his time between farming on the earth and in cyperspace.

PreviousTopIndexNext
©1997-2005 Stone Design top