Scripting macOS with Javascript Automation

I've been playing with ActivityWatch, a really neat open-source application to track what you are doing when you are on your computer. Similar to rescue time RescueTime, but open source, with some more advanced features. I've been using it for a couple of months as part of my digital minimalism toolkit and it's worked great to give me an idea of what's taking up my time.

There's been a couple of things that have bugged me about the application, and it's written in a couple of languages I've wanted to learn (Rust, Vue), so I decided to make a couple of changes as part of a learning project.

I ended up needing to modify an AppleScript and ran into macOS's Javascript Automation for the first time. It's a really powerful system but horribly documented, with very little open source code to learn from.

Retrieving the Active Application, Window, and URL using AppleScript

I wanted to extract the active application, title of the main window, and the URL of that window (if the active application is a browser). I found this AppleScript, which was close to what I wanted, but I also wanted to identify the main window if a non-browser was in use:

global frontApp, frontAppName, windowTitle

set windowTitle to ""
tell application "System Events"
    set frontApp to first application process whose frontmost is true
    set frontAppName to name of frontApp
    tell process frontAppName
        try
            tell (1st window whose value of attribute "AXMain" is true)
                set windowTitle to value of attribute "AXTitle"
            end tell
        end try
    end tell
end tell

do shell script "echo " & "\"\\\"" & frontAppName & "\\\",\\\"" & windowTitle & "\\\"\""

Here's what combining these two scripts looks like in Javascript for Automation:

 var seApp         = Application("System Events");
 var oProcess      = seApp.processes.whose({frontmost: true})[0];
 var appName       = oProcess.displayedName();

 // these are set to undefined for a specific reason, read more below!
 var url = undefined, incognito = undefined, title = undefined;

 switch(appName) {
   case "Safari":
     url = Application(appName).documents[0].url();
      title = Application(appName).documents[0].name();
     break;
   case "Google Chrome":
   case "Google Chrome Canary":
   case "Chromium":
   case "Brave Browser":
     const activeWindow = Application(appName).windows[0];
     const activeTab = activeWindow.activeTab();

     url = activeTab.url();
     title = activeTab.name();
     break;
   default:
     mainWindow = oProcess.
       windows().
       find(w => w.attributes.byName("AXMain").value() === true)

     // in some cases, the primary window of an application may not be found
     // this occurs rarely and seems to be triggered by switching to a different application
     if(mainWindow) {
       title = mainWindow.
         attributes.
         byName("AXTitle").
         value()
     }
 }

 JSON.stringify({
   app: appName,
   url,
   title,
   incognito
 }); 

Some notes & learnings that help explain the above code:

  • You can write & test JXA from the "Script Editor" application. You can connect the script editor to Safari for a full-blown debugger experience, which is neat.
  • Open up a REPL via osascript -il JavaScript
  • There's not really an API reference anywhere. The best alternative is Script Editor -> File -> Open Dictionary.
  • The javascript runtime is definitely heavily modified: Object.getOwnPropertyNames returns __private__ for all of the system events-related objects. This makes it much harder to poke around in a repl to determine what methods are available to you.
  • Use #!/usr/bin/osascript -l JavaScript at the top of your jxa to run a script directly in your terminal.
  • whose only seems to work with properties, not attributes. If you want to filter on attributes you need to iterate over each element: windows().find(w => w.attributes.byName("AXMain").value() === true)
  • Application objects seem to use some sort of query ORM-type model underneath the hood. The application only seems to execute queries when value() or another value is requested, otherwise you'll just get a reference to the query that could retrieve the object. This makes it harder to poke at the objects in a repl.
  • If you compile a script once and rerun it, you must reset your variables to undefined otherwise the values they were set to will stick around. This is why all var declarations above are set to undefined.
  • You can import objc libraries and use them in your JXA.

It's worth noting that some folks online mention that JXA is dead, although not deprecated. I think this is a general state on macOS scripting (including AppleScript): Apple has built some very neat technologies but has done a horrible job at continuing to develop and evangelize them so they have many sharp edges and there is sparse documentation out there.

Executing Javascript Automation Scripts from Python

A powerful aspect of the python ecosystem is PyObjc which enables you to reach into the macOS Objective-C APIs within a python script. In this case, this allows you to compile & run applescript/javascript from within a python script without shelling out to osascript. This improves performance, but also makes it much easier to detect errors and parse output from the script.

The snippet below was adapter from this StackOverflow post and requires that you pip install pyobjc-framework-OSAKit :

script = None

def compileScript():
    from OSAKit import OSAScript, OSALanguage

    scriptPath = "path/to/file.jxa"
    scriptContents = open(scriptPath, mode="r").read()
    javascriptLanguage = OSALanguage.languageForName_("JavaScript")

    script = OSAScript.alloc().initWithSource_language_(scriptContents, javascriptLanguage)
    (success, err) = script.compileAndReturnError_(None)

    # should only occur if jxa is incorrectly written
    if not success:
        raise Exception("error compiling jxa script")

    return script

def execute():
    # use a global variable to cache the compiled script for performance
    global script
    if not script:
        script = compileScript()

    (result, err) = script.executeAndReturnError_(None)

    if err:
        raise Exception("jxa error: {}".format(err["NSLocalizedDescription"]))

    # assumes your jxa script returns JSON as described in the above example
    return json.loads(result.stringValue())

Here's the structure of an AppleScript err after executing the script:

{
  NSLocalizedDescription = "Error: Error: Can't get object.";
  NSLocalizedFailureReason = "Error: Error: Can't get object.";
  OSAScriptErrorBriefMessageKey = "Error: Error: Can't get object.";
  OSAScriptErrorMessageKey = "Error: Error: Can't get object.";
  OSAScriptErrorNumberKey = "-1728";
  OSAScriptErrorRangeKey = "NSRange: {0, 0}";
}

Here are some tips and tricks for working with pyobjc in python:

  • Always pass None for objc reference arguments. References are returned in a tuple instead. You can see this in the above code ((result, err) = script.executeAndReturnError_(None)): result is the return value of the method, while err is reference argument passed as None in
  • : is replaced by _ in the method signatures
  • There's a separate package for each objc framework. Import only what you need to avoid application bloat.
  • Objc keyword arguments are transformed into positional arguments, not python keyword arguments.
  • I ran into weird initialization errors if I had pyobj calls in the global namespace (for instance, caching the script immediately as opposed to setting script = None). I'm not sure if this was specific to how the rest of the application I was working in was structured.

Resources

Here are some helpful resources I ran into when