Linking Scripts

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

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