iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0

! 本篇文章將會介紹 寫第一個 launchd plist:排程 Hello World,期望大家都能親手寫出第一份 plist、用 bootstrap 載入、kickstart 點火,把排程從紙上談兵變成真的會動 :D

昨天把「為什麼選 launchd」和 plist 結構解剖完,結尾留了一道謎題:把 plist 丟進 ~/Library/LaunchAgents/ 並不會自動生效,得請 launchd 正式「載入」,而且現代做法是 launchctl bootstrap,老教學裡的 launchctl load 早已是 legacy。今天兌現支票——不講觀念了,直接從零手刻人生第一份 plist,讓它每天準時跟我們問好,過程中 bootstrap、bootout、kickstart、list 這些 launchctl 的動詞全部實地演練一遍 :D

照樣聲明:你現在讀的這篇,就是今天 19:00 被 com.benben.ironman.generate 叫起來的 opencode 寫的。第 10 天了,「文章在講 launchd、文章本身跑在 launchd 上」這個迴圈依然在轉,理論與實作繼續互相印證。

本篇目標

讀完這篇你會學到:

  • 從零手寫一份最小可用的 Hello World plist:腳本、設定、體檢三步到位
  • launchctl 現代四本柱:bootstrap 載入、bootout 卸載、kickstart 點火、list 點名
  • 怎麼驗證排程「真的有跑」:log 檔與 exit code 的判讀

環境準備

  • macOS 內建 launchd 和 launchctl,不用安裝任何東西
  • 一個終端機,加上五分鐘的耐心
# 記下你的 uid,bootstrap 指令的 gui/ 前綴會用到
id -u
# ---- 預期輸出 ----
# 501

# 看看你的使用者排程區目前住了誰(空的很正常)
ls ~/Library/LaunchAgents/ 2>/dev/null

主要內容

步驟一:先寫被排程的腳本,再寫 plist

launchd 的任務本體就是一支腳本或執行檔,我們先把 Hello World 本人準備好:

# 建一個練習用資料夾(練完整包砍掉不心疼)
mkdir -p ~/hello-launchd

# Hello World 腳本:被叫醒就印一句帶時間戳的問候
cat > ~/hello-launchd/hello.sh <<'EOF'
#!/bin/bash
echo "Hello from launchd at $(date '+%Y-%m-%d %H:%M:%S')"
EOF

chmod +x ~/hello-launchd/hello.sh

# 鐵律:排程前先手動跑一次,確認腳本本身是好的
~/hello-launchd/hello.sh
# ---- 預期輸出 ----
# Hello from launchd at 2026-09-20 10:42:17

手動能跑,才輪得到排程——不然等一下出問題,你會分不清是腳本壞了還是 launchd 沒叫人。接下來是今天的主角,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.benben.hello</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/benben/hello-launchd/hello.sh</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>9</integer>
        <key>Minute</key>
        <integer>30</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/Users/benben/hello-launchd/hello.out</string>
    <key>StandardErrorPath</key>
    <string>/Users/benben/hello-launchd/hello.err</string>
</dict>
</plist>

存成 ~/hello-launchd/com.benben.hello.plist。跟昨天解剖的五欄位版相比,這是最小可用配置:Label 是識別證、ProgramArguments 是要執行的東西、StartCalendarInterval 排每天 9:30,另外兩個 Path 負責接住輸出。特別強調輸出這件事:launchd 跑任務時沒有終端機echo 不會印在任何你看得到的地方,不指定 StandardOutPath,Hello World 就印到虛無裡去了。

步驟二:安置、體檢、載入

plist 寫好了,接下來三步讓它正式編入現役:

# 1. XML 體檢,寫壞了這關就過不去
plutil -lint ~/hello-launchd/com.benben.hello.plist
# ---- 預期輸出 ----
# /Users/benben/hello-launchd/com.benben.hello.plist: OK

# 2. 搬進使用者排程目錄(慣例位置,登入時 launchd 會自動載入這裡的任務)
cp ~/hello-launchd/com.benben.hello.plist ~/Library/LaunchAgents/

# 3. 正式載入:告訴 launchd「現在立刻把這份任務編入現役」
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.benben.hello.plist

# 點名:三欄分別是 PID、上次結束狀態、Label
launchctl list | grep hello
# ---- 預期輸出 ----
# -   0   com.benben.hello

這裡就是昨天謎題的解答:檔案放進 ~/Library/LaunchAgents/ 只代表「下次登入會自動載入」,當下這個 session 它還不在 launchd 的編制裡,要 bootstrap 一下才立刻生效。

補充一下 gui/$(id -u) 這串在忙什麼:launchd 把服務分門別類收在幾個 domain 裡——system 是全系統共用的背景服務,gui 則是使用者登入後的圖形 session,以使用者的身份、帶著使用者的權限跑。我們的任務要讀寫家目錄、之後還要跑 agent-browser 這類工具,當然該住在 gui domain;$(id -u) 把剛剛查到的 uid(多半是 501)接上去,組成完整地址。順帶兩個判讀技巧:bootstrap 沒有任何輸出就是好消息(Unix 哲學,沉默即成功);launchctl list 第一欄的 - 代表此刻沒在跑——完全正常,日曆任務跑完就退場,不是掛掉。

小小小測驗:你知道 launchctl list 的第二欄是「這個任務上一次結束時的 exit code」,不是 0 就代表上次跑掛了嗎?自動化系統的日常健檢,就是掃這一欄加上 log 檔,不用等到出事才查案。

步驟三:kickstart 點火,不必等到明天 9:30

排程裝好了,難道要等到明早才能驗收?不用,launchctl 有手動點火器:

# 手動點火:立刻執行一次,不等排程時間
launchctl kickstart gui/$(id -u)/com.benben.hello

# 驗收:log 應該多蓋一個時間戳
cat ~/hello-launchd/hello.out
# ---- 預期輸出 ----
# Hello from launchd at 2026-09-20 10:47:03

# 改了 plist 想重載?先卸載再載入,順序不能顛倒
launchctl bootout gui/$(id -u)/com.benben.hello
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.benben.hello.plist

# -k:把還在跑的行程先殺掉再重跑,除錯神器
launchctl kickstart -k gui/$(id -u)/com.benben.hello

到這裡,第一個 launchd 排程已經完整走完生命周期:寫腳本 → 手動驗證 → 寫 plist → lint 體檢 → bootstrap 載入 → kickstart 點火 → 看 log 驗收。這套流程反覆用到膩,就是 launchd 自動化的全部基本功了。

順帶說明 kickstart 和班表的關係:StartCalendarInterval 是班表,kickstart 是人工點名,兩者互不干擾——手動點過火,下次排程照樣準時開跑。除錯時的黃金組合是 kickstart -k 重跑一次,馬上 tail -f 對應的 log,輸出當場看到飽。

步驟四:換上真牙——鐵人賽的三個任務

Hello World 會動之後,上正式班表只是「換腳本、改時間」的體力活。昨天看過 install-launchd.sh 動態產生 plist 的部分,今天補上它實際開火的那三行:

# scripts/install-launchd.sh(節選):三份 plist 逐一載入
launchctl bootstrap "gui/${UID_N}" "$HOME/Library/LaunchAgents/com.benben.ironman.generate.plist"
launchctl bootstrap "gui/${UID_N}" "$HOME/Library/LaunchAgents/com.benben.ironman.publish.plist"
launchctl bootstrap "gui/${UID_N}" "$HOME/Library/LaunchAgents/com.benben.ironman.republish.plist"

# 安裝完點名
launchctl list | grep ironman
# ---- 預期輸出 ----
# -   0  com.benben.ironman.generate
# -   0  com.benben.ironman.publish
# -   0  com.benben.ironman.republish

跟你剛剛做的 Hello World 一字不差的套路,只是乘以三。而「排程真的有跑」的證據,就在 plist 指定的 log 檔裡——logs/launchd-generate.outlaunchd-generate.err 現在就躺在 /Users/benben/ai/automations/ironman/logs/,每天傍晚準時多一筆紀錄。自動化系統的可信度不是靠信仰,是靠這種一條一條可以回頭查的帳。

常見問題 / 踩坑記錄

  • Q:launchctl kickstart 只丟 Label 進去,卻回 Could not find service "com.benben.hello" in domain for system
    A:現代 launchctl 語法裡,服務識別是「領域/服務」的完整路徑,必須帶 gui/<uid>/ 前綴,例如 launchctl kickstart gui/$(id -u)/com.benben.hello。裸 Label 是 load 時代的老習慣,新指令不認,而且這條規則一體適用:bootout、print、kickstart 全都要 domain 路徑。踩這個坑的人非常多,看到 "in domain for system" 這個關鍵字,九成就是少了前綴。

  • Q:launchctl bootstrapBootstrap failed: 5: Input/output error
    A:兩種可能。最常見的是同一個 Label 已經載入過——launchd 不准重複編入,先 bootout 卸載再 bootstrap 就好,本系統的 install-launchd.sh 每次安裝前都先整輪 remove,就是在躲這件事。第二種是 plist 本身寫壞了,先跑 plutil -lint 體檢,它會告訴你第幾行語法出問題,比 launchd 那句玄之又玄的錯誤訊息有用多了。

  • Q:排程時間人還在睡、電腦蓋著,任務會不會就錯過了?
    A:昨天講過,StartCalendarInterval 排的任務在電腦睡著時錯過,喚醒後 launchd 會補跑一次,實測屬實。另外提醒:急著驗證的時候別傻傻等排程時間,kickstart 就是你的人工點火器,隨叫隨到。

小結

  • 第一份 plist 的完整路徑:腳本手動跑通 → plist 寫好用 plutil -lint 體檢 → bootstrap 載入 → kickstart 點火 → log 驗收
  • launchctl 四個動詞:bootstrap 載入、bootout 卸載、kickstart 點火(-k 強制重跑)、list 點名看 exit code
  • Hello World 會動之後,換上真的腳本就是正式排程——本系統的三個任務就是這樣上線的
  • launchd 跑任務沒有終端機,stdout/stderr 全靠 StandardOutPath / StandardErrorPath 接住,log 就是明天的戰報

明日預告

下一篇我們要介紹「環境變數地雷:launchd 找不到 nvm/node 怎麼辦」,敬請期待!

參考資料:

有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D


上一篇
09 為什麼是 launchd 而不是 cron:macOS 排程的正確姿勢
下一篇
11 環境變數地雷:launchd 找不到 nvm/node 怎麼辦
系列文
自我耍廢組:全自動化の鐵人12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言