From 283210affcaeaa83885edc2ae5384e29049b76c9 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 14:59:18 +0900 Subject: [PATCH 01/12] =?UTF-8?q?test(#276):=20UTM=20macOS=20VM=20?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=83=8F=E3=83=BC=E3=83=8D=E3=82=B9=E3=82=92?= =?UTF-8?q?=E6=95=B4=E5=82=99=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/rules/vm-verification.md を追加。lyra 固有の検証レーン分類 (VM で確認できること / できないこと、Dynamic Resolution の位置付け、 display hot-plug vs ScreenProvider fixture の棲み分け)を定義する。 .claude/scripts/lyra-vm-harness.sh を追加。utmctl + SSH を使って ホストから VM のライフサイクル管理・lyra のビルド/インストール/起動・ サービス状態の保存と復元・成果物収集(スクリーンショット/ログ/プロセス サンプル)を行うハーネスを提供する。スクリプトは自己完結しており、 ユーザーグローバルのスキルやルールに依存しない。 .claude/rules/dev-verification.md に VM レーンへの参照を追記。 設計方針: lyra 固有手順はプロジェクト .claude/、汎用 macOS VM 操作 パターンはユーザーグローバル ~/.config/claude/rules/utm-macos-vm.md に 切り出し。両者は同じ設計思想を共有するが依存しない。 --- .claude/rules/dev-verification.md | 10 ++ .claude/rules/vm-verification.md | 164 ++++++++++++++++++ .claude/scripts/lyra-vm-harness.sh | 258 +++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 .claude/rules/vm-verification.md create mode 100755 .claude/scripts/lyra-vm-harness.sh diff --git a/.claude/rules/dev-verification.md b/.claude/rules/dev-verification.md index 6f1a2d07..13bcd919 100644 --- a/.claude/rules/dev-verification.md +++ b/.claude/rules/dev-verification.md @@ -90,3 +90,13 @@ brew services start lyra # only if step 1 showed "started" binary. It shares the real config and `~/.cache/lyra` (intended — you are verifying real behavior), but TCC-gated capabilities granted to the brew binary may need granting to the debug path too if a feature depends on them. + +## When to use the VM lane instead + +This document covers **host-side visual verification** (overlay rendering, +ripple, lyrics, wallpaper). For scenarios that require OS-level side effects +without touching the developer's machine — launchd KeepAlive, guest reboot, +unified log observation, CPU/memory profiling — use the UTM VM lane instead: + +- `.claude/rules/vm-verification.md` — lyra VM verification lane map and harness usage +- `.claude/scripts/lyra-vm-harness.sh` — harness script (boot / run-lyra / capture / restore) diff --git a/.claude/rules/vm-verification.md b/.claude/rules/vm-verification.md new file mode 100644 index 00000000..9b89630e --- /dev/null +++ b/.claude/rules/vm-verification.md @@ -0,0 +1,164 @@ +# VM Verification — lyra UTM Guest Harness + +Defines the **UTM macOS VM verification lane** for lyra. Use this when a +test requires OS-level side effects — service lifecycle, launchd KeepAlive, +guest reboot, unified log — without disrupting the developer's own machine. + +The harness script is `.claude/scripts/lyra-vm-harness.sh`. It is +self-contained; no user-global skill or rule is required to run it. + +--- + +## Verification lane map + +| Scenario | Lane | +|---|---| +| Service install / uninstall / KeepAlive resurrection | VM (`lyra-vm-harness.sh`) | +| Daemon crash recovery | VM | +| Guest OS reboot persistence | VM | +| Unified log / OSLog observation | VM | +| CPU / memory profiling (`lyra benchmark`, `sample`) | VM | +| Screen resolution change (approximation via Dynamic Resolution) | VM — see note below | +| `lyra healthcheck` / API smoke | VM | +| Display hot-plug (external monitor attach / detach) | ScreenProvider fixture + final manual smoke | +| NSScreen topology change (`NSApplicationDidChangeScreenParameters`) | ScreenProvider fixture | +| Visual overlay pixel verification | Host debug-build lane (`dev-verification.md`) | + +### Dynamic Resolution — approximation, not hot-plug + +UTM Dynamic Resolution changes the guest framebuffer resolution when the UTM +window is resized. It does **not** add or remove `NSScreen` entries; the +screen count stays at 1. It is useful for verifying that `AppWindow.apply` +reconciles a frame change (#265 regression class), but it is NOT a substitute +for testing display topology changes. + +**Do not describe a Dynamic Resolution test as verifying "monitor hot-plug".** + +### Display topology → ScreenProvider fixture + +`NSScreen` count changes cannot be automated inside a VM. Inject a fixture +`ScreenProvider` in a unit or integration test instead: + +```swift +// Example: exercise ScreenInteractorImpl with two screens +let fakeProvider = FakeScreenProvider(screens: [primaryScreen, secondScreen]) +let interactor = withDependencies { + $0.screenProvider = fakeProvider +} operation: { + ScreenInteractorImpl() +} +``` + +Reserve physical hot-plug confirmation for the final manual smoke check — one +confirmation per PR that modifies `ScreenInteractor` or `AppWindow`. + +--- + +## Prerequisites + +1. UTM installed: `brew install --cask utm` +2. A registered UTM macOS Apple-backend VM (macOS 15+) +3. Guest has: Xcode CLT, Homebrew, lyra installed via brew (formula must be + known so `brew services` can manage it), and passwordless sudo +4. SSH key at `~/.ssh/lyra_vm_rsa` (default); configure via + `LYRA_VM_SSH_KEY` env var if different + +One-time guest setup: + +```sh +xcode-select --install +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +brew install lyra +brew services stop lyra # harness will manage the service +# Add to /etc/sudoers: admin ALL=(ALL) NOPASSWD: ALL +``` + +--- + +## Harness script + +```sh +SCRIPT=".claude/scripts/lyra-vm-harness.sh" +VM="lyra-test" # exact name shown in utmctl list + +$SCRIPT boot $VM # start + wait for SSH +$SCRIPT run-lyra $VM # build on host, push, install, start daemon +$SCRIPT exec $VM -- lyra healthcheck +$SCRIPT exec $VM -- lyra track +$SCRIPT capture $VM /tmp/out # screenshot + unified log + process sample +$SCRIPT restore $VM # kill daemon, restore brew service state +$SCRIPT shutdown $VM # graceful guest shutdown +``` + +### Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `LYRA_VM_SSH_USER` | `admin` | Guest login name | +| `LYRA_VM_SSH_KEY` | `~/.ssh/lyra_vm_rsa` | SSH private key path | +| `LYRA_VM_SSH_PORT` | `22` | Guest SSH port | +| `LYRA_VM_BOOT_TIMEOUT` | `120` | Seconds to wait for SSH after start | +| `LYRA_VM_ARTIFACTS_DIR` | `/tmp/lyra-vm-artifacts-` | Artifact output dir | + +### Always clean up + +Wrap sessions in a trap so the guest is never left in a dirty state: + +```sh +trap "$SCRIPT restore $VM; $SCRIPT shutdown $VM" EXIT +``` + +--- + +## Common scenarios + +### Service lifecycle / KeepAlive + +```sh +$SCRIPT boot $VM +$SCRIPT exec $VM -- brew services start lyra +$SCRIPT exec $VM -- "pgrep -x lyra | xargs kill -9" +sleep 5 +$SCRIPT exec $VM -- pgrep -x lyra # new PID expected +$SCRIPT capture $VM /tmp/keepalive-test +$SCRIPT exec $VM -- brew services stop lyra +$SCRIPT shutdown $VM +``` + +### Reboot persistence + +```sh +$SCRIPT boot $VM +$SCRIPT exec $VM -- brew services start lyra +$SCRIPT reboot $VM +$SCRIPT exec $VM -- "brew services list | grep lyra" # should show 'started' +$SCRIPT capture $VM /tmp/reboot-test +$SCRIPT exec $VM -- brew services stop lyra +$SCRIPT shutdown $VM +``` + +### Build + benchmark in VM + +```sh +$SCRIPT boot $VM +$SCRIPT run-lyra $VM +$SCRIPT exec $VM -- "lyra benchmark -d 30 --json" > /tmp/vm-benchmark.json +$SCRIPT restore $VM +$SCRIPT shutdown $VM +``` + +--- + +## Agent rules + +- **Use `lyra-vm-harness.sh` for all guest operations.** Do not craft raw + `ssh`/`utmctl` commands from scratch — the script handles key options, + state persistence, and restore consistently. +- **Never describe Dynamic Resolution as hot-plug.** These are distinct + scenarios. Use the correct label in test names, issue descriptions, and + commit messages. +- **ScreenProvider fixture owns topology tests.** Any code path depending on + `NSScreen` count changing must have a fixture-based test. The VM does not + replace this requirement. +- **Restore always runs.** The `restore` subcommand must run even if an + intermediate step fails. Use `trap` in any script that calls `run-lyra`. diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh new file mode 100755 index 00000000..d0aae966 --- /dev/null +++ b/.claude/scripts/lyra-vm-harness.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# lyra-vm-harness.sh — UTM macOS guest verification harness for lyra +# +# Primary path: SSH. utmctl is used only for VM lifecycle (start/stop/reboot) +# and IP discovery. All build, install, run, and artifact operations go over SSH. +# +# Prerequisites: see .claude/rules/vm-verification.md +# +# Usage: +# lyra-vm-harness.sh boot # Start VM, wait for SSH +# lyra-vm-harness.sh shutdown # Clean guest shutdown +# lyra-vm-harness.sh reboot # Guest reboot, wait for SSH +# lyra-vm-harness.sh run-lyra # Build (host), push binary, install, start +# lyra-vm-harness.sh capture [out-dir] # Screenshot + logs + process sample +# lyra-vm-harness.sh restore # Restore lyra service to prior state +# lyra-vm-harness.sh exec -- # Run arbitrary command via SSH +# lyra-vm-harness.sh ip # Print guest IP + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration — override via environment variables +# --------------------------------------------------------------------------- +: "${LYRA_VM_SSH_USER:=admin}" +: "${LYRA_VM_SSH_KEY:=$HOME/.ssh/lyra_vm_rsa}" +: "${LYRA_VM_SSH_PORT:=22}" +: "${LYRA_VM_BOOT_TIMEOUT:=120}" # seconds to wait for SSH after utmctl start + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +: "${LYRA_VM_ARTIFACTS_DIR:=/tmp/lyra-vm-artifacts-${TIMESTAMP}}" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { printf '[lyra-vm] %s\n' "$*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +vm_ip() { + utmctl ip-address "$1" 2>/dev/null | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1 +} + +# ssh_run — run a command on the guest over 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}" "$@" +} + +# scp_get +scp_get() { + local ip="$1" remote="$2" local_path="$3" + scp -i "$LYRA_VM_SSH_KEY" \ + -P "$LYRA_VM_SSH_PORT" \ + -o StrictHostKeyChecking=no \ + -o BatchMode=yes \ + "${LYRA_VM_SSH_USER}@${ip}:${remote}" "$local_path" +} + +# scp_put +scp_put() { + local ip="$1" local_path="$2" remote="$3" + scp -i "$LYRA_VM_SSH_KEY" \ + -P "$LYRA_VM_SSH_PORT" \ + -o StrictHostKeyChecking=no \ + -o BatchMode=yes \ + "$local_path" "${LYRA_VM_SSH_USER}@${ip}:${remote}" +} + +wait_for_ssh() { + local vm="$1" + local deadline=$((SECONDS + LYRA_VM_BOOT_TIMEOUT)) + local ip="" + log "Waiting for $vm to be reachable via SSH (timeout: ${LYRA_VM_BOOT_TIMEOUT}s)..." + while [[ $SECONDS -lt $deadline ]]; do + ip="$(vm_ip "$vm")" + if [[ -n "$ip" ]] && ssh_run "$ip" exit 0 2>/dev/null; then + log "SSH ready at $ip" + echo "$ip" + return 0 + fi + sleep 5 + done + die "Timed out waiting for SSH on $vm" +} + +require_vm() { + [[ -n "${1:-}" ]] || die "VM name required" +} + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +cmd_boot() { + local vm="${1:-}"; require_vm "$vm" + log "Starting $vm..." + utmctl start "$vm" + wait_for_ssh "$vm" +} + +cmd_shutdown() { + local vm="${1:-}"; require_vm "$vm" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm — is it running?" + log "Shutting down $vm gracefully..." + # Ask the guest to shut down; fall back to utmctl stop if SSH fails + ssh_run "$ip" "sudo shutdown -h now" 2>/dev/null || true + sleep 5 + if [[ "$(utmctl status "$vm" 2>/dev/null)" != "stopped" ]]; then + log "Guest did not stop cleanly — forcing stop via utmctl" + utmctl stop "$vm" --kill 2>/dev/null || true + fi + log "$vm stopped." +} + +cmd_reboot() { + local vm="${1:-}"; require_vm "$vm" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" + log "Rebooting $vm..." + ssh_run "$ip" "sudo reboot" 2>/dev/null || true + sleep 10 # allow the guest time to begin rebooting before polling + wait_for_ssh "$vm" +} + +cmd_run_lyra() { + local vm="${1:-}"; require_vm "$vm" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" + + log "Building lyra release binary on host..." + (cd "$REPO_ROOT" && swift build -c release) + + local binary="$REPO_ROOT/.build/release/lyra" + [[ -f "$binary" ]] || die "Build succeeded but binary not found at $binary" + + log "Pushing binary to guest..." + ssh_run "$ip" "mkdir -p /tmp/lyra-drop" + scp_put "$ip" "$binary" "/tmp/lyra-drop/lyra" + + log "Installing on guest..." + ssh_run "$ip" "sudo install -m 755 /tmp/lyra-drop/lyra /usr/local/bin/lyra" + + log "Saving current lyra service state on guest..." + local prior_state + prior_state="$(ssh_run "$ip" "brew services list 2>/dev/null | grep '^lyra' | awk '{print \$2}'" 2>/dev/null || echo "none")" + # Persist state so restore subcommand can read it back + ssh_run "$ip" "printf '%s\n' '$prior_state' > ~/.lyra-vm-prior-service-state" + + log "Stopping any running lyra instance (KeepAlive bootout)..." + ssh_run "$ip" "brew services stop lyra 2>/dev/null || true" + + log "Starting lyra daemon on guest (detached)..." + ssh_run "$ip" "nohup lyra daemon > ~/.lyra-vm-daemon.log 2>&1 & printf '%s\n' \$! > ~/.lyra-vm-daemon.pid" + sleep 3 + + local pid + pid="$(ssh_run "$ip" "cat ~/.lyra-vm-daemon.pid 2>/dev/null || printf '?'")" + log "lyra daemon running on guest (PID=$pid)" +} + +cmd_capture() { + local vm="${1:-}"; require_vm "$vm" + local out_dir="${2:-$LYRA_VM_ARTIFACTS_DIR}" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" + + mkdir -p "$out_dir" + log "Collecting artifacts from $vm -> $out_dir" + + # Screenshot — requires a logged-in GUI session on the guest + if ssh_run "$ip" "screencapture -x /tmp/lyra-vm-screenshot.png" 2>/dev/null; then + scp_get "$ip" "/tmp/lyra-vm-screenshot.png" "$out_dir/screenshot.png" && \ + log " screenshot -> $out_dir/screenshot.png" + else + log " WARNING: screencapture failed (no GUI display session on guest?)" + fi + + # Unified log — lyra subsystem, last 10 minutes + ssh_run "$ip" \ + "log show --last 10m --predicate 'subsystem CONTAINS \"lyra\" OR process == \"lyra\"' 2>/dev/null" \ + > "$out_dir/unified.log" && \ + log " unified log -> $out_dir/unified.log" || \ + log " WARNING: log show failed" + + # Process sample — 5 seconds + # note: `sample` requires sudo to sample other users' processes on macOS + ssh_run "$ip" "pid=\"\$(pgrep -x lyra | head -1)\"; \ + if [ -n \"\$pid\" ]; then sudo sample \"\$pid\" 5 -f /tmp/lyra-vm-sample.txt 2>/dev/null; \ + else printf 'lyra not running\n' > /tmp/lyra-vm-sample.txt; fi" 2>/dev/null && \ + scp_get "$ip" "/tmp/lyra-vm-sample.txt" "$out_dir/process-sample.txt" && \ + log " process sample -> $out_dir/process-sample.txt" || \ + log " WARNING: process sample failed" + + # Daemon log written by run-lyra + scp_get "$ip" "~/.lyra-vm-daemon.log" "$out_dir/daemon.log" 2>/dev/null && \ + log " daemon log -> $out_dir/daemon.log" || \ + log " (no daemon.log — daemon may not have been started via run-lyra)" + + log "Artifacts collected in $out_dir" + printf '%s\n' "$out_dir" +} + +cmd_restore() { + local vm="${1:-}"; require_vm "$vm" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" + + # Stop the daemon we started, if any + ssh_run "$ip" "pid=\"\$(cat ~/.lyra-vm-daemon.pid 2>/dev/null)\"; \ + [ -n \"\$pid\" ] && kill \"\$pid\" 2>/dev/null; rm -f ~/.lyra-vm-daemon.pid" \ + 2>/dev/null || true + + # Restore brew service to its prior state + local prior_state + prior_state="$(ssh_run "$ip" "cat ~/.lyra-vm-prior-service-state 2>/dev/null || printf 'none'")" + if [[ "$prior_state" == "started" ]]; then + log "Restoring lyra brew service on guest..." + ssh_run "$ip" "brew services start lyra" + else + log "Prior state was '$prior_state' — leaving brew service stopped" + fi + ssh_run "$ip" "rm -f ~/.lyra-vm-prior-service-state" + log "Restore complete." +} + +cmd_exec() { + local vm="${1:-}"; require_vm "$vm"; shift + [[ "${1:-}" == "--" ]] && shift + [[ $# -gt 0 ]] || die "No command supplied after exec" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" + ssh_run "$ip" "$@" +} + +cmd_ip() { + local vm="${1:-}"; require_vm "$vm" + local ip; ip="$(vm_ip "$vm")" + [[ -n "$ip" ]] || die "Could not determine IP for $vm (not running or guest agent unavailable)" + printf '%s\n' "$ip" +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- +SUBCOMMAND="${1:-}"; shift || true + +case "$SUBCOMMAND" in + boot) cmd_boot "$@" ;; + shutdown) cmd_shutdown "$@" ;; + reboot) cmd_reboot "$@" ;; + run-lyra) cmd_run_lyra "$@" ;; + capture) cmd_capture "$@" ;; + restore) cmd_restore "$@" ;; + exec) cmd_exec "$@" ;; + ip) cmd_ip "$@" ;; + "") die "Subcommand required. See .claude/rules/vm-verification.md for usage." ;; + *) die "Unknown subcommand: $SUBCOMMAND" ;; +esac From 4387a70c54f0f51fc29c40e31bb38ce14b0d69d9 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 14:59:18 +0900 Subject: [PATCH 02/12] chore: bump version to 2.13.12 --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 3caff04a..f5245c66 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.10 +2.13.12 From 3f7b1d9f3caf1446bdac07ed5d0498e7fadcf466 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 16:30:11 +0900 Subject: [PATCH 03/12] =?UTF-8?q?docs(#276):=20vm/dev-verification.md=20?= =?UTF-8?q?=E3=81=AB=20paths=20frontmatter=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift ファイル作業時のみロードされるよう *.swift / Package.swift に絞る。 macOS ネイティブ挙動の検証が必要になるのは Swift を書いている時であり、 マルチプラットフォームな設定・ドキュメント作業では不要なため。 --- .claude/rules/dev-verification.md | 6 ++++++ .claude/rules/vm-verification.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.claude/rules/dev-verification.md b/.claude/rules/dev-verification.md index 13bcd919..8eb9aaec 100644 --- a/.claude/rules/dev-verification.md +++ b/.claude/rules/dev-verification.md @@ -1,3 +1,9 @@ +--- +paths: + - "**/*.swift" + - "**/Package.swift" +--- + # Dev Verification — Run the Debug Build, Not the Installed One When you need the user to **visually verify lyra runtime behavior** (overlay diff --git a/.claude/rules/vm-verification.md b/.claude/rules/vm-verification.md index 9b89630e..805ae27f 100644 --- a/.claude/rules/vm-verification.md +++ b/.claude/rules/vm-verification.md @@ -1,3 +1,9 @@ +--- +paths: + - "**/*.swift" + - "**/Package.swift" +--- + # VM Verification — lyra UTM Guest Harness Defines the **UTM macOS VM verification lane** for lyra. Use this when a From 51485fffebc224ee25f42a3ee674ab319233dcc5 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 16:39:31 +0900 Subject: [PATCH 04/12] fix(#276): LYRA_VM_SSH_HOST override + shellcheck fixes in lyra-vm-harness.sh - Add `LYRA_VM_SSH_HOST` env var to `vm_ip()` so the harness works with macOS Apple Virtualization Framework backend VMs where `utmctl ip-address` returns "Operation not supported by the backend" - Convert `A && B || C` chains in `cmd_capture` to proper `if/then/else` (SC2015) - Add `shellcheck disable=SC2088` with justification for tilde in remote scp path --- .claude/scripts/lyra-vm-harness.sh | 37 ++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh index d0aae966..d80875d1 100755 --- a/.claude/scripts/lyra-vm-harness.sh +++ b/.claude/scripts/lyra-vm-harness.sh @@ -1,8 +1,12 @@ #!/usr/bin/env bash # lyra-vm-harness.sh — UTM macOS guest verification harness for lyra # -# Primary path: SSH. utmctl is used only for VM lifecycle (start/stop/reboot) -# and IP discovery. All build, install, run, and artifact operations go over SSH. +# Primary path: SSH. utmctl is used only for VM lifecycle (start/stop/reboot). +# All build, install, run, and artifact operations go over SSH. +# +# NOTE: utmctl ip-address and utmctl exec are NOT supported by the macOS +# Apple Virtualization Framework backend (only QEMU supports them). +# Set LYRA_VM_SSH_HOST to the guest's static/bridge IP to bypass utmctl IP lookup. # # Prerequisites: see .claude/rules/vm-verification.md # @@ -37,6 +41,12 @@ log() { printf '[lyra-vm] %s\n' "$*" >&2; } die() { log "ERROR: $*"; exit 1; } vm_ip() { + # LYRA_VM_SSH_HOST overrides utmctl IP lookup (required for Apple Virtualization backend + # where utmctl ip-address is unsupported — only works for QEMU backend VMs). + if [[ -n "${LYRA_VM_SSH_HOST:-}" ]]; then + printf '%s\n' "$LYRA_VM_SSH_HOST" + return 0 + fi utmctl ip-address "$1" 2>/dev/null | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1 } @@ -178,25 +188,32 @@ cmd_capture() { fi # Unified log — lyra subsystem, last 10 minutes - ssh_run "$ip" \ + if ssh_run "$ip" \ "log show --last 10m --predicate 'subsystem CONTAINS \"lyra\" OR process == \"lyra\"' 2>/dev/null" \ - > "$out_dir/unified.log" && \ - log " unified log -> $out_dir/unified.log" || \ + > "$out_dir/unified.log"; then + log " unified log -> $out_dir/unified.log" + else log " WARNING: log show failed" + fi # Process sample — 5 seconds # note: `sample` requires sudo to sample other users' processes on macOS - ssh_run "$ip" "pid=\"\$(pgrep -x lyra | head -1)\"; \ + if ssh_run "$ip" "pid=\"\$(pgrep -x lyra | head -1)\"; \ if [ -n \"\$pid\" ]; then sudo sample \"\$pid\" 5 -f /tmp/lyra-vm-sample.txt 2>/dev/null; \ else printf 'lyra not running\n' > /tmp/lyra-vm-sample.txt; fi" 2>/dev/null && \ - scp_get "$ip" "/tmp/lyra-vm-sample.txt" "$out_dir/process-sample.txt" && \ - log " process sample -> $out_dir/process-sample.txt" || \ + scp_get "$ip" "/tmp/lyra-vm-sample.txt" "$out_dir/process-sample.txt"; then + log " process sample -> $out_dir/process-sample.txt" + else log " WARNING: process sample failed" + fi # Daemon log written by run-lyra - scp_get "$ip" "~/.lyra-vm-daemon.log" "$out_dir/daemon.log" 2>/dev/null && \ - log " daemon log -> $out_dir/daemon.log" || \ + # shellcheck disable=SC2088 # ~ is in a remote scp path and intentionally expands on the guest + if scp_get "$ip" "~/.lyra-vm-daemon.log" "$out_dir/daemon.log" 2>/dev/null; then + log " daemon log -> $out_dir/daemon.log" + else log " (no daemon.log — daemon may not have been started via run-lyra)" + fi log "Artifacts collected in $out_dir" printf '%s\n' "$out_dir" From 4c7c6493ede1fc78a8bfbbf92b500fa1f1c47d66 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 16:48:35 +0900 Subject: [PATCH 05/12] =?UTF-8?q?chore(#276):=20default=20SSH=20key=20?= =?UTF-8?q?=E3=82=92=20vm=5Frsa=20=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/vm-verification.md | 4 ++-- .claude/scripts/lyra-vm-harness.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/rules/vm-verification.md b/.claude/rules/vm-verification.md index 805ae27f..63701321 100644 --- a/.claude/rules/vm-verification.md +++ b/.claude/rules/vm-verification.md @@ -66,7 +66,7 @@ confirmation per PR that modifies `ScreenInteractor` or `AppWindow`. 2. A registered UTM macOS Apple-backend VM (macOS 15+) 3. Guest has: Xcode CLT, Homebrew, lyra installed via brew (formula must be known so `brew services` can manage it), and passwordless sudo -4. SSH key at `~/.ssh/lyra_vm_rsa` (default); configure via +4. SSH key at `~/.ssh/vm_rsa` (default); configure via `LYRA_VM_SSH_KEY` env var if different One-time guest setup: @@ -101,7 +101,7 @@ $SCRIPT shutdown $VM # graceful guest shutdown | Variable | Default | Purpose | |---|---|---| | `LYRA_VM_SSH_USER` | `admin` | Guest login name | -| `LYRA_VM_SSH_KEY` | `~/.ssh/lyra_vm_rsa` | SSH private key path | +| `LYRA_VM_SSH_KEY` | `~/.ssh/vm_rsa` | SSH private key path | | `LYRA_VM_SSH_PORT` | `22` | Guest SSH port | | `LYRA_VM_BOOT_TIMEOUT` | `120` | Seconds to wait for SSH after start | | `LYRA_VM_ARTIFACTS_DIR` | `/tmp/lyra-vm-artifacts-` | Artifact output dir | diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh index d80875d1..8432a2fa 100755 --- a/.claude/scripts/lyra-vm-harness.sh +++ b/.claude/scripts/lyra-vm-harness.sh @@ -26,7 +26,7 @@ set -euo pipefail # Configuration — override via environment variables # --------------------------------------------------------------------------- : "${LYRA_VM_SSH_USER:=admin}" -: "${LYRA_VM_SSH_KEY:=$HOME/.ssh/lyra_vm_rsa}" +: "${LYRA_VM_SSH_KEY:=$HOME/.ssh/vm_rsa}" : "${LYRA_VM_SSH_PORT:=22}" : "${LYRA_VM_BOOT_TIMEOUT:=120}" # seconds to wait for SSH after utmctl start From 16620cbbc46dc2a22e5a34682d457c2ae58f623e Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 17:49:57 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat(#276):=20play-music=20=E3=82=B5?= =?UTF-8?q?=E3=83=96=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=82=92=20harnes?= =?UTF-8?q?s=20=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safari で URL を開いて AppleScript で自動再生。 MediaRemote が認識するか lyra track でそのまま確認できる。 ゲスト側にスクリプトは置かず、ホスト側から SSH で完結。 --- .claude/scripts/lyra-vm-harness.sh | 40 ++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh index 8432a2fa..e6ab906c 100755 --- a/.claude/scripts/lyra-vm-harness.sh +++ b/.claude/scripts/lyra-vm-harness.sh @@ -11,14 +11,15 @@ # Prerequisites: see .claude/rules/vm-verification.md # # Usage: -# lyra-vm-harness.sh boot # Start VM, wait for SSH -# lyra-vm-harness.sh shutdown # Clean guest shutdown -# lyra-vm-harness.sh reboot # Guest reboot, wait for SSH -# lyra-vm-harness.sh run-lyra # Build (host), push binary, install, start -# lyra-vm-harness.sh capture [out-dir] # Screenshot + logs + process sample -# lyra-vm-harness.sh restore # Restore lyra service to prior state -# lyra-vm-harness.sh exec -- # Run arbitrary command via SSH -# lyra-vm-harness.sh ip # Print guest IP +# lyra-vm-harness.sh boot # Start VM, wait for SSH +# lyra-vm-harness.sh shutdown # Clean guest shutdown +# lyra-vm-harness.sh reboot # Guest reboot, wait for SSH +# lyra-vm-harness.sh run-lyra # Build (host), push binary, install, start +# lyra-vm-harness.sh capture [out-dir] # Screenshot + logs + process sample +# lyra-vm-harness.sh restore # Restore lyra service to prior state +# lyra-vm-harness.sh play-music [url] # Open URL in Safari + auto-play (MediaRemote test) +# lyra-vm-harness.sh exec -- # Run arbitrary command via SSH +# lyra-vm-harness.sh ip # Print guest IP set -euo pipefail @@ -241,6 +242,24 @@ cmd_restore() { log "Restore complete." } +cmd_play_music() { + local vm="${1:-}"; require_vm "$vm" + local url="${2:-https://www.youtube.com/watch?v=jNQXAC9IVRw}" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" + + log "Opening $url in Safari on guest..." + ssh_run "$ip" "open -a Safari '$url'" + sleep 10 + + log "Injecting play via AppleScript..." + # Enable JavaScript from Apple Events if not already set + ssh_run "$ip" "defaults write com.apple.Safari AllowJavaScriptFromAppleEvents 1" 2>/dev/null || true + ssh_run "$ip" "osascript -e 'tell application \"Safari\" to tell window 1 to tell current tab to do JavaScript \"document.querySelectorAll(\\\"video,audio\\\").forEach(function(m){try{m.play()}catch(e){}})\"'" + + sleep 5 + log "Playback started — MediaRemote should now see it. Run: $0 exec $vm -- lyra track" +} + cmd_exec() { local vm="${1:-}"; require_vm "$vm"; shift [[ "${1:-}" == "--" ]] && shift @@ -267,8 +286,9 @@ case "$SUBCOMMAND" in reboot) cmd_reboot "$@" ;; run-lyra) cmd_run_lyra "$@" ;; capture) cmd_capture "$@" ;; - restore) cmd_restore "$@" ;; - exec) cmd_exec "$@" ;; + restore) cmd_restore "$@" ;; + play-music) cmd_play_music "$@" ;; + exec) cmd_exec "$@" ;; ip) cmd_ip "$@" ;; "") die "Subcommand required. See .claude/rules/vm-verification.md for usage." ;; *) die "Unknown subcommand: $SUBCOMMAND" ;; From 3a2662cf7c208bd712459b66334cb41ac962c9f0 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 18:01:14 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix(#276):=20run-lyra=20=E3=81=A7?= =?UTF-8?q?=E3=83=90=E3=83=B3=E3=83=89=E3=83=AB=E3=82=92=E8=BB=A2=E9=80=81?= =?UTF-8?q?=E3=81=97=20GUI=20=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=81=A7=20daemon=20=E8=B5=B7=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scp_put_r ヘルパーを追加 (ディレクトリ再帰コピー) - run-lyra: /tmp/lyra-drop を事前 sudo rm -rf してルート所有古バンドルを除去 - run-lyra: *.bundle を /usr/local/bin/ に配置 (resource bundle 必須) - run-lyra: nohup → sudo launchctl asuser + ランチャースクリプト方式に変更 SSH コンテキストから AppKit ウィンドウを表示するには GUI bootstrap namespace への inject が必要なため - capture: screencapture も GUI session が必要 (TODO コメント追加) 実証済み: VM 上で Payphone (Maroon 5) の歌詞が全画面オーバーレイ表示 --- .claude/scripts/lyra-vm-harness.sh | 31 +++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh index e6ab906c..ca16c923 100755 --- a/.claude/scripts/lyra-vm-harness.sh +++ b/.claude/scripts/lyra-vm-harness.sh @@ -82,6 +82,16 @@ scp_put() { "$local_path" "${LYRA_VM_SSH_USER}@${ip}:${remote}" } +# scp_put_r — recursive directory copy +scp_put_r() { + local ip="$1" local_dir="$2" remote_parent="$3" + scp -r -i "$LYRA_VM_SSH_KEY" \ + -P "$LYRA_VM_SSH_PORT" \ + -o StrictHostKeyChecking=no \ + -o BatchMode=yes \ + "$local_dir" "${LYRA_VM_SSH_USER}@${ip}:${remote_parent}" +} + wait_for_ssh() { local vm="$1" local deadline=$((SECONDS + LYRA_VM_BOOT_TIMEOUT)) @@ -148,11 +158,19 @@ cmd_run_lyra() { [[ -f "$binary" ]] || die "Build succeeded but binary not found at $binary" log "Pushing binary to guest..." - ssh_run "$ip" "mkdir -p /tmp/lyra-drop" + ssh_run "$ip" "sudo rm -rf /tmp/lyra-drop && mkdir -p /tmp/lyra-drop" scp_put "$ip" "$binary" "/tmp/lyra-drop/lyra" + log "Pushing resource bundles to guest..." + local bundle_dir + for bundle_dir in "$REPO_ROOT"/.build/release/*.bundle; do + [[ -d "$bundle_dir" ]] || continue + scp_put_r "$ip" "$bundle_dir" "/tmp/lyra-drop/" + done + log "Installing on guest..." ssh_run "$ip" "sudo install -m 755 /tmp/lyra-drop/lyra /usr/local/bin/lyra" + ssh_run "$ip" "for b in /tmp/lyra-drop/*.bundle; do [ -d \"\$b\" ] && sudo cp -r \"\$b\" /usr/local/bin/; done" log "Saving current lyra service state on guest..." local prior_state @@ -163,8 +181,15 @@ cmd_run_lyra() { log "Stopping any running lyra instance (KeepAlive bootout)..." ssh_run "$ip" "brew services stop lyra 2>/dev/null || true" - log "Starting lyra daemon on guest (detached)..." - ssh_run "$ip" "nohup lyra daemon > ~/.lyra-vm-daemon.log 2>&1 & printf '%s\n' \$! > ~/.lyra-vm-daemon.pid" + log "Starting lyra daemon on guest (GUI session via launchctl asuser)..." + # Write a launcher script on the guest so quoting stays simple. + # sudo launchctl asuser injects the command into the logged-in user's GUI + # bootstrap namespace — required for AppKit windows to appear on the guest + # display. nohup alone spawns in the SSH bootstrap context where WindowServer + # is inaccessible. sudo is required; launchctl asuser cannot switch audit + # sessions from an SSH session without elevated privileges. + ssh_run "$ip" "printf '#!/bin/sh\nnohup /usr/local/bin/lyra daemon > \"\$HOME\"/.lyra-vm-daemon.log 2>&1 &\nprintf %%s\\\\n \"\$!\" > \"\$HOME\"/.lyra-vm-daemon.pid\n' > /tmp/lyra-vm-launch.sh && chmod +x /tmp/lyra-vm-launch.sh" + ssh_run "$ip" "sudo launchctl asuser \$(id -u) /tmp/lyra-vm-launch.sh" sleep 3 local pid From f6f324edd99d99746826f72059cc3e014e1b77f2 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 18:01:43 +0900 Subject: [PATCH 08/12] chore: bump version to 2.13.13 --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index f5245c66..55fcfb7f 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.12 +2.13.13 From ebdf6b5895066093ff1881660bdf8cb3b5a9cc02 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 18:09:55 +0900 Subject: [PATCH 09/12] fix(harness): kill existing lyra before starting + fix PID echo bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit `pgrep -x lyra | kill` step in run-lyra before launching new daemon to prevent "Another lyra daemon is already running" when re-running - Fix launcher script to use `echo "$!"` instead of `printf '%s\n' "$!"` (unquoted `\n` in sh was consumed by the shell → PID file contained `n`) --- .claude/scripts/lyra-vm-harness.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh index ca16c923..d9dabd91 100755 --- a/.claude/scripts/lyra-vm-harness.sh +++ b/.claude/scripts/lyra-vm-harness.sh @@ -178,8 +178,11 @@ cmd_run_lyra() { # Persist state so restore subcommand can read it back ssh_run "$ip" "printf '%s\n' '$prior_state' > ~/.lyra-vm-prior-service-state" - log "Stopping any running lyra instance (KeepAlive bootout)..." + log "Stopping any running lyra instance (KeepAlive bootout + direct kill)..." ssh_run "$ip" "brew services stop lyra 2>/dev/null || true" + # Kill any leftover daemon (e.g. from a previous run-lyra that wasn't restored). + # pgrep -x matches the binary name exactly to avoid killing unrelated processes. + ssh_run "$ip" "pid=\$(pgrep -x lyra | head -1); [ -n \"\$pid\" ] && kill \"\$pid\" 2>/dev/null; sleep 1" 2>/dev/null || true log "Starting lyra daemon on guest (GUI session via launchctl asuser)..." # Write a launcher script on the guest so quoting stays simple. @@ -188,7 +191,9 @@ cmd_run_lyra() { # display. nohup alone spawns in the SSH bootstrap context where WindowServer # is inaccessible. sudo is required; launchctl asuser cannot switch audit # sessions from an SSH session without elevated privileges. - ssh_run "$ip" "printf '#!/bin/sh\nnohup /usr/local/bin/lyra daemon > \"\$HOME\"/.lyra-vm-daemon.log 2>&1 &\nprintf %%s\\\\n \"\$!\" > \"\$HOME\"/.lyra-vm-daemon.pid\n' > /tmp/lyra-vm-launch.sh && chmod +x /tmp/lyra-vm-launch.sh" + # Use 'echo' for PID capture — avoids the printf \n quoting trap where an + # unquoted \n in sh is consumed by the shell and becomes literal 'n'. + ssh_run "$ip" "printf '#!/bin/sh\nnohup /usr/local/bin/lyra daemon > \"\$HOME\"/.lyra-vm-daemon.log 2>&1 &\necho \"\$!\" > \"\$HOME\"/.lyra-vm-daemon.pid\n' > /tmp/lyra-vm-launch.sh && chmod +x /tmp/lyra-vm-launch.sh" ssh_run "$ip" "sudo launchctl asuser \$(id -u) /tmp/lyra-vm-launch.sh" sleep 3 From be84342ffe3ebebabb46a6d41f0394e905dbe895 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 18:39:57 +0900 Subject: [PATCH 10/12] fix(wallpaper): use android player_client + fallback format to avoid YouTube 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yt-dlp with the default web client triggers YouTube SABR streaming which returns HTTP 403. Switching to `player_client=android` avoids SABR, but video-only formats then require a GVS PO Token (unavailable), causing "Requested format is not available". Fix: add `best[ext=mp4][height<=maxHeight]` as fallback after the video-only selector. This picks the combined A/V format (format 18, 360p MP4) when video-only streams are blocked — confirmed working in VM verification. --no-audio is a no-op for combined formats. fix(harness): use /tmp for daemon log+pid instead of $HOME sudo resets HOME to /var/root, so "$HOME"/.lyra-vm-daemon.log and .pid were written to /var/root/ and invisible to the SSH login user (babu). Fixed to use /tmp/lyra-vm-daemon.{log,pid} which all users can read. fix(harness): add host-side UTM window fallback screenshot in capture When SSH screencapture -x fails (no display in SSH context), falls back to a Swift CGWindow lookup on the host to capture the largest UTM window. Enables visual verification of the download indicator and UI state. --- .claude/scripts/lyra-vm-harness.sh | 63 +++++++++++++++---- .../YouTubeWallpaperDataSourceImpl.swift | 9 ++- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh index d9dabd91..86dc65dc 100755 --- a/.claude/scripts/lyra-vm-harness.sh +++ b/.claude/scripts/lyra-vm-harness.sh @@ -182,7 +182,8 @@ cmd_run_lyra() { ssh_run "$ip" "brew services stop lyra 2>/dev/null || true" # Kill any leftover daemon (e.g. from a previous run-lyra that wasn't restored). # pgrep -x matches the binary name exactly to avoid killing unrelated processes. - ssh_run "$ip" "pid=\$(pgrep -x lyra | head -1); [ -n \"\$pid\" ] && kill \"\$pid\" 2>/dev/null; sleep 1" 2>/dev/null || true + # sudo required: daemon is launched via 'sudo launchctl asuser', so the process is root-owned + ssh_run "$ip" "pid=\$(pgrep -x lyra | head -1); [ -n \"\$pid\" ] && sudo kill \"\$pid\" 2>/dev/null; sleep 1" 2>/dev/null || true log "Starting lyra daemon on guest (GUI session via launchctl asuser)..." # Write a launcher script on the guest so quoting stays simple. @@ -193,12 +194,18 @@ cmd_run_lyra() { # sessions from an SSH session without elevated privileges. # Use 'echo' for PID capture — avoids the printf \n quoting trap where an # unquoted \n in sh is consumed by the shell and becomes literal 'n'. - ssh_run "$ip" "printf '#!/bin/sh\nnohup /usr/local/bin/lyra daemon > \"\$HOME\"/.lyra-vm-daemon.log 2>&1 &\necho \"\$!\" > \"\$HOME\"/.lyra-vm-daemon.pid\n' > /tmp/lyra-vm-launch.sh && chmod +x /tmp/lyra-vm-launch.sh" + # sudo launchctl asuser injects into the GUI bootstrap namespace (runs as root). + # Set UV_CACHE_DIR to a root-private path so uvx doesn't pollute the login + # user's ~/.cache/uv with root-owned files, which would break manual uvx runs. + # Use fixed /tmp paths for log and PID — sudo resets HOME to /var/root so + # "$HOME" in the launcher would point there, making the files invisible to the + # SSH login user (babu). /tmp is always writable and readable by all users. + ssh_run "$ip" "printf '#!/bin/sh\nexport UV_CACHE_DIR=/tmp/lyra-vm-uv-cache\nnohup /usr/local/bin/lyra daemon > /tmp/lyra-vm-daemon.log 2>&1 &\necho \"\$!\" > /tmp/lyra-vm-daemon.pid\n' > /tmp/lyra-vm-launch.sh && chmod +x /tmp/lyra-vm-launch.sh" ssh_run "$ip" "sudo launchctl asuser \$(id -u) /tmp/lyra-vm-launch.sh" sleep 3 local pid - pid="$(ssh_run "$ip" "cat ~/.lyra-vm-daemon.pid 2>/dev/null || printf '?'")" + pid="$(ssh_run "$ip" "cat /tmp/lyra-vm-daemon.pid 2>/dev/null || printf '?'")" log "lyra daemon running on guest (PID=$pid)" } @@ -210,12 +217,42 @@ cmd_capture() { mkdir -p "$out_dir" log "Collecting artifacts from $vm -> $out_dir" - # Screenshot — requires a logged-in GUI session on the guest + # Screenshot — try guest-side first (shows lyra overlay correctly); fall back + # to host-side UTM window capture if screencapture fails in the SSH context. + local screenshot_ok=false if ssh_run "$ip" "screencapture -x /tmp/lyra-vm-screenshot.png" 2>/dev/null; then - scp_get "$ip" "/tmp/lyra-vm-screenshot.png" "$out_dir/screenshot.png" && \ - log " screenshot -> $out_dir/screenshot.png" - else - log " WARNING: screencapture failed (no GUI display session on guest?)" + if scp_get "$ip" "/tmp/lyra-vm-screenshot.png" "$out_dir/screenshot.png" 2>/dev/null; then + log " screenshot (guest) -> $out_dir/screenshot.png" + screenshot_ok=true + fi + fi + if ! $screenshot_ok; then + log " guest screencapture failed — trying host-side UTM window capture..." + local utm_wid + utm_wid="$(swift - 2>/dev/null <<'SWIFT' +import CoreGraphics +let wins = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String:Any]] +// Prefer large windows (actual VM display) over panels/menus +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 +)" + if [[ -n "$utm_wid" ]]; then + if screencapture -l "$utm_wid" "$out_dir/screenshot.png" 2>/dev/null; then + log " screenshot (host UTM wid=$utm_wid) -> $out_dir/screenshot.png" + else + log " WARNING: host screencapture also failed" + fi + else + log " WARNING: no UTM window found on host — screenshot skipped" + fi fi # Unified log — lyra subsystem, last 10 minutes @@ -238,9 +275,8 @@ cmd_capture() { log " WARNING: process sample failed" fi - # Daemon log written by run-lyra - # shellcheck disable=SC2088 # ~ is in a remote scp path and intentionally expands on the guest - if scp_get "$ip" "~/.lyra-vm-daemon.log" "$out_dir/daemon.log" 2>/dev/null; then + # Daemon log written by run-lyra (always at /tmp — sudo resets HOME to /var/root) + if scp_get "$ip" "/tmp/lyra-vm-daemon.log" "$out_dir/daemon.log" 2>/dev/null; then log " daemon log -> $out_dir/daemon.log" else log " (no daemon.log — daemon may not have been started via run-lyra)" @@ -255,8 +291,9 @@ cmd_restore() { local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" # Stop the daemon we started, if any - ssh_run "$ip" "pid=\"\$(cat ~/.lyra-vm-daemon.pid 2>/dev/null)\"; \ - [ -n \"\$pid\" ] && kill \"\$pid\" 2>/dev/null; rm -f ~/.lyra-vm-daemon.pid" \ + # sudo required: daemon is root-owned (launched via 'sudo launchctl asuser') + ssh_run "$ip" "pid=\"\$(cat /tmp/lyra-vm-daemon.pid 2>/dev/null)\"; \ + [ -n \"\$pid\" ] && sudo kill \"\$pid\" 2>/dev/null; rm -f /tmp/lyra-vm-daemon.pid" \ 2>/dev/null || true # Restore brew service to its prior state diff --git a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift index 8db002b8..7bfd6fb4 100644 --- a/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift +++ b/Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift @@ -110,7 +110,14 @@ extension YouTubeWallpaperDataSourceImpl { extension YouTubeWallpaperDataSourceImpl { func buildArgs(tool: Tool, url: URL, maxHeight: Int, format: String, destPath: String) -> [String] { let ytdlpArgs = [ - "-f", "bestvideo[ext=\(format)][height<=\(maxHeight)][vcodec^=avc]", + // Use the Android player client — the default web client triggers YouTube SABR + // streaming (HTTP 403). Android avoids SABR, but video-only formats require a + // GVS PO Token that we don't have, so they get skipped. Adding + // best[ext=format][height<=maxHeight] as fallback picks the combined A/V format + // (e.g. format 18) when video-only streams are unavailable. --no-audio is a + // no-op for combined formats and still suppresses separate audio downloads. + "--extractor-args", "youtube:player_client=android", + "-f", "bestvideo[ext=\(format)][height<=\(maxHeight)][vcodec^=avc]/best[ext=\(format)][height<=\(maxHeight)]", "--no-audio", "-o", destPath, url.absoluteString, From 187d40374b9e616c7ab0ee0eebcceefef39334c4 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 19:00:28 +0900 Subject: [PATCH 11/12] chore: bump version to 2.13.14 --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 55fcfb7f..5658920e 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.13 +2.13.14 From e98e9047b0d878f6a9c13bde2b8c5aa1e0d12314 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 19:10:14 +0900 Subject: [PATCH 12/12] fix(harness): guard IP poll, validate daemon liveness, install to /tmp --- .claude/scripts/lyra-vm-harness.sh | 122 +++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 14 deletions(-) diff --git a/.claude/scripts/lyra-vm-harness.sh b/.claude/scripts/lyra-vm-harness.sh index 86dc65dc..0420c9de 100755 --- a/.claude/scripts/lyra-vm-harness.sh +++ b/.claude/scripts/lyra-vm-harness.sh @@ -98,7 +98,7 @@ wait_for_ssh() { local ip="" log "Waiting for $vm to be reachable via SSH (timeout: ${LYRA_VM_BOOT_TIMEOUT}s)..." while [[ $SECONDS -lt $deadline ]]; do - ip="$(vm_ip "$vm")" + ip="$(vm_ip "$vm")" || true if [[ -n "$ip" ]] && ssh_run "$ip" exit 0 2>/dev/null; then log "SSH ready at $ip" echo "$ip" @@ -168,9 +168,14 @@ cmd_run_lyra() { scp_put_r "$ip" "$bundle_dir" "/tmp/lyra-drop/" done - log "Installing on guest..." - ssh_run "$ip" "sudo install -m 755 /tmp/lyra-drop/lyra /usr/local/bin/lyra" - ssh_run "$ip" "for b in /tmp/lyra-drop/*.bundle; do [ -d \"\$b\" ] && sudo cp -r \"\$b\" /usr/local/bin/; done" + log "Installing on guest (isolated under /tmp/lyra-vm-test to avoid touching brew-managed binaries)..." + # Install to /tmp rather than /usr/local/bin so the Homebrew-managed binary is + # never overwritten. restore therefore does not need to recover a clobbered + # brew binary, and the fix works identically on both Intel and Apple Silicon guests. + # Bundle resources must live next to the binary for Bundle.module lookups. + 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" + ssh_run "$ip" "for b in /tmp/lyra-drop/*.bundle; do [ -d \"\$b\" ] && cp -r \"\$b\" /tmp/lyra-vm-test/; done" log "Saving current lyra service state on guest..." local prior_state @@ -200,12 +205,25 @@ cmd_run_lyra() { # Use fixed /tmp paths for log and PID — sudo resets HOME to /var/root so # "$HOME" in the launcher would point there, making the files invisible to the # SSH login user (babu). /tmp is always writable and readable by all users. - ssh_run "$ip" "printf '#!/bin/sh\nexport UV_CACHE_DIR=/tmp/lyra-vm-uv-cache\nnohup /usr/local/bin/lyra daemon > /tmp/lyra-vm-daemon.log 2>&1 &\necho \"\$!\" > /tmp/lyra-vm-daemon.pid\n' > /tmp/lyra-vm-launch.sh && chmod +x /tmp/lyra-vm-launch.sh" + ssh_run "$ip" "printf '#!/bin/sh\nexport UV_CACHE_DIR=/tmp/lyra-vm-uv-cache\nnohup /tmp/lyra-vm-test/lyra daemon > /tmp/lyra-vm-daemon.log 2>&1 &\necho \"\$!\" > /tmp/lyra-vm-daemon.pid\n' > /tmp/lyra-vm-launch.sh && chmod +x /tmp/lyra-vm-launch.sh" + # Truncate the root-owned log before each launch so polling for new events works + # correctly. babu cannot truncate root-owned files; sudo is required. + ssh_run "$ip" "sudo truncate -s 0 /tmp/lyra-vm-daemon.log 2>/dev/null || true" ssh_run "$ip" "sudo launchctl asuser \$(id -u) /tmp/lyra-vm-launch.sh" sleep 3 local pid pid="$(ssh_run "$ip" "cat /tmp/lyra-vm-daemon.pid 2>/dev/null || printf '?'")" + if [[ "$pid" == "?" ]]; then + log "WARNING: PID file not written — daemon may have failed to start" + log " daemon log:"; ssh_run "$ip" "cat /tmp/lyra-vm-daemon.log 2>/dev/null" >&2 || true + die "run-lyra: daemon did not start (no PID file)" + fi + if ! ssh_run "$ip" "kill -0 '$pid' 2>/dev/null"; then + log "WARNING: PID $pid is no longer alive — daemon crashed at startup" + log " daemon log:"; ssh_run "$ip" "cat /tmp/lyra-vm-daemon.log 2>/dev/null" >&2 || true + die "run-lyra: daemon exited immediately (PID=$pid)" + fi log "lyra daemon running on guest (PID=$pid)" } @@ -245,11 +263,17 @@ if let best = candidates.first { print(best.0) } SWIFT )" if [[ -n "$utm_wid" ]]; then + # caffeinate -u prevents the display from going dark during capture. + # Without it, macOS may dim/blank the display on inactivity, causing + # screencapture -l to return a black frame even though the window has content. + caffeinate -u -t 3 & + local caff_pid=$! if screencapture -l "$utm_wid" "$out_dir/screenshot.png" 2>/dev/null; then log " screenshot (host UTM wid=$utm_wid) -> $out_dir/screenshot.png" else log " WARNING: host screencapture also failed" fi + kill "$caff_pid" 2>/dev/null || true else log " WARNING: no UTM window found on host — screenshot skipped" fi @@ -342,21 +366,91 @@ cmd_ip() { printf '%s\n' "$ip" } +# capture-loading: clear wallpaper cache, restart daemon, then take a screenshot +# at the moment yt-dlp begins its mandatory 6-second sleep — the loading indicator +# should be visible during this window. Polls the daemon log (rather than sleeping +# a fixed number of seconds) so the capture is not sensitive to build/boot timing. +cmd_capture_loading() { + local vm="${1:-}"; require_vm "$vm" + local out_dir="${2:-$LYRA_VM_ARTIFACTS_DIR}" + local ip; ip="$(vm_ip "$vm")" || die "Cannot determine IP for $vm" + mkdir -p "$out_dir" + + log "Clearing wallpaper cache on guest (both root and login-user locations)..." + ssh_run "$ip" "sudo rm -rf /private/var/root/.cache/lyra/wallpapers/ /Users/${LYRA_VM_SSH_USER}/.cache/lyra/wallpapers/ 2>/dev/null || true" + + log "Restarting lyra daemon..." + local prev_pid + prev_pid="$(ssh_run "$ip" "pgrep -x lyra | head -1" 2>/dev/null || echo "")" + [[ -n "$prev_pid" ]] && ssh_run "$ip" "sudo kill '$prev_pid' 2>/dev/null || true" 2>/dev/null + sleep 0.5 + ssh_run "$ip" "sudo truncate -s 0 /tmp/lyra-vm-daemon.log 2>/dev/null || true" + ssh_run "$ip" "sudo launchctl asuser \$(id -u) /tmp/lyra-vm-launch.sh" + + log "Polling daemon log for yt-dlp download start (timeout 30s)..." + local deadline=$(( SECONDS + 30 )) + local detected=false + while [[ $SECONDS -lt $deadline ]]; do + if ssh_run "$ip" "cat /tmp/lyra-vm-daemon.log 2>/dev/null" 2>/dev/null \ + | grep -q "Sleeping 6.00 seconds"; then + detected=true + break + fi + sleep 0.5 + done + + if ! $detected; then + log "WARNING: yt-dlp download start not detected within 30s — screenshotting anyway" + else + log "yt-dlp download detected — capturing loading indicator screenshot" + fi + + # caffeinate prevents display dimming while screencapture runs + local utm_wid + utm_wid="$(swift - 2>/dev/null <<'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 +)" + if [[ -n "$utm_wid" ]]; then + caffeinate -u -t 3 & + local caff_pid=$! + if screencapture -l "$utm_wid" "$out_dir/loading-indicator.png" 2>/dev/null; then + log " loading indicator screenshot -> $out_dir/loading-indicator.png" + else + log " WARNING: screencapture failed" + fi + kill "$caff_pid" 2>/dev/null || true + else + log " WARNING: no UTM window found — screenshot skipped" + fi +} + # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- SUBCOMMAND="${1:-}"; shift || true case "$SUBCOMMAND" in - boot) cmd_boot "$@" ;; - shutdown) cmd_shutdown "$@" ;; - reboot) cmd_reboot "$@" ;; - run-lyra) cmd_run_lyra "$@" ;; - capture) cmd_capture "$@" ;; - restore) cmd_restore "$@" ;; - play-music) cmd_play_music "$@" ;; - exec) cmd_exec "$@" ;; - ip) cmd_ip "$@" ;; + boot) cmd_boot "$@" ;; + shutdown) cmd_shutdown "$@" ;; + reboot) cmd_reboot "$@" ;; + run-lyra) cmd_run_lyra "$@" ;; + capture) cmd_capture "$@" ;; + capture-loading) cmd_capture_loading "$@" ;; + restore) cmd_restore "$@" ;; + play-music) cmd_play_music "$@" ;; + exec) cmd_exec "$@" ;; + ip) cmd_ip "$@" ;; "") die "Subcommand required. See .claude/rules/vm-verification.md for usage." ;; *) die "Unknown subcommand: $SUBCOMMAND" ;; esac