27.4. AppleScript Studio ScriptabilityIt is natural to wonder whether an AppleScript Studio application is scriptable. The news here is something of a mixed bag. AppleScript programmers who are accustomed to writing applets, which are inherently scriptable, will be disappointed to learn that AppleScript Studio applications are not scriptable in quite the same easy way. The mere presence of a top-level entity in an applet's script makes the applet scriptable with respect to that entity, but no such thing is true of an AppleScript Studio application. So, for example, you cannot simply tell our SearchTidBITS application to displayResults( ) (see Example 27-7). The problem is that an AppleScript Studio application is not merely an application shell wrapped around a script; it's a true Cocoa application. So your message isn't magically routed to the correct script, because in the way stands the entire mechanism of a Cocoa application. On the other hand, an AppleScript Studio application is scriptable with respect to the entire AppleScriptKit.sdef dictionary, which is actually visible to users though a script editor application as if it were your application's own dictionary. This means that whatever built-in commands you can give from within the code of an AppleScript Studio application, a user can give from outside it. For example: tell application "SearchTidBITS" activate tell window "search" tell matrix 1 set content of cell 1 to "AppleScript" set content of cell 3 to "Matt Neuburg" end tell tell button 1 to perform action end tell end tell That's exactly the same as if the user had typed values into two of the text fields and then pressed the Search button! Initially this may sound exciting, but most AppleScript Studio programmers ultimately regret that things work this way, for the following reasons:
You might wonder, as you can get the script of an interface element, whether it might be possible to route a message to that script. This is a clever idea, and at first it looks promising:
tell application "SearchTidBITS"
set s to (get script of button 1 of window "search")
tell s
urlEncode("hi there") -- "hi+there"
end tell
end tell It turns out, however, that when you get the script of an interface element, it's a copy. The real script object, the one that the interface element is actually using at that moment, was copied and loaded when the application started up; what you've got is a different script object, completely unbound from its proper context in the running applicationfor example, its top-level entity values are not the same as the current top-level entity values of the real script. (This is a serious problem for AppleScript Studio programmers as well, because it complicates communication between scripts and makes the reliable storage of true globals rather an elaborate exercise.) Thus an AppleScript Studio application is automatically scriptable, but only in a messy, disordered way. On the other hand, because an AppleScript Studio application is a Cocoa application, you might wonder whether you can add customized scriptability to your AppleScript Studio application through Cocoa scripting. You can, although there are two major shortcomings to this approach:
With those caveats, it is possible to add Cocoa scripting to an AppleScript Studio application. The procedure is straightforward, except for one thing: you can't use the sdef dictionary format to implement your scriptability. To put it technically, if you add the OSAScriptingDefinition key to your Info.plist, AppleScript Studio itself will break and your application will be stripped of its functionality. Therefore you must implement scriptability the old way, with a resource file. This is not such a terrible thing, as it's what you would have to do in order to implement scriptability for a pre-Tiger application anyway. And besides, you can develop your scriptability using an sdef file; you simply can't implement it with an sdef file in the built application. Thus, as the application is built, you must transform your sdef file into a different format, one that is compatible with earlier systems; and it happens that there's a Unix tool, sdp , that makes this easy to do. To illustrate, we'll add some basic custom scriptability to the SearchTidBITS application developed earlier in this chapter with AppleScript Studio. The first step is to whip out the Sdef Editor application and create the sdef file. Here it is (for brevity, descriptions are omitted):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">
<dictionary title="Dictionary">
<suite name="SearchTidBITS Suite" code="sTBs">
<class name="application" code="capp" plural="applications"
inherits="ASKApplicationSuite.NSApplication">
<cocoa class="NSApplication"/>
<property name="search text" code="sTBt" description="" type="text">
<cocoa key="searchText"/>
</property>
<property name="search title" code="sTBi" description="" type="text">
<cocoa key="searchTitle"/>
</property>
<property name="search author" code="sTBa" description="" type="text">
<cocoa key="searchAuthor"/>
</property>
</class>
<command name="do search" code="sTIDdSRC" description="">
<cocoa class="MyScriptCommand"/>
</command>
</suite>
</dictionary> The most important thing here is to hook the application class in this file to the existing AppleScript Studio application class. The lines in boldface are absolutely crucial. Get one of these values wrong and the integration between Cocoa scripting and AppleScript Studio will fail. The four-letter code must be 'capp'; the inheritance must specify "ASKApplicationSuite.NSApplication" as the superclass; and the Cocoa class must be "NSApplication." Save the sdef file as searchTidBITS.sdef. Open the SearchTidBITS project in Xcode and import the sdef file into the project; elect to copy it into the project folder but do not add it to the target. We want this file to live in the project folder for development purposes, but we do not want it to be copied into the built application. What we do want copied into the built application is a resource file containing the dictionary, along with scriptSuite and scriptTerminology files to implement Cocoa scriptability (see "Dictionary Formats" in Chapter 3). To arrange for this to happen automatically, choose Project New Build Phase New Shell Script Build Phase. The info window for the run script phase will appear; in the script field, enter this Unix code: /usr/bin/sdp -fast -o "$BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME/Contents/Resources" "$SOURCE_ROOT/searchTidBITS.sdef" When you build and run the application, you'll find that it runs normally; if you examine its dictionary in a script editor application, you'll find that the SearchTidBITS Suite is present and that there are three new application properties (search text, search title, and search author) and a new command (do search). Now let's add implementation code. We'll need a place to put it, so we'll create a new Cocoa class. (I assume you know how to do this, so my instructions will be very abbreviated.) Open MainMenu.nib and, in Interface Builder, create a new NSObject subclass called MyObject, instantiate it, and make the instance the application delegate by making the Cocoa connection between the File's Owner and MyObject. Now save MyObject into the project. Save, and quit Interface Builder. Back in Xcode, here's the implementation code for MyObject: @implementation MyObject - (BOOL) application: (id) sender delegateHandlesKey:(NSString*) key { NSLog(@"handles key? %@", key); if ([key isEqualToString: @"searchText"]) return YES; if ([key isEqualToString: @"searchTitle"]) return YES; if ([key isEqualToString: @"searchAuthor"]) return YES; return NO; } - (NSAppleEventDescriptor*) doAS: (NSString*) s { NSAppleScript* as = [[NSAppleScript alloc] initWithSource:s]; NSAppleEventDescriptor* d = [as executeAndReturnError:nil]; [as release]; return d; } - (NSString*) searchText { NSString* s = @"tell current application " @"to get content of cell 1 of matrix 1 of window \"search\""; return [[self doAS:s] stringValue]; } - (void) setSearchText: (NSString*) t { NSString* s = [NSString stringWithFormat: @"tell current application " @"to set content of cell 1 of matrix 1 of window \"search\" " @"to \"%@\"", t]; [self doAS:s]; } // ... and so on ... @end The accessors for "searchTitle" and "searchAuthor" are omitted for brevity; you should be able to write them easily. (They are exactly the same as the accessors for "searchText" except for the names, and except for the cell numbers, which are 2 and 3 respectively.) This implementation works around the problem of communicating from Objective-C code to AppleScript code by not even trying to do so. Instead, we communicate with the interface. We know that our AppleScript Studio application is scriptable through the native AppleScript Studio commands, so we use them directly, just as we do in our AppleScript code, to drive the interface. We can do this readily; the current application is SearchTidBITS itself, so we are sending a message to ourselves. But this is still a skanky solution: instead of sending a message from one region of code to another, we are using the interface as a kind of drop box. We can't tell our AppleScript code to set its internal textSought, titleSought, and authorSought globals, so we content ourselves with leaving the corresponding values in the interface, where the AppleScript code will find them later. So now let's tell the AppleScript code to find them, by implementing the do search command. This command doesn't take a direct object, so we'll implement it using verb-first dispatch. (See the earlier section "Cocoa Scripting" for the other way of implementing a command, object-first dispatch.) It works like this: in the sdef, you declare a Cocoa class representing your command; in your project, you create an NSScriptCommand subclass with the same name. We've declared in the sdef that our class is called MyScriptCommand, so now we create that class. In Xcode, choose File New File, making the new file an Objective-C class and calling it MyScriptCommand. In the header file, make MyScriptCommand a subclass of NSScriptCommand. In the implementation file, enter this code: @implementation MyScriptCommand - (NSAppleEventDescriptor*) doAS: (NSString*) s { NSAppleScript* as = [[NSAppleScript alloc] initWithSource:s]; NSAppleEventDescriptor* d = [as executeAndReturnError:nil]; [as release]; return d; } - (id) performDefaultImplementation { NSString* s = @"tell current application " @" to perform action of button 1 of window \"search\""; [self doAS:s]; return nil; } @end In our override of the performDefaultImplementation method, we get to implement our own functionality for this command. Once more we have not tried to solve the problem of communicating from Objective-C to AppleScript; instead, we have again used the interface as a medium of indirect communication. We can't call the clicked handler directly, so instead we effectively press the Search button, using an AppleScript Studio command that permits us to do so, and this triggers the clicked handler already tied to that button. Even though we can't send messages from Objective-C to AppleScript, perhaps we can improve the way we send messages to the interface. At present we are forming a script as text on the fly and then compiling and executing it. This way is very slow, uses a lot of unnecessary overhead, and may be justly charged with a certain fragility. To give just one example, if the user says set search text to with a value that contains a quote character, our setSearchText: method will break, because of the blithe way it constructs a literal string. That's clearly a bug. I will conclude, therefore, by demonstrating a more elegant architecture that uses a compiled script as an intermediary (a "trampoline"). We will call into this compiled script with an Apple event, and the compiled script will send an Apple event to our interface. Apple events are fast, and running a compiled script is fast; it's compiling text on the fly that's slow. And this approach will be immune to the bug with strings containing a quote, because we will never "unpack" an AppleScript stringwe will pass it along directly to the compiled script, and we know that this will work because the string must have been a valid AppleScript string to start with (or we could never have received it in the first place). Start with a script encapsulating the AppleScript code we're already using to get and set the contents of a form cell in the Search window: on setCell(n, s) tell current application using terms from application "Automator" set content of cell n of matrix 1 of window "search" to s end using terms from end tell end setCell on getCell(n) tell current application using terms from application "Automator" get content of cell n of matrix 1 of window "search" end using terms from end tell end getCell The terms blocks, targeting Automator, are a trick: in order for the central lines to compile, we must resolve them with respect to AppleScript Studio's terminology; Automator's dictionary contains this terminology. Now compile the script (at which point Automator's task is done, because it is never actually targeted), and save it as trampoline.scpt. Add it to the SearchTidBITS project so that it will be copied into the built application bundle. Because we might be using this script any time the user targets the SearchTidBITS application, we'll save time by loading it once and for all into an NSAppleScript* instance variable (called trampoline) as our application starts up: - (void) awakeFromNib { NSString* path = [[NSBundle mainBundle] pathForResource:@"trampoline" ofType:@"scpt"]; NSURL* url = [NSURL fileURLWithPath:path]; trampoline = [[NSAppleScript alloc] initWithContentsOfURL:url error:nil]; } When the user wants to get or set any of the properties search text or search title or search author, we will call the corresponding handler of trampoline.scpt, along with appropriate parameter values. We know how to execute a script as a whole in Cocoa using NSAppleScript, but how do we call a particular handler, and how do we pass it parameters? The solution is the very same mechanism by which scriptability of user handlers in an applet is implemented (see "Applet Scriptability," earlier in this chapter)the 'ascr\psbr' Apple event. We must form this Apple event more or less manually, but it isn't hard to do. Here's how. Observe that for the sake of brevity and clarity I've issued a #define equating "Desc" to the lengthy term "NSAppleEventDescriptor" which would otherwise clutter up the code. #define Desc NSAppleEventDescriptor - (Desc*) callSub:(NSString*)handler params:(Desc*)firstParam, ... { Desc* list = [Desc listDescriptor]; int i=0; va_list ppp; va_start(ppp, firstParam); Desc* aParam = firstParam; while(aParam) { [list insertDescriptor:aParam atIndex:++i]; aParam = va_arg(ppp, Desc*); } Desc* h = [Desc descriptorWithString:[handler lowercaseString]]; Desc* ae = [Desc appleEventWithEventClass:'ascr' eventID:'psbr' targetDescriptor:[Desc nullDescriptor] returnID:kAutoGenerateReturnID transactionID:kAnyTransactionID]; [ae setParamDescriptor:h forKeyword:'snam']; [ae setParamDescriptor:list forKeyword:keyDirectObject]; return ae; } - (void) setCell: (int) n toString: (NSString*) s { Desc* dn = [Desc descriptorWithInt32:n]; Desc* ds = [Desc descriptorWithString:s]; [trampoline executeAppleEvent : [self callSub:@"setCell" params:dn, ds, nil] error:nil]; } - (id) getCell: (int) n{ Desc* dn = [Desc descriptorWithInt32:n]; return [[trampoline executeAppleEvent: [self callSub:@"getCell" params:dn, nil] error:nil] stringValue]; } - (NSString*) searchText { return [self getCell: 1]; } - (void) setSearchText: (NSString*) t { [self setCell: 1 toString: t]; } // ...and so on... The callSub: routine is a general utility for helping to form the 'ascr\psbr' Apple event. It takes as its parameters the name of the AppleScript handler you want to call, followed by a nil-terminated series of AppleScript parameter values. Each AppleScript parameter value must have previously been embedded into an NSAppleEventDescriptor of the proper type, but this is not usually difficult to do; the setCell: and getCell: methods exemplify the technique, and show how to call callSub:. |