Scripting macOS with Javascript Automation
Tags: applescript • Categories: Software
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 whenvalue()
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 toundefined
. - 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, whileerr
is reference argument passed asNone
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
- Best group open source example scripts I could find: https://github.com/voostindie/vincents-productivity-suite-for-alfred
- Not sure why, but this forum has a lot of good sample code to copy from. https://forum.keyboardmaestro.com
- https://apple-dev.groups.io/g/jxa/wiki/3202
- Some helpful snippets & usage examples https://gist.github.com/heckj/5b7bb332463a762639e179a37ea3a216
- Official Apple release notes which a nice group of snippets.
- A great technical deep dive with links to many interesting resources