Being able to share code between files is a great way to put programming logic “in its right place”, and prevent single files from containing hundreds or thousands of lines of code.

A very basic example of sharing code in Python could be having a directory called code/, and in it, a file called greetings.py. This file contains very important business logic about how to say “hello”:

code/greetings.py

def hello():
    print("Hello there!")

Now, say I have a greeter.py file in the same directory, who has no idea how to say “hello”, and wants to leverage the specialised knowledge its neighbour file has on how to do it. It can do so easily by importing the hello function from the greetings file, and using it:

code/greeter.py

from greetings import hello

hello()

Running the greeter program outputs what you would expect:

$ python code/greeter.py
Hello there!

The from greetings import hello line is able to find the greetings file thanks to Python’s sys.path, a “list of strings that specifies the search path for modules”, which includes the directory of the script being run: in this case, the code/ directory. Makes sense.

Many programming languages have similar mechanisms to allow sharing code in simple, unobstructive ways. AppleScript can share code, but certainly not in an intuitive way like Python. The extra steps required to do so compelled me to make a note of them somewhere, in order to not have to scour the internet to figure this out again.

So, I will illustrate this sharing process by refactoring out handlers (read: functions) into separate files using an example from my stenography dictionaries, where I have employed AppleScript to control my Mac using stenographic chords (don’t worry, the stenography context here is not important).

Contextual Refreshing

I have an AppleScript file that performs a keyboard shortcut for a “refresh”.

The most common use case for a “refresh” on a computer would probably be refreshing a browser window, and its keyboard shortcut on macOS is ⌘R (Command-R). Many other applications use the same ⌘R shortcut for their own interpretation of “refresh”, so contextually, it is quite a safe one to use.

However, when I have the very specific use case of using the Vim text editor in an iTerm2 terminal, I need a “refresh” to mean “refresh the ctrlp.vim fuzzy file finder’s cache, so it picks up the existence of any new files”, and the shortcut for that is F5 (Function Key-5).

So, the script needs to figure out what current the “active” application is, and then “press” the appropriate keyboard shortcut (either ⌘R, or F5). Here is what that looks like in my code:

src/command/actions/refresh.applescript

on run
  set activeApp to getActiveApp()

  if activeApp is "iTerm2" then
    performiTerm2Refresh()
  else
    performRefresh(activeApp)
  end if
end run

on performiTerm2Refresh()
  set processName to getiTermProcessName()

  if processName contains "vim" then
    performVimRefresh()
  else
    display notification "Nothing to refresh." with title "Error"
  end if
end performiTerm2Refresh

on performVimRefresh()
  tell application "System Events" to tell process "iTerm2"
    # 96 = F5
    key code 96
  end tell
end performVimRefresh

on performRefresh(activeApp)
  tell application "System Events" to tell process activeApp
    keystroke "r" using {command down}
  end tell
end performRefresh

on getActiveApp()
  tell application "System Events"
    return name of first application process whose frontmost is true
  end tell
end getActiveApp

on getiTermProcessName()
  tell application "iTerm2"
    return name of current session of current window
  end tell
end getiTermProcessName

In this file there are six handlers, with the on run handler at the top being the entry point for when the script is run. The first four handlers contain code that is specific to “refreshing”, but the final two handlers, getActiveApp() and getiTermProcessName(), contain code that is general enough that other scripts could leverage them. Therefore, they are the perfect candidates for extraction into some other file, where they can be shared.

Let’s remove them from refresh.applescript, and put them into a “utilities” file:

src/command/actions/util.applescript

on getActiveApp()
  tell application "System Events"
    return name of first application process whose frontmost is true
  end tell
end getActiveApp

on getiTermProcessName()
  tell application "iTerm2"
    return name of current session of current window
  end tell
end getiTermProcessName

Okay, so now the big question: how can refresh.applescript use the code that now lives in util.applescript?

Creating Shared Libraries

AppleScript cannot just reach into neighbouring files with a line like from util import getActiveApp. What needs to occur is the metamorphosis of the utilities script into what AppleScript calls a Script Library, which involves:

  • Creating a compiled version of the script with the osacompile command line tool (the compiled script will have a .scpt file extension, instead of .applescript)
  • Putting the compiled script in a designated “Script Libraries” folder, whose locations are numerous (see previous Script Library link), but the one I have seen cited most often, and that did work for me, is in the user Library directory, specifically: ~/Library/Script Libraries/

After those steps are done, we can use the utility handlers again, so let’s give it a shot!

First, create the compiled script:

osacompile -o util.scpt util.applescript

Now, move the newly created util.scpt script to the Script Libraries directory. Since that directory gets used by other programs as well, let’s silo the file in its own directory called steno-dictionaries:

mkdir -p ~/Library/Script Libraries/steno-dictionaries
mv util.scpt ~/Library/Script Libraries/steno-dictionaries

Now, we can change refresh.applescript to use the handlers in the newly-minted Script Library:

src/command/actions/refresh.applescript

property Util : script "steno-dictionaries/util"

on run
  set activeApp to Util's getActiveApp()

  # ...
end run

on performiTerm2Refresh()
  set processName to Util's getiTermProcessName()

  # ...
end performiTerm2Refresh

# ...

Done! Since Shared Libraries are compiled, this enables us to reference them as a static Property (here named Util), allowing for commands to be sent to it using the possessive syntax ('s).

Shared Libraries at Scale

The example above is all well and good for compiling a single Shared Library, but performing those commands for multiple files gets tiresome quite quickly.

In order to automate this in my steno-dictionaries repo, I wrote some shell scripts (that live in its bin/ directory) that “bootstrap” the process of making the AppleScript code in the repository ready to use after being cloned. They ensure that running one command (./bin/bootstrap) will, in the following order:

  • Create a ~/Library/Script Libraries/steno-dictionaries directory
  • Compile all AppleScript files that will become Script Libraries into .scpt files
  • Move the Script Library .scpt files to ~/Library/Script Libraries/steno-dictionaries
  • Then, compile all other AppleScript files that reference the Script Libraries (but are not, themselves, Script Libraries) into .scpt files

(I think running .scpt files is faster than running .applescript files…maybe…I cannot seem to find a conclusive answer on the internet…).

The .scpt scripts are executed by shell commands that run osascript commands, which are contained in steno chord entries in the repo’s commands directory. The one that runs the “refresh” script looks like this:

bash -ci 'osascript $STENO_DICTIONARIES/src/command/actions/refresh.scpt'

The shell commands run in interactive mode for reasons.

Caring about Sharing

I really wish that sharing code in AppleScript was not as complex as it currently is, but I do not see that changing at all, assuming that AppleScript itself even survives into the future.

The revamped Apple Developer site would seem to ignore AppleScript’s existence altogether (all the documentation links used in this post seem to come from the archive, implying they are now legacy and unmaintained…), but I do not see any alternative candidate language being put forward for macOS system automation programming.

Personally, I would be happy to change everything I have written into Swift, if that was possible. But, for now, I need AppleScript, and if you do too, hopefully this post has been able to serve as some reference.

Comments