launchd statt cron auf dem Mac
Cron gibt es auf macOS, und es funktioniert, also landete mein erster geplanter Job auf einem Mac in einer Crontab. Er lief bis zum nächsten Neustart, danach nicht mehr, und der Grund hat einen Nachmittag gekostet. Seitdem lebt alles Geplante auf einem Mac in einer launchd-Plist. Hier ist das Warum und die kleinste Version, die funktioniert.
Warum launchd
Drei Dinge, die cron dir auf einem Mac nicht gibt. Ein launchd-Agent aus ~/Library/LaunchAgents wird nach einem Neustart wieder gestartet, sobald deine Benutzersitzung steht, ohne weiteren Handgriff. Er läuft als dein Benutzer mit deiner Umgebung, also sind Schlüsselbund, deine PATH-Ergänzungen und deine Anmeldeobjekte dort, wo der Job sie erwartet. Und er hat eigene Pfade für stdout und stderr, du bestimmst also, wo das Log landet, statt in /var/mail nach Post zu suchen. Logs gehören unter ~/Library/Logs, da schaut die Konsole-App ohnehin hin.
Außerdem startet er einen Job neu, der laufen bleiben soll (KeepAlive), was cron gar nicht kann.
Die minimale Plist
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Label</key><string>local.daily-report</string>
<key>ProgramArguments</key><array>
<string>/usr/bin/python3</string>
<string>/Users/me/jobs/daily_report.py</string>
</array>
<key>StartCalendarInterval</key><dict>
<key>Hour</key><integer>7</integer><key>Minute</key><integer>40</integer>
</dict>
<key>StandardOutPath</key><string>/Users/me/Library/Logs/daily-report.log</string>
<key>StandardErrorPath</key><string>/Users/me/Library/Logs/daily-report.log</string>
</dict></plist>
Speicher sie als ~/Library/LaunchAgents/local.daily-report.plist (der Dateiname muss zum Label passen), dann launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.daily-report.plist. launchctl list | grep local. zeigt sie geladen, und launchctl kickstart -k gui/$(id -u)/local.daily-report führt sie sofort aus, statt auf 07:40 zu warten. Pfade sind überall absolut: launchd weiß weder, was ~ ist, noch was dein PATH aus der Shell enthält. Für "alle N Sekunden" statt einer Uhrzeit nimmst du StartInterval mit einer Zahl.
Die Falle: Operation not permitted
Von Hand lief der Job. Unter launchd geladen scheiterte er in der ersten Zeile mit /bin/bash: /Users/me/Documents/jobs/run.sh: Operation not permitted.
Das Skript lag unter ~/Documents. macOS behandelt Dokumente, Schreibtisch und Downloads als geschützte Ordner, und jedes Programm, das darin lesen will, braucht die Erlaubnis des Benutzers. Startest du das Skript aus dem Terminal, hat das Terminal diese Erlaubnis (du hast den Dialog vor langer Zeit einmal weggeklickt). Unter launchd ist kein Terminal in der Kette; der Interpreter selbst, /bin/bash oder /usr/bin/python3, fragt nach Zugriff, niemand sitzt vor dem Bildschirm und antwortet, und der Aufruf wird abgelehnt. Seltsamerweise gilt das pro Programm: Auf meiner Maschine wurde bash in Dokumente abgewiesen, während python3 durchkam, was den Fehler zufällig aussehen ließ, bis ich die ganze Fehlermeldung gelesen habe.
Zwei Lösungen. Die saubere: Jobskripte außerhalb der geschützten Ordner halten, etwa unter ~/jobs oder ~/.local/bin, und die Plist dorthin zeigen lassen. Die schnelle: Systemeinstellungen, Datenschutz und Sicherheit, Festplattenvollzugriff, den Interpreter aus der Plist hinzufügen (/bin/bash, /usr/bin/python3). Ich nehme die erste. Einer Shell Vollzugriff auf die Platte zu geben, um eine Ordnerwahl zu umgehen, ist der falsche Tausch.
Läuft ein Job von Hand und nicht unter launchd, such im Log zuerst nach genau dieser Meldung. In vier von fünf Fällen war das die Antwort.
