Embedding script libraries that call other libraries

When a script contains a use script statement, AppleScript follows some pretty simple rules for where to look for the script library when compiling.

If the host script is an applet or script bundle, the first port of call is to check inside the bundle’s /Contents/Resources/Script Libraries folder, if there is one. If that comes up empty, or the script is not a bundle, the next stop is ~/Library/Script Libraries, followed by /Library/Script Libraries. In 10.11 or later, it will also look for libraries included in other applications (in their /Contents/Library/Script Libraries folder, not in /Contents/Resources/Script Libraries).

However, things get tricky is when one script library uses another library.

Consider the case of two script libraries, Lib A and Lib B, where Lib A also uses Lib B. Now consider an applet that uses both. As long as both scripts are in one of the folders searched by all scripts — usually ~/Library/Script Libraries — all will be fine.

Suppose you want to distribute the applet, so you embed copies of Lib A and Lib B in your applet, in its /Contents/Resources/Script Libraries folder. Your main script will find them there, but what of Lib A’s dependence in Lib B? Lib A knows nothing of the bundle it is embedded in, but as long as it can see a copy in ~/Library/Script Libraries, it will compile fine.

Now what happens if the embedded libraries are the only copies — either the applet has been moved to a different Mac, or the copies in ~/Library/Script Libraries have been removed?

The good news is that the applet will generally run fine, because the search rules for running a script are slightly different to those when compiling: Lib A will first check to see if Lib B has already been loaded, which it will have been by the applet, so there will be no need to search any further. And of course you can edit the applet script fine.

What you can’t do, though, is edit Lib A as long it depends on Lib B and it can’t find a copy of Lib B in its search path. The fact that both are part of a larger bundle is irrelevant.

The best way to handle this is to try to avoid it. Edit and build scripts with libraries in ~/Library/Script Libraries, and only bundle them when you are about to deploy, preferably in run-only form. But if you cannot do that for some reason, you can work around the issue using symlinks.

Doing this requires that your libraries are saved as bundles (.scptd files), not simple .scpt files. Then you can put relative symlinks inside the bundles’ /Contents/Resources/Script Libraries folders pointing to the other libraries.

It’s a bit fiddly to attempt manually, but something scripting can do simply, as below. Place the script in Script Debugger’s Scripts menu, and run it with the applet open.

use AppleScript version "2.5" -- macOS 10.11 or later
use framework "Foundation"
use scripting additions

-- classes, constants, and enums used
property NSFileManager : a reference to current application's NSFileManager
property NSPredicate : a reference to current application's NSPredicate
property NSURL : a reference to current application's NSURL

tell application id "com.latenightsw.ScriptDebugger7" -- Script Debugger.app
	set thePath to file spec of document 1 -- change to suit if testing
end tell
-- get a list of all embedded libraries
set mainLibURL to NSURL's fileURLWithPath:"Contents/Resources/Script Libraries" isDirectory:true relativeToURL:thePath
set fileManager to NSFileManager's defaultManager()
set theURLs to (fileManager's contentsOfDirectoryAtURL:mainLibURL includingPropertiesForKeys:(missing value) options:0 |error|:(missing value))'s allObjects()
set bundleLibs to theURLs's filteredArrayUsingPredicate:(NSPredicate's predicateWithFormat:"pathExtension == 'scptd'")
set allLibs to theURLs's filteredArrayUsingPredicate:(NSPredicate's predicateWithFormat:"pathExtension == 'scptd' OR pathExtension == 'scpt'")
set allLibNames to allLibs's valueForKeyPath:"lastPathComponent"
-- loop through bundles
repeat with aBundle in bundleLibs
	-- make sure they have their own Script Libraries folders
	set subLibURL to (NSURL's fileURLWithPath:"Contents/Resources/Script Libraries" isDirectory:true relativeToURL:aBundle)
	(fileManager's createDirectoryAtURL:subLibURL withIntermediateDirectories:true attributes:(missing value) |error|:(reference))
	set bundleNameLessExt to aBundle's lastPathComponent()'s stringByDeletingPathExtension()
	-- loop through library names
	repeat with libName in allLibNames
		set libNameLessExt to libName's stringByDeletingPathExtension()
		if not (bundleNameLessExt's isEqualToString:libNameLessExt) as boolean then -- don't link to self
			-- make relative symbolic link to the library; will silently fail if already there
			(fileManager's createSymbolicLinkAtPath:(subLibURL's |path|()'s stringByAppendingPathComponent:libName) withDestinationPath:("../../../../" & (libName as text)) |error|:(missing value))
		end if
	end repeat
end repeat

Make sure you save after running the script. It uses a bit of brute force – it puts links whether needed or not – but unused links don’t matter.

If you want to remove the links, perhaps to modify the scripts significantly or use them elsewhere, this script will do the job:

use AppleScript version "2.5" -- macOS 10.11 or later
use framework "Foundation"
use scripting additions

-- classes, constants, and enums used
property NSPredicate : a reference to current application's NSPredicate
property NSURL : a reference to current application's NSURL
property NSFileManager : a reference to current application's NSFileManager
property NSURLIsSymbolicLinkKey : a reference to current application's NSURLIsSymbolicLinkKey

tell application id "com.latenightsw.ScriptDebugger7" -- Script Debugger.app
	set thePath to file spec of document 1 -- change to suit if testing
end tell
-- get a list of all embedded library bundles
set mainLibURL to NSURL's fileURLWithPath:"Contents/Resources/Script Libraries" isDirectory:true relativeToURL:thePath
set fileManager to NSFileManager's defaultManager()
set theURLs to (fileManager's contentsOfDirectoryAtURL:mainLibURL includingPropertiesForKeys:(missing value) options:0 |error|:(missing value))'s allObjects()
set bundleLibs to theURLs's filteredArrayUsingPredicate:(NSPredicate's predicateWithFormat:"pathExtension == 'scptd'")
-- loop through bundles
repeat with aBundle in bundleLibs
	-- see if they have their own Script Libraries folders
	set subLibURL to (NSURL's fileURLWithPath:"Contents/Resources/Script Libraries" isDirectory:true relativeToURL:aBundle)
	if (subLibURL's checkResourceIsReachableAndReturnError:(missing value)) as boolean then
		-- get sub-library URLs
		set subLibURLs to (fileManager's contentsOfDirectoryAtURL:subLibURL includingPropertiesForKeys:{NSURLIsSymbolicLinkKey} options:0 |error|:(missing value))'s allObjects()
		-- loop through sub-libraries
		repeat with aSubLib in subLibURLs
			-- check if it's a symlink and if so delete it
			set {theResult, theValue} to (aSubLib's getResourceValue:(reference) forKey:(NSURLIsSymbolicLinkKey) |error|:(missing value))
			if theValue as boolean then
				(fileManager's removeItemAtURL:aSubLib |error|:(missing value))
			end if
		end repeat
	end if
end repeat
4 Likes

Thanks, Shane. This is very help. Evernoted for future ref.

1 Like