Ты пришел с Linux и по привычке набираешь crontab -e. Оно даже сработает - cron в macOS формально еще жив. Но это ловушка. Реализация cron в macOS унаследована из BSD, помечена как устаревшая, не интегрирована с системой управления питанием и - главное - почти гарантированно упрется в TCC (Transparency, Consent and Control). Твоя задача по cron не сможет прочитать файлы на Рабочем столе или в Документах, потому что у демона cron нет ни Full Disk Access, ни понятного способа его получить. Скрипт молча отвалится с Operation not permitted, а ты будешь час искать причину.
Правильный способ запуска своих служб и периодических задач на mac - это launchd. Это PID 1, инит-система macOS, аналог systemd по роли, но со своей логикой. launchd умеет: запускать задачу по расписанию (cron-подобно), держать процесс живым (как supervisor), стартовать по событию - появился файл, изменилась папка, поднялась сеть. Все это описывается одним декларативным файлом - property list, или plist. Дальше разберем структуру launchd plist по косточкам, соберем рабочий launchagent (пример - бэкап по расписанию) и пройдемся по граблям, на которых спотыкаются все.

Agents и Daemons: кто, где и от чьего имени
launchd оперирует двумя типами задач, и путать их нельзя.
- LaunchAgent - работает в сессии конкретного пользователя, имеет доступ к графике (Aqua), запускается после логина. Это твой выбор для пользовательских задач: бэкап домашней папки, синхронизация, нотификации.
- LaunchDaemon - работает в системном контексте, до логина, от root (или указанного UserName). Нет доступа к GUI. Для системных служб: сетевой сервис, мониторинг железа.
Код: Выделить всё
~/Library/LaunchAgents/ агенты текущего юзера (грузятся при его логине)
/Library/LaunchAgents/ агенты для ВСЕХ юзеров (грузятся при логине любого)
/Library/LaunchDaemons/ демоны сторонних разработчиков (от root, при загрузке)
/System/Library/... ТОЛЬКО Apple. Защищено SIP. Не трогаем.
Анатомия plist: разбираем каждый ключ
plist - это XML (или бинарь, но руками пишем XML). Минимально жизнеспособная служба требует двух ключей: Label и одного из ProgramArguments/Program. Вот рабочий launchagent пример - ночной бэкап домашней папки в внешний раздел:
Код: Выделить всё
<?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>ru.cyberlake.backup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/khovanskiy/bin/backup.sh</string>
<string>--dest</string>
<string>/Volumes/Backup</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<key>WorkingDirectory</key>
<string>/Users/khovanskiy</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>BACKUP_KEEP</key>
<string>7</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/khovanskiy/Library/Logs/backup.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/khovanskiy/Library/Logs/backup.err.log</string>
<key>RunAtLoad</key>
<false/>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
- Label - уникальный идентификатор службы в домене launchd. По нему ты будешь обращаться к задаче через launchctl. Должен совпадать с базовым именем файла (без .plist) - это не строгое требование, но сильно упрощает жизнь.
- ProgramArguments - массив argv. ПЕРВЫЙ элемент - абсолютный путь к исполняемому файлу, остальные - его аргументы. launchd НЕ зовет шелл, не выполняет ~, $HOME, не делает glob по *. Если напишешь относительный путь или тильду - служба не стартует. Это грабля номер один.
- Program - альтернатива, путь к одному бинарю без аргументов. Если есть ProgramArguments, можно Program не указывать.
- WorkingDirectory - chdir перед запуском. По умолчанию рабочая папка - /, не домашняя. Скрипт, ожидающий относительные пути от $HOME, без этого ключа сломается.
- EnvironmentVariables - окружение процесса. launchd-задача стартует с почти ПУСТЫМ окружением: твой ~/.zshrc, PATH из профиля, exports - ничего этого нет. Поэтому brew, ffmpeg, node из /opt/homebrew/bin будут not found, пока ты явно не пропишешь PATH здесь. Грабля номер два.
- StandardOutPath / StandardErrorPath - куда писать stdout/stderr. Без них вывод уходит в никуда, и ты слеп при отладке. Всегда задавай хотя бы StandardErrorPath. Папка должна существовать и быть доступна на запись от имени задачи.
- RunAtLoad - запустить сразу при загрузке plist, не дожидаясь триггера. Для периодической задачи обычно false (иначе бэкап стартанет прямо при логине). Для постоянной службы - true.
- ProcessType - Background/Standard/Adaptive/Interactive. Background понижает приоритет и дает системе придерживать задачу - корректно для фоновых бэкапов.
Это сердце темы periodic mac и расписание задач mac. Два ключа, разная логика.
StartInterval - запуск каждые N СЕКУНД. Просто и грубо:
Код: Выделить всё
<key>StartInterval</key>
<integer>1800</integer> <!-- каждые 30 минут -->
Несколько расписаний? Передай МАССИВ словарей:
Код: Выделить всё
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Weekday</key><integer>1</integer>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Weekday</key><integer>5</integer>
<key>Hour</key><integer>18</integer>
<key>Minute</key><integer>0</integer>
</dict>
</array>
Запуск по событию: WatchPaths и QueueDirectories
launchd умеет триггерить задачу не по времени, а по файловой системе - cron так не может в принципе.
- WatchPaths - массив путей. Как только содержимое любого из них меняется (изменен файл, права, mtime) - задача запускается. Удобно для "пересобери конфиг, когда я отредактировал исходник".
- QueueDirectories - массив папок. Задача запускается, ПОКА в папке есть файлы, и не запускается, когда папка пуста. Классика - папка-инбокс: кинул файл, обработчик его подхватил и обработал.
Код: Выделить всё
<key>WatchPaths</key>
<array>
<string>/Users/khovanskiy/.config/app/config.yml</string>
</array>
KeepAlive: служба-демон, которую нельзя убить
До сих пор был разовый запуск. А если нужна постоянно живая служба (свой веб-хук, локальный прокси)? Ключ KeepAlive:
Код: Выделить всё
<key>KeepAlive</key>
<true/>
Код: Выделить всё
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
Загрузка и управление: launchctl bootstrap, kickstart, bootout
Старые команды launchctl load/unload объявлены устаревшими. В macOS 2026 (Tahoe и далее) используем современный синтаксис с явным указанием домена.
Домен для агента текущего юзера - gui/$UID, где $UID - твой числовой идентификатор (обычно 501 для первого пользователя). Полный цикл:
Код: Выделить всё
# узнать свой UID (пригодится для домена)
id -u
# 501
# загрузить агент в GUI-домен
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ru.cyberlake.backup.plist
# проверить, что служба видна (вторая колонка - последний код возврата)
launchctl list | grep cyberlake
# - 0 ru.cyberlake.backup
# принудительно запустить ПРЯМО СЕЙЧАС, не дожидаясь расписания
launchctl kickstart -k gui/$UID/ru.cyberlake.backup
# выгрузить службу
launchctl bootout gui/$UID/ru.cyberlake.backup
Для системного демона домен другой - system, и команды от root:
Код: Выделить всё
sudo launchctl bootstrap system /Library/LaunchDaemons/ru.cyberlake.daemon.plist
sudo launchctl kickstart -k system/ru.cyberlake.daemon
Отладка: читаем логи и расшифровываем коды
Когда служба не работает, идешь по цепочке.
Шаг 1 - твои собственные логи из StandardErrorPath:
Код: Выделить всё
log stream --predicate 'process == "backup.sh"' --info
# или просто
cat ~/Library/Logs/backup.err.log
Код: Выделить всё
launchctl print gui/$UID/ru.cyberlake.backup
Шаг 3 - системный лог launchd через unified logging:
Код: Выделить всё
log show --predicate 'subsystem == "com.apple.xpc.launchd"' --last 1h --info
- exit 127 - command not found. Почти всегда пустой PATH в EnvironmentVariables: brew/node/python не найдены. Пропиши полный PATH.
- exit 126 - найден, но не исполняемый. Забыл chmod +x на скрипт.
- exit 78 (EX_CONFIG) - проблема конфигурации скрипта.
- Bootstrap failed: 5: Input/output error - частая ошибка bootstrap. Причины: невалидный XML (проверь plutil), служба уже загружена, кривые права на plist.
- Operation not permitted при чтении файлов - это TCC, не баг скрипта. О нем отдельно ниже.
Код: Выделить всё
plutil -lint ~/Library/LaunchAgents/ru.cyberlake.backup.plist
# ~/Library/LaunchAgents/ru.cyberlake.backup.plist: OK
Вот почему cron на mac мертв, а launchd тоже требует внимания. TCC - подсистема приватности - блокирует доступ к Рабочему столу, Документам, Загрузкам, Фото, внешним томам для процессов без явного разрешения. Твой бэкап-скрипт через launchd упрется в Operation not permitted на ~/Desktop, даже если в Finder ты туда ходишь свободно.
Механика: TCC привязывает разрешение к ПОДПИСАННОМУ исполняемому файлу или к "ответственному процессу". Шелл-скрипт сам по себе подписать нельзя - реальный процесс это /bin/zsh или /bin/bash, и именно интерпретатор просит доступ. Поэтому в Системных настройках -> Конфиденциальность и безопасность -> Полный доступ к диску нужно добавить НЕ скрипт, а бинарь-интерпретатор (или, что чище, обернуть логику в подписанный .app/бинарь).
Практический рецепт для launchagent с доступом к защищенным папкам:
- Дай Full Disk Access на /bin/zsh (или /opt/homebrew/bin/... если зовешь конкретный бинарь). Через перетаскивание в список Полного доступа к диску (Cmd+Shift+G -> /bin/zsh).
- Либо собери задачу как подписанный исполняемый файл и выдай доступ ему - так правильнее для парка машин.
- Бэкапь в путь, не покрытый TCC (свой каталог вне Desktop/Documents), если защищенные данные не нужны - тогда разрешения вообще не потребуются.
Код: Выделить всё
if ! mount | grep -q "/Volumes/Backup"; then
echo "backup volume not mounted, skip" >&2
exit 0
fi
Повтори вживую, по шагам.
- 1. Создай скрипт ~/bin/backup.sh: rsync домашней папки в /Volumes/Backup или в ~/Backups. Внутри - проверка mount и echo с датой в stderr. Сделай chmod +x ~/bin/backup.sh.
- 2. Напиши ~/Library/LaunchAgents/ru.cyberlake.backup.plist по образцу выше. Для теста поставь StartInterval 120 (каждые 2 минуты), чтобы не ждать ночи.
- 3. Проверь синтаксис: plutil -lint на файл. Должно быть OK.
- 4. Загрузи: launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ru.cyberlake.backup.plist
- 5. Дерни вручную: launchctl kickstart -k gui/$UID/ru.cyberlake.backup
- 6. Смотри логи: cat ~/Library/Logs/backup.err.log и launchctl list | grep cyberlake. Поймай exit-код во второй колонке.
- 7. Сломай специально: убери PATH из EnvironmentVariables, зови brew из скрипта. Получи exit 127, найди в логе not found, почини. Это закрепляет грабли с окружением.
- 8. Прибери за собой: launchctl bootout gui/$UID/ru.cyberlake.backup, верни StartInterval на боевое значение, перезагрузи.
- 1. Почему launchd-задача не видит твоего brew/node, хотя в обычном терминале они работают? Какой ключ это лечит?
- 2. В чем разница поведения StartCalendarInterval и cron, когда Mac спал в момент запланированного запуска?
- 3. launchctl list показывает во второй колонке 127. Что это значит и куда смотреть в первую очередь?
- 4. Почему добавлять в Full Disk Access нужно /bin/zsh, а не сам твой backup.sh?
cron на macOS - наследие, которое спотыкается о TCC и не дружит со сном. launchd - родной механизм: один декларативный plist описывает службу, расписание (StartInterval/StartCalendarInterval) или событийный триггер (WatchPaths/QueueDirectories), окружение и логи. Грузишь через launchctl bootstrap gui/$UID, гоняешь kickstart, отлаживаешь через launchctl print и unified logging. Три вещи, которые сэкономят тебе вечер: абсолютные пути в ProgramArguments, явный PATH в EnvironmentVariables и понимание, что Full Disk Access выдается интерпретатору, а не скрипту.