Linking Applescript-ObjC Scripts Within The Application Bundle
Linking scripts in Applescript is not nearly as simple as it is in C. For example, in order for a linked script to be used, it simply can’t be called by name only and then linked upon compilation as in C. Instead, a linked script must be loaded at runtime via its full, absolute path each time it is to be used by another script, so special handling is required. ASOC complicates this by keeping Applescript files in Xcode projects as.applescript, a plain text file, and then converting them to .scpt, Applescript’s own pre-compiled format, upon building. In other words, linking via absolute path only works if you manage both the path to the application bundle and the names of the Applescript files requiring linking.
So, why bother if all of that work is needed? Simple: Code re-use. I have an application I am working on at the time of this writing that is taking three discrete, yet ridiculously similar, projects that share a lot of code, or in this case duplicates a lot of code. Previously, I couldn’t merge them in pure Applescript because each one of the these projects hovers around 3,000 lines of code, with only 30% of the code being duplicated. Applescript starts to get “choppy” at certain sizes or scopes, so I had to keep them separate. ASOC now allows me to merge into one, single project, but 30% of 3,000 lines multiplied by three projects is a lot of code that needs to be better managed.
To manage this transition in paths and file names, I devised a helper object in the form of a dictionary-like class can be used to help manage the paths to linked scripts. Once the path is determined by the application’s runtime, the dictionary can hold a key-value pair of the script name and the script path, and would do so for all linked scripts, regardless of use. This dictionary can then be passed to any of the linked scripts to initialize resources where the script just takes what it needs. The generation and storage of paths would happen in the applicationWillFinishLaunching_event of the AppDelegate.
Essentially, the process would boil down to the following steps…
In applicationWillFinishLaunching_ for each script:
- Get the path to the script saved in the app’s bundle
- Load the script to ensure compatibility
- Create the dictionary object to store paths
- Save the path to the dictionary by the script’s (hardcoded) name
To use the script path:
- Pass the dictionary to the root process of a linked script
- In the root process of the linked script, get the paths to the linked scripts and
- reload them in the linked script, not the AppDelegate
- Press forward with the process as required
The following is some code to fully illustrate what these steps entail. All extraneous code has been removed. In this example, we reference the following resources:
- an AppDelegate, provided by Xcode when making a new ASOC project,
- a “process” script, OCKitController, that manages a user-initiated action.
- a “library” script, OCFinderLib, that holds commonly used Finder functions
- a “class” script, OCDictionaryClass, which manages a custom script object, in
- this case the base class that makes up the aforementioned dictionary. That code
- is not included here, and can, instead be found in the section OCDictionaryData Storage Class.
One could argue that since we are using ASOC, we can just use the NSDictionaryclass to handle these key-value pairs. But I don’t always work in ASOC, and I needed something more portable since I hand off scripts by themselves, well outside of the Xcode environment. Thus, a dictionary class written in pure Applescript. This also gets around the wonky syntax issues that come with ASOC.
AppDelegate File
script OCAppDelegate (* This pattern is devised as an easy, if verbose, way of managing script locations. It should appear in any linked script that uses internal (application) resources. We need the name as a key and the absolute path to the script itself as a value, and not the script 'object', so we keep all three seperate. This also makes initialization and use easier in linked scripts later as we can just copy and paste the three properties where we need it. *) (* A helper script for common Finder functions. Can be linked to by any number of scripts. *) property kFinderLibName : "OCFinderLib" property kFinderLibPath : missing value property kFinderLib : missing value (* A "process script" that actually performs a user-initiated task, launched from the AppDelegate and links to the OCFinderLib. *) property kKitControllerName : "KitController" property kKitControllerPath : missing value property kKitController : missing value (* This the base class of the processInitializer. *) property kDictionaryClassName : "DictionaryClass" property kDictionaryClassPath : missing value property kDictionaryClass : missing value (* The process initializer itself. *) property pProcessInitializer : missing value on applicationWillFinishLaunching_(aNotification) (* Basically, either everything loads or they don't. This is as low-level as it gets for this application, so there isn't a lot of wiggle room here. *) (* load the script files to ensure they are included in the app buundle and useable *) set scriptsLoaded to loadScripts() of me if (not scriptsLoaded) then display dialog "There was a problem loading the application. Please contact support." quit end if (* Now that the OCDictionary Class script has been loaded, we can use it to create the processInitializer *) my createProcessInitializer() (* One last step to manage these paths. We now include the paths to the scripts in the process initializer. This pattern is probably overkill but we want to be sure everything is in place. *) set scriptsInited to initializeScripts() of me if (not scriptsInited) then display dialog "There was a problem initializing the application. Please contact support." quit end if (* Thunderbirds are GO *) end applicationWillFinishLaunching_ on loadScripts() --(void) as boolean (* in this pattern, paths are stored for inclusion in the processInitializer later in the process *) set kFinderLibPath to getScriptPathInBundleWithName(kFinderLibName) set kFinderLib to loadScriptInBundleWithPath(kFinderLibPath) if (kFinderLib = missing value) then -- something bad happened return false end if (* A bit of hand-waving here only to say that we do the same with the other global script properties *) return true end loadScripts on createProcessInitializer() -- (void) as void (* This represents the first call to a linked script. Note we don't use 'tell' here since we are only calling a subroutine of a script and not calling a subroutine of a script object. *) set pProcessInitializer to MakeDictionary() of kDictionaryClass end createProcessInitializer on initializeScripts() --(void) as boolean (* Now we store the path in the processInitializer for use later since everthing is in place *) tell pProcessInitializer set valueSet to setValueForKey(kFinderLibPath, kFinderLibName) if (not valueSet) then log {"OCAppDelegate:initializeLibs:kFinderLib", valueSet} return false end if end tell (* A bit of hand-waving here only to say that we do the same with the other global script properties *) return true end initializeScripts (* Script Loading *) (* These subroutines do the heavy lifting of getting the path. This is one of the first instances of true ApplescriptObjC in the loading process with the call to NSBundle. These were ported and updated from the following method I wrote ages ago... 1 - (NSString *) stringFromTextFileInBundleWithName:(NSString *)fileName { 2 NSBundle* myBundle = [NSBundle mainBundle]; 3 NSString *dataPath = [myBundle pathForResource:fileName ofType:@"txt"]; 4 NSURL *dataURL = [NSURL fileURLWithPath:dataPath]; 5 NSError *readError; 6 NSString *dataString = [NSString stringWithContentsOfURL:dataURL encoding:NSUTF8StringEncoding error:&readError]; 7 if ( !dataString ) { 8 NSLog(@"fileName:%@, targetReadError: %@", fileName, readError); 9 } 10 return dataString; 11 } For the sake of discussion of porting Objective-C to ASOC, the real "meat" of the method is in lines 2–3, which can be easily shortened to... NSString *dataPath = [[NSBundle mainBundle] pathForResource:fileName ofType:@"txt"]; ...which then translates to... set hfsScriptPath to (current application's NSBundle's mainBundle's pathForResource_ofType_(fileName, "txt")) as text We don't need an NSURL object with ASOC once we have the path. One thing that is important to remember is the use of 'targetScriptName', 'hfsScriptPath', 'posixScriptPath', 'aScriptPath' as opposed to just 'scriptName' and 'scriptPath' in both. Variables are pretty much of global scope within a script in ASOC even across functions, or at least Xcode was giving me a hard time until I changed all of the variables into something unique. *) on getScriptPathInBundleWithName(targetScriptName) set hfsScriptPath to (current application's NSBundle's mainBundle's pathForResource_ofType_(targetScriptName, "scpt")) as text set posixScriptPath to (hfsScriptPath as POSIX file) return posixScriptPath end getScriptPathInBundleWithName on loadScriptInBundleWithPath(aScriptPath) -- (string) as script return load script aScriptPath as alias end loadScriptInBundleWithName (* Syntactic sugar just to show how simple this can be, but we also lose the discreet name and path and just get a script object *) on loadScriptInBundleWithName(scriptName) -- (string) as script set scriptPath to current application's NSBundle's mainBundle's pathForResource_ofType_(scriptName, "scpt") as text set scriptPath to (scriptPath as POSIX file) return load script scriptPath as alias end loadScriptInBundleWithName (* Finally, here is where the rubber meets the road. The scripts have been linked, the app fully launched, and the user has clicked a menu item. *) on newKit_(sender) (* [snip] get all of the data we need to create a folder like name and location *) createNewKit(pProcessInitializer, kSourceFolder, kitName) of kKitController end newKit_ end script
Kit Controller File
(* The process initializer is a constant here, and not something that is to be manipulated so we add a 'k' prefix to be sure we don't muck it up. *) property kProcessInitializer : missing value (* This is copied straight from the App Delegate. We don't need everything the delegate does so we just take what we need from the initializer *) property kFinderLibName : "OCFinderLib" property kFinderLib : missing value (* A designated point of entry for the script *) on createNewKit(processInitializer, saveFolder, folderName) -- (path, string) as boolean (* Receive the initializer, pass it to the init process for this script, which will need to happen every time we run something, and then proceed back down the stack. *) set inited to initialize(processInitializer) of me if (not inited) then log "OCKitController:createNewKit:initialize = false" return false end if (* FIN-a-lly, everything is in place, so now we can make a folder. *) (* Now we make a call to the FinderLib that was originally linked in the AppDelegate and then re-linked here. *) revealItemInFinder(saveFolder) of kFinderLib (* That's all, folks! *) end createNewKit (* This needs to be called from inside *) on initialize(processInitializer) -- (processInitializer) as boolean if processInitializer is missing value then return false end if set kProcessInitializer to processInitializer set kFinderLib to valueForKey(kFinderLibName) of processInitializer set kFinderLib to load script kFinderLib as alias if kFinderLib is missing value then return false end if return true end initialize
Finder Lib File
(* The Finder Lib contains a bunch of little subroutines like this that help reduce the amount of code and promote code reuse. Here is one of them. *) on revealItemInFinder(itemPath) -- (String) as void tell application "Finder" activate open parent of itemPath select itemPath end tell end revealItemInFinder