Ghostty の背景画像を外から差し替えるツールを作りました。macOS で launchd に何かを常駐させる人向けの記事です。途中でシグナルとフォルダ保護の話をします。犬に興味がなくても、その2つは他のツールでそのまま使えます。
動作環境は macOS と Ghostty 1.2 以上(1.3.1 で確認)。依存は sips・launchd・python3 だけで、どれも OS 標準です。実体はシェルスクリプト1つで、cherenkov/dogtty に置いてあります。ライセンスは MIT です。
3分ごとに犬が替わる
やっていることは3つです。Pollinations.ai で犬の画像を生成し、300px に落として決まった場所に上書きし、Ghostty に設定を読み直させる。これを launchd が180秒ごとに起こします。
ポーズ6種と画風6種をランダムに組み合わせるので、毎回違う犬が出ます。画風は水彩・パステル・フラット・色鉛筆の4つと、一眼写真・フィルム写真の2つ。犬種は柴犬が既定で、シーズー・コーギー・トイプードル・ゴールデンレトリバーから選べます。自由入力にすれば犬以外も出せます。
作った動機は集中対策です。同じ画面を何時間も見ていると視界が固まるので、端にゆっくり変わるものを置きたかった。仕事が速くなるツールではありません。ただ、作る過程で macOS 固有の穴を3つ踏んだので、そこを書きます。
設定を読み直させる道は SIGUSR2 の1本しかない
画像ファイルを上書きしても、Ghostty は何も気づきません。手で読み直すなら Cmd+Shift+, ですが、launchd から起こす以上、外から読み直させる経路が必要になります。
Ghostty が外から受け取るシグナルは SIGUSR2 だけです。実装は作者の mitchellh による PR #7759 で、2025-07-01 にマージされました。macOS 側は DispatchSource.makeSignalSource、GTK 側は g_unix_signal_add を使います。どちらも同じ PR に入っています。
なぜアプリ層でやるのかは PR 本文に書いてあります。
(1) For libghostty, we don't have a way to know what the embedding application is doing, so its risky to create signal handlers that might overwrite the application's signal handlers. (2) It's extremely messy to deal with signals and multi-threading. Apprts have framework access that handles this for us.
ここで肝心なのは、SIGUSR2 以外を送らないことです。macOS の man 3 signal を引くと、既定の動作はこうなっています。
1 SIGHUP terminate process terminal line hangup
30 SIGUSR1 terminate process User defined signal 1
31 SIGUSR2 terminate process User defined signal 2SIGUSR2 も、ハンドラが無ければプロセスを殺すシグナルです。Ghostty はそこにハンドラを付けたので設定リロードとして機能します。付いていない SIGUSR1 や SIGHUP を送れば、既定の動作がそのまま起きます。つまり「設定を読み直させたい」だけで SIGHUP を送ると、開いていた端末が全部消えて作業も消えます。
外から GUI アプリの状態を変えるツールを書くときは、相手が明示的に受け付けているシグナルだけを使うしかありません。それ以外は挙動が未定義ではなく、殺すという定義済みの挙動です。
リリースノートは macOS のことを書いていない
出典が食い違うので、そのまま書きます。Ghostty 1.2.0 のリリースノートは SIGUSR2 を GTK の項目として載せていて、macOS の記述がありません。一方で PR #7759 は macOS の AppDelegate.swift を実際に変更しています。
私は macOS でも 1.2.0 から動くものとして扱っていて、1.3.1 で実際に動いています。ただリリースノートだけを読むと Linux 限定の機能に見えます。ここは公式の記述が追いついていないと判断しました。
もう1つ、Ghostty のソースにこんな注意書きがあります。
Warning: signal handlers don't work when run via Xcode. They have to be run on a real app bundle.
Xcode から起動した Ghostty ではシグナルハンドラが動きません。自分で試すときは配布版のアプリで確かめてください。
launchd から ~/Documents は読めない
最初は ~/.local/bin/dogtty をリポジトリへのシンボリックリンクにしていました。スクリプトを直したら即反映されるので、そのほうが楽です。
launchd から起動した瞬間に、これが Operation not permitted で止まりました。手で叩けば動くのに、launchd 経由だと読めません。
原因は macOS のフォルダ保護です。書類・デスクトップ・ダウンロードのようなフォルダへのアクセスには、利用者の同意が要ります。システム設定の「ファイルとフォルダ」と「フルディスクアクセス」が、その同意の一覧です。dogtty のリポジトリは ~/Documents の下にあります。launchd から起動する素のシェルスクリプトは、同意を持つアプリとして登録されていない。だから拒否されます。
なぜ同意を求めるダイアログすら出ないのか、そこまで説明した公式の記述は見つけられませんでした。ダイアログが出れば許可して終わる話なので、ここは分からないままです。
直し方はシンボリックリンクをやめて実体をコピーすることです。install サブコマンドが自分自身を ~/.local/bin/ へ cp します。毎回書き戻すと無駄なので、cmp -s で内容が同じときは飛ばしています。
if ! cmp -s "$0" "$BIN_TARGET"; then
cp "$0" "$BIN_TARGET"
chmod +x "$BIN_TARGET"
fi代償は残ります。スクリプトを編集しても、install を再実行するまで launchd が動かすのは古い実体です。開発中にこれで一度混乱しました。
300px に落としている理由は Ghostty 側にある
生成は 512×512 で頼み、保存前に sips -Z 300 で 300px に落としています。見た目の都合ではありません。理由は Ghostty の設定リファレンスに書いてあります。
Background images are currently duplicated in VRAM per-terminal. For sufficiently large images, this could lead to a large increase in memory usage (specifically VRAM usage).
背景画像はウィンドウ単位ではなくターミナル単位で VRAM に複製されます。分割を多用する人だと画像が分割ごとに繰り返されること、将来はテクスチャを端末間で共有して解決する予定であることも、同じドキュメントに書かれています。
つまり画面を分割するほど、同じ画像のコピーが増えていく。分割を4つ開けば、1枚のサイズがそのまま4倍で乗ります。抑えたいのはこの4倍です。
background-image-fit を none にしているのも同じ話です。既定は contain で、端末の大きさに合わせて画像を拡大します。それだと 300px に落とした意味がなくなるので、拡大させない none を選びます。位置は bottom-right。どちらも 1.2.0 から使えるオプションです。
background-image = ~/.local/share/dogtty/dogtty.png
background-image-position = bottom-right
background-image-fit = none
background-image-opacity = 0.3受け付ける形式は PNG と JPEG に限られます。Pollinations.ai が返してくるのは、名前に反して JPEG のほうでした。dogtty.png として保存していますが、中身が PNG になるのは sips を通した後です。Ghostty は両方受け付けるので、形式の変換そのものは要りません。sips を通す本当の理由は縮小のほうにあります。
呼ばない条件を先に書く
launchd は3分ごとに必ず起きます。起きたからといって毎回 API を叩くと、無駄なだけでなく上限に当たります。
Pollinations.ai の API ドキュメントにある利用階層はこうなっています。
- 匿名: 15秒に1リクエスト。登録不要
- Seed: 5秒に1リクエスト。無料登録
- Flower: 3秒に1リクエスト。有料
- Nectar: 上限なし。法人向け
dogtty は匿名で使うので、上限は15秒に1回です。180秒間隔なら当たりません。当たるのは手で続けて叩いたときで、そのとき 429 が返ります。リトライの待ちを15秒にしているのは、それが上限の単位そのものだからです。適当な秒数を選んだわけではありません。
もう1つの条件が Ghostty の起動確認です。Ghostty が動いていなければ表示先が無いので、生成せずに終わります。
GHOSTTY_PID=$(ps aux | grep "Ghostty.app/Contents/MacOS/ghostty" | grep -v grep | awk '{print $2}' | head -1)
if [[ -z "${GHOSTTY_PID}" ]]; then
echo "Ghostty is not running; skipping"
exit 0
filaunchd 自体にも待ちが必要でした。launchctl bootout の直後に bootstrap すると失敗することがあり、落ち着くまで数秒かかります。そこで1秒・2秒・3秒・4秒・5秒と伸ばしながら、最大5回やり直す形にしました。1回で諦める実装だと再インストールが失敗する。何回に1回なのかは数えていないので、そこは書けません。
どこまで真似できるか
犬の部分は好みの問題なので置きます。他のツールに持っていけるのは3つです。
外から GUI アプリの状態を変えたいときは、相手が公開しているシグナルだけを使います。公開されているものが無ければ、その経路は諦めて別の手段を探すことになります。手当たり次第に送って当たりを探す、というやり方が取れない領域です。
launchd に常駐させるときは、実行ファイルを保護フォルダの外に実体で置きます。~/Documents や ~/Desktop の下へシンボリックリンクを張ると、手では動くのに launchd では動きません。原因の見えにくい症状なので、先に知っているだけで詰まる時間が減ります。
外部 API を叩く常駐では、呼ばない条件を先に決めます。dogtty の場合は「表示先が無い」と「上限の単位ぶん待つ」の2つでした。定期実行は黙って回り続けるので、無駄打ちに気づく機会がありません。
依存を足さなかったのは、この種のツールが手元でしか動かないからです。sips も launchd も python3 も macOS に入っていて、launchd の plist もスクリプトが自前で書き出します。結果として、ファイル1つをダウンロードすれば動きます。誰かの環境で brew install から始まるツールにはしたくありませんでした。