MacOS Launchd

Launchd in MacOS is kind of combination of systemctl and crontab

There are system and user services, we are interested in user services only

Each service configuration file lives here: ~/Library/LaunchAgents/*.plist

"plist" - stands for "properties list" and is good old XML file (yes, not YAML)

Here is an example of such file to run powershell script on schedule

<?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>sample</string>

    <key>ProgramArguments</key>
    <array>
      <string>/usr/local/bin/pwsh</string>
      <string>/Users/mini/Desktop/sample/sample.ps1</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/mini/Desktop/sample</string>

    <key>EnvironmentVariables</key>
    <dict>
      <key>FOO</key>
      <string>bar</string>
    </dict>

    <key>StartCalendarInterval</key>
    <array>
      <dict>
        <key>Weekday</key>
        <integer>1</integer>        <!-- Monday -->
        <key>Hour</key>
        <integer>14</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
      <dict>
        <key>Weekday</key>
        <integer>5</integer>        <!-- Friday -->
        <key>Hour</key>
        <integer>14</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
    </array>

    <key>StandardOutPath</key>
    <string>/Users/mini/Desktop/sample/sample.log</string>

    <key>StandardErrorPath</key>
    <string>/Users/mini/Desktop/sample/sample.log</string>
  </dict>
</plist>

Notes:

  • no $PATH defined
  • no user environment variables defined
  • usually Label contains inversed FQDN, aka ua.org.mac-blog.sample, and file name is named ua.org.mac-blog.sample.plist
  • carefully read man launchd.plist to see what you can configure in plist, it is not so big but quite interesting and has all the answers

If you decide to store files somewhere else you can always symlink them:

ln -s ~/Desktop/sample/sample.plist ~/Library/LaunchAgents/sample.plist

Once file is in place you can enable/disable it like so:

launchctl load ~/Library/LaunchAgents/sample.plist
launchctl unload ~/Library/LaunchAgents/sample.plist

aka it is the same as systemctl enable sample

Note: there are also start and stop commands, start is usefull for cronjobs, like this:

launchctl start ~/Library/LaunchAgents/sample.plist

To list loaded agents use:

launchctl list
launchctl list ua.org.mac-blog.sample

This one may be usefull to see if agent is running or not and what was exit code.

Here is one more example for dotnet service

<?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>
    <!-- Basic service information -->
    <key>Label</key>
    <string>cflog</string>

    <key>LimitLoadToSessionType</key>
    <array>
      <string>Aqua</string>
      <string>Background</string>
      <string>LoginWindow</string>
      <string>StandardIO</string>
      <string>System</string>
    </array>

    <!-- Command to execute and its arguments -->
    <key>ProgramArguments</key>
    <array>
      <string>/Users/mini/.dotnet/dotnet</string>
      <string>run</string>
    </array>

    <!-- Environment variables if needed -->
    <key>EnvironmentVariables</key>
    <dict>
      <key>ASPNETCORE_URLS</key>
      <string>http://+:5000</string>
    </dict>

    <!-- Working directory for the web server -->
    <key>WorkingDirectory</key>
    <string>/Users/mini/Desktop/cflog</string>

    <!-- Run configuration -->
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>

    <!-- Log files -->
    <key>StandardOutPath</key>
    <string>/Users/mini/Desktop/cflog/stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/mini/Desktop/cflog/stderr.log</string>
  </dict>
</plist>

Note: brew services list actually doing something similar, and copying files from /opt/homebrew/Cellar/traefik/3.3.4/homebrew.mxcl.traefik.plist to ~/Library/LaunchAgents/homebrew.mxcl.traefik.plist

For convenience here is small script to make life little bit easier:

#!/usr/bin/env bash

list() {
  printf "%-8s %-8s %s\n" "PID" "STATUS" "LABEL"
  for f in ~/Library/LaunchAgents/*.plist
  do
    label=$(plutil -convert json -o - $f | jq -r '.Label')
    if [ "$label" == "null" ]
    then
      continue
    fi
    pid=$(launchctl list | grep $label | cut -f1)
    # if empty set to -
    if [ -z "$pid" ]
    then
      pid="-"
    fi
    status=$(launchctl list | grep $label | cut -f2)
    if [ -z "$status" ]
    then
      status="-"
    fi
    printf "%-8s %-8s %s\n" $pid $status $label
  done
}

enable() {
  if [ -f ~/Library/LaunchAgents/$1.plist ]
  then
    launchctl load ~/Library/LaunchAgents/$1.plist
  else
    echo "File '~/Library/LaunchAgents/$1.plist' does not exist"
    exit 1
  fi
}

disable() {
  if [ -f ~/Library/LaunchAgents/$1.plist ]
  then
    launchctl unload ~/Library/LaunchAgents/$1.plist
  else
    echo "File '~/Library/LaunchAgents/$1.plist' does not exist"
    exit 1
  fi
}

start() {
  if [ -f ~/Library/LaunchAgents/$1.plist ]
  then
    launchctl start ~/Library/LaunchAgents/$1.plist
  else
    echo "File '~/Library/LaunchAgents/$1.plist' does not exist"
    exit 1
  fi
}

case $1 in
  list)
    list
    ;;
  enable)
    enable $2
    ;;
  disable)
    disable $2
    ;;
  start)
    start $2
    ;;
  *)
    echo "Usage:"
    echo "services list"
    echo "services enable nginx"
    echo "services disable nginx"
    exit 1
    ;;
esac

There is an GUI application - LaunchControl

And as you can guess it is quite easy to create some small web ui to manage the thing