07 December 2017

Automation + Scheduling file and application opening...

How do you automate the scheduling of a script/application launching (or any kind of file opening)?

macOS provides at least two solutions when it comes to scheduling file opening and application launching. One is the "
open file alert" thing in Calendar and the other is launchd.

If you don't need to automate the creation of open file alerts and have no problem creating them by hand, you'll do just fine with Calendar. There are tons of articles on the web that describe how to do it. But that is not the subject of this article...



The problem with Calendar


As I discovered over the course of the last weekend when I (at long last) decided to automate my invoicing by programmatically creating invoice launch triggers for each job that I created with my usual script, it is not possible to script Calendar to create proper open file alerts.

Since Calendar became a sandboxed application (which is a good thing), it is not possible anymore to specify the path to the file you want to open with AppleScript. Specifically, the problem is the following: when you check Calendar's dictionary in your script editor, you can see that an open file alert object has three properties: a filepath, a trigger date and a trigger interval. You can set the last two with Applescript, but setting filepath is not allowed. You just can't. Period. And it's not even worth wasting time trying.

In fact, Calendar does not even return the filepath of an open file alert that you'd have successfully created manually. The only thing that's returned is an empty string. The result is that you can only partially script the creation of the open file alert, without the most important part, which is specifying the file that you want to open at the specified time. So you're back to manually completing the creation of the alert, which defeats the purpose of automating this task in the first place.


The solution? launchd!


Calendar being out of the picture, the only solution that remains is launchd.
According to people who know, launchd is extremely powerful, and I'm only going to very slightly scratch the surface of what it can do here, which is still sufficient for what we want to accomplish: create a thing that launches the application/script of your choice at the time you specified and using that creation process in a script.

launchd is not just a process launcher, it is the process launcher on macOS. In fact, launchd is the very first process that is started when you launch your Mac. Once it has launched, it launches, or loads for later launching, all the processes that Mac needs to run properly. launchd also has a number of other roles that go beyond what it relevant here and what I can understand of my machine's internals...

Using launchd to manually schedule processes is very different from doing so in Calendar. launchd only works from the command line, its settings require manipulating XML based plist files, and such files require knowledge of a number of terms specific to the launchd format. For our purpose launchd requires what is called a "launch agent".

Launch agents define the actions to take and their timing in XML Property List files (plist). User created agents are located in ~/Library/LaunchAgents and are "loaded" by launchctl which is the only way the user can interact with the agents and with launchd.

To automate the creation of the equivalent of Calendar's "open file alert", we'll need to create an agent that would look like:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.mac4translators.launchd.test</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/osascript</string>
<string>~/MyScripts/mypresents.scrpt</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Day</key>
<integer>25</integer>
<key>Hour</key>
<integer>00</integer>
<key>Minute</key>
<integer>00</integer>
<key>Month</key>
<string>December</string>
</dict>
</dict>
</plist>

Then we need to load it, and unload it when it has accomplished its mission.

Using
launchd from AppleScript is not very different from scripting Calendar. It only involves a different set of objects, but not different concepts.

The following code, adapted from what can be found on the web already, creates the agent above and puts it in the location from where we will be able to load it in
launchd. The comments should be sufficient for you to understand what is going on and to create your own launch agents. The highlighted parts are the parts where we'll need to adapt contents so as to truly automate the creation of other agents.

use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions

# Create the file path where the launchd agent plist will be created. As written in the man page, it is a convention that the name of the file should be Label.plist. See below for what "Label" is.
set the launch_agent_path to "~/Library/LaunchAgents/org.mac4translators.launchd.test.plist" #

# System Events and it's plist suite are the tools we need to create the plist itself
tell application "System Events"
# Create an empty property list
set the launch_agent to make new property list item with properties {kind:record}
# Create a new property list file using the empty launch_agent as contents and the launch_agent_path as location.
set this_launch_agent to make new property list file with properties {contents:launch_agent, name:launch_agent_path}
# Now we work inside the property list, that starts empty
tell property list items of this_launch_agent
# Add new property list items consecutively, from the end of the file. The first item is "Label", which should be a unique identifier: you can add a date, or a job name to make it truly unique. As written above, the "Label" value should be the name of the file before the .plist extension
make new property list item at its end with properties {kind:string, name:"Label", value:"org.mac4translators.launchd.test"} #
# The second item is the arguments to the command that will launch your program. The command is "exec", so in the present case, think of launchd as really launching "exec /usr/bin/osascript ~/MyScripts/mypresents.scrpt", which ends up running the mypresents.scrpt script (check "man osascript" for more information on running your scripts from the command line).
make new property list item at its end with properties {kind:record, name:"ProgramArguments", value:{"/usr/bin/osascript", "~/MyScripts/mypresents.scrpt"}} #
# The third item is the date/time you want the thing launched. Notice that this is the "Start Calendar Interval" value. It is not a unique date which is the reason why the "Year" argument is not supported. Your agent will thus run at least once a year, unless it is unloaded after having accomplished its mission. "Month" and "Day" are put between vertical bars to avoid conflict with AppleScript because AppleScript thinks they are AS terminology, but here they're only XML values in the plist file.
make new property list item at its end with properties {kind:list, name:"StartCalendarInterval", value:{|Month|:"December", |Day|:25, Hour:0, Minute:0}} #
end tell
end tell

When you run this code, you should get an XML plist that looks like the one above.

So, we now have a way to programmatically create our launch agent. But to create a new one we'll have to edit the above code and change the highlighted parts, which is still way more complicated than editing a plain XML plist file. That's where real automation of the process comes into play.

What we need to feed the script is the following:

  1. a unique "label" for the agent, used in above
  2. the thing to launch (as we'd launch it from the command line), used in above
  3. the trigger date/interval,  used in above
So, now the script becomes:

use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions

# We'll need to work with specific text items delimiters later so let's store the defaults in a safe place
set savedTextDelimiters to AppleScript's text item delimiters

# We need 3 items to create the Launch Agent:
# 1) A unique label. You can use a job name that you fetch from another script.
set agent_label to text returned of (display dialog "Launch Agent Label?" default answer (short user name of (system info)) & "." & (time of (current date) as text))
set the launch_agent_path to "~/Library/LaunchAgents/" & agent_label & ".plist"

# 2) The command to launch your application/script, as you'd type it in Terminal. Anything that goes on the command line will work. For ex: open -a "Microsoft Word" ~/Documents/myInvoiceTemplate.docx
set agent_command to text returned of (display dialog "Launch Agent Command?" default answer "osascript ~/MyAppleScripts/myInvoicingScript.scrpt")
set AppleScript's text item delimiters to {" "}
set agent_parameters to get text items of agent_command

# 3) The trigger date. "Year" is not something that you can set, so just leave it out.
set agent_trigger_date to text returned of (display dialog "Launch Agent Trigger Date?" default answer "mm/dd/hr/mn")
set AppleScript's text item delimiters to {"/"}
set agent_trigger_date_fields to get text items of agent_trigger_date

# And we return the text delimiters to their default value
set AppleScript's text item delimiters to savedTextDelimiters

# Now we ask System Events and it's plist suite to create the plist itself and to fill it with the data we just fed it. The code being the same as above, comments are left out.

tell application "System Events"
set the launch_agent to make new property list item with properties {kind:record}
set this_launch_agent to make new property list file with properties {contents:launch_agent, name:launch_agent_path}
tell property list items of this_launch_agent
make new property list item at its end with properties {kind:string, name:"Label", value:agent_label}
make new property list item at its end with properties {kind:record, name:"ProgramArguments", value:agent_parameters}
make new property list item at its end with properties {kind:list, name:"StartCalendarInterval", value:{|Month|:item 1 of agent_trigger_date_fields, |Day|:item 2 of agent_trigger_date_fields, Hour:item 3 of agent_trigger_date_fields, Minute:item 4 of agent_trigger_date_fields}}
end tell
end tell

# We have created the launch agent where it can now be loaded into the system to be launched at the date we specified. We just need to make sure everything is fine and then load it with launchctl:

# Open the agent with TextEdit, or change the command to open with your editor of choice.
do shell script "open -e " & launch_agent_path

# Ask whether the agent is fine, and if yes, load it.
tell application "System Events"
tell (first process whose frontmost is true)
set reallyLaunch to button returned of (display alert "Are you fine with this Launch Agent?")
end tell
end tell

if reallyLaunch is "OK" then
set load_agent to "launchctl load " & launch_agent_path
do shell script load_agent
end if

Et voilĂ ! Now we eventually need to separately find a way to regularly check the agents that we created and unload/remove them from ~/Library/LaunchAgents/ after their mission is accomplished... You can for example match the name of the agent to the name of your job and use do shell script to unload and remove the agent once the invoice has been created.

The resulting system is not exactly as elegant as just automating the creation of an "open file alert" in Calendar, but since that is not possible anymore, we don't have much choice.

Update (12/11):
It appears that I had not fully understood the Program key that I had mentioned in the first version of this article. I've removed references to it, but you can still use it in place of  ProgramArguments if your command runs on its own and does not take arguments. The command must be in your path to be discovered by which, but if you've understood the code so far you should be able to change it so that it fits your configuration.

The plist would require the following key:

<key>Program</key>

<string>/Users/[your account]/bin/[your command]</string>

And you'd need the following code (first listing) to generate it:

make new property list item at its end with properties {kind:string, name:"Program", value:"/Users/[your account]/bin/[your command]"} #

Or this code in the second listing:

set agent_command to text returned of (display dialog "Launch Agent Command?" default answer "[your command]")
set AppleScript's text item delimiters to {" "}
set agent_parameters to get text items of agent_command
set agent_program to do shell script "which " & item 1 of agent_parameters
...
make new property list item at its end with properties {kind:string, name:"Program", value:agent_program}


References:
launchd was released in 2005 and included in Tiger that same year. If you want to know more about it, read the following links (pretty much all the places from where I gathered information to write this article):

launchd Wikipedia page:

launchd plist format (man page)

launchd description (man page)

launchctl description (man page)

You can also access the man pages on your local system (
man launchd, man launchctl, man launch.plist)

Apple Developer documentation about launchd:

"Daemons and Agents", Apple Technical Note TN2083

Detailed overview of launchd by Jonathan Levin, who  wrote "the" book on OSX internals:

The chapter from that book that covers launchd (which also happens to be in free access):

A very thorough and very well designed introduction to launchd, by soma-zone, the creators of LaunchControl, "the only full featured launchd GUI for macOS" (I can't download it so I don't know if it is scriptable).

An online tool to easily create launchd agents (and check their syntax before you automate all that with AppleScript):

"How to Use launchd to Run Scripts on Schedule in macOS"

Some AppleScript code to write property lists:

A property list applescript library (not really needed for our purposes, but just so that you know):

Lingon is a FOSS but inactive utility (development has continued on the paid version) that helps you create, visualize and manage the launch agents that run on your machine, it is not applescriptable:

Discussions:
The discussions that triggered the writing of this of this article...

AppleScript official list
https://lists.apple.com/archives/applescript-users/2017/Nov/msg00171.html

AppleScript unofficial list
https://apple-dev.groups.io/g/applescript/topic/7325421#154

Script Debugger user forum
http://forum.latenightsw.com/t/calendar-open-file-alert/839