Scheduled Scripts on macOS

On macOS,you can run a background job on a timed schedule in several different ways, although some of them are deprecated:

If you are new to working on Macs and need to run a scheduled task, your first instinct might be to reach for cron, although this is not necessarily the right tool for the job.

According to Apple’s archived documentation on scheduling timed jobs:

The preferred way to add a timed job is to use launchd […] Although it is still supported, cron is not a recommended solution. It has been deprecated in favor of launchd. [1]

In this post, I will outline some examples of where launchd indeed outshines cron.

Scenario

To start, let’s say you have a Python script called convert_txt_to_md.py that points at a path and recursively goes through all the directories and changes all the text files to markdown:

import os

directory = '/Users/andrew.katz/notes/superwhisper/recordings'

for root, dirs, files in os.walk(directory):
    for file in files:
        if file.endswith(".txt"):
            txt_path = os.path.join(root, file)
            md_path = txt_path[:-4] + '.md'
            os.rename(txt_path, md_path)

For context, I use superwhisper for dictation which outputs a transcript of my voice recording as text files to my Obsidian vault (/notes). Since Obsidian can’t search text files, I convert them to markdown using this script which allows Obsidian to natively search for them.

![[Pasted image 20240811131454.png]]

To create a scheduled job using launchd, you need to create a Launch Agent. Launch Agents run once the user has logged in with standard user permissions, and they may interact with the user session. Third-party launch agents live in /Library/LaunchAgents or ~/Library/LaunchAgents.

Creating your Launch Agent involves creating a property list (plist) file that you will add to ~Library/LaunchAgents. A plist is an XML, JSON, or binary file that contains key/value pairs that store configuration information, settings, serialized objects, and more.

Plists are usually named with com.companyName.itemName, so we are going to go with com.ackatz.converttxttomd as the name/label.

Using nano ~/Library/LaunchAgents/com.ackatz.converttxttomd.plist, I create the plist using the following information:

<?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>com.ackatz.converttxttomd</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/python3</string>
        <string>/Users/andrew.katz/convert_txt_to_md.py</string>
    </array>
    <key>StartInterval</key>
    <integer>3600</integer> <!-- Runs every hour -->
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>

I find plists a lot easier to read than cron expressions/crontab syntax

To describe some of the keys above:

KeyBehavior
Labelname/label of your Launch Agent
Program Argumentspath to the executable script or binary + args
Start Intervalrun job every n seconds
RunAtLoadrun job one time at load (in addition to other schedules)

Finally, to tell launchd that it needs to start using our Launch Agent, we run the following command:

launchctl load ~/Library/LaunchAgents/com.ackatz.converttxttomd.plist

To check that it has been added:

❯ launchctl list | grep ackatz
-	0	com.ackatz.converttxttomd
831	0	application.ackatz.seclook

[!info] seclook is a security lookup app that I wrote.. check it out!: https://seclook.app

- for the PID tells us that our Launch Agent does not have a continuously running process associated with it, which makes sense.

0 means that it ran successfully the last time it was invoked, which is good.

launchd Advantages over cron

Besides the fact that launchd offers significantly more options to constrain and schedule your job [2], one interesting edge that launchd has over cron is related to how job scheduling is handled depending on power state:

sleeppowered off
launchdrun @computer wakeuprun @next designated time
cronrun @next designated timerun @next designated time

[!info] 💡 If your machine is always powered off at the designated time, your script is not going to run.

If you were reaching for cron in the first place, it is likely that the scheduling key you will use in your Launch Agent plist is going to either be StartCalendarInterval or StartInterval.

scheduling keybehavior
StartIntervalrun job every n seconds
StartCalendarIntervalrun job at a specific time/date

Let’s say that you decided to use StartInterval with an interval of 30 seconds. If your machine is in a sleep state and missed 100 invocations, it will coalesce all of those missed jobs together until it wakes up, invoking it just once.

When using StartCalendarInterval, if you missed the scheduled start time due to the machine being asleep, it will just run the job as soon as it wakes up.

It’s worth mentioning that launchd has so many more scheduling options than just the two above. Consider the convert_txt_to_md.py example from above. There is a scheduling option called WatchPaths that you can point at a path/directory/file and your job will run any time that changes are detected on the path.

<key>WatchPaths</key>
<array>
    <string>/path/to/directory_or_file</string>
</array>

Instead of waiting potentially an hour for the txt to be changed to mdWatchPaths runs the job as needed. In my testing, it usually occurred either instantly or within a few seconds. This would have made a lot more sense to use in this scenario, so I’ve switched to it 😄

This is what it looks like in a Launch Agent plist:

<?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>com.ackatz.converttxttomd</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/python3</string>
        <string>/Users/andrew.katz/convert_txt_to_md.py</string>
    </array>
    <key>WatchPaths</key>
    <array>
        <string>/Users/andrew.katz/notes/superwhisper/recordings</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>

Summary

Sources & Further Reading

  1. Scheduling Timed Jobs
    Explains how to write background processes that perform work on behalf of applications or serve content over the network.
    https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html
  2. A launchd Tutorial
    A launchd primer covering configuration, administration and troubleshooting. Complete with examples.
    https://launchd.info/