Claude Code に Windows と Linux の GUI アプリの動作確認まで自動化させた話
目次
AI のコーディング支援(Claude Code)でデスクトップアプリを作っていると、「実装しました。ビルドも通りました。画面での動作は確認できていないので、確かめてください」で止まることがよくあります。
FlexFlyout(音量を変えたときに曲の情報を出すアプリ。Windows 11 と KDE Plasma 向け)を作ったときは、これを避けるために、Claude Code が自分でビルドして、動かして、スクリーンショットを見て直すところまでを回せる環境を、Linux のサーバー 1 台に用意しました。 何番煎じか分かりませんが、組んだ構成と詰まったところを残しておきます。
FlexFlyout の紹介記事の「どうやって確かめながら作ったか」の詳しい版です。
なんで要るのか
- GUI のアプリは、型の検査もユニットテストも通るのに、画面では壊れていることがよくあります
- FlexFlyout でも、「クリックするとフォーカスを奪う」「同じ位置をもう一度クリックすると無視される」「上端に 1 ピクセルの白い線が出る」といった不具合は、画面を見るまで分かりませんでした
- かといって、毎回自分が実機で確かめて結果を伝えていると、修正の往復のたびに自分の手が止まります
- そこで、確かめる作業もできるだけエージェントに渡して、自分は最後に実機で見るだけにしたいと考えました
全体の構成
自宅の Linux サーバー(KVM・Docker)
├─ Claude Code
│ ├─ cargo xwin build ─→ Windows 版の exe
│ └─ cargo build ──────→ Linux 版
├─ Windows 11 の仮想マシン(libvirt / QEMU、評価版)
│ └─ ファイルの転送・起動・キー・クリック・画面の撮影を、スクリプト(win11vm)から
└─ Docker のコンテナ(Arch Linux)
└─ 画面の無い sway(Wayland)+ PipeWire + mpv
└─ grim で画面の撮影、小さな道具(wlclick)でクリック・ドラッグ- Claude Code は、撮ったスクリーンショット(PNG)をファイルとして読んで、そのまま中身を見られます。なので、画面を撮る手段と、キー・マウスを送る手段さえ用意すれば、見て判断するところまで任せられます
- Windows も Linux も、同じサーバーの上でビルドから確認まで閉じています。手元の PC の画面やマウスを横取りされることもありません
作業環境
- ホスト: Ubuntu 26.04、libvirt 12.0、QEMU 10.2、OVMF 2025.11、Docker(CPU は Ryzen 9 9950X3D、ディスクは NVMe)
- 仮想マシン: Windows 11 Enterprise 評価版 26H2(日本語)、Win32-OpenSSH 10.4.0.0
- コンテナ: archlinux:latest、sway 1.11、kwin 6.8.2
- Rust の Windows 向けのビルド: cargo-xwin 0.23.1
- いずれも 2026-11 時点
Windows: 仮想マシンを無人で作って、スクリプトから操作する
作る
- Windows 11 の評価版(Enterprise、90 日)を、libvirt / KVM の上に無人インストールで作ります
- 作る・操作するスクリプトは misc-scripts/win11vm に置いています
$ ./win11vm download # 評価版の ISO(約 8GB)と SSH サーバーの MSI を取得し、SHA-256 を照合 $ ./win11vm create # 仮想マシンを作り、無人インストールを始める $ ./win11vm ssh hostname # SSH が通れば完了 $ ./win11vm stop && ./win11vm save-clean # 今のディスクを初期状態として保存createから SSH が通るまで約 6 分半でした
- 作る・操作するスクリプトは misc-scripts/win11vm に置いています
- 構成は UEFI(セキュアブート)+ TPM 2.0(swtpm)、メモリ 8GB、CPU 4、画面 1280×800
- 音量の表示を確かめたいので、音声の装置(ich9)も付けています。無いと音量の操作自体ができません
- 使い終わったら
resetでディスクを初期状態に戻します(約 16GB のコピーで 10 秒ほど)- ほかのプロジェクトでも同じ仮想マシンを使い回すためです
操作する
- エージェントが使うのは、次のようなコマンドです
$ ./win11vm push target/x86_64-pc-windows-msvc/release/flexflyout.exe C:/work/flexflyout $ ./win11vm run-gui 'C:\work\flexflyout\flexflyout.exe' # ログオン中の画面で起動 $ ./win11vm key KEY_VOLUMEUP # キーを送る(Linux のキー名) $ ./win11vm click 1150 60 # 画面のピクセルでクリック $ ./win11vm shot after-volume-up # 画面を PNG で保存して、パスを表示- 中身は、キーが
virsh send-key、マウスが QEMU の QMP のinput-send-event、画面がvirsh screenshotです - マウスを USB タブレットにしておくと、画面のピクセルを 0〜32767 の絶対座標に換算して送れます
- 送った
KEY_VOLUMEUPは PS/2 キーボードとして届き、Windows の低レベルのキーボードフック(WH_KEYBOARD_LL)も通りました。なので「音量キーを押したら出る」の確認もできます
- 中身は、キーが

詰まったところ
- scp が
Connection closedで失敗する(SSH でのコマンドの実行は通る)- Win32-OpenSSH を MSI で Server の機能だけ入れると、インストール先(
C:\Program Files\OpenSSH)が PATH に入りません。そのため、既定のSubsystem sftp sftp-server.exeではプログラムが見つかりません C:\ProgramData\ssh\sshd_configのSubsystemを絶対パスにして直りました
- Win32-OpenSSH を MSI で Server の機能だけ入れると、インストール先(
- 絶対パスにしても、今度は
Ensure the remote shell produces no output for non-interactive sessions.で失敗する- SSH の既定のシェル(レジストリの
HKLM\SOFTWARE\OpenSSH\DefaultShell)を PowerShell にしていると、sftp のプログラムも PowerShell 経由で起動されます。引用符付きのパスが実行されずに文字列として出力され、転送のデータに混ざる模様です - 既定のシェルは cmd.exe のままにして、PowerShell が要るときは
powershell.exe -NoProfile -EncodedCommand <Base64 の UTF-16LE>で明示して呼ぶことにしました(引用符の入れ子も気にしなくてよくなります)
- SSH の既定のシェル(レジストリの
- SSH から起動したアプリが画面に出ない
- SSH のセッションは、画面の無いセッションで動くためです
- タスク スケジューラーに「ログオン中のユーザー」(
LogonType Interactive)として登録して起動すると、画面に出ます - ついでに、コンソールのプログラムは Windows Terminal で開いて画面をふさぎ、
-WindowStyle Hiddenも効きません。conhost.exe --headless <コマンド>で起動すると出なくなります(文書化された使い方ではないので、将来は動かないかも)
- タスクで起動したはずのアプリが動いていない。エラーも出ない
- タスクの多重起動の既定(
IgnoreNew)では、前に起動したもの(メモ帳など)が動いている間のStart-ScheduledTaskが拒否されます - 結果は
Get-ScheduledTaskInfoのLastTaskResultに0x800710E0(要求が拒否された)と残るだけでした -MultipleInstances Parallelで登録し、起動の後に結果を確かめるようにしました$action = New-ScheduledTaskAction -Execute 'conhost.exe' -Argument '--headless powershell.exe -File C:\work\check.ps1' $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited $settings = New-ScheduledTaskSettingsSet -MultipleInstances Parallel -ExecutionTimeLimit ([TimeSpan]::Zero) Register-ScheduledTask -TaskName 'run-gui' -Action $action -Principal $principal -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName 'run-gui' Start-Sleep -Milliseconds 1500 # 0 は終了済み、0x41301 は実行中。それ以外は起動の失敗 '0x{0:X}' -f (Get-ScheduledTaskInfo -TaskName 'run-gui').LastTaskResult
- タスクの多重起動の既定(
- CD から起動させるキーで、セットアップを取り消してしまいそうになる
- UEFI で Windows の ISO から起動すると、「Press any key to boot from CD or DVD」が数秒だけ出ます
- スペースや Enter を送り続けていると、その間にセットアップの画面が出たとき、フォーカスのある「キャンセル」を押しかねません(実際、起動から 22 秒後にはインストールが 10% まで進んでいて、キーを送っていた時間とかなり近かった)
- 下矢印キーを送るようにしました。矢印キーでも CD から起動でき、セットアップのボタンは押しません
- 日本語のコメントを書いた PowerShell のスクリプトが構文エラーになる
- Windows PowerShell 5.1 は、BOM の無いスクリプトを既定のコードページ(日本語版では CP932)で読みます。UTF-8 の BOM 付きで置けば大丈夫です
ビルドは Linux から
- Rust のアプリなので、Windows 版は Linux から cargo-xwin でクロスビルドしています(ビルドは 15 秒ほど)
$ cargo xwin build --release --target x86_64-pc-windows-msvc - 入れたばかりの Windows には
VCRUNTIME140.dllが無く、そのままだと0xC0000135(DLL が見つからない)で起動しません。Visual C++ のランタイムを実行ファイルに組み込みます# .cargo/config.toml [target.x86_64-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] - 仮想マシンには GPU が無く、描画はソフトウェア(DirectX 12 の WARP)になります
- egui は wgpu(DirectX 12)なら動きましたが、OpenGL 2.0 以上を要する glow では動きませんでした
Linux: Docker の中で画面の無い sway を動かす
sway を動かす
- 手元の実機は KDE Plasma なのですが、コンテナの中で KWin を画面なしで動かすと、クライアントがつないだ途端に落ちました(exit 139、ログなし)
- そこで、同じ layer-shell(Wayland で最前面に出すための仕組み)を実装している sway を、画面の無い設定で動かすことにしました
FROM archlinux:latest RUN pacman -Syu --noconfirm --needed sway grim dbus mesa vulkan-swrast \ pipewire pipewire-pulse wireplumber libpulse mpv mpv-mpris noto-fonts-cjk # コンテナでは cap_sys_nice をもらえないので外す(普通の優先度で動く) RUN setcap -r /usr/bin/sway RUN useradd -m tester && install -d -o tester -m 700 /run/user/1000 USER tester ENV XDG_RUNTIME_DIR=/run/user/1000 \ WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 \ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bussetcap -rが要るのは、Arch のパッケージのsway(とkwin_wayland)にファイルケーパビリティ(cap_sys_nice)が付いているためです。コンテナではその権限が与えられず、「Operation not permitted」で起動自体ができませんでした
- コンテナは
sleep infinityで起こしておき、docker exec -dで D-Bus → sway → PipeWire の順に起動します$ docker run -d --rm --name ffsway -v "$PWD/out:/out" flexflyout-sway:local sleep infinity $ docker exec -d ffsway bash -c 'dbus-daemon --session --address=$DBUS_SESSION_BUS_ADDRESS --nofork' $ docker exec -d ffsway sway -c /w/sway.conf # 中身は output HEADLESS-1 resolution 1280x800 $ docker exec -d ffsway pipewire # wireplumber・pipewire-pulse も同じようにtimeout docker run ...で済ませようとすると、時間切れになってもコンテナが残ります
- 音の出力が無いので、ダミーの出力を作ってから、mpv で曲を流します(mpv-mpris で、曲の情報が MPRIS に出ます)
$ docker exec ffsway pactl load-module module-null-sink sink_name=speakers $ docker exec ffsway pactl set-default-sink speakers $ docker exec -d ffsway mpv --no-video --ao=pulse --loop /media/track.mp3 - スクリーンショットは grim で撮れます
$ docker exec -e WAYLAND_DISPLAY=wayland-1 ffsway grim /out/shot.png

クリックとドラッグ
- 画面の無い seat にはポインターが無く、
swaymsg seat seat0 cursor pressは効きませんでした - wlroots の virtual pointer(
zwlr_virtual_pointer_manager_v1)でポインターを作れば、絶対座標での移動とボタンを送れますwayland-clientとwayland-protocols-wlrのクレートで、110 行ほどの小さな道具(wlclick)を書きました。要所はこれだけです// registry で wl_seat と zwlr_virtual_pointer_manager_v1 を bind しておく let pointer = manager.create_virtual_pointer(Some(&seat), &qh, ()); // 移動(画面の大きさ w×h の中の絶対座標) pointer.motion_absolute(time, x, y, w, h); pointer.frame(); // 左ボタン(BTN_LEFT = 0x110)を押して離す pointer.button(time, 0x110, ButtonState::Pressed); pointer.frame(); pointer.button(time, 0x110, ButtonState::Released); pointer.frame(); conn.flush().unwrap();$ wlclick 1280 800 click 1100 60 wait 500 drag 1000 100 600 400- 座標を補間するときは符号付きで計算します(
u32の引き算で、左へのドラッグが画面の端に飛んでいました) - 呼ぶたびにポインターを作り直して消すので、終わるとポインターが外れた扱いになります。「マウスを乗せている間は消えない」のような確認は、1 回の呼び出しの中で(
waitを挟んで)済ませる必要がありました
KWin 固有のことを確かめる
- 後から分かったのですが、KWin は gdb の下で動かすと落ちませんでした(原因は分かっていません)
$ gdb -batch -ex run -ex bt --args \ kwin_wayland --virtual --no-lockscreen --width 1280 --height 800 --output-count 2 --socket wayland-1- gdb を使うには、コンテナを
--cap-add SYS_PTRACE付きで起動しておきます
- gdb を使うには、コンテナを
- ただし、スクリーンショット(spectacle)を撮ろうとすると KWin が落ちます(virtual のバックエンドには EGL の context が無く、スクリーンショットのプラグインが SIGSEGV になる)
- 代わりに、KWin スクリプトでウィンドウの位置と出力を読みます。layer-shell の surface も
workspace.windowList()に入っていましたfor (const w of workspace.windowList()) { const g = w.frameGeometry; print("WINDOW " + w.resourceClass + " at " + g.x + "," + g.y + " " + g.width + "x" + g.height + " on " + (w.output ? w.output.name : "?")); }$ id=$(qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript /out/list.js list1) $ qdbus6 org.kde.KWin /Scripting/Script$id org.kde.kwin.Script.run # 結果は KWin のログに "js: WINDOW ..."printの出力を見るには、KWin を起動するときにQT_LOGGING_RULES="kwin_scripting.debug=true"とQT_FORCE_STDERR_LOGGING=1が要ります(Qt は stderr が端末でないと journald に書くので、journal の無いコンテナでは消えてしまう)
エージェントへの渡し方
- 手順は、プロジェクトの
CLAUDE.md(Claude Code が毎回読む指示書)に書いておきます。Windows の分はこんな感じです- 動作確認: 共有の仮想マシン win11-eval。操作は win11vm で status → use flexflyout → start → push <exe> C:/work/flexflyout → run-gui <exe> → key KEY_VOLUMEUP / click <x> <y> → shot <名前>(画像を見て判断)→ stop → reset → release - 仮想マシンで確かめられないこと(画面の明るさ、手元のキーボードの特殊なボタン、Bluetooth、 複数モニター)は、最後にユーザーに実機で確かめてもらう - 仮想マシンは複数のプロジェクトで使い回すので、「使う前に使用中の印を付ける(
use/release)」「終わったら初期状態に戻す」「システムの設定を変えたら必ず戻す」は、プロジェクトをまたいだ共通のルールにしています - もう 1 つ、「仮想マシンで確かめられることは、ユーザーに実機での確認を頼む前に確かめる」とも書いています。確認を人に振る前に、まず自分で撮って見てもらうためです
- 詰まったところと、その解決は、そのつど
CLAUDE.mdに書き足してもらっています。上の落とし穴の多くは、そうやって溜まったものです
それでも実機でしか出なかったもの
- コンテナの sway では正常だったのに、KDE の実機で出た不具合が 2 つありました
- 拡大率を変えると落ちる: 描画は自分が求めた大きさで行い、wgpu の surface はコンポジターが返した大きさで設定していたため、2 つが食い違うと wgpu の検証エラーで panic していました。原因が分かってからは sway でも再現でき、直ったことも sway で確かめられました
- 中央に置くと部品が重なる: layer-shell で中央に置くために両端に anchor していたのを、KWin が sway と違う大きさで解釈していました。こちらは sway では再現しないので、直したものを実機で確かめるしかありませんでした
- そもそも仮想マシンやコンテナには無いものもあります
- 画面の明るさ・機内モード(仮想マシンには明るさの調整も無線も無い)
- 手元のキーボードの特殊なキー(HID の Consumer の報告で届くもの)、Bluetooth のヘッドホン
- 複数のモニター、拡大率(DPI)の違うモニターの組み合わせ、GPU での描画
- 結局、「自分が実機で見つけて症状を伝える → Claude Code が仮想マシンやコンテナで再現できる形にして直す → 再現できないものだけ、また実機で見る」という分担に落ち着きました
必要なリソースと手間
- Windows は評価版なので、ライセンスのお金はかかりません
- 仮想マシンはメモリを 8GB 使うので、使わないときは止めています
- ディスクはインストール直後で約 16GB(qcow2、上限 80GB)、初期状態の複製も同じくらい。ISO(約 8GB)は保存の後に消してよいです
- 評価版は 90 日で期限が切れるので、作り直しが要ります(
destroy→download→create。createから SSH が通るまでは約 6 分半) - コンテナは、Arch のイメージに sway・PipeWire・mpv・フォントを足した程度です
ほかの方法
- 同じ発想の道具は、ここ 1〜2 か月でいくつも出ています。自分で組むのが面倒なら、こちらも良さそうです(自分は試していません)
- Linux(画面の無い Wayland のデスクトップで動かして操作する): headless-gui-skill、vway、agent-desktop
- Windows(Hyper-V の仮想マシンで試す): vmtest
- 今回は、Windows と Linux の両方を 1 台のサーバーで回したかったのと、KWin 固有の確認まで欲しかったので、自前で組みました
まとめ
- 画面を撮る手段と、キー・マウスを送る手段さえあれば、GUI の動作確認もかなりエージェントに任せられます
- ただ、コンテナの sway と実機の KWin のように、確かめる環境と本番の環境が違うところは、どうしても残ります。今の時点では、最後の確認は人が受け持つ前提で組むのが良さそうです
- とはいえ、上の「ほかの方法」のように道具はここ数か月で急に増えていて、手元の実機の画面をエージェントが直接操作する仕組み(Claude Code の Computer Use など。2026-11 時点では研究プレビューで、CLI からは macOS だけ)も出てきています。この「最後は人が見る」という前提も、近いうちに変わっていくかもしれません
お疲れ様でした!