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
  • 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