#macos On macOS, you can run a background job on a timed schedule in several different ways, although some of them are deprecated: - `launchd` - `cron` - `at` jobs (_deprecated_) - `periodic` jobs (_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: > [!info] **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: ```python 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_](https://superwhisper.com/?ref=akatz.org) _for dictation which outputs a transcript of my voice recording as text files to my_ [_Obsidian_](https://obsidian.md/?ref=akatz.org) _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 <?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: | Key | Behavior | |------------------|----------------------------------------------------| | Label | name/label of your Launch Agent | | Program Arguments| path to the executable script or binary + args | | Start Interval | run job every _n_ seconds | | RunAtLoad | run 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: | | sleep | powered off | | ------- | ------------------------- | ------------------------- | | launchd | run @computer wakeup | run @next designated time | | cron | run @next designated time | run @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 key | behavior | | --------------------- | --------------------------------- | | StartInterval | run job every _n_ seconds | | StartCalendarInterval | run 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 `md`, `WatchPaths` 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 - The preferred way to run a scheduled job on macOS is using `launchd`, where you create a Launch Agent to run your job. - You can still run jobs with `cron` on macOS, but your options are way more limited (i.e., can't run things based on triggers and less survivability of your job based on power states/missed jobs) - If you want to learn more about `launchd` and the other things you can do with it, check out this amazing site: [https://launchd.info/](https://launchd.info/?ref=akatz.org) ## 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](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html?ref=akatz.org) 2. A launchd Tutorial A launchd primer covering configuration, administration and troubleshooting. Complete with examples. [https://launchd.info/](https://launchd.info/?ref=akatz.org)