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 分半でした
  • 構成は 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)も通りました。なので「音量キーを押したら出る」の確認もできます

仮想マシンの Windows 11 で、音量キーを送って FlexFlyout を出し、win11vm で撮った画面

詰まったところ

  • scp が Connection closed で失敗する(SSH でのコマンドの実行は通る)
    • Win32-OpenSSH を MSI で Server の機能だけ入れると、インストール先(C:\Program Files\OpenSSH)が PATH に入りません。そのため、既定の Subsystem sftp sftp-server.exe ではプログラムが見つかりません
    • C:\ProgramData\ssh\sshd_config の Subsystem を絶対パスにして直りました
  • 絶対パスにしても、今度は 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 のセッションは、画面の無いセッションで動くためです
    • タスク スケジューラーに「ログオン中のユーザー」(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/bus
    • setcap -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

コンテナの sway で撮った画面(右上を切り抜き)。FlexFlyout のプレーヤーの一覧を開いたところ(Fake Player は試験用の偽のプレーヤー)

クリックとドラッグ

  • 画面の無い 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 付きで起動しておきます
  • ただし、スクリーンショット(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 か月でいくつも出ています。自分で組むのが面倒なら、こちらも良さそうです(自分は試していません)
  • 今回は、Windows と Linux の両方を 1 台のサーバーで回したかったのと、KWin 固有の確認まで欲しかったので、自前で組みました

まとめ

  • 画面を撮る手段と、キー・マウスを送る手段さえあれば、GUI の動作確認もかなりエージェントに任せられます
  • ただ、コンテナの sway と実機の KWin のように、確かめる環境と本番の環境が違うところは、どうしても残ります。今の時点では、最後の確認は人が受け持つ前提で組むのが良さそうです
  • とはいえ、上の「ほかの方法」のように道具はここ数か月で急に増えていて、手元の実機の画面をエージェントが直接操作する仕組み(Claude Code の Computer Use など。2026-11 時点では研究プレビューで、CLI からは macOS だけ)も出てきています。この「最後は人が見る」という前提も、近いうちに変わっていくかもしれません

お疲れ様でした!