Table of contents
Open Table of contents
Intro
I needed to run a scheduled task periodically on macOS, and this is how I did it with launchd
. At the time of writing I am on macOS Sequoia 15.1.
The task itself is a simple backup shell script.
About launchd
Launchd
comes builtin with macOS, and it is an open source service management framework. It is responsible of starting, stopping and managing daemons and agents. This little thing is the first process the kernel runs on system start up, which then starts all the deamons and other apps running automatically when macOS starts.
To interface with launchd, we need to use the CLI app launchctl
.
Create the shell script to run
The task I wanted to run periodically is a simple backup script, which copies one file from source location to destination with timestamped name.
#!/bin/bash
source_file="/Users/me/Library/Application Support/App/content.txt"
# Get current date and time formatted as 2024-11-10_17-01-02
timestamp=$(date +%Y-%m-%d_%H-%M-%S)
target_folder="/Users/me/backups/App"
cp "$source_file" "$target_folder/content_$timestamp.txt"
I saved this file as /Users/me/backups/scripts/app-backup.sh
and made sure it is executable with chmod +x /Users/me/backups/scripts/app-backup.sh
.
This script of course could be anything, but let’s stick with this simple example.
Note: The script will run outside of your “normal” shell context / session, e.g. anything you do in .bashrc
will not be loaded.
Define the agent task
Tasks are defined in plist
format, which is a specific definition on top of xml. The key difference between an agent and a daemon is that an agent runs on behalf of logged in user while a daemon runs on behalf of root.
I want to run the task as a logged in user, while the laptop is on.
<?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>net.janik6n.backups</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/me/backups/scripts/app-backup.sh</string>
</array>
<key>StandardErrorPath</key>
<string>/Users/me/backups/logs/app-backup-error.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>MY_KEY</key>
<string>hey</string>
</dict>
<key>StartInterval</key>
<integer>3600</integer>
</dict>
</plist>
The format is a rather simple key-value based file. These definitions can contain a lot more than I needed here. To walk through each key:
Label
: this is the unique identifier for the task. It can be pretty much anything, but a common naming convention is a sensible guide to follow.net.janik6n.backups
is just my namespaced value I used here.ProgramArguments
: We are running a bash script, the script’s location given as argument.StandardErrorPath
: Error log file location.EnvironmentVariables
: Provide environment variables as key-value pairs. Not needed in this use case, but can be useful.StartInterval
: How often to run the task, in this case every hour (3600 seconds). This could be calendar-based schedule too with keyStartCalendarInterval
instead.
The file need to be saved to a specific location depending on what context the task needs to run:
/System/Library/LaunchDaemons
: Apple-supplied system daemons/System/Library/LaunchAgents
: Apple-supplied agents that apply to all users on a per-user basis/Library/LaunchDaemons
: Third-party system daemons/Library/LaunchAgents
: Third-party agents that apply to all users on a per-user basis~/Library/LaunchAgents
: Third-party agents that apply only to the logged-in user
Since the last option is the one I want, and most likely is always what should be used, I saved the file as ~/Library/LaunchAgents/net.janik6n.backups.plist
.
Launch the agent task
Now that everything is in place, the agent can be started with launchctl
by running:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/net.janik6n.backups.plist
Where
gui
means that agent will run as the given user, but only be active when the user is logged in to the graphical user interface (and while the computer is sleeping)id -u
returns the current user id (most likely 501)
Note: If agent with same “name” already exists, it must be enabled before it can be bootstrapped again.
And just like that I had the backups automated!
Other useful launchctl commands
There are few other launchctl
commands, which are necessary to know.
List launch agents:
launchctl print gui/$(id -u) | grep "janik6n"
Remove agent task:
# Unload launch agent
# Note: Unloaded agent will run again after reboot!
launchctl bootout gui/$(id -u) net.janik6n.backups
# Disable launch agent
# Note: Disabled agent will not run after reboot, until it is enabled
launchctl disable gui/$(id -u)/net.janik6n.backups
# Enable launch agent
# Note: will not run until bootstrapped again or computer is rebooted
launchctl enable gui/$(id -u)/net.janik6n.backups
So, always bootout & disable, so that the launch agent will not start again after reboot unless you want to disable a task temporarily.