author: GeneralD

UTMでmacOSアプリ検証を別船に逃がした話


別の小さな船の上で、机にちょこんと載った macOS アプリの窓を確かめている図

macOS の常駐アプリを書いていると、ある場面で手が止まります。ビルドした debug build を起動して、「ちゃんと窓が出るか」を確かめたい。メニューバー常駐でも、オーバーレイでも、壁紙でも。やることは単純なはずなのに、自分開発機でこれをやると、なぜか毎回甲板が汚れます。

気になったので、なぜ汚れるのかを一つずつ手元転がしてみました。原因が見えてくると、逃げ道も見えてきます。

解決したい問題

自分lyra という macOS 常駐アプリ(再生中の歌詞動画壁紙をデスクトップに重ねる道具)を書いていて、この動作確認でいくつもつまずきました。開発機を起動して、debug build を走らせて、窓見る。それだけのことが、なかなか素直に進みません。

観察してみると、厄介さの正体は三つに分かれていました。一つは launchd に飼われた本番サービスが、殺しても蘇ること。二つは本番と debug が同じロック奪い合うこと。三つは debug build が本番設定キャッシュ直接食うこと。

同じ困りごとに出会っている人がいるかもしれないので、手元でやってみたことを置いておきます。開発機を一切汚さずに OS レベル確認をする、その配線の話です。

この記事を読み終えたら手に入るもの

  • 開発機を汚さずに macOS アプリを OS レベル確かめ手順
  • なぜ「自分のマシンでの確認」が罠だらけなのか、その根本
  • UTM の Apple 仮想化バックエンドで utmctl exec使えないという落とし穴と、その回避
  • boot / run / capture / restore といった部品組み方と、その裏にある判断
  • GUI セッションデーモンを挿す launchctl asuser使い方と、それが無い窓が出ない理由

前提

  • ホスト: macOS、Apple Silicon。UTM を入れてあること(brew install --cask utm
  • ゲスト: UTM 上に macOS 15+ の VM を Apple 仮想化バックエンド作成済み。Remote Login (SSH) を有効にし、admin ユーザー作ってある前提
  • 知識: ssh / scp と bash が読めること
  • スコープ外: VM の初期構築そのもの(SSH 鍵流し込み、passwordless sudo の設定など)はここでは深掘りしません。主題手元で組んだ配線判断です

なぜ開発機での確認が厄介なのか

開発機の上で口を開けて待つ三つの罠 三つの罠を、一つずつ並べて潰す

罠は三つ。頭決めつける前に、一個ずつ並べてみます。

罠その一: launchd KeepAlive の蘇生

Homebrewサービスとして入れたアプリは、launchd に homebrew.mxcl.lyra として登録されます。厄介なのは、こいつの KeepAlivetrue だということ。

つまり SIGTERM だろうが SIGKILL だろうが、殺し瞬間に launchd が即座蘇生します。lyra stop中身kill)を叩いても、また這い出てきます。止めるには、KeepAlive の契約そのものを解除するしかありません。

brew services stop lyra
# 中身はこれ:
# launchctl bootout gui/$UID/homebrew.mxcl.lyra

launchctl bootout で GUI ドメインから叩き出して、ようやく静かになります。自分はこれを知らずに kill連打していて、しばらく噛み合わない時間を過ごしました。

罠その二: single-instance flock の奪い合い

lyra は ~/.cache/lyra/lyra.pidflock を掛けて、同時に二プロセス走らないようにしています。行儀のいい設計です。とはいえ開発時には牙剥きます。

本番サービス生きていると、debug build はロック取れずに即終了します。仮両方無理やり起動できたとしても、今度はオーバーレイ二重描かれて、どちらを見ているのか分からなくなります。本番窓か、たった今ビルドした窓か。区別のつかない確認は、確認になりません。

罠その三: 本番の設定とキャッシュを食う

debug build も ~/.config/lyra/本物設定~/.cache/lyra/読みます。同じ顔をして、同じ皿手突っ込みます。

実験のつもりで設定をいじって、本番設定壊す。あるいは debug build が吐い壊れたキャッシュが残って、次に本番起動したときにそのゴミ食うデスクトップ実験場と本番兼ねている限り、この汚染避けられません。

並べてみて腑に落ちたのは、この三つは別々問題に見えて、同じ一点に根を持つ、ということでした。確認する場所開発する場所同じだから混ざる。なら、場所を物理的に分ければいい。開発機はビルドだけして、確認はクリーンな別マシンへ投げる。とはいえ、もう一台 Mac を買うのは贅沢です。だから VM を使ってみました。

なぜ UTM の VM なのか

UTM は macOS の上で VM を回す道具です。Apple Silicon なら Apple 仮想化バックエンド(AVF)が使えて、これは速く軽い。ゲストが汚れたらスナップショットを戻すだけ。開発機は無傷のまま。ちょうどよさそうだったので、まず最小動かしてみました。

ところが、海図載っていない暗礁を一つ踏みました。

Apple 仮想化バックエンドでは utmctlip-addressexec動きません。 これらは QEMU バックエンド専用機能です。AVF ゲストに utmctl exec叩いても、何返ってきません。

思っていたのと違いました。悪い意味ではありません。想像手元挙動がずれていた、ただそれだけのことです。じゃあどうやってゲストを操るか。答え単純で、全部 SSH 経由でやる です。ゲストに専用の SSH 鍵~/.ssh/vm_rsa)を仕込んで、ビルド転送も、デーモン起動も、スクリーンショットの回収も、ぜんぶ sshscp叩きます。utmctl残す仕事は、こいつにしかできない start / stop / status だけ。

手順 — ハーネスの設計判断

ここからが本題です。lyra-vm-harness.sh という一本のスクリプトに全部詰めました。サブコマンド方式で、役割ごとに部品を分けています。

lyra-vm-harness.sh boot     <vm>          # utmctl start + SSH ポーリング (120s timeout)
lyra-vm-harness.sh run      <vm>          # swift build → scp → /tmp install → daemon 起動
lyra-vm-harness.sh exec     <vm> -- <cmd> # SSH でコマンド実行
lyra-vm-harness.sh capture  <vm> [dir]    # スクリーンショット + unified log + process sample
lyra-vm-harness.sh restore  <vm>          # daemon 停止 + brew service 状態を復元
lyra-vm-harness.sh shutdown <vm>          # graceful shutdown

手元組んでいくうちに、四つの判断残りました。「なんでそうなっているのか」が伝わらないと真似しづらい部分なので、一つずつ瓶詰めにしていきます。

1. utmctl exec ではなく SSH を土台にする

土台になる関数はこれだけです。SSH のオプションを一個の関数畳んで、あとは全部こいつを呼びます。

ssh_run() {
    local ip="$1"; shift
    ssh -i "$LYRA_VM_SSH_KEY" -p "$LYRA_VM_SSH_PORT" \
        -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10 \
        "${LYRA_VM_SSH_USER}@${ip}" "$@"
}

なぜ utmctl exec使わないか。理由は二つです。

一つは前述通り、AVF バックエンドでそもそも動かない。これは決定的です。もう一つ。仮動いたとしても、utmctl exec は GUI ログインセッション迂回します。Homebrew も launchd も AppKit も、ログイン環境変数PATHTMPDIR)が揃っていないと正しく動きません。SSH でログインユーザーとして入れば、その全部最初から揃っています。GUI アプリを確かめたいなら、GUI セッション環境ごと借りるのが筋でした。

2. launchctl asuser で GUI にデーモンを挿す

SSH で起動した宙ぶらりんのプロセスを GUI セッションへ橋渡しする 窓を出すには、GUI セッションの中に挿し込む必要がある

これが一番長くつまずいところです。

SSH で入って、ゲストの中で nohup lyra daemon & とやってみました。PID は返ってきます。プロセス生きています。なのに——窓が一枚も出ません。 真っ暗画面に、生きているはずのプロセスだけが浮いています。

理由は、SSH セッションには window server への接続無いからでした。AppKit の NSWindow は、誰かの GUI セッション中でないと画面絵を出せません。SSH で起動したプロセスは、どの GUI セッションにも属さない宙ぶらりん幽霊でした。

浮き輪はこれです。launchctl asuser で、ログインユーザーの GUI bootstrap namespace にデーモン挿します。まず /tmp/lyra-vm-launch.sh という起動スクリプトをゲスト上書きます。

# /tmp/lyra-vm-launch.sh の内容 (ゲスト側に生成して渡す)
#!/bin/sh
export UV_CACHE_DIR=/tmp/lyra-vm-uv-cache
nohup /tmp/lyra-vm-test/lyra daemon > /tmp/lyra-vm-daemon.log 2>&1 &
echo "$!" > /tmp/lyra-vm-daemon.pid

UV_CACHE_DIR/tmp向けるのは、sudo起動した root プロセスログインユーザー~/.cache に root 所有ファイル積まないためです。ログと PID を固定/tmp パス吐くのにも理由があって、sudo$HOME/var/root書き換えるので、$HOME を使うと SSH ユーザーから見えないパスになります。

次に id -uログインユーザー数値 UID を引いて、sudo launchctl asuser <uid> でそのユーザーの GUI namespace の中起動します。

local uid
uid=$(ssh_run "$ip" "id -u")
ssh_run "$ip" "sudo launchctl asuser $uid /tmp/lyra-vm-launch.sh"

これで初めNSWindow が window server に繋がって、画面絵が出ました。UID をハードコードせず id -u引いているのは、ゲストのユーザー構成変わっても壊れないようにです。船変わっても通る配線にしておきます。

3. /tmp への隔離インストール

ゲストにも brew で入れ本番の lyra がいます。これを debug build で**絶対上書きしない。** 上書きしたら、ゲストの clean state が壊れて、restore の意味消えます。だから debug build は /tmp隔離ディレクトリに install します。

# ホストでビルドしてゲストの /tmp/lyra-drop に転送
swift build -c release
scp -i "$LYRA_VM_SSH_KEY" .build/release/lyra "${LYRA_VM_SSH_USER}@${ip}:/tmp/lyra-drop/"
# Bundle.module lookup に必要な *.bundle も転送
for bundle in .build/release/*.bundle; do
    [ -d "$bundle" ] && scp -r -i "$LYRA_VM_SSH_KEY" "$bundle" "${LYRA_VM_SSH_USER}@${ip}:/tmp/lyra-drop/"
done

# /tmp の隔離ディレクトリにインストール(brew 管理バイナリは絶対に触らない)
ssh_run "$ip" "rm -rf /tmp/lyra-vm-test && mkdir -p /tmp/lyra-vm-test"
ssh_run "$ip" "install -m 755 /tmp/lyra-drop/lyra /tmp/lyra-vm-test/lyra"

/tmp置けば、ゲストを再起動するか restore すれば跡形もなく消えます。brew が管理しているバイナリには指一本触れていません。実験実験砂場完結します。

4. kill -0 による起動直後の生死確認

nohup ... &起動すると、PID は即座返ってきます。とはいえ、起動した直後墜ちることもあります。設定ミス、ライブラリ読み込み失敗、なんでも。PID はあるのに、中身はもう死んでいる。

ここで嘘の「起動成功」を報告したら、死体に向かってスクリーンショットを撮ることになります。だから 3 秒待って、生死だけ確かめます。

sleep 3
if ! ssh_run "$ip" "kill -0 '$pid' 2>/dev/null"; then
    ssh_run "$ip" "tail -n 40 /tmp/lyra-vm-test/lyra.log" >&2
    echo "daemon は起動直後に沈んだ" >&2
    exit 1
fi

kill -0名前反してシグナルを送りません。プロセス存在するかどうかだけを返します。生きていれば成功死んでいれば失敗。3 秒猶予与えてから叩くことで、「起動はしたが即墜ちた」を捕まえます。沈んだなら、黙ってクラッシュログ吐かせて止めます。報告正直なほうがいいので。

検証 — スクリーンショットを回収する

デーモン生きていることを確かめたら、絵撮ります。capture仕事です。ここでも一つ罠踏みました。撮ろうとした瞬間にゲストのディスプレイがスリープしていると、撮れるのは真っ暗画面だけでした。

だから撮る前caffeinate -u -t 3 でディスプレイを起こして、UTM ウィンドウの ID を CGWindowListCopyWindowInfo引いて、screencapture -l <wid> でその窓だけ撮ります。これはホスト側コードです。

# ホスト側で UTM ウィンドウの window id を取得
utm_wid="$(swift - <<'SWIFT'
import CoreGraphics
let wins = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String:Any]]
let candidates = wins.compactMap { w -> (Int, Int)? in
    guard let owner = w["kCGWindowOwnerName"] as? String, owner == "UTM",
          let num = w["kCGWindowNumber"] as? Int,
          let bounds = w["kCGWindowBounds"] as? [String:Any],
          let w2 = bounds["Width"] as? CGFloat, let h2 = bounds["Height"] as? CGFloat
    else { return nil }
    return (num, Int(w2 * h2))
}.sorted { $0.1 > $1.1 }
if let best = candidates.first { print(best.0) }
SWIFT
)"

# 撮影直前にディスプレイを起こして、UTM ウィンドウだけキャプチャ
caffeinate -u -t 3 &
screencapture -l "$utm_wid" "$out_dir/screenshot.png"

caffeinate -u -t 3 で 3 秒だけユーザー活動装ってディスプレイを起こします。Swift のスニペットは UTM ウィンドウを「最大面積」で選びます——VM 表示窓がいちばん大きいからです。capture絵だけでなく、log show の unified log と sample による process sample も同時回収します。動作確認は「窓が出たか」だけでなく、「どう動いているか」まで持ち帰ってこそ、と手元やってみて思いました。

落とし穴

航海の終わりに、必ず元の港へ戻して後始末する 後始末を trap に縛りつけて、必ず clean state へ戻す

症状: utmctl exec が無言で何も返さない

原因: Apple 仮想化バックエンドでは utmctl exec / ip-address未対応。QEMU 専用です。 対処: 全部操作を SSH 経由切り替えます。IP は環境変数固定するか、Bridged networking で DHCP 固定にすると楽でした。

症状: デーモンは生きているのに窓が一枚も出ない

原因: SSH セッションには window server 接続無いNSWindow は GUI セッション中でないと描画できません。 対処: sudo launchctl asuser $(id -u) <起動スクリプト> で、ログインユーザーの GUI namespace に挿します。

症状: スクリーンショットが真っ黒

原因: キャプチャの瞬間にゲストのディスプレイがスリープしていた。 対処: caffeinate -u -t 3 &撮影直前にディスプレイを起こします。

症状: 確認の途中で落ちて、状態が散らかったまま残る

ゲストを汚さないのが信条なら、後始末同じくらい大事です。一番困るのは、確認途中でスクリプトが落ちて、デーモン起動しっぱなし・brew サービス止まったまま放置されることでした。次のセッションで「あれ、状態がおかしい」が始まります。

だから traprestore を EXIT に縛りつけます。

# 確認の典型的な航海:
.claude/scripts/lyra-vm-harness.sh boot "lyra-test-vm"
trap ".claude/scripts/lyra-vm-harness.sh restore 'lyra-test-vm'" EXIT
.claude/scripts/lyra-vm-harness.sh run "lyra-test-vm"
.claude/scripts/lyra-vm-harness.sh capture "lyra-test-vm" ./artifacts
.claude/scripts/lyra-vm-harness.sh shutdown "lyra-test-vm"

restore は二つやります。/tmp の debug デーモン止めて、ゲストの brew サービス元状態戻します。これを EXIT trap に括っておけば、スクリプトが正常終わろうが、エラー沈もうが、Ctrl-C で蹴られようが、必ずゲストは clean state に帰ります。補足すると、boot中では utmctl start後に SSH が通るまでポーリングします。macOS ゲストは SSH が立つまで 60〜90 秒かかることがあるので、タイムアウトは 120 秒見ておくと安心でした。早諦めると、まだ目覚めていないゲストに「死んでいる」と烙印押すことになります。

次の一手

ここまでで、開発機を一切汚さずに macOS アプリを OS レベル確かめる配線通りました。自分のアプリでやってみるなら、まず固有の「汚染源」を三つ書き出してみてもいいかもしれません。launchd の登録名何か、single-instance ロックはどこか、設定とキャッシュはどのパス食うか。それが分かれば、runrestore中身自分のアプリ用置き換えるだけです。

動かしてみて分かったのは、厄介さの正体思ったより単純だった、という一点でした。複雑そうに見えていたのは、確認開発同じ場所混ざっていた、ただそれだけ。場所別船分けたら、三つの罠静か消えました。とはいえ、この VM ハーネスは窓が出るところまでで、クリックやキー入力といった UI の自動操作まではやっていません。そこまで踏み込むなら、また別航海です。

配線図は置いておきました。あとは自分手元で、好き転がしてみてください。