diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index be85773..affcaa1 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -157,6 +157,10 @@ jobs: set -euo pipefail ./target/debug/iphone-use-mcp flow validate examples/flows/open-spotlight.json + - name: Device runner (unsigned build + coherence) + shell: bash + run: bash runner/ci-check.sh + - name: Shell and Python helper tests shell: bash run: | @@ -166,14 +170,18 @@ jobs: # (agent reference lives in docs/agent-reference.md). test "$(find skills/iphone-use -type f | wc -l | tr -d '[:space:]')" = 1 test -f skills/iphone-use/SKILL.md - bash -n install.sh - bash -n uninstall.sh - bash -n scripts/setup-wda.sh + # /bin/bash is the 3.2 that launchd and users run; Homebrew bash 5 + # accepts constructs it rejects (an apostrophe in a heredoc inside + # $(...) broke the runner supervisor once). + /bin/bash -n install.sh + /bin/bash -n uninstall.sh + /bin/bash -n scripts/setup-wda.sh bash scripts/test-install-release-transaction.sh bash scripts/test-install-cli-link.sh bash scripts/test-setup-wda-warp-preflight.sh bash scripts/test-setup-wda-lock-backoff.sh - bash scripts/test-setup-wda-icon-injection.sh + bash scripts/test-setup-wda-runner-build.sh + bash scripts/test-install-runner-sources.sh bash scripts/test-setup-wda-proxy-preflight.sh bash scripts/test-setup-wda-probe-threshold.sh bash scripts/test-setup-wda-runner-repair.sh diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index bcf0240..6dacc31 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -43,12 +43,14 @@ jobs: bash scripts/test-install-release-transaction.sh bash scripts/test-setup-wda-warp-preflight.sh bash scripts/test-setup-wda-lock-backoff.sh - bash scripts/test-setup-wda-icon-injection.sh + bash scripts/test-setup-wda-runner-build.sh + bash scripts/test-install-runner-sources.sh bash scripts/test-setup-wda-runner-repair.sh bash scripts/test-setup-wda-probe-threshold.sh python3 scripts/test-setup-wda-status.py python3 scripts/test-setup-wda-runner-product.py python3 scripts/test-setup-wda-asc-signing.py + python3 scripts/test-setup-wda-runner-cache.py python3 scripts/test-auto-update.py skill_file_count="$( @@ -86,6 +88,15 @@ jobs: test "$GITHUB_REF_NAME" = "v$server_version" fi + # ── Device runner ─────────────────────────────────────────────────────── + # setup-wda.sh builds runner/IPhoneUseRunner on each user's Mac, so a + # release must prove it compiles (unsigned, no device), that its + # device-free logic passes, and that the names setup and uninstall rely + # on still match the project. + - name: Validate the device runner (unsigned build + coherence) + shell: bash + run: bash runner/ci-check.sh + # ── Rust toolchain ─────────────────────────────────────────────────────── - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -338,14 +349,35 @@ jobs: test "$(tar -tzf iphone-use-mcp-macos-universal.tar.gz)" = "iphone-use-mcp" rm -rf "$package_dir" + # install.sh lays these sources down at ~/.iphone-use/runner; every + # entry must sit under runner/ (the installer refuses anything else). + - name: Package device runner sources + shell: bash + run: | + set -euo pipefail + cd "$GITHUB_WORKSPACE" + COPYFILE_DISABLE=1 tar --exclude xcuserdata --exclude .DS_Store \ + -czf iphone-use-runner.tar.gz runner/IPhoneUseRunner runner/README.md + # (`! pipeline` would not stop the step under `set -e`; test explicitly.) + if tar -tzf iphone-use-runner.tar.gz | grep -v '^runner/'; then + echo "runner archive has entries outside runner/" >&2; exit 1 + fi + if tar -tvzf iphone-use-runner.tar.gz | grep -E '^[lh]'; then + echo "runner archive contains links" >&2; exit 1 + fi + tar -tzf iphone-use-runner.tar.gz \ + | grep -qx 'runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj' + - name: Generate SHA-256 checksums run: | cd "$GITHUB_WORKSPACE" shasum -a 256 iPhoneUse.app.zip > iPhoneUse.app.zip.sha256 shasum -a 256 iphone-use-mcp-macos-universal.tar.gz \ > iphone-use-mcp-macos-universal.tar.gz.sha256 + shasum -a 256 iphone-use-runner.tar.gz > iphone-use-runner.tar.gz.sha256 cat iPhoneUse.app.zip.sha256 cat iphone-use-mcp-macos-universal.tar.gz.sha256 + cat iphone-use-runner.tar.gz.sha256 # ── Release (tag push only) ──────────────────────────────────────────── # On a workflow_dispatch run there is no tag ref, so we upload as an @@ -359,6 +391,8 @@ jobs: iPhoneUse.app.zip.sha256 iphone-use-mcp-macos-universal.tar.gz iphone-use-mcp-macos-universal.tar.gz.sha256 + iphone-use-runner.tar.gz + iphone-use-runner.tar.gz.sha256 generate_release_notes: true draft: false prerelease: ${{ contains(github.ref_name, '-') }} @@ -375,4 +409,6 @@ jobs: iPhoneUse.app.zip.sha256 iphone-use-mcp-macos-universal.tar.gz iphone-use-mcp-macos-universal.tar.gz.sha256 + iphone-use-runner.tar.gz + iphone-use-runner.tar.gz.sha256 retention-days: 7 diff --git a/README.md b/README.md index 8a9f55d..9b0930c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ License: MIT Platform: macOS 15+ Built with Rust - Runs on WebDriverAgent + Runs on XCTest

@@ -26,7 +26,8 @@ iphone-use lets an AI agent see and operate a real iPhone: read the screen as te swipe and type, and get told plainly when an action did not land. It works on apps that have no API, including banking and payment apps that hide their screens from capture. -A daemon on your Mac runs WebDriverAgent on a USB-connected iPhone and exposes it as: +A daemon on your Mac runs its own XCTest-based device runner on a USB-connected iPhone +(it replaced WebDriverAgent and speaks the same API) and exposes it as: - an HTTP API for agents and scripts (`/agent/*`), - an MCP server with 23 tools for Claude Code, Claude Desktop and other MCP clients, @@ -42,7 +43,7 @@ works), an iPhone with Developer Mode on and trusted over USB, and ```bash curl -fsSL https://raw.githubusercontent.com/leeguooooo/iphone-use/main/install.sh | sh -~/.iphone-use/setup-wda.sh # builds and starts WDA; keep the phone unlocked +~/.iphone-use/setup-wda.sh # builds and starts the device runner; keep the phone unlocked ``` Then open `http://:44321/phone` and log in with the password the installer @@ -103,9 +104,10 @@ $MCP flow draft --out my-task.json # what you just did on the phone, as - [Full guide](docs/guide.md): browser and iOS app, flows and the flow registry, lifecycle, configuration, security, development. - [Agent API reference](docs/agent-api.html) · [MCP tools](crates/mcp/README.md) · - [Architecture](docs/direct-device-architecture.html) · [WDA setup](docs/wda-setup.html) -- Security in one line: the password protects port 44321 only; WDA's own ports on the - phone are unauthenticated, so use a trusted network ([details](docs/guide.md#security)). + [Architecture](docs/direct-device-architecture.html) · [Device setup pitfalls](docs/wda-setup.html) · + [Device runner](runner/README.md) +- Security in one line: the password protects port 44321 only; the runner's own ports on + the phone are unauthenticated, so use a trusted network ([details](docs/guide.md#security)). - Issues and ideas: [GitHub issues](https://github.com/leeguooooo/iphone-use/issues). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 22bb7ff..aaf867b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ 许可证:MIT 平台:macOS 15+ 使用 Rust 构建 - 基于 WebDriverAgent + 基于 XCTest

@@ -24,7 +24,7 @@ https://github.com/user-attachments/assets/a9947152-6655-4509-ac1e-49953a3cea70 iphone-use 让 AI agent 操作一台真 iPhone:把屏幕读成文字,点、滑、输入,操作没生效时直接告诉它。没有 API 的 App 也能用,包括禁止截屏的银行、支付类 App。 -Mac 上的守护进程通过 USB 在 iPhone 上运行 WebDriverAgent,对外提供: +Mac 上的守护进程通过 USB 在 iPhone 上运行自己的设备 runner(基于 XCTest,取代了 WebDriverAgent,接口兼容),对外提供: - 给 agent 和脚本用的 HTTP 接口(`/agent/*`); - 23 个工具的 MCP server,Claude Code、Claude Desktop 等 MCP 客户端直接接; @@ -38,7 +38,7 @@ Mac 上的守护进程通过 USB 在 iPhone 上运行 WebDriverAgent,对外提 ```bash curl -fsSL https://raw.githubusercontent.com/leeguooooo/iphone-use/main/install.sh | sh -~/.iphone-use/setup-wda.sh # 编译并启动 WDA,期间保持手机解锁 +~/.iphone-use/setup-wda.sh # 编译并启动设备 runner,期间保持手机解锁 ``` 然后打开 `http://:44321/phone`,用安装时打印的密码登录。USB、信任、VPN、签名有问题时,跑 `~/.iphone-use/setup-wda.sh doctor` 会告诉你卡在哪;以后升级用 `iphone-use upgrade`。 @@ -86,8 +86,8 @@ $MCP flow draft --out my-task.json # 把刚才在手机上做的事导 ## 更多 - [完整指南](docs/guide.zh-CN.md):网页和 iOS App、flow 与官方 flow 源、生命周期、配置、安全、开发。 -- [Agent API 参考](docs/agent-api.html) · [MCP 工具](crates/mcp/README.md) · [架构](docs/direct-device-architecture.html) · [WDA 配置](docs/wda-setup.html) -- 安全只说一句:密码只保护 44321 端口,手机上 WDA 自己的端口没有鉴权,只在可信网络里用([详情](docs/guide.zh-CN.md#安全))。 +- [Agent API 参考](docs/agent-api.html) · [MCP 工具](crates/mcp/README.md) · [架构](docs/direct-device-architecture.html) · [设备设置常见坑](docs/wda-setup.html) · [设备 runner](runner/README.md) +- 安全只说一句:密码只保护 44321 端口,手机上 runner 自己的端口没有鉴权,只在可信网络里用([详情](docs/guide.zh-CN.md#安全))。 - 问题和建议:[GitHub issues](https://github.com/leeguooooo/iphone-use/issues)。 ## 许可证 diff --git a/docs/guide.md b/docs/guide.md index 9618e27..ea1c468 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -6,12 +6,14 @@ The README is the short version; this is everything else. ```text Browser <── GET /agent/mjpeg ── iphone-use daemon ── 127.0.0.1:9100 ──┐ -Browser ── POST /control ─────> iphone-use daemon ── 127.0.0.1:8100 ──┤ WDA on iPhone +Browser ── POST /control ─────> iphone-use daemon ── 127.0.0.1:8100 ──┤ device runner on iPhone Agent ── /agent/* ──────────> iphone-use daemon ── 127.0.0.1:8100 ──┘ ``` -- `scripts/setup-wda.sh` builds and signs WDA, starts the XCUITest runner on the phone, - and pins two `iproxy` loopback relays: `8100` for control, `9100` for the MJPEG screen. +- `scripts/setup-wda.sh` builds and signs the iphone-use **device runner** + (`runner/IPhoneUseRunner`, an XCTest UI-test bundle that replaced WebDriverAgent and + speaks the same HTTP API), starts it on the phone, and pins two `iproxy` loopback + relays: `8100` for control, `9100` for the MJPEG screen. The daemon only ever talks to localhost, so a background process never holds the phone's changing IP. USB is the supported path; Wi-Fi/`socat` is a manual experiment. - The browser gets the live picture from `/agent/mjpeg` (PNG stills as fallback) and @@ -19,8 +21,11 @@ Agent ── /agent/* ──────────> iphone-use daemon ── command instead of accepting it blindly over a possibly dead channel. - Agents read the accessibility tree as text (`/agent/elements`), screenshots as PNG, and act through `/agent/input` or a guarded multi-step batch (`/agent/actions`). -- The daemon owns the WDA lifecycle: it releases the phone after idle time, rebuilds - WDA with backoff, and reports every state in `/agent/status`. +- The daemon owns the runner lifecycle: it releases the phone after idle time, rebuilds + the runner with backoff, and reports every state in `/agent/status`. +- "WDA" below, in status fields (`wda`, `wda_actionable`, …) and in environment + variables (`WDA_*`, `PHONE_REMOTE_WDA_*`) is the historical name and now means this + device runner. Design, lifecycle, failure states, and security boundaries: **[`docs/direct-device-architecture.html`](direct-device-architecture.html)**. @@ -31,10 +36,12 @@ Design, lifecycle, failure states, and security boundaries: - macOS 15 or later, with **full Xcode.app** (Command Line Tools alone are not enough). Sign in under Xcode → Settings → Accounts and pick a development team; a free - Personal Team works, but its WDA profile needs periodic renewal. + Personal Team works, but its runner profile needs periodic renewal. An App Store + Connect API key (`WDA_ASC_KEY_PATH`, `WDA_ASC_KEY_ID`, `WDA_ASC_ISSUER_ID`) signs + without an Xcode account. - An iPhone with **Developer Mode** on, paired with and trusted by the Mac over USB. -- The phone **unlocked and awake** while WDA is built, launched, and used. WDA cannot - get past Face ID or the passcode. +- The phone **unlocked and awake** while the runner is built, launched, and used. It + cannot get past Face ID or the passcode. - `iproxy` from `brew install libimobiledevice`. - A Rust toolchain only if you build from source. @@ -45,19 +52,20 @@ curl -fsSL https://raw.githubusercontent.com/leeguooooo/iphone-use/main/install. ``` The installer fetches the latest GitHub Release, registers a per-user LaunchAgent, -writes the loopback WDA endpoints, installs the matching -agent skill, and drops the setup helper at `~/.iphone-use/setup-wda.sh`. It does not +writes the loopback runner endpoints, installs the matching agent skill, lays the +device runner sources down at `~/.iphone-use/runner`, and drops the setup helper at +`~/.iphone-use/setup-wda.sh`. It does not prove your team, phone, runner, and relays work together — that is the next step, with the phone connected, trusted, unlocked, and awake: ```bash ~/.iphone-use/setup-wda.sh doctor # explains any USB / trust / DDI / WARP blocker -~/.iphone-use/setup-wda.sh # build, sign, install, launch WDA, start relays +~/.iphone-use/setup-wda.sh # build, sign, install, launch the runner, start relays ~/.iphone-use/setup-wda.sh status ``` Then open **`http://:44321/setup`**. The built-in guide translates -`/agent/status` into the current blocker (USB, trust, developer service, WDA, external +`/agent/status` into the current blocker (USB, trust, developer service, runner, external host) without changing your VPN or running setup for you. Once the phone is drivable, continue to **`/phone`** and enter the password `install.sh` printed. @@ -71,7 +79,7 @@ WDA_UDID="$PHONE_REMOTE_UDID" ~/.iphone-use/setup-wda.sh ### Hand the phone back to yourself -WDA occupies the phone while it runs. To use the phone by hand, pause managed WDA and +The runner occupies the phone while it runs. To use the phone by hand, pause it and resume it before the next agent session: ```bash @@ -80,7 +88,7 @@ resume it before the next agent session: ``` Simpler still is the **交还 (hand back)** button in the web toolbar, or -`POST /agent/mode {"mode":"human"}`: the daemon stops WDA so the phone belongs to whoever +`POST /agent/mode {"mode":"human"}`: the daemon stops the runner so the phone belongs to whoever is holding it, and reports `human_handoff:true`; while that holds, agent input gets 409 `phone_handed_to_human` instead of restarting the runner under your fingers. The same button then reads **交给 agent** (give to agent); press it, or send `{"mode":"agent"}`, to @@ -91,7 +99,7 @@ live viewer it stops the runner and parks its supervisor, and the phone stays pa across logouts and reboots. A runner kept up around the clock is relaunched every time iOS kills it, and each launch asks for the passcode to enable UI automation — so the phone prompted all day while nobody was using it. The next agent request, or -`POST /agent/mode {"mode":"agent"}`, brings WDA back from the cached runner (no +`POST /agent/mode {"mode":"agent"}`, brings the runner back from its cached product (no rebuild) — unlock the phone if asked. On `/phone` a parked phone shows **连接手机** (connect phone); press it with the phone unlocked and awake. `PHONE_REMOTE_IDLE_RELEASE_SECS` changes the window; `0` keeps the runner up (v0.6.3–v0.7.3 behaviour). @@ -463,12 +471,12 @@ PHONE_REMOTE_INSTANCE=lab ~/.iphone-use/instances/lab/setup-wda.sh ``` The instance gets its own copy of the app, state directory -(`~/.iphone-use/instances/lab`, including its own WebDriverAgent checkout and so its own -DerivedData), launchd labels (`com.leeguoo.iphone-use.lab`, +(`~/.iphone-use/instances/lab`, including its own runner build products under +`runner-build/`; the sources at `~/.iphone-use/runner` are shared), launchd labels (`com.leeguoo.iphone-use.lab`, `com.leeguoo.iphone-use.wda.lab`), loopback-only daemon, agent token and ports. Ports are derived from the name and persisted. The installer prints them, and `PHONE_REMOTE_INSTANCE=lab setup-wda.sh instance-context` shows them later. Signing -(team, bundle ID, App Store Connect key) is inherited from the default instance's WDA +(team, bundle ID, App Store Connect key) is inherited from the default instance's runner supervisor unless set explicitly. A phone that another instance already drives, or a port another instance owns, is refused before anything changes. Agents target the instance with `PHONE_REMOTE_URL=http://127.0.0.1:` and that instance's @@ -535,14 +543,15 @@ signature could invalidate. | `PHONE_REMOTE_HOST` / `PHONE_REMOTE_PORT` | `127.0.0.1` / `44321` | Listen address and port (`0.0.0.0` for LAN; a password is then mandatory). | | `PHONE_REMOTE_PASSWORD` | *(none)* | Browser login; doubles as the agent bearer only when no agent token is set. | | `PHONE_REMOTE_AGENT_TOKEN` | *(none)* | Dedicated agent bearer. When set, it is the **only** accepted bearer. | -| `PHONE_REMOTE_UDID` | detected and persisted by the installer | Canonical iPhone for managed WDA and destructive commands. Requests cannot switch it; change the deployment and restart. Pass the same value as `WDA_UDID` to setup. | -| `PHONE_REMOTE_WDA_URL` / `PHONE_REMOTE_WDA_MJPEG_URL` | `http://127.0.0.1:8100` / `:9100` | WDA control and MJPEG loopbacks. Control fails closed when unreachable. | -| `PHONE_REMOTE_WDA_MANAGED` | on for loopback endpoints | Whether this daemon owns the WDA supervisor/relay lifecycle. | -| `PHONE_REMOTE_IDLE_RELEASE_SECS` | `300` | Stop WDA and park its supervisor after this many idle seconds; the next agent request starts it again. `0` keeps the runner up, at the cost of a passcode prompt each time iOS kills it. | +| `PHONE_REMOTE_UDID` | detected and persisted by the installer | Canonical iPhone for the managed runner and destructive commands. Requests cannot switch it; change the deployment and restart. Pass the same value as `WDA_UDID` to setup. | +| `PHONE_REMOTE_WDA_URL` / `PHONE_REMOTE_WDA_MJPEG_URL` | `http://127.0.0.1:8100` / `:9100` | Runner control and MJPEG loopbacks (any WebDriverAgent-compatible endpoint works). Control fails closed when unreachable. | +| `PHONE_REMOTE_WDA_MANAGED` | on for loopback endpoints | Whether this daemon owns the runner supervisor/relay lifecycle. | +| `PHONE_REMOTE_IDLE_RELEASE_SECS` | `300` | Stop the runner and park its supervisor after this many idle seconds; the next agent request starts it again. `0` keeps the runner up, at the cost of a passcode prompt each time iOS kills it. | | `PHONE_REMOTE_OWNER_LEASE_SECS` | `300` | How long an `X-Phone-Owner` lease lives without a refreshing request. | -| `WDA_RUNNER_ICON` | `auto` | Home-screen icon for the runner: `auto` reuses the app icon, `none` keeps WDA's placeholder, or a `.png`/`.icns` path. Failures only warn. | -| `PHONE_REMOTE_WDA_SNAPSHOT_MAX_DEPTH` | WDA default 50 | Bound the accessibility snapshot depth (try `20`–`30` for apps with huge trees, issue #44). | -| `PHONE_REMOTE_WDA_SNAPSHOT_TIMEOUT_S` | WDA default 15 | Bound snapshot resolution time so one oversized read fails instead of wedging the runner. | +| `IPU_RUNNER_SRC` | `~/.iphone-use/runner` | Device runner sources setup builds (a repo checkout's `scripts/setup-wda.sh` uses its own `runner/`). Persisted only when not the default. | +| `WDA_RUNNER_REBUILD` | off | `1` makes the next setup ignore the recorded runner product and build again. | +| `PHONE_REMOTE_WDA_SNAPSHOT_MAX_DEPTH` | runner default 64 | Applies to an external WebDriverAgent only; the device runner bounds its own tree reads (depth ladder, 5000 nodes). | +| `PHONE_REMOTE_WDA_SNAPSHOT_TIMEOUT_S` | — | Same: WebDriverAgent only. | | `PHONE_REMOTE_ELEMENTS_AFFORDANCES` | off | `1` adds sparse `actions`, `selected`, `min`/`max` to `/agent/elements` rows. | | `PHONE_REMOTE_ELEMENTS_TRAITS` | off | `1` also emits raw accessibility trait names. | | `PHONE_REMOTE_NO_UPDATE_CHECK` | off | Skip the daily release check. | @@ -552,14 +561,14 @@ signature could invalidate. The daemon exposes live phone control over the network; treat its URL and password as credentials. -- The password / cookie / bearer protects port `44321` only. **WDA's own `8100` and - `9100` on the phone have no authentication**, and the USB `iproxy` relay does not add +- The password / cookie / bearer protects port `44321` only. **The runner's own `8100` + and `9100` on the phone have no authentication**, and the USB `iproxy` relay does not add any — another host on the phone's Wi-Fi can reach them directly. Use it only on a trusted, isolated network; turning off iPhone Wi-Fi while on USB removes that exposure. - A real authenticated device transport is Phase 2 (a companion app or a controlled - tunnel). Until then, daemon login is not WDA protection. + tunnel). Until then, daemon login does not protect the runner. - From outside the LAN, reach `44321` through an authenticated HTTPS reverse proxy or a - trusted VPN/tunnel (Tailscale, for example) — never by exposing WDA's ports. The daemon + trusted VPN/tunnel (Tailscale, for example) — never by exposing the runner's ports. The daemon serves plain HTTP, honours `X-Forwarded-Proto`, and sets an `HttpOnly` + `SameSite=Lax` session cookie. - The owner lease (`X-Phone-Owner`) is coordination between cooperating sessions, not a @@ -591,13 +600,14 @@ Release: `scripts/release.sh 0.6.8` bumps the crates, runs the release gate's te | Path | What lives there | |---|---| -| `crates/server` | daemon: WDA control, MJPEG proxy, browser `/control`, agent API | +| `crates/server` | daemon: device control (WebDriverAgent protocol), MJPEG proxy, browser `/control`, agent API | +| `runner/` | the device runner: XCTest UI-test bundle serving the control API and MJPEG on the phone | | `crates/mcp` | `iphone-use-mcp`: MCP server, flow runner, registry client, `flow publish` / `report` | | `crates/core` | shared auth helpers | | `web/index.html` | browser client (MJPEG + `/control`) | | `skills/iphone-use` | the agent skill the installer ships | -| `scripts/`, `deploy/`, `install.sh` | WDA setup, packaging, LaunchAgent, bridge-shortcut generator | -| `docs/` | architecture, agent API reference, WDA setup, flows research | +| `scripts/`, `deploy/`, `install.sh` | runner setup, packaging, LaunchAgent, bridge-shortcut generator | +| `docs/` | architecture, agent API reference, device setup pitfalls, flows research | ### Roadmap diff --git a/docs/guide.zh-CN.md b/docs/guide.zh-CN.md index e570305..3e27822 100644 --- a/docs/guide.zh-CN.md +++ b/docs/guide.zh-CN.md @@ -6,14 +6,15 @@ README 是精简版,这里是全部细节。 ```text 浏览器 <── GET /agent/mjpeg ── iphone-use daemon ── 127.0.0.1:9100 ──┐ -浏览器 ── POST /control ─────> iphone-use daemon ── 127.0.0.1:8100 ──┤ iPhone 上的 WDA +浏览器 ── POST /control ─────> iphone-use daemon ── 127.0.0.1:8100 ──┤ iPhone 上的设备 runner Agent ── /agent/* ──────────> iphone-use daemon ── 127.0.0.1:8100 ──┘ ``` -- `scripts/setup-wda.sh` 编译并签名 WDA,在手机上启动 XCUITest runner,用 `iproxy` 建两条固定的 loopback 中继:`8100` 控制,`9100` MJPEG 画面。daemon 只和 localhost 说话,后台进程手里不会攥着一个会变的手机 IP。USB 是唯一支持的路径,Wi-Fi 或 `socat` 属于手动实验。 +- `scripts/setup-wda.sh` 编译并签名 iphone-use 的**设备 runner**(`runner/IPhoneUseRunner`,一个 XCTest UI 测试包,取代了 WebDriverAgent,HTTP 接口与它兼容),在手机上启动它,用 `iproxy` 建两条固定的 loopback 中继:`8100` 控制,`9100` MJPEG 画面。daemon 只和 localhost 说话,后台进程手里不会攥着一个会变的手机 IP。USB 是唯一支持的路径,Wi-Fi 或 `socat` 属于手动实验。 - 浏览器从 `/agent/mjpeg` 拿实时画面(失败时退回 PNG 静帧),通过 `POST /control` 发输入,每条命令都返回成功或失败,不会把一个可能已经断掉的通道当成功。 - agent 用 `/agent/elements` 读文本形式的辅助功能树,用 `/agent/screenshot` 拿 PNG,用 `/agent/input` 做单步动作,或用 `/agent/actions` 跑一批带检查点的步骤。 -- daemon 负责 WDA 的生命周期:空闲后释放手机、带退避地重建 WDA、把每个状态写进 `/agent/status`。 +- daemon 负责 runner 的生命周期:空闲后释放手机、带退避地重建 runner、把每个状态写进 `/agent/status`。 +- 下文、状态字段(`wda`、`wda_actionable` 等)和环境变量(`WDA_*`、`PHONE_REMOTE_WDA_*`)里的 “WDA” 沿用旧名,指的就是这个设备 runner。 设计、生命周期、失败状态和安全边界见 **[`docs/direct-device-architecture.html`](direct-device-architecture.html)**。 @@ -21,9 +22,9 @@ Agent ── /agent/* ──────────> iphone-use daemon ── ### 前置条件 -- macOS 15 或更高,装**完整 Xcode.app**(只有 Command Line Tools 不够)。在 Xcode → Settings → Accounts 登录并选一个开发团队;免费 Personal Team 能用,但 WDA 的描述文件要定期续。 +- macOS 15 或更高,装**完整 Xcode.app**(只有 Command Line Tools 不够)。在 Xcode → Settings → Accounts 登录并选一个开发团队;免费 Personal Team 能用,但 runner 的描述文件要定期续。也可以用 App Store Connect API key(`WDA_ASC_KEY_PATH`、`WDA_ASC_KEY_ID`、`WDA_ASC_ISSUER_ID`)签名,不需要 Xcode 账号。 - iPhone 开启**开发者模式**,通过 USB 与 Mac 配对并点过信任。 -- 编译、启动、使用 WDA 期间手机保持**解锁、亮屏**。WDA 过不了 Face ID 和密码。 +- 编译、启动、使用 runner 期间手机保持**解锁、亮屏**。它过不了 Face ID 和密码。 - `brew install libimobiledevice` 装 `iproxy`。 - 只有从源码构建才需要 Rust 工具链。 @@ -33,15 +34,15 @@ Agent ── /agent/* ──────────> iphone-use daemon ── curl -fsSL https://raw.githubusercontent.com/leeguooooo/iphone-use/main/install.sh | sh ``` -安装器下载最新 GitHub Release,注册当前用户的 LaunchAgent,写入 loopback 的 WDA 地址,安装同版本的 agent skill,并把设置脚本放到 `~/.iphone-use/setup-wda.sh`。它不会证明你的团队、手机、runner 和中继能一起工作,那是下一步的事。手机连上、信任、解锁、亮屏后: +安装器下载最新 GitHub Release,注册当前用户的 LaunchAgent,写入 loopback 的 runner 地址,安装同版本的 agent skill,把设备 runner 的源码放到 `~/.iphone-use/runner`,并把设置脚本放到 `~/.iphone-use/setup-wda.sh`。它不会证明你的团队、手机、runner 和中继能一起工作,那是下一步的事。手机连上、信任、解锁、亮屏后: ```bash ~/.iphone-use/setup-wda.sh doctor # 解释当前的 USB / 信任 / DDI / WARP 阻塞项 -~/.iphone-use/setup-wda.sh # 编译、签名、安装、启动 WDA,拉起中继 +~/.iphone-use/setup-wda.sh # 编译、签名、安装、启动 runner,拉起中继 ~/.iphone-use/setup-wda.sh status ``` -然后打开 **`http://:44321/setup`**。内置向导把 `/agent/status` 翻译成当前的阻塞项(USB、信任、开发者服务、WDA、外部主机),它不会改你的 VPN,也不会替你跑 setup。手机可驱动后进 **`/phone`**,输入 `install.sh` 打印的密码。 +然后打开 **`http://:44321/setup`**。内置向导把 `/agent/status` 翻译成当前的阻塞项(USB、信任、开发者服务、runner、外部主机),它不会改你的 VPN,也不会替你跑 setup。手机可驱动后进 **`/phone`**,输入 `install.sh` 打印的密码。 配对了多台 iPhone?两处固定同一个 classic UDID: @@ -53,16 +54,16 @@ WDA_UDID="$PHONE_REMOTE_UDID" ~/.iphone-use/setup-wda.sh ### 把手机还给自己 -WDA 运行期间会占着手机。想自己用手机,先暂停托管的 WDA,下次 agent 用之前再恢复: +runner 运行期间会占着手机。想自己用手机,先暂停它,下次 agent 用之前再恢复: ```bash ~/.iphone-use/setup-wda.sh pause # 禁用 launchd job,只停 PID 校验通过的进程 ~/.iphone-use/setup-wda.sh resume ``` -更省事的是网页控制栏里的 **交还** 按钮(或 `POST /agent/mode {"mode":"human"}`):daemon 停掉 WDA,手机归拿着它的人用,状态里 `human_handoff:true`。这期间 agent 的输入请求一律 409 `phone_handed_to_human`,不会在你用着的时候把手机抢回去。交还之后同一个按钮变成 **交给 agent**,点它(或 `{"mode":"agent"}`)手机重新交给远程控制。 +更省事的是网页控制栏里的 **交还** 按钮(或 `POST /agent/mode {"mode":"human"}`):daemon 停掉 runner,手机归拿着它的人用,状态里 `human_handoff:true`。这期间 agent 的输入请求一律 409 `phone_handed_to_human`,不会在你用着的时候把手机抢回去。交还之后同一个按钮变成 **交给 agent**,点它(或 `{"mode":"agent"}`)手机重新交给远程控制。 -daemon 也会自己交还:5 分钟没有 agent 活动(释放后很快又被用到,下次的空闲时长会自动翻倍,最长 1 小时)、也没有人在看画面,它就停掉 runner 并停放 supervisor,注销、重启之后也保持停放。runner 常驻的话,iOS 每杀一次它就被拉起一次,每次拉起 iOS 都要输锁屏密码才能开 UI 自动化,结果没人用的时候手机也整天弹密码。下一次 agent 请求或 `POST /agent/mode {"mode":"agent"}` 会用缓存的 runner 把 WDA 拉回来(不重新编译),手机锁了就解一下。网页 `/phone` 上停放中的手机会显示 **连接手机**,手机解锁、亮屏后点它即可。`PHONE_REMOTE_IDLE_RELEASE_SECS` 改空闲时长,`0` 表示常驻(v0.6.3–v0.7.3 的行为)。 +daemon 也会自己交还:5 分钟没有 agent 活动(释放后很快又被用到,下次的空闲时长会自动翻倍,最长 1 小时)、也没有人在看画面,它就停掉 runner 并停放 supervisor,注销、重启之后也保持停放。runner 常驻的话,iOS 每杀一次它就被拉起一次,每次拉起 iOS 都要输锁屏密码才能开 UI 自动化,结果没人用的时候手机也整天弹密码。下一次 agent 请求或 `POST /agent/mode {"mode":"agent"}` 会用缓存的 runner 产物把它拉回来(不重新编译),手机锁了就解一下。网页 `/phone` 上停放中的手机会显示 **连接手机**,手机解锁、亮屏后点它即可。`PHONE_REMOTE_IDLE_RELEASE_SECS` 改空闲时长,`0` 表示常驻(v0.6.3–v0.7.3 的行为)。 ### 升级 @@ -289,11 +290,12 @@ iphone-use upgrade # 或者:curl -fsSL https://raw.githubusercontent.com/le | `PHONE_REMOTE_UDID` | 安装器识别并持久化 | 托管 WDA 和破坏性命令使用的 canonical iPhone。请求不能临时换机,要改就改部署并重启。setup 时传同值 `WDA_UDID`。 | | `PHONE_REMOTE_WDA_URL` / `PHONE_REMOTE_WDA_MJPEG_URL` | `http://127.0.0.1:8100` / `:9100` | WDA 控制和 MJPEG 的 loopback。不可达时控制请求直接失败。 | | `PHONE_REMOTE_WDA_MANAGED` | loopback 端点默认开 | daemon 是否负责 WDA supervisor / 中继的生命周期。 | -| `PHONE_REMOTE_IDLE_RELEASE_SECS` | `300` | 空闲多少秒后停 WDA 并停放 supervisor,下一次 agent 请求再拉起;`0` 表示常驻,代价是 iOS 每杀一次 runner 就弹一次密码。 | +| `PHONE_REMOTE_IDLE_RELEASE_SECS` | `300` | 空闲多少秒后停 runner 并停放 supervisor,下一次 agent 请求再拉起;`0` 表示常驻,代价是 iOS 每杀一次 runner 就弹一次密码。 | | `PHONE_REMOTE_OWNER_LEASE_SECS` | `300` | `X-Phone-Owner` 租约在没有请求刷新时的存活时间。 | -| `WDA_RUNNER_ICON` | `auto` | runner 的桌面图标:`auto` 用 app 图标,`none` 用 WDA 占位图,或给 `.png` / `.icns` 路径。失败只警告。 | -| `PHONE_REMOTE_WDA_SNAPSHOT_MAX_DEPTH` | WDA 默认 50 | 限制辅助功能快照深度(树特别大的 app 试 `20`–`30`,issue #44)。 | -| `PHONE_REMOTE_WDA_SNAPSHOT_TIMEOUT_S` | WDA 默认 15 | 限制快照解析时间,让一次过大的读取失败而不是卡死 runner。 | +| `IPU_RUNNER_SRC` | `~/.iphone-use/runner` | setup 编译的设备 runner 源码(仓库里的 `scripts/setup-wda.sh` 用仓库自己的 `runner/`)。只有不是默认值时才持久化。 | +| `WDA_RUNNER_REBUILD` | 关 | `1` 让下一次 setup 忽略记录的 runner 产物、重新编译。 | +| `PHONE_REMOTE_WDA_SNAPSHOT_MAX_DEPTH` | runner 默认 64 | 只对外部 WebDriverAgent 生效;设备 runner 自己限制读树(深度阶梯、5000 个节点)。 | +| `PHONE_REMOTE_WDA_SNAPSHOT_TIMEOUT_S` | — | 同上,只对 WebDriverAgent 生效。 | | `PHONE_REMOTE_ELEMENTS_AFFORDANCES` | 关 | `1` 给 `/agent/elements` 的行加稀疏的 `actions`、`selected`、`min` / `max`。 | | `PHONE_REMOTE_ELEMENTS_TRAITS` | 关 | `1` 再输出原始的辅助功能 trait 名。 | | `PHONE_REMOTE_NO_UPDATE_CHECK` | 关 | 跳过每日 release 检查。 | @@ -302,9 +304,9 @@ iphone-use upgrade # 或者:curl -fsSL https://raw.githubusercontent.com/le daemon 把手机的实时控制放到了网络上,它的 URL 和密码要当凭据对待。 -- 密码 / cookie / bearer 只保护 `44321`。**手机上 WDA 自己的 `8100` 和 `9100` 没有鉴权**,USB `iproxy` 中继也不会加,手机所在 Wi-Fi 里的另一台机器能直接连过去。只在可信、隔离的网络里用;走 USB 时关掉手机 Wi-Fi 就没有这层暴露。 -- 真正带鉴权的设备传输属于 Phase 2(companion app 或受控隧道)。在此之前,daemon 的登录不等于 WDA 的保护。 -- 从局域网外访问时,经由带鉴权的 HTTPS 反向代理,或可信的 VPN / 隧道(比如 Tailscale)连到 `44321`,绝不要把 WDA 的端口暴露出去。daemon 只提供明文 HTTP,识别 `X-Forwarded-Proto`,session cookie 是 `HttpOnly` + `SameSite=Lax`。 +- 密码 / cookie / bearer 只保护 `44321`。**手机上 runner 自己的 `8100` 和 `9100` 没有鉴权**,USB `iproxy` 中继也不会加,手机所在 Wi-Fi 里的另一台机器能直接连过去。只在可信、隔离的网络里用;走 USB 时关掉手机 Wi-Fi 就没有这层暴露。 +- 真正带鉴权的设备传输属于 Phase 2(companion app 或受控隧道)。在此之前,daemon 的登录保护不到 runner。 +- 从局域网外访问时,经由带鉴权的 HTTPS 反向代理,或可信的 VPN / 隧道(比如 Tailscale)连到 `44321`,绝不要把 runner 的端口暴露出去。daemon 只提供明文 HTTP,识别 `X-Forwarded-Proto`,session cookie 是 `HttpOnly` + `SameSite=Lax`。 - owner 租约(`X-Phone-Owner`)是协作会话之间的协调机制,不是安全边界。 - 开放访问期间不要停在支付、私聊或 2FA 画面。不用时停掉 LaunchAgent。 @@ -329,13 +331,14 @@ PHONE_REMOTE_HOST=0.0.0.0 PHONE_REMOTE_PASSWORD=secret ./target/release/iphone-u | 路径 | 内容 | |---|---| -| `crates/server` | daemon:WDA 控制、MJPEG 代理、浏览器 `/control`、agent API | +| `crates/server` | daemon:设备控制(WebDriverAgent 协议)、MJPEG 代理、浏览器 `/control`、agent API | +| `runner/` | 设备 runner:在手机上提供控制接口和 MJPEG 的 XCTest UI 测试包 | | `crates/mcp` | `iphone-use-mcp`:MCP server、flow 运行器、源客户端、`flow publish` / `report` | | `crates/core` | 共用的鉴权工具 | | `web/index.html` | 浏览器客户端(MJPEG + `/control`) | | `skills/iphone-use` | 安装器随包交付的 agent skill | -| `scripts/`、`deploy/`、`install.sh` | WDA 设置、打包、LaunchAgent、桥接快捷指令生成器 | -| `docs/` | 架构、agent API 参考、WDA 设置、flow 调研 | +| `scripts/`、`deploy/`、`install.sh` | runner 设置、打包、LaunchAgent、桥接快捷指令生成器 | +| `docs/` | 架构、agent API 参考、设备设置常见坑、flow 调研 | ### 路线 diff --git a/docs/wda-setup.html b/docs/wda-setup.html index df52f97..1121d1b 100644 --- a/docs/wda-setup.html +++ b/docs/wda-setup.html @@ -46,6 +46,12 @@

WDA 接入 — 边界与真机验收

127.0.0.1:8100,手机端 MJPEG 画面走 127.0.0.1:9100。 浏览器看 /agent/mjpeg、控 /control,agent 走 /agent/*。 daemon 不碰 Mac 的屏幕、光标和焦点,也不需要任何 macOS 权限授权。

+

更新:iphone-use 已用自己的设备 runner + (runner/IPhoneUseRunner,基于 XCTest,接口与 WDA 兼容,见 + runner/README.md)取代 WebDriverAgent。 + setup-wda.sh 不再拉取、钉版或改图标 WDA,而是编译安装目录里的 + ~/.iphone-use/runner;端口、中继、签名、锁屏、信任、DDI、WARP 等 + 下面记录的坑全部照旧适用,文中的 “WDA” 读作 “设备 runner” 即可。

工作原理

diff --git a/install.sh b/install.sh index a33a9b4..66fd21a 100755 --- a/install.sh +++ b/install.sh @@ -639,6 +639,7 @@ _installer_cleanup() { _restore_old_daemon_plist _restore_uninstall _restore_setup_wda + _restore_runner_sources _restore_app _restore_wda_transition || true _restore_daemon_runtime @@ -653,6 +654,8 @@ _installer_cleanup() { [ -z "$SETUP_WDA_DL" ] || rm -f "$SETUP_WDA_DL" 2>/dev/null || true [ -z "$UNINSTALL_STAGE" ] || rm -f "$UNINSTALL_STAGE" 2>/dev/null || true [ -z "$UNINSTALL_DL" ] || rm -f "$UNINSTALL_DL" 2>/dev/null || true + [ -z "$RUNNER_SRC_STAGE" ] || rm -rf "$RUNNER_SRC_STAGE" 2>/dev/null || true + [ -z "$RUNNER_SRC_DL" ] || rm -rf "$RUNNER_SRC_DL" 2>/dev/null || true [ -z "$SKILL_STAGE_DIR" ] || rm -rf "$SKILL_STAGE_DIR" 2>/dev/null || true [ -z "$SKILL_EXPECTED_DL" ] || rm -f "$SKILL_EXPECTED_DL" 2>/dev/null || true [ -z "$TMPDIR_INSTALL" ] || rm -rf "$TMPDIR_INSTALL" 2>/dev/null || true @@ -665,6 +668,8 @@ _installer_cleanup() { || warn "setup-wda.sh recovery backup retained at: $SETUP_WDA_BACKUP" [ -z "$UNINSTALL_BACKUP" ] \ || warn "uninstall.sh recovery backup retained at: $UNINSTALL_BACKUP" + [ -z "$RUNNER_SRC_BACKUP" ] || [ ! -d "$RUNNER_SRC_BACKUP" ] \ + || warn "Device runner source recovery backup retained at: $RUNNER_SRC_BACKUP" [ -z "$OLD_DISABLED_BACKUP" ] \ || warn "Legacy disabled-plist recovery backup retained at: $OLD_DISABLED_BACKUP" [ -z "$OLD_PLIST_STAGED" ] \ @@ -972,6 +977,157 @@ download_verified_release_app() { extract_verified_release_app_archive "$ref" "$zip" "$destination" } +# ── Device runner sources ───────────────────────────────────────────────────── +# setup-wda.sh builds the iphone-use device runner (an XCTest UI-test bundle) +# from sources at ~/.iphone-use/runner. They come from the same local checkout +# or exact release as this installer: a local `runner/` directory, or the +# release asset iphone-use-runner.tar.gz verified against its .sha256 file. +# The swap joins the install transaction: the previous tree is kept aside +# until the daemon commits, and put back on any failure. +RUNNER_ASSET_NAME="iphone-use-runner.tar.gz" +RUNNER_SRC_DST="$HOME/.iphone-use/runner" +RUNNER_SRC_STAGE="" +RUNNER_SRC_BACKUP="" +RUNNER_SRC_DL="" +RUNNER_SRC_HAD_EXISTING=0 +RUNNER_SRC_REPLACED=0 +RUNNER_SRC_COMMITTED=0 + +# A runner tree: IPhoneUseRunner/ with its project file, and no symlinks. +runner_tree_valid() { + local dir="$1" + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + [ -d "$dir/IPhoneUseRunner" ] && [ ! -L "$dir/IPhoneUseRunner" ] || return 1 + [ -f "$dir/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj" ] || return 1 + ! find "$dir" -type l -print -quit 2>/dev/null | grep -q . +} + +# Downloads and verifies the release's runner sources into $2/runner. +# Returns 1 (without dying) only when the release has no such asset, so a +# release that predates the device runner can still be installed. +download_verified_release_runner() { + local ref="$1" + local destination="$2" + local asset_url="https://github.com/$REPO/releases/download/$ref/$RUNNER_ASSET_NAME" + local tarball="$destination/$RUNNER_ASSET_NAME" + local expected actual + mkdir -p "$destination" + info "Downloading $asset_url ..." + curl -fsSL "$asset_url" -o "$tarball" 2>/dev/null || return 1 + curl -fsSL "${asset_url}.sha256" -o "$tarball.sha256" \ + || die "Release $ref is missing the required $RUNNER_ASSET_NAME.sha256 file." + expected="$(awk 'NR == 1 { print $1 }' "$tarball.sha256")" + printf '%s' "$expected" | grep -Eq '^[0-9A-Fa-f]{64}$' \ + || die "Release $ref has an invalid $RUNNER_ASSET_NAME checksum file." + actual="$(shasum -a 256 "$tarball" | awk '{print $1}')" + [ "$actual" = "$expected" ] \ + || die "SHA-256 mismatch for $RUNNER_ASSET_NAME in release $ref (expected $expected, got $actual)." + extract_verified_runner_archive "$ref" "$tarball" "$destination" + ok "SHA-256 verified for the release's device runner sources" +} + +# Only a runner/ tree, no absolute or parent paths, no links of any kind. +extract_verified_runner_archive() { + local ref="$1" + local tarball="$2" + local destination="$3" + local entries entry + entries="$(/usr/bin/tar -tzf "$tarball")" \ + || die "Could not read the $RUNNER_ASSET_NAME archive of release $ref." + [ -n "$entries" ] || die "Release $ref contains an empty $RUNNER_ASSET_NAME." + while IFS= read -r entry; do + case "$entry" in + runner|runner/|runner/*) ;; + *) die "Release $ref runner archive contains an unexpected entry: $entry" ;; + esac + case "$entry" in + */../*|*/..|../*|*//*|*/./*) + die "Release $ref runner archive contains an unsafe path: $entry" + ;; + esac + done </dev/null || true + chmod -R go-w "$RUNNER_SRC_STAGE" && chmod 700 "$RUNNER_SRC_STAGE" \ + || die "Could not secure the staged device runner sources." + runner_tree_valid "$RUNNER_SRC_STAGE" \ + || die "The staged device runner sources are incomplete." + if [ -e "$RUNNER_SRC_DST" ]; then + [ -d "$RUNNER_SRC_DST" ] || die "$RUNNER_SRC_DST exists but is not a directory." + RUNNER_SRC_BACKUP="$HOME/.iphone-use/.runner.backup.$$" + rm -rf "$RUNNER_SRC_BACKUP" + mv "$RUNNER_SRC_DST" "$RUNNER_SRC_BACKUP" \ + || die "Could not move the previous device runner sources aside." + RUNNER_SRC_HAD_EXISTING=1 + fi + mv "$RUNNER_SRC_STAGE" "$RUNNER_SRC_DST" \ + || die "Could not install the device runner sources at $RUNNER_SRC_DST." + RUNNER_SRC_STAGE="" + RUNNER_SRC_REPLACED=1 + ok "Device runner sources atomically updated: $RUNNER_SRC_DST" +} + +# Where this install's runner sources come from: the local checkout, else the +# release asset. Sets RUNNER_SOURCE_DIR; returns 1 when neither has them (a +# release that predates the device runner). A bad checksum or archive dies. +RUNNER_SOURCE_DIR="" +resolve_runner_source() { + RUNNER_SOURCE_DIR="" + if [ "$SCRIPT_IS_LOCAL" = "1" ] && runner_tree_valid "$SCRIPT_DIR/runner"; then + RUNNER_SOURCE_DIR="$SCRIPT_DIR/runner" + return 0 + fi + ensure_release_ref + RUNNER_SRC_DL="$(mktemp -d)" + download_verified_release_runner "$RELEASE_REF" "$RUNNER_SRC_DL" || return 1 + RUNNER_SOURCE_DIR="$RUNNER_SRC_DL/runner" +} + +_restore_runner_sources() { + [ "$RUNNER_SRC_REPLACED" = "1" ] || return 0 + [ "$RUNNER_SRC_COMMITTED" = "0" ] || return 0 + rm -rf "$RUNNER_SRC_DST" 2>/dev/null || true + if [ "$RUNNER_SRC_HAD_EXISTING" = "1" ] && [ -d "$RUNNER_SRC_BACKUP" ]; then + if mv "$RUNNER_SRC_BACKUP" "$RUNNER_SRC_DST" 2>/dev/null; then + RUNNER_SRC_BACKUP="" + warn "Restored the previous device runner sources after an incomplete install." + else + warn "Could not restore the previous device runner sources from $RUNNER_SRC_BACKUP" + fi + fi + RUNNER_SRC_REPLACED=0 +} + +_finish_runner_sources() { + [ -z "$RUNNER_SRC_BACKUP" ] || rm -rf "$RUNNER_SRC_BACKUP" 2>/dev/null || true + RUNNER_SRC_BACKUP="" +} + _configure_skill_lock_paths() { local xdg_state="${XDG_STATE_HOME:-}" local xdg_root @@ -1628,6 +1784,7 @@ _commit_daemon_transaction() { PLIST_COMMITTED=1 SETUP_WDA_COMMITTED=1 UNINSTALL_COMMITTED=1 + RUNNER_SRC_COMMITTED=1 WDA_TRANSITION_COMMITTED=1 DAEMON_RUNTIME_COMMITTED=1 OLD_PLIST_COMMITTED=1 @@ -1700,6 +1857,12 @@ bootstrap_pinned_installer() { die "Release $ref contains a shell script that failed syntax validation." fi download_verified_release_app "$ref" "$asset_dir" + # A release whose setup-wda.sh builds the device runner ships its sources + # as a verified asset; the inner installer then finds them next to itself. + if grep -q 'IPhoneUseRunner' "$helper_dir/scripts/setup-wda.sh"; then + download_verified_release_runner "$ref" "$helper_dir" \ + || die "Release $ref is missing $RUNNER_ASSET_NAME; refusing a mixed-version install." + fi ok "Raw helper scripts pinned to exact commit $commit" if [ "${IPHONE_USE_SKIP_SKILL:-0}" = "1" ]; then @@ -1924,7 +2087,7 @@ install_named_instance() { for key in PHONE_REMOTE_IDLE_RELEASE_SECS PHONE_REMOTE_PASSWORD PHONE_REMOTE_NO_UPDATE_CHECK; do add_env "$key" "$(pick "$key")" done - for key in WDA_TEAM_ID WDA_BUNDLE_ID WDA_REF WDA_ALLOW_LAN WDA_RUNNER_NAME WDA_RUNNER_ICON; do + for key in WDA_TEAM_ID WDA_BUNDLE_ID WDA_ALLOW_LAN IPU_RUNNER_SRC; do add_env "$key" "$(pick "$key" inherit)" done if [ -n "${WDA_ASC_KEY_PATH:-}${WDA_ASC_KEY_ID:-}${WDA_ASC_ISSUER_ID:-}" ]; then @@ -2008,9 +2171,16 @@ install_named_instance() { && mv -f "$state_dir/$value.new.$$" "$state_dir/$value" \ || die "Could not install $state_dir/$value" done + # Every instance builds the device runner from the shared sources. + if runner_tree_valid "$SCRIPT_DIR/runner"; then + install_runner_sources "$SCRIPT_DIR/runner" + elif ! runner_tree_valid "$RUNNER_SRC_DST"; then + warn "No device runner sources next to this installer or at $RUNNER_SRC_DST; install the default instance first." + fi named_rollback() { warn "Rolling back instance $INSTANCE_NAME: $1" + _restore_runner_sources launchctl bootout "gui/$UID_NUM/$label" 2>/dev/null || true if [ -n "$plist_backup" ]; then mv -f "$plist_backup" "$plist"; else rm -f "$plist"; fi if [ -n "$app_backup" ]; then rm -rf "$app_dst"; mv "$app_backup" "$app_dst"; fi @@ -2034,18 +2204,20 @@ install_named_instance() { esac [ -z "$app_backup" ] || rm -rf "$app_backup" [ -z "$plist_backup" ] || rm -f "$plist_backup" + RUNNER_SRC_COMMITTED=1 + _finish_runner_sources ok "Instance $INSTANCE_NAME daemon is up on http://127.0.0.1:$daemon_port" echo "" info "iPhone : $udid" info "Daemon : http://127.0.0.1:$daemon_port (launchd $label)" - info "WDA relays : 127.0.0.1:$wda_port control, 127.0.0.1:$mjpeg_port video (launchd $wda_label)" + info "Relays : 127.0.0.1:$wda_port control, 127.0.0.1:$mjpeg_port video (launchd $wda_label)" info "Token : EnvironmentVariables:PHONE_REMOTE_AGENT_TOKEN in $plist" info "State : $state_dir" echo "" printf ' Agents target this phone with:\n' printf " ${BOLD}PHONE_REMOTE_URL=http://127.0.0.1:%s PHONE_REMOTE_AGENT_TOKEN=${RESET}\n" "$daemon_port" - printf ' Next, build and start WebDriverAgent for this phone (keep it unlocked):\n' + printf ' Next, build and start the device runner for this phone (keep it unlocked):\n' printf " ${BOLD}PHONE_REMOTE_INSTANCE=%s %s/setup-wda.sh${RESET}\n" "$INSTANCE_NAME" "$state_dir" printf ' Remove only this instance with:\n' printf " ${BOLD}%s/uninstall.sh --instance %s${RESET}\n" "$state_dir" "$INSTANCE_NAME" @@ -2066,6 +2238,32 @@ if [ "${IPHONE_USE_INTERNAL_TEST_RESOLVE_COMMIT_ONLY:-0}" = "1" ]; then exit 0 fi +# Exercises the device-runner source transaction (and the release archive +# checks) in an isolated fake HOME. Same gating as the hooks around it. +if [ "${IPHONE_USE_INTERNAL_TEST_RUNNER_ONLY:-0}" = "1" ]; then + [ "$SCRIPT_IS_LOCAL" = "1" ] \ + || die "The internal runner-source test hook requires a local installer." + [ -f "$HOME/.iphone-use-installer-test-root" ] \ + || die "The internal runner-source test sentinel is missing." + case "${IPHONE_USE_INTERNAL_TEST_RUNNER_SRC:-}${IPHONE_USE_INTERNAL_TEST_RUNNER_ARCHIVE:-}" in + "$HOME"/*) ;; + *) die "The internal runner fixture must be inside the isolated HOME." ;; + esac + if [ -n "${IPHONE_USE_INTERNAL_TEST_RUNNER_ARCHIVE:-}" ]; then + RUNNER_SRC_DL="$(mktemp -d "$HOME/.runner-archive.XXXXXX")" + extract_verified_runner_archive v-test "$IPHONE_USE_INTERNAL_TEST_RUNNER_ARCHIVE" "$RUNNER_SRC_DL" + install_runner_sources "$RUNNER_SRC_DL/runner" + else + install_runner_sources "$IPHONE_USE_INTERNAL_TEST_RUNNER_SRC" + fi + if [ "${IPHONE_USE_INTERNAL_TEST_RUNNER_FAIL:-0}" = "1" ]; then + die "simulated failure after the runner sources were swapped in" + fi + RUNNER_SRC_COMMITTED=1 + _finish_runner_sources + exit 0 +fi + if [ "${IPHONE_USE_INTERNAL_TEST_ARCHIVE_ONLY:-0}" = "1" ]; then [ "$SCRIPT_IS_LOCAL" = "1" ] \ || die "The internal archive test hook requires a local installer." @@ -2264,7 +2462,7 @@ env_or_existing() { if [ "${PHONE_REMOTE_BACKEND:-$(plist_env_get PHONE_REMOTE_BACKEND)}" = "mirror" ] \ || [ "${PHONE_REMOTE_BACKEND:-$(plist_env_get PHONE_REMOTE_BACKEND)}" = "legacy-mirror" ]; then warn "The iPhone Mirroring backend was removed in v0.9; this install serves the phone over WDA." - warn " Run setup-wda.sh once if this Mac has never set WDA up." + warn " Run setup-wda.sh once if this Mac has never set the device runner up." fi # ── Step 1 — Obtain the .app ────────────────────────────────────────────────── @@ -2605,6 +2803,15 @@ UNINSTALL_STAGE="" UNINSTALL_REPLACED=1 ok "Uninstaller atomically updated: $UNINSTALL_DST" +# Device runner sources for setup-wda.sh, from the same checkout or release. +if resolve_runner_source; then + install_runner_sources "$RUNNER_SOURCE_DIR" +elif runner_tree_valid "$RUNNER_SRC_DST"; then + warn "Release ${RELEASE_REF:-?} ships no device runner sources; keeping the existing $RUNNER_SRC_DST." +else + warn "Release ${RELEASE_REF:-?} ships no device runner sources; setup-wda.sh will report them missing until a release that has them is installed." +fi + info "For cross-network access, put an authenticated HTTPS reverse proxy or a trusted VPN/tunnel in front of the daemon; never expose WDA ports." # Persist every daemon setting the installer knows about, not just the four @@ -2635,10 +2842,9 @@ done IDLE_RELEASE_SECS="$(env_or_existing PHONE_REMOTE_IDLE_RELEASE_SECS)" append_plist_env PHONE_REMOTE_IDLE_RELEASE_SECS "$IDLE_RELEASE_SECS" for ENV_KEY in \ - WDA_REF \ WDA_TEAM_ID \ WDA_BUNDLE_ID \ - WDA_DIR \ + IPU_RUNNER_SRC \ WDA_PORT \ MJPEG_PORT \ WDA_ALLOW_LAN @@ -2961,6 +3167,7 @@ PLIST_BACKUP="" SETUP_WDA_BACKUP="" [ -z "$UNINSTALL_BACKUP" ] || rm -f "$UNINSTALL_BACKUP" 2>/dev/null || true UNINSTALL_BACKUP="" +_finish_runner_sources [ -z "$OLD_DISABLED_BACKUP" ] || rm -f "$OLD_DISABLED_BACKUP" 2>/dev/null || true OLD_DISABLED_BACKUP="" @@ -2970,7 +3177,7 @@ install_cli_link "$DEST/$BINARY_INSIDE_APP" # ── Step 9 — First-run work ──────────────────────────────────────────────────── echo "" -printf '%b━━━ Device layer (WebDriverAgent) ━━━%b\n' "$BOLD" "$RESET" +printf '%b━━━ Device layer (iphone-use device runner) ━━━%b\n' "$BOLD" "$RESET" echo "" if [ "$WDA_MANAGED" = "true" ]; then printf " Before managed setup:\n" @@ -3090,5 +3297,5 @@ if [ "$DAEMON_HTTP_READY" = "1" ] \ ok "Installed; daemon HTTP, WDA endpoints, and product drivable status verified." else ok "Installed; daemon HTTP control plane verified." - warn "The device layer is pending: WDA is parked, or still needs setup-wda.sh." + warn "The device layer is pending: the device runner is parked, or still needs setup-wda.sh." fi diff --git a/runner/.gitignore b/runner/.gitignore new file mode 100644 index 0000000..fa9772a --- /dev/null +++ b/runner/.gitignore @@ -0,0 +1,3 @@ +/build/ +IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcuserdata/ +IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.xcworkspace/xcuserdata/ diff --git a/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj b/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d6519da --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj @@ -0,0 +1,330 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 075A08DD9BA77808A042E207 /* RunnerElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70866DAEACF7115FC193101 /* RunnerElements.swift */; }; + 603A181324BE3D2D3D9CEF19 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470393750BF9CDEBE680ECF3 /* RunnerTests.swift */; }; + 6DE77AA9E758F2E53FDAC651 /* RunnerWDA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7994977E24197EBB0129A53F /* RunnerWDA.swift */; }; + 70CDE4345DB673547F97FC7F /* RunnerHTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BDD3F0E1278AAB2988DB12 /* RunnerHTTP.swift */; }; + 985FD83F33DC0CB4D8E76A6D /* RunnerMJPEG.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4DD8C426E23E9EBDBA0F0FE /* RunnerMJPEG.swift */; }; + 9CF8432F1753A46FC2F1D2CF /* IPURBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = 9E3171D1115C2E98DD29F3F9 /* IPURBridge.m */; }; + CBF5659BBA0993E2D2E21C71 /* RunnerActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 929B13B4B2EE3205992F3E2E /* RunnerActions.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 3C28F51DDB8C8EDC604DA1F4 /* IPhoneUseRunnerUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = IPhoneUseRunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 470393750BF9CDEBE680ECF3 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 74BD68A60C7ABD17C9DCEB07 /* IPURBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = IPURBridge.h; sourceTree = ""; }; + 7994977E24197EBB0129A53F /* RunnerWDA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerWDA.swift; sourceTree = ""; }; + 929B13B4B2EE3205992F3E2E /* RunnerActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerActions.swift; sourceTree = ""; }; + 94BDD3F0E1278AAB2988DB12 /* RunnerHTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHTTP.swift; sourceTree = ""; }; + 9E3171D1115C2E98DD29F3F9 /* IPURBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IPURBridge.m; sourceTree = ""; }; + B70866DAEACF7115FC193101 /* RunnerElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerElements.swift; sourceTree = ""; }; + E2F8EFE7B3C21CA5A2498C9E /* IPhoneUseRunnerUITests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "IPhoneUseRunnerUITests-Bridging-Header.h"; sourceTree = ""; }; + F4DD8C426E23E9EBDBA0F0FE /* RunnerMJPEG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerMJPEG.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0B2AC3C3B49C8FE9200944FE /* Products */ = { + isa = PBXGroup; + children = ( + 3C28F51DDB8C8EDC604DA1F4 /* IPhoneUseRunnerUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 9694C7E9B44097594ECF991A /* IPhoneUseRunnerUITests */ = { + isa = PBXGroup; + children = ( + E2F8EFE7B3C21CA5A2498C9E /* IPhoneUseRunnerUITests-Bridging-Header.h */, + 74BD68A60C7ABD17C9DCEB07 /* IPURBridge.h */, + 9E3171D1115C2E98DD29F3F9 /* IPURBridge.m */, + 929B13B4B2EE3205992F3E2E /* RunnerActions.swift */, + B70866DAEACF7115FC193101 /* RunnerElements.swift */, + 94BDD3F0E1278AAB2988DB12 /* RunnerHTTP.swift */, + F4DD8C426E23E9EBDBA0F0FE /* RunnerMJPEG.swift */, + 470393750BF9CDEBE680ECF3 /* RunnerTests.swift */, + 7994977E24197EBB0129A53F /* RunnerWDA.swift */, + ); + path = IPhoneUseRunnerUITests; + sourceTree = ""; + }; + 96D2C0663F204A0AF7975CDF = { + isa = PBXGroup; + children = ( + 9694C7E9B44097594ECF991A /* IPhoneUseRunnerUITests */, + 0B2AC3C3B49C8FE9200944FE /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 88F6F4571B11FE5B273A52C9 /* IPhoneUseRunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 176F9333B11757F953CE042E /* Build configuration list for PBXNativeTarget "IPhoneUseRunnerUITests" */; + buildPhases = ( + F0142532A949F474CB689D6F /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = IPhoneUseRunnerUITests; + packageProductDependencies = ( + ); + productName = IPhoneUseRunnerUITests; + productReference = 3C28F51DDB8C8EDC604DA1F4 /* IPhoneUseRunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AC789425D577A4C85F6056B0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 88F6F4571B11FE5B273A52C9 = { + DevelopmentTeam = 6ZPXG4KVVS; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 5E2D7488BFBBE99FEB77EF21 /* Build configuration list for PBXProject "IPhoneUseRunner" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 96D2C0663F204A0AF7975CDF; + minimizedProjectReferenceProxies = 1; + productRefGroup = 0B2AC3C3B49C8FE9200944FE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 88F6F4571B11FE5B273A52C9 /* IPhoneUseRunnerUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + F0142532A949F474CB689D6F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9CF8432F1753A46FC2F1D2CF /* IPURBridge.m in Sources */, + CBF5659BBA0993E2D2E21C71 /* RunnerActions.swift in Sources */, + 075A08DD9BA77808A042E207 /* RunnerElements.swift in Sources */, + 70CDE4345DB673547F97FC7F /* RunnerHTTP.swift in Sources */, + 985FD83F33DC0CB4D8E76A6D /* RunnerMJPEG.swift in Sources */, + 603A181324BE3D2D3D9CEF19 /* RunnerTests.swift in Sources */, + 6DE77AA9E758F2E53FDAC651 /* RunnerWDA.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 22A671C583D97769977BECBE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 6ZPXG4KVVS; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.2.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A7E56434819A5A7A8185111 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 6ZPXG4KVVS; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.2.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 8CAF3728D2CD61A311DEADF0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.leeguoo.iphone-use.runner"; + PRODUCT_NAME = iPhoneUse; + SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "IPhoneUseRunnerUITests/IPhoneUseRunnerUITests-Bridging-Header.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + C76EF55291F0A5BD1A184B0F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.leeguoo.iphone-use.runner"; + PRODUCT_NAME = iPhoneUse; + SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "IPhoneUseRunnerUITests/IPhoneUseRunnerUITests-Bridging-Header.h"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 176F9333B11757F953CE042E /* Build configuration list for PBXNativeTarget "IPhoneUseRunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C76EF55291F0A5BD1A184B0F /* Debug */, + 8CAF3728D2CD61A311DEADF0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 5E2D7488BFBBE99FEB77EF21 /* Build configuration list for PBXProject "IPhoneUseRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 22A671C583D97769977BECBE /* Debug */, + 2A7E56434819A5A7A8185111 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = AC789425D577A4C85F6056B0 /* Project object */; +} diff --git a/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcshareddata/xcschemes/IPhoneUseRunner.xcscheme b/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcshareddata/xcschemes/IPhoneUseRunner.xcscheme new file mode 100644 index 0000000..db324fc --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcshareddata/xcschemes/IPhoneUseRunner.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPURBridge.h b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPURBridge.h new file mode 100644 index 0000000..3a6b2c4 --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPURBridge.h @@ -0,0 +1,158 @@ +// IPURBridge — the private-XCTest surface of the iphone-use native runner. +// +// Portions derived from callstack/agent-device (MIT License, Copyright (c) 2026 Callstack): +// the private XCAXClient snapshot request with a reduced attribute set +// (RunnerAXSnapshotBridge.m), XCSynthesizedEventRecord / XCPointerEventPath gesture and text +// synthesis (RunnerSynthesizedGesture.m, RunnerSynthesizedTextEntry.m, RunnerXCTestEventBridge.m) +// and the quiescence-skipping interaction options (RunnerTests+Lifecycle.swift). +// See runner/README.md for the full attribution and license text. +// +// Every private class and selector is resolved at runtime and checked before use; a missing one +// surfaces as an error string (or a nil result) so the Swift layer can fall back to public API. + +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Result keys of +wdaTreeForAXElement:… / +wdaTreeForSnapshot:…. +FOUNDATION_EXPORT NSString *const IPURTreeOkKey; // NSNumber(BOOL) +FOUNDATION_EXPORT NSString *const IPURTreeRootKey; // NSDictionary, WDA /source JSON shape +FOUNDATION_EXPORT NSString *const IPURTreeErrorKey; // NSString +FOUNDATION_EXPORT NSString *const IPURTreeNodeCountKey; // NSNumber +FOUNDATION_EXPORT NSString *const IPURTreeDepthKey; // NSNumber: the accepted request depth +FOUNDATION_EXPORT NSString *const IPURTreeTruncatedKey; // NSNumber(BOOL) +FOUNDATION_EXPORT NSString *const IPURTreeExtensionCallsKey; // NSNumber: re-rooted follow-up requests +/// Node key holding the live XCAccessibilityElement, present only in trees requested with +/// `includeElements:YES`. Never JSON-serialize such a tree. +FOUNDATION_EXPORT NSString *const IPURNodeAXElementKey; + +@interface IPURBridge : NSObject + +/// Turns every XCTest quiescence / idle wait into a no-op for the whole process +/// (XCUIApplicationProcess waitForQuiescence…, XCAXClient_iOS +/// waitForQuiescenceOnAllForegroundApplicationsAsPreEvent:, XCUIApplication _waitForQuiescence…). +/// Returns the selectors that were patched, for the startup log. ++ (NSArray *)installQuiescenceBypass; + +/// Runs `block` inside XCUIApplication `_performWithInteractionOptions:block:` with both the +/// pre-event and post-event quiescence skip bits set, when the selector exists; otherwise runs it +/// directly. `application` may be nil. ++ (void)performWithoutQuiescence:(nullable XCUIApplication *)application block:(void (NS_NOESCAPE ^)(void))block; + +/// Runs `block`, returning ": " if it raised an Objective-C exception, else nil. ++ (nullable NSString *)catchException:(void (NS_NOESCAPE ^)(void))block; + +// MARK: - Applications + +/// `XCUIDevice.sharedDevice.accessibilityInterface` (XCAXClient_iOS), or nil. ++ (nullable id)axClient; + +/// Pids of the applications the AX client reports as active. ++ (NSArray *)activeApplicationPIDs; + +/// The foreground application's AX element (XCAccessibilityElement), resolved from +/// `activeApplications`: the only non-SpringBoard active app when there is exactly one, else a +/// hit-test at `probePoint` (screen points), else the first non-SpringBoard app, else SpringBoard. +/// Writes its pid into `pid`. ++ (nullable id)foregroundApplicationElementWithProbePoint:(CGPoint)probePoint pid:(int *)pid; + +/// The system application (SpringBoard) AX element. ++ (nullable id)systemApplicationElement; + +/// Pid of an XCAccessibilityElement, or 0. ++ (int)pidForAXElement:(id)element; + +/// Bundle id of a running process, via XCUIDevice.applicationMonitor +/// monitoredApplicationWithProcessIdentifier:. Cached per pid. ++ (nullable NSString *)bundleIDForPID:(int)pid; + +/// XCUIApplication for a running pid (applicationMonitor), or nil. ++ (nullable XCUIApplication *)applicationForPID:(int)pid; + +/// Process id of an XCUIApplication (private `processID`), or 0. ++ (int)pidForApplication:(XCUIApplication *)application; + +// MARK: - Accessibility tree + +/// Snapshots `axElement` through XCAXClient_iOS requestSnapshotForElement:attributes:parameters:error: +/// with only nine attributes, walking a depth ladder on kAXErrorIllegalArgument and re-rooting +/// depth-capped frontier nodes (bounded by `extensionCallLimit`). The root is serialized into +/// WDA's /source JSON node shape. `rememberKey` (e.g. the pid) remembers the accepted depth so +/// later captures of the same process skip known-rejected rungs; pass nil to always probe. ++ (NSDictionary *)wdaTreeForAXElement:(id)axElement + maxDepth:(NSInteger)maxDepth + maxNodes:(NSInteger)maxNodes + extensionCallLimit:(NSInteger)extensionCallLimit + rememberKey:(nullable NSString *)rememberKey; + +/// Same as above; with `includeElements` every node also carries its live accessibility element +/// under IPURNodeAXElementKey, so it can be re-snapshotted later (element registry). ++ (NSDictionary *)wdaTreeForAXElement:(id)axElement + maxDepth:(NSInteger)maxDepth + maxNodes:(NSInteger)maxNodes + extensionCallLimit:(NSInteger)extensionCallLimit + rememberKey:(nullable NSString *)rememberKey + includeElements:(BOOL)includeElements; + +/// Serializes an already-taken snapshot (public XCUIElementSnapshot or private XCElementSnapshot) +/// into WDA's /source node shape. ++ (NSDictionary *)wdaTreeForSnapshot:(id)snapshot maxNodes:(NSInteger)maxNodes; ++ (NSDictionary *)wdaTreeForSnapshot:(id)snapshot + maxNodes:(NSInteger)maxNodes + includeElements:(BOOL)includeElements; + +/// WDA's "XCUIElementType…" name for an element type raw value. ++ (NSString *)elementTypeName:(NSInteger)elementType; + +// MARK: - Event synthesis (all coordinates are screen points) + +/// Whether the private event-synthesis classes and selectors are present. ++ (BOOL)eventSynthesisAvailable; + ++ (nullable NSString *)synthesizeTapAt:(CGPoint)point pid:(int)pid; ++ (nullable NSString *)synthesizeLongPressAt:(CGPoint)point duration:(NSTimeInterval)duration pid:(int)pid; +/// Linear drag sampled every ~16 ms, finger lifts at `duration`. ++ (nullable NSString *)synthesizeDragFrom:(CGPoint)start + to:(CGPoint)end + duration:(NSTimeInterval)duration + pid:(int)pid; +/// Several touches in one event record. Each path is an array of steps +/// `{"type": "down"|"move"|"up", "x": pt, "y": pt, "t": seconds}`; the first step must be "down" +/// and a path without a final "up" lifts at its last offset. Paths run concurrently on the shared +/// timeline (offsets are absolute), so sequential touches simply use later offsets. ++ (nullable NSString *)synthesizeTouchPaths:(NSArray *> *> *)paths + name:(NSString *)name; + +/// Types into whatever holds keyboard focus. `charactersPerSecond` 0 → 60. ++ (nullable NSString *)synthesizeText:(NSString *)text + charactersPerSecond:(NSUInteger)charactersPerSecond + pid:(int)pid; + +// MARK: - Screen capture + +/// One JPEG of the main screen, safe to call off the main thread. Tries, fastest first: +/// 1. `XCUIDevice.screenDataSource requestScreenshotWithRequest:withReply:` with an +/// XCTScreenshotRequest asking testmanagerd for JPEG at `quality` (what WDA's MJPEG server uses); +/// 2. `XCUIScreen screenshotWithEncoding:options:` with the same JPEG encoding; +/// 3. the public `XCUIScreen.mainScreen.screenshot` PNG, re-encoded. +/// `scale` (0 < scale ≤ 1) downsizes with ImageIO and re-encodes at `quality` (0…1). +/// `path` receives which capture path produced the frame ("request", "encoding", "public"). ++ (nullable NSData *)jpegScreenshotWithQuality:(double)quality + scale:(double)scale + path:(NSString *_Nullable *_Nullable)path + error:(NSString *_Nullable *_Nullable)error; + +// MARK: - Device + +/// Screen lock state from SpringBoardServices (SBGetScreenLockStatus), like WDA. Writes NO into +/// `known` when the private function is unavailable (the return value is then NO). ++ (BOOL)isScreenLocked:(BOOL *)known; + +/// `-[XCUIDevice pressLockButton]`; returns an error string when unavailable. ++ (nullable NSString *)pressLockButton; + +@end + +NS_ASSUME_NONNULL_END diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPURBridge.m b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPURBridge.m new file mode 100644 index 0000000..a2f6225 --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPURBridge.m @@ -0,0 +1,1122 @@ +// IPURBridge — the private-XCTest surface of the iphone-use native runner. +// +// Portions derived from callstack/agent-device (MIT License, Copyright (c) 2026 Callstack): +// RunnerAXSnapshotBridge.m (reduced-attribute XCAXClient snapshot, depth ladder, frontier +// re-rooting), RunnerSynthesizedGesture.m / RunnerSynthesizedTextEntry.m / +// RunnerXCTestEventBridge.m (XCSynthesizedEventRecord + XCPointerEventPath synthesis) and +// RunnerTests+Lifecycle.swift (_performWithInteractionOptions:block: quiescence skip bits). +// See runner/README.md for the full attribution and license text. + +#import "IPURBridge.h" + +#import +#import +#import +#import +#import +#import + +NSString *const IPURTreeOkKey = @"ok"; +NSString *const IPURTreeRootKey = @"root"; +NSString *const IPURTreeErrorKey = @"error"; +NSString *const IPURTreeNodeCountKey = @"nodeCount"; +NSString *const IPURTreeDepthKey = @"depth"; +NSString *const IPURTreeTruncatedKey = @"truncated"; +NSString *const IPURTreeExtensionCallsKey = @"extensionCalls"; +NSString *const IPURNodeAXElementKey = @"__axElement"; + +static NSString *const IPURSpringBoardBundleID = @"com.apple.springboard"; + +// Deep trees (React Native feeds) make the AX server reject a bulk request with +// kAXErrorIllegalArgument once the depth crosses a content-dependent limit; a shallower retry +// succeeds. Same rungs agent-device ships. +static NSInteger const IPURDepthLadder[] = {56, 40, 24, 12}; + +typedef id (*IPURMsgSendObject)(id, SEL); +typedef int (*IPURMsgSendInt)(id, SEL); +typedef long long (*IPURMsgSendLongLong)(id, SEL); +typedef id (*IPURMsgSendObjectInt)(id, SEL, int); +typedef id (*IPURMsgSendSnapshotRequest)(id, SEL, id, id, id, NSError **); +typedef id (*IPURMsgSendElementAtPoint)(id, SEL, CGPoint, NSError **); +typedef id (*IPURMsgSendMapAttributes)(id, SEL, id, BOOL); +typedef void (*IPURMsgSendPerformWithOptions)(id, SEL, unsigned int, void (^)(void)); +typedef id (*IPURMsgSendInitRecordDisplay)(id, SEL, NSString *, unsigned long long, long long); +typedef id (*IPURMsgSendInitRecordOrientation)(id, SEL, NSString *, long long); +typedef id (*IPURMsgSendInitRecordName)(id, SEL, NSString *); +typedef void (*IPURMsgSendSetLongLong)(id, SEL, long long); +typedef id (*IPURMsgSendInitPath)(id, SEL, CGPoint, double); +typedef void (*IPURMsgSendPathMove)(id, SEL, CGPoint, double); +typedef void (*IPURMsgSendPathOffset)(id, SEL, double); +typedef void (*IPURMsgSendAddPath)(id, SEL, id); +typedef BOOL (*IPURMsgSendSynthesize)(id, SEL, NSError **); +typedef void (*IPURMsgSendTypeText)(id, SEL, NSString *, double, unsigned long long, BOOL); + +/// A childless serialized node remembered with its depth relative to the request that produced +/// it. The deepest level of a depth-capped request is where the AX server withheld children. +@interface IPURFrontier : NSObject +@property(nonatomic, strong) id snapshot; +@property(nonatomic, strong) NSMutableDictionary *node; +@property(nonatomic, assign) NSInteger depth; +@end + +@implementation IPURFrontier +@end + +typedef struct { + NSInteger nodeCount; + NSInteger maxNodes; + BOOL truncated; + BOOL includeElements; +} IPURWalk; + +@implementation IPURBridge + +// MARK: - Small runtime helpers + +static id IPURObject(id target, NSString *selectorName) +{ + if (target == nil) return nil; + SEL selector = NSSelectorFromString(selectorName); + if (![target respondsToSelector:selector]) return nil; + return ((IPURMsgSendObject)objc_msgSend)(target, selector); +} + +static int IPURInt(id target, NSString *selectorName) +{ + if (target == nil) return 0; + SEL selector = NSSelectorFromString(selectorName); + if (![target respondsToSelector:selector]) return 0; + // processIdentifier / processID return pid_t (int32): read them through an int-returning + // cast so the upper half of x0 is never trusted. + NSMethodSignature *signature = [target methodSignatureForSelector:selector]; + const char *returnType = signature.methodReturnType; + if (returnType != NULL && strcmp(returnType, @encode(int)) == 0) { + return ((IPURMsgSendInt)objc_msgSend)(target, selector); + } + return (int)((IPURMsgSendLongLong)objc_msgSend)(target, selector); +} + ++ (nullable NSString *)catchException:(void (NS_NOESCAPE ^)(void))block +{ + @try { + block(); + return nil; + } @catch (NSException *exception) { + return [NSString stringWithFormat:@"%@: %@", exception.name ?: @"NSException", + exception.reason ?: @"(no reason)"]; + } +} + +// MARK: - Quiescence + +static void IPURNoopVoidMethod(Class cls, NSString *selectorName, NSMutableArray *patched) +{ + if (cls == Nil) return; + Method method = class_getInstanceMethod(cls, NSSelectorFromString(selectorName)); + if (method == NULL) return; + char returnType[8] = {0}; + method_getReturnType(method, returnType, sizeof(returnType)); + // Only void waits are neutralised; a wait that returns a verdict keeps its implementation. + if (returnType[0] != 'v') return; + // Extra arguments (BOOL flags, an activity) are ignored by the block; that is safe for the + // register-passed scalars and pointers these selectors take. + IMP noop = imp_implementationWithBlock(^(__unused id receiver) { + }); + method_setImplementation(method, noop); + [patched addObject:[NSString stringWithFormat:@"-[%@ %@]", NSStringFromClass(cls), selectorName]]; +} + ++ (NSArray *)installQuiescenceBypass +{ + static NSArray *installed; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSMutableArray *patched = [NSMutableArray array]; + Class process = NSClassFromString(@"XCUIApplicationProcess"); + IPURNoopVoidMethod(process, @"waitForQuiescenceIncludingAnimationsIdle:", patched); + IPURNoopVoidMethod(process, @"waitForQuiescenceIncludingAnimationsIdle:isPreEvent:", patched); + IPURNoopVoidMethod(process, @"waitForQuiescenceIncludingAnimationsIdle:usingActivity:isPreEvent:", patched); + Class axClient = NSClassFromString(@"XCAXClient_iOS"); + IPURNoopVoidMethod(axClient, @"waitForQuiescenceOnAllForegroundApplicationsAsPreEvent:", patched); + Class application = NSClassFromString(@"XCUIApplication"); + IPURNoopVoidMethod(application, @"_waitForQuiescence", patched); + IPURNoopVoidMethod(application, @"_waitForQuiescenceAsPreEvent:", patched); + installed = patched.copy; + }); + return installed; +} + ++ (void)performWithoutQuiescence:(nullable XCUIApplication *)application block:(void (NS_NOESCAPE ^)(void))block +{ + SEL selector = NSSelectorFromString(@"_performWithInteractionOptions:block:"); + if (application == nil || ![application respondsToSelector:selector]) { + block(); + return; + } + // Bit 0 skips the pre-event wait, bit 1 the post-event wait. + unsigned int options = 1u | 2u; + ((IPURMsgSendPerformWithOptions)objc_msgSend)(application, selector, options, block); +} + +// MARK: - Applications + ++ (nullable id)axClient +{ + return IPURObject(XCUIDevice.sharedDevice, @"accessibilityInterface"); +} + ++ (int)pidForAXElement:(id)element +{ + return IPURInt(element, @"processIdentifier"); +} + ++ (int)pidForApplication:(XCUIApplication *)application +{ + return IPURInt(application, @"processID"); +} + ++ (NSArray *)activeApplicationElements +{ + id active = IPURObject([self axClient], @"activeApplications"); + return [active isKindOfClass:NSArray.class] ? active : @[]; +} + ++ (NSArray *)activeApplicationPIDs +{ + NSMutableArray *pids = [NSMutableArray array]; + for (id element in [self activeApplicationElements]) { + int pid = [self pidForAXElement:element]; + if (pid > 0) [pids addObject:@(pid)]; + } + return pids; +} + ++ (nullable id)systemApplicationElement +{ + return IPURObject([self axClient], @"systemApplication"); +} + ++ (nullable id)foregroundApplicationElementWithProbePoint:(CGPoint)probePoint pid:(int *)pid +{ + if (pid != NULL) *pid = 0; + NSArray *active = [self activeApplicationElements]; + int springBoardPID = [self pidForAXElement:[self systemApplicationElement] ?: NSNull.null]; + NSMutableArray *candidates = [NSMutableArray array]; + id springBoard = nil; + for (id element in active) { + int elementPID = [self pidForAXElement:element]; + if (elementPID <= 0) continue; + BOOL isSpringBoard = springBoardPID > 0 + ? elementPID == springBoardPID + : [[self bundleIDForPID:elementPID] isEqualToString:IPURSpringBoardBundleID]; + if (isSpringBoard) { + springBoard = element; + } else { + [candidates addObject:element]; + } + } + id chosen = nil; + if (candidates.count == 1) { + chosen = candidates.firstObject; + } else if (candidates.count > 1) { + // Several apps report active (split screen, a PiP, an app extension): ask the AX server + // who owns the probe point, like WDA's active-app detection point. + id axClient = [self axClient]; + SEL hitTest = NSSelectorFromString(@"accessibilityElementForElementAtPoint:error:"); + if (axClient != nil && [axClient respondsToSelector:hitTest]) { + NSError *error = nil; + id hit = nil; + @try { + hit = ((IPURMsgSendElementAtPoint)objc_msgSend)(axClient, hitTest, probePoint, &error); + } @catch (__unused NSException *exception) { + hit = nil; + } + int hitPID = [self pidForAXElement:hit]; + for (id element in candidates) { + if (hitPID > 0 && [self pidForAXElement:element] == hitPID) { + chosen = element; + break; + } + } + } + if (chosen == nil) chosen = candidates.firstObject; + } else { + chosen = springBoard ?: [self systemApplicationElement]; + } + if (chosen != nil && pid != NULL) *pid = [self pidForAXElement:chosen]; + return chosen; +} + ++ (nullable XCUIApplication *)applicationForPID:(int)pid +{ + if (pid <= 0) return nil; + id monitor = IPURObject(XCUIDevice.sharedDevice, @"applicationMonitor"); + SEL selector = NSSelectorFromString(@"monitoredApplicationWithProcessIdentifier:"); + if (monitor == nil || ![monitor respondsToSelector:selector]) return nil; + id application = nil; + @try { + application = ((IPURMsgSendObjectInt)objc_msgSend)(monitor, selector, pid); + } @catch (__unused NSException *exception) { + application = nil; + } + return [application isKindOfClass:XCUIApplication.class] ? application : nil; +} + ++ (nullable NSString *)bundleIDForPID:(int)pid +{ + if (pid <= 0) return nil; + static NSMutableDictionary *cache; + static dispatch_once_t once; + dispatch_once(&once, ^{ + cache = [NSMutableDictionary dictionary]; + }); + @synchronized(cache) { + NSString *cached = cache[@(pid)]; + if (cached != nil) return cached; + } + id bundleID = IPURObject([self applicationForPID:pid], @"bundleID"); + if (![bundleID isKindOfClass:NSString.class] || [(NSString *)bundleID length] == 0) { + // XCUIApplicationMonitor applicationProcessWithPID: knows processes XCTest did not launch. + id monitor = IPURObject(XCUIDevice.sharedDevice, @"applicationMonitor"); + SEL processSelector = NSSelectorFromString(@"applicationProcessWithPID:"); + if (monitor != nil && [monitor respondsToSelector:processSelector]) { + @try { + id process = ((IPURMsgSendObjectInt)objc_msgSend)(monitor, processSelector, pid); + bundleID = IPURObject(process, @"bundleID"); + } @catch (__unused NSException *exception) { + bundleID = nil; + } + } + } + if ((![bundleID isKindOfClass:NSString.class] || [(NSString *)bundleID length] == 0) + && pid == [self pidForAXElement:[self systemApplicationElement] ?: NSNull.null]) { + bundleID = IPURSpringBoardBundleID; + } + if (![bundleID isKindOfClass:NSString.class] || [(NSString *)bundleID length] == 0) return nil; + @synchronized(cache) { + cache[@(pid)] = bundleID; + } + return bundleID; +} + +// MARK: - Accessibility tree + ++ (NSString *)elementTypeName:(NSInteger)elementType +{ + static NSArray *names; + static dispatch_once_t once; + dispatch_once(&once, ^{ + // XCUIElementType raw values 0…82, in declaration order (XCUIElementTypes.h). + names = @[ + @"Any", @"Other", @"Application", @"Group", @"Window", @"Sheet", @"Drawer", @"Alert", + @"Dialog", @"Button", @"RadioButton", @"RadioGroup", @"CheckBox", @"DisclosureTriangle", + @"PopUpButton", @"ComboBox", @"MenuButton", @"ToolbarButton", @"Popover", @"Keyboard", + @"Key", @"NavigationBar", @"TabBar", @"TabGroup", @"Toolbar", @"StatusBar", @"Table", + @"TableRow", @"TableColumn", @"Outline", @"OutlineRow", @"Browser", @"CollectionView", + @"Slider", @"PageIndicator", @"ProgressIndicator", @"ActivityIndicator", + @"SegmentedControl", @"Picker", @"PickerWheel", @"Switch", @"Toggle", @"Link", @"Image", + @"Icon", @"SearchField", @"ScrollView", @"ScrollBar", @"StaticText", @"TextField", + @"SecureTextField", @"DatePicker", @"TextView", @"Menu", @"MenuItem", @"MenuBar", + @"MenuBarItem", @"Map", @"WebView", @"IncrementArrow", @"DecrementArrow", @"Timeline", + @"RatingIndicator", @"ValueIndicator", @"SplitGroup", @"Splitter", @"RelevanceIndicator", + @"ColorWell", @"HelpTag", @"Matte", @"DockItem", @"Ruler", @"RulerMarker", @"Grid", + @"LevelIndicator", @"Cell", @"LayoutArea", @"LayoutItem", @"Handle", @"Stepper", @"Tab", + @"TouchBar", @"StatusItem", + ]; + }); + NSString *name = (elementType >= 0 && elementType < (NSInteger)names.count) ? names[elementType] : @"Other"; + return [@"XCUIElementType" stringByAppendingString:name]; +} + +/// The nine AX attributes the serializer reads, mapped from snapshot key paths by XCElementSnapshot +/// (the AX server ignores raw key-path strings). The mapper adds expensive extras (automation type, +/// window display id, base type); only the nine needed attributes are kept. ++ (NSArray *)snapshotAttributes +{ + static NSArray *attributes; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSArray *keyPaths = @[ + @"elementType", @"identifier", @"label", @"value", @"placeholderValue", @"frame", + @"enabled", @"selected", @"hasFocus", @"children", + ]; + NSArray *mappedAttributes = keyPaths; + Class snapshotClass = NSClassFromString(@"XCElementSnapshot"); + SEL mapSelector = NSSelectorFromString(@"axAttributesForElementSnapshotKeyPaths:isMacOS:"); + if ([snapshotClass respondsToSelector:mapSelector]) { + id mapped = ((IPURMsgSendMapAttributes)objc_msgSend)(snapshotClass, mapSelector, keyPaths, NO); + if ([mapped isKindOfClass:NSSet.class]) mapped = [(NSSet *)mapped allObjects]; + if ([mapped isKindOfClass:NSArray.class] && [(NSArray *)mapped count] > 0) { + NSArray *needed = @[ + @"ElementType", @"Identifier", @"Label", @"Value", @"PlaceholderValue", @"Frame", + @"Enabled", @"Selected", @"Focus", + ]; + NSMutableArray *filtered = [NSMutableArray array]; + for (id attribute in (NSArray *)mapped) { + NSString *name = [attribute description]; + for (NSString *suffix in needed) { + if ([name hasSuffix:suffix]) { + [filtered addObject:attribute]; + break; + } + } + } + mappedAttributes = filtered.count > 0 ? filtered.copy : mapped; + } + } + attributes = mappedAttributes; + }); + return attributes; +} + ++ (nullable id)requestSnapshotForElement:(id)element + maxDepth:(NSInteger)maxDepth + maxNodes:(NSInteger)maxNodes + error:(NSString **)errorMessage +{ + id axClient = [self axClient]; + SEL selector = NSSelectorFromString(@"requestSnapshotForElement:attributes:parameters:error:"); + if (axClient == nil || ![axClient respondsToSelector:selector]) { + if (errorMessage) *errorMessage = @"XCAXClient requestSnapshotForElement:attributes:parameters:error: unavailable"; + return nil; + } + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + id defaults = IPURObject(axClient, @"defaultParameters"); + if ([defaults isKindOfClass:NSDictionary.class]) [parameters addEntriesFromDictionary:defaults]; + parameters[@"maxDepth"] = @(MAX(1, maxDepth)); + parameters[@"maxChildren"] = @(MAX(1, maxNodes)); + parameters[@"maxArrayCount"] = @(MAX(1, maxNodes)); + parameters[@"traverseFromParentsToChildren"] = @YES; + + NSError *error = nil; + id result = nil; + @try { + result = ((IPURMsgSendSnapshotRequest)objc_msgSend)( + axClient, selector, element, [self snapshotAttributes], parameters.copy, &error); + } @catch (NSException *exception) { + if (errorMessage) *errorMessage = [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + return nil; + } + if (result == nil) { + if (errorMessage) *errorMessage = error.localizedDescription ?: @"AX snapshot request returned nil"; + return nil; + } + id root = nil; + @try { + root = [result valueForKey:@"_rootElementSnapshot"]; + } @catch (__unused NSException *exception) { + root = nil; + } + return root ?: result; +} + +static id IPURKVC(id snapshot, NSString *key) +{ + @try { + id value = [snapshot valueForKey:key]; + return value == NSNull.null ? nil : value; + } @catch (__unused NSException *exception) { + return nil; + } +} + +static NSString *IPURNonEmptyString(id value) +{ + if (value == nil) return nil; + NSString *string = [value isKindOfClass:NSString.class] ? value : [value description]; + return string.length > 0 ? string : nil; +} + +static NSString *IPURValueString(id value) +{ + if (value == nil) return nil; + if ([value isKindOfClass:NSString.class]) return value; + if ([value isKindOfClass:NSNumber.class]) return [(NSNumber *)value stringValue]; + return [value description]; +} + +static NSDictionary *IPURRect(id snapshot) +{ + CGRect frame = CGRectZero; + id value = IPURKVC(snapshot, @"frame"); + if ([value isKindOfClass:NSValue.class] && strcmp([(NSValue *)value objCType], @encode(CGRect)) == 0) { + [(NSValue *)value getValue:&frame]; + } + if (CGRectIsNull(frame) || CGRectIsInfinite(frame)) frame = CGRectZero; + return @{ + @"x": @(frame.origin.x), + @"y": @(frame.origin.y), + @"width": @(frame.size.width), + @"height": @(frame.size.height), + }; +} + +static NSString *IPURBoolString(id snapshot, NSString *key, BOOL fallback) +{ + id value = IPURKVC(snapshot, key); + BOOL flag = [value respondsToSelector:@selector(boolValue)] ? [value boolValue] : fallback; + return flag ? @"1" : @"0"; +} + +static NSArray *IPURChildren(id snapshot) +{ + id children = IPURKVC(snapshot, @"children"); + return [children isKindOfClass:NSArray.class] ? children : @[]; +} + +/// One snapshot node in WDA's /source?format=json shape. No isVisible / isAccessible / isHittable: +/// computing them costs extra AX round trips per node, which is exactly what this runner avoids. +static NSMutableDictionary *IPURSerialize( + id snapshot, NSInteger depth, IPURWalk *walk, NSMutableArray *leaves) +{ + if (snapshot == nil) return nil; + if (walk->nodeCount >= walk->maxNodes) { + walk->truncated = YES; + return nil; + } + walk->nodeCount += 1; + + NSMutableDictionary *node = [NSMutableDictionary dictionaryWithCapacity:12]; + id typeValue = IPURKVC(snapshot, @"elementType"); + NSInteger elementType = [typeValue respondsToSelector:@selector(integerValue)] ? [typeValue integerValue] : 1; + NSString *identifier = IPURNonEmptyString(IPURKVC(snapshot, @"identifier")); + NSString *label = IPURNonEmptyString(IPURKVC(snapshot, @"label")); + NSString *value = IPURValueString(IPURKVC(snapshot, @"value")); + NSString *placeholder = IPURNonEmptyString(IPURKVC(snapshot, @"placeholderValue")); + + node[@"type"] = [IPURBridge elementTypeName:elementType]; + node[@"label"] = label ?: (id)NSNull.null; + // WDA's name: the identifier when there is one, else the label. + node[@"name"] = identifier ?: label ?: (id)NSNull.null; + node[@"value"] = value ?: (id)NSNull.null; + node[@"rawIdentifier"] = identifier ?: (id)NSNull.null; + node[@"placeholderValue"] = placeholder ?: (id)NSNull.null; + node[@"rect"] = IPURRect(snapshot); + node[@"isEnabled"] = IPURBoolString(snapshot, @"enabled", YES); + node[@"isFocused"] = IPURBoolString(snapshot, @"hasFocus", NO); + if (walk->includeElements) { + id element = IPURKVC(snapshot, @"accessibilityElement"); + if (element != nil) node[IPURNodeAXElementKey] = element; + } + + NSMutableArray *children = [NSMutableArray array]; + for (id child in IPURChildren(snapshot)) { + NSMutableDictionary *childNode = IPURSerialize(child, depth + 1, walk, leaves); + if (childNode != nil) [children addObject:childNode]; + if (walk->nodeCount >= walk->maxNodes) { + walk->truncated = YES; + break; + } + } + if (children.count > 0) { + node[@"children"] = children; + } else if (leaves != nil) { + IPURFrontier *leaf = [[IPURFrontier alloc] init]; + leaf.snapshot = snapshot; + leaf.node = node; + leaf.depth = depth; + [leaves addObject:leaf]; + } + return node; +} + +/// Only childless nodes on the request's deepest possible level can be branches whose children +/// the server withheld (it emits `maxDepth` node levels, so the deepest is maxDepth - 1). +static NSMutableArray *IPURCappedFrontiers(NSArray *leaves, NSInteger maxDepth) +{ + NSMutableArray *frontiers = [NSMutableArray array]; + NSInteger deepest = -1; + for (IPURFrontier *leaf in leaves) deepest = MAX(deepest, leaf.depth); + if (deepest < maxDepth - 1) return frontiers; + for (IPURFrontier *leaf in leaves) { + if (leaf.depth == deepest) [frontiers addObject:leaf]; + } + return frontiers; +} + +static NSMutableDictionary *IPURAcceptedDepths(void) +{ + static NSMutableDictionary *depths; + static dispatch_once_t once; + dispatch_once(&once, ^{ + depths = [NSMutableDictionary dictionary]; + }); + return depths; +} + ++ (NSDictionary *)wdaTreeForAXElement:(id)axElement + maxDepth:(NSInteger)maxDepth + maxNodes:(NSInteger)maxNodes + extensionCallLimit:(NSInteger)extensionCallLimit + rememberKey:(nullable NSString *)rememberKey +{ + return [self wdaTreeForAXElement:axElement + maxDepth:maxDepth + maxNodes:maxNodes + extensionCallLimit:extensionCallLimit + rememberKey:rememberKey + includeElements:NO]; +} + ++ (NSDictionary *)wdaTreeForAXElement:(id)axElement + maxDepth:(NSInteger)maxDepth + maxNodes:(NSInteger)maxNodes + extensionCallLimit:(NSInteger)extensionCallLimit + rememberKey:(nullable NSString *)rememberKey + includeElements:(BOOL)includeElements +{ + maxDepth = MAX(1, maxDepth); + maxNodes = MAX(1, maxNodes); + NSMutableArray *depths = [NSMutableArray arrayWithObject:@(maxDepth)]; + for (size_t index = 0; index < sizeof(IPURDepthLadder) / sizeof(IPURDepthLadder[0]); index++) { + if (IPURDepthLadder[index] < maxDepth) [depths addObject:@(IPURDepthLadder[index])]; + } + NSNumber *remembered = nil; + if (rememberKey != nil) { + @synchronized(IPURAcceptedDepths()) { + remembered = IPURAcceptedDepths()[rememberKey]; + } + } + if (remembered != nil && remembered.integerValue < maxDepth) { + NSIndexSet *keep = [depths indexesOfObjectsPassingTest:^BOOL(NSNumber *depth, NSUInteger idx, BOOL *stop) { + return depth.integerValue <= remembered.integerValue; + }]; + if (keep.count > 0) depths = [[depths objectsAtIndexes:keep] mutableCopy]; + } + + id root = nil; + NSInteger acceptedDepth = 0; + NSString *lastError = @"AX snapshot request failed"; + for (NSNumber *depth in depths) { + NSString *error = nil; + root = [self requestSnapshotForElement:axElement maxDepth:depth.integerValue maxNodes:maxNodes error:&error]; + if (root != nil) { + acceptedDepth = depth.integerValue; + break; + } + lastError = error ?: lastError; + NSLog(@"ipu-runner: ax snapshot rejected at depth %ld: %@", (long)depth.integerValue, lastError); + } + if (root == nil) { + return @{IPURTreeOkKey: @NO, IPURTreeErrorKey: lastError}; + } + if (rememberKey != nil && acceptedDepth < maxDepth) { + @synchronized(IPURAcceptedDepths()) { + IPURAcceptedDepths()[rememberKey] = @(acceptedDepth); + } + } + + IPURWalk walk = {.nodeCount = 0, .maxNodes = maxNodes, .truncated = NO, .includeElements = includeElements}; + NSMutableArray *leaves = extensionCallLimit > 0 ? [NSMutableArray array] : nil; + NSMutableDictionary *rootNode = IPURSerialize(root, 0, &walk, leaves); + if (rootNode == nil) { + return @{IPURTreeOkKey: @NO, IPURTreeErrorKey: @"AX snapshot root could not be serialized"}; + } + + // Re-root the same request at each depth-capped frontier: the depth limit is per request, so + // this reaches content the app-rooted request could not, without a larger depth parameter. + NSInteger calls = 0; + NSMutableArray *frontiers = IPURCappedFrontiers(leaves ?: @[], acceptedDepth); + while (frontiers.count > 0) { + if (calls >= extensionCallLimit || walk.nodeCount >= maxNodes) { + walk.truncated = YES; + break; + } + IPURFrontier *frontier = frontiers.firstObject; + [frontiers removeObjectAtIndex:0]; + id element = IPURKVC(frontier.snapshot, @"accessibilityElement"); + if (element == nil) continue; + calls += 1; + id subRoot = [self requestSnapshotForElement:element + maxDepth:acceptedDepth + maxNodes:maxNodes - walk.nodeCount + error:NULL]; + if (subRoot == nil) continue; + NSMutableArray *subLeaves = [NSMutableArray array]; + NSMutableArray *children = [NSMutableArray array]; + for (id child in IPURChildren(subRoot)) { + NSMutableDictionary *childNode = IPURSerialize(child, 1, &walk, subLeaves); + if (childNode != nil) [children addObject:childNode]; + if (walk.nodeCount >= maxNodes) { + walk.truncated = YES; + break; + } + } + if (children.count > 0) frontier.node[@"children"] = children; + [frontiers addObjectsFromArray:IPURCappedFrontiers(subLeaves, acceptedDepth)]; + } + + return @{ + IPURTreeOkKey: @YES, + IPURTreeRootKey: rootNode, + IPURTreeNodeCountKey: @(walk.nodeCount), + IPURTreeDepthKey: @(acceptedDepth), + IPURTreeTruncatedKey: @(walk.truncated), + IPURTreeExtensionCallsKey: @(calls), + }; +} + ++ (NSDictionary *)wdaTreeForSnapshot:(id)snapshot maxNodes:(NSInteger)maxNodes +{ + return [self wdaTreeForSnapshot:snapshot maxNodes:maxNodes includeElements:NO]; +} + ++ (NSDictionary *)wdaTreeForSnapshot:(id)snapshot + maxNodes:(NSInteger)maxNodes + includeElements:(BOOL)includeElements +{ + IPURWalk walk = {.nodeCount = 0, .maxNodes = MAX(1, maxNodes), .truncated = NO, .includeElements = includeElements}; + NSMutableDictionary *rootNode = IPURSerialize(snapshot, 0, &walk, nil); + if (rootNode == nil) { + return @{IPURTreeOkKey: @NO, IPURTreeErrorKey: @"snapshot could not be serialized"}; + } + return @{ + IPURTreeOkKey: @YES, + IPURTreeRootKey: rootNode, + IPURTreeNodeCountKey: @(walk.nodeCount), + IPURTreeDepthKey: @0, + IPURTreeTruncatedKey: @(walk.truncated), + IPURTreeExtensionCallsKey: @0, + }; +} + +// MARK: - Event synthesis + ++ (BOOL)eventSynthesisAvailable +{ + Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord"); + Class pathClass = NSClassFromString(@"XCPointerEventPath"); + return recordClass != Nil && pathClass != Nil + && [recordClass instancesRespondToSelector:NSSelectorFromString(@"synthesizeWithError:")] + && [recordClass instancesRespondToSelector:NSSelectorFromString(@"addPointerEventPath:")] + && [pathClass instancesRespondToSelector:NSSelectorFromString(@"initForTouchAtPoint:offset:")] + && [pathClass instancesRespondToSelector:NSSelectorFromString(@"moveToPoint:atOffset:")] + && [pathClass instancesRespondToSelector:NSSelectorFromString(@"liftUpAtOffset:")] + && ([recordClass instancesRespondToSelector:NSSelectorFromString(@"initWithName:displayID:interfaceOrientation:")] + || [recordClass instancesRespondToSelector:NSSelectorFromString(@"initWithName:interfaceOrientation:")]); +} + +/// UIInterfaceOrientation for the record. UIDeviceOrientation 1…4 share raw values with +/// UIInterfaceOrientation (device landscapeLeft == interface landscapeRight == 3); face up/down +/// and unknown fall back to portrait. +static long long IPURInterfaceOrientation(void) +{ + NSInteger orientation = 1; + @try { + orientation = (NSInteger)XCUIDevice.sharedDevice.orientation; + } @catch (__unused NSException *exception) { + orientation = 1; + } + return (orientation >= 1 && orientation <= 4) ? orientation : 1; +} + +static unsigned long long IPURMainDisplayID(void) +{ + static unsigned long long displayID; + static dispatch_once_t once; + dispatch_once(&once, ^{ + id screen = XCUIScreen.mainScreen; + SEL selector = NSSelectorFromString(@"displayID"); + if ([screen respondsToSelector:selector]) { + displayID = (unsigned long long)((IPURMsgSendLongLong)objc_msgSend)(screen, selector); + } + }); + return displayID; +} + +static NSString *IPURCreateGestureRecord(NSString *name, int pid, id *record) +{ + if (![IPURBridge eventSynthesisAvailable]) { + return @"private XCTest event synthesis unavailable (XCSynthesizedEventRecord / XCPointerEventPath)"; + } + Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord"); + SEL displaySelector = NSSelectorFromString(@"initWithName:displayID:interfaceOrientation:"); + SEL orientationSelector = NSSelectorFromString(@"initWithName:interfaceOrientation:"); + long long orientation = IPURInterfaceOrientation(); + unsigned long long displayID = IPURMainDisplayID(); + id created = nil; + if (displayID != 0 && [recordClass instancesRespondToSelector:displaySelector]) { + created = ((IPURMsgSendInitRecordDisplay)objc_msgSend)( + [recordClass alloc], displaySelector, name, displayID, orientation); + } else if ([recordClass instancesRespondToSelector:orientationSelector]) { + created = ((IPURMsgSendInitRecordOrientation)objc_msgSend)( + [recordClass alloc], orientationSelector, name, orientation); + } else { + created = ((IPURMsgSendInitRecordDisplay)objc_msgSend)( + [recordClass alloc], displaySelector, name, displayID, orientation); + } + if (created == nil) return @"private XCTest event synthesis failed: could not create event record"; + SEL targetSelector = NSSelectorFromString(@"setTargetProcessID:"); + if (pid > 0 && [created respondsToSelector:targetSelector]) { + ((IPURMsgSendSetLongLong)objc_msgSend)(created, targetSelector, (long long)pid); + } + *record = created; + return nil; +} + +static id IPURNewTouchPath(CGPoint point, double offset) +{ + Class pathClass = NSClassFromString(@"XCPointerEventPath"); + return ((IPURMsgSendInitPath)objc_msgSend)( + [pathClass alloc], NSSelectorFromString(@"initForTouchAtPoint:offset:"), point, offset); +} + +static void IPURMove(id path, CGPoint point, double offset) +{ + ((IPURMsgSendPathMove)objc_msgSend)(path, NSSelectorFromString(@"moveToPoint:atOffset:"), point, offset); +} + +static void IPURLift(id path, double offset) +{ + ((IPURMsgSendPathOffset)objc_msgSend)(path, NSSelectorFromString(@"liftUpAtOffset:"), offset); +} + +static NSString *IPURSynthesize(id record, id path) +{ + ((IPURMsgSendAddPath)objc_msgSend)(record, NSSelectorFromString(@"addPointerEventPath:"), path); + NSError *error = nil; + BOOL ok = ((IPURMsgSendSynthesize)objc_msgSend)(record, NSSelectorFromString(@"synthesizeWithError:"), &error); + if (!ok) { + return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", + error.localizedDescription ?: @"synthesizeWithError returned NO"]; + } + return nil; +} + ++ (nullable NSString *)synthesizeTapAt:(CGPoint)point pid:(int)pid +{ + return [self synthesizeLongPressAt:point duration:0.05 pid:pid name:@"ipu-tap"]; +} + ++ (nullable NSString *)synthesizeLongPressAt:(CGPoint)point duration:(NSTimeInterval)duration pid:(int)pid +{ + return [self synthesizeLongPressAt:point duration:duration pid:pid name:@"ipu-longpress"]; +} + ++ (nullable NSString *)synthesizeLongPressAt:(CGPoint)point + duration:(NSTimeInterval)duration + pid:(int)pid + name:(NSString *)name +{ + @try { + id record = nil; + NSString *error = IPURCreateGestureRecord(name, pid, &record); + if (error != nil) return error; + id path = IPURNewTouchPath(point, 0.0); + if (path == nil) return @"private XCTest event synthesis failed: could not create pointer path"; + IPURLift(path, MAX(0.01, duration)); + return IPURSynthesize(record, path); + } @catch (NSException *exception) { + return [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + } +} + ++ (nullable NSString *)synthesizeDragFrom:(CGPoint)start + to:(CGPoint)end + duration:(NSTimeInterval)duration + pid:(int)pid +{ + @try { + id record = nil; + NSString *error = IPURCreateGestureRecord(@"ipu-drag", pid, &record); + if (error != nil) return error; + id path = IPURNewTouchPath(start, 0.0); + if (path == nil) return @"private XCTest event synthesis failed: could not create pointer path"; + duration = MAX(0.05, duration); + NSInteger steps = MIN(60, MAX(2, (NSInteger)ceil(duration / 0.016))); + for (NSInteger step = 1; step <= steps; step++) { + double t = (double)step / (double)steps; + CGPoint point = CGPointMake(start.x + (end.x - start.x) * t, start.y + (end.y - start.y) * t); + IPURMove(path, point, duration * t); + } + IPURLift(path, duration); + return IPURSynthesize(record, path); + } @catch (NSException *exception) { + return [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + } +} + ++ (nullable NSString *)synthesizeText:(NSString *)text + charactersPerSecond:(NSUInteger)charactersPerSecond + pid:(int)pid +{ + @try { + Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord"); + Class pathClass = NSClassFromString(@"XCPointerEventPath"); + SEL initRecord = NSSelectorFromString(@"initWithName:"); + SEL initPath = NSSelectorFromString(@"initForTextInput"); + SEL typeText = NSSelectorFromString(@"typeText:atOffset:typingSpeed:shouldRedact:"); + if (recordClass == Nil || pathClass == Nil || ![recordClass instancesRespondToSelector:initRecord] + || ![pathClass instancesRespondToSelector:initPath] || ![pathClass instancesRespondToSelector:typeText]) { + return @"private XCTest text synthesis unavailable"; + } + id record = ((IPURMsgSendInitRecordName)objc_msgSend)([recordClass alloc], initRecord, @"ipu-type"); + id path = ((IPURMsgSendObject)objc_msgSend)([pathClass alloc], initPath); + if (record == nil || path == nil) return @"private XCTest text synthesis failed: could not create text event"; + SEL targetSelector = NSSelectorFromString(@"setTargetProcessID:"); + if (pid > 0 && [record respondsToSelector:targetSelector]) { + ((IPURMsgSendSetLongLong)objc_msgSend)(record, targetSelector, (long long)pid); + } + ((IPURMsgSendTypeText)objc_msgSend)( + path, typeText, text, 0.0, (unsigned long long)(charactersPerSecond > 0 ? charactersPerSecond : 60), NO); + return IPURSynthesize(record, path); + } @catch (NSException *exception) { + return [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + } +} + ++ (nullable NSString *)synthesizeTouchPaths:(NSArray *> *> *)paths + name:(NSString *)name +{ + if (paths.count == 0) return @"no touch paths to synthesize"; + @try { + id record = nil; + NSString *error = IPURCreateGestureRecord(name, 0, &record); + if (error != nil) return error; + for (NSArray *> *steps in paths) { + NSDictionary *first = steps.firstObject; + if (first == nil || ![first[@"type"] isEqual:@"down"]) { + return @"touch path must start with a down step"; + } + double lastOffset = [first[@"t"] doubleValue]; + id path = IPURNewTouchPath(CGPointMake([first[@"x"] doubleValue], [first[@"y"] doubleValue]), lastOffset); + if (path == nil) return @"private XCTest event synthesis failed: could not create pointer path"; + BOOL lifted = NO; + for (NSUInteger index = 1; index < steps.count && !lifted; index++) { + NSDictionary *step = steps[index]; + double offset = MAX(lastOffset, [step[@"t"] doubleValue]); + if ([step[@"type"] isEqual:@"move"]) { + IPURMove(path, CGPointMake([step[@"x"] doubleValue], [step[@"y"] doubleValue]), offset); + } else if ([step[@"type"] isEqual:@"up"]) { + IPURLift(path, offset); + lifted = YES; + } else { + return [NSString stringWithFormat:@"unsupported touch step type %@", step[@"type"]]; + } + lastOffset = offset; + } + if (!lifted) IPURLift(path, lastOffset + 0.01); + ((IPURMsgSendAddPath)objc_msgSend)(record, NSSelectorFromString(@"addPointerEventPath:"), path); + } + NSError *synthesisError = nil; + BOOL ok = ((IPURMsgSendSynthesize)objc_msgSend)( + record, NSSelectorFromString(@"synthesizeWithError:"), &synthesisError); + if (!ok) { + return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", + synthesisError.localizedDescription ?: @"synthesizeWithError returned NO"]; + } + return nil; + } @catch (NSException *exception) { + return [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + } +} + +// MARK: - Screen capture + +static id IPURJPEGEncoding(double quality) +{ + Class encodingClass = NSClassFromString(@"XCTImageEncoding"); + SEL initSelector = NSSelectorFromString(@"initWithUniformTypeIdentifier:compressionQuality:"); + if (encodingClass == Nil || ![encodingClass instancesRespondToSelector:initSelector]) return nil; + return ((id (*)(id, SEL, NSString *, double))objc_msgSend)( + [encodingClass alloc], initSelector, @"public.jpeg", quality); +} + +/// XCTImage / XCUIScreenshot → its encoded bytes. +static NSData *IPURImageData(id image) +{ + if (image == nil) return nil; + id data = IPURObject(image, @"data"); + if ([data isKindOfClass:NSData.class]) return data; + return nil; +} + +static NSData *IPURCaptureViaRequest(id encoding, NSString **error) +{ + id dataSource = IPURObject(XCUIDevice.sharedDevice, @"screenDataSource"); + SEL requestSelector = NSSelectorFromString(@"requestScreenshotWithRequest:withReply:"); + Class requestClass = NSClassFromString(@"XCTScreenshotRequest"); + SEL initSelector = NSSelectorFromString(@"initWithScreenID:rect:encoding:options:"); + if (dataSource == nil || ![dataSource respondsToSelector:requestSelector] || requestClass == Nil + || ![requestClass instancesRespondToSelector:initSelector]) { + if (error) *error = @"screenshot request API unavailable"; + return nil; + } + long long screenID = (long long)IPURMainDisplayID(); + id request = ((id (*)(id, SEL, long long, CGRect, id, unsigned long long))objc_msgSend)( + [requestClass alloc], initSelector, screenID, CGRectNull, encoding, 0ULL); + if (request == nil) { + if (error) *error = @"could not build XCTScreenshotRequest"; + return nil; + } + __block NSData *result = nil; + __block NSString *failure = nil; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + void (^reply)(id, NSError *) = ^(id image, NSError *replyError) { + result = IPURImageData(image); + if (result == nil) failure = replyError.localizedDescription ?: @"screenshot reply carried no data"; + dispatch_semaphore_signal(done); + }; + ((void (*)(id, SEL, id, id))objc_msgSend)(dataSource, requestSelector, request, reply); + if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC))) != 0) { + if (error) *error = @"screenshot request timed out"; + return nil; + } + if (result == nil && error) *error = failure; + return result; +} + +static NSData *IPURCaptureViaEncoding(id encoding, NSString **error) +{ + id screen = XCUIScreen.mainScreen; + SEL selector = NSSelectorFromString(@"screenshotWithEncoding:options:"); + if (![screen respondsToSelector:selector]) { + if (error) *error = @"XCUIScreen screenshotWithEncoding:options: unavailable"; + return nil; + } + id screenshot = ((id (*)(id, SEL, id, unsigned long long))objc_msgSend)(screen, selector, encoding, 0ULL); + NSData *data = IPURImageData(IPURObject(screenshot, @"internalImage")); + if (data == nil && error) *error = @"encoded screenshot carried no data"; + return data; +} + +/// Downscales and/or re-encodes as JPEG with ImageIO. `scale` 1 with `reencode` NO returns the +/// input untouched. +static NSData *IPURJPEGTranscode(NSData *input, double scale, double quality, BOOL reencode) +{ + if (input == nil) return nil; + if (scale >= 0.999 && !reencode) return input; + CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)input, NULL); + if (source == NULL) return nil; + CGImageRef image = NULL; + if (scale < 0.999) { + NSDictionary *properties = CFBridgingRelease(CGImageSourceCopyPropertiesAtIndex(source, 0, NULL)); + double width = [properties[(id)kCGImagePropertyPixelWidth] doubleValue]; + double height = [properties[(id)kCGImagePropertyPixelHeight] doubleValue]; + NSUInteger maxPixels = (NSUInteger)MAX(16.0, round(MAX(width, height) * scale)); + NSDictionary *options = @{ + (id)kCGImageSourceCreateThumbnailFromImageAlways: @YES, + (id)kCGImageSourceThumbnailMaxPixelSize: @(maxPixels), + (id)kCGImageSourceCreateThumbnailWithTransform: @YES, + (id)kCGImageSourceShouldCacheImmediately: @YES, + }; + image = CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)options); + } else { + image = CGImageSourceCreateImageAtIndex(source, 0, NULL); + } + CFRelease(source); + if (image == NULL) return nil; + NSMutableData *output = [NSMutableData data]; + CGImageDestinationRef destination = + CGImageDestinationCreateWithData((__bridge CFMutableDataRef)output, CFSTR("public.jpeg"), 1, NULL); + if (destination == NULL) { + CGImageRelease(image); + return nil; + } + NSDictionary *properties = @{(id)kCGImageDestinationLossyCompressionQuality: @(quality)}; + CGImageDestinationAddImage(destination, image, (__bridge CFDictionaryRef)properties); + BOOL ok = CGImageDestinationFinalize(destination); + CFRelease(destination); + CGImageRelease(image); + return ok ? output : nil; +} + ++ (nullable NSData *)jpegScreenshotWithQuality:(double)quality + scale:(double)scale + path:(NSString *_Nullable *_Nullable)path + error:(NSString *_Nullable *_Nullable)error +{ + quality = MIN(1.0, MAX(0.01, quality)); + scale = (scale <= 0 || scale > 1) ? 1.0 : scale; + // Paths that failed once are not retried on every frame. + static atomic_bool requestBroken = false; + static atomic_bool encodingBroken = false; + NSString *lastError = nil; + @try { + id encoding = IPURJPEGEncoding(quality); + if (encoding != nil && !atomic_load(&requestBroken)) { + NSString *failure = nil; + NSData *jpeg = IPURCaptureViaRequest(encoding, &failure); + if (jpeg != nil) { + if (path) *path = @"request"; + return IPURJPEGTranscode(jpeg, scale, quality, NO); + } + // A timeout is transient (testmanagerd busy, a reply routed through a busy main thread); + // anything else means the path does not work here. + if ([failure isEqualToString:@"screenshot request timed out"]) { + NSLog(@"ipu-runner: screenshot request timed out; using the next path for this frame"); + } else { + NSLog(@"ipu-runner: screenshot request path failed, not retrying it: %@", failure); + atomic_store(&requestBroken, true); + } + lastError = failure; + } + if (encoding != nil && !atomic_load(&encodingBroken)) { + NSString *failure = nil; + NSData *jpeg = IPURCaptureViaEncoding(encoding, &failure); + if (jpeg != nil) { + if (path) *path = @"encoding"; + return IPURJPEGTranscode(jpeg, scale, quality, NO); + } + NSLog(@"ipu-runner: screenshot encoding path failed, not retrying it: %@", failure); + atomic_store(&encodingBroken, true); + lastError = failure; + } + NSData *png = XCUIScreen.mainScreen.screenshot.PNGRepresentation; + NSData *jpeg = IPURJPEGTranscode(png, scale, quality, YES); + if (jpeg != nil) { + if (path) *path = @"public"; + return jpeg; + } + lastError = @"public screenshot could not be encoded"; + } @catch (NSException *exception) { + lastError = [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + } + if (error) *error = lastError; + return nil; +} + +// MARK: - Device + ++ (BOOL)isScreenLocked:(BOOL *)known +{ + // The same SpringBoardServices calls WDA's fb_isScreenLocked makes. + typedef mach_port_t (*IPURServerPort)(void); + typedef void (*IPURLockStatus)(mach_port_t, BOOL *, BOOL *); + static IPURServerPort serverPort; + static IPURLockStatus lockStatus; + static dispatch_once_t once; + dispatch_once(&once, ^{ + void *handle = dlopen( + "/System/Library/PrivateFrameworks/SpringBoardServices.framework/SpringBoardServices", RTLD_LAZY); + if (handle != NULL) { + serverPort = (IPURServerPort)dlsym(handle, "SBSSpringBoardServerPort"); + lockStatus = (IPURLockStatus)dlsym(handle, "SBGetScreenLockStatus"); + } + }); + if (serverPort == NULL || lockStatus == NULL) { + if (known != NULL) *known = NO; + return NO; + } + BOOL locked = NO; + BOOL passcodeEnabled = NO; + lockStatus(serverPort(), &locked, &passcodeEnabled); + if (known != NULL) *known = YES; + return locked; +} + ++ (nullable NSString *)pressLockButton +{ + SEL selector = NSSelectorFromString(@"pressLockButton"); + if (![XCUIDevice.sharedDevice respondsToSelector:selector]) { + return @"XCUIDevice pressLockButton is unavailable"; + } + @try { + ((void (*)(id, SEL))objc_msgSend)(XCUIDevice.sharedDevice, selector); + return nil; + } @catch (NSException *exception) { + return [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + } +} + +@end diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPhoneUseRunnerUITests-Bridging-Header.h b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPhoneUseRunnerUITests-Bridging-Header.h new file mode 100644 index 0000000..8a07cac --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/IPhoneUseRunnerUITests-Bridging-Header.h @@ -0,0 +1 @@ +#import "IPURBridge.h" diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerActions.swift b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerActions.swift new file mode 100644 index 0000000..7172609 --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerActions.swift @@ -0,0 +1,91 @@ +// W3C Actions (pointer sources) → touch paths for IPURBridge synthesizeTouchPaths:name:. +// Pure logic, no XCTest, so runner/unit-check.sh can exercise it on the Mac. + +import CoreGraphics +import Foundation + +enum W3CActions { + static func number(_ object: [String: Any], _ key: String) -> Double? { + if let value = object[key] as? NSNumber { return value.doubleValue } + if let value = object[key] as? String { return Double(value) } + return nil + } + + /// Turns one W3C pointer source's `actions` into touch paths (one per down…up). Moves while the + /// pointer is down are sampled every ~16 ms; a down…up with no time between them is held for + /// 50 ms so iOS sees a tap. Each step is `{"type": "down"|"move"|"up", "x", "y", "t" (s)}`. + /// `elementCenter` resolves an element-origin pointerMove. + static func pointerPaths( + _ actions: [[String: Any]], elementCenter: (String) throws -> CGPoint + ) throws -> [[[String: Any]]] { + var paths: [[[String: Any]]] = [] + var current: [[String: Any]] = [] + var t = 0.0 + var position = CGPoint.zero + var downAt: Double? + func step(_ type: String, _ point: CGPoint, _ offset: Double) -> [String: Any] { + ["type": type, "x": Double(point.x), "y": Double(point.y), "t": offset] + } + for action in actions { + let duration = max(0, number(action, "duration") ?? 0) / 1000 + switch action["type"] as? String { + case "pause": + t += duration + case "pointerMove": + let dx = CGFloat(number(action, "x") ?? 0) + let dy = CGFloat(number(action, "y") ?? 0) + var target = CGPoint(x: dx, y: dy) + if let origin = action["origin"] as? String, origin == "pointer" { + target = CGPoint(x: position.x + dx, y: position.y + dy) + } else if let elementID = ElementReference.id(from: action["origin"]) { + let center = try elementCenter(elementID) + target = CGPoint(x: center.x + dx, y: center.y + dy) + } + if downAt != nil { + let samples = max(1, min(120, Int((duration / 0.016).rounded(.up)))) + for index in 1...samples { + let fraction = Double(index) / Double(samples) + let point = CGPoint( + x: position.x + (target.x - position.x) * CGFloat(fraction), + y: position.y + (target.y - position.y) * CGFloat(fraction)) + current.append(step("move", point, t + duration * fraction)) + } + } + t += duration + position = target + case "pointerDown": + if downAt == nil { + current = [step("down", position, t)] + downAt = t + } + case "pointerUp", "pointerCancel": + if let down = downAt { + t = max(t, down + 0.05) + current.append(step("up", position, t)) + paths.append(current) + current = [] + downAt = nil + } + default: + throw RunnerError.invalidArgument("unsupported pointer action '\(action["type"] ?? "nil")'") + } + } + if let down = downAt { + current.append(step("up", position, max(t, down + 0.05))) + paths.append(current) + } + return paths + } +} + +/// WDA / W3C element reference objects. +enum ElementReference { + static let w3cKey = "element-6066-11e4-a52e-4f735466cecf" + + static func make(_ id: String) -> [String: Any] { ["ELEMENT": id, w3cKey: id] } + + static func id(from value: Any?) -> String? { + guard let object = value as? [String: Any] else { return nil } + return object[w3cKey] as? String ?? object["ELEMENT"] as? String + } +} diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerElements.swift b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerElements.swift new file mode 100644 index 0000000..c1700ad --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerElements.swift @@ -0,0 +1,329 @@ +// Element lookup for the WDA-compatible routes: an in-memory tree built from the private AX +// snapshot (the same read /source serves), WDA's locator strategies evaluated against it, and an +// id -> element registry. Finding elements this way costs one tree read instead of an XCUI query +// resolution per call, and it sees exactly what /source shows. + +import CoreGraphics +import Foundation + +/// One accessibility node. `attributes` holds the WDA /source keys (no children); `axElement` is +/// the live XCAccessibilityElement, used to re-snapshot just this element later. +final class UINode { + let attributes: [String: Any] + let axElement: AnyObject? + private(set) var children: [UINode] = [] + private(set) weak var parent: UINode? + let pid: Int32 + + init(raw: [String: Any], parent: UINode?, pid: Int32) { + var attributes = raw + attributes.removeValue(forKey: "children") + let element = attributes.removeValue(forKey: IPURNodeAXElementKey) + self.attributes = attributes + self.axElement = element as AnyObject? + self.parent = parent + self.pid = pid + children = (raw["children"] as? [[String: Any]] ?? []).map { UINode(raw: $0, parent: self, pid: pid) } + } + + var type: String { attributes["type"] as? String ?? "XCUIElementTypeOther" } + var label: String? { nonEmpty(attributes["label"]) } + var identifier: String? { nonEmpty(attributes["rawIdentifier"]) } + var name: String? { identifier ?? label } + var value: String? { attributes["value"] as? String } + var placeholder: String? { nonEmpty(attributes["placeholderValue"]) } + var isEnabled: Bool { attributes["isEnabled"] as? String != "0" } + var isFocused: Bool { attributes["isFocused"] as? String == "1" } + + var rect: CGRect { + let r = attributes["rect"] as? [String: Any] ?? [:] + func v(_ key: String) -> CGFloat { CGFloat((r[key] as? NSNumber)?.doubleValue ?? 0) } + return CGRect(x: v("x"), y: v("y"), width: v("width"), height: v("height")) + } + + var center: CGPoint { CGPoint(x: rect.midX, y: rect.midY) } + + /// WDA has no cheap visibility either way; the runner answers geometrically: a non-empty frame + /// that intersects the screen. + func isVisible(screen: CGSize) -> Bool { + let frame = rect + guard frame.width > 0, frame.height > 0 else { return false } + return frame.intersects(CGRect(origin: .zero, size: screen)) + } + + /// Every node below this one, in document (pre-) order. + func descendants() -> [UINode] { + var out: [UINode] = [] + func walk(_ node: UINode) { + for child in node.children { + out.append(child) + walk(child) + } + } + walk(self) + return out + } + + /// The object NSPredicate locators are evaluated against: WDA's predicate attribute names + /// (and their wd*/is* aliases) mapped onto this node. + func predicateObject(screen: CGSize) -> NSDictionary { + let visible = isVisible(screen: screen) + let frame = rect + let rectDictionary: [String: Any] = [ + "x": frame.origin.x, "y": frame.origin.y, "width": frame.width, "height": frame.height, + ] + var object: [String: Any] = [ + "type": type, "wdType": type, "elementType": RunnerElementTypes.rawValue(of: type), + "enabled": isEnabled, "isEnabled": isEnabled, "wdEnabled": isEnabled, + "visible": visible, "isVisible": visible, "wdVisible": visible, + "accessible": true, "isAccessible": true, "wdAccessible": true, + "focused": isFocused, "hasFocus": isFocused, "isFocused": isFocused, "wdFocused": isFocused, + "selected": false, "isSelected": false, "wdSelected": false, + "rect": rectDictionary, "wdRect": rectDictionary, "frame": rectDictionary, + "hittable": visible, "isHittable": visible, "wdHittable": visible, + ] + if let name { object["name"] = name; object["wdName"] = name } + if let label { object["label"] = label; object["wdLabel"] = label } + if let value { object["value"] = value; object["wdValue"] = value } + if let identifier { object["identifier"] = identifier; object["rawIdentifier"] = identifier } + if let placeholder { object["placeholderValue"] = placeholder; object["wdPlaceholderValue"] = placeholder } + object["title"] = "" + return object as NSDictionary + } + + private func nonEmpty(_ value: Any?) -> String? { + guard let string = value as? String, !string.isEmpty else { return nil } + return string + } +} + +enum RunnerElementTypes { + static let names: [String] = (0...82).map { IPURBridge.elementTypeName($0) } + static let indexByName: [String: Int] = Dictionary(uniqueKeysWithValues: names.enumerated().map { ($1, $0) }) + + static func rawValue(of name: String) -> Int { indexByName[name] ?? 1 } + + /// Accepts "XCUIElementTypeButton" or the short "Button". + static func normalize(_ name: String) -> String { + name.hasPrefix("XCUIElementType") ? name : "XCUIElementType" + name + } +} + +// MARK: - Locators + +enum Locator { + /// Evaluates one WDA locator strategy below `root` (the root itself never matches). + static func find(using: String, value: String, root: UINode, screen: CGSize) throws -> [UINode] { + switch using { + case "accessibility id", "id", "name": + // XCUI's identifier matching (what WDA uses) matches the identifier or, failing that, the label. + return root.descendants().filter { $0.identifier == value || $0.label == value } + case "class name": + let type = RunnerElementTypes.normalize(value) + return root.descendants().filter { $0.type == type } + case "predicate string": + let predicate = try makePredicate(value) + return try filter(root.descendants(), predicate, screen: screen) + case "class chain": + return try ClassChain(value).evaluate(root: root, screen: screen) + case "link text", "partial link text": + let parts = value.split(separator: "=", maxSplits: 1).map(String.init) + guard parts.count == 2 else { throw RunnerError.invalidSelector("link text must be 'attribute=value'") } + let partial = using == "partial link text" + return root.descendants().filter { node in + let candidate = node.predicateObject(screen: screen)[parts[0]] as? String ?? "" + return partial ? candidate.contains(parts[1]) : candidate == parts[1] + } + default: + throw RunnerError.invalidSelector("locator strategy '\(using)' is not supported by the native runner") + } + } + + static func makePredicate(_ format: String) throws -> NSPredicate { + var predicate: NSPredicate? + if let exception = IPURBridge.catchException({ predicate = NSPredicate(format: format) }) { + throw RunnerError.invalidSelector("invalid predicate '\(format)': \(exception)") + } + guard let predicate else { throw RunnerError.invalidSelector("invalid predicate '\(format)'") } + return predicate + } + + static func filter(_ nodes: [UINode], _ predicate: NSPredicate, screen: CGSize) throws -> [UINode] { + var matched: [UINode] = [] + let exception = IPURBridge.catchException { + for node in nodes where predicate.evaluate(with: node.predicateObject(screen: screen)) { + matched.append(node) + } + } + if let exception { + throw RunnerError.invalidSelector("predicate '\(predicate.predicateFormat)' failed: \(exception)") + } + return matched + } +} + +/// WDA's class chain locator: `/`-separated steps, each `**` (any depth) or a type / `*`, with +/// `[n]` (1-based, negative from the end), `` [`predicate`] `` and `[$predicate$]` (has a matching +/// descendant) filters. An index applies to everything the step matched so far, like the XCUI +/// query WDA builds. +struct ClassChain { + struct Step { + var anyDepth = false + var type: String? // nil = "*" + var filters: [Filter] = [] + } + + enum Filter { + case index(Int) + case predicate(NSPredicate) + case descendantPredicate(NSPredicate) + } + + let steps: [Step] + + init(_ chain: String) throws { + var steps: [Step] = [] + var pendingAnyDepth = false + for raw in try Self.split(chain) { + let token = raw.trimmingCharacters(in: .whitespaces) + if token == "**" { + pendingAnyDepth = true + continue + } + var step = try Self.parseStep(token) + step.anyDepth = pendingAnyDepth + pendingAnyDepth = false + steps.append(step) + } + if pendingAnyDepth { throw RunnerError.invalidSelector("class chain cannot end with '**'") } + if steps.isEmpty { throw RunnerError.invalidSelector("empty class chain") } + self.steps = steps + } + + /// Splits on `/` outside backtick / dollar quoted predicates. + private static func split(_ chain: String) throws -> [String] { + var parts: [String] = [] + var current = "" + var quote: Character? + for character in chain { + if let open = quote { + current.append(character) + if character == open { quote = nil } + continue + } + if character == "`" || character == "$" { + quote = character + current.append(character) + } else if character == "/" { + parts.append(current) + current = "" + } else { + current.append(character) + } + } + if quote != nil { throw RunnerError.invalidSelector("unterminated predicate in class chain") } + parts.append(current) + return parts.filter { !$0.isEmpty } + } + + private static func parseStep(_ token: String) throws -> Step { + var step = Step() + let typeEnd = token.firstIndex(of: "[") ?? token.endIndex + let typeName = String(token[.. [UINode] { + var current: [UINode] = [root] + for step in steps { + var candidates: [UINode] = [] + var seen = Set() + for node in current { + for candidate in (step.anyDepth ? node.descendants() : node.children) { + if seen.insert(ObjectIdentifier(candidate)).inserted { candidates.append(candidate) } + } + } + if let type = step.type { candidates = candidates.filter { $0.type == type } } + for filter in step.filters { + switch filter { + case .index(let number): + let index = number > 0 ? number - 1 : candidates.count + number + candidates = candidates.indices.contains(index) ? [candidates[index]] : [] + case .predicate(let predicate): + candidates = try Locator.filter(candidates, predicate, screen: screen) + case .descendantPredicate(let predicate): + candidates = try candidates.filter { !(try Locator.filter($0.descendants(), predicate, screen: screen)).isEmpty } + } + } + current = candidates + if current.isEmpty { break } + } + return current + } +} + +// MARK: - Registry + +/// Element ids handed out by the find routes, WDA style. Entries keep the node they were found +/// as; reads re-snapshot the live element so rects and values are fresh. +final class ElementRegistry { + private var entries: [String: UINode] = [:] + private var order: [String] = [] + private let capacity = 4000 + + func register(_ node: UINode) -> String { + let id = UUID().uuidString + entries[id] = node + order.append(id) + if order.count > capacity { + for stale in order.prefix(capacity / 4) { entries.removeValue(forKey: stale) } + order.removeFirst(capacity / 4) + } + return id + } + + func node(_ id: String) -> UINode? { entries[id] } +} diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerHTTP.swift b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerHTTP.swift new file mode 100644 index 0000000..74ed312 --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerHTTP.swift @@ -0,0 +1,315 @@ +// Minimal HTTP/1.1 server for the iphone-use native runner: one request per connection, +// `Connection: close`, JSON bodies. +// +// The single-long-running-XCTest-method + NWListener design is adapted from +// callstack/agent-device (MIT License, Copyright (c) 2026 Callstack), RunnerTests+Transport.swift. +// See runner/README.md for the full attribution and license text. + +import Foundation +import Network + +struct HTTPRequest { + let method: String + let path: String + let query: [String: String] + let headers: [String: String] + let body: Data + + /// The JSON object body, or an empty dictionary when there is no body. + func jsonObject() throws -> [String: Any] { + if body.isEmpty { return [:] } + let parsed = try JSONSerialization.jsonObject(with: body, options: [.fragmentsAllowed]) + guard let object = parsed as? [String: Any] else { + throw RunnerError.invalidArgument("request body must be a JSON object") + } + return object + } + + enum ParseResult { + case incomplete + case invalid(String) + case complete(HTTPRequest) + } + + static func parse(_ data: Data) -> ParseResult { + guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { + return data.count > 64 * 1024 ? .invalid("request header too large") : .incomplete + } + let head = String(decoding: data.subdata(in: data.startIndex..= 2 else { return .invalid("malformed request line") } + var headers: [String: String] = [:] + for line in lines { + guard let colon = line.firstIndex(of: ":") else { continue } + let name = line[.. RunnerHTTPServer.maxBodyBytes { + return .invalid("content-length out of range") + } + let bodyStart = headerEnd.upperBound + if data.count - (bodyStart - data.startIndex) < contentLength { return .incomplete } + let body = data.subdata(in: bodyStart..<(bodyStart + contentLength)) + + let target = String(requestLine[1]) + var path = target + var query: [String: String] = [:] + if let questionMark = target.firstIndex(of: "?") { + path = String(target[.. 1 && path.hasSuffix("/") { path.removeLast() } + return .complete( + HTTPRequest(method: String(requestLine[0]).uppercased(), path: path, query: query, headers: headers, body: body) + ) + } +} + +struct HTTPResponse { + var status: Int + var body: Data + var headers: [String: String] = [:] + + /// WDA-style success envelope: `{"value": }`. + static func value(_ value: Any, headers: [String: String] = [:], sessionId: String? = nil) -> HTTPResponse { + var envelope: [String: Any] = ["value": value] + if let sessionId { envelope["sessionId"] = sessionId } + return HTTPResponse(status: 200, body: encode(envelope), headers: headers) + } + + /// WDA-style error envelope: `{"value": {"error": code, "message": message}}`. + static func error(_ status: Int, _ code: String, _ message: String) -> HTTPResponse { + HTTPResponse(status: status, body: encode(["value": ["error": code, "message": message]])) + } + + static func from(_ error: Error) -> HTTPResponse { + if let runnerError = error as? RunnerError { + return .error(runnerError.status, runnerError.code, runnerError.message) + } + return .error(500, "unknown error", String(describing: error)) + } + + static func encode(_ object: Any) -> Data { + do { + return try JSONSerialization.data(withJSONObject: object, options: [.withoutEscapingSlashes]) + } catch { + return Data(#"{"value":{"error":"unknown error","message":"response is not JSON-serializable"}}"#.utf8) + } + } + + func serialized() -> Data { + var head = "HTTP/1.1 \(status) \(Self.reason(status))\r\n" + head += "Content-Type: application/json; charset=utf-8\r\n" + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n" + for (name, value) in headers.sorted(by: { $0.key < $1.key }) { + head += "\(name): \(value)\r\n" + } + head += "\r\n" + var data = Data(head.utf8) + data.append(body) + return data + } + + static func reason(_ status: Int) -> String { + switch status { + case 200: return "OK" + case 400: return "Bad Request" + case 404: return "Not Found" + case 405: return "Method Not Allowed" + case 413: return "Payload Too Large" + case 500: return "Internal Server Error" + case 501: return "Not Implemented" + case 503: return "Service Unavailable" + default: return "Status" + } + } +} + +/// A failed request, carried to the WDA error envelope `{"value":{"error":code,"message":...}}`. +/// The codes are WDA's / W3C's, so the daemon's 404 checks ("no such alert", stale elements) hold. +struct RunnerError: Error { + let status: Int + let code: String + let message: String + + static func invalidArgument(_ message: String) -> RunnerError { + RunnerError(status: 400, code: "invalid argument", message: message) + } + + static func notFound(_ message: String, code: String = "no such element") -> RunnerError { + RunnerError(status: 404, code: code, message: message) + } + + static func failed(_ message: String) -> RunnerError { + RunnerError(status: 500, code: "unknown error", message: message) + } + + static func unsupported(_ message: String) -> RunnerError { + RunnerError(status: 501, code: "unsupported operation", message: message) + } + + static func invalidSelector(_ message: String) -> RunnerError { + RunnerError(status: 400, code: "invalid selector", message: message) + } + + static func staleElement(_ id: String) -> RunnerError { + RunnerError( + status: 404, code: "stale element reference", + message: "The previously found element \(id) is not present in the current view anymore") + } + + static func noSuchAlert() -> RunnerError { + RunnerError( + status: 404, code: "no such alert", + message: "An attempt was made to operate on a modal dialog when one was not open") + } +} + +/// Accepts connections on a background queue, hands each complete request to `mainHandler` on the +/// main queue (serially — XCTest and the private AX client are main-thread APIs), and answers +/// requests `inlineHandler` claims directly on the transport queue so liveness probes never wait +/// behind a slow command. +final class RunnerHTTPServer { + static let maxBodyBytes = 8 * 1024 * 1024 + + private let queue = DispatchQueue(label: "com.leeguoo.iphone-use.runner.transport") + private let commandQueue = DispatchQueue(label: "com.leeguoo.iphone-use.runner.commands") + private let listener: NWListener + private let inlineHandler: (HTTPRequest) -> HTTPResponse? + private let mainHandler: (HTTPRequest) -> HTTPResponse + var onFailure: ((Error) -> Void)? + + init( + port: UInt16, + inlineHandler: @escaping (HTTPRequest) -> HTTPResponse?, + mainHandler: @escaping (HTTPRequest) -> HTTPResponse + ) throws { + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + throw RunnerError.invalidArgument("invalid port \(port)") + } + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + listener = try NWListener(using: parameters, on: endpointPort) + self.inlineHandler = inlineHandler + self.mainHandler = mainHandler + } + + /// The phone's Wi-Fi IPv4 address (en0), else 127.0.0.1. Only the LAN relay uses the host + /// part; the default USB relay needs just the port. + static func deviceAddress() -> String { + var head: UnsafeMutablePointer? + guard getifaddrs(&head) == 0, let first = head else { return "127.0.0.1" } + defer { freeifaddrs(head) } + var cursor: UnsafeMutablePointer? = first + while let entry = cursor { + defer { cursor = entry.pointee.ifa_next } + guard let address = entry.pointee.ifa_addr, address.pointee.sa_family == UInt8(AF_INET), + String(cString: entry.pointee.ifa_name) == "en0" + else { continue } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + if getnameinfo(address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), + nil, 0, NI_NUMERICHOST) == 0 { + return String(cString: host) + } + } + return "127.0.0.1" + } + + func start() { + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + let port = Int(self?.listener.port?.rawValue ?? 0) + NSLog("ipu-runner: listening on port %d", port) + // The line setup-wda.sh waits for (WebDriverAgent's marker, kept so the relay logic and + // its LAN fallback read the device address the same way). + NSLog("ServerURLHere->http://%@:%d<-ServerURLHere", RunnerHTTPServer.deviceAddress(), port) + case .failed(let error): + NSLog("ipu-runner: listener failed: %@", String(describing: error)) + self?.onFailure?(error) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + guard let self else { return } + connection.start(queue: self.queue) + self.receive(on: connection, buffer: Data()) + } + listener.start(queue: queue) + } + + func stop() { + listener.cancel() + } + + private func receive(on connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 20) { [weak self] data, _, isComplete, error in + guard let self else { + connection.cancel() + return + } + var buffer = buffer + if let data { buffer.append(data) } + if buffer.count > Self.maxBodyBytes + 64 * 1024 { + self.send(.error(413, "invalid argument", "request too large"), on: connection) + return + } + switch HTTPRequest.parse(buffer) { + case .incomplete: + if isComplete || error != nil { + connection.cancel() + } else { + self.receive(on: connection, buffer: buffer) + } + case .invalid(let message): + self.send(.error(400, "invalid argument", message), on: connection) + case .complete(let request): + self.dispatch(request, on: connection) + } + } + } + + private func dispatch(_ request: HTTPRequest, on connection: NWConnection) { + let started = DispatchTime.now().uptimeNanoseconds + if let response = inlineHandler(request) { + finish(request, response, started: started, on: connection) + return + } + // One command at a time: the serial command queue blocks on main.sync, so even if XCTest + // spins the main run loop inside a handler (synthesis, queries) no second request can start. + commandQueue.async { [weak self] in + guard let self else { return } + let response = DispatchQueue.main.sync { self.mainHandler(request) } + self.finish(request, response, started: started, on: connection) + } + } + + private func finish(_ request: HTTPRequest, _ response: HTTPResponse, started: UInt64, on connection: NWConnection) { + let elapsedMs = Double(DispatchTime.now().uptimeNanoseconds - started) / 1_000_000 + var response = response + response.headers["Server-Timing"] = String(format: "runner;dur=%.1f", elapsedMs) + NSLog("ipu-runner: %@ %@ -> %d (%.1f ms)", request.method, request.path, response.status, elapsedMs) + send(response, on: connection) + } + + private func send(_ response: HTTPResponse, on connection: NWConnection) { + connection.send(content: response.serialized(), isComplete: true, completion: .contentProcessed { error in + if let error { + NSLog("ipu-runner: send failed: %@", String(describing: error)) + } + connection.cancel() + }) + } +} diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerMJPEG.swift b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerMJPEG.swift new file mode 100644 index 0000000..8e8b3f5 --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerMJPEG.swift @@ -0,0 +1,241 @@ +// MJPEG screen stream in WebDriverAgent's wire format, so the daemon's MjpegSplitter +// (crates/server/src/video.rs) reads it unchanged: +// +// HTTP/1.0 200 OK ... Content-Type: multipart/x-mixed-replace; boundary=--BoundaryString +// --BoundaryString\r\nContent-type: image/jpg\r\nContent-Length: N\r\n\r\n\r\n (per frame) +// +// Frames are captured on a dedicated thread, never on main, and only while a client is +// connected. Framerate / scaling / quality follow WDA's appium settings (mjpegServerFramerate, +// mjpegScalingFactor %, mjpegServerScreenshotQuality %). + +import Foundation +import Network + +final class RunnerMJPEGServer { + struct Settings { + var framerate = 10 // WDA's defaults + var scalingPercent = 100 + var qualityPercent = 25 + } + + private let listener: NWListener + private let queue = DispatchQueue(label: "com.leeguoo.iphone-use.runner.mjpeg") + private let lock = NSLock() + private var settings = Settings() + private var clients: [ObjectIdentifier: Client] = [:] + private var capturing = false + private var stats = (frames: 0, since: Date(), fps: 0.0, lastPath: "-", lastBytes: 0, lastCaptureMs: 0.0) + + private final class Client { + let connection: NWConnection + var sending = false + init(_ connection: NWConnection) { self.connection = connection } + } + + init(port: UInt16) throws { + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + throw RunnerError.invalidArgument("invalid MJPEG port \(port)") + } + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + listener = try NWListener(using: parameters, on: endpointPort) + } + + func start() { + listener.stateUpdateHandler = { [weak self] state in + if case .ready = state { + NSLog("ipu-runner: MJPEG stream on port %d", Int(self?.listener.port?.rawValue ?? 0)) + } else if case .failed(let error) = state { + NSLog("ipu-runner: MJPEG listener failed: %@", String(describing: error)) + } + } + listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) } + listener.start(queue: queue) + } + + func stop() { listener.cancel() } + + /// Applies whichever mjpeg* keys `values` carries (appium/settings body). + func apply(_ values: [String: Any]) { + func int(_ key: String) -> Int? { + (values[key] as? NSNumber)?.intValue ?? (values[key] as? String).flatMap { Int($0) } + } + lock.lock() + if let framerate = int("mjpegServerFramerate") { settings.framerate = min(60, max(1, framerate)) } + if let scaling = int("mjpegScalingFactor") { settings.scalingPercent = min(100, max(1, scaling)) } + if let quality = int("mjpegServerScreenshotQuality") { settings.qualityPercent = min(100, max(1, quality)) } + let current = settings + lock.unlock() + NSLog("ipu-runner: MJPEG settings fps=%d scale=%d%% quality=%d%%", + current.framerate, current.scalingPercent, current.qualityPercent) + } + + /// Settings plus what the stream achieves, for /status. + func statusValue() -> [String: Any] { + lock.lock() + defer { lock.unlock() } + return [ + "clients": clients.count, + "framerate": settings.framerate, + "scalingFactor": settings.scalingPercent, + "quality": settings.qualityPercent, + "achievedFps": (stats.fps * 10).rounded() / 10, + "capturePath": stats.lastPath, + "lastFrameBytes": stats.lastBytes, + "lastCaptureMs": (stats.lastCaptureMs * 10).rounded() / 10, + ] + } + + // MARK: - Connections + + private func accept(_ connection: NWConnection) { + let client = Client(connection) + connection.stateUpdateHandler = { [weak self, weak client] state in + switch state { + case .failed, .cancelled: + if let client { self?.remove(client) } + default: + break + } + } + connection.start(queue: queue) + // Like WDA: wait for the request (any bytes), then answer with the stream headers. + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, _, error in + guard let self, error == nil, data != nil else { + connection.cancel() + return + } + let head = [ + "HTTP/1.0 200 OK", + "Server: iphone-use runner MJPEG", + "Connection: close", + "Max-Age: 0", + "Expires: 0", + "Cache-Control: no-cache, private", + "Pragma: no-cache", + "Content-Type: multipart/x-mixed-replace; boundary=--BoundaryString", + "", "", + ].joined(separator: "\r\n") + connection.send(content: Data(head.utf8), completion: .contentProcessed { [weak self] error in + guard let self else { return } + if error != nil { + connection.cancel() + return + } + self.add(client) + }) + self.drain(connection) + } + } + + /// Keeps reading (and discarding) so a closed client is noticed. + private func drain(_ connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] _, _, isComplete, error in + if isComplete || error != nil { + connection.cancel() + return + } + self?.drain(connection) + } + } + + private func add(_ client: Client) { + lock.lock() + clients[ObjectIdentifier(client)] = client + let startCapture = !capturing + if startCapture { + capturing = true + stats = (0, Date(), 0, stats.lastPath, stats.lastBytes, stats.lastCaptureMs) + } + let count = clients.count + lock.unlock() + NSLog("ipu-runner: MJPEG client connected (%d total)", count) + if startCapture { + let thread = Thread { [weak self] in self?.captureLoop() } + thread.name = "ipu-runner-mjpeg-capture" + thread.qualityOfService = .userInitiated + thread.start() + } + } + + private func remove(_ client: Client) { + lock.lock() + let removed = clients.removeValue(forKey: ObjectIdentifier(client)) != nil + let count = clients.count + lock.unlock() + if removed { NSLog("ipu-runner: MJPEG client left (%d remaining)", count) } + } + + // MARK: - Capture + + /// Runs while at least one client is connected; exits (and costs nothing) once the last leaves. + private func captureLoop() { + NSLog("ipu-runner: MJPEG capture started") + while true { + lock.lock() + if clients.isEmpty { + capturing = false + lock.unlock() + break + } + let current = settings + lock.unlock() + + let started = Date() + var path: NSString? + var error: NSString? + let jpeg = autoreleasepool { + IPURBridge.jpegScreenshot( + withQuality: Double(current.qualityPercent) / 100, + scale: Double(current.scalingPercent) / 100, + path: &path, error: &error) + } + let captureMs = Date().timeIntervalSince(started) * 1000 + if let jpeg { + broadcast(jpeg, path: path as String? ?? "?", captureMs: captureMs) + } else { + NSLog("ipu-runner: MJPEG capture failed: %@", (error as String?) ?? "unknown") + Thread.sleep(forTimeInterval: 0.5) + continue + } + let interval = 1.0 / Double(max(1, current.framerate)) + let remaining = interval - Date().timeIntervalSince(started) + if remaining > 0 { Thread.sleep(forTimeInterval: remaining) } + } + NSLog("ipu-runner: MJPEG capture stopped (no clients)") + } + + private func broadcast(_ jpeg: Data, path: String, captureMs: Double) { + var part = Data("--BoundaryString\r\nContent-type: image/jpg\r\nContent-Length: \(jpeg.count)\r\n\r\n".utf8) + part.append(jpeg) + part.append(Data("\r\n".utf8)) + + lock.lock() + let now = Date() + stats.frames += 1 + stats.lastPath = path + stats.lastBytes = jpeg.count + stats.lastCaptureMs = captureMs + let window = now.timeIntervalSince(stats.since) + if window >= 2 { + stats.fps = Double(stats.frames) / window + stats.frames = 0 + stats.since = now + } + // A client still writing the previous frame skips this one instead of queueing it, so a + // slow viewer gets fewer frames rather than growing latency. + let ready = clients.values.filter { !$0.sending } + ready.forEach { $0.sending = true } + lock.unlock() + + for client in ready { + client.connection.send(content: part, completion: .contentProcessed { [weak self, weak client] error in + guard let self, let client else { return } + self.lock.lock() + client.sending = false + self.lock.unlock() + if error != nil { client.connection.cancel() } + }) + } + } +} diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift new file mode 100644 index 0000000..78a9cbe --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift @@ -0,0 +1,580 @@ +// iphone-use native runner: a single long-running XCTest method that serves a small WDA-shaped +// HTTP API for the iphone-use daemon. +// +// The overall shape (one test method hosting an NWListener, blocking in XCTWaiter, swallowing +// recorded issues so the runner survives XCTest failures) is adapted from callstack/agent-device +// (MIT License, Copyright (c) 2026 Callstack), RunnerTests.swift. See runner/README.md. + +import Network +import UIKit +import XCTest + +final class RunnerTests: XCTestCase { + static let springBoardBundleID = "com.apple.springboard" + /// WDA's ports, so existing relays and daemon config work unchanged. + static let defaultPort: UInt16 = 8100 + static let defaultMJPEGPort: UInt16 = 9100 + static let defaultMaxDepth = 64 + static let defaultMaxNodes = 5000 + static let defaultExtensionCalls = 8 + + var server: RunnerHTTPServer? + var mjpeg: RunnerMJPEGServer? + var serveExpectation: XCTestExpectation? + let busyLock = NSLock() + var busySince: Date? + /// Issues XCTest recorded while the current request ran (fallback XCUI paths report through here). + var recordedIssues: [String] = [] + + // WDA-compatible surface (RunnerWDA.swift). + /// The one session id this runner hands out; any id is accepted in session paths. + static let sessionID = UUID().uuidString + static let systemVersion = UIDevice.current.systemVersion + let elements = ElementRegistry() + /// Last alert scan and when it ran; reused for a second while nothing was POSTed (WdaClient asks + /// /alert/text and /wda/alert/buttons back to back). + var alertCache: (at: Date, alert: FoundAlert?)? + /// appium/settings values, accepted and echoed (the runner never waits for idle anyway). + var wdaSettings: [String: Any] = ["waitForIdleTimeout": 0, "animationCoolOffTimeout": 0] + + override func setUp() { + continueAfterFailure = true + // Only enforced when xcodebuild runs with -test-timeouts-enabled YES; keep it out of reach + // anyway so a timeout-enabled launch does not kill the server after the default 10 minutes. + executionTimeAllowance = 365 * 24 * 60 * 60 + } + + /// The runner must outlive any XCTest failure: a recorded issue would otherwise end the serving + /// test case. Issues are logged and attached to the request that caused them. + override func record(_ issue: XCTIssue) { + NSLog("ipu-runner: xctest issue suppressed: %@", issue.compactDescription) + recordedIssues.append(issue.compactDescription) + } + + func testServe() throws { + let patched = IPURBridge.installQuiescenceBypass() + NSLog("ipu-runner: quiescence bypass installed on %@", patched.joined(separator: ", ")) + NSLog("ipu-runner: private AX client %@, event synthesis %@", + IPURBridge.axClient() == nil ? "MISSING" : "available", + IPURBridge.eventSynthesisAvailable() ? "available" : "MISSING") + + let environment = ProcessInfo.processInfo.environment + let port = environment["IPU_RUNNER_PORT"].flatMap { UInt16($0) } ?? Self.defaultPort + let expectation = XCTestExpectation(description: "ipu-runner serves until /shutdown") + serveExpectation = expectation + + let server = try RunnerHTTPServer( + port: port, + inlineHandler: { [weak self] request in self?.inlineResponse(request) }, + mainHandler: { [weak self] request in + guard let self else { return .error(503, "unknown error", "runner is shutting down") } + return self.handleOnMain(request) + } + ) + server.onFailure = { [weak self] _ in + DispatchQueue.main.async { self?.serveExpectation?.fulfill() } + } + self.server = server + server.start() + + // MJPEG stream; IPU_RUNNER_MJPEG_PORT=0 turns it off. A failure here must not take the + // command API down with it. + let mjpegPort = environment["IPU_RUNNER_MJPEG_PORT"].flatMap { UInt16($0) } ?? Self.defaultMJPEGPort + if mjpegPort > 0 { + do { + let mjpeg = try RunnerMJPEGServer(port: mjpegPort) + mjpeg.start() + self.mjpeg = mjpeg + } catch { + NSLog("ipu-runner: MJPEG server not started: %@", String(describing: error)) + } + } + + // Block this test (and keep the main run loop spinning for the request handlers) for a year, + // or until POST /shutdown. + let result = XCTWaiter.wait(for: [expectation], timeout: 365 * 24 * 60 * 60) + NSLog("ipu-runner: serve loop ended (%@)", String(describing: result)) + server.stop() + mjpeg?.stop() + } + + // MARK: - Dispatch + + /// Answered on the transport queue: liveness must not wait behind a slow command on main. + func inlineResponse(_ request: HTTPRequest) -> HTTPResponse? { + guard request.method == "GET" else { return nil } + if request.path == "/status" { + return .value(statusValue(), sessionId: Self.sessionID) + } + // WDA serves the lock state without a session; the daemon reads it first precisely because a + // locked phone stalls everything else, so it must not queue behind main either. + if request.path == "/wda/locked" + || (request.path.hasPrefix("/session/") && request.path.hasSuffix("/wda/locked")) { + var known = ObjCBool(false) + let locked = IPURBridge.isScreenLocked(&known) + if known.boolValue { return .value(locked) } + return nil // unknown: answered on main (see RunnerWDA.locked) + } + return nil + } + + func statusValue() -> [String: Any] { + busyLock.lock() + let busySince = self.busySince + busyLock.unlock() + let bundle = Bundle(for: RunnerTests.self) + let bundleID = bundle.bundleIdentifier ?? "com.leeguoo.iphone-use.runner" + let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + var value: [String: Any] = [ + "ready": true, + "message": "iphone-use native runner is ready to accept commands", + "state": "success", + "sessionId": Self.sessionID, + "os": ["name": "iOS", "version": Self.systemVersion, "sdkVersion": Self.systemVersion], + "ios": ["ip": NSNull()], + "build": ["productBundleIdentifier": bundleID, "version": version, "runner": "iphone-use-native"], + "bundle": bundleID, + "version": version, + "busy": busySince != nil, + ] + if let mjpeg { value["mjpeg"] = mjpeg.statusValue() } + if let busySince { + value["busyMs"] = Int(Date().timeIntervalSince(busySince) * 1000) + } + return value + } + + func handleOnMain(_ request: HTTPRequest) -> HTTPResponse { + busyLock.lock() + busySince = Date() + busyLock.unlock() + recordedIssues.removeAll() + if request.method != "GET", Self.mayChangeScreen(request.path) { + alertCache = nil + } + defer { + busyLock.lock() + busySince = nil + busyLock.unlock() + } + var response: HTTPResponse = .error(500, "unknown error", "handler did not run") + let exception = IPURBridge.catchException { + do { + response = try self.route(request) + } catch { + response = .from(error) + } + } + if let exception { + response = .error(500, "unknown error", exception) + } + if !recordedIssues.isEmpty { + response.headers["X-IPU-XCTest-Issues"] = String(recordedIssues.count) + } + return response + } + + func route(_ request: HTTPRequest) throws -> HTTPResponse { + if let response = try routeWDA(request) { return response } + switch (request.method, request.path) { + case ("GET", "/source"): return try source(request) + case ("POST", "/tap"): return try tap(request) + case ("POST", "/swipe"): return try swipe(request) + case ("POST", "/longpress"): return try longPress(request) + case ("POST", "/type"): return try typeText(request) + case ("POST", "/home"): return try home() + case ("POST", "/launch"): return try launch(request) + case ("GET", "/apps/active"): return try activeApp() + case ("GET", "/screenshot"): return try screenshot() + case ("GET", "/alert"): return try alert() + case ("POST", "/alert"): return try alertTap(request) + case ("GET", "/window/size"): return windowSize() + case ("POST", "/shutdown"): + DispatchQueue.main.async { [weak self] in self?.serveExpectation?.fulfill() } + return .value(["shutdown": true]) + default: + return .error(404, "unknown command", "\(request.method) \(request.path) is not a runner endpoint") + } + } + + // MARK: - Foreground application + + struct Foreground { + let element: AnyObject? + let pid: Int32 + var bundleID: String? { IPURBridge.bundleID(forPID: pid) } + + /// An XCUIApplication for public-API fallbacks: the monitored instance for the pid, else one + /// built from the bundle id, else SpringBoard. + var application: XCUIApplication { + if let app = IPURBridge.application(forPID: pid) { return app } + return XCUIApplication(bundleIdentifier: bundleID ?? RunnerTests.springBoardBundleID) + } + } + + func screenCenter() -> CGPoint { + let size = windowSizePoints() + return CGPoint(x: size.width / 2, y: size.height / 2) + } + + func foreground() -> Foreground { + var pid: Int32 = 0 + let element = IPURBridge.foregroundApplicationElement(withProbePoint: screenCenter(), pid: &pid) + return Foreground(element: element as AnyObject?, pid: pid) + } + + // MARK: - Arguments + + func number(_ object: [String: Any], _ key: String) -> Double? { + if let value = object[key] as? NSNumber { return value.doubleValue } + if let value = object[key] as? String { return Double(value) } + return nil + } + + func requiredNumber(_ object: [String: Any], _ key: String) throws -> Double { + guard let value = number(object, key), value.isFinite else { + throw RunnerError.invalidArgument("'\(key)' must be a number") + } + return value + } + + func requiredString(_ object: [String: Any], _ keys: String...) throws -> String { + for key in keys { + if let value = object[key] as? String, !value.isEmpty { return value } + } + throw RunnerError.invalidArgument("'\(keys[0])' must be a non-empty string") + } + + // MARK: - /source + + func source(_ request: HTTPRequest) throws -> HTTPResponse { + let explicitDepth = request.query["max_depth"].flatMap { Int($0) } + let maxDepth = max(1, explicitDepth ?? Self.defaultMaxDepth) + let maxNodes = max(1, request.query["max_nodes"].flatMap { Int($0) } ?? Self.defaultMaxNodes) + // An explicit depth is honoured as asked: no frontier re-rooting past it. + let extensionCalls = request.query["extension_calls"].flatMap { Int($0) } + ?? (explicitDepth == nil ? Self.defaultExtensionCalls : 0) + let forceXCUI = request.query["backend"] == "xcui" + let target = foreground() + + var privateError: String? + if !forceXCUI, let element = target.element { + let tree = IPURBridge.wdaTree( + forAXElement: element, + maxDepth: maxDepth, + maxNodes: maxNodes, + extensionCallLimit: extensionCalls, + rememberKey: target.pid > 0 ? String(target.pid) : nil + ) + if (tree[IPURTreeOkKey] as? Bool) == true, let root = tree[IPURTreeRootKey] { + return .value(root, headers: treeHeaders(tree, backend: "private-ax", pid: target.pid)) + } + privateError = tree[IPURTreeErrorKey] as? String ?? "private AX snapshot failed" + NSLog("ipu-runner: private AX snapshot failed, falling back to XCUI snapshot: %@", privateError!) + } + + let application = target.application + var snapshot: XCUIElementSnapshot? + var snapshotError: Error? + IPURBridge.performWithoutQuiescence(application) { + do { + snapshot = try application.snapshot() + } catch { + snapshotError = error + } + } + guard let snapshot else { + let reason = snapshotError.map { String(describing: $0) } ?? recordedIssues.last ?? "snapshot unavailable" + throw RunnerError.failed("source failed (private AX: \(privateError ?? "skipped"); XCUI: \(reason))") + } + let tree = IPURBridge.wdaTree(forSnapshot: snapshot as AnyObject, maxNodes: maxNodes) + guard (tree[IPURTreeOkKey] as? Bool) == true, let root = tree[IPURTreeRootKey] else { + throw RunnerError.failed(tree[IPURTreeErrorKey] as? String ?? "snapshot serialization failed") + } + return .value(root, headers: treeHeaders(tree, backend: "xcui-snapshot", pid: target.pid)) + } + + func treeHeaders(_ tree: [String: Any], backend: String, pid: Int32) -> [String: String] { + [ + "X-IPU-Backend": backend, + "X-IPU-Node-Count": "\(tree[IPURTreeNodeCountKey] ?? 0)", + "X-IPU-Depth": "\(tree[IPURTreeDepthKey] ?? 0)", + "X-IPU-Truncated": ((tree[IPURTreeTruncatedKey] as? Bool) == true) ? "1" : "0", + "X-IPU-Extension-Calls": "\(tree[IPURTreeExtensionCallsKey] ?? 0)", + "X-IPU-Pid": "\(pid)", + ] + } + + // MARK: - Gestures + + /// Runs a synthesized gesture; when the private path is missing or fails, runs `fallback` + /// (public XCUICoordinate API, quiescence skipped) and reports which path acted. + func gesture( + _ name: String, + synthesized: () -> String?, + fallback: (XCUIApplication) -> Void + ) throws -> HTTPResponse { + if let error = synthesized() { + NSLog("ipu-runner: %@ synthesis failed, using XCUICoordinate: %@", name, error) + let application = foreground().application + let issuesBefore = recordedIssues.count + var exception: String? + IPURBridge.performWithoutQuiescence(application) { + exception = IPURBridge.catchException { fallback(application) } + } + if let exception { + throw RunnerError.failed("\(name) failed (synthesis: \(error); coordinate: \(exception))") + } + if recordedIssues.count > issuesBefore { + throw RunnerError.failed("\(name) failed (synthesis: \(error); coordinate: \(recordedIssues.last!))") + } + return .value(NSNull(), headers: ["X-IPU-Gesture": "xcui-coordinate"]) + } + return .value(NSNull(), headers: ["X-IPU-Gesture": "synthesized"]) + } + + func coordinate(_ application: XCUIApplication, _ point: CGPoint) -> XCUICoordinate { + application.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + .withOffset(CGVector(dx: point.x, dy: point.y)) + } + + func tap(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let point = CGPoint(x: try requiredNumber(body, "x"), y: try requiredNumber(body, "y")) + return try gesture("tap", synthesized: { IPURBridge.synthesizeTap(at: point, pid: 0) }) { app in + coordinate(app, point).tap() + } + } + + func swipe(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let start = CGPoint(x: try requiredNumber(body, "x1"), y: try requiredNumber(body, "y1")) + let end = CGPoint(x: try requiredNumber(body, "x2"), y: try requiredNumber(body, "y2")) + let duration = max(0.05, (number(body, "duration_ms") ?? 300) / 1000) + return try gesture("swipe", synthesized: { + IPURBridge.synthesizeDrag(from: start, to: end, duration: duration, pid: 0) + }) { app in + coordinate(app, start).press(forDuration: 0.05, thenDragTo: coordinate(app, end)) + } + } + + func longPress(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let point = CGPoint(x: try requiredNumber(body, "x"), y: try requiredNumber(body, "y")) + let duration = max(0.05, (number(body, "duration_ms") ?? 1000) / 1000) + return try gesture("longpress", synthesized: { + IPURBridge.synthesizeLongPress(at: point, duration: duration, pid: 0) + }) { app in + coordinate(app, point).press(forDuration: duration) + } + } + + // MARK: - Text, buttons, apps + + func typeText(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + guard let text = body["text"] as? String else { + throw RunnerError.invalidArgument("'text' must be a string") + } + if text.isEmpty { return .value(NSNull()) } + let frequency = UInt(max(1, min(1000, number(body, "frequency") ?? 60))) + if let error = IPURBridge.synthesizeText(text, charactersPerSecond: frequency, pid: 0) { + NSLog("ipu-runner: text synthesis failed, using XCUIApplication.typeText: %@", error) + let application = foreground().application + let issuesBefore = recordedIssues.count + var exception: String? + IPURBridge.performWithoutQuiescence(application) { + exception = IPURBridge.catchException { application.typeText(text) } + } + if let failure = exception ?? (recordedIssues.count > issuesBefore ? recordedIssues.last : nil) { + throw RunnerError.failed("type failed (synthesis: \(error); typeText: \(failure))") + } + return .value(NSNull(), headers: ["X-IPU-Gesture": "xcui-typetext"]) + } + return .value(NSNull(), headers: ["X-IPU-Gesture": "synthesized"]) + } + + func home() throws -> HTTPResponse { + if let exception = IPURBridge.catchException({ XCUIDevice.shared.press(.home) }) { + throw RunnerError.failed("home failed: \(exception)") + } + return .value(NSNull()) + } + + func launch(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let bundle = try requiredString(body, "bundle", "bundleId") + let application = XCUIApplication(bundleIdentifier: bundle) + let issuesBefore = recordedIssues.count + var exception: String? + IPURBridge.performWithoutQuiescence(application) { + exception = IPURBridge.catchException { application.activate() } + } + if let failure = exception ?? (recordedIssues.count > issuesBefore ? recordedIssues.last : nil) { + throw RunnerError.failed("launch \(bundle) failed: \(failure)") + } + return .value(["bundle": bundle, "pid": Int(IPURBridge.pid(for: application))]) + } + + func activeApp() throws -> HTTPResponse { + let target = foreground() + guard target.pid > 0 else { + throw RunnerError.notFound("no active application", code: "unknown error") + } + return .value([ + "bundleId": target.bundleID.map { $0 as Any } ?? NSNull(), + "pid": Int(target.pid), + "activePids": IPURBridge.activeApplicationPIDs(), + ]) + } + + // MARK: - Screen + + func screenshot() throws -> HTTPResponse { + var png = Data() + if let exception = IPURBridge.catchException({ png = XCUIScreen.main.screenshot().pngRepresentation }) { + throw RunnerError.failed("screenshot failed: \(exception)") + } + guard !png.isEmpty else { throw RunnerError.failed("screenshot returned no data") } + return .value(png.base64EncodedString()) + } + + func windowSizePoints() -> CGSize { + var size = UIScreen.main.bounds.size + let landscape = XCUIDevice.shared.orientation.isLandscape + if landscape != (size.width > size.height) { + size = CGSize(width: size.height, height: size.width) + } + return size + } + + func windowSize() -> HTTPResponse { + let size = windowSizePoints() + return .value(["width": size.width, "height": size.height]) + } + + // MARK: - Alerts + + struct FoundAlert { + let node: [String: Any] + let pid: Int32 + let text: String + let buttons: [(label: String, rect: CGRect)] + } + + /// Looks for an XCUIElementTypeAlert in SpringBoard first (system prompts) and then in the + /// foreground app, using the private AX snapshot. Falls back to XCUI queries when the private + /// client is unavailable. + func findAlert() -> FoundAlert? { + var candidates: [(AnyObject, Int32)] = [] + if let springBoard = IPURBridge.systemApplicationElement() { + candidates.append((springBoard as AnyObject, IPURBridge.pid(forAXElement: springBoard))) + } + let target = foreground() + if let element = target.element, !candidates.contains(where: { $0.1 == target.pid }) { + candidates.append((element, target.pid)) + } + var privateWorked = false + for (element, pid) in candidates { + let tree = IPURBridge.wdaTree( + forAXElement: element, maxDepth: Self.defaultMaxDepth, maxNodes: Self.defaultMaxNodes, + extensionCallLimit: 0, rememberKey: pid > 0 ? String(pid) : nil) + guard (tree[IPURTreeOkKey] as? Bool) == true, let root = tree[IPURTreeRootKey] as? [String: Any] else { + continue + } + privateWorked = true + if let alert = firstNode(in: root, type: "XCUIElementTypeAlert") { + return describeAlert(alert, pid: pid) + } + } + if privateWorked { return nil } + return findAlertWithQueries() + } + + func firstNode(in node: [String: Any], type: String) -> [String: Any]? { + if node["type"] as? String == type { return node } + for child in node["children"] as? [[String: Any]] ?? [] { + if let found = firstNode(in: child, type: type) { return found } + } + return nil + } + + func describeAlert(_ alert: [String: Any], pid: Int32) -> FoundAlert { + var texts: [String] = [] + var buttons: [(String, CGRect)] = [] + func label(_ node: [String: Any]) -> String? { + for key in ["label", "name", "value"] { + if let value = node[key] as? String, !value.isEmpty { return value } + } + return nil + } + func rect(_ node: [String: Any]) -> CGRect { + let r = node["rect"] as? [String: Any] ?? [:] + func v(_ key: String) -> CGFloat { CGFloat((r[key] as? NSNumber)?.doubleValue ?? 0) } + return CGRect(x: v("x"), y: v("y"), width: v("width"), height: v("height")) + } + func walk(_ node: [String: Any]) { + let type = node["type"] as? String + if type == "XCUIElementTypeButton" { + if let text = label(node) { buttons.append((text, rect(node))) } + return + } + if type == "XCUIElementTypeStaticText" || type == "XCUIElementTypeTextView", + let text = label(node), !texts.contains(text) { + texts.append(text) + } + for child in node["children"] as? [[String: Any]] ?? [] { walk(child) } + } + for child in alert["children"] as? [[String: Any]] ?? [] { walk(child) } + if texts.isEmpty, let title = label(alert) { texts.append(title) } + return FoundAlert(node: alert, pid: pid, text: texts.joined(separator: "\n"), buttons: buttons) + } + + func findAlertWithQueries() -> FoundAlert? { + var found: FoundAlert? + _ = IPURBridge.catchException { + for application in [XCUIApplication(bundleIdentifier: Self.springBoardBundleID), foreground().application] { + let alert = application.alerts.firstMatch + guard alert.exists else { continue } + let texts = alert.staticTexts.allElementsBoundByIndex.map(\.label).filter { !$0.isEmpty } + let buttons = alert.buttons.allElementsBoundByIndex.map { ($0.label, $0.frame) } + found = FoundAlert( + node: [:], pid: IPURBridge.pid(for: application), text: texts.joined(separator: "\n"), + buttons: buttons.map { (label: $0.0, rect: $0.1) }) + return + } + } + return found + } + + func alert() throws -> HTTPResponse { + guard let alert = findAlert() else { + throw RunnerError.notFound("no alert is open", code: "no such alert") + } + return .value([ + "text": alert.text, + "buttons": alert.buttons.map(\.label), + "pid": Int(alert.pid), + ]) + } + + func alertTap(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let wanted = try requiredString(body, "button", "name") + guard let alert = findAlert() else { + throw RunnerError.notFound("no alert is open", code: "no such alert") + } + let match = alert.buttons.first { $0.label == wanted } + ?? alert.buttons.first { $0.label.localizedCaseInsensitiveCompare(wanted) == .orderedSame } + ?? alert.buttons.first { $0.label.localizedCaseInsensitiveContains(wanted) } + guard let button = match else { + throw RunnerError.notFound( + "alert has no button '\(wanted)' (buttons: \(alert.buttons.map(\.label).joined(separator: ", ")))") + } + let point = CGPoint(x: button.rect.midX, y: button.rect.midY) + _ = try gesture("alert tap", synthesized: { IPURBridge.synthesizeTap(at: point, pid: 0) }) { app in + coordinate(app, point).tap() + } + return .value(["tapped": button.label]) + } +} diff --git a/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerWDA.swift b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerWDA.swift new file mode 100644 index 0000000..bca92c6 --- /dev/null +++ b/runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerWDA.swift @@ -0,0 +1,891 @@ +// WebDriverAgent-compatible routes, so the iphone-use daemon's WdaClient (crates/server/src/wda.rs) +// can point PHONE_REMOTE_WDA_URL at this runner unchanged. Request and response shapes follow +// what WdaClient sends and parses; element finds run over the private AX tree (RunnerElements.swift). + +import Foundation +import UIKit +import XCTest + +extension RunnerTests { + /// POSTs that can change what is on screen (everything except session setup, settings and finds). + static func mayChangeScreen(_ path: String) -> Bool { + !(path == "/session" || path.hasSuffix("/appium/settings") || path.hasSuffix("/elements") + || path.hasSuffix("/element") || path == "/shutdown") + } + + /// Matches `parts` against a pattern such as "element/:id/attribute/:name"; returns the + /// `:captures` in order. + private func match(_ parts: [String], _ pattern: String) -> [String]? { + let patternParts = pattern.split(separator: "/").map(String.init) + guard patternParts.count == parts.count else { return nil } + var captures: [String] = [] + for (expected, actual) in zip(patternParts, parts) { + if expected.hasPrefix(":") { + captures.append(actual) + } else if expected != actual { + return nil + } + } + return captures + } + + /// Answers WDA routes, with or without a `/session/:sid` prefix (any sid is accepted). + /// Returns nil for paths that are not WDA routes, so the native routes get them. + func routeWDA(_ request: HTTPRequest) throws -> HTTPResponse? { + var parts = request.path.split(separator: "/").map(String.init) + let method = request.method + + if parts == ["session"] { + guard method == "POST" else { return nil } + return try createSession(request) + } + if parts.first == "session", parts.count >= 2 { + parts.removeFirst(2) + if parts.isEmpty { + switch method { + case "DELETE": return .value(NSNull(), sessionId: Self.sessionID) + case "GET": return .value(["sessionId": Self.sessionID, "capabilities": capabilities()], sessionId: Self.sessionID) + default: return nil + } + } + } + + switch method { + case "GET": + if match(parts, "status") != nil { return .value(statusValue(), sessionId: Self.sessionID) } + if match(parts, "source") != nil { return try source(request) } + if match(parts, "screenshot") != nil { return try screenshot() } + if match(parts, "window/size") != nil { return windowSize() } + if match(parts, "window/rect") != nil { + let size = windowSizePoints() + return .value(["x": 0, "y": 0, "width": size.width, "height": size.height]) + } + if match(parts, "appium/settings") != nil { return .value(wdaSettings) } + if match(parts, "wda/locked") != nil { return .value(lockedOnMain()) } + if match(parts, "wda/apps/list") != nil { return appsList() } + if match(parts, "wda/activeAppInfo") != nil { return activeAppInfo() } + if match(parts, "element/active") != nil { return try activeElement() } + if match(parts, "alert/text") != nil { return try alertText() } + if match(parts, "wda/alert/buttons") != nil { return try alertButtons() } + if let id = match(parts, "element/:id/rect")?.first { return try elementRect(id) } + if let captures = match(parts, "element/:id/attribute/:name") { + return try elementAttribute(captures[0], captures[1]) + } + if let id = match(parts, "element/:id/text")?.first { + let node = try freshNode(id) + return .value(node.label ?? node.value ?? "") + } + if let id = match(parts, "element/:id/displayed")?.first { + return .value(try freshNode(id).isVisible(screen: windowSizePoints())) + } + if let id = match(parts, "element/:id/enabled")?.first { return .value(try freshNode(id).isEnabled) } + if let id = match(parts, "element/:id/selected")?.first { + _ = try freshNode(id) + return .value(false) + } + if let id = match(parts, "element/:id/name")?.first { return .value(try freshNode(id).type) } + return nil + + case "POST": + if match(parts, "appium/settings") != nil { return try updateSettings(request) } + if match(parts, "actions") != nil { return try performActions(request) } + if match(parts, "elements") != nil { return try findElements(request, from: nil) } + if match(parts, "element") != nil { return try findElement(request, from: nil) } + if let id = match(parts, "element/:id/elements")?.first { return try findElements(request, from: id) } + if let id = match(parts, "element/:id/element")?.first { return try findElement(request, from: id) } + if let id = match(parts, "element/:id/click")?.first { return try clickElement(id) } + if let id = match(parts, "element/:id/value")?.first { return try setElementValue(id, request) } + if let id = match(parts, "element/:id/clear")?.first { return try clearElement(id) } + if let captures = match(parts, "wda/element/:id/:action") { + return try elementGesture(captures[0], captures[1], request) + } + if let id = match(parts, "wda/pickerwheel/:id/select")?.first { return try pickerWheelSelect(id, request) } + if match(parts, "wda/pressButton") != nil { return try pressButton(request) } + if match(parts, "wda/homescreen") != nil { return try home() } + if match(parts, "wda/apps/launch") != nil || match(parts, "wda/apps/activate") != nil { + return try launchApp(request) + } + if match(parts, "wda/keys") != nil { return try wdaKeys(request) } + if match(parts, "wda/keyboard/dismiss") != nil { return try dismissKeyboard(request) } + if match(parts, "url") != nil { return try openURL(request) } + if match(parts, "wda/lock") != nil { return try lockScreen() } + if match(parts, "wda/unlock") != nil { return try unlockScreen() } + if match(parts, "alert/accept") != nil { return try alertPress(request, accept: true) } + if match(parts, "alert/dismiss") != nil { return try alertPress(request, accept: false) } + return nil + + case "DELETE": + return nil + + default: + return nil + } + } + + // MARK: - Session + + private func capabilities() -> [String: Any] { + [ + "device": UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone", + "sdkVersion": Self.systemVersion, + "browserName": NSNull(), + "CFBundleIdentifier": NSNull(), + ] + } + + private func createSession(_ request: HTTPRequest) throws -> HTTPResponse { + let body = (try? request.jsonObject()) ?? [:] + let capabilities = body["capabilities"] as? [String: Any] + let always = capabilities?["alwaysMatch"] as? [String: Any] ?? [:] + let first = (capabilities?["firstMatch"] as? [[String: Any]])?.first ?? [:] + let desired = body["desiredCapabilities"] as? [String: Any] ?? [:] + let bundle = (always["bundleId"] ?? first["bundleId"] ?? desired["bundleId"]) as? String + if let bundle, !bundle.isEmpty { + _ = try activate(bundle) + } + let value: [String: Any] = ["sessionId": Self.sessionID, "capabilities": self.capabilities()] + return .value(value, sessionId: Self.sessionID) + } + + private func updateSettings(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let settings = body["settings"] as? [String: Any] ?? [:] + for (key, value) in settings { + wdaSettings[key] = value + } + mjpeg?.apply(settings) + return .value(wdaSettings) + } + + // MARK: - Trees + + /// The foreground app's tree with live AX elements on every node. + func foregroundTree(_ target: Foreground? = nil) throws -> UINode { + let target = target ?? foreground() + if let element = target.element { + let tree = IPURBridge.wdaTree( + forAXElement: element, maxDepth: Self.defaultMaxDepth, maxNodes: Self.defaultMaxNodes, + extensionCallLimit: Self.defaultExtensionCalls, + rememberKey: target.pid > 0 ? String(target.pid) : nil, includeElements: true) + if (tree[IPURTreeOkKey] as? Bool) == true, let root = tree[IPURTreeRootKey] as? [String: Any] { + return UINode(raw: root, parent: nil, pid: target.pid) + } + NSLog("ipu-runner: element tree via private AX failed: %@", tree[IPURTreeErrorKey] as? String ?? "?") + } + let application = target.application + var snapshot: XCUIElementSnapshot? + var failure: Error? + IPURBridge.performWithoutQuiescence(application) { + do { snapshot = try application.snapshot() } catch { failure = error } + } + guard let snapshot else { + throw RunnerError.failed("cannot read the foreground tree: \(failure.map { String(describing: $0) } ?? "no snapshot")") + } + let tree = IPURBridge.wdaTree(forSnapshot: snapshot as AnyObject, maxNodes: Self.defaultMaxNodes, includeElements: true) + guard let root = tree[IPURTreeRootKey] as? [String: Any] else { + throw RunnerError.failed(tree[IPURTreeErrorKey] as? String ?? "snapshot serialization failed") + } + return UINode(raw: root, parent: nil, pid: target.pid) + } + + /// SpringBoard's tree when SpringBoard is active but not the chosen foreground app (a banner, + /// a system sheet over an app), else nil. + private func springBoardTreeIfRelevant(foregroundPID: Int32) -> UINode? { + guard let springBoard = IPURBridge.systemApplicationElement() else { return nil } + let pid = IPURBridge.pid(forAXElement: springBoard) + guard pid > 0, pid != foregroundPID, + IPURBridge.activeApplicationPIDs().contains(NSNumber(value: pid)) + else { return nil } + let tree = IPURBridge.wdaTree( + forAXElement: springBoard, maxDepth: Self.defaultMaxDepth, maxNodes: Self.defaultMaxNodes, + extensionCallLimit: 0, rememberKey: String(pid), includeElements: true) + guard let root = tree[IPURTreeRootKey] as? [String: Any] else { return nil } + return UINode(raw: root, parent: nil, pid: pid) + } + + /// Re-snapshots a registered element. `depth` 1 reads the element alone (fresh rect/value); + /// larger depths read its subtree. Elements without a live AX element answer from the stored + /// node. A live element that cannot be read any more is stale (404, as WDA). + func freshNode(_ id: String, depth: Int = 1) throws -> UINode { + guard let stored = elements.node(id) else { throw RunnerError.staleElement(id) } + guard let axElement = stored.axElement else { return stored } + let tree = IPURBridge.wdaTree( + forAXElement: axElement, maxDepth: depth, maxNodes: Self.defaultMaxNodes, + extensionCallLimit: depth > 1 ? Self.defaultExtensionCalls : 0, rememberKey: nil, + includeElements: true) + guard (tree[IPURTreeOkKey] as? Bool) == true, let root = tree[IPURTreeRootKey] as? [String: Any] else { + NSLog("ipu-runner: element %@ re-read failed: %@", id, tree[IPURTreeErrorKey] as? String ?? "?") + throw RunnerError.staleElement(id) + } + let node = UINode(raw: root, parent: nil, pid: stored.pid) + // A zero frame on a re-read means the element left the hierarchy. + if node.rect == .zero, stored.rect != .zero { throw RunnerError.staleElement(id) } + return node + } + + // MARK: - Finding elements + + private func locator(_ request: HTTPRequest) throws -> (using: String, value: String) { + let body = try request.jsonObject() + guard let using = body["using"] as? String, let value = body["value"] as? String else { + throw RunnerError.invalidArgument("'using' and 'value' must be strings") + } + return (using, value) + } + + private func findNodes(_ request: HTTPRequest, from id: String?) throws -> [UINode] { + let (using, value) = try locator(request) + let screen = windowSizePoints() + if let id { + let scope = try freshNode(id, depth: Self.defaultMaxDepth) + return try Locator.find(using: using, value: value, root: scope, screen: screen) + } + let target = foreground() + let root = try foregroundTree(target) + let found = try Locator.find(using: using, value: value, root: root, screen: screen) + if !found.isEmpty { return found } + if let springBoard = springBoardTreeIfRelevant(foregroundPID: target.pid) { + return try Locator.find(using: using, value: value, root: springBoard, screen: screen) + } + return [] + } + + private func findElements(_ request: HTTPRequest, from id: String?) throws -> HTTPResponse { + let nodes = try findNodes(request, from: id) + return .value(nodes.map { ElementReference.make(elements.register($0)) }) + } + + private func findElement(_ request: HTTPRequest, from id: String?) throws -> HTTPResponse { + guard let node = try findNodes(request, from: id).first else { + let (using, value) = (try? locator(request)) ?? ("?", "?") + throw RunnerError.notFound("unable to find an element using '\(using)', value '\(value)'") + } + return .value(ElementReference.make(elements.register(node))) + } + + private func activeElement() throws -> HTTPResponse { + let root = try foregroundTree() + let focused = root.descendants().filter(\.isFocused) + let inputs: Set = [ + "XCUIElementTypeTextField", "XCUIElementTypeSecureTextField", "XCUIElementTypeTextView", + "XCUIElementTypeSearchField", + ] + guard let node = focused.first(where: { inputs.contains($0.type) }) ?? focused.last else { + throw RunnerError.notFound("there is no focused element") + } + return .value(ElementReference.make(elements.register(node))) + } + + // MARK: - Element reads + + private func rectValue(_ rect: CGRect) -> [String: Any] { + ["x": rect.origin.x, "y": rect.origin.y, "width": rect.width, "height": rect.height] + } + + private func elementRect(_ id: String) throws -> HTTPResponse { + .value(rectValue(try freshNode(id).rect)) + } + + private func elementAttribute(_ id: String, _ name: String) throws -> HTTPResponse { + let node = try freshNode(id) + let screen = windowSizePoints() + func orNull(_ value: Any?) -> Any { value ?? NSNull() } + switch name { + case "value", "wdValue": return .value(orNull(node.value)) + case "label", "wdLabel": return .value(orNull(node.label)) + case "name", "wdName": return .value(orNull(node.name)) + case "type", "wdType": return .value(node.type) + case "rawIdentifier", "identifier": return .value(orNull(node.identifier)) + case "placeholderValue", "wdPlaceholderValue": return .value(orNull(node.placeholder)) + case "enabled", "wdEnabled", "isEnabled": return .value(node.isEnabled) + case "visible", "wdVisible", "isVisible", "displayed", "hittable", "isHittable": + return .value(node.isVisible(screen: screen)) + case "accessible", "wdAccessible", "isAccessible": return .value(true) + case "focused", "hasFocus", "wdFocused", "isFocused": return .value(node.isFocused) + case "selected", "wdSelected", "isSelected": return .value(false) + case "rect", "wdRect", "frame": return .value(rectValue(node.rect)) + default: return .value(orNull(node.predicateObject(screen: screen)[name])) + } + } + + // MARK: - Element actions + + /// Taps a point through synthesis, falling back to XCUICoordinate. + func tapPoint(_ point: CGPoint) throws { + _ = try gesture("tap", synthesized: { IPURBridge.synthesizeTap(at: point, pid: 0) }) { app in + coordinate(app, point).tap() + } + } + + private func clickElement(_ id: String) throws -> HTTPResponse { + let node = try freshNode(id) + guard node.rect.width > 0, node.rect.height > 0 else { + throw RunnerError.failed("element \(id) has an empty frame and cannot be clicked") + } + try tapPoint(node.center) + return .value(NSNull()) + } + + /// Makes `id` the keyboard target: taps it unless it already has focus, then waits briefly for + /// focus to land. + private func focus(_ id: String) throws -> UINode { + var node = try freshNode(id) + if node.isFocused { return node } + try tapPoint(node.center) + let deadline = Date().addingTimeInterval(1.0) + while Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + if let fresh = try? freshNode(id) { + node = fresh + if fresh.isFocused { break } + } + } + return node + } + + /// Types text (with WebDriver private-use key codes mapped) into whatever has focus. + func typeIntoFocus(_ text: String, frequency: UInt = 60) throws -> String { + let mapped = Self.mapWebDriverKeys(text) + if mapped.isEmpty { return "none" } + if let error = IPURBridge.synthesizeText(mapped, charactersPerSecond: frequency, pid: 0) { + NSLog("ipu-runner: text synthesis failed, using XCUIApplication.typeText: %@", error) + let application = foreground().application + let issuesBefore = recordedIssues.count + var exception: String? + IPURBridge.performWithoutQuiescence(application) { + exception = IPURBridge.catchException { application.typeText(mapped) } + } + if let failure = exception ?? (recordedIssues.count > issuesBefore ? recordedIssues.last : nil) { + throw RunnerError.failed("typing failed (synthesis: \(error); typeText: \(failure))") + } + return "xcui-typetext" + } + return "synthesized" + } + + /// WebDriver encodes special keys as private-use code points (U+E000…); XCTest typing wants + /// XCUIKeyboardKey strings. Unknown private-use keys are dropped rather than typed literally. + static func mapWebDriverKeys(_ text: String) -> String { + var out = "" + for scalar in text.unicodeScalars { + switch scalar.value { + case 0xE003: out += XCUIKeyboardKey.delete.rawValue + case 0xE004: out += XCUIKeyboardKey.tab.rawValue + case 0xE006, 0xE007: out += XCUIKeyboardKey.return.rawValue + case 0xE00C: out += XCUIKeyboardKey.escape.rawValue + case 0xE00D: out += " " + case 0xE012: out += XCUIKeyboardKey.leftArrow.rawValue + case 0xE013: out += XCUIKeyboardKey.upArrow.rawValue + case 0xE014: out += XCUIKeyboardKey.rightArrow.rawValue + case 0xE015: out += XCUIKeyboardKey.downArrow.rawValue + case 0xE017: out += XCUIKeyboardKey.forwardDelete.rawValue + case 0xE000...0xF8FF: continue + default: out.unicodeScalars.append(scalar) + } + } + return out + } + + /// Joins WDA's `value` (array of strings/characters, or a string) and `text` body forms. + private func textArgument(_ body: [String: Any], preferText: Bool) -> String? { + if preferText, let text = body["text"] as? String { return text } + if let array = body["value"] as? [Any] { return array.map { "\($0)" }.joined() } + if let string = body["value"] as? String { return string } + return body["text"] as? String + } + + private func setElementValue(_ id: String, _ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let node = try freshNode(id) + // Like WDA: a bare `value` on a picker wheel or slider adjusts it; `text` always types. + if body["text"] == nil, let value = textArgument(body, preferText: false) { + if node.type == "XCUIElementTypePickerWheel" { + try adjust(node, id) { $0.adjust(toPickerWheelValue: value) } + return .value(NSNull()) + } + if node.type == "XCUIElementTypeSlider" { + guard let position = Double(value), (0...1).contains(position) else { + throw RunnerError.invalidArgument("slider value must be a number in 0...1, got '\(value)'") + } + try adjust(node, id) { $0.adjust(toNormalizedSliderPosition: CGFloat(position)) } + return .value(NSNull()) + } + } + guard let text = textArgument(body, preferText: true) else { + throw RunnerError.invalidArgument("'value' or 'text' is required") + } + _ = try focus(id) + let path = try typeIntoFocus(text, frequency: UInt((body["frequency"] as? NSNumber)?.intValue ?? 60)) + return .value(NSNull(), headers: ["X-IPU-Gesture": path]) + } + + private func clearElement(_ id: String) throws -> HTTPResponse { + var node = try focus(id) + for _ in 0..<3 { + let current = node.value ?? "" + let count = (current == node.placeholder) ? 0 : current.count + if count == 0 { break } + _ = try typeIntoFocus(String(repeating: XCUIKeyboardKey.delete.rawValue, count: min(count, 500)), frequency: 120) + guard let fresh = try? freshNode(id) else { break } + node = fresh + } + return .value(NSNull()) + } + + /// Resolves a registered node to an XCUIElement (for the few XCUI-only APIs: picker/slider + /// adjustment, force press). Matches by type, identity and frame; XCUI queries are slow, so this + /// is used only where synthesis cannot stand in. + func xcuiElement(for node: UINode) throws -> XCUIElement { + let application = IPURBridge.application(forPID: node.pid) + ?? XCUIApplication(bundleIdentifier: IPURBridge.bundleID(forPID: node.pid) ?? Self.springBoardBundleID) + let type = XCUIElement.ElementType(rawValue: UInt(RunnerElementTypes.rawValue(of: node.type))) ?? .any + var query = application.descendants(matching: type) + if let identifier = node.identifier { + query = query.matching(identifier: identifier) + } else if let label = node.label { + query = query.matching(NSPredicate(format: "label == %@", label)) + } + var found: XCUIElement? + let target = node.rect + let exception = IPURBridge.catchException { + let candidates = query.allElementsBoundByIndex + if candidates.count == 1 { + found = candidates[0] + return + } + found = candidates.min { a, b in + Self.frameDistance(a.frame, target) < Self.frameDistance(b.frame, target) + } + } + if let exception { throw RunnerError.failed("XCUI element lookup failed: \(exception)") } + guard let found else { throw RunnerError.notFound("no XCUI element matches \(node.type) at \(target)") } + return found + } + + private static func frameDistance(_ a: CGRect, _ b: CGRect) -> CGFloat { + abs(a.minX - b.minX) + abs(a.minY - b.minY) + abs(a.width - b.width) + abs(a.height - b.height) + } + + private func adjust(_ node: UINode, _ id: String, _ body: (XCUIElement) -> Void) throws { + let element = try xcuiElement(for: node) + let application = IPURBridge.application(forPID: node.pid) + let issuesBefore = recordedIssues.count + var exception: String? + IPURBridge.performWithoutQuiescence(application) { + exception = IPURBridge.catchException { body(element) } + } + if let failure = exception ?? (recordedIssues.count > issuesBefore ? recordedIssues.last : nil) { + throw RunnerError.failed("adjusting element \(id) failed: \(failure)") + } + } + + // MARK: - Element gestures (/wda/element/:id/:action) + + private func touch(_ steps: [(String, CGPoint, Double)]) -> [[String: Any]] { + steps.map { ["type": $0.0, "x": $0.1.x, "y": $0.1.y, "t": $0.2] } + } + + private func synthesizePaths(_ paths: [[[String: Any]]], name: String) throws { + if let error = IPURBridge.synthesizeTouchPaths(paths, name: name) { + throw RunnerError.failed("\(name) failed: \(error)") + } + } + + private func elementGesture(_ id: String, _ action: String, _ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let node = try freshNode(id) + let frame = node.rect + let center = node.center + switch action { + case "touchAndHold": + let duration = max(0.05, number(body, "duration") ?? 1.0) + if let error = IPURBridge.synthesizeLongPress(at: center, duration: duration, pid: 0) { + throw RunnerError.failed("touchAndHold failed: \(error)") + } + case "tap": + try tapPoint(center) + case "doubleTap": + try synthesizePaths([ + touch([("down", center, 0), ("up", center, 0.05)]), + touch([("down", center, 0.15), ("up", center, 0.2)]), + ], name: "ipu-double-tap") + case "twoFingerTap": + let offset = min(20, max(4, frame.width / 6)) + let left = CGPoint(x: center.x - offset, y: center.y) + let right = CGPoint(x: center.x + offset, y: center.y) + try synthesizePaths([ + touch([("down", left, 0), ("up", left, 0.1)]), + touch([("down", right, 0), ("up", right, 0.1)]), + ], name: "ipu-two-finger-tap") + case "pinch": + let scale = number(body, "scale") ?? 1 + let velocity = abs(number(body, "velocity") ?? 1) + guard scale > 0 else { throw RunnerError.invalidArgument("'scale' must be positive") } + let half = max(10, min(frame.width, frame.height) / 2 * 0.9) + let start = scale >= 1 ? half / max(scale, 1) * 0.5 : half + let end = scale >= 1 ? min(half, start * scale) : max(5, half * scale) + let duration = min(3, max(0.25, abs(scale - 1) / max(0.1, velocity))) + try synthesizePaths(twoFingerPaths(center: center, duration: duration) { t in + let radius = start + (end - start) * t + return (CGPoint(x: center.x - radius, y: center.y), CGPoint(x: center.x + radius, y: center.y)) + }, name: "ipu-pinch") + case "rotate": + let rotation = number(body, "rotation") ?? 0 + let velocity = abs(number(body, "velocity") ?? 1) + let radius = max(10, min(frame.width, frame.height) / 4) + let duration = min(3, max(0.25, abs(rotation) / max(0.1, velocity))) + try synthesizePaths(twoFingerPaths(center: center, duration: duration) { t in + let angle = rotation * t + let dx = radius * cos(angle), dy = radius * sin(angle) + return (CGPoint(x: center.x - dx, y: center.y - dy), CGPoint(x: center.x + dx, y: center.y + dy)) + }, name: "ipu-rotate") + case "forceTouch": + guard XCUIDevice.shared.responds(to: NSSelectorFromString("supportsPressureInteraction")), + (XCUIDevice.shared.value(forKey: "supportsPressureInteraction") as? Bool) == true + else { + // WDA's exact refusal, which WdaClient maps to ForcePressUnsupported. + throw RunnerError(status: 400, code: "invalid argument", message: "Force press is not supported on this device") + } + let element = try xcuiElement(for: node) + let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + if let exception = IPURBridge.catchException({ _ = coordinate.perform(NSSelectorFromString("forcePress")) }) { + throw RunnerError.failed("forceTouch failed: \(exception)") + } + case "scrollTo": + try scrollIntoView(id) + case "swipe", "scroll": + guard let direction = (body["direction"] as? String)?.lowercased() else { + throw RunnerError.invalidArgument("'direction' is required for \(action)") + } + let reach = action == "swipe" ? 0.8 : min(1, max(0.1, number(body, "distance") ?? 0.5)) + let dx = frame.width / 2 * reach, dy = frame.height / 2 * reach + // swipe "up" moves the finger up; scroll "up" reveals content above (finger moves down). + let sign: CGFloat = action == "swipe" ? 1 : -1 + var end = center + switch direction { + case "up": end.y -= dy * sign + case "down": end.y += dy * sign + case "left": end.x -= dx * sign + case "right": end.x += dx * sign + default: throw RunnerError.invalidArgument("unknown direction '\(direction)'") + } + let duration = action == "swipe" ? 0.15 : 0.5 + if let error = IPURBridge.synthesizeDrag(from: center, to: end, duration: duration, pid: 0) { + throw RunnerError.failed("\(action) failed: \(error)") + } + default: + throw RunnerError(status: 404, code: "unknown command", message: "wda/element/:id/\(action) is not supported by the native runner") + } + return .value(NSNull()) + } + + private func twoFingerPaths( + center: CGPoint, duration: Double, at position: (CGFloat) -> (CGPoint, CGPoint) + ) -> [[[String: Any]]] { + let steps = max(4, min(60, Int(duration / 0.016))) + var first: [(String, CGPoint, Double)] = [] + var second: [(String, CGPoint, Double)] = [] + for step in 0...steps { + let t = CGFloat(step) / CGFloat(steps) + let (a, b) = position(t) + let kind = step == 0 ? "down" : "move" + first.append((kind, a, duration * Double(t))) + second.append((kind, b, duration * Double(t))) + } + let (a, b) = position(1) + first.append(("up", a, duration)) + second.append(("up", b, duration)) + return [touch(first), touch(second)] + } + + /// Approximates WDA's scrollTo: drags the screen toward the element until its frame sits inside + /// the visible band, re-reading the element after each drag. + private func scrollIntoView(_ id: String) throws { + let screen = windowSizePoints() + let band = CGRect(x: 0, y: screen.height * 0.12, width: screen.width, height: screen.height * 0.73) + for _ in 0..<12 { + let node = try freshNode(id) + let frame = node.rect + if frame.width > 0, frame.height > 0, band.contains(CGPoint(x: frame.midX, y: frame.midY)) { return } + let x = screen.width / 2 + var start = CGPoint(x: x, y: screen.height * 0.65) + var end = CGPoint(x: x, y: screen.height * 0.35) + if frame.midY < band.minY && frame.height > 0 { + swap(&start, &end) + } else if frame.midX > screen.width { + start = CGPoint(x: screen.width * 0.8, y: screen.height / 2) + end = CGPoint(x: screen.width * 0.2, y: screen.height / 2) + } else if frame.maxX < 0 { + start = CGPoint(x: screen.width * 0.2, y: screen.height / 2) + end = CGPoint(x: screen.width * 0.8, y: screen.height / 2) + } + if let error = IPURBridge.synthesizeDrag(from: start, to: end, duration: 0.4, pid: 0) { + throw RunnerError.failed("scrollTo failed: \(error)") + } + RunLoop.current.run(until: Date().addingTimeInterval(0.35)) + } + throw RunnerError.failed("could not scroll element \(id) into view") + } + + private func pickerWheelSelect(_ id: String, _ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let order = (body["order"] as? String ?? "next").lowercased() + let offset = number(body, "offset") ?? 0.2 + guard order == "next" || order == "previous" else { + throw RunnerError.invalidArgument("'order' must be 'next' or 'previous'") + } + let node = try freshNode(id) + guard node.type == "XCUIElementTypePickerWheel" else { + throw RunnerError.invalidArgument("element \(id) is \(node.type), not a picker wheel") + } + let before = node.value + let frame = node.rect + let dy = frame.height * CGFloat(offset) * (order == "next" ? 1 : -1) + try tapPoint(CGPoint(x: frame.midX, y: frame.midY + dy)) + // WDA reports an error when the wheel did not move; poll the value briefly. + let deadline = Date().addingTimeInterval(1.5) + while Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + if let fresh = try? freshNode(id), fresh.value != before { return .value(NSNull()) } + } + throw RunnerError.failed("picker wheel value did not change from '\(before ?? "")' after selecting \(order)") + } + + // MARK: - W3C actions + + private func performActions(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + guard let sources = body["actions"] as? [[String: Any]] else { + throw RunnerError.invalidArgument("'actions' must be an array of input sources") + } + var paths: [[[String: Any]]] = [] + var keys = "" + for source in sources { + let actions = source["actions"] as? [[String: Any]] ?? [] + switch source["type"] as? String { + case "pointer": + paths += try W3CActions.pointerPaths(actions) { try self.freshNode($0).center } + case "key": + for action in actions where action["type"] as? String == "keyDown" { + keys += action["value"] as? String ?? "" + } + case "none", nil: + continue + case let other?: + throw RunnerError.invalidArgument("unsupported input source type '\(other)'") + } + } + var used: [String] = [] + if !paths.isEmpty { + if let error = IPURBridge.synthesizeTouchPaths(paths, name: "ipu-w3c-actions") { + // A single tap can still go through the public coordinate API. + if paths.count == 1, let down = paths[0].first, paths[0].count == 2, + let x = down["x"] as? CGFloat, let y = down["y"] as? CGFloat { + try tapPoint(CGPoint(x: x, y: y)) + used.append("xcui-coordinate") + } else { + throw RunnerError.failed("actions failed: \(error)") + } + } else { + used.append("synthesized") + } + } + if !keys.isEmpty { + used.append(try typeIntoFocus(keys)) + } + return .value(NSNull(), headers: ["X-IPU-Gesture": used.joined(separator: ",")]) + } + + // MARK: - Device and apps + + private func pressButton(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let name = (body["name"] as? String ?? "").lowercased() + let button: XCUIDevice.Button + switch name { + case "home": button = .home + case "volumeup": button = .volumeUp + case "volumedown": button = .volumeDown + default: throw RunnerError.invalidArgument("unsupported button '\(name)'; use home, volumeUp or volumeDown") + } + if let exception = IPURBridge.catchException({ XCUIDevice.shared.press(button) }) { + throw RunnerError.failed("pressButton \(name) failed: \(exception)") + } + return .value(NSNull()) + } + + private func activate(_ bundle: String) throws -> Int32 { + let application = XCUIApplication(bundleIdentifier: bundle) + let issuesBefore = recordedIssues.count + var exception: String? + IPURBridge.performWithoutQuiescence(application) { + exception = IPURBridge.catchException { application.activate() } + } + if let failure = exception ?? (recordedIssues.count > issuesBefore ? recordedIssues.last : nil) { + throw RunnerError.failed("launching \(bundle) failed: \(failure)") + } + return IPURBridge.pid(for: application) + } + + private func launchApp(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + let bundle = try requiredString(body, "bundleId", "bundle") + _ = try activate(bundle) + return .value(NSNull()) + } + + /// Active apps as WDA lists them, the foreground app first (WdaClient reads the first entry as + /// the frontmost bundle, and "only SpringBoard" as the Home Screen). + private func appsList() -> HTTPResponse { + let target = foreground() + var pids = IPURBridge.activeApplicationPIDs().map(\.int32Value) + if target.pid > 0 { + pids.removeAll { $0 == target.pid } + pids.insert(target.pid, at: 0) + } + let apps: [[String: Any]] = pids.map { pid in + ["pid": Int(pid), "bundleId": IPURBridge.bundleID(forPID: pid).map { $0 as Any } ?? NSNull()] + } + return .value(apps) + } + + private func activeAppInfo() -> HTTPResponse { + let target = foreground() + return .value([ + "pid": Int(target.pid), + "bundleId": target.bundleID.map { $0 as Any } ?? NSNull(), + "name": "", + "processArguments": ["args": [], "env": [:]], + ]) + } + + private func wdaKeys(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + guard let text = textArgument(body, preferText: false) else { + throw RunnerError.invalidArgument("'value' must be an array of strings") + } + let frequency = UInt((body["frequency"] as? NSNumber)?.intValue ?? 60) + let path = try typeIntoFocus(text, frequency: max(1, frequency)) + return .value(NSNull(), headers: ["X-IPU-Gesture": path]) + } + + private func dismissKeyboard(_ request: HTTPRequest) throws -> HTTPResponse { + let body = (try? request.jsonObject()) ?? [:] + let names = (body["keyNames"] as? [String]) ?? ["Done", "Return", "return", "Hide keyboard"] + let root = try foregroundTree() + var keyboard = root.descendants().first { $0.type == "XCUIElementTypeKeyboard" } + if keyboard == nil, let springBoard = springBoardTreeIfRelevant(foregroundPID: root.pid) { + keyboard = springBoard.descendants().first { $0.type == "XCUIElementTypeKeyboard" } + } + guard let keyboard else { return .value(NSNull()) } // no keyboard: already dismissed + let keys = keyboard.descendants().filter { + ($0.type == "XCUIElementTypeButton" || $0.type == "XCUIElementTypeKey") + && (names.contains($0.label ?? "\u{0}") || names.contains($0.name ?? "\u{0}")) + } + guard let key = keys.first(where: { $0.isVisible(screen: windowSizePoints()) }) ?? keys.first else { + throw RunnerError.failed("Did not know how to dismiss the keyboard. Try to dismiss it in the way supported by your application under test.") + } + try tapPoint(key.center) + return .value(NSNull()) + } + + private func openURL(_ request: HTTPRequest) throws -> HTTPResponse { + let body = try request.jsonObject() + guard let string = body["url"] as? String, let url = URL(string: string) else { + throw RunnerError.invalidArgument("'url' must be a valid URL string") + } + if #available(iOS 16.4, *) { + if let exception = IPURBridge.catchException({ XCUIDevice.shared.system.open(url) }) { + throw RunnerError.failed("opening \(string) failed: \(exception)") + } + return .value(NSNull()) + } + // Before iOS 16.4: type the URL into Safari's address field. + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + let issuesBefore = recordedIssues.count + var exception: String? + IPURBridge.performWithoutQuiescence(safari) { + exception = IPURBridge.catchException { + safari.activate() + let field = safari.textFields.firstMatch + field.tap() + safari.typeText(string + "\n") + } + } + if let failure = exception ?? (recordedIssues.count > issuesBefore ? recordedIssues.last : nil) { + throw RunnerError.failed("opening \(string) through Safari failed: \(failure)") + } + return .value(NSNull()) + } + + /// Lock state when SpringBoardServices is unavailable to the inline path: the cover sheet + /// (lock screen) window present in SpringBoard's tree. + private func lockedOnMain() -> Bool { + var known = ObjCBool(false) + let locked = IPURBridge.isScreenLocked(&known) + if known.boolValue { return locked } + guard let springBoard = IPURBridge.systemApplicationElement() else { return false } + let tree = IPURBridge.wdaTree( + forAXElement: springBoard, maxDepth: 6, maxNodes: 500, extensionCallLimit: 0, rememberKey: nil) + guard let root = tree[IPURTreeRootKey] as? [String: Any] else { return false } + let node = UINode(raw: root, parent: nil, pid: 0) + return node.descendants().contains { $0.identifier == "SBCoverSheetWindow" && $0.rect.height > 0 } + } + + private func lockScreen() throws -> HTTPResponse { + if lockedOnMain() { return .value(NSNull()) } + if let error = IPURBridge.pressLockButton() { throw RunnerError.failed(error) } + return .value(NSNull()) + } + + private func unlockScreen() throws -> HTTPResponse { + // Without a passcode a Home press wakes and opens the phone; with one it only shows the + // passcode pad (as with WDA, which cannot type the passcode either). + guard lockedOnMain() else { return .value(NSNull()) } + if let exception = IPURBridge.catchException({ XCUIDevice.shared.press(.home) }) { + throw RunnerError.failed("unlock failed: \(exception)") + } + return .value(NSNull()) + } + + // MARK: - Alerts + + func cachedAlert() -> FoundAlert? { + if let cache = alertCache, Date().timeIntervalSince(cache.at) < 1.0 { return cache.alert } + let found = findAlert() + alertCache = (Date(), found) + return found + } + + private func alertText() throws -> HTTPResponse { + guard let alert = cachedAlert() else { throw RunnerError.noSuchAlert() } + return .value(alert.text) + } + + private func alertButtons() throws -> HTTPResponse { + guard let alert = cachedAlert() else { throw RunnerError.noSuchAlert() } + return .value(alert.buttons.map(\.label)) + } + + /// WDA picks the last button to accept and the first to dismiss when no name is given. + private func alertPress(_ request: HTTPRequest, accept: Bool) throws -> HTTPResponse { + let body = (try? request.jsonObject()) ?? [:] + alertCache = nil + guard let alert = findAlert() else { throw RunnerError.noSuchAlert() } + let button: (label: String, rect: CGRect)? + if let name = body["name"] as? String, !name.isEmpty { + button = alert.buttons.first { $0.label == name } + ?? alert.buttons.first { $0.label.localizedCaseInsensitiveCompare(name) == .orderedSame } + guard button != nil else { + throw RunnerError.notFound( + "alert has no button '\(name)' (buttons: \(alert.buttons.map(\.label).joined(separator: ", ")))") + } + } else { + button = accept ? alert.buttons.last : alert.buttons.first + } + guard let button else { throw RunnerError.failed("the alert has no buttons") } + try tapPoint(CGPoint(x: button.rect.midX, y: button.rect.midY)) + alertCache = nil + return .value(NSNull()) + } +} diff --git a/runner/IPhoneUseRunner/project.yml b/runner/IPhoneUseRunner/project.yml new file mode 100644 index 0000000..ec25b84 --- /dev/null +++ b/runner/IPhoneUseRunner/project.yml @@ -0,0 +1,51 @@ +# XcodeGen spec for the iphone-use device runner. +# The generated IPhoneUseRunner.xcodeproj is checked in, so plain `xcodebuild` works without +# XcodeGen. Regenerate after editing this file: (cd runner/IPhoneUseRunner && xcodegen generate) +# +# One UI-test bundle and no host app, like WebDriverAgent: the phone gets exactly one app, +# "iPhoneUse-Runner" (bundle id .xctrunner). setup-wda.sh passes +# DEVELOPMENT_TEAM and a team-specific PRODUCT_BUNDLE_IDENTIFIER on the xcodebuild command line. +name: IPhoneUseRunner +options: + deploymentTarget: + iOS: "15.0" + createIntermediateGroups: true + developmentLanguage: en + # objectVersion 56: opens in Xcode 14 and later (CI images and older installs). + projectFormat: xcode14_0 +settings: + base: + DEVELOPMENT_TEAM: 6ZPXG4KVVS + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "5.0" + MARKETING_VERSION: "0.2.0" + CURRENT_PROJECT_VERSION: "1" + IPHONEOS_DEPLOYMENT_TARGET: "15.0" + TARGETED_DEVICE_FAMILY: "1,2" + ENABLE_USER_SCRIPT_SANDBOXING: YES + CLANG_ENABLE_OBJC_ARC: YES +targets: + IPhoneUseRunnerUITests: + type: bundle.ui-testing + platform: iOS + sources: + - IPhoneUseRunnerUITests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.leeguoo.iphone-use.runner + # The installed runner app is "-Runner"; the target name (used by + # -only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe) stays the same. + PRODUCT_NAME: iPhoneUse + GENERATE_INFOPLIST_FILE: YES + SWIFT_OBJC_BRIDGING_HEADER: IPhoneUseRunnerUITests/IPhoneUseRunnerUITests-Bridging-Header.h + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF: NO +schemes: + IPhoneUseRunner: + build: + targets: + IPhoneUseRunnerUITests: [test] + test: + config: Debug + targets: + - name: IPhoneUseRunnerUITests + parallelizable: false diff --git a/runner/README.md b/runner/README.md new file mode 100644 index 0000000..5b9f882 --- /dev/null +++ b/runner/README.md @@ -0,0 +1,423 @@ +# iphone-use device runner + +The runner is the iphone-use device service. It replaces WebDriverAgent: `scripts/setup-wda.sh` +builds it, installs it on the phone, keeps it running under the KeepAlive supervisor and points the +daemon at it (see [Managed by setup-wda.sh](#managed-by-setup-wdash)). It is a lean XCTest +runner that runs as one long-lived XCTest method (`RunnerTests.testServe`) and serves two things +on the phone: + +- **port 8100**: an HTTP/1.1 API. It answers every WebDriverAgent route the daemon's `WdaClient` + (`crates/server/src/wda.rs`) uses, with the same shapes, so `PHONE_REMOTE_WDA_URL` can point at + it unchanged. It also has a few native routes. +- **port 9100**: an MJPEG screen stream in WDA's wire format, read by the daemon's `MjpegSplitter`. + +Both ports are WDA's, so existing relays (`iproxy`, socat) and daemon config work as they are. + +What makes it faster than WDA: + +- **Tree reads ask for less.** `/source` and every element find call the private `XCAXClient_iOS + requestSnapshotForElement:attributes:parameters:error:` with only nine attributes: ElementType, + Identifier, Label, Value, PlaceholderValue, Frame, Enabled, Selected and Focus. It never computes + `isVisible`, `isAccessible` or `isHittable`, which cost extra AX round trips per node. + - When the AX server rejects a deep request, it retries down a depth ladder (64, 56, 40, 24, 12) + and remembers the depth that worked for each pid. + - Nodes cut off by the depth cap are fetched again from their own root, up to 8 extra requests. +- **Element lookups need no XCUI queries.** Finds evaluate WDA's locator strategies against that + same tree. Element reads re-snapshot just the one element, through the live accessibility + element kept for it. +- **No quiescence.** Every XCTest idle wait is turned into a no-op when the runner starts: + `XCUIApplicationProcess waitForQuiescence…`, + `XCAXClient_iOS waitForQuiescenceOnAllForegroundApplicationsAsPreEvent:` and + `XCUIApplication _waitForQuiescence…`. Public-API fallbacks also run inside + `_performWithInteractionOptions:` with the pre-event and post-event skip bits set. +- **Direct event synthesis.** Taps, W3C actions, element gestures and typing are built as + `XCSynthesizedEventRecord` + `XCPointerEventPath`. No element query or snapshot happens first. + +## Layout + +``` +runner/ + build.sh build-for-testing (generic device, or --device ), prints the .xctestrun + ci-check.sh CI gate: unit check, unsigned build, names setup/uninstall rely on + unit-check.sh Mac-side unit check of the device-independent logic (no device) + unit-check/ its test driver and IPURBridge stub + IPhoneUseRunner/project.yml XcodeGen spec (the generated .xcodeproj is checked in) + IPhoneUseRunner/IPhoneUseRunner.xcodeproj + IPhoneUseRunner/IPhoneUseRunnerUITests/ the UI-test bundle (no host app), product name iPhoneUse + RunnerTests.swift testServe, dispatch, native routes + RunnerWDA.swift WDA-compatible routes + RunnerElements.swift element tree, locators (predicate / class chain / …), id registry + RunnerActions.swift W3C pointer actions → touch paths + RunnerMJPEG.swift MJPEG server + RunnerHTTP.swift HTTP/1.1 server (NWListener), envelopes, errors + IPURBridge.h/.m private XCTest API (AX snapshot, event synthesis, screenshots, lock state) +scripts/runner-compat.py host-side WDA compatibility check (every WdaClient route + MJPEG) +scripts/runner-smoke.py host-side smoke test / benchmark of the native routes +``` + +The phone gets exactly one app, **iPhoneUse-Runner** (bundle id `.xctrunner`; +`com.leeguoo.iphone-use.runner.xctrunner` for `build.sh`, the team-derived `WDA_BUNDLE_ID` for setup). +It is the process that serves both ports. When the ready listener comes up it logs +`ServerURLHere->http://:8100<-ServerURLHere`, the marker setup waits for. + +## Protocol + +- HTTP/1.1, one request per connection (`Connection: close`). Request bodies are JSON objects. +- Commands are handled one at a time on the main thread. A serial command queue hands each one to + main with `main.sync`, so no second command can start while one runs. +- `GET /status` and `GET /wda/locked` (with or without a session) are answered on the transport + queue instead. The daemon's health probes therefore never wait behind a slow command. `/status` + reports `busy` / `busyMs` while a command runs. +- Success: `200 {"value": }`, WDA's envelope. `/status` and `POST /session` also carry a + top-level `sessionId`. +- Error: `4xx/5xx {"value": {"error": "", "message": ""}}`, using WDA/W3C codes: + - `invalid argument`, `invalid selector` (400) + - `no such element`, `stale element reference`, `no such alert`, `unknown command` (404) + - `unknown error` (500) + + WdaClient detects missing alerts and elements by the `404 Not Found` status. +- Every response carries `Server-Timing: runner;dur=` (time spent in the runner). +- Coordinates are screen points, the same space as WDA `rect` values. + +### WDA-compatible routes + +Every route works with or without a `/session/:sid` prefix, and any `:sid` is accepted. The runner +has one fixed session id per launch. + +| Method | Path | Notes | +|---|---|---| +| GET | `/status` | `{ready, sessionId, message, state, os, build, …, mjpeg: {achievedFps, …}}`, answered off-main | +| POST | `/session` | `{value: {sessionId, capabilities}, sessionId}`. Capabilities are ignored, except `bundleId`, which activates that app. | +| GET, DELETE | `/session/:sid` | session info / no-op | +| GET, POST | `/session/:sid/appium/settings` | Accepted and echoed. `mjpegServerFramerate`, `mjpegScalingFactor` and `mjpegServerScreenshotQuality` drive the MJPEG stream. | +| GET | `/source?format=json[&excluded_attributes=…]` | WDA node shape; `excluded_attributes` is ignored, since the expensive attributes are never computed | +| GET | `/screenshot` | base64 PNG | +| GET | `/session/:sid/window/size`, `/window/rect` | points | +| POST | `/session/:sid/actions` | W3C actions, details below | +| POST | `/session/:sid/elements`, `/element` | `using`: `accessibility id`, `id`, `name`, `class name`, `predicate string`, `class chain`, `link text`, `partial link text` | +| POST | `/session/:sid/element/:id/elements`, `/element/:id/element` | searches the element's freshly re-read subtree | +| POST | `/session/:sid/element/:id/click` | synthesized tap at the centre of the freshly read frame | +| GET | `/session/:sid/element/:id/rect` | fresh frame | +| GET | `/session/:sid/element/:id/attribute/:name` | `value`, `label`, `name`, `type`, `rawIdentifier`, `placeholderValue`, `enabled`, `visible`, `focused`, `accessible`, `selected`, `rect`, plus the `wd*` aliases | +| GET | `/session/:sid/element/:id/{text,displayed,enabled,selected,name}` | | +| POST | `/session/:sid/element/:id/value` | Body is `{text}` or `{value: […]}`. Bare `value` on a PickerWheel or Slider adjusts it, as WDA does; otherwise the runner focuses the element and types. | +| POST | `/session/:sid/element/:id/clear` | focuses the element, then deletes its value's length in characters | +| GET | `/session/:sid/element/active` | focused element, or 404 `no such element` | +| POST | `/session/:sid/wda/element/:id/{touchAndHold,doubleTap,twoFingerTap,tap,pinch,rotate,scrollTo,forceTouch,swipe,scroll}` | synthesized; see the notes below | +| POST | `/session/:sid/wda/pickerwheel/:id/select` | `{order: next\|previous, offset}`; taps above or below the centre, then waits for the value to change | +| POST | `/session/:sid/wda/pressButton` | `{name: home\|volumeUp\|volumeDown}` | +| POST | `/session/:sid/wda/homescreen` | Home | +| POST | `/session/:sid/wda/apps/launch`, `/wda/apps/activate` | `{bundleId}` → `XCUIApplication.activate()` | +| GET | `/session/:sid/wda/apps/list` | `[{bundleId, pid}]`, foreground app first; only SpringBoard on the Home Screen | +| GET | `/session/:sid/wda/activeAppInfo` | `{pid, bundleId, …}` | +| POST | `/session/:sid/wda/keys` | `{value: [text]}` into the focused element | +| POST | `/session/:sid/wda/keyboard/dismiss` | `{keyNames}`: taps the first matching key on the keyboard; no keyboard is a no-op | +| POST | `/session/:sid/url` | `{url}`. iOS 16.4+ uses `XCUIDevice.system.open`; older versions type it into Safari. | +| GET | `/wda/locked`, `/session/:sid/wda/locked` | `SBGetScreenLockStatus` (as WDA), answered off-main | +| POST | `/session/:sid/wda/lock`, `/wda/unlock` | lock button / Home press | +| GET | `/session/:sid/alert/text`, `/wda/alert/buttons` | 404 `no such alert` when no alert is open | +| POST | `/session/:sid/alert/accept`, `/alert/dismiss` | `{name?}`. Without a name, accept presses the last button and dismiss the first. | + +#### W3C actions + +- **Pointer sources** (touch, mouse and pen are all treated as touch): `pointerMove` (with origin + `viewport`, `pointer` or an element reference, which is relative to the element's centre), + `pointerDown`, `pause`, `pointerUp` and `pointerCancel`. +- **Down-to-up paths.** Each `pointerDown…pointerUp` becomes one touch path. Moves while the + pointer is down are sampled every ~16 ms over their duration. A down/up with no time between them + is held for 50 ms, so iOS sees a tap. +- **Multiple sources** become concurrent fingers in one event record. +- **Key sources:** the `keyDown` values are typed. WebDriver private-use keys map to + `XCUIKeyboardKey`: + - `` → delete + - `` / `` → return + - `` → escape + - `` → space + - `` → tab + - arrows → arrow keys + - `` → forward delete + +#### Locators + +- **Searched tree.** Every locator runs over the foreground app's tree. If nothing matches and + SpringBoard is also active (a banner, a system sheet), SpringBoard's tree is searched too. +- **`accessibility id` / `id` / `name`** match the identifier or the label, like XCUI identifier + matching. +- **`predicate string`** is an `NSPredicate` evaluated per node. It accepts WDA's attribute names: + - `type`, `name`, `label`, `value`, `identifier`/`rawIdentifier`, `placeholderValue` + - `enabled`, `visible`, `accessible`, `focused`, `selected`, `hittable` + - `rect`/`frame`/`wdRect` + - the `wd*` and `is*` aliases + + `visible` and `hittable` are geometric: a non-empty frame that intersects the screen. +- **`class chain`** supports: + - `/`-separated steps + - `**`, a type or `*` + - `[n]` and `[-n]` + - `` [`predicate`] `` and `[$descendant predicate$]` + + An index applies to the whole step's match list, as in the XCUI query WDA builds. +- `xpath` is not supported and answers 400 `invalid selector`. The daemon never sends it. + +#### Element ids + +- **Format.** Element ids are UUIDs, returned as `{"ELEMENT": id, "element-6066-…": id}`. +- **Registry.** The runner remembers the last 4000 ids. +- **Fresh reads.** Reading or acting on an id re-snapshots that element. An element that is gone + answers 404 `stale element reference`. + +### Native routes + +| Method | Path | Body / query | `value` | +|---|---|---|---| +| GET | `/source` | `?max_depth=N` `?max_nodes=N` (default 5000) `?extension_calls=N` `?backend=xcui` | foreground tree, WDA node shape | +| POST | `/tap` | `{x, y}` | `null` | +| POST | `/swipe` | `{x1, y1, x2, y2, duration_ms?}` (default 300) | `null` | +| POST | `/longpress` | `{x, y, duration_ms?}` (default 1000) | `null` | +| POST | `/type` | `{text, frequency?}` | `null` | +| POST | `/home` | | `null` | +| POST | `/launch` | `{bundle}` | `{bundle, pid}` | +| GET | `/apps/active` | | `{bundleId, pid, activePids}` | +| GET | `/alert` | | `{text, buttons, pid}` or 404 | +| POST | `/alert` | `{button}` | `{tapped}` | +| GET | `/window/size` | | `{width, height}` | +| POST | `/shutdown` | | ends `testServe` | + +### `/source` node shape + +```json +{"type": "XCUIElementTypeButton", "label": "OK", "name": "ok-button", "value": null, + "rawIdentifier": "ok-button", "placeholderValue": null, + "rect": {"x": 20, "y": 100, "width": 80, "height": 44}, + "isEnabled": "1", "isFocused": "0", "children": [ ... ]} +``` + +- `name` is the identifier when there is one, otherwise the label. +- Fields with no value are JSON `null`. +- `children` is left out when a node has none. +- `isVisible`, `isAccessible` and `isHittable` are never emitted. The daemon treats a missing + `isVisible` as unknown. + +These response headers describe the read: + +- `X-IPU-Backend`: `private-ax` or `xcui-snapshot` +- `X-IPU-Node-Count` +- `X-IPU-Depth` +- `X-IPU-Truncated` +- `X-IPU-Extension-Calls` +- `X-IPU-Pid` + +The foreground app is picked from the AX client's `activeApplications`: + +1. If exactly one non-SpringBoard app is active, that app is used. +2. If several are active, the runner hit-tests the screen centre. +3. Otherwise SpringBoard is used. + +## MJPEG stream (port 9100) + +The format is WDA's: + +- The server answers any request with `HTTP/1.0 200 OK` and `Content-Type: + multipart/x-mixed-replace; boundary=--BoundaryString`. +- Each frame follows as `--BoundaryString\r\nContent-type: image/jpg\r\nContent-Length: + N\r\n\r\n\r\n`. + +How it captures: + +- **Settings.** The defaults are WDA's: 10 fps, 100 % scale, quality 25. + `POST /appium/settings` changes `mjpegServerFramerate` (1–60), `mjpegScalingFactor` (% of native + pixels) and `mjpegServerScreenshotQuality` (%). The daemon sets 30 / 50 / 60. +- **Only while watched.** Capture runs only while at least one client is connected. The capture + thread exits when the last client leaves, so an unwatched stream costs nothing. +- **Off the main thread.** Frames are captured on a dedicated thread and never block command + handling. A client still sending the previous frame skips the current one, so a slow viewer gets + fewer frames instead of growing latency. +- **Capture path, fastest first:** + 1. `XCUIDevice.screenDataSource requestScreenshotWithRequest:withReply:` with an + `XCTScreenshotRequest` that asks testmanagerd for a JPEG at the requested quality. This is the + path WDA's `FBScreenshot` uses. The device has no scaled-capture API, so scaling happens next. + 2. `XCUIScreen screenshotWithEncoding:options:` with the same JPEG encoding. + 3. The public `XCUIScreen.main.screenshot()` PNG, re-encoded. + + The runner scales with an ImageIO thumbnail (`kCGImageSourceThumbnailMaxPixelSize`) and + re-encodes at the same quality. At 100 % scale the device JPEG passes through untouched. + A path that fails outright is not retried. A 2 s timeout only skips to the next path for that + frame. +- **Measured fps.** `/status` reports `mjpeg.achievedFps`, `capturePath`, `lastCaptureMs` and + `lastFrameBytes`. `scripts/runner-compat.py` also measures fps from the client side. + + **Achieved fps:** 27–28 fps on an iPhone 13 over USB with the daemon's 30 / 50 / 60 settings + (`runner-compat.py`, first hardware run). + +## Build + +```bash +runner/build.sh # signed when ASC credentials exist, else unsigned +runner/build.sh --unsigned # compile check only (CODE_SIGNING_ALLOWED=NO) +runner/build.sh --device 00008110-0002346211A0401E # signed, and registers that device in the team +runner/unit-check.sh # Mac-side unit check, no device needed +``` + +`build.sh` runs `xcodebuild build-for-testing` into `runner/build/DerivedData`. The destination is +`generic/platform=iOS`, or `id=` with `--device`. Its last stdout line is the produced +`.xctestrun`, for example +`runner/build/DerivedData/Build/Products/IPhoneUseRunner_iphoneos27.0-arm64.xctestrun`. + +Signing: + +- The build signs with team `6ZPXG4KVVS`, automatic signing, through the App Store Connect API key. +- The key comes from `WDA_ASC_KEY_PATH`, `WDA_ASC_KEY_ID` and `WDA_ASC_ISSUER_ID`. If those are + unset, the script reads them from `~/Library/LaunchAgents/com.leeguoo.iphone-use.wda.plist`. +- Without them it falls back to an unsigned compile check. +- `--device` adds `-allowProvisioningDeviceRegistration`, so the provisioning profile includes + that phone. It builds only; nothing is installed or launched. +- If the profile was regenerated, a stale profile path in DerivedData can fail the next build. + Delete `runner/build/DerivedData` and build again. +- The key values are never printed. Build logs go to `runner/build/build-{signed,unsigned}.log`. + +After editing `project.yml`, regenerate the project with +`(cd runner/IPhoneUseRunner && xcodegen generate)` and commit the result. + +## Managed by setup-wda.sh + +This is how every install runs the runner; the manual steps below are for development. + +- **Sources.** `install.sh` lays `runner/` down at `~/.iphone-use/runner` (from a local checkout, + or the release asset `iphone-use-runner.tar.gz`, checked against its `.sha256`). A repo + checkout's `scripts/setup-wda.sh` builds its own `runner/`; `IPU_RUNNER_SRC` overrides both. + Sources must be owned by the user and not writable by others. +- **Build.** `xcodebuild -project …/IPhoneUseRunner.xcodeproj -scheme IPhoneUseRunner + -destination platform=iOS,id= -derivedDataPath /runner-build + DEVELOPMENT_TEAM= PRODUCT_BUNDLE_IDENTIFIER= build-for-testing`, signed + through the Xcode account or the `WDA_ASC_*` API key, exactly as WebDriverAgent was. Each + instance has its own `runner-build/`. +- **Launch.** `xcodebuild -destination platform=iOS,id= test-without-building -xctestrun + /runner-build/Build/Products/IPhoneUseRunner_iphoneos-arm64.xctestrun + -only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe`, from the state directory. That + argv is the runner's process identity for stop/pause/status and uninstall. +- **Reuse.** The verified product is recorded in `/wda-runner-product.json`, keyed on the + source hash, signing identity, device and Xcode/SDK; a matching reconnect skips the build. + `WDA_RUNNER_REBUILD=1` forces one. +- **Unchanged.** Relays (`iproxy` 8100/9100), the KeepAlive supervisor and its label, lock wait and + backoff, trust/DDI/automation blockers, the status file the daemon reads, and every `WDA_*` + variable keep their names and behaviour. + +## Run on a device + +```bash +XCTESTRUN="$(runner/build.sh --device | tail -1)" +xcodebuild test-without-building -xctestrun "$XCTESTRUN" -destination "id=" \ + -only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe +iproxy 8100 8100 & iproxy 9100 9100 & # host → device; or the existing WDA relays +python3 scripts/runner-compat.py # every WdaClient route + MJPEG; read-only by default +python3 scripts/runner-smoke.py --runs 5 # timing of the native routes +``` + +- **Pointing the daemon at it.** Set `PHONE_REMOTE_WDA_URL=http://127.0.0.1:8100`. The MJPEG URL + stays `:9100`. +- **Other ports.** Set `TEST_RUNNER_IPU_RUNNER_PORT=…` and `TEST_RUNNER_IPU_RUNNER_MJPEG_PORT=…` on + the `xcodebuild` command. xcodebuild strips the `TEST_RUNNER_` prefix. MJPEG port `0` turns the + stream off. +- **Screen-changing checks are opt-in.** `runner-compat.py` takes `--mutate` (Home, Settings, + swipes, a cell click), `--tap X Y`, `--keys TEXT`, `--url URL` and `--lock`. +- **Unlock first.** The phone must be unlocked when the runner starts, as with WDA. +- **No test timeouts.** Do not pass `-test-timeouts-enabled YES`. +- **One automation session.** iOS gives one XCTest automation session at a time, so a managed + runner (or WebDriverAgent) and a manual one cannot both run on one phone; pause the managed + one first (`~/.iphone-use/setup-wda.sh pause`). Both also want ports 8100 and 9100. +- **Stopping.** `POST /shutdown` ends `testServe` cleanly. +- **Logs.** Device log lines are prefixed `ipu-runner:`. + +## Approximations (not exact WDA behaviour) + +- **`scrollTo`** drags the screen toward the element's frame, up to 12 drags, until its centre is + inside the middle band of the screen. WDA scrolls the element's scroll-view ancestor. +- **`pinch` and `rotate`** are two synthesized fingers. Duration is derived from `velocity`: + |scale−1|/velocity, and rotation/velocity, clamped to 0.25–3 s. +- **`doubleTap`** is two 50 ms taps 150 ms apart. **`twoFingerTap`** is two fingers ±(width/6) at + the centre. +- **`forceTouch`** answers WDA's 400 "Force press is not supported on this device" when + `XCUIDevice.supportsPressureInteraction` is false, which is every current iPhone. Otherwise it + calls `forcePress` on an XCUI coordinate. +- **`visible` / `displayed` / `hittable`** are geometric (frame intersects the screen), not WDA's + occlusion check. +- **`selected`** is always false. The attribute is fetched but not yet serialized. +- **Picker wheel and slider adjustment** are the only operations that resolve an `XCUIElement`. + The runner matches by type plus identifier or label, then picks the closest frame. They use + XCUI's `adjust(toPickerWheelValue:)` and `adjust(toNormalizedSliderPosition:)`. +- **`/wda/unlock`** presses Home. It cannot enter a passcode, and neither can WDA. +- **`/wda/locked` fallback.** It uses SpringBoardServices `SBGetScreenLockStatus`. If that is + unavailable, the runner looks for SpringBoard's cover-sheet window instead. +- **`/url` before iOS 16.4** taps Safari's first text field and types the URL. +- **`appium/settings`** values other than the MJPEG ones are stored and echoed but change nothing. + The runner never waits for idle anyway. + +## Private-API caveats + +All private classes, selectors and functions are looked up at runtime and checked before use. When +one is missing, the runner uses the public fallback or returns an error; it never crashes. The +lookups were checked against Xcode 27 (`XCUIAutomation.framework`). These parts still need +hardware verification: + +- **Bundle ids.** The bundle id for a pid comes from + `XCUIDevice.applicationMonitor monitoredApplicationWithProcessIdentifier:`, then + `applicationProcessWithPID:`. SpringBoard is recognised by `XCAXClient_iOS systemApplication`'s + pid. +- **Event records** use `initWithName:displayID:interfaceOrientation:` with + `XCUIScreen.mainScreen.displayID` and the device orientation. No target pid is set, so events go + to whatever is on screen, as with WDA. +- **Screen capture.** The `XCTScreenshotRequest` is built with `CGRectNull` as the full-screen rect. + If testmanagerd rejects that, the next capture path takes over. +- **Lock state.** `SBGetScreenLockStatus` / `SBSSpringBoardServerPort` are loaded with + `dlopen`/`dlsym` from SpringBoardServices, as WDA does. +- **Quiescence.** The bypass applies to the whole process. `activate()` and `press(.home)` return + without waiting for the app to settle, so callers poll. +- **Deployment target.** Xcode 27's XCTest is built for iOS 17. The iOS 15.0 deployment target only + sets the minimum for the host app. +- **Benchmark fairness.** The generated `.xctestrun` includes Xcode's default + `DYLD_INSERT_LIBRARIES=/usr/lib/libRPAC.dylib`, as WDA's does. Remove it from both for a fair + benchmark, or from neither. + +## Attribution + +The WDA-compatible routes, envelopes and MJPEG framing reproduce +[appium/WebDriverAgent](https://github.com/appium/WebDriverAgent)'s wire behaviour, written fresh +against the daemon's client. The same goes for the lock-state call and the screenshot-request path. +No WebDriverAgent source is copied. + +Several parts are adapted from [callstack/agent-device](https://github.com/callstack/agent-device) +(`apple/runner/AgentDeviceRunner`): + +- the reduced-attribute private AX snapshot, depth ladder and frontier re-rooting + (`RunnerAXSnapshotBridge.m`) +- XCSynthesizedEventRecord / XCPointerEventPath gesture and text synthesis + (`RunnerSynthesizedGesture.m`, `RunnerSynthesizedTextEntry.m`, `RunnerXCTestEventBridge.m`) +- the quiescence-skipping interaction options (`RunnerTests+Lifecycle.swift`) +- the single long-running XCTest method hosting an NWListener (`RunnerTests.swift`, + `RunnerTests+Transport.swift`) + +The derived files carry an attribution header. agent-device's license: + +``` +MIT License + +Copyright (c) 2026 Callstack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/runner/build.sh b/runner/build.sh new file mode 100755 index 0000000..a11acd6 --- /dev/null +++ b/runner/build.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Build the iphone-use native runner for a generic iOS device (build-for-testing) and print the +# produced .xctestrun path on the last line of stdout. +# +# Signing: +# - WDA_ASC_KEY_PATH / WDA_ASC_KEY_ID / WDA_ASC_ISSUER_ID set (or readable from the WDA +# LaunchAgent plist) → automatic signing for team 6ZPXG4KVVS through the App Store Connect +# API key (-allowProvisioningUpdates -authenticationKey*). +# - otherwise, or with --unsigned / IPU_RUNNER_UNSIGNED=1 → CODE_SIGNING_ALLOWED=NO (compile check; +# the products cannot be installed). +# +# Options / environment: +# --unsigned force an unsigned compile-check build +# --device (signed builds) build for that connected device and register it +# in the team, so the provisioning profile includes it +# (-destination id= -allowProvisioningDeviceRegistration). +# Also IPU_RUNNER_DEVICE_ID=. +# IPU_RUNNER_DERIVED_DATA=dir derived data dir (default: runner/build/DerivedData) +# IPU_RUNNER_TEAM_ID=team signing team (default: 6ZPXG4KVVS) +# XCODEBUILD=path xcodebuild binary (default: xcodebuild) +# +# Never prints the ASC key values. Never installs or launches anything on a device. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT="$SCRIPT_DIR/IPhoneUseRunner/IPhoneUseRunner.xcodeproj" +SCHEME="IPhoneUseRunner" +DERIVED_DATA="${IPU_RUNNER_DERIVED_DATA:-$SCRIPT_DIR/build/DerivedData}" +TEAM_ID="${IPU_RUNNER_TEAM_ID:-6ZPXG4KVVS}" +XCODEBUILD_BIN="${XCODEBUILD:-xcodebuild}" +WDA_PLIST="$HOME/Library/LaunchAgents/com.leeguoo.iphone-use.wda.plist" + +unsigned="${IPU_RUNNER_UNSIGNED:-0}" +device="${IPU_RUNNER_DEVICE_ID:-}" +while [ "$#" -gt 0 ]; do + case "$1" in + --unsigned) unsigned=1 ;; + --device) + [ "$#" -ge 2 ] || { echo "--device needs a UDID" >&2; exit 2; } + device="$2" + shift + ;; + -h|--help) sed -n '2,24p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac + shift +done +if [ -n "$device" ] && ! printf '%s\n' "$device" | LC_ALL=C grep -Eq '^[A-Za-z0-9-]+$'; then + echo "invalid device UDID: $device" >&2 + exit 2 +fi +destination="generic/platform=iOS" +if [ -n "$device" ] && [ "$unsigned" != "1" ]; then + destination="id=$device" +fi + +_plist_env() { + [ -f "$WDA_PLIST" ] || return 0 + plutil -extract "EnvironmentVariables.$1" raw -o - "$WDA_PLIST" 2>/dev/null || true +} + +if [ "$unsigned" != "1" ]; then + : "${WDA_ASC_KEY_PATH:=$(_plist_env WDA_ASC_KEY_PATH)}" + : "${WDA_ASC_KEY_ID:=$(_plist_env WDA_ASC_KEY_ID)}" + : "${WDA_ASC_ISSUER_ID:=$(_plist_env WDA_ASC_ISSUER_ID)}" +fi + +args=( + -project "$PROJECT" + -scheme "$SCHEME" + -configuration Debug + -destination "$destination" + -derivedDataPath "$DERIVED_DATA" +) + +if [ "$unsigned" != "1" ] && [ -n "${WDA_ASC_KEY_PATH:-}" ] && [ -n "${WDA_ASC_KEY_ID:-}" ] \ + && [ -n "${WDA_ASC_ISSUER_ID:-}" ]; then + if [ ! -f "$WDA_ASC_KEY_PATH" ]; then + echo "ipu-runner build: WDA_ASC_KEY_PATH does not point at a file" >&2 + exit 1 + fi + echo "ipu-runner build: signed (team $TEAM_ID, App Store Connect API key)" >&2 + args+=( + -allowProvisioningUpdates + -authenticationKeyPath "$WDA_ASC_KEY_PATH" + -authenticationKeyID "$WDA_ASC_KEY_ID" + -authenticationKeyIssuerID "$WDA_ASC_ISSUER_ID" + DEVELOPMENT_TEAM="$TEAM_ID" + CODE_SIGN_STYLE=Automatic + ) + if [ -n "$device" ]; then + echo "ipu-runner build: registering device $device in the team's provisioning profile" >&2 + args+=(-allowProvisioningDeviceRegistration) + fi + mode=signed +else + if [ -n "$device" ]; then + echo "ipu-runner build: --device ignored, an unsigned build registers nothing" >&2 + args=("${args[@]/#id=$device/generic/platform=iOS}") + fi + echo "ipu-runner build: unsigned compile check (CODE_SIGNING_ALLOWED=NO)" >&2 + args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="") + mode=unsigned +fi + +log="$DERIVED_DATA/../build-$mode.log" +mkdir -p "$DERIVED_DATA" +started=$SECONDS +if ! "$XCODEBUILD_BIN" "${args[@]}" build-for-testing >"$log" 2>&1; then + grep -E "error:|BUILD FAILED|\*\* TEST BUILD FAILED" "$log" | head -40 >&2 || true + echo "ipu-runner build: FAILED ($mode) — full log: $log" >&2 + exit 1 +fi +echo "ipu-runner build: succeeded ($mode) in $((SECONDS - started))s — log: $log" >&2 + +xctestrun="$(ls -t "$DERIVED_DATA"/Build/Products/*.xctestrun 2>/dev/null | head -1 || true)" +if [ -z "$xctestrun" ]; then + echo "ipu-runner build: no .xctestrun produced under $DERIVED_DATA/Build/Products" >&2 + exit 1 +fi +echo "$xctestrun" diff --git a/runner/ci-check.sh b/runner/ci-check.sh new file mode 100755 index 0000000..ca8623d --- /dev/null +++ b/runner/ci-check.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# CI gate for the iphone-use device runner (release-binaries.yml, pr-checks.yml): +# 1. the device-independent logic passes (runner/unit-check.sh); +# 2. the UI-test bundle compiles for iOS, unsigned (CODE_SIGNING_ALLOWED=NO); +# 3. the names setup-wda.sh and uninstall.sh rely on match the project. +# Needs Xcode; never signs, installs or touches a device. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +fail() { printf 'runner ci-check: %s\n' "$*" >&2; exit 1; } + +xcodebuild -version | sed -n 1p + +# ── 3 first: cheap coherence checks ───────────────────────────────────────── +PROJECT=runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj +[ -f "$PROJECT/project.pbxproj" ] || fail "missing $PROJECT" +[ -f "$PROJECT/xcshareddata/xcschemes/IPhoneUseRunner.xcscheme" ] \ + || fail "the shared IPhoneUseRunner scheme setup-wda.sh builds is missing" +grep -q '^RUNNER_SCHEME="IPhoneUseRunner"$' scripts/setup-wda.sh \ + || fail "setup-wda.sh does not build the IPhoneUseRunner scheme" +grep -q '^RUNNER_TEST_ID="IPhoneUseRunnerUITests/RunnerTests/testServe"$' scripts/setup-wda.sh \ + || fail "setup-wda.sh does not launch IPhoneUseRunnerUITests/RunnerTests/testServe" +grep -q 'func testServe()' runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift \ + || fail "RunnerTests.testServe, the method setup-wda.sh launches, is missing" +grep -q '^RUNNER_APP_NAME="iPhoneUse-Runner.app"$' scripts/setup-wda.sh \ + || fail "setup-wda.sh expects a different runner app name" +grep -q 'PRODUCT_NAME: iPhoneUse$' runner/IPhoneUseRunner/project.yml \ + || fail "project.yml no longer names the runner product iPhoneUse" +grep -q 'static let defaultPort: UInt16 = 8100' runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift \ + && grep -q 'static let defaultMJPEGPort: UInt16 = 9100' runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift \ + || fail "the runner no longer listens on the device ports the relays forward (8100/9100)" +grep -q 'ServerURLHere->' runner/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerHTTP.swift \ + || fail "the runner no longer prints the ServerURLHere marker setup-wda.sh waits for" +grep -q 'IPhoneUseRunner_\[\^ /\]+\\\.xctestrun -only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe' uninstall.sh \ + || fail "uninstall.sh does not recognise the runner process setup-wda.sh starts" +echo "runner ci-check: names coherent across project, setup-wda.sh and uninstall.sh" + +# ── 1. device-independent logic ───────────────────────────────────────────── +bash runner/unit-check.sh + +# ── 2. unsigned iOS build ─────────────────────────────────────────────────── +DERIVED="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/iphone-use-runner-ci.$$" +trap 'rm -rf "$DERIVED"' EXIT +IPU_RUNNER_DERIVED_DATA="$DERIVED/DerivedData" bash runner/build.sh --unsigned \ + || { tail -50 "$DERIVED/build-unsigned.log" 2>/dev/null || true; fail "unsigned build failed"; } +[ -d "$DERIVED/DerivedData/Build/Products/Debug-iphoneos/iPhoneUse-Runner.app/PlugIns/iPhoneUse.xctest" ] \ + || fail "the build did not produce iPhoneUse-Runner.app with its test bundle" +ls "$DERIVED"/DerivedData/Build/Products/IPhoneUseRunner_iphoneos*.xctestrun >/dev/null 2>&1 \ + || fail "the build did not produce the IPhoneUseRunner .xctestrun setup-wda.sh launches" +echo "runner ci-check: OK" diff --git a/runner/unit-check.sh b/runner/unit-check.sh new file mode 100755 index 0000000..6282d5f --- /dev/null +++ b/runner/unit-check.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Mac-side unit check of the runner's device-independent Swift logic (HTTP parsing, WDA locator +# strategies, W3C pointer actions). Compiles those sources with a stub IPURBridge for macOS and +# runs them; needs no device and no signing. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOURCES="$SCRIPT_DIR/IPhoneUseRunner/IPhoneUseRunnerUITests" +OUT="${TMPDIR:-/tmp}/ipu-runner-unit-check.$$" +mkdir -p "$OUT" +trap 'rm -rf "$OUT"' EXIT + +clang -fobjc-arc -c "$SCRIPT_DIR/unit-check/IPURBridgeStub.m" -o "$OUT/stub.o" +swiftc -O -import-objc-header "$SCRIPT_DIR/unit-check/IPURBridgeStub.h" \ + "$SOURCES/RunnerHTTP.swift" "$SOURCES/RunnerActions.swift" "$SOURCES/RunnerElements.swift" \ + "$SCRIPT_DIR/unit-check/main.swift" "$OUT/stub.o" -o "$OUT/unit-check" +"$OUT/unit-check" diff --git a/runner/unit-check/IPURBridgeStub.h b/runner/unit-check/IPURBridgeStub.h new file mode 100644 index 0000000..6f69546 --- /dev/null +++ b/runner/unit-check/IPURBridgeStub.h @@ -0,0 +1,14 @@ +// Mac-side stand-in for the parts of IPURBridge the pure runner logic uses (element type names, +// Objective-C exception catching), so runner/unit-check.sh can test it without a device. +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSString *const IPURNodeAXElementKey; + +@interface IPURBridge : NSObject ++ (NSString *)elementTypeName:(NSInteger)elementType; ++ (nullable NSString *)catchException:(void (NS_NOESCAPE ^)(void))block; +@end + +NS_ASSUME_NONNULL_END diff --git a/runner/unit-check/IPURBridgeStub.m b/runner/unit-check/IPURBridgeStub.m new file mode 100644 index 0000000..1dd5122 --- /dev/null +++ b/runner/unit-check/IPURBridgeStub.m @@ -0,0 +1,44 @@ +#import "IPURBridgeStub.h" + +NSString *const IPURNodeAXElementKey = @"__axElement"; + +@implementation IPURBridge + ++ (NSString *)elementTypeName:(NSInteger)elementType +{ + // Same table as IPURBridge.m (XCUIElementType raw values 0…82). + static NSArray *names; + static dispatch_once_t once; + dispatch_once(&once, ^{ + names = @[ + @"Any", @"Other", @"Application", @"Group", @"Window", @"Sheet", @"Drawer", @"Alert", + @"Dialog", @"Button", @"RadioButton", @"RadioGroup", @"CheckBox", @"DisclosureTriangle", + @"PopUpButton", @"ComboBox", @"MenuButton", @"ToolbarButton", @"Popover", @"Keyboard", + @"Key", @"NavigationBar", @"TabBar", @"TabGroup", @"Toolbar", @"StatusBar", @"Table", + @"TableRow", @"TableColumn", @"Outline", @"OutlineRow", @"Browser", @"CollectionView", + @"Slider", @"PageIndicator", @"ProgressIndicator", @"ActivityIndicator", + @"SegmentedControl", @"Picker", @"PickerWheel", @"Switch", @"Toggle", @"Link", @"Image", + @"Icon", @"SearchField", @"ScrollView", @"ScrollBar", @"StaticText", @"TextField", + @"SecureTextField", @"DatePicker", @"TextView", @"Menu", @"MenuItem", @"MenuBar", + @"MenuBarItem", @"Map", @"WebView", @"IncrementArrow", @"DecrementArrow", @"Timeline", + @"RatingIndicator", @"ValueIndicator", @"SplitGroup", @"Splitter", @"RelevanceIndicator", + @"ColorWell", @"HelpTag", @"Matte", @"DockItem", @"Ruler", @"RulerMarker", @"Grid", + @"LevelIndicator", @"Cell", @"LayoutArea", @"LayoutItem", @"Handle", @"Stepper", @"Tab", + @"TouchBar", @"StatusItem", + ]; + }); + NSString *name = (elementType >= 0 && elementType < (NSInteger)names.count) ? names[elementType] : @"Other"; + return [@"XCUIElementType" stringByAppendingString:name]; +} + ++ (nullable NSString *)catchException:(void (NS_NOESCAPE ^)(void))block +{ + @try { + block(); + return nil; + } @catch (NSException *exception) { + return [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]; + } +} + +@end diff --git a/runner/unit-check/main.swift b/runner/unit-check/main.swift new file mode 100644 index 0000000..b5039f3 --- /dev/null +++ b/runner/unit-check/main.swift @@ -0,0 +1,203 @@ +// Mac-side unit check of the runner's device-independent logic: HTTP parsing and error envelopes, +// WDA locator strategies (accessibility id, class name, predicate string, class chain) and W3C +// pointer-action parsing. Run with runner/unit-check.sh. + +import CoreGraphics +import Foundation + +var failures = 0 +var checks = 0 + +func check(_ condition: @autoclosure () throws -> Bool, _ message: String, line: Int = #line) { + checks += 1 + do { + if try !condition() { + failures += 1 + print("FAIL (line \(line)): \(message)") + } + } catch { + failures += 1 + print("FAIL (line \(line)): \(message): threw \(error)") + } +} + +func expectThrows(_ code: String, _ message: String, line: Int = #line, _ body: () throws -> Void) { + checks += 1 + do { + try body() + failures += 1 + print("FAIL (line \(line)): \(message): did not throw") + } catch let error as RunnerError { + if error.code != code { + failures += 1 + print("FAIL (line \(line)): \(message): threw \(error.code), expected \(code)") + } + } catch { + failures += 1 + print("FAIL (line \(line)): \(message): threw \(error)") + } +} + +// MARK: HTTP + +do { + let raw = Data("GET /source?format=json&excluded_attributes=visible,accessible HTTP/1.1\r\nHost: x\r\n\r\n".utf8) + if case .complete(let request) = HTTPRequest.parse(raw) { + check(request.method == "GET" && request.path == "/source", "request line") + check(request.query["excluded_attributes"] == "visible,accessible", "query") + } else { + check(false, "GET parses") + } + let post = Data("POST /session/S/actions HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}".utf8) + if case .complete(let request) = HTTPRequest.parse(post) { + check((try? request.jsonObject())?.isEmpty == true, "empty JSON body") + } else { + check(false, "POST parses") + } + if case .incomplete = HTTPRequest.parse(Data("POST /x HTTP/1.1\r\nContent-Length: 10\r\n\r\n{}".utf8)) { + } else { + check(false, "short body waits for more bytes") + } + let response = HTTPResponse.from(RunnerError.noSuchAlert()) + let body = try JSONSerialization.jsonObject(with: response.body) as? [String: Any] + let value = body?["value"] as? [String: Any] + check(response.status == 404 && value?["error"] as? String == "no such alert", "no such alert envelope") + check(String(decoding: response.serialized().prefix(24), as: UTF8.self).hasPrefix("HTTP/1.1 404 Not Found"), + "404 status line (WdaClient matches '404 Not Found')") + let session = HTTPResponse.value(["sessionId": "S"], sessionId: "S") + let sessionBody = try JSONSerialization.jsonObject(with: session.body) as? [String: Any] + check(sessionBody?["sessionId"] as? String == "S", "top-level sessionId") +} + +// MARK: Tree + locators + +func node(_ type: String, label: String? = nil, id: String? = nil, value: String? = nil, + placeholder: String? = nil, rect: [Double] = [0, 0, 10, 10], focused: Bool = false, + children: [[String: Any]] = []) -> [String: Any] { + var dictionary: [String: Any] = [ + "type": "XCUIElementType" + type, + "label": label as Any? ?? NSNull(), "name": (id ?? label) as Any? ?? NSNull(), + "value": value as Any? ?? NSNull(), + "rawIdentifier": id as Any? ?? NSNull(), "placeholderValue": placeholder as Any? ?? NSNull(), + "rect": ["x": rect[0], "y": rect[1], "width": rect[2], "height": rect[3]], + "isEnabled": "1", "isFocused": focused ? "1" : "0", + ] + if !children.isEmpty { dictionary["children"] = children } + return dictionary +} + +let screen = CGSize(width: 390, height: 844) +let tree = UINode(raw: node("Application", label: "Settings", rect: [0, 0, 390, 844], children: [ + node("Window", rect: [0, 0, 390, 844], children: [ + node("Button", label: "Done", rect: [300, 40, 60, 30]), + node("Button", label: "OK", id: "ok-button", rect: [20, 40, 60, 30]), + node("TextField", label: "SpotlightSearchField", placeholder: "Search", rect: [10, 100, 300, 40], focused: true), + node("Picker", rect: [0, 600, 390, 200], children: [ + node("PickerWheel", value: "June", rect: [0, 600, 130, 200]), + node("PickerWheel", value: "6", rect: [130, 600, 130, 200]), + node("PickerWheel", value: "2026", rect: [260, 600, 130, 200]), + ]), + node("Cell", rect: [0, 900, 390, 44], children: [node("StaticText", label: "Offscreen", rect: [0, 900, 100, 44])]), + ]), +]), parent: nil, pid: 1) + +func labels(_ nodes: [UINode]) -> [String] { nodes.map { $0.label ?? $0.value ?? "?" } } + +do { + check(labels(try Locator.find(using: "accessibility id", value: "ok-button", root: tree, screen: screen)) == ["OK"], + "accessibility id matches identifier") + check(labels(try Locator.find(using: "accessibility id", value: "Done", root: tree, screen: screen)) == ["Done"], + "accessibility id matches label") + check(try Locator.find(using: "class name", value: "XCUIElementTypePickerWheel", root: tree, screen: screen).count == 3, + "class name") + let dismiss = "type == 'XCUIElementTypeButton' AND (name IN {'Hide keyboard', 'Done'} OR label IN {'Hide keyboard', 'Done'})" + check(labels(try Locator.find(using: "predicate string", value: dismiss, root: tree, screen: screen)) == ["Done"], + "keyboard-dismiss predicate") + let spotlight = "type == 'XCUIElementTypeTextField' AND (label == 'SpotlightSearchField' OR placeholderValue IN {'搜索', 'Search', '検索'})" + check(try Locator.find(using: "predicate string", value: spotlight, root: tree, screen: screen).count == 1, + "spotlight predicate") + check(try Locator.find(using: "predicate string", value: "focused == 1", root: tree, screen: screen).count == 1, + "focused == 1") + check(labels(try Locator.find(using: "predicate string", value: "type == 'XCUIElementTypeStaticText' AND visible == 0", + root: tree, screen: screen)) == ["Offscreen"], "geometric visible") + check(try Locator.find(using: "predicate string", value: "(label == 'OK' OR name == 'OK') AND enabled == 1", + root: tree, screen: screen).count == 1, "daemon locator predicate") + expectThrows("invalid selector", "bad predicate") { + _ = try Locator.find(using: "predicate string", value: "label ==", root: tree, screen: screen) + } + expectThrows("invalid selector", "xpath unsupported") { + _ = try Locator.find(using: "xpath", value: "//*", root: tree, screen: screen) + } + + check(labels(try Locator.find(using: "class chain", value: "**/XCUIElementTypePickerWheel", root: tree, screen: screen)) + == ["June", "6", "2026"], "class chain descendants") + check(labels(try Locator.find(using: "class chain", value: "**/XCUIElementTypeButton[`label == \"OK\"`]", + root: tree, screen: screen)) == ["OK"], "class chain predicate") + check(labels(try Locator.find(using: "class chain", value: "XCUIElementTypeWindow/XCUIElementTypeButton[2]", + root: tree, screen: screen)) == ["OK"], "class chain child index") + check(labels(try Locator.find(using: "class chain", value: "**/XCUIElementTypePickerWheel[-1]", + root: tree, screen: screen)) == ["2026"], "class chain negative index") + check(try Locator.find(using: "class chain", value: "**/XCUIElementTypeCell[$label == 'Offscreen'$]", + root: tree, screen: screen).count == 1, "class chain descendant predicate") + check(try Locator.find(using: "class chain", value: "Button", root: tree, screen: screen).isEmpty, + "short type name, direct children only") + expectThrows("invalid selector", "class chain ending in **") { + _ = try Locator.find(using: "class chain", value: "XCUIElementTypeWindow/**", root: tree, screen: screen) + } +} + +// MARK: W3C actions + +do { + func pointer(_ actions: [[String: Any]]) throws -> [[[String: Any]]] { + try W3CActions.pointerPaths(actions) { id in + guard id == "E1" else { throw RunnerError.staleElement(id) } + return CGPoint(x: 100, y: 200) + } + } + func t(_ step: [String: Any]) -> Double { step["t"] as? Double ?? -1 } + + let tap = try pointer([ + ["type": "pointerMove", "duration": 0, "x": 50, "y": 60], + ["type": "pointerDown", "button": 0], + ["type": "pointerUp", "button": 0], + ]) + check(tap.count == 1 && tap[0].count == 2, "tap is one down/up path") + check(tap.first?.first?["x"] as? Double == 50 && tap.first?.last?["type"] as? String == "up", "tap position") + check(abs(t(tap[0][1]) - 0.05) < 1e-9, "tap held 50 ms") + + let hold = try pointer([ + ["type": "pointerMove", "duration": 0, "x": 1, "y": 1], + ["type": "pointerDown", "button": 0], + ["type": "pause", "duration": 800], + ["type": "pointerUp", "button": 0], + ]) + check(abs(t(hold[0][1]) - 0.8) < 1e-9, "long press lifts after the pause") + + let swipe = try pointer([ + ["type": "pointerMove", "duration": 0, "x": 10, "y": 500], + ["type": "pointerDown", "button": 0], + ["type": "pause", "duration": 80], + ["type": "pointerMove", "duration": 250, "x": 10, "y": 100], + ["type": "pointerUp", "button": 0], + ]) + let moves = swipe[0].filter { $0["type"] as? String == "move" } + check(moves.count == 16, "250 ms move sampled every ~16 ms (got \(moves.count))") + check(moves.last?["y"] as? Double == 100 && abs(t(moves.last!) - 0.33) < 1e-9, "move ends at target and time") + check(abs(t(moves.first!) - (0.08 + 0.25 / 16)) < 1e-9, "move starts after the pause") + + let relative = try pointer([ + ["type": "pointerMove", "duration": 0, "x": 5, "y": 5, "origin": ["element-6066-11e4-a52e-4f735466cecf": "E1"]], + ["type": "pointerDown"], ["type": "pointerUp"], + ]) + check(relative[0][0]["x"] as? Double == 105 && relative[0][0]["y"] as? Double == 205, "element origin") + + let unfinished = try pointer([["type": "pointerDown"], ["type": "pause", "duration": 10]]) + check(unfinished.count == 1 && unfinished[0].last?["type"] as? String == "up", "dangling down is lifted") + expectThrows("invalid argument", "unknown pointer action") { + _ = try pointer([["type": "wiggle"]]) + } +} + +print(failures == 0 ? "unit check: \(checks) checks passed" : "unit check: \(failures) of \(checks) checks FAILED") +exit(failures == 0 ? 0 : 1) diff --git a/scripts/release.sh b/scripts/release.sh index 4135f32..d127e24 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -45,7 +45,9 @@ t bash -n install.sh t bash scripts/test-install-release-transaction.sh t bash scripts/test-setup-wda-warp-preflight.sh t bash scripts/test-setup-wda-lock-backoff.sh -t bash scripts/test-setup-wda-icon-injection.sh +t bash scripts/test-setup-wda-runner-build.sh +t bash scripts/test-install-runner-sources.sh +t bash runner/ci-check.sh t bash scripts/test-setup-wda-probe-threshold.sh t python3 scripts/test-setup-wda-status.py t python3 scripts/test-setup-wda-runner-product.py diff --git a/scripts/runner-compat.py b/scripts/runner-compat.py new file mode 100644 index 0000000..9685493 --- /dev/null +++ b/scripts/runner-compat.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""WDA-compatibility check for the native iphone-use runner (runner/). + +Exercises every WebDriverAgent route the daemon's WdaClient (crates/server/src/wda.rs) uses, +with the same request bodies, and checks the response shapes WdaClient parses. Also reads the +MJPEG stream (WDA's multipart format, port 9100) and reports the achieved frame rate. + + python3 scripts/runner-compat.py [--base http://127.0.0.1:8100] [--mjpeg http://127.0.0.1:9100] + +By default it only reads: session setup, settings, tree, screenshot, finds, element reads, +alert reads, lock state, the MJPEG stream. Opt-in flags change the screen: + + --mutate Home, launch Settings, a W3C swipe there and back, element click on a + Settings row, keyboard dismiss (a no-op without a keyboard), back to Home + --tap X Y one W3C tap at a harmless point (screen points) + --keys TEXT type TEXT into the focused field (/wda/keys and a W3C key action) + --url URL POST /session/:sid/url + --lock lock the phone (POST /wda/lock) and check /wda/locked turns true + +Exit code 1 when any check fails. +""" + +import argparse +import base64 +import json +import sys +import time +import urllib.error +import urllib.request + +W3C_KEY = "element-6066-11e4-a52e-4f735466cecf" + + +class Checker: + def __init__(self, base): + self.base = base.rstrip("/") + self.failures = 0 + self.sid = None + + def call(self, method, path, body=None, timeout=60.0): + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(self.base + path, data=data, method=method) + if data is not None: + request.add_header("Content-Type", "application/json") + started = time.perf_counter() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status, raw, headers = response.status, response.read(), dict(response.headers) + except urllib.error.HTTPError as error: + status, raw, headers = error.code, error.read(), dict(error.headers) + elapsed = (time.perf_counter() - started) * 1000 + try: + payload = json.loads(raw) if raw else None + except ValueError: + payload = None + return status, payload, headers, elapsed + + def s(self, path): + return f"/session/{self.sid}{path}" + + def report(self, ok, label, elapsed, detail=""): + if not ok: + self.failures += 1 + mark = "ok " if ok else "FAIL" + print(f"{mark} {label:<48} {elapsed:7.0f} ms {detail}"[:220]) + + def expect(self, label, method, path, body=None, status=200, check=None, timeout=60.0): + """Calls a route; checks the HTTP status, the WDA envelope, and `check(value)`.""" + code, payload, headers, elapsed = self.call(method, path, body, timeout) + value = (payload or {}).get("value") if isinstance(payload, dict) else None + ok = code == status and isinstance(payload, dict) and "value" in payload + detail = json.dumps(value, ensure_ascii=False)[:120] + if ok and status >= 400: + ok = isinstance(value, dict) and "error" in value and "message" in value + if ok and check is not None: + try: + verdict = check(value) + except Exception as error: # noqa: BLE001 - a broken shape is a failed check + verdict = f"check raised {error!r}" + if verdict is not True: + ok = False + detail = f"{verdict} :: {detail}" + if code != status: + detail = f"HTTP {code} (want {status}) :: {detail}" + self.report(ok, label, elapsed, detail) + return value, headers + + def expect_either(self, label, method, path, body=None, check=None, missing_code="no such element"): + """200 with `check`, or 404 with WDA's `missing_code` error (both are valid answers).""" + code, payload, _, elapsed = self.call(method, path, body) + value = (payload or {}).get("value") if isinstance(payload, dict) else None + if code == 404: + ok = isinstance(value, dict) and value.get("error") == missing_code + self.report(ok, label + " (none)", elapsed, json.dumps(value, ensure_ascii=False)[:120]) + return None + ok = code == 200 and (check is None or check(value) is True) + self.report(ok, label, elapsed, json.dumps(value, ensure_ascii=False)[:120]) + return value if ok else None + + +def is_ref(value): + if not isinstance(value, dict): + return "not an object" + if not isinstance(value.get("ELEMENT"), str) or value.get(W3C_KEY) != value.get("ELEMENT"): + return "element reference lacks ELEMENT / W3C key" + return True + + +def is_rect(value): + keys = ("x", "y", "width", "height") + return True if isinstance(value, dict) and all(isinstance(value.get(k), (int, float)) for k in keys) else "bad rect" + + +def tree_shape(root): + if not isinstance(root, dict) or not str(root.get("type", "")).startswith("XCUIElementType"): + return "root is not a WDA node" + for key in ("label", "name", "value", "rawIdentifier", "placeholderValue", "rect", "isEnabled", "isFocused"): + if key not in root: + return f"root lacks {key}" + return True + + +def count(node): + return 1 + sum(count(child) for child in node.get("children") or []) if isinstance(node, dict) else 0 + + +def check_mjpeg(url, seconds): + """Reads the stream for `seconds`; returns (frames, bytes per frame, header ok, first error).""" + request = urllib.request.Request(url.rstrip("/") + "/") + frames, sizes = 0, [] + started = time.perf_counter() + try: + with urllib.request.urlopen(request, timeout=10) as response: + content_type = response.headers.get("Content-Type", "") + header_ok = "multipart/x-mixed-replace" in content_type and "BoundaryString" in content_type + buffer = b"" + while time.perf_counter() - started < seconds: + chunk = response.read1(65536) if hasattr(response, "read1") else response.read(65536) + if not chunk: + break + buffer += chunk + while True: + soi = buffer.find(b"\xff\xd8") + if soi < 0: + break + head = buffer[:soi].decode("latin-1").rsplit("--", 1)[-1] + length = None + for line in head.splitlines(): + name, _, value = line.partition(":") + if name.strip().lower() == "content-length": + length = int(value.strip()) + if length is None: + return frames, sizes, header_ok, "part without Content-Length" + if len(buffer) < soi + length: + break + jpeg = buffer[soi:soi + length] + if not jpeg.endswith(b"\xff\xd9"): + return frames, sizes, header_ok, "frame does not end with FFD9" + frames += 1 + sizes.append(length) + buffer = buffer[soi + length:] + except OSError as error: + return frames, sizes, False, str(error) + return frames, sizes, header_ok, None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--base", default="http://127.0.0.1:8100") + parser.add_argument("--mjpeg", default="http://127.0.0.1:9100", help="MJPEG URL; 'off' skips it") + parser.add_argument("--mjpeg-seconds", type=float, default=5) + parser.add_argument("--mutate", action="store_true") + parser.add_argument("--tap", nargs=2, type=float, metavar=("X", "Y")) + parser.add_argument("--keys") + parser.add_argument("--url") + parser.add_argument("--lock", action="store_true") + args = parser.parse_args() + c = Checker(args.base) + print(f"runner at {c.base}") + + # --- health, session, settings ------------------------------------------------------------- + c.expect("GET /status", "GET", "/status", + check=lambda v: True if v.get("ready") is True and v.get("sessionId") else "ready/sessionId missing") + c.expect("GET /wda/locked (sessionless)", "GET", "/wda/locked", + check=lambda v: True if isinstance(v, bool) else "not a bool") + code, payload, _, elapsed = c.call("POST", "/session", { + "capabilities": {"alwaysMatch": {"shouldWaitForQuiescence": False}, "firstMatch": [{}]}}) + c.sid = (payload or {}).get("sessionId") or ((payload or {}).get("value") or {}).get("sessionId") + c.report(code == 200 and bool(c.sid), "POST /session", elapsed, f"sessionId={c.sid}") + if not c.sid: + print("no session; stopping") + return 1 + c.expect("POST /appium/settings (idle)", "POST", c.s("/appium/settings"), + {"settings": {"waitForIdleTimeout": 0, "animationCoolOffTimeout": 0}}, + check=lambda v: True if isinstance(v, dict) else "settings not echoed") + c.expect("GET /session/:sid/wda/locked", "GET", c.s("/wda/locked"), + check=lambda v: True if isinstance(v, bool) else "not a bool") + c.expect("GET /session/:sid/wda/apps/list", "GET", c.s("/wda/apps/list"), + check=lambda v: True if isinstance(v, list) and v and all("bundleId" in a and "pid" in a for a in v) + else "need [{bundleId, pid}]") + + # --- tree, screen ---------------------------------------------------------------------------- + for path in ("/source?format=json", "/source?format=json&excluded_attributes=visible,accessible"): + root, headers = c.expect(f"GET {path[:40]}", "GET", path, check=tree_shape) + if isinstance(root, dict): + print(f" nodes={count(root)} backend={headers.get('X-IPU-Backend')} " + f"server={headers.get('Server-Timing')}") + c.expect("GET /screenshot", "GET", "/screenshot", + check=lambda v: True if base64.b64decode(v)[:4] == b"\x89PNG" else "not a base64 PNG") + c.expect("GET /session/:sid/window/size", "GET", c.s("/window/size"), + check=lambda v: True if v.get("width", 0) > 0 and v.get("height", 0) > 0 else "bad size") + + # --- finds and element reads ----------------------------------------------------------------- + refs, _ = c.expect("POST /elements (class chain)", "POST", c.s("/elements"), + {"using": "class chain", "value": "**/XCUIElementTypeStaticText"}, + check=lambda v: True if isinstance(v, list) and all(is_ref(r) is True for r in v) else "bad refs") + c.expect("POST /elements (predicate, none)", "POST", c.s("/elements"), + {"using": "predicate string", "value": "label == '__ipu_no_such_label__'"}, + check=lambda v: True if v == [] else "expected []") + c.expect("POST /element (missing -> 404)", "POST", c.s("/element"), + {"using": "accessibility id", "value": "__ipu_no_such_id__"}, status=404, + check=lambda v: True if v.get("error") == "no such element" else "wrong error code") + c.expect("POST /elements (bad predicate -> 400)", "POST", c.s("/elements"), + {"using": "predicate string", "value": "label =="}, status=400) + element = refs[0]["ELEMENT"] if isinstance(refs, list) and refs else None + if element: + c.expect("GET /element/:id/rect", "GET", c.s(f"/element/{element}/rect"), check=is_rect) + c.expect("GET /element/:id/attribute/value", "GET", c.s(f"/element/{element}/attribute/value"), + check=lambda v: True if v is None or isinstance(v, (str, int, float, bool)) else "bad value") + c.expect("GET /element/:id/attribute/visible", "GET", c.s(f"/element/{element}/attribute/visible"), + check=lambda v: True if isinstance(v, bool) else "not a bool") + c.expect("POST /element/:id/elements", "POST", c.s(f"/element/{element}/elements"), + {"using": "class chain", "value": "**/*"}, + check=lambda v: True if isinstance(v, list) else "not a list") + label, _ = c.expect("GET /element/:id/attribute/label", "GET", c.s(f"/element/{element}/attribute/label")) + if isinstance(label, str) and label: + c.expect("POST /element (accessibility id = label)", "POST", c.s("/element"), + {"using": "accessibility id", "value": label}, check=is_ref) + else: + print("SKIP element reads: no StaticText on screen") + c.expect("GET /element/stale/rect -> 404", "GET", c.s("/element/00000000-STALE/rect"), status=404) + c.expect_either("GET /element/active", "GET", c.s("/element/active"), check=is_ref) + + # --- alerts ---------------------------------------------------------------------------------- + text = c.expect_either("GET /alert/text", "GET", c.s("/alert/text"), + check=lambda v: isinstance(v, str), missing_code="no such alert") + c.expect_either("GET /wda/alert/buttons", "GET", c.s("/wda/alert/buttons"), + check=lambda v: isinstance(v, list), missing_code="no such alert") + if text is None: + c.expect("POST /alert/accept (none -> 404)", "POST", c.s("/alert/accept"), {}, status=404, + check=lambda v: True if v.get("error") == "no such alert" else "wrong error code") + c.expect("POST /alert/dismiss (none -> 404)", "POST", c.s("/alert/dismiss"), {}, status=404, + check=lambda v: True if v.get("error") == "no such alert" else "wrong error code") + else: + print("SKIP alert accept/dismiss: an alert is open and accepting it would change the phone") + c.expect("unknown route -> 404", "GET", c.s("/no/such/route"), status=404) + + # --- opt-in: screen-changing routes ---------------------------------------------------------- + if args.tap: + x, y = args.tap + c.expect("POST /actions (tap)", "POST", c.s("/actions"), {"actions": [{ + "type": "pointer", "id": "finger1", "parameters": {"pointerType": "touch"}, + "actions": [{"type": "pointerMove", "duration": 0, "x": x, "y": y}, + {"type": "pointerDown", "button": 0}, {"type": "pointerUp", "button": 0}]}]}) + if args.mutate: + c.expect("POST /wda/pressButton home", "POST", c.s("/wda/pressButton"), {"name": "home"}) + c.expect("POST /wda/apps/launch Settings", "POST", c.s("/wda/apps/launch"), {"bundleId": "com.apple.Preferences"}) + time.sleep(1.0) + c.expect("GET /wda/apps/list (Settings first)", "GET", c.s("/wda/apps/list"), + check=lambda v: True if v and v[0].get("bundleId") == "com.apple.Preferences" else "Settings not first") + size, _ = c.expect("GET /window/size", "GET", c.s("/window/size")) + w, h = (size or {}).get("width", 390), (size or {}).get("height", 844) + for name, (y1, y2) in (("up", (0.7, 0.4)), ("down", (0.4, 0.7))): + c.expect(f"POST /actions (swipe {name})", "POST", c.s("/actions"), {"actions": [{ + "type": "pointer", "id": "finger1", "parameters": {"pointerType": "touch"}, + "actions": [{"type": "pointerMove", "duration": 0, "x": w / 2, "y": h * y1}, + {"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 80}, + {"type": "pointerMove", "duration": 250, "x": w / 2, "y": h * y2}, + {"type": "pointerUp", "button": 0}]}]}) + time.sleep(0.6) + cells, _ = c.expect("POST /elements (Settings cells)", "POST", c.s("/elements"), + {"using": "class chain", "value": "**/XCUIElementTypeCell"}) + if isinstance(cells, list) and cells: + cell = cells[0]["ELEMENT"] + c.expect("POST /element/:id/click (first cell)", "POST", c.s(f"/element/{cell}/click"), {}) + time.sleep(0.8) + c.expect("POST /actions (edge swipe back)", "POST", c.s("/actions"), {"actions": [{ + "type": "pointer", "id": "finger1", "parameters": {"pointerType": "touch"}, + "actions": [{"type": "pointerMove", "duration": 0, "x": 1, "y": h / 2}, + {"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 80}, + {"type": "pointerMove", "duration": 250, "x": w * 0.55, "y": h / 2}, + {"type": "pointerUp", "button": 0}]}]}) + c.expect("POST /wda/keyboard/dismiss", "POST", c.s("/wda/keyboard/dismiss"), + {"keyNames": ["Done", "完了", "return", "前往", "search"]}) + c.expect("POST /wda/pressButton home (back)", "POST", c.s("/wda/pressButton"), {"name": "home"}) + if args.keys: + c.expect("POST /wda/keys", "POST", c.s("/wda/keys"), {"value": [args.keys]}) + c.expect("POST /actions (key: return)", "POST", c.s("/actions"), {"actions": [{ + "type": "key", "id": "keyboard", + "actions": [{"type": "keyDown", "value": ""}, {"type": "keyUp", "value": ""}]}]}) + if args.url: + c.expect("POST /session/:sid/url", "POST", c.s("/url"), {"url": args.url}) + if args.lock: + c.expect("POST /wda/lock", "POST", c.s("/wda/lock"), {}) + time.sleep(1.0) + c.expect("GET /wda/locked (after lock)", "GET", "/wda/locked", + check=lambda v: True if v is True else "phone did not report locked") + + # --- MJPEG ----------------------------------------------------------------------------------- + if args.mjpeg != "off": + c.expect("POST /appium/settings (mjpeg 30/50/60)", "POST", c.s("/appium/settings"), + {"settings": {"mjpegServerFramerate": 30, "mjpegScalingFactor": 50, + "mjpegServerScreenshotQuality": 60}}) + frames, sizes, header_ok, error = check_mjpeg(args.mjpeg, args.mjpeg_seconds) + fps = frames / args.mjpeg_seconds + average = sum(sizes) / len(sizes) if sizes else 0 + ok = header_ok and frames > 0 and error is None + c.report(ok, f"MJPEG {args.mjpeg}", args.mjpeg_seconds * 1000, + f"{frames} frames, {fps:.1f} fps, avg {average / 1024:.0f} KiB" + + ("" if header_ok else ", bad Content-Type") + (f", {error}" if error else "")) + status, payload, _, _ = c.call("GET", "/status") + print(f" runner mjpeg status: {json.dumps(((payload or {}).get('value') or {}).get('mjpeg'))}") + + print("OK" if c.failures == 0 else f"{c.failures} failure(s)") + return 0 if c.failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/runner-smoke.py b/scripts/runner-smoke.py new file mode 100755 index 0000000..a041012 --- /dev/null +++ b/scripts/runner-smoke.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Smoke test + micro-benchmark for the native iphone-use runner (runner/). + +Talks straight to the runner's HTTP port (forwarded with `iproxy 8100 8100`). +Read-only except for one tap, and only when --tap X Y is given — pick a harmless +point (an empty area of the current screen). + + python3 scripts/runner-smoke.py [--base http://127.0.0.1:8100] [--runs 5] [--tap 200 60] + +Reports: /status, /window/size, /apps/active, /alert, then /source timing over +--runs (with node counts and the runner's backend/depth headers), the xcui +fallback once for comparison, the optional tap, and /screenshot timing. +Exit code 1 when any check fails. +""" + +import argparse +import base64 +import json +import statistics +import sys +import time +import urllib.error +import urllib.request + + +def call(base, method, path, body=None, timeout=60.0): + """Returns (status, json_or_None, headers, elapsed_ms).""" + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(base + path, data=data, method=method) + if data is not None: + request.add_header("Content-Type", "application/json") + started = time.perf_counter() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read() + status, headers = response.status, dict(response.headers) + except urllib.error.HTTPError as error: + raw = error.read() + status, headers = error.code, dict(error.headers) + elapsed = (time.perf_counter() - started) * 1000 + try: + payload = json.loads(raw) if raw else None + except ValueError: + payload = None + return status, payload, headers, elapsed + + +def count_nodes(node): + if not isinstance(node, dict): + return 0 + return 1 + sum(count_nodes(child) for child in node.get("children") or []) + + +def check_wda_shape(node): + """Problems with the root node's WDA /source shape (empty list = fine).""" + problems = [] + for key in ("type", "label", "name", "value", "rawIdentifier", "placeholderValue", "rect", "isEnabled", "isFocused"): + if key not in node: + problems.append(f"missing {key}") + if not str(node.get("type", "")).startswith("XCUIElementType"): + problems.append(f"type {node.get('type')!r}") + rect = node.get("rect") or {} + if not all(k in rect for k in ("x", "y", "width", "height")): + problems.append("rect lacks x/y/width/height") + if node.get("isEnabled") not in ("0", "1"): + problems.append(f"isEnabled {node.get('isEnabled')!r}") + return problems + + +def summary(samples): + if not samples: + return "-" + return (f"min {min(samples):.0f} median {statistics.median(samples):.0f} " + f"max {max(samples):.0f} ms (n={len(samples)})") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--base", default="http://127.0.0.1:8100") + parser.add_argument("--runs", type=int, default=5) + parser.add_argument("--tap", nargs=2, type=float, metavar=("X", "Y"), + help="tap this point (screen points) once and time it; skipped when absent") + parser.add_argument("--max-depth", type=int, help="pass max_depth to /source") + parser.add_argument("--no-xcui", action="store_true", help="skip the xcui-snapshot comparison run") + parser.add_argument("--screenshot-out", help="write the last screenshot PNG here") + args = parser.parse_args() + base = args.base.rstrip("/") + failures = [] + + def fail(message): + failures.append(message) + print(f" FAIL {message}") + + print(f"runner at {base}") + try: + status, payload, _, elapsed = call(base, "GET", "/status", timeout=5) + except OSError as error: + print(f" FAIL /status unreachable: {error}") + return 1 + value = (payload or {}).get("value") or {} + print(f"/status {status} {elapsed:6.0f} ms {json.dumps(value)}") + if status != 200 or value.get("ready") is not True: + fail("/status not ready") + + for path in ("/window/size", "/apps/active", "/alert"): + status, payload, _, elapsed = call(base, "GET", path) + value = (payload or {}).get("value") + print(f"{path:<14} {status} {elapsed:6.0f} ms {json.dumps(value, ensure_ascii=False)[:160]}") + if path == "/alert": + if status not in (200, 404): + fail(f"/alert returned {status}") + elif status != 200: + fail(f"{path} returned {status}") + + query = f"?max_depth={args.max_depth}" if args.max_depth else "" + samples, server_samples = [], [] + for run in range(args.runs): + status, payload, headers, elapsed = call(base, "GET", "/source" + query) + root = (payload or {}).get("value") + if status != 200 or not isinstance(root, dict): + fail(f"/source run {run + 1}: {status} {json.dumps(root)[:200]}") + continue + samples.append(elapsed) + server = headers.get("Server-Timing", "") + if "dur=" in server: + server_samples.append(float(server.split("dur=")[1])) + nodes = count_nodes(root) + print(f"/source #{run + 1} {status} {elapsed:6.0f} ms nodes={nodes} " + f"backend={headers.get('X-IPU-Backend')} depth={headers.get('X-IPU-Depth')} " + f"ext={headers.get('X-IPU-Extension-Calls')} truncated={headers.get('X-IPU-Truncated')} " + f"pid={headers.get('X-IPU-Pid')} bytes={headers.get('Content-Length')}") + if run == 0: + for problem in check_wda_shape(root): + fail(f"/source shape: {problem}") + print(f"/source wall {summary(samples)}") + print(f"/source server {summary(server_samples)}") + + if not args.no_xcui: + status, payload, headers, elapsed = call(base, "GET", "/source?backend=xcui", timeout=120) + root = (payload or {}).get("value") + nodes = count_nodes(root) if isinstance(root, dict) else 0 + print(f"/source xcui {status} {elapsed:6.0f} ms nodes={nodes} backend={headers.get('X-IPU-Backend')}") + + if args.tap: + x, y = args.tap + status, payload, headers, elapsed = call(base, "POST", "/tap", {"x": x, "y": y}) + print(f"/tap ({x:g},{y:g}) {status} {elapsed:6.0f} ms path={headers.get('X-IPU-Gesture')} " + f"{json.dumps((payload or {}).get('value'))[:120]}") + if status != 200: + fail("/tap failed") + + shots = [] + png = None + for _ in range(max(1, min(3, args.runs))): + status, payload, _, elapsed = call(base, "GET", "/screenshot") + encoded = (payload or {}).get("value") + if status != 200 or not isinstance(encoded, str): + fail(f"/screenshot returned {status}") + break + png = base64.b64decode(encoded) + if not png.startswith(b"\x89PNG"): + fail("/screenshot is not a PNG") + break + shots.append(elapsed) + if png: + print(f"/screenshot {summary(shots)} png={len(png)} bytes") + if args.screenshot_out: + with open(args.screenshot_out, "wb") as handle: + handle.write(png) + print(f" wrote {args.screenshot_out}") + + status, _, _, _ = call(base, "GET", "/nope") + if status != 404: + fail(f"unknown endpoint returned {status}, expected 404") + + print("OK" if not failures else f"{len(failures)} failure(s)") + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/setup-wda.sh b/scripts/setup-wda.sh index 5040dd2..bfa1db7 100755 --- a/scripts/setup-wda.sh +++ b/scripts/setup-wda.sh @@ -1,34 +1,42 @@ #!/usr/bin/env bash -# scripts/setup-wda.sh — one-command setup for the L2 element-tree layer (WDA). +# scripts/setup-wda.sh — one-command setup for the device layer (the iphone-use +# device runner). # -# Builds Appium's WebDriverAgent, installs it on your iPhone, keeps it running, -# starts a localhost relay, and points the iphone-use daemon at it. Encodes -# every pitfall we hit validating this on hardware (see docs/wda-setup.html). +# Builds the iphone-use device runner (runner/IPhoneUseRunner, an XCTest UI-test +# bundle that serves the daemon's HTTP API on the phone's port 8100 and an MJPEG +# stream on 9100), installs it on your iPhone, keeps it running, starts the +# localhost relays, and points the iphone-use daemon at them. Encodes every +# pitfall we hit validating this on hardware (see docs/wda-setup.html). +# +# The file keeps its name, its WDA_* environment variables, its launchd label and +# its status file: the daemon, the installer and existing installs all address +# the device layer through them. # # Usage: # ./scripts/setup-wda.sh # full setup (interactive prompts as needed) -# ./scripts/setup-wda.sh status # is WDA + relay up? -# ./scripts/setup-wda.sh stop # stop WDA runner + relay +# ./scripts/setup-wda.sh status # is the runner + relay up? +# ./scripts/setup-wda.sh stop # stop the runner + relays # ./scripts/setup-wda.sh pause # give the phone back; disable auto-restart -# ./scripts/setup-wda.sh resume # re-enable the managed WDA supervisor +# ./scripts/setup-wda.sh resume # re-enable the managed runner supervisor +# ./scripts/setup-wda.sh doctor # read-only preflight checklist # ./scripts/setup-wda.sh instance-context # read-only: resolved paths/ports # # Env overrides: -# WDA_UDID=... target device UDID (default: first xcodebuild iOS device) +# WDA_UDID=... target device UDID (default: the one USB iPhone) # WDA_TEAM_ID=... Apple dev team (default: Xcode's last-selected team) # WDA_ASC_KEY_PATH=... absolute .p8 path; with both IDs, use ASC API key signing # WDA_ASC_KEY_ID=... App Store Connect key ID (all three WDA_ASC_* required) # WDA_ASC_ISSUER_ID=... App Store Connect issuer ID # WDA_BUNDLE_ID=... runner bundle id (default: derived from validated Team ID) -# WDA_DIR=... WDA checkout (default: ~/.iphone-use/WebDriverAgent) -# WDA_REF=... exact upstream commit (default: pinned v9.15.3 commit) -# WDA_RUNNER_ICON=... runner icon: auto, none, or a local .png/.icns (default: auto) -# WDA_PORT=... relay port (default: 8100; named instances: derived) +# IPU_RUNNER_SRC=... device runner sources (default: ~/.iphone-use/runner, laid +# down by install.sh; a repo checkout uses its own runner/) +# WDA_PORT=... control relay port (default: 8100; named instances: derived) # MJPEG_PORT=... video relay port (default: 9100; named instances: derived) # PHONE_REMOTE_INSTANCE=... which daemon/phone pair (default: default). A named -# instance keeps its state, checkout, launchd labels and -# ports apart and requires an explicit target UDID (#67). -# WDA_ALLOW_LAN=1 permit unauthenticated WDA over LAN (unsafe; default off) +# instance keeps its state, build products, launchd labels +# and ports apart and requires an explicit target UDID (#67). +# WDA_ALLOW_LAN=1 permit an unauthenticated LAN relay (unsafe; default off) +# WDA_RUNNER_REBUILD=1 ignore the recorded runner product and build again # # Requirements: Xcode (an Apple ID in Settings → Accounts, or WDA_ASC_* signing), # the iPhone paired + Developer Mode on, and `iproxy` for the default USB relay. @@ -265,13 +273,18 @@ _instance_check_bindings() { _instance_resolve # END instance context. -WDA_CHECKOUT_MARKER="$STATE_DIR/wda-checkout-owner.v1" RUN_LOG="$STATE_DIR/wda-runner.log" RUNNER_PID_FILE="$STATE_DIR/wda-runner.pid" RELAY_PID_FILE="$STATE_DIR/wda-relay.pid" -WDA_REPO="https://github.com/appium/WebDriverAgent.git" -DEFAULT_WDA_REF="54f9fc702b5ba40249017a4b9bf48c69b757389b" -DEFAULT_WDA_REF_TAG="v9.15.3" +# The device runner: one UI-test bundle, built with build-for-testing and run with +# test-without-building. Products live under this instance's state directory, so +# instances never share (or invalidate) each other's build. +RUNNER_SCHEME="IPhoneUseRunner" +RUNNER_TEST_ID="IPhoneUseRunnerUITests/RunnerTests/testServe" +RUNNER_APP_NAME="iPhoneUse-Runner.app" +RUNNER_DERIVED_DATA="$STATE_DIR/runner-build" +RUNNER_PRODUCTS_DIR="$RUNNER_DERIVED_DATA/Build/Products/Debug-iphoneos" +RUNNER_DEFAULT_SRC="$HOME/.iphone-use/runner" WDA_AGENT_PLIST="$HOME/Library/LaunchAgents/$WDA_AGENT_LABEL.plist" WDA_AGENT_LOG="$STATE_DIR/wda-agent.log" WDA_RETRY_STATE="$STATE_DIR/wda-retry-state.v1" @@ -280,18 +293,10 @@ DAEMON_PLIST="$HOME/Library/LaunchAgents/$DAEMON_LABEL.plist" DAEMON_ROLLBACK_PLIST="$STATE_DIR/daemon.rollback.$$.plist" UID_NUM="$(id -u)" GUI_DOMAIN="gui/$UID_NUM" -WDA_CHECKOUT_CREATED_THIS_RUN=0 -WDA_MARKER_REFRESH_ALLOWED=0 -WDA_CANONICAL_DIR="" -SHASUM_BIN="$(command -v shasum 2>/dev/null || true)" -WDA_ICON_WORK_DIR="" -WDA_ICON_PRODUCTS_DIR="" -WDA_ICON_APP_PATH="" -WDA_ICON_BACKUP_PATH="" -WDA_ICON_MUTATION_ACTIVE=0 -WDA_RUNNER_ICON_INJECTED=0 -WDA_ICON_BUILD_LOCKED=0 -WDA_BUILT_SDK_VERSION="" +RUNNER_BUILT_PRODUCTS="" +RUNNER_APP_PATH="" +RUNNER_BUILD_LOCKED=0 +RUNNER_SOURCE_HASH="" WDA_RUNNER_REPAIR_ATTEMPTED=0 WDA_RUNNER_VALIDATION_ERROR="" KEEPALIVE_ATTEMPT_ACTIVE=0 @@ -322,11 +327,34 @@ _port_from_daemon_url() { } # Preserve setup-owned supervisor policy on a plain rerun. Precedence is: -# explicit environment > existing WDA supervisor > daemon endpoint > default. -# A named instance has its own checkout, so its DerivedData (keyed by project -# path) never mixes with another instance's build. +# explicit environment > existing runner supervisor > daemon endpoint > default. +# +# WDA_DIR names the WebDriverAgent checkout that releases before the device +# runner built from. Nothing is built from it any more; it is kept only so a +# PID-only record from such a release can still be matched against its cwd. WDA_DIR="${WDA_DIR:-$(_existing_wda_env WDA_DIR)}" WDA_DIR="${WDA_DIR:-$STATE_DIR/WebDriverAgent}" + +# Device runner sources. Explicit > persisted in the supervisor > the runner/ +# directory of the repo checkout this script was started from > the copy +# install.sh lays down. The installed copy of this script never looks next to +# itself, so ~/.iphone-use cannot be mistaken for a repo. +_runner_repo_src() { + local self_dir repo + self_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" || return 1 + case "$self_dir" in + "$HOME/.iphone-use"|"$HOME/.iphone-use/"*) return 1 ;; + esac + repo="$(cd "$self_dir/.." 2>/dev/null && pwd)" || return 1 + [ -f "$repo/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj" ] \ + && [ -d "$repo/crates/server" ] || return 1 + printf '%s\n' "$repo/runner" +} +RUNNER_SRC="${IPU_RUNNER_SRC:-$(_existing_wda_env IPU_RUNNER_SRC)}" +[ -n "$RUNNER_SRC" ] || RUNNER_SRC="$(_runner_repo_src || true)" +RUNNER_SRC="${RUNNER_SRC:-$RUNNER_DEFAULT_SRC}" +RUNNER_SRC="${RUNNER_SRC%/}" +RUNNER_PROJECT="$RUNNER_SRC/IPhoneUseRunner/IPhoneUseRunner.xcodeproj" WDA_PORT="${WDA_PORT:-$(_existing_wda_env WDA_PORT)}" WDA_PORT="${WDA_PORT:-$(_port_from_daemon_url PHONE_REMOTE_WDA_URL)}" MJPEG_PORT="${MJPEG_PORT:-$(_existing_wda_env MJPEG_PORT)}" @@ -363,7 +391,6 @@ fi if [ "$INSTANCE_NAME" != default ]; then WDA_BUNDLE_ID="${WDA_BUNDLE_ID:-$(_existing_daemon_env WDA_BUNDLE_ID)}" WDA_TEAM_ID="${WDA_TEAM_ID:-$(_existing_daemon_env WDA_TEAM_ID)}" - WDA_REF="${WDA_REF:-$(_existing_daemon_env WDA_REF)}" WDA_ALLOW_LAN="${WDA_ALLOW_LAN:-$(_existing_daemon_env WDA_ALLOW_LAN)}" if [ "${WDA_ASC_KEY_PATH:-}${WDA_ASC_KEY_ID:-}${WDA_ASC_ISSUER_ID:-}" = "" ]; then WDA_ASC_KEY_PATH="$(_existing_daemon_env WDA_ASC_KEY_PATH)" @@ -371,30 +398,10 @@ if [ "$INSTANCE_NAME" != default ]; then WDA_ASC_ISSUER_ID="$(_existing_daemon_env WDA_ASC_ISSUER_ID)" fi fi -WDA_REF="${WDA_REF:-$(_existing_wda_env WDA_REF)}" -WDA_REF="${WDA_REF:-$DEFAULT_WDA_REF}" -if [ "$WDA_REF" = "$DEFAULT_WDA_REF" ]; then - WDA_REF_LABEL="$DEFAULT_WDA_REF_TAG" -else - WDA_REF_LABEL="custom pin" -fi -# Home-screen name of the XCUITest runner app that gets installed on the phone. -# Upstream leaves it as "WebDriverAgentRunner-Runner", which shows up on the -# user's device as an unexplained blank-icon app (issue #64). Xcode derives the -# runner's CFBundleName from the test target's PRODUCT_NAME, so renaming that -# target — and ONLY that target, in the checked-out project — relabels it. -# Set to an empty string to keep upstream's name. -WDA_RUNNER_NAME="${WDA_RUNNER_NAME:-iPhoneUse}" -case "$WDA_RUNNER_NAME" in - ''|*[!A-Za-z0-9_-]*) - [ -z "$WDA_RUNNER_NAME" ] || die "WDA_RUNNER_NAME must be ASCII letters, digits, '_' or '-' (got '$WDA_RUNNER_NAME')" ;; -esac -# The XCUITest runner is synthesised by Xcode, so a normal target asset catalog -# lands inside the nested .xctest instead of the home-screen .xctrunner app. -# `auto` injects the already-installed iPhoneUse app icon after Xcode has built -# that outer app; `none` preserves upstream's blank placeholder. A local PNG or -# ICNS path allows callers to supply a different icon without editing WDA. -WDA_RUNNER_ICON="${WDA_RUNNER_ICON:-auto}" +# WDA_REF, WDA_RUNNER_NAME and WDA_RUNNER_ICON configured the WebDriverAgent +# build this script no longer makes. A supervisor plist written by an older +# release may still carry them; they are read by nothing and dropped on the +# next supervisor install. WDA_ALLOW_LAN="${WDA_ALLOW_LAN:-$(_existing_wda_env WDA_ALLOW_LAN)}" WDA_ALLOW_LAN="${WDA_ALLOW_LAN:-0}" WDA_UDID="${WDA_UDID:-${PHONE_REMOTE_UDID:-}}" @@ -432,6 +439,7 @@ if [ "$COMMAND" = "instance-context" ]; then printf 'daemon_plist=%s\n' "$DAEMON_PLIST" printf 'wda_plist=%s\n' "$WDA_AGENT_PLIST" printf 'wda_dir=%s\n' "$WDA_DIR" + printf 'runner_src=%s\n' "$RUNNER_SRC" printf 'daemon_port=%s\n' "$INSTANCE_DAEMON_PORT" printf 'wda_port=%s\n' "$WDA_PORT" printf 'mjpeg_port=%s\n' "$MJPEG_PORT" @@ -487,20 +495,25 @@ _wda_xcodebuild() { "$XCODEBUILD_BIN" "${XCODEBUILD_ARGS[@]}" } +# The runner is always launched from its built product: build-for-testing +# produced the .xctestrun, and test-without-building installs it as-is. This +# argv is also the runner's process identity (see _runner_signature_valid). _prepare_runner_args() { - if [ -n "${WDA_XCTESTRUN:-}" ]; then - _prepare_xcodebuild_args -destination "platform=iOS,id=$WDA_UDID" \ - test-without-building -xctestrun "$WDA_XCTESTRUN" || return 1 - else - _prepare_xcodebuild_args -project WebDriverAgent.xcodeproj \ - -scheme WebDriverAgentRunner -destination "platform=iOS,id=$WDA_UDID" \ - -allowProvisioningUpdates DEVELOPMENT_TEAM="$TEAM_ID" \ - PRODUCT_BUNDLE_IDENTIFIER="$WDA_BUNDLE_ID" test || return 1 - fi + [ -n "${WDA_XCTESTRUN:-}" ] || return 1 + _prepare_xcodebuild_args -destination "platform=iOS,id=$WDA_UDID" \ + test-without-building -xctestrun "$WDA_XCTESTRUN" \ + "-only-testing:$RUNNER_TEST_ID" || return 1 RUNNER_ARGV=("${XCODEBUILD_ARGS[@]}") RUNNER_ARGS="${RUNNER_ARGV[*]}" } +# Every build-time xcodebuild of the runner project: build-for-testing and the +# destination listing. -derivedDataPath keeps products under STATE_DIR. +_runner_xcodebuild() { + _wda_xcodebuild -project "$RUNNER_PROJECT" -scheme "$RUNNER_SCHEME" \ + -derivedDataPath "$RUNNER_DERIVED_DATA" "$@" +} + _report_missing_xcode_account() { _setstatus signing-fail account "sign in to an Apple account in Xcode, or configure WDA_ASC_KEY_PATH / WDA_ASC_KEY_ID / WDA_ASC_ISSUER_ID for API key signing" die "Xcode has no signed-in Apple account. Open Xcode → Settings → Accounts, @@ -510,88 +523,6 @@ _report_missing_xcode_account() { } # END ASC signing helpers. -_cleanup_wda_icon_work_dir() { - [ -n "${WDA_ICON_WORK_DIR:-}" ] || return 0 - # Only remove the exact setup-owned mktemp shape below STATE_DIR. This can - # run from the global EXIT trap, so never trust a broader or partial path. - case "$WDA_ICON_WORK_DIR" in - "$STATE_DIR"/wda-runner-icon.*) - /bin/rm -rf -- "$WDA_ICON_WORK_DIR" 2>/dev/null || true - ;; - *) - warn "refusing to remove unexpected runner-icon work path: $WDA_ICON_WORK_DIR" - ;; - esac - WDA_ICON_WORK_DIR="" -} - -_restore_wda_icon_app() { - [ "${WDA_ICON_MUTATION_ACTIVE:-0}" = "1" ] || return 0 - # Mutation starts only after all three paths have been resolved and the - # pristine, signed runner has been copied. Revalidate their containment - # before removing anything, including during signal/EXIT cleanup. - case "${WDA_ICON_PRODUCTS_DIR:-}" in - /*/Build/Products/*) ;; - *) return 1 ;; - esac - case "${WDA_ICON_APP_PATH:-}" in - "$WDA_ICON_PRODUCTS_DIR"/*-Runner.app) ;; - *) return 1 ;; - esac - [ "${WDA_ICON_BACKUP_PATH:-}" = "$WDA_ICON_WORK_DIR/original.app" ] \ - && [ -d "$WDA_ICON_BACKUP_PATH" ] || return 1 - - /bin/rm -rf -- "$WDA_ICON_APP_PATH" 2>/dev/null || return 1 - if /usr/bin/ditto "$WDA_ICON_BACKUP_PATH" "$WDA_ICON_APP_PATH" 2>/dev/null \ - && codesign --verify --deep --strict "$WDA_ICON_APP_PATH" 2>/dev/null; then - WDA_ICON_MUTATION_ACTIVE=0 - return 0 - fi - - # A missing build product is safe: the unchanged `xcodebuild ... test` - # below will rebuild it. A half-restored, invalidly signed product is not. - /bin/rm -rf -- "$WDA_ICON_APP_PATH" 2>/dev/null || true - WDA_ICON_MUTATION_ACTIVE=0 - return 1 -} - -# Drop an icon-injected runner so the next xcodebuild step rebuilds a pristine, -# Xcode-signed one. Used after a successful injection (its pristine backup is -# already gone, so restore is impossible) when the product cannot be launched -# with `test-without-building`: the plain `test` action would re-emplace the -# hand-signed bundle's Info.plist and poison the next round (#75). Same -# containment guard as _restore_wda_icon_app; anything else fails closed. -_discard_injected_runner() { - [ "${WDA_RUNNER_ICON_INJECTED:-0}" = "1" ] || return 0 - case "${WDA_ICON_PRODUCTS_DIR:-}" in - /*/Build/Products/*) ;; - *) return 1 ;; - esac - case "${WDA_ICON_APP_PATH:-}" in - "$WDA_ICON_PRODUCTS_DIR"/*-Runner.app) ;; - *) return 1 ;; - esac - [ -L "$WDA_ICON_APP_PATH" ] && return 1 - /bin/rm -rf -- "$WDA_ICON_APP_PATH" 2>/dev/null || return 1 - WDA_RUNNER_ICON_INJECTED=0 - return 0 -} - -# An earlier round's injected runner is not a valid incremental-build input: -# build-for-testing over it re-emplaces Info.plist without re-signing, and the -# product fails validation ("invalid Info.plist (plist or signature have been -# modified)"), costing a second, repair build every round (#75). Start the -# prebuild from a pristine baseline instead: drop the app when it carries the -# injected icon. A non-injected product is left alone for the incremental build. -_discard_previous_injection() { - local products="$1" app="$2" - [ -e "$app/AppIcon60x60@2x.png" ] || return 0 - WDA_ICON_PRODUCTS_DIR="$products" - WDA_ICON_APP_PATH="$app" - WDA_RUNNER_ICON_INJECTED=1 - _discard_injected_runner -} - if [ "$COMMAND" = "setup" ]; then mkdir -p "$STATE_DIR" chmod 700 "$STATE_DIR" @@ -688,7 +619,7 @@ _runner_log_shows_automation_mode_disabled() { _report_automation_mode_disabled() { _setstatus building-fail automation_mode_disabled "$AUTOMATION_MODE_HINT" - die "iOS did not enable UI automation for the WDA runner — $AUTOMATION_MODE_HINT, then rerun setup (KeepAlive retries on its own). Log: $RUN_LOG" + die "iOS did not enable UI automation for the device runner — $AUTOMATION_MODE_HINT, then rerun setup (KeepAlive retries on its own). Log: $RUN_LOG" } _exponential_retry_delay() { @@ -783,9 +714,9 @@ _record_keepalive_failure() { # made both the daemon hint and the web client tell the operator # to read logs and re-run setup for a state that clears by # unlocking the phone. - _setstatus lock-backoff locked "lock screen blocked WDA; next quiet retry in ${delay}s" + _setstatus lock-backoff locked "lock screen blocked the device runner; next quiet retry in ${delay}s" if [ "$previous_kind" != "locked" ]; then - warn "iPhone lock screen blocked WDA; retrying quietly every 5s to 1min until it is unlocked" + warn "iPhone lock screen blocked the device runner; retrying quietly every 5s to 1min until it is unlocked" fi else warn "KeepAlive rebuild failed; next retry in ${delay}s (failure $attempt)" @@ -1023,14 +954,6 @@ _valid_bundle_id() { | LC_ALL=C grep -Eq '^[A-Za-z0-9]+([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]+([A-Za-z0-9-]*[A-Za-z0-9])?)+$' } -_valid_wda_ref() { - [ "${#1}" -eq 40 ] || return 1 - case "$1" in - *[!0123456789ABCDEFabcdef]*) return 1 ;; - *) return 0 ;; - esac -} - _valid_port() { case "$1" in ''|*[!0-9]*) return 1 ;; @@ -1038,39 +961,6 @@ _valid_port() { [ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null } -_sha256_text() { - local value="$1" - local output - [ -n "$SHASUM_BIN" ] && [ -x "$SHASUM_BIN" ] || return 1 - output="$(printf '%s' "$value" | "$SHASUM_BIN" -a 256 2>/dev/null)" || return 1 - output="${output%% *}" - [ "${#output}" -eq 64 ] || return 1 - case "$output" in - *[!0-9a-f]*) return 1 ;; - esac - printf '%s\n' "$output" -} - -_git_metadata_digests() { - local checkout="$1" - local refs reflog worktrees objects - refs="$(GIT_OPTIONAL_LOCKS=0 git -C "$checkout" \ - for-each-ref --format='%(refname) %(objectname)' 2>/dev/null)" || return 1 - reflog="$(GIT_OPTIONAL_LOCKS=0 git -C "$checkout" \ - reflog show --all --format='%H %gD %gs' 2>/dev/null)" || return 1 - worktrees="$(GIT_OPTIONAL_LOCKS=0 git -C "$checkout" \ - worktree list --porcelain 2>/dev/null)" || return 1 - objects="$(GIT_OPTIONAL_LOCKS=0 git -C "$checkout" \ - cat-file --batch-all-objects --batch-check='%(objectname)' 2>/dev/null)" \ - || return 1 - objects="$(printf '%s\n' "$objects" | LC_ALL=C sort)" || return 1 - WDA_MARKER_REFS_DIGEST="$(_sha256_text "$refs")" || return 1 - WDA_MARKER_REFLOG_DIGEST="$(_sha256_text "$reflog")" || return 1 - WDA_MARKER_WORKTREES_DIGEST="$(_sha256_text "$worktrees")" || return 1 - WDA_MARKER_OBJECTS_DIGEST="$(_sha256_text "$objects")" || return 1 - return 0 -} - _marker_file_secure() { local marker="$1" local metadata owner mode @@ -1081,88 +971,6 @@ _marker_file_secure() { [ "$owner" = "$UID_NUM" ] && [ "$mode" = "600" ] } -_existing_marker_matches_checkout() { - local checkout="$1" - local origin="$2" - local head="$3" - local marker="$WDA_CHECKOUT_MARKER" - local count version path marker_origin marker_head - local refs_digest reflog_digest worktrees_digest objects_digest - _marker_file_secure "$marker" || return 1 - count="$(awk 'END { print NR }' "$marker" 2>/dev/null)" || return 1 - [ "$count" = "9" ] || return 1 - version="$(sed -n '1s/^version=//p' "$marker")" - path="$(sed -n '2s/^path=//p' "$marker")" - marker_origin="$(sed -n '3s/^origin=//p' "$marker")" - marker_head="$(sed -n '4s/^head=//p' "$marker")" - # The old UDID may legitimately differ when setup is moving the managed - # checkout to another explicitly selected phone. It is validated and - # rebound only after the new device path is fully proven. - case "$(sed -n '5s/^udid=//p' "$marker")" in - ''|*[!0-9A-Fa-f-]*) return 1 ;; - esac - refs_digest="$(sed -n '6s/^refs_sha256=//p' "$marker")" - reflog_digest="$(sed -n '7s/^reflog_sha256=//p' "$marker")" - worktrees_digest="$(sed -n '8s/^worktrees_sha256=//p' "$marker")" - objects_digest="$(sed -n '9s/^objects_sha256=//p' "$marker")" - [ "$version" = "1" ] \ - && [ "$path" = "$checkout" ] \ - && [ "$marker_origin" = "$origin" ] \ - && [ "$(printf '%s' "$marker_head" | tr 'A-F' 'a-f')" \ - = "$(printf '%s' "$head" | tr 'A-F' 'a-f')" ] \ - || return 1 - _git_metadata_digests "$checkout" || return 1 - [ "$refs_digest" = "$WDA_MARKER_REFS_DIGEST" ] \ - && [ "$reflog_digest" = "$WDA_MARKER_REFLOG_DIGEST" ] \ - && [ "$worktrees_digest" = "$WDA_MARKER_WORKTREES_DIGEST" ] \ - && [ "$objects_digest" = "$WDA_MARKER_OBJECTS_DIGEST" ] -} - -_write_wda_checkout_marker() { - local canonical origin head tmp - [ "$WDA_MARKER_REFRESH_ALLOWED" = "1" ] || return 2 - canonical="$(cd -P "$WDA_DIR" 2>/dev/null && pwd)" || return 1 - case "$canonical" in - *$'\n'*|*$'\r'*) return 1 ;; - esac - origin="$(git -C "$canonical" config --get remote.origin.url 2>/dev/null || true)" - case "$origin" in - https://github.com/appium/WebDriverAgent|\ - https://github.com/appium/WebDriverAgent.git|\ - git@github.com:appium/WebDriverAgent.git|\ - ssh://git@github.com/appium/WebDriverAgent.git) - ;; - *) return 1 ;; - esac - head="$(git -C "$canonical" rev-parse HEAD 2>/dev/null || true)" - [ "$(printf '%s' "$head" | tr 'A-F' 'a-f')" \ - = "$(printf '%s' "$WDA_REF" | tr 'A-F' 'a-f')" ] || return 1 - case "$WDA_UDID" in - ''|*[!0-9A-Fa-f-]*) return 1 ;; - esac - _git_metadata_digests "$canonical" || return 1 - tmp="$(mktemp "$STATE_DIR/wda-checkout-owner.v1.new.XXXXXX")" || return 1 - if printf '%s\n' \ - "version=1" \ - "path=$canonical" \ - "origin=$origin" \ - "head=$head" \ - "udid=$WDA_UDID" \ - "refs_sha256=$WDA_MARKER_REFS_DIGEST" \ - "reflog_sha256=$WDA_MARKER_REFLOG_DIGEST" \ - "worktrees_sha256=$WDA_MARKER_WORKTREES_DIGEST" \ - "objects_sha256=$WDA_MARKER_OBJECTS_DIGEST" > "$tmp" \ - && chmod 600 "$tmp" \ - && mv -f "$tmp" "$WDA_CHECKOUT_MARKER" \ - && _marker_file_secure "$WDA_CHECKOUT_MARKER" \ - && _existing_marker_matches_checkout "$canonical" "$origin" "$head"; then - WDA_CANONICAL_DIR="$canonical" - return 0 - fi - rm -f "$tmp" - return 1 -} - # Resolve one signing identity for doctor, setup, and the persisted supervisor. # A shared default bundle ID cannot work across Apple Developer teams, so a fresh # install derives a legal, team-specific suffix only after the Team ID validates. @@ -1188,19 +996,87 @@ _resolve_signing_identity() { SIGNING_ERROR="Invalid WDA_BUNDLE_ID '$WDA_BUNDLE_ID'. Use dot-separated ASCII letters, digits, dots, and hyphens only." return 1 fi - if ! _valid_wda_ref "$WDA_REF"; then - SIGNING_ERROR="Invalid WDA_REF '$WDA_REF'. Pin one exact 40-character upstream commit SHA." + return 0 +} + +# BEGIN runner source helpers. +# The sources are built (and their build scripts run) as this user, so they +# must be this user's own files that nobody else can change. +RUNNER_SOURCE_ERROR="" +_runner_source_valid() { + local dir meta + RUNNER_SOURCE_ERROR="" + case "$RUNNER_SRC" in + /*) ;; + *) RUNNER_SOURCE_ERROR="IPU_RUNNER_SRC must be an absolute path (got '$RUNNER_SRC')"; return 1 ;; + esac + case "$RUNNER_SRC" in + *[[:space:]]*|*$'\n'*) + RUNNER_SOURCE_ERROR="the runner source path must not contain whitespace: $RUNNER_SRC" + return 1 + ;; + esac + if [ ! -f "$RUNNER_PROJECT/project.pbxproj" ]; then + RUNNER_SOURCE_ERROR="device runner sources are missing: $RUNNER_PROJECT + Rerun the installer (it lays them down at $RUNNER_DEFAULT_SRC), or set + IPU_RUNNER_SRC=/runner when working from a checkout." return 1 fi - WDA_REF="$(printf '%s' "$WDA_REF" | tr 'ABCDEF' 'abcdef')" - if [ "$WDA_REF" = "$DEFAULT_WDA_REF" ]; then - WDA_REF_LABEL="$DEFAULT_WDA_REF_TAG" - else - WDA_REF_LABEL="custom pin" - fi + for dir in "$RUNNER_SRC" "$RUNNER_SRC/IPhoneUseRunner"; do + if [ -L "$dir" ]; then + RUNNER_SOURCE_ERROR="refusing a symlinked runner source directory: $dir" + return 1 + fi + meta="$(/usr/bin/stat -f '%u %Lp' "$dir" 2>/dev/null)" || { + RUNNER_SOURCE_ERROR="cannot inspect $dir" + return 1 + } + if [ "${meta%% *}" != "$UID_NUM" ]; then + RUNNER_SOURCE_ERROR="runner sources are not owned by this user: $dir" + return 1 + fi + case "${meta##* }" in + *[2367][0-7]|*[0-7][2367]) + RUNNER_SOURCE_ERROR="runner sources are writable by other users: $dir" + return 1 + ;; + esac + done return 0 } +# Content hash of the runner sources: every regular file under IPhoneUseRunner +# (Xcode's per-user state excluded), path and bytes, in sorted order. It keys +# the product cache, so an upgraded runner is never mistaken for the old build. +_runner_source_hash() { + python3 - "$RUNNER_SRC/IPhoneUseRunner" <<'PY_RUNNER_HASH' +import hashlib +import os +import sys + +root = sys.argv[1] +digest = hashlib.sha256() +entries = [] +for directory, dirs, files in os.walk(root, followlinks=False): + dirs[:] = sorted(d for d in dirs if d != "xcuserdata" and not d.startswith(".")) + for name in files: + if name.startswith("."): + continue + full = os.path.join(directory, name) + if os.path.islink(full) or not os.path.isfile(full): + continue + entries.append(os.path.relpath(full, root)) +if not entries: + raise SystemExit(1) +for relative in sorted(entries): + digest.update(relative.encode() + b"\0") + with open(os.path.join(root, relative), "rb") as handle: + digest.update(hashlib.sha256(handle.read()).digest()) +print(digest.hexdigest()) +PY_RUNNER_HASH +} +# END runner source helpers. + _wait_job_gone() { local label="$1" local _ @@ -1261,8 +1137,8 @@ _plist_set_env() { fi } -# Install the same dedicated WDA supervisor used by POST /agent/mode. Keeping -# setup-wda.sh in its own launchd job means daemon restarts cannot reap WDA, and +# Install the same dedicated runner supervisor used by POST /agent/mode. Keeping +# setup-wda.sh in its own launchd job means daemon restarts cannot reap the runner, and # KeepAlive can rebuild after sleep/USB/CoreDevice failures. _install_wda_supervisor() { local env_block="" @@ -1276,7 +1152,7 @@ _install_wda_supervisor() { for key in \ WDA_KEEPALIVE PATH WDA_UDID WDA_TEAM_ID WDA_BUNDLE_ID \ - WDA_DIR WDA_REF WDA_RUNNER_ICON WDA_PORT MJPEG_PORT WDA_ALLOW_LAN \ + IPU_RUNNER_SRC WDA_PORT MJPEG_PORT WDA_ALLOW_LAN \ WDA_ASC_KEY_PATH WDA_ASC_KEY_ID WDA_ASC_ISSUER_ID \ PHONE_REMOTE_INSTANCE PHONE_REMOTE_STATE_DIR do @@ -1292,9 +1168,12 @@ _install_wda_supervisor() { WDA_UDID) value="$WDA_UDID" ;; WDA_TEAM_ID) value="$TEAM_ID" ;; WDA_BUNDLE_ID) value="$WDA_BUNDLE_ID" ;; - WDA_DIR) value="$WDA_DIR" ;; - WDA_REF) value="$WDA_REF" ;; - WDA_RUNNER_ICON) value="$WDA_RUNNER_ICON" ;; + # Only a non-default source is persisted, so an install keeps + # following the copy install.sh refreshes. + IPU_RUNNER_SRC) + [ "$RUNNER_SRC" != "$RUNNER_DEFAULT_SRC" ] || continue + value="$RUNNER_SRC" + ;; WDA_PORT) value="$WDA_PORT" ;; MJPEG_PORT) value="$MJPEG_PORT" ;; WDA_ALLOW_LAN) value="${WDA_ALLOW_LAN:-}" ;; @@ -1335,41 +1214,41 @@ PLIST then rm -f "$WDA_AGENT_STAGED_PLIST" WDA_AGENT_STAGED_PLIST="" - warn "could not stage the WDA supervisor plist" + warn "could not stage the runner supervisor plist" return 1 fi if ! chmod 600 "$WDA_AGENT_STAGED_PLIST" \ || ! plutil -lint "$WDA_AGENT_STAGED_PLIST" >/dev/null 2>&1; then rm -f "$WDA_AGENT_STAGED_PLIST" WDA_AGENT_STAGED_PLIST="" - warn "generated WDA supervisor plist is invalid" + warn "generated runner supervisor plist is invalid" return 1 fi if ! mv -f "$WDA_AGENT_STAGED_PLIST" "$WDA_AGENT_PLIST"; then rm -f "$WDA_AGENT_STAGED_PLIST" WDA_AGENT_STAGED_PLIST="" - warn "could not atomically install the WDA supervisor plist" + warn "could not atomically install the runner supervisor plist" return 1 fi WDA_AGENT_STAGED_PLIST="" launchctl bootout "$GUI_DOMAIN/$WDA_AGENT_LABEL" 2>/dev/null || true if ! _wait_job_gone "$WDA_AGENT_LABEL"; then - warn "old WDA supervisor did not finish stopping" + warn "old runner supervisor did not finish stopping" return 1 fi - # A prior installer may have persistently disabled the legacy WDA label. + # A prior installer may have persistently disabled the supervisor label. # Clear that policy before bootstrap; enabling afterward is too late. launchctl enable "$GUI_DOMAIN/$WDA_AGENT_LABEL" 2>/dev/null || true if ! launchctl bootstrap "$GUI_DOMAIN" "$WDA_AGENT_PLIST" 2>/dev/null; then - warn "could not bootstrap WDA supervisor: $WDA_AGENT_PLIST" + warn "could not bootstrap the runner supervisor: $WDA_AGENT_PLIST" return 1 fi if launchctl print "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1; then - ok "WDA supervisor job loaded: $GUI_DOMAIN/$WDA_AGENT_LABEL" + ok "runner supervisor job loaded: $GUI_DOMAIN/$WDA_AGENT_LABEL" return 0 fi - warn "launchctl accepted the WDA plist but the job is not visible" + warn "launchctl accepted the runner plist but the job is not visible" return 1 } @@ -1712,15 +1591,6 @@ _cleanup_on_exit() { local supervisor_restore_ok=1 local daemon_restore_ok=1 set +e - if [ "${WDA_ICON_MUTATION_ACTIVE:-0}" = "1" ]; then - warn "setup stopped during runner-icon injection — restoring the pristine signed app" - _restore_wda_icon_app || cleanup_failed=1 - fi - if [ "${WDA_ICON_MUTATION_ACTIVE:-0}" != "1" ]; then - _cleanup_wda_icon_work_dir - else - warn "runner-icon recovery backup retained at: $WDA_ICON_BACKUP_PATH" - fi if [ "$status" -ne 0 ]; then if [ "${STARTED_MJPEG_RELAY:-0}" = "1" ]; then _stop_managed_process "$MJPEG_RELAY_PID_FILE" "$LEGACY_MJPEG_EXPECTED" mjpeg \ @@ -1757,12 +1627,12 @@ _cleanup_on_exit() { if [ "$status" -ne 0 ] \ && [ "$SUPERVISOR_TRANSACTION_ACTIVE" = "1" ] \ && [ "$SUPERVISOR_HANDOFF_COMPLETE" != "1" ]; then - warn "setup failed — restoring the prior WDA supervisor file and loaded state" + warn "setup failed — restoring the prior runner supervisor file and loaded state" launchctl bootout "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1 || true if ! _wait_job_gone "$WDA_AGENT_LABEL"; then supervisor_restore_ok=0 cleanup_failed=1 - warn "new WDA supervisor did not fully stop during rollback" + warn "new runner supervisor did not fully stop during rollback" fi if [ "$PREVIOUS_SUPERVISOR_PLIST_PRESENT" = "1" ]; then if ! _restore_backup_file "$WDA_AGENT_ROLLBACK_PLIST" \ @@ -1789,12 +1659,12 @@ _cleanup_on_exit() { || ! launchctl print "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1; then supervisor_restore_ok=0 cleanup_failed=1 - warn "prior WDA supervisor plist was restored, but its loaded state was not" + warn "prior runner supervisor plist was restored, but its loaded state was not" fi else supervisor_restore_ok=0 cleanup_failed=1 - warn "prior WDA supervisor was not restarted because its files were not fully restored" + warn "prior runner supervisor was not restarted because its files were not fully restored" fi fi _restore_job_policy "$WDA_AGENT_LABEL" "$PREVIOUS_SUPERVISOR_DISABLED" \ @@ -1889,7 +1759,7 @@ _warp_check() { fi _setstatus prereq warp "WARP is connected and breaks CoreDevice" die "$WARP_PREFLIGHT_ERROR - WARP would otherwise invalidate the just-verified WDA session and create a restart loop. + WARP would otherwise invalidate the just-verified runner session and create a restart loop. See docs/wda-setup.html pitfall (WARP)." } @@ -1909,13 +1779,14 @@ if [ "${IPHONE_USE_INTERNAL_TEST_WARP_PREFLIGHT_ONLY:-0}" = "1" ]; then fi # BEGIN Xcode compatibility helpers. -# Xcode 27 dropped iOS deployment targets below 15.0, while the pinned WDA -# project still says 12.0, so `xcodebuild` refuses to build it at all. The -# runner argv is an identity checked by `_runner_signature_valid` and -# `_command_matches_expected`, so the fix must not add build settings to the -# command line. Instead setup writes an xcconfig in the state directory and -# exports XCODE_XCCONFIG_FILE, which every xcodebuild this script starts (and -# the runner it leaves behind) inherits. The pinned checkout is not edited. +# A future Xcode can drop iOS deployment targets the runner project still names +# (Xcode 27 did exactly that to WebDriverAgent's 12.0), and `xcodebuild` then +# refuses to build at all. The runner argv is an identity checked by +# `_runner_signature_valid` and `_command_matches_expected`, so the fix must not +# add build settings to the command line. Instead setup writes an xcconfig in +# the state directory and exports XCODE_XCCONFIG_FILE, which every xcodebuild +# this script starts (and the runner it leaves behind) inherits. The runner +# sources are not edited. WDA_XCCONFIG_FILE="$STATE_DIR/wda-xcode-compat.xcconfig" WDA_DEPLOYMENT_TARGET_OVERRIDE="" WDA_IOS_SDK_VERSION="" @@ -1972,9 +1843,10 @@ _ios_sdk_min_deployment_target() { [ -n "$minimum" ] && printf '%s\n' "$minimum" } -# Prints " " IPHONEOS_DEPLOYMENT_TARGET in the WDA project. +# Prints " " IPHONEOS_DEPLOYMENT_TARGET in the runner project +# ($1 is the .xcodeproj). _wda_project_deployment_targets() { - local pbxproj="$1/WebDriverAgent.xcodeproj/project.pbxproj" value low="" high="" + local pbxproj="$1/project.pbxproj" value low="" high="" [ -f "$pbxproj" ] || return 1 while IFS= read -r value; do _valid_os_version "$value" || continue @@ -2010,7 +1882,7 @@ _wda_required_deployment_target() { _prepare_wda_xcconfig() { local target inherited="" tmp WDA_IOS_SDK_VERSION="$(_ios_sdk_version || true)" - target="$(_wda_required_deployment_target "$WDA_DIR")" + target="$(_wda_required_deployment_target "$RUNNER_PROJECT")" if [ -L "$WDA_XCCONFIG_FILE" ]; then warn "refusing to use a symlinked xcconfig: $WDA_XCCONFIG_FILE" return 1 @@ -2037,7 +1909,7 @@ _prepare_wda_xcconfig() { tmp="$(mktemp "$STATE_DIR/wda-xcode-compat.xcconfig.new.XXXXXX")" || return 1 { printf '%s\n' "// Generated by setup-wda.sh on every run; edits are overwritten." - printf '%s\n' "// The selected Xcode rejects the pinned WDA project's iOS deployment target." + printf '%s\n' "// The selected Xcode rejects the runner project's iOS deployment target." [ -z "$inherited" ] || printf '#include? "%s"\n' "$inherited" printf 'IPHONEOS_DEPLOYMENT_TARGET = %s\n' "$target" } > "$tmp" || { rm -f "$tmp"; return 1; } @@ -2049,21 +1921,16 @@ _prepare_wda_xcconfig() { export XCODE_XCCONFIG_FILE="$WDA_XCCONFIG_FILE" } -# `xcodebuild test` regenerates the runner's Info.plist from the XCTRunner -# template on every run that considers the product stale — it wipes the -# injected icon keys AND does not re-sign, so installd rejects the bundle with -# 0xe8008001 (hardware-verified: Info.plist was 180s newer than the signature). -# `test-without-building` installs the already-built product as-is, so the -# injection survives. Only used when an injection actually happened; without -# one the original `test` argv is kept untouched. +# The runner launches from its built product with `test-without-building +# -xctestrun`, which installs the product as built (a plain `xcodebuild test` +# would rebuild on every launch). # # xcodebuild names the file after the SDK it built against -# (WebDriverAgentRunner_iphoneos27.0-arm64.xctestrun) and never deletes the -# one an older Xcode left behind, so after an Xcode upgrade the directory holds -# two. When there is more than one, take the one for the SDK this build used -# ($2, from the build settings; else the selected SDK). Anything other than -# exactly one match stays ambiguous and fails: the caller must then not run the -# normal test action over a hand-signed bundle. +# (IPhoneUseRunner_iphoneos27.0-arm64.xctestrun) and never deletes the one an +# older Xcode left behind, so after an Xcode upgrade the directory holds two. +# When there is more than one, take the one for the SDK this build used ($2; +# else the selected SDK). Anything other than exactly one match stays ambiguous +# and fails rather than launching a stale product. _resolve_xctestrun() { local products_dir="$1" sdk="${2:-}" parent match count [ -n "$products_dir" ] || return 1 @@ -2091,30 +1958,27 @@ _resolve_xctestrun() { # Read-only doctor checks for the two ways an Xcode upgrade wedged setup. _doctor_xcode_compat() { local fail=0 major minimum targets required current dir runs sdk matches name - local derived="$HOME/Library/Developer/Xcode/DerivedData" major="$(_xcode_major)" minimum="$(_ios_sdk_min_deployment_target || true)" - targets="$(_wda_project_deployment_targets "$WDA_DIR" || true)" - required="$(_wda_required_deployment_target "$WDA_DIR")" + targets="$(_wda_project_deployment_targets "$RUNNER_PROJECT" || true)" + required="$(_wda_required_deployment_target "$RUNNER_PROJECT")" if [ -n "$required" ]; then current="" if [ -f "$WDA_XCCONFIG_FILE" ] && [ ! -L "$WDA_XCCONFIG_FILE" ]; then current="$(sed -n 's/^IPHONEOS_DEPLOYMENT_TARGET = \([0-9.]*\)$/\1/p' "$WDA_XCCONFIG_FILE" | tail -1)" fi if [ -f "$SELF_INSTALL" ] && ! grep -q 'XCODE_XCCONFIG_FILE' "$SELF_INSTALL" 2>/dev/null; then - warn "X Xcode ${major:-?} supports iOS deployment targets from $minimum, but WDA sets ${targets%% *}; the installed $SELF_INSTALL predates the override, so KeepAlive builds fail. Rerun setup to install the fixed script" + warn "X Xcode ${major:-?} supports iOS deployment targets from $minimum, but the runner project sets ${targets%% *}; the installed $SELF_INSTALL predates the override, so KeepAlive builds fail. Rerun setup to install the fixed script" fail=1 elif [ "$current" = "$required" ]; then ok "Xcode ${major:-?} deployment target override: IPHONEOS_DEPLOYMENT_TARGET = $required ($WDA_XCCONFIG_FILE)" else - warn "~ Xcode ${major:-?} supports iOS deployment targets from $minimum, but WDA sets ${targets%% *}; no override is in place yet. Setup writes $WDA_XCCONFIG_FILE (IPHONEOS_DEPLOYMENT_TARGET = $required) on its next run" + warn "~ Xcode ${major:-?} supports iOS deployment targets from $minimum, but the runner project sets ${targets%% *}; no override is in place yet. Setup writes $WDA_XCCONFIG_FILE (IPHONEOS_DEPLOYMENT_TARGET = $required) on its next run" fi - elif [ -n "$major" ] && [ "$major" -ge 27 ] && [ -z "$targets" ]; then - warn "~ Xcode $major requires iOS deployment target ${minimum:-15.0}+; the WDA checkout is not present yet, so setup will decide the override after checking it out" fi sdk="$(_ios_sdk_version || true)" - for dir in "$derived"/WebDriverAgent-*/Build/Products; do + for dir in "$RUNNER_DERIVED_DATA/Build/Products"; do [ -d "$dir" ] || continue runs="$(find "$dir" -maxdepth 1 -name '*.xctestrun' 2>/dev/null | sort)" [ "$(printf '%s' "$runs" | awk 'NF { c++ } END { print c + 0 }')" -gt 1 ] || continue @@ -2130,7 +1994,7 @@ _doctor_xcode_compat() { if [ "$matches" = "1" ]; then printf ' %s\n' "setup uses the one for the current SDK (iphoneos$sdk); the others are stale and can be deleted" else - printf ' %s\n' "none is uniquely for the current SDK (${sdk:+iphoneos$sdk}); setup rebuilds the runner and skips the custom icon until the stale files are deleted" + printf ' %s\n' "none is uniquely for the current SDK (${sdk:+iphoneos$sdk}); setup cannot pick a product to launch until the stale files are deleted" fi done return $fail @@ -2140,7 +2004,7 @@ _doctor_xcode_compat() { # One-shot preflight: report the FIRST blocker as a checklist instead of a blind # wait loop. `setup-wda.sh doctor` cmd_doctor() { - info "WDA preflight" + info "Device runner preflight" local fail=0 local xcode_version if [ -d "$STATE_DIR" ]; then @@ -2163,11 +2027,26 @@ cmd_doctor() { else ok "Runner bundle ID: $WDA_BUNDLE_ID (explicit or persisted)" fi - ok "WDA source pin: $WDA_REF_LABEL $WDA_REF" else warn "X $SIGNING_ERROR" fail=1 fi + if _runner_source_valid; then + local source_hash + source_hash="$(_runner_source_hash 2>/dev/null || true)" + if [ -n "$source_hash" ]; then + ok "Device runner source: $RUNNER_SRC (sha256 ${source_hash:0:12})" + else + warn "X device runner sources could not be read: $RUNNER_SRC" + fail=1 + fi + else + warn "X $RUNNER_SOURCE_ERROR" + fail=1 + fi + if [ -d "$WDA_DIR/.git" ]; then + warn "~ a WebDriverAgent checkout from an earlier release is still at $WDA_DIR; nothing uses it any more (uninstall.sh removes it when it can prove ownership)" + fi if _valid_port "$WDA_PORT" && _valid_port "$MJPEG_PORT" \ && [ "$WDA_PORT" != "$MJPEG_PORT" ]; then ok "Loopback ports: control $WDA_PORT, video $MJPEG_PORT" @@ -2193,7 +2072,7 @@ cmd_doctor() { usb="$(_usb_udids)" usb_count="$(printf '%s' "$usb" | wc -w | tr -d '[:space:]')" if [ "$WDA_ALLOW_LAN" = "0" ] && [ -z "$usb" ]; then - warn "X default Direct/WDA requires an iPhone connected over USB" + warn "X the default device layer requires an iPhone connected over USB" fail=1 elif [ "$WDA_ALLOW_LAN" = "0" ] && [ "$usb_count" -gt 1 ] \ && [ -z "${WDA_UDID:-}" ]; then @@ -2224,7 +2103,7 @@ cmd_doctor() { fi if _valid_port "$WDA_PORT"; then curl -s -m 4 "http://127.0.0.1:$WDA_PORT/status" >/dev/null 2>&1 \ - && ok "WDA already serving on 127.0.0.1:$WDA_PORT" + && ok "device runner already serving on 127.0.0.1:$WDA_PORT" fi if [ "$fail" = 0 ]; then ok "preflight checks passed; build, signing, device trust, and launch still require setup verification" @@ -2331,12 +2210,17 @@ _expected_role_valid() { # The optional ASC suffix must be complete and in the order emitted by # _prepare_xcodebuild_args. Do not replace it with an arbitrary-arguments tail: # these signatures authorize signalling the recorded process. +# +# The first form is the device runner this script starts. The two +# WebDriverAgent forms are what releases before it started; they stay +# recognised so the first setup/stop/pause after an upgrade can still stop a +# WDA runner the previous release left behind. _runner_signature_valid() { local asc_suffix _safe_expected "$1" || return 1 asc_suffix=' -authenticationKeyPath /[^|[:cntrl:]]+\.p8 -authenticationKeyID [A-Za-z0-9]+ -authenticationKeyIssuerID [A-Za-z0-9-]+ -allowProvisioningDeviceRegistration' printf '%s\n' "$1" | LC_ALL=C grep -Eq \ - "^(/[^ ]*/)?xcodebuild (-project WebDriverAgent\\.xcodeproj -scheme WebDriverAgentRunner -destination platform=iOS,id=[0-9A-Fa-f-]+ -allowProvisioningUpdates DEVELOPMENT_TEAM=[A-Z0-9]{10} PRODUCT_BUNDLE_IDENTIFIER=[A-Za-z0-9.-]+ test($asc_suffix)?|-destination platform=iOS,id=[0-9A-Fa-f-]+ test-without-building -xctestrun /[^ ]+/WebDriverAgentRunner_[^ /]+\\.xctestrun( -allowProvisioningUpdates$asc_suffix)?)$" + "^(/[^ ]*/)?xcodebuild (-destination platform=iOS,id=[0-9A-Fa-f-]+ test-without-building -xctestrun /[^ ]+/IPhoneUseRunner_[^ /]+\\.xctestrun -only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe( -allowProvisioningUpdates$asc_suffix)?|-project WebDriverAgent\\.xcodeproj -scheme WebDriverAgentRunner -destination platform=iOS,id=[0-9A-Fa-f-]+ -allowProvisioningUpdates DEVELOPMENT_TEAM=[A-Z0-9]{10} PRODUCT_BUNDLE_IDENTIFIER=[A-Za-z0-9.-]+ test($asc_suffix)?|-destination platform=iOS,id=[0-9A-Fa-f-]+ test-without-building -xctestrun /[^ ]+/WebDriverAgentRunner_[^ /]+\\.xctestrun( -allowProvisioningUpdates$asc_suffix)?)$" } _command_matches_expected() { @@ -2380,16 +2264,11 @@ _command_matches_expected() { # exact equality with the full command, including the key path. base_command="${command%% -authenticationKeyPath *}" base_command="${base_command% -allowProvisioningUpdates}" + # PID-only records predate the device runner: they name a + # WebDriverAgent runner in either of its two launch forms (the + # xctestrun form cannot be combined with -project/-scheme, so it + # shares only the destination with the `test` form). legacy_argv="xcodebuild -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination platform=iOS,id=$target_udid -allowProvisioningUpdates DEVELOPMENT_TEAM=$team_id PRODUCT_BUNDLE_IDENTIFIER=$bundle_id" - # A runner started with an injected icon runs `test-without-building - # -xctestrun ` instead of `test`. Both are ours; anything else - # is not. Matching only `test` would leave `pause`/`stop` unable to - # recognise (and therefore unable to stop) an icon-carrying runner. - # A runner started with an injected icon runs the xctestrun form, - # which xcodebuild forbids combining with -project/-scheme — so it - # shares only the destination with the normal form. Both are ours; - # matching just one would leave `pause`/`stop` unable to recognise - # (and therefore unable to stop) the other. xctestrun_argv="xcodebuild -destination platform=iOS,id=$target_udid test-without-building -xctestrun" case "$base_command" in "$legacy_argv test"|*/"$legacy_argv test") return 0 ;; @@ -2663,7 +2542,7 @@ cmd_stop() { warn "setup state is not initialized at $STATE_DIR; there are no PID-owned processes to stop safely" return 1 fi - info "Stopping the dedicated WDA supervisor and its managed processes" + info "Stopping the dedicated runner supervisor and its managed processes" launchctl bootout "$GUI_DOMAIN/$WDA_AGENT_LABEL" 2>/dev/null || true _wait_job_gone "$WDA_AGENT_LABEL" || failed=1 _stop_managed_process "$RUNNER_PID_FILE" "$LEGACY_RUNNER_EXPECTED" runner || failed=1 @@ -2676,16 +2555,16 @@ cmd_stop() { warn "stop was not fully verified; no unowned process was killed" return 1 fi - ok "WDA supervisor and all PID-verified managed processes stopped" + ok "runner supervisor and all PID-verified managed processes stopped" } cmd_pause() { local failed=0 if [ ! -d "$STATE_DIR" ]; then - warn "setup state is not initialized at $STATE_DIR; there is no managed WDA stack to pause" + warn "setup state is not initialized at $STATE_DIR; there is no managed runner stack to pause" return 1 fi - info "Pausing managed WDA and giving the phone back to the user" + info "Pausing the managed device runner and giving the phone back to the user" # Disable the exact launchd label before bootout so KeepAlive cannot race # the PID-verified shutdown. Never use pkill: another xcodebuild or relay # may belong to the user rather than this setup. @@ -2705,7 +2584,7 @@ cmd_pause() { warn "pause was not fully verified; no process without a matching PID/argv record was killed" return 1 fi - ok "WDA paused: supervisor disabled and all PID-verified runner/relay processes stopped" + ok "device runner paused: supervisor disabled and all PID-verified runner/relay processes stopped" printf ' Resume: %s resume\n' "$SELF_INSTALL" } @@ -2717,7 +2596,7 @@ cmd_resume() { fi if ! _marker_file_secure "$WDA_AGENT_PLIST" \ || ! plutil -lint "$WDA_AGENT_PLIST" >/dev/null 2>&1; then - warn "managed WDA supervisor plist is missing or unsafe: $WDA_AGENT_PLIST; run setup again" + warn "managed runner supervisor plist is missing or unsafe: $WDA_AGENT_PLIST; run setup again" return 1 fi label="$(/usr/libexec/PlistBuddy -c 'Print :Label' "$WDA_AGENT_PLIST" 2>/dev/null || true)" @@ -2727,29 +2606,29 @@ cmd_resume() { || [ "$interpreter" != "/bin/bash" ] \ || [ "$program" != "$SELF_INSTALL" ] \ || [ ! -x "$SELF_INSTALL" ]; then - warn "managed WDA supervisor identity is not the expected setup helper; run setup again" + warn "managed runner supervisor identity is not the expected setup helper; run setup again" return 1 fi - info "Resuming the managed WDA supervisor" + info "Resuming the managed runner supervisor" _reset_keepalive_retry || return 1 if ! launchctl enable "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1; then - warn "could not enable the WDA supervisor" + warn "could not enable the runner supervisor" return 1 fi if ! launchctl print "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1 \ && ! launchctl bootstrap "$GUI_DOMAIN" "$WDA_AGENT_PLIST" >/dev/null 2>&1; then launchctl disable "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1 || true - warn "could not bootstrap the WDA supervisor; it remains paused" + warn "could not bootstrap the runner supervisor; it remains paused" return 1 fi if [ "$(_job_disabled_state "$WDA_AGENT_LABEL" 2>/dev/null || true)" != "0" ] \ || ! launchctl print "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1; then launchctl disable "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1 || true - warn "resume was not verified; the WDA supervisor remains paused" + warn "resume was not verified; the runner supervisor remains paused" return 1 fi - ok "WDA resume requested; lock-screen failures will retry with quiet backoff" + ok "device runner resume requested; lock-screen failures will retry with quiet backoff" printf ' Status: %s status\n' "$SELF_INSTALL" printf ' Log : %s\n' "$WDA_AGENT_LOG" } @@ -2766,7 +2645,7 @@ cmd_status() { return 1 fi if [ "$(_job_disabled_state "$WDA_AGENT_LABEL" 2>/dev/null || true)" = "1" ]; then - warn "WDA is paused; run $SELF_INSTALL resume before the next agent session" + warn "the device runner is paused; run $SELF_INSTALL resume before the next agent session" return 1 fi if ! command -v lsof >/dev/null 2>&1; then @@ -2774,15 +2653,15 @@ cmd_status() { return 1 fi if launchctl print "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1; then - ok "WDA supervisor loaded: $GUI_DOMAIN/$WDA_AGENT_LABEL" + ok "runner supervisor loaded: $GUI_DOMAIN/$WDA_AGENT_LABEL" else - warn "WDA supervisor not loaded" + warn "runner supervisor not loaded" failed=1 fi if _validate_pid_record "$RUNNER_PID_FILE" "$LEGACY_RUNNER_EXPECTED" runner; then - ok "PID-verified WDA runner alive: $VALIDATED_PID" + ok "PID-verified device runner alive: $VALIDATED_PID" else - warn "WDA runner PID record is absent, stale, or does not match its process" + warn "device runner PID record is absent, stale, or does not match its process" failed=1 fi if _verify_loopback_listener "$RELAY_PID_FILE" "$LEGACY_RELAY_EXPECTED" relay "$WDA_PORT"; then @@ -2798,9 +2677,9 @@ cmd_status() { failed=1 fi if curl -fsS -m 4 "http://127.0.0.1:$WDA_PORT/status" >/dev/null 2>&1; then - ok "WDA /status reachable through the loopback relay" + ok "device runner /status reachable through the loopback relay" else - warn "WDA /status is not reachable" + warn "device runner /status is not reachable" failed=1 fi return "$failed" @@ -2824,24 +2703,24 @@ _status_begin_run || die "could not initialize the setup status owner" # A manual setup temporarily owns the lifecycle so an already-running KeepAlive # job cannot race its build or relays. A successful run installs and bootstraps -# the same supervisor again after WDA has been proven reachable. +# the same supervisor again after the runner has been proven reachable. if [ "${WDA_KEEPALIVE:-0}" != "1" ]; then SUPERVISOR_TRANSACTION_ACTIVE=1 PREVIOUS_SUPERVISOR_DISABLED="$(_job_disabled_state "$WDA_AGENT_LABEL")" \ - || die "could not snapshot the WDA supervisor's launchd disabled policy" + || die "could not snapshot the runner supervisor's launchd disabled policy" if [ -f "$WDA_AGENT_PLIST" ]; then PREVIOUS_SUPERVISOR_PLIST_PRESENT=1 cp -p "$WDA_AGENT_PLIST" "$WDA_AGENT_ROLLBACK_PLIST" \ - || die "could not save the existing WDA supervisor plist for rollback" + || die "could not save the existing runner supervisor plist for rollback" fi if launchctl print "$GUI_DOMAIN/$WDA_AGENT_LABEL" >/dev/null 2>&1; then [ "$PREVIOUS_SUPERVISOR_PLIST_PRESENT" = "1" ] \ - || die "WDA supervisor is loaded but its plist is missing; refusing an unrecoverable handoff" - info "Pausing existing WDA supervisor for interactive setup" + || die "runner supervisor is loaded but its plist is missing; refusing an unrecoverable handoff" + info "Pausing the existing runner supervisor for interactive setup" PREVIOUS_SUPERVISOR_LOADED=1 launchctl bootout "$GUI_DOMAIN/$WDA_AGENT_LABEL" 2>/dev/null || true _wait_job_gone "$WDA_AGENT_LABEL" \ - || die "existing WDA supervisor did not stop; refusing to race it" + || die "existing runner supervisor did not stop; refusing to race it" fi fi @@ -2878,7 +2757,6 @@ if ! _system_proxy_check; then _setstatus prereq proxy "macOS system proxy is enabled but unusable" die "$SYSTEM_PROXY_ERROR" fi -command -v git >/dev/null 2>&1 || die "git is required to fetch the pinned WebDriverAgent source" command -v lsof >/dev/null 2>&1 || die "lsof is required to verify exclusive loopback relay ownership" XCODEBUILD_BIN="$(command -v xcodebuild || true)" [ -n "$XCODEBUILD_BIN" ] \ @@ -2902,7 +2780,6 @@ if [ "$BUNDLE_ID_DERIVED" = "1" ]; then else ok "Runner bundle ID: $WDA_BUNDLE_ID (explicit or persisted)" fi -ok "WDA source pin: $WDA_REF_LABEL $WDA_REF" # ── 1. Resolve device ───────────────────────────────────────────────────────── info "Resolving target device" @@ -2944,8 +2821,8 @@ fi if [ "$WDA_ALLOW_LAN" = "0" ]; then if [ -z "${WDA_UDID:-}" ]; then _setstatus prereq usb "no USB iPhone is connected" - die "Direct/WDA defaults to USB, but no USB iPhone was found. - Plug in and unlock one iPhone, or set WDA_UDID=; no source was fetched or build started." + die "the device layer defaults to USB, but no USB iPhone was found. + Plug in and unlock one iPhone, or set WDA_UDID=; no build was started." fi if ! _target_on_usb; then _setstatus prereq usb "the configured iPhone is not connected over USB" @@ -2953,132 +2830,19 @@ if [ "$WDA_ALLOW_LAN" = "0" ]; then Plug in that iPhone, or set WDA_UDID to the exact USB-connected device; refusing a slow Wi-Fi fallback." fi elif [ -z "${WDA_UDID:-}" ]; then - warn "WDA_ALLOW_LAN=1: no USB target; paired destinations will be enumerated after the pinned checkout" + warn "WDA_ALLOW_LAN=1: no USB target; paired destinations will be enumerated from the runner project" fi _instance_check_bindings "$WDA_PORT" "$MJPEG_PORT" 2>&1 \ || die "refusing to set up instance $INSTANCE_NAME (see above)" _setstatus prereq "" "prerequisites passed" -# ── 2. Clone / update WDA ───────────────────────────────────────────────────── -info "WebDriverAgent checkout" -if [ -e "$WDA_DIR" ] && [ ! -d "$WDA_DIR/.git" ]; then - die "WDA_DIR exists but is not a Git checkout: $WDA_DIR - Move it aside or set WDA_DIR to an empty managed path; no files were overwritten." -fi -if [ ! -d "$WDA_DIR/.git" ]; then - # A no-checkout clone has an index at the remote HEAD but an empty worktree, - # so `git status` reports every tracked file as deleted. That made a clean - # first setup fail our dirty-check before it could select the pinned commit. - # Check out the clone normally; we still fetch and detach at the exact pin - # below before any project source is built or executed. - git clone --filter=blob:none "$WDA_REPO" "$WDA_DIR" \ - || die "could not clone the official WebDriverAgent repository" - WDA_CHECKOUT_CREATED_THIS_RUN=1 -fi -WDA_ORIGIN="$(git -C "$WDA_DIR" remote get-url origin 2>/dev/null || true)" -case "$WDA_ORIGIN" in - https://github.com/appium/WebDriverAgent|https://github.com/appium/WebDriverAgent.git|\ - git@github.com:appium/WebDriverAgent.git|ssh://git@github.com/appium/WebDriverAgent.git) - ;; - *) - die "refusing to fetch the WDA pin from unexpected origin '$WDA_ORIGIN'. - Expected the official appium/WebDriverAgent repository; use a separate WDA_DIR for custom forks." - ;; -esac -WDA_CANONICAL_DIR="$(cd -P "$WDA_DIR" 2>/dev/null && pwd)" \ - || die "could not resolve the WDA checkout's canonical path" -case "$WDA_CANONICAL_DIR" in - *$'\n'*|*$'\r'*) die "WDA_DIR contains unsafe newline characters" ;; -esac -WDA_PREUPDATE_HEAD="$(git -C "$WDA_DIR" rev-parse HEAD 2>/dev/null || true)" -if [ "$WDA_CHECKOUT_CREATED_THIS_RUN" = "1" ]; then - WDA_MARKER_REFRESH_ALLOWED=1 -elif [ -n "$WDA_PREUPDATE_HEAD" ] \ - && _existing_marker_matches_checkout \ - "$WDA_CANONICAL_DIR" "$WDA_ORIGIN" "$WDA_PREUPDATE_HEAD"; then - WDA_MARKER_REFRESH_ALLOWED=1 -else - warn "This existing checkout has no matching setup ownership marker. - Setup will use it, but uninstall will preserve it rather than guessing ownership: - $WDA_CANONICAL_DIR" -fi -# The runner rename applied after the checkout (below) edits the tracked -# project.pbxproj, which the guard immediately after this would refuse to -# overwrite on every later run. Restore that one file when the ONLY thing -# changed in it is our own PRODUCT_NAME swap, so re-runs work while any other -# local edit is still reported and refused. -_restore_runner_rename() { - _rrn_pbx='WebDriverAgent.xcodeproj/project.pbxproj' - [ -n "$WDA_RUNNER_NAME" ] || return 0 - [ -n "$(git -C "$WDA_DIR" status --porcelain --untracked-files=no -- "$_rrn_pbx" 2>/dev/null)" ] || return 0 - # Bail out (leave it dirty for the guard to report) if any changed line is - # something other than the exact swap we make. - if git -C "$WDA_DIR" diff -U0 -- "$_rrn_pbx" 2>/dev/null \ - | grep -E '^[+-][^+-]' \ - | grep -qvE '^\+[[:space:]]*PRODUCT_NAME = '"$WDA_RUNNER_NAME"';$|^-[[:space:]]*PRODUCT_NAME = "\$\(TARGET_NAME\)";$'; then - return 0 - fi - git -C "$WDA_DIR" checkout -- "$_rrn_pbx" 2>/dev/null || true -} -_restore_runner_rename -if ! WDA_TRACKED_CHANGES="$(git -C "$WDA_DIR" status --porcelain --untracked-files=no 2>/dev/null)"; then - die "could not inspect tracked changes in $WDA_DIR" -fi -if [ -n "$WDA_TRACKED_CHANGES" ]; then - printf '%s\n' "$WDA_TRACKED_CHANGES" | sed 's/^/ /' >&2 - die "tracked changes exist in $WDA_DIR; refusing to overwrite them. - Commit, stash, or choose a separate WDA_DIR, then rerun." -fi -# Already holding the pinned commit (every reconnect after the first): no -# network round trip, and a reconnect keeps working offline. -if [ "$(git -C "$WDA_DIR" rev-parse --verify --quiet "$WDA_REF^{commit}" 2>/dev/null)" != "$WDA_REF" ]; then - git -C "$WDA_DIR" fetch --depth 1 origin "$WDA_REF" \ - || die "could not fetch pinned WDA commit $WDA_REF ($WDA_REF_LABEL)" -fi -FETCHED_WDA_COMMIT="$(git -C "$WDA_DIR" rev-parse --verify "$WDA_REF^{commit}" 2>/dev/null || true)" -[ "$FETCHED_WDA_COMMIT" = "$WDA_REF" ] \ - || die "fetched WDA object did not resolve to the required commit $WDA_REF" -git -C "$WDA_DIR" checkout --detach --quiet "$WDA_REF" \ - || die "could not detach the WDA checkout at $WDA_REF; inspect untracked path conflicts" -WDA_COMMIT="$(git -C "$WDA_DIR" rev-parse HEAD 2>/dev/null || true)" -[ "$WDA_COMMIT" = "$WDA_REF" ] \ - || die "WDA checkout verification failed: expected $WDA_REF, found ${WDA_COMMIT:-nothing}" -WDA_COMMIT_SHORT="$(printf '%.12s' "$WDA_COMMIT")" -ok "Pinned WDA source: $WDA_REF_LABEL $WDA_COMMIT_SHORT at $WDA_DIR" - -# Relabel the runner app that lands on the user's home screen (issue #64). -# Xcode synthesises the runner as "-Runner" from its own template -# and ignores the test target's Info.plist, so there is no plist lever — -# hardware-verified: INFOPLIST_KEY_CFBundleDisplayName is NOT injected into the -# generated .xctrunner. Patch ONLY the Runner target's build configurations, -# identified by their INFOPLIST_FILE; passing PRODUCT_NAME= on the xcodebuild -# command line instead would apply to every target and rename WebDriverAgentLib -# — the build breakage warned about at the build step below. The bundle id and -# the xcodebuild argv are untouched, so the runner identity checks still hold. -if [ -n "$WDA_RUNNER_NAME" ]; then - if WDA_RUNNER_NAME="$WDA_RUNNER_NAME" python3 - "$WDA_DIR/WebDriverAgent.xcodeproj/project.pbxproj" <<'PY' -import os, re, sys - -path = sys.argv[1] -name = os.environ["WDA_RUNNER_NAME"] -source = open(path).read() -blocks = re.split(r'(?=\t\t[0-9A-F]{24} /\* (?:Debug|Release) \*/ = \{)', source) -patched = 0 -for index, block in enumerate(blocks): - if ('INFOPLIST_FILE = WebDriverAgentRunner/Info.plist;' in block - and 'PRODUCT_NAME = "$(TARGET_NAME)";' in block): - blocks[index] = block.replace( - 'PRODUCT_NAME = "$(TARGET_NAME)";', 'PRODUCT_NAME = %s;' % name) - patched += 1 -if patched: - open(path, 'w').write(''.join(blocks)) -PY - then - ok "Runner app installs as ${WDA_RUNNER_NAME}-Runner (set WDA_RUNNER_NAME= to keep upstream's name)" - else - die "could not relabel the WDA runner target in project.pbxproj" - fi -fi +# ── 2. Device runner source ────────────────────────────────────────────────── +info "Device runner source" +_runner_source_valid || { _setstatus prereq wda "device runner sources are missing or unsafe"; die "$RUNNER_SOURCE_ERROR"; } +RUNNER_SOURCE_HASH="$(_runner_source_hash)" \ + || { _setstatus prereq wda "device runner sources could not be read"; die "could not read the device runner sources in $RUNNER_SRC"; } +ok "Device runner source: $RUNNER_SRC (sha256 ${RUNNER_SOURCE_HASH:0:12})" +mkdir -p "$RUNNER_DERIVED_DATA" || die "could not create $RUNNER_DERIVED_DATA" # Must run before the first xcodebuild that reads the project (see the Xcode # compatibility helpers): the exported XCODE_XCCONFIG_FILE reaches every build, @@ -3086,28 +2850,9 @@ fi _prepare_wda_xcconfig \ || die "could not write the Xcode compatibility xcconfig at $WDA_XCCONFIG_FILE" if [ -n "$WDA_DEPLOYMENT_TARGET_OVERRIDE" ]; then - ok "iOS deployment target raised to $WDA_DEPLOYMENT_TARGET_OVERRIDE for this Xcode (via $WDA_XCCONFIG_FILE; checkout untouched)" + ok "iOS deployment target raised to $WDA_DEPLOYMENT_TARGET_OVERRIDE for this Xcode (via $WDA_XCCONFIG_FILE; sources untouched)" fi -_runner_icon_fail() { - local reason="$1" - local recovery="" - if [ "${WDA_ICON_MUTATION_ACTIVE:-0}" = "1" ]; then - if _restore_wda_icon_app; then - recovery="; restored the pristine signed runner" - elif [ "${WDA_ICON_MUTATION_ACTIVE:-0}" = "1" ]; then - recovery="; automatic restore failed and the recovery backup was retained at $WDA_ICON_BACKUP_PATH" - else - recovery="; removed the invalid build product so xcodebuild can rebuild it" - fi - fi - if [ "${WDA_ICON_MUTATION_ACTIVE:-0}" != "1" ]; then - _cleanup_wda_icon_work_dir - fi - warn "Runner icon skipped: ${reason}${recovery}. WDA setup will continue without a custom icon." - return 1 -} - # BEGIN runner product validation. _validate_runner_bundle() { local app="$1" detail @@ -3166,21 +2911,22 @@ PY_RUNNER WDA_RUNNER_VALIDATION_ERROR="" } +# One build-for-testing of the runner. A lock screen seen during the build is +# not a build failure: RUNNER_BUILD_LOCKED hands it to the lock backoff. _run_runner_prebuild() { local build_log="$1" - _setstatus building "${_BUILD_BLOCKER:-}" "building WDA runner product" + _setstatus building "${_BUILD_BLOCKER:-}" "building the device runner" : > "$build_log" if ! ( - cd "$WDA_DIR" || exit 1 - _wda_xcodebuild -project WebDriverAgent.xcodeproj \ - -scheme WebDriverAgentRunner \ + cd "$STATE_DIR" || exit 1 + _runner_xcodebuild \ -destination "platform=iOS,id=$WDA_UDID" \ -allowProvisioningUpdates \ DEVELOPMENT_TEAM="$TEAM_ID" PRODUCT_BUNDLE_IDENTIFIER="$WDA_BUNDLE_ID" \ build-for-testing ) >>"$build_log" 2>&1; then if grep -Eiq 'Unlock iPhone to Continue|device is locked|deviceprep.*Code=-3|Code=-3.*deviceprep' "$build_log"; then - WDA_ICON_BUILD_LOCKED=1 + RUNNER_BUILD_LOCKED=1 fi WDA_RUNNER_VALIDATION_ERROR="build-for-testing failed (log: $build_log)" return 1 @@ -3199,10 +2945,11 @@ _repair_runner_if_invalid() { _setstatus building-fail wda "runner still invalid after one repair: $reason" return 1 fi - # Only the current target's app, at the build-settings-derived products - # path, may be discarded. Never clean all DerivedData or follow an app link. + # Only this instance's own runner app, at its fixed products path, may be + # discarded. Never clean all of DerivedData or follow an app link. case "$products" in /*/Build/Products/*) ;; *) return 1 ;; esac - if [ "$app" != "$products/${WDA_RUNNER_NAME:-WebDriverAgentRunner}-Runner.app" ] \ + if [ "$products" != "$RUNNER_PRODUCTS_DIR" ] \ + || [ "$app" != "$products/$RUNNER_APP_NAME" ] \ || [ -L "$app" ]; then WDA_RUNNER_VALIDATION_ERROR="refusing to remove an unowned runner product" return 1 @@ -3224,85 +2971,60 @@ PY_PRODUCT_PATH # Deleting the exact poisoned app also discards its .cstemp leftovers; # merely unlinking those files would leave missing framework contents. # The rebuild gets its own log: the failed build's log is the only record - # of which step produced the invalid product, and truncating it destroyed - # that evidence every round (#75). + # of which step produced the invalid product (#75). local repair_log="${build_log%.log}.repair.log" warn "Runner repair: keeping the failed build's log at $build_log; rebuild log at $repair_log" if ! _run_runner_prebuild "$repair_log" || ! _validate_runner_bundle "$app"; then _setstatus building-fail wda "runner repair failed: $WDA_RUNNER_VALIDATION_ERROR" return 1 fi - WDA_RUNNER_ICON_INJECTED=0 - WDA_XCTESTRUN="" _setstatus building "${_BUILD_BLOCKER:-}" "runner product rebuilt and verified" } +# Build (incrementally) and verify the runner product, then pick its .xctestrun. +# Sets RUNNER_BUILT_PRODUCTS, RUNNER_APP_PATH and WDA_XCTESTRUN on success. _ensure_launchable_runner() { - local products="${WDA_ICON_PRODUCTS_DIR:-}" settings app build_log + local products="$RUNNER_PRODUCTS_DIR" app build_log build_log="$STATE_DIR/wda-runner-product-build.log" - if [ -z "$products" ]; then - _setstatus building "${_BUILD_BLOCKER:-}" "resolving runner product before launch" - settings="$( - cd "$WDA_DIR" || exit 1 - _wda_xcodebuild -project WebDriverAgent.xcodeproj \ - -scheme WebDriverAgentRunner \ - -destination "platform=iOS,id=$WDA_UDID" \ - -allowProvisioningUpdates \ - DEVELOPMENT_TEAM="$TEAM_ID" PRODUCT_BUNDLE_IDENTIFIER="$WDA_BUNDLE_ID" \ - -showBuildSettings -json 2>>"$build_log" - )" || { WDA_RUNNER_VALIDATION_ERROR="could not resolve runner build settings"; return 1; } - products="$(printf '%s' "$settings" | python3 -c ' -import json,sys -records=json.load(sys.stdin) -paths={r.get("buildSettings",{}).get("BUILT_PRODUCTS_DIR") for r in records if r.get("target")=="WebDriverAgentRunner"} -paths.discard(None) -if len(paths)!=1: raise SystemExit(1) -print(paths.pop()) -')" || { WDA_RUNNER_VALIDATION_ERROR="ambiguous runner products directory"; return 1; } - fi - case "$products" in - /*/Build/Products/*) ;; - *) WDA_RUNNER_VALIDATION_ERROR="unexpected runner products path"; return 1 ;; - esac - app="$products/${WDA_RUNNER_NAME:-WebDriverAgentRunner}-Runner.app" + app="$products/$RUNNER_APP_NAME" + _run_runner_prebuild "$build_log" || return 1 if [ ! -e "$app" ] && [ ! -L "$app" ]; then - _run_runner_prebuild "$build_log" || return 1 + WDA_RUNNER_VALIDATION_ERROR="build-for-testing produced no $RUNNER_APP_NAME (log: $build_log)" + return 1 + fi + _repair_runner_if_invalid "$products" "$app" "$build_log" || return 1 + WDA_XCTESTRUN="$(_resolve_xctestrun "$products" "" || true)" + if [ -z "$WDA_XCTESTRUN" ]; then + WDA_RUNNER_VALIDATION_ERROR="could not resolve a unique .xctestrun next to $products (run doctor)" + return 1 fi - _repair_runner_if_invalid "$products" "$app" "$build_log" + RUNNER_BUILT_PRODUCTS="$products" + RUNNER_APP_PATH="$app" } # END runner product validation. # BEGIN runner product cache. -# A verified, icon-injected runner product is recorded before its launch -# (its products dir + .xctestrun) so the next reconnect installs it as-is with -# `test-without-building` instead of building twice. Before this, every -# KeepAlive round ran build-for-testing, found the previous round's injected -# product "invalid" (the incremental build re-emplaces Info.plist without -# re-signing), deleted it, rebuilt, and injected the icon again — 20-40s of -# xcodebuild per reconnect that the phone never needed. +# A verified runner product is recorded before its launch (its products dir + +# .xctestrun) so the next reconnect installs it as-is with +# `test-without-building` instead of running build-for-testing again — even an +# up-to-date incremental build costs xcodebuild seconds on every reconnect. # -# The record is keyed on everything that changes the product: WDA commit, -# bundle id, team, target device, the icon source + its content hash, and the Xcode / -# SDK / deployment target it was built with. Any -# mismatch, a missing file, or a product that no longer validates falls -# through to the normal build. A launch that names a failure of the product -# itself drops the record so the following round rebuilds from scratch. +# The record is keyed on everything that changes the product: the runner +# source hash, the signing identity (team, bundle id, ASC key or Xcode +# account), the target device, and the Xcode / SDK / deployment target it was +# built with. Any mismatch, a missing file, or a product that no longer +# validates falls through to the normal build. A launch that names a failure of +# the product itself drops the record so the following round rebuilds. WDA_RUNNER_CACHE="$STATE_DIR/wda-runner-product.json" WDA_RUNNER_FROM_CACHE=0 _runner_cache_key() { - # The icon's content, not its mtime: every app install rewrites the same - # icon file, and keying on mtime made the first connect after each upgrade - # (nightly auto-update included) rebuild WDA from scratch. - local icon_hash=0 - if [ -n "${RUNNER_ICON_SOURCE:-}" ] && [ -f "$RUNNER_ICON_SOURCE" ]; then - icon_hash="$(shasum -a 256 "$RUNNER_ICON_SOURCE" 2>/dev/null | cut -d' ' -f1)" - [ -n "$icon_hash" ] || icon_hash="mtime:$(stat -f %m "$RUNNER_ICON_SOURCE" 2>/dev/null || echo 0)" - fi - # The Xcode, SDK and deployment target override are part of the product: - # a product (and its .xctestrun) from before an Xcode upgrade is stale. - printf 'v2|%s|%s|%s|%s|%s|%s|%s|%s|%s' "${WDA_COMMIT:-}" "${WDA_BUNDLE_ID:-}" "${TEAM_ID:-}" \ - "${WDA_UDID:-}" "${RUNNER_ICON_SOURCE:-}" "$icon_hash" "${XCODE_VERSION:-}" \ + local signer="account" + if _asc_signing_enabled; then + signer="asc:${WDA_ASC_KEY_ID:-}" + fi + printf 'v3|%s|%s|%s|%s|%s|%s|%s|%s' "${RUNNER_SOURCE_HASH:-}" "${WDA_BUNDLE_ID:-}" \ + "${TEAM_ID:-}" "$signer" "${WDA_UDID:-}" "${XCODE_VERSION:-}" \ "${WDA_IOS_SDK_VERSION:-}" "${WDA_DEPLOYMENT_TARGET_OVERRIDE:-}" } @@ -3314,15 +3036,15 @@ _runner_cache_drop() { rm -f "$WDA_RUNNER_CACHE" } -# Record the product that just served. Atomic write; a symlinked path is -# never followed. +# Record the verified product. Atomic write; a symlinked path is never followed. _runner_cache_write() { - [ -n "${WDA_ICON_PRODUCTS_DIR:-}" ] && [ -n "${WDA_XCTESTRUN:-}" ] || return 1 + [ -n "${RUNNER_BUILT_PRODUCTS:-}" ] && [ -n "${WDA_XCTESTRUN:-}" ] || return 1 + [ -n "${RUNNER_SOURCE_HASH:-}" ] || return 1 if [ -L "$WDA_RUNNER_CACHE" ]; then warn "refusing to write a symlinked runner cache record: $WDA_RUNNER_CACHE" return 1 fi - python3 - "$WDA_RUNNER_CACHE" "$(_runner_cache_key)" "$WDA_ICON_PRODUCTS_DIR" "$WDA_XCTESTRUN" <<'PY_CACHE' + python3 - "$WDA_RUNNER_CACHE" "$(_runner_cache_key)" "$RUNNER_BUILT_PRODUCTS" "$WDA_XCTESTRUN" <<'PY_CACHE' import json, os, sys, tempfile, time path, key, products, xctestrun = sys.argv[1:] record = {"schema_version": 1, "key": key, "products_dir": products, @@ -3336,13 +3058,14 @@ PY_CACHE } # Reuse the recorded product when it still matches and still validates. -# On success sets WDA_ICON_PRODUCTS_DIR + WDA_XCTESTRUN and returns 0. +# On success sets RUNNER_BUILT_PRODUCTS, RUNNER_APP_PATH and WDA_XCTESTRUN. _runner_cache_read() { local products xctestrun app + [ -n "${RUNNER_SOURCE_HASH:-}" ] || return 1 [ -f "$WDA_RUNNER_CACHE" ] && [ ! -L "$WDA_RUNNER_CACHE" ] || return 1 - products="$(python3 - "$WDA_RUNNER_CACHE" "$(_runner_cache_key)" <<'PY_CACHE' -import json, sys -path, key = sys.argv[1:] + products="$(python3 - "$WDA_RUNNER_CACHE" "$(_runner_cache_key)" "$RUNNER_PRODUCTS_DIR" <<'PY_CACHE' +import json, posixpath, sys +path, key, expected_products = sys.argv[1:] try: with open(path, encoding="utf-8") as handle: record = json.load(handle) @@ -3353,20 +3076,15 @@ if record.get("schema_version") != 1 or record.get("key") != key: products, xctestrun = record.get("products_dir"), record.get("xctestrun") if not (isinstance(products, str) and isinstance(xctestrun, str)): raise SystemExit(1) -if not products.startswith("/") or "/Build/Products/" not in products: - raise SystemExit(1) -# xcodebuild writes the .xctestrun beside the configuration directory, not -# inside it: .../Build/Products/X.xctestrun next to .../Build/Products/Debug-iphoneos. -# `_resolve_xctestrun` looks in that parent, so accept either location and -# keep both inside the same Build/Products tree. -import posixpath -parent = posixpath.dirname(products.rstrip("/")) -if not xctestrun.endswith(".xctestrun"): +# Only the products directory of this instance is ever reused. +if products.rstrip("/") != expected_products.rstrip("/"): raise SystemExit(1) -if not (xctestrun.startswith(products.rstrip("/") + "/") - or xctestrun.startswith(parent + "/")): +if "/Build/Products/" not in products or not xctestrun.endswith(".xctestrun"): raise SystemExit(1) -if "/Build/Products/" not in xctestrun: +# xcodebuild writes the .xctestrun beside the configuration directory: +# .../Build/Products/X.xctestrun next to .../Build/Products/Debug-iphoneos. +parent = posixpath.dirname(products.rstrip("/")) +if posixpath.dirname(xctestrun) != parent: raise SystemExit(1) print(products) print(xctestrun) @@ -3374,13 +3092,13 @@ PY_CACHE )" || return 1 xctestrun="${products#*$'\n'}" products="${products%%$'\n'*}" - [ -d "$products" ] && [ -f "$xctestrun" ] || return 1 - app="$products/${WDA_RUNNER_NAME:-WebDriverAgentRunner}-Runner.app" + [ -d "$products" ] && [ -f "$xctestrun" ] && [ ! -L "$xctestrun" ] || return 1 + app="$products/$RUNNER_APP_NAME" [ -d "$app" ] && [ ! -L "$app" ] || return 1 _validate_runner_bundle "$app" || return 1 - WDA_ICON_PRODUCTS_DIR="$products" + RUNNER_BUILT_PRODUCTS="$products" + RUNNER_APP_PATH="$app" WDA_XCTESTRUN="$xctestrun" - WDA_ICON_APP_PATH="$app" WDA_RUNNER_FROM_CACHE=1 return 0 } @@ -3393,319 +3111,11 @@ _runner_log_shows_product_failure() { } # END runner product cache. -_build_and_inject_runner_icon() { - local source="$1" - local extension icon_png iconset assets_catalog compiled partial_plist - local icon_dimensions has_alpha build_log build_settings products_dir - local runner_product signing_identity entitlements nested xctest_count - - extension="$(printf '%s' "${source##*.}" | tr '[:upper:]' '[:lower:]')" - case "$extension" in - icns) - command -v iconutil >/dev/null 2>&1 \ - || { _runner_icon_fail "iconutil is unavailable" || true; return 1; } - ;; - png) ;; - *) - _runner_icon_fail "WDA_RUNNER_ICON must name a .png or .icns file" || true - return 1 - ;; - esac - command -v sips >/dev/null 2>&1 \ - || { _runner_icon_fail "sips is unavailable" || true; return 1; } - command -v codesign >/dev/null 2>&1 \ - || { _runner_icon_fail "codesign is unavailable" || true; return 1; } - [ -x /usr/bin/ditto ] \ - || { _runner_icon_fail "/usr/bin/ditto is unavailable" || true; return 1; } - xcrun --find actool >/dev/null 2>&1 \ - || { _runner_icon_fail "Xcode actool is unavailable" || true; return 1; } - - WDA_ICON_WORK_DIR="$(mktemp -d "$STATE_DIR/wda-runner-icon.XXXXXX")" \ - || { _runner_icon_fail "could not create a private icon work directory" || true; return 1; } - icon_png="$WDA_ICON_WORK_DIR/icon-1024.png" - assets_catalog="$WDA_ICON_WORK_DIR/Assets.xcassets" - compiled="$WDA_ICON_WORK_DIR/compiled" - partial_plist="$WDA_ICON_WORK_DIR/partial.plist" - mkdir -p "$assets_catalog/AppIcon.appiconset" "$compiled" \ - || { _runner_icon_fail "could not prepare the asset catalog" || true; return 1; } - - if [ "$extension" = "icns" ]; then - iconset="$WDA_ICON_WORK_DIR/source.iconset" - if ! iconutil -c iconset "$source" -o "$iconset" \ - >"$WDA_ICON_WORK_DIR/iconutil.log" 2>&1 \ - || [ ! -f "$iconset/icon_512x512@2x.png" ]; then - _runner_icon_fail "the ICNS has no usable 1024px representation" || true - return 1 - fi - cp "$iconset/icon_512x512@2x.png" "$icon_png" \ - || { _runner_icon_fail "could not stage the ICNS image" || true; return 1; } - elif ! sips -s format png -z 1024 1024 "$source" --out "$icon_png" \ - >"$WDA_ICON_WORK_DIR/sips.log" 2>&1; then - _runner_icon_fail "the PNG could not be converted to 1024x1024" || true - return 1 - fi - - icon_dimensions="$(sips -g pixelWidth -g pixelHeight "$icon_png" 2>/dev/null \ - | awk '/pixelWidth:/ { width=$2 } /pixelHeight:/ { height=$2 } END { printf "%sx%s", width, height }')" - if [ "$icon_dimensions" != "1024x1024" ]; then - _runner_icon_fail "the staged icon is $icon_dimensions instead of 1024x1024" || true - return 1 - fi - has_alpha="$(sips -g hasAlpha "$icon_png" 2>/dev/null \ - | awk '/hasAlpha:/ { print tolower($2); exit }')" - case "$has_alpha" in - yes|true) - # iOS rejects primary app icons with alpha. `actool` may still - # compile them, so flatten first instead of discovering this only - # after installation. - if ! sips --setProperty hasAlpha false "$icon_png" \ - >>"$WDA_ICON_WORK_DIR/sips.log" 2>&1; then - _runner_icon_fail "the icon alpha channel could not be flattened" || true - return 1 - fi - ;; - esac - - cat > "$assets_catalog/AppIcon.appiconset/Contents.json" <<'JSON' -{"images":[{"filename":"icon-1024.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}} -JSON - cp "$icon_png" "$assets_catalog/AppIcon.appiconset/icon-1024.png" \ - || { _runner_icon_fail "could not populate the asset catalog" || true; return 1; } - if ! xcrun actool --compile "$compiled" --app-icon AppIcon \ - --minimum-deployment-target 13.0 --platform iphoneos \ - --target-device iphone --output-partial-info-plist "$partial_plist" \ - "$assets_catalog" >"$WDA_ICON_WORK_DIR/actool.log" 2>&1; then - _runner_icon_fail "actool could not compile the runner icon" || true - return 1 - fi - for nested in Assets.car AppIcon60x60@2x.png AppIcon76x76@2x~ipad.png; do - if [ ! -f "$compiled/$nested" ]; then - _runner_icon_fail "actool did not produce $nested" || true - return 1 - fi - done - if [ ! -s "$partial_plist" ]; then - _runner_icon_fail "actool did not produce its partial Info.plist" || true - return 1 - fi - - # `xcodebuild ... test` builds, installs, and launches in one action, so - # there is otherwise no safe point to edit the synthesised .xctrunner app. - # A hardware-verified build-for-testing pass creates it first; the runner is - # then launched with `test-without-building -xctestrun`, which installs the - # already-built product as-is. The plain `test` action must NOT be used - # here: it re-emplaces the runner's Info.plist from the XCTRunner template - # without re-signing, which both drops the icon keys and breaks the seal - # (hardware-verified: installd rejects it with 0xe8008001). - build_log="$STATE_DIR/wda-runner-icon-build.log" - build_settings="$WDA_ICON_WORK_DIR/build-settings.json" - _setstatus building "${_BUILD_BLOCKER:-}" "resolving WDA build settings" - if ! ( - cd "$WDA_DIR" || exit 1 - _wda_xcodebuild -project WebDriverAgent.xcodeproj \ - -scheme WebDriverAgentRunner \ - -destination "platform=iOS,id=$WDA_UDID" \ - -allowProvisioningUpdates \ - DEVELOPMENT_TEAM="$TEAM_ID" \ - PRODUCT_BUNDLE_IDENTIFIER="$WDA_BUNDLE_ID" \ - -showBuildSettings -json - ) >"$build_settings" 2>"$build_log"; then - _runner_icon_fail "could not resolve the built-products directory (log: $build_log)" || true - return 1 - fi - products_dir="$(python3 - "$build_settings" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - records = json.load(handle) -paths = { - record.get("buildSettings", {}).get("BUILT_PRODUCTS_DIR") - for record in records - if record.get("target") == "WebDriverAgentRunner" -} -paths.discard(None) -if len(paths) != 1: - raise SystemExit(1) -print(paths.pop()) -PY - )" || { - _runner_icon_fail "xcodebuild returned an ambiguous built-products directory" || true - return 1 - } - case "$products_dir" in - /*/Build/Products/*) ;; - *) - _runner_icon_fail "xcodebuild returned an unexpected products path" || true - return 1 - ;; - esac - # The SDK this build used picks the matching .xctestrun when an older - # Xcode left another one beside it (see _resolve_xctestrun). - WDA_BUILT_SDK_VERSION="$(python3 - "$build_settings" <<'PY_SDK' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - records = json.load(handle) -versions = { - record.get("buildSettings", {}).get("SDK_VERSION") - for record in records - if record.get("target") == "WebDriverAgentRunner" -} -versions.discard(None) -if len(versions) == 1: - print(versions.pop()) -PY_SDK - )" || WDA_BUILT_SDK_VERSION="" - # Resolved before the prebuild so the previous round's injected runner can - # be dropped first: building over it is what poisoned every KeepAlive - # round's product (#75). Build settings do not depend on a built product. - runner_product="${WDA_RUNNER_NAME:-WebDriverAgentRunner}-Runner.app" - if ! _discard_previous_injection "$products_dir" "$products_dir/$runner_product"; then - _runner_icon_fail "refusing to build over the previous round's injected runner, which could not be discarded" || true - return 1 - fi - info "Prebuilding WDA so the runner icon can be injected before installation" - _setstatus building "${_BUILD_BLOCKER:-}" "prebuilding WDA for runner icon injection" - if ! ( - cd "$WDA_DIR" || exit 1 - _wda_xcodebuild -project WebDriverAgent.xcodeproj \ - -scheme WebDriverAgentRunner \ - -destination "platform=iOS,id=$WDA_UDID" \ - -allowProvisioningUpdates \ - DEVELOPMENT_TEAM="$TEAM_ID" \ - PRODUCT_BUNDLE_IDENTIFIER="$WDA_BUNDLE_ID" \ - build-for-testing - ) >>"$build_log" 2>&1; then - if grep -Eiq 'Unlock iPhone to Continue|device is locked|deviceprep.*Code=-3|Code=-3.*deviceprep' \ - "$build_log" 2>/dev/null; then - # Do not immediately run the guarded `test` action and prompt a - # second time in the same cycle. The caller records lock backoff. - WDA_ICON_BUILD_LOCKED=1 - _cleanup_wda_icon_work_dir - return 1 - fi - _runner_icon_fail "build-for-testing failed (log: $build_log)" || true - return 1 - fi - - WDA_ICON_PRODUCTS_DIR="$products_dir" - WDA_ICON_APP_PATH="$products_dir/$runner_product" - if [ ! -f "$WDA_ICON_APP_PATH/Info.plist" ] \ - || [ ! -d "$WDA_ICON_APP_PATH/PlugIns" ]; then - _runner_icon_fail "the expected built runner is missing: $runner_product" || true - return 1 - fi - if ! _repair_runner_if_invalid "$products_dir" "$WDA_ICON_APP_PATH" "$build_log"; then - _runner_icon_fail "$WDA_RUNNER_VALIDATION_ERROR" || true - return 1 - fi - signing_identity="$(codesign -dvv "$WDA_ICON_APP_PATH" 2>&1 \ - | sed -n 's/^Authority=//p' | head -1 || true)" - if [ -z "$signing_identity" ]; then - _runner_icon_fail "the runner signing identity could not be read" || true - return 1 - fi - entitlements="$WDA_ICON_WORK_DIR/runner-entitlements.plist" - if ! codesign -d --entitlements - --xml "$WDA_ICON_APP_PATH" \ - >"$entitlements" 2>>"$build_log" \ - || [ ! -s "$entitlements" ] \ - || ! plutil -lint "$entitlements" >/dev/null 2>&1; then - _runner_icon_fail "the runner entitlements could not be preserved" || true - return 1 - fi - - WDA_ICON_BACKUP_PATH="$WDA_ICON_WORK_DIR/original.app" - if ! /usr/bin/ditto "$WDA_ICON_APP_PATH" "$WDA_ICON_BACKUP_PATH" \ - || ! codesign --verify --deep --strict "$WDA_ICON_BACKUP_PATH" 2>>"$build_log"; then - _runner_icon_fail "the pristine runner could not be backed up safely" || true - return 1 - fi - WDA_ICON_MUTATION_ACTIVE=1 - - if ! cp "$compiled/Assets.car" "$compiled/AppIcon60x60@2x.png" \ - "$compiled/AppIcon76x76@2x~ipad.png" "$WDA_ICON_APP_PATH/"; then - _runner_icon_fail "compiled icon assets could not be copied into the runner" || true - return 1 - fi - if ! python3 - "$partial_plist" "$WDA_ICON_APP_PATH/Info.plist" <<'PY' -import os -import plistlib -import sys -import tempfile - -partial_path, target_path = sys.argv[1:] -with open(partial_path, "rb") as handle: - generated = plistlib.load(handle) -with open(target_path, "rb") as handle: - original_data = handle.read() -info = plistlib.loads(original_data) -info.update(generated) -name = (info.get("CFBundleIcons", {}) - .get("CFBundlePrimaryIcon", {}) - .get("CFBundleIconName")) -if name != "AppIcon": - raise SystemExit("actool plist is missing the primary CFBundleIconName=AppIcon") -fmt = plistlib.FMT_BINARY if original_data.startswith(b"bplist00") else plistlib.FMT_XML -mode = os.stat(target_path).st_mode -with tempfile.NamedTemporaryFile( - dir=os.path.dirname(target_path), prefix=".Info.plist.icon.", delete=False) as handle: - temp_path = handle.name - plistlib.dump(info, handle, fmt=fmt, sort_keys=False) -os.chmod(temp_path, mode) -os.replace(temp_path, target_path) -PY - then - _runner_icon_fail "actool's icon metadata could not be merged into Info.plist" || true - return 1 - fi - - # Re-sign strictly from the inside out. Signing the app first and then a - # nested framework/test bundle invalidates the outer resource seal and - # makes installation fail with 0xe8008001. - for nested in "$WDA_ICON_APP_PATH"/Frameworks/*.dylib \ - "$WDA_ICON_APP_PATH"/Frameworks/*.framework; do - [ -e "$nested" ] || continue - if ! codesign -f -s "$signing_identity" "$nested" >>"$build_log" 2>&1; then - _runner_icon_fail "a nested runner framework could not be re-signed" || true - return 1 - fi - done - xctest_count=0 - for nested in "$WDA_ICON_APP_PATH"/PlugIns/*.xctest; do - [ -e "$nested" ] || continue - xctest_count=$((xctest_count + 1)) - if ! codesign -f -s "$signing_identity" "$nested" >>"$build_log" 2>&1; then - _runner_icon_fail "the runner xctest bundle could not be re-signed" || true - return 1 - fi - done - if [ "$xctest_count" -eq 0 ]; then - _runner_icon_fail "the runner contains no xctest bundle to re-sign" || true - return 1 - fi - if ! codesign -f -s "$signing_identity" --entitlements "$entitlements" \ - "$WDA_ICON_APP_PATH" >>"$build_log" 2>&1 \ - || ! codesign --verify --deep --strict "$WDA_ICON_APP_PATH" \ - >>"$build_log" 2>&1; then - _runner_icon_fail "the final runner signature did not verify" || true - return 1 - fi - - WDA_ICON_MUTATION_ACTIVE=0 - WDA_RUNNER_ICON_INJECTED=1 - _cleanup_wda_icon_work_dir - ok "Runner icon injected and signature verified (source: $source)" - return 0 -} - if [ -z "${WDA_UDID:-}" ]; then - # xcodebuild exposes the classic UDID WDA needs. Never use `head -1`: with - # multiple paired phones, guessing can build/sign/drive the wrong device. - WDA_DESTINATION_UDIDS="$(cd "$WDA_DIR" \ - && _wda_xcodebuild -project WebDriverAgent.xcodeproj \ - -scheme WebDriverAgentRunner -showdestinations 2>/dev/null \ + # xcodebuild exposes the classic UDID the runner needs. Never use `head -1`: + # with multiple paired phones, guessing can build/sign/drive the wrong device. + WDA_DESTINATION_UDIDS="$(cd "$STATE_DIR" \ + && _runner_xcodebuild -showdestinations 2>/dev/null \ | sed -n 's/.*platform:iOS, arch:arm64.*id:\([0-9A-F-]*\),.*/\1/p' \ | sort -u || true)" WDA_DESTINATION_COUNT="$(printf '%s\n' "$WDA_DESTINATION_UDIDS" \ @@ -3860,12 +3270,10 @@ if [ "$(_device_passcode_required)" = "true" ]; then done ok "iPhone unlocked after $((SECONDS - _lock_wait_started))s" fi -_setstatus building "$_BUILD_BLOCKER" "building + launching WDA" +_setstatus building "$_BUILD_BLOCKER" "building + launching the device runner" -# ── 4. Build + run WDA (stays running; this is the server) ─────────────────── -# Pitfall: PRODUCT_NAME must NOT be overridden (it renames WebDriverAgentLib -# and breaks the build) — only PRODUCT_BUNDLE_IDENTIFIER is safe to rebrand. -info "Building + launching WDA on the phone (first build takes a few minutes)" +# ── 4. Build + run the device runner (stays running; this is the server) ───── +info "Building + launching the device runner on the phone (the first build takes a minute or two)" _stop_managed_process "$RUNNER_PID_FILE" "$LEGACY_RUNNER_EXPECTED" runner \ || die "the prior runner PID record does not safely identify a process; refusing to kill anything" : > "$RUN_LOG" @@ -3876,84 +3284,51 @@ _stop_managed_process "$RUNNER_PID_FILE" "$LEGACY_RUNNER_EXPECTED" runner \ XCODEBUILD_BIN="$(xcrun --find xcodebuild 2>/dev/null || true)" [ -n "$XCODEBUILD_BIN" ] && [ -x "$XCODEBUILD_BIN" ] \ || die "could not resolve the selected Xcode's xcodebuild executable" -RUNNER_ICON_SOURCE="" -case "$WDA_RUNNER_ICON" in - none) - ok "Runner icon injection disabled (WDA_RUNNER_ICON=none)" - ;; - auto) - RUNNER_ICON_SOURCE="$HOME/Applications/iPhoneUse.app/Contents/Resources/AppIcon.icns" - if [ ! -f "$RUNNER_ICON_SOURCE" ]; then - warn "Runner icon source is not installed at $RUNNER_ICON_SOURCE; continuing with WDA's placeholder icon" - RUNNER_ICON_SOURCE="" - fi - ;; - *) - RUNNER_ICON_SOURCE="$WDA_RUNNER_ICON" - if [ ! -f "$RUNNER_ICON_SOURCE" ]; then - warn "WDA_RUNNER_ICON does not name a readable file: $RUNNER_ICON_SOURCE; continuing with WDA's placeholder icon" - RUNNER_ICON_SOURCE="" - elif RUNNER_ICON_DIR="$(cd -P "$(dirname "$RUNNER_ICON_SOURCE")" 2>/dev/null && pwd)"; then - RUNNER_ICON_SOURCE="$RUNNER_ICON_DIR/$(basename "$RUNNER_ICON_SOURCE")" - # launchd has no stable working directory. Persist the canonical - # absolute path so a custom icon survives supervisor restarts. - WDA_RUNNER_ICON="$RUNNER_ICON_SOURCE" - else - warn "WDA_RUNNER_ICON could not be resolved to an absolute path; continuing with WDA's placeholder icon" - RUNNER_ICON_SOURCE="" - fi - ;; -esac WDA_XCTESTRUN="" -if [ -n "$RUNNER_ICON_SOURCE" ] && [ "${WDA_RUNNER_REBUILD:-0}" != "1" ] && _runner_cache_read; then +if [ "${WDA_RUNNER_REBUILD:-0}" != "1" ] && _runner_cache_read; then ok "Reusing the runner product from the last bring-up (no rebuild; WDA_RUNNER_REBUILD=1 forces one)" _setstatus building "${_BUILD_BLOCKER:-}" "reusing the verified runner product from the last bring-up" -elif [ -n "$RUNNER_ICON_SOURCE" ]; then - if _build_and_inject_runner_icon "$RUNNER_ICON_SOURCE"; then - WDA_XCTESTRUN="$(_resolve_xctestrun "$WDA_ICON_PRODUCTS_DIR" "${WDA_BUILT_SDK_VERSION:-}" || true)" - if [ -z "$WDA_XCTESTRUN" ]; then - if _discard_injected_runner; then - warn "Could not resolve a unique .xctestrun; discarded the injected runner so the launch rebuilds a pristine one (the custom icon is skipped this round)" - else - _setstatus building-fail wda "injected runner cannot be launched and could not be discarded" - die "could not resolve a unique .xctestrun for the injected runner, and refusing to run the normal test action over a hand-signed bundle" - fi +elif ! _ensure_launchable_runner; then + if [ "$RUNNER_BUILD_LOCKED" = "1" ]; then + if [ "${WDA_KEEPALIVE:-0}" = "1" ]; then + _prepare_locked_retry + exit 1 fi + die "the phone is locked and the runner build exited. Unlock it, then rerun setup." fi -fi -if [ "$WDA_ICON_BUILD_LOCKED" != "1" ] && ! _ensure_launchable_runner; then - if [ "$WDA_ICON_BUILD_LOCKED" != "1" ]; then - _setstatus building-fail wda "$WDA_RUNNER_VALIDATION_ERROR" - die "runner product is not launchable: $WDA_RUNNER_VALIDATION_ERROR" + if grep -q "No Accounts:" "$STATE_DIR/wda-runner-product-build.log" 2>/dev/null; then + _report_missing_xcode_account fi -fi -if [ "$WDA_ICON_BUILD_LOCKED" = "1" ]; then - if [ "${WDA_KEEPALIVE:-0}" = "1" ]; then - _prepare_locked_retry - exit 1 + if grep -q "No profiles for .* were found\|requires a provisioning profile" \ + "$STATE_DIR/wda-runner-product-build.log" 2>/dev/null; then + _setstatus signing-fail account "Xcode could not create the runner provisioning profile" + # "could not find or create the WDA development provisioning" is the + # phrase the daemon maps to its `account` blocker; keep it verbatim. + die "Xcode could not find or create the WDA development provisioning profile for the device runner. + In Xcode → Settings → Accounts, refresh the selected team, keep the iPhone + registered, then rerun. With WDA_ASC_* API-key signing, check that the key + can manage profiles. Build log: $STATE_DIR/wda-runner-product-build.log" fi - die "the phone is locked and WDA prebuild exited. Unlock it, then rerun setup." + _setstatus building-fail wda "$WDA_RUNNER_VALIDATION_ERROR" + die "device runner product is not launchable: $WDA_RUNNER_VALIDATION_ERROR" fi -# Record the product as soon as it is verified launchable, not after WDA +# Record the product as soon as it is verified launchable, not after the runner # serves. A round that builds a good product and then fails for a reason # outside it (a locked phone, a dropped device link, a relay error) otherwise -# leaves no record, so every KeepAlive retry built and injected again (#75). +# leaves no record, so every KeepAlive retry would build again (#75). # A launch that names a failure of the product itself still drops it below. -if [ -n "${WDA_XCTESTRUN:-}" ] && [ "$WDA_RUNNER_FROM_CACHE" != "1" ]; then +if [ "$WDA_RUNNER_FROM_CACHE" != "1" ]; then _runner_cache_write && ok "Recorded the verified runner product; the next reconnect installs it without rebuilding" \ || warn "could not record the runner product for reuse; the next reconnect rebuilds" fi -# Keep `RUNNER_COMMAND=` at column 0: the icon regression test isolates the -# selection block by scanning for it, and indenting it swallowed the runner -# launch block into that excerpt. -# `-xctestrun` cannot be combined with `-project`/`-scheme`. Build both launch -# forms and their signing suffix through one argv source, also used by the -# exact PID identity record. No eval or string-based command execution. -_prepare_runner_args || die "could not prepare WDA runner signing arguments" +# Keep `RUNNER_COMMAND=` at column 0 (tests isolate the launch block by it). +# One argv source builds the launch command and its signing suffix, and is +# also the exact PID identity record. No eval or string-based execution. +_prepare_runner_args || die "could not prepare the device runner launch arguments" RUNNER_COMMAND="$XCODEBUILD_BIN $RUNNER_ARGS" RUNNER_EXPECTED="runner:$RUNNER_COMMAND" ( - cd "$WDA_DIR" || exit 1 + cd "$STATE_DIR" || exit 1 exec nohup "$XCODEBUILD_BIN" "${RUNNER_ARGV[@]}" ) > "$RUN_LOG" 2>&1 & RUNNER_PID=$! @@ -3979,8 +3354,8 @@ while [ -z "$PHONE_URL" ]; do if _runner_log_shows_automation_mode_disabled "$RUN_LOG"; then _report_automation_mode_disabled fi - _setstatus building-fail wda "WDA did not report its server URL before the startup timeout" - die "timed out waiting for WDA to start — check $RUN_LOG" + _setstatus building-fail wda "the device runner did not report its server URL before the startup timeout" + die "timed out waiting for the device runner to start — check $RUN_LOG" fi # Read actionable xcodebuild failures before checking whether its PID is # still alive. Fast failures can exit between polls; validating the process @@ -3996,8 +3371,10 @@ while [ -z "$PHONE_URL" ]; do if [ -n "${WDA_XCTESTRUN:-}" ]; then _runner_cache_drop || true fi - _setstatus signing-fail account "Xcode could not create the WDA provisioning profile" - die "Xcode could not find or create the WDA development provisioning profile. + _setstatus signing-fail account "Xcode could not create the runner provisioning profile" + # "could not find or create the WDA development provisioning" is the + # phrase the daemon maps to its `account` blocker; keep it verbatim. + die "Xcode could not find or create the WDA development provisioning profile for the device runner. In Xcode → Settings → Accounts, refresh the selected team, keep the iPhone registered, then rerun. If WARP is connected, its effective Excluded routes must contain fe80::/10 and fd00::/8 (otherwise disconnect it temporarily)." @@ -4017,7 +3394,7 @@ while [ -z "$PHONE_URL" ]; do exit 1 fi if _wda_failure_is_lock_related log-only; then - _setstatus building-fail wda "phone is locked and the WDA runner exited" + _setstatus building-fail wda "phone is locked and the device runner exited" die "the phone is locked and xcodebuild exited. Unlock it, then rerun setup." fi # Only evict for a failure of the product itself. A runner can also @@ -4032,8 +3409,8 @@ while [ -z "$PHONE_URL" ]; do if _runner_log_shows_automation_mode_disabled "$RUN_LOG"; then _report_automation_mode_disabled fi - _setstatus building-fail wda "WDA runner exited before reporting its server URL" - die "the PID-verified WDA runner exited before reporting its server URL — check $RUN_LOG" + _setstatus building-fail wda "the device runner exited before reporting its server URL" + die "the PID-verified device runner exited before reporting its server URL — check $RUN_LOG" fi if _wda_failure_is_lock_related log-only \ && [ -z "$(sed -n 's/.*ServerURLHere->\(http[^<]*\)<-ServerURLHere.*/\1/p' \ @@ -4046,27 +3423,17 @@ while [ -z "$PHONE_URL" ]; do || die "the phone remained locked for 5 minutes. Unlock it, then rerun setup." elif [ $((TRIES % 10)) -eq 0 ]; then BUILD_ELAPSED="$(( $(date +%s) - BUILD_STARTED_AT ))" - _setstatus building "$_BUILD_BLOCKER" "building + launching WDA (${BUILD_ELAPSED}s elapsed)" + _setstatus building "$_BUILD_BLOCKER" "launching the device runner (${BUILD_ELAPSED}s elapsed)" fi PHONE_URL="$(sed -n 's/.*ServerURLHere->\(http[^<]*\)<-ServerURLHere.*/\1/p' "$RUN_LOG" | head -1)" [ -z "$PHONE_URL" ] && sleep 3 done case "$PHONE_URL" in http://*) ;; - *) die "WDA reported an unexpected server URL '$PHONE_URL' (plain http:// expected)" ;; + *) die "the device runner reported an unexpected server URL '$PHONE_URL' (plain http:// expected)" ;; esac -ok "WDA serving at $PHONE_URL" -if [ "$WDA_RUNNER_ICON_INJECTED" = "1" ]; then - if [ ! -f "$WDA_ICON_APP_PATH/Assets.car" ] \ - || [ ! -f "$WDA_ICON_APP_PATH/AppIcon60x60@2x.png" ] \ - || [ ! -f "$WDA_ICON_APP_PATH/AppIcon76x76@2x~ipad.png" ] \ - || [ "$(/usr/libexec/PlistBuddy \ - -c 'Print :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName' \ - "$WDA_ICON_APP_PATH/Info.plist" 2>/dev/null || true)" != "AppIcon" ]; then - warn "Xcode rebuilt the runner and replaced its injected icon; WDA is healthy, and the next setup run will inject the icon again" - fi -fi -_setstatus serving "" "WDA serving — starting relay" +ok "device runner serving at $PHONE_URL" +_setstatus serving "" "device runner serving — starting relay" # ── 5. Localhost relay ──────────────────────────────────────────────────────── # Pitfall (macOS 15+/26): the daemon is a background LaunchAgent and macOS @@ -4078,7 +3445,7 @@ info "Starting localhost relay on 127.0.0.1:$WDA_PORT" PHONE_HOSTPORT="${PHONE_URL#http://}"; PHONE_HOSTPORT="${PHONE_HOSTPORT%/}" PHONE_IP="${PHONE_HOSTPORT%%:*}"; PHONE_WDA_PORT="${PHONE_HOSTPORT##*:}" _valid_port "$PHONE_WDA_PORT" \ - || die "WDA reported an invalid device port in '$PHONE_URL'" + || die "the device runner reported an invalid device port in '$PHONE_URL'" _stop_managed_process "$RELAY_PID_FILE" "$LEGACY_RELAY_EXPECTED" relay \ || die "the prior control-relay PID record is not safe to stop; refusing to reuse TCP $WDA_PORT" _assert_port_free "$WDA_PORT" \ @@ -4095,9 +3462,9 @@ if [ "$TARGET_IS_USB" = "1" ] && command -v iproxy >/dev/null 2>&1; then RELAY_PID=$! RELAY_DESC="USB iproxy on 127.0.0.1:$WDA_PORT" elif [ "$WDA_ALLOW_LAN" = "1" ] && command -v socat >/dev/null; then - warn "WDA_ALLOW_LAN=1: WDA has no authentication; use only on a trusted, isolated LAN" + warn "WDA_ALLOW_LAN=1: the device runner has no authentication; use only on a trusted, isolated LAN" printf '%s\n' "$PHONE_IP" | LC_ALL=C grep -Eq '^[A-Za-z0-9.:%_-]+$' \ - || die "WDA reported a LAN host that is unsafe for socat: '$PHONE_IP'" + || die "the device runner reported a LAN host that is unsafe for socat: '$PHONE_IP'" SOCAT_BIN="$(command -v socat)" RELAY_COMMAND="$SOCAT_BIN TCP-LISTEN:$WDA_PORT,fork,reuseaddr,bind=127.0.0.1 TCP:$PHONE_IP:$PHONE_WDA_PORT" RELAY_EXPECTED="relay:$RELAY_COMMAND" @@ -4111,9 +3478,9 @@ else else _setstatus serving wda "no permitted control relay tool is available" fi - die "Direct/WDA uses USB iproxy by default. Keep this iPhone connected over USB and install + die "the device layer uses USB iproxy by default. Keep this iPhone connected over USB and install libimobiledevice (brew install libimobiledevice), then rerun. - The on-phone WDA server has no HTTP authentication. A LAN relay is therefore disabled + The on-phone runner has no HTTP authentication. A LAN relay is therefore disabled unless WDA_ALLOW_LAN=1 is explicitly set for a trusted, isolated network." fi if ! _write_pid_record "$RELAY_PID_FILE" "$RELAY_PID" "$RELAY_EXPECTED" relay; then @@ -4128,14 +3495,14 @@ _verify_loopback_listener "$RELAY_PID_FILE" "$LEGACY_RELAY_EXPECTED" relay "$WDA Expected only PID $RELAY_PID on 127.0.0.1:$WDA_PORT; inspect $STATE_DIR/wda-relay.log" ok "PID-verified control relay $RELAY_PID: $RELAY_DESC" curl -fsS -m 5 "http://127.0.0.1:$WDA_PORT/status" >/dev/null \ - || die "relay up but WDA not answering through it — check $STATE_DIR/wda-relay.log" -ok "WDA reachable at http://127.0.0.1:$WDA_PORT" -warn "The Mac relay is loopback-only, but WDA on the iPhone has no HTTP authentication. + || die "relay up but the device runner is not answering through it — check $STATE_DIR/wda-relay.log" +ok "device runner reachable at http://127.0.0.1:$WDA_PORT" +warn "The Mac relay is loopback-only, but the runner on the iPhone has no HTTP authentication. Keep the iPhone on a trusted, isolated network even when the Mac relay uses USB." # ── 5b. MJPEG relay (live video for agent mode — /agent/mjpeg) ───────────────── -# WDA serves an MJPEG screen stream on the device's :9100, INSIDE the same -# XCUITest session as control — so live video and driving coexist. The daemon +# The runner serves an MJPEG screen stream on the device's :9100, inside the +# same XCUITest session as control — so live video and driving coexist. The daemon # needs this stream, so setup does not publish a video URL unless relay ownership and # an initial stream byte are both verified. PHONE_MJPEG_PORT=9100 @@ -4242,9 +3609,9 @@ if [ -f "$DAEMON_PLIST" ]; then _plist_set_env "$DAEMON_STAGED_PLIST" PHONE_REMOTE_WDA_MANAGED true _plist_set_env "$DAEMON_STAGED_PLIST" WDA_ALLOW_LAN "$WDA_ALLOW_LAN" CONFIG_CHANGED=1 - ok "daemon plist set to managed direct + fixed device + WDA control/video endpoints (changed:$CHANGED_KEYS)" + ok "daemon plist set to managed direct + fixed device + runner control/video endpoints (changed:$CHANGED_KEYS)" else - ok "daemon plist already has the managed direct + fixed device + WDA endpoint configuration" + ok "daemon plist already has the managed direct + fixed device + runner endpoint configuration" fi plutil -lint "$DAEMON_STAGED_PLIST" >/dev/null 2>&1 \ @@ -4264,7 +3631,7 @@ if [ -f "$DAEMON_PLIST" ]; then || die "daemon LaunchAgent did not finish stopping" launchctl enable "$GUI_DOMAIN/$DAEMON_LABEL" 2>/dev/null || true if ! launchctl bootstrap "$GUI_DOMAIN" "$DAEMON_PLIST" 2>/dev/null; then - die "WDA is reachable, but the daemon LaunchAgent could not be bootstrapped" + die "the device runner is reachable, but the daemon LaunchAgent could not be bootstrapped" fi fi if launchctl print "$GUI_DOMAIN/$DAEMON_LABEL" >/dev/null 2>&1; then @@ -4291,7 +3658,7 @@ if [ -f "$DAEMON_PLIST" ]; then fi fi else - warn "daemon LaunchAgent not found; WDA can be verified, but the product daemon cannot be started" + warn "daemon LaunchAgent not found; the runner can be verified, but the product daemon cannot be started" printf ' PHONE_REMOTE_BACKEND=direct PHONE_REMOTE_WDA_URL=%s PHONE_REMOTE_WDA_MJPEG_URL=%s iphone-use serve\n' \ "$TARGET_URL" "$TARGET_MJPEG_URL" fi @@ -4317,10 +3684,10 @@ else _validate_pid_record "$RUNNER_PID_FILE" "$LEGACY_RUNNER_EXPECTED" runner \ || die "interactive runner identity was lost before launchd handoff" HANDOFF_OLD_ID="$PID_RECORD_PID|$PID_RECORD_LSTART" - _setstatus supervisor "" "handing WDA to its launchd supervisor" - info "Handing the verified WDA setup to its dedicated launchd supervisor" + _setstatus supervisor "" "handing the device runner to its launchd supervisor" + info "Handing the verified runner setup to its dedicated launchd supervisor" _install_wda_supervisor \ - || die "WDA is reachable now, but its launchd supervisor could not be installed" + || die "the device runner is reachable now, but its launchd supervisor could not be installed" # Bootstrap starts a fresh supervisor-owned setup process. Verify that it # replaced the interactive runner (not merely that launchctl accepted XML) @@ -4348,16 +3715,16 @@ else done if [ "$SUPERVISOR_VERIFIED" != "1" ]; then _setstatus supervisor-fail wda "launchd handoff not verified" - die "WDA launchd job loaded, but its replacement runner was not verified within 120s. + die "runner launchd job loaded, but its replacement runner was not verified within 120s. Check: $WDA_AGENT_LOG Then: $SELF_INSTALL status" fi - ok "launchd replacement verified: runner identity, both loopback relays, and WDA /status" + ok "launchd replacement verified: runner identity, both loopback relays, and runner /status" fi if [ "$SUPERVISOR_VERIFIED" != "1" ] || [ "$MJPEG_READY" != "1" ]; then - _setstatus supervisor-fail wda "WDA is reachable but launchd supervision is unverified" - die "WDA endpoint is up, but dedicated launchd supervision could not be verified" + _setstatus supervisor-fail wda "the device runner is reachable but launchd supervision is unverified" + die "the device runner endpoint is up, but dedicated launchd supervision could not be verified" fi # Post-handoff verdict from one `/agent/status` body. Prints exactly one word: @@ -4452,30 +3819,21 @@ if [ "$DAEMON_HTTP_READY" = "1" ]; then DAEMON_STATUS="" DAEMON_AGENT_SECRET="" if [ "$DAEMON_PRODUCT_READY" != "1" ]; then - _setstatus daemon-fail wda "daemon never reached WDA after verified WDA handoff" - die "WDA, relays, and launchd supervision are verified, but the daemon did not report wda=true within $((DAEMON_STATUS_MAX_TRIES / 2))s. + _setstatus daemon-fail wda "daemon never reached the device runner after a verified handoff" + die "the device runner, relays, and launchd supervision are verified, but the daemon did not report wda=true within $((DAEMON_STATUS_MAX_TRIES / 2))s. Inspect: ~/Library/Logs/iPhoneUse/iphone-use.err" fi if [ "$DAEMON_PRODUCT_VERDICT" = "drivable" ]; then ok "daemon product status verified: drivable=true" elif [ "$DAEMON_LOCKED_HINT" = "1" ]; then - ok "daemon product status verified: WDA reachable through the relays" - warn "the iPhone is locked — unlock it once; the daemon keeps probing and reports drivable=true as soon as WDA can act" + ok "daemon product status verified: device runner reachable through the relays" + warn "the iPhone is locked — unlock it once; the daemon keeps probing and reports drivable=true as soon as the runner can act" else - ok "daemon product status verified: WDA reachable through the relays" - warn "WDA answers but cannot act yet (drivable=false) — keep the iPhone unlocked and awake; the daemon keeps probing" + ok "daemon product status verified: device runner reachable through the relays" + warn "the device runner answers but cannot act yet (drivable=false) — keep the iPhone unlocked and awake; the daemon keeps probing" fi fi -if [ "$WDA_MARKER_REFRESH_ALLOWED" = "1" ]; then - _write_wda_checkout_marker \ - || die "Direct/WDA is healthy, but the checkout ownership marker could not be written atomically. - Runtime changes are being rolled back; the checkout will be preserved by uninstall." - ok "WDA checkout ownership marker: $WDA_CHECKOUT_MARKER" -else - warn "WDA checkout remains unmarked and will be preserved by uninstall: $WDA_CANONICAL_DIR" -fi - SUPERVISOR_HANDOFF_COMPLETE=1 if [ "${WDA_KEEPALIVE:-0}" = "1" ]; then _reset_keepalive_retry \ @@ -4485,9 +3843,9 @@ rm -f "$WDA_AGENT_ROLLBACK_PLIST" "$DAEMON_ROLLBACK_PLIST" "$SELF_INSTALL_ROLLBA SUPERVISOR_TRANSACTION_ACTIVE=0 DAEMON_TRANSACTION_ACTIVE=0 SELF_INSTALL_REPLACED_THIS_RUN=0 -_setstatus ready "" "direct WDA backend and launchd supervisor verified" -printf '\n%s\n' "${BOLD}━━━ WDA device layer verified ━━━${RST}" -printf ' WDA : %s (on-phone), %s (verified relay)\n' "$PHONE_URL" "$TARGET_URL" +_setstatus ready "" "device runner and launchd supervisor verified" +printf '\n%s\n' "${BOLD}━━━ Device layer verified ━━━${RST}" +printf ' Runner : %s (on-phone), %s (verified relay)\n' "$PHONE_URL" "$TARGET_URL" printf ' Supervisor: %s (job, runner, and /status verified)\n' "$GUI_DOMAIN/$WDA_AGENT_LABEL" printf ' Video : %s (startup stream + relay ownership verified)\n' "$TARGET_MJPEG_URL" if [ "$DAEMON_HTTP_READY" = "1" ]; then @@ -4500,13 +3858,13 @@ printf ' Try : curl -H "Authorization: Bearer %s" http://127.0.0.1:%s/age printf ' Stop : %s stop\n' "$SELF_INSTALL" printf ' Pause : %s pause (give the phone back without auto-restart)\n' "$SELF_INSTALL" printf ' Resume : %s resume\n' "$SELF_INSTALL" -printf ' WDA source: %s %s (exact checkout)\n' "$WDA_REF_LABEL" "$WDA_REF" +printf ' Source : %s (sha256 %s)\n' "$RUNNER_SRC" "${RUNNER_SOURCE_HASH:0:12}" printf ' Signing : free Apple ID profiles may expire after 7 days; re-run setup when needed.\n' # One health probe, and what it means. Returns 0 to keep holding, 1 to rebuild. # # A single unanswered probe cannot tell a transient busy period, a network -# hiccup, and a genuinely dead WDA apart — and treating one 4s timeout as a +# hiccup, and a genuinely dead runner apart — and treating one 4s timeout as a # verdict tore down the runner and both relays during heavy element reads. # Requiring consecutive non-answers keeps the distinction the probe cannot # make from becoming a decision. Process death and a vanished listener are @@ -4520,7 +3878,7 @@ _keepalive_probe_verdict() { if [ "$KEEPALIVE_PROBE_FAILURES" -ge "$KEEPALIVE_PROBE_MAX_FAILURES" ]; then return 1 fi - info "WDA did not answer /status within 4s (${KEEPALIVE_PROBE_FAILURES}/${KEEPALIVE_PROBE_MAX_FAILURES}); the runner and relays are alive, so holding" + info "the device runner did not answer /status within 4s (${KEEPALIVE_PROBE_FAILURES}/${KEEPALIVE_PROBE_MAX_FAILURES}); the runner and relays are alive, so holding" return 0 } @@ -4566,14 +3924,14 @@ if [ "${WDA_KEEPALIVE:-0}" = "1" ]; then # Report the observation, not a diagnosis: this loop cannot tell # which cause it was, and naming one sent a session hunting the # wrong thing. - warn "WDA did not answer /status ${KEEPALIVE_PROBE_FAILURES} times in a row while the runner and both relays stayed alive — rebuilding" - _setstatus building "" "WDA did not answer ${KEEPALIVE_PROBE_FAILURES} consecutive /status probes — rebuilding" + warn "the device runner did not answer /status ${KEEPALIVE_PROBE_FAILURES} times in a row while the runner and both relays stayed alive — rebuilding" + _setstatus building "" "the device runner did not answer ${KEEPALIVE_PROBE_FAILURES} consecutive /status probes — rebuilding" ;; relay) - warn "the WDA relay stopped listening — exiting so launchd KeepAlive rebuilds it" + warn "the runner relay stopped listening — exiting so launchd KeepAlive rebuilds it" ;; *) - warn "the WDA runner exited — exiting so launchd KeepAlive rebuilds it" + warn "the device runner exited — exiting so launchd KeepAlive rebuilds it" ;; esac _stop_managed_process "$MJPEG_RELAY_PID_FILE" "$LEGACY_MJPEG_EXPECTED" mjpeg || true diff --git a/scripts/test-install-runner-sources.sh b/scripts/test-install-runner-sources.sh new file mode 100644 index 0000000..b2ed457 --- /dev/null +++ b/scripts/test-install-runner-sources.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# install.sh's device-runner source transaction, in an isolated fake HOME: +# fresh install, upgrade, rollback on a later failure, and the release-archive +# checks (only runner/, no unsafe paths, no links). No network, no device. +set -euo pipefail +umask 077 + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +INSTALLER="$ROOT/install.sh" +TMP_BASE="$(cd -P "${TMPDIR:-/tmp}" && pwd)" +TMP_ROOT="$(cd -P "$(mktemp -d "$TMP_BASE/iphone-use-runner-src-test.XXXXXX")" && pwd)" +trap 'rm -rf "$TMP_ROOT"' EXIT INT TERM + +pass_count=0 +pass() { pass_count=$((pass_count + 1)); printf 'ok %d - %s\n' "$pass_count" "$*"; } +fail_test() { printf 'not ok - %s\n' "$*" >&2; exit 1; } + +new_home() { + TEST_HOME="$TMP_ROOT/$1/home" + mkdir -p "$TEST_HOME/.iphone-use" + TEST_HOME="$(cd -P "$TEST_HOME" && pwd)" + touch "$TEST_HOME/.iphone-use-installer-test-root" +} + +make_tree() { # make_tree + mkdir -p "$1/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcuserdata/me.xcuserdatad" \ + "$1/IPhoneUseRunner/IPhoneUseRunnerUITests" + printf '// %s\n' "$2" > "$1/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj" + printf 'import XCTest // %s\n' "$2" > "$1/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift" + printf 'per-user\n' > "$1/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcuserdata/me.xcuserdatad/state" + printf '# runner %s\n' "$2" > "$1/README.md" +} + +run_hook() { # run_hook + env HOME="$TEST_HOME" \ + IPHONE_USE_INTERNAL_TEST_RUNNER_ONLY=1 \ + IPHONE_USE_INTERNAL_TEST_RUNNER_SRC="${1:-}" \ + IPHONE_USE_INTERNAL_TEST_RUNNER_ARCHIVE="${2:-}" \ + IPHONE_USE_INTERNAL_TEST_RUNNER_FAIL="${3:-0}" \ + /bin/bash "$INSTALLER" >"$TEST_HOME/out" 2>&1 +} + +installed_marker() { + sed -n 's#^// ##p' "$TEST_HOME/.iphone-use/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj" +} + +no_leftovers() { + if find "$TEST_HOME/.iphone-use" -maxdepth 1 -name '.runner.*' | grep -q .; then + fail_test "staging or backup directories were left behind: $(ls -A "$TEST_HOME/.iphone-use")" + fi +} + +# ── fresh install ──────────────────────────────────────────────────────────── +new_home fresh +make_tree "$TEST_HOME/src-v1" v1 +run_hook "$TEST_HOME/src-v1" "" 0 || fail_test "fresh install failed: $(cat "$TEST_HOME/out")" +[ "$(installed_marker)" = v1 ] || fail_test "fresh install did not lay down the sources" +[ -f "$TEST_HOME/.iphone-use/runner/README.md" ] || fail_test "the runner README was not copied" +if find "$TEST_HOME/.iphone-use/runner" -name xcuserdata | grep -q .; then + fail_test "Xcode per-user state was copied into the shared sources" +fi +if find "$TEST_HOME/.iphone-use/runner" -perm -g+w -o -perm -o+w | grep -q .; then + fail_test "installed sources are writable by group or others" +fi +no_leftovers +pass "a fresh install lays down private sources without Xcode per-user state" + +# ── upgrade, then a failure after the swap ─────────────────────────────────── +make_tree "$TEST_HOME/src-v2" v2 +run_hook "$TEST_HOME/src-v2" "" 0 || fail_test "upgrade failed: $(cat "$TEST_HOME/out")" +[ "$(installed_marker)" = v2 ] || fail_test "upgrade did not replace the sources" +no_leftovers +pass "an upgrade replaces the sources and drops the backup on commit" + +make_tree "$TEST_HOME/src-v3" v3 +if run_hook "$TEST_HOME/src-v3" "" 1; then fail_test "the simulated failure did not fail"; fi +[ "$(installed_marker)" = v2 ] || fail_test "a failed install left the new sources in place" +no_leftovers +pass "a failure after the swap restores the previous sources" + +mkdir -p "$TEST_HOME/not-runner" +if run_hook "$TEST_HOME/not-runner" "" 0; then fail_test "a tree without the project was accepted"; fi +[ "$(installed_marker)" = v2 ] || fail_test "a rejected source touched the installed sources" +pass "a source tree without the runner project is refused" + +# ── release archive checks ─────────────────────────────────────────────────── +make_archive() { # make_archive + local work="$TEST_HOME/archive-$1" + mkdir -p "$work" + "$2" "$work" + (cd "$work" && COPYFILE_DISABLE=1 /usr/bin/tar -czf "$TEST_HOME/$1.tar.gz" $(ls -A)) + printf '%s\n' "$TEST_HOME/$1.tar.gz" +} +good_layout() { make_tree "$1/runner" v4; } +extra_layout() { make_tree "$1/runner" v5; printf 'x\n' > "$1/evil.sh"; } +link_layout() { make_tree "$1/runner" v6; ln -s /etc/hosts "$1/runner/IPhoneUseRunner/hosts"; } + +run_hook "" "$(make_archive good good_layout)" 0 || fail_test "a valid runner archive was refused: $(cat "$TEST_HOME/out")" +[ "$(installed_marker)" = v4 ] || fail_test "the archive's sources were not installed" +pass "a valid runner/ archive installs" + +if run_hook "" "$(make_archive extra extra_layout)" 0; then fail_test "an archive with an extra top-level entry was accepted"; fi +grep -q "unexpected entry" "$TEST_HOME/out" || fail_test "extra-entry refusal reason missing" +if run_hook "" "$(make_archive link link_layout)" 0; then fail_test "an archive with a symlink was accepted"; fi +grep -q "contains a link" "$TEST_HOME/out" || fail_test "symlink refusal reason missing" +[ "$(installed_marker)" = v4 ] || fail_test "a refused archive touched the installed sources" +pass "archives with other top-level entries or links are refused" + +printf '1..%d\n' "$pass_count" diff --git a/scripts/test-runner-suite.sh b/scripts/test-runner-suite.sh new file mode 100644 index 0000000..f1731e7 --- /dev/null +++ b/scripts/test-runner-suite.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Runs every device-free setup/install test with HOME pointed at a throwaway +# directory, so no test can reach the real ~/.iphone-use or LaunchAgents. +# Prints one PASS/FAIL line per test and exits non-zero when any fails. +set -u +cd "$(dirname "$0")/.." +SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/iu-test-home.XXXXXX")" +trap 'rm -rf "$SANDBOX"' EXIT +mkdir -p "$SANDBOX/home" +failed=0 +for test in \ + scripts/test-install-release-transaction.sh \ + scripts/test-install-cli-link.sh \ + scripts/test-install-runner-sources.sh \ + scripts/test-uninstall-safety.sh \ + scripts/test-setup-wda-warp-preflight.sh \ + scripts/test-setup-wda-lock-backoff.sh \ + scripts/test-setup-wda-proxy-preflight.sh \ + scripts/test-setup-wda-probe-threshold.sh \ + scripts/test-setup-wda-runner-repair.sh \ + scripts/test-setup-wda-devicectl-timeout.sh \ + scripts/test-setup-wda-lock-wait.sh \ + scripts/test-setup-wda-runner-build.sh \ + scripts/test-setup-wda-status.py \ + scripts/test-setup-wda-runner-product.py \ + scripts/test-setup-wda-asc-signing.py \ + scripts/test-setup-wda-runner-cache.py \ + scripts/test-setup-wda-xcode-compat.py \ + scripts/test-setup-wda-product-verdict.py \ + scripts/test-instance-context.py \ + scripts/test-auto-update.py +do + [ -f "$test" ] || { printf 'SKIP %s (missing)\n' "$test"; continue; } + log="$SANDBOX/$(basename "$test").log" + case "$test" in + *.py) runner=(python3 "$test") ;; + *) runner=(bash "$test") ;; + esac + if HOME="$SANDBOX/home" "${runner[@]}" >"$log" 2>&1; then + printf 'PASS %s\n' "$test" + else + printf 'FAIL %s\n' "$test" + tail -25 "$log" | sed 's/^/ /' + failed=1 + fi +done +exit "$failed" diff --git a/scripts/test-setup-wda-asc-signing.py b/scripts/test-setup-wda-asc-signing.py index 01d0e80..bf7ecd3 100644 --- a/scripts/test-setup-wda-asc-signing.py +++ b/scripts/test-setup-wda-asc-signing.py @@ -16,6 +16,7 @@ "/nonexistent/fixture keys/AuthKey_FAKE123456.p8", 'FAKE123456', '00000000-1111-2222-3333-444444444444', ))) +XCTESTRUN = '/fixture/runner-build/Build/Products/IPhoneUseRunner_iphoneos27.0-arm64.xctestrun' def function(name): @@ -26,7 +27,7 @@ def function(name): HELPERS += ''.join(function(name) for name in ( '_safe_expected', '_valid_team_id', '_valid_bundle_id', '_xml_escape', '_runner_signature_valid', '_command_matches_expected', '_run_runner_prebuild', - '_ensure_launchable_runner', + '_ensure_launchable_runner', '_resolve_xctestrun', '_valid_os_version', )) RESTORE = SOURCE.split('# Restore the saved trio', 1)[1] RESTORE = RESTORE[RESTORE.index('\nif '):].split('\nfi\n', 1)[0] + '\nfi\n' @@ -44,26 +45,26 @@ def setUp(self): def tearDown(self): self.temp.cleanup() - def run_shell(self, body, asc=None): + def run_shell(self, body, asc=None, extra_env=None): harness = self.root / 'run.sh' harness.write_text('''set -eu . "$HELPERS" WDA_UDID=00008110-001234567890001E TEAM_ID=ABCDE12345 WDA_BUNDLE_ID=com.example.wda -WDA_DIR="$FIXTURE_DIR" STATE_DIR="$FIXTURE_DIR" -WDA_RUNNER_NAME=iPhoneUse +RUNNER_SCHEME=IPhoneUseRunner +RUNNER_TEST_ID=IPhoneUseRunnerUITests/RunnerTests/testServe +RUNNER_APP_NAME=iPhoneUse-Runner.app +RUNNER_PROJECT="$FIXTURE_DIR/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj" +RUNNER_DERIVED_DATA="$FIXTURE_DIR/runner-build" +RUNNER_PRODUCTS_DIR="$RUNNER_DERIVED_DATA/Build/Products/Debug-iphoneos" +RUNNER_BUILD_LOCKED=0 WDA_XCTESTRUN="" -WDA_ICON_BUILD_LOCKED=0 XCODEBUILD_BIN=fake_xcodebuild +_ios_sdk_version() { return 1; } fake_xcodebuild() { "$FIXTURE_PYTHON" -c 'import json,os,sys; open(os.environ["CALLS"], "a").write(json.dumps(sys.argv[1:])+"\\n")' "$@" - case " $* " in - *" -showBuildSettings "*) - "$FIXTURE_PYTHON" -c 'import json,os; print(json.dumps([{"target":"WebDriverAgentRunner","buildSettings":{"BUILT_PRODUCTS_DIR":os.environ["FIXTURE_DIR"]+"/Build/Products/Debug-iphoneos"}}]))' - ;; - esac } _setstatus() { "$FIXTURE_PYTHON" -c 'import json,os,sys; json.dump(dict(zip(("phase","blocked_on","message"),sys.argv[1:])),open(os.environ["STATUS_LOG"],"w"))' "$@" @@ -73,6 +74,7 @@ def run_shell(self, body, asc=None): ''' + body + '\n') env = {k: v for k, v in os.environ.items() if k not in ASC_NAMES} env.update(asc or {}) + env.update(extra_env or {}) env.update(HELPERS=str(self.helpers), FIXTURE_DIR=str(self.root), FIXTURE_PYTHON=sys.executable, CALLS=str(self.calls), STATUS_LOG=str(self.status)) return subprocess.run(['/bin/bash', str(harness)], env=env, cwd=self.root, @@ -84,28 +86,24 @@ def suffix(self, asc=FAKE_ASC): '-authenticationKeyIssuerID', asc['WDA_ASC_ISSUER_ID'], '-allowProvisioningDeviceRegistration'] - def test_all_presence_combinations_preserve_both_runner_forms(self): + def test_all_presence_combinations_launch_from_the_built_product(self): for bits in itertools.product((False, True), repeat=3): asc = {key: FAKE_ASC[key] for key, present in zip(ASC_NAMES, bits) if present} - for icon in (False, True): - with self.subTest(bits=bits, icon=icon): - path = '/fixture/Build/Products/WebDriverAgentRunner_iphoneos.xctestrun' - body = ('WDA_XCTESTRUN=' + path + '\n' if icon else '') - result = self.run_shell(body + '_prepare_runner_args\ndump_runner', asc) - self.assertEqual(result.returncode, 0, result.stderr) - text, *argv = json.loads(result.stdout) - base = (['-destination', 'platform=iOS,id=00008110-001234567890001E', - 'test-without-building', '-xctestrun', path] if icon else - ['-project', 'WebDriverAgent.xcodeproj', '-scheme', 'WebDriverAgentRunner', - '-destination', 'platform=iOS,id=00008110-001234567890001E', - '-allowProvisioningUpdates', 'DEVELOPMENT_TEAM=ABCDE12345', - 'PRODUCT_BUNDLE_IDENTIFIER=com.example.wda', 'test']) - if all(bits): - if icon: - base += ['-allowProvisioningUpdates'] - base += self.suffix() - self.assertEqual(argv, base) - self.assertEqual(text, ' '.join(argv)) + with self.subTest(bits=bits): + result = self.run_shell('WDA_XCTESTRUN=' + XCTESTRUN + '\n_prepare_runner_args\ndump_runner', asc) + self.assertEqual(result.returncode, 0, result.stderr) + text, *argv = json.loads(result.stdout) + base = ['-destination', 'platform=iOS,id=00008110-001234567890001E', + 'test-without-building', '-xctestrun', XCTESTRUN, + '-only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe'] + if all(bits): + base += ['-allowProvisioningUpdates'] + self.suffix() + self.assertEqual(argv, base) + self.assertEqual(text, ' '.join(argv)) + + def test_runner_cannot_launch_without_a_resolved_product(self): + result = self.run_shell('_prepare_runner_args', FAKE_ASC) + self.assertNotEqual(result.returncode, 0) def test_all_xcode_actions_receive_one_complete_signing_suffix(self): for action in ('build-for-testing', 'test', 'test-without-building', '-showBuildSettings', '-showdestinations'): @@ -119,24 +117,50 @@ def test_all_xcode_actions_receive_one_complete_signing_suffix(self): self.assertEqual(argv[-7:], self.suffix()) self.assertEqual(argv.count('-allowProvisioningUpdates'), 1) - def test_real_prebuild_and_settings_helpers_forward_signing(self): - result = self.run_shell('''_run_runner_prebuild "$FIXTURE_DIR/build.log" + def test_runner_project_actions_build_into_the_state_directory(self): + result = self.run_shell('_runner_xcodebuild -showdestinations', FAKE_ASC) + self.assertEqual(result.returncode, 0, result.stderr) + argv = json.loads(self.calls.read_text()) + self.assertEqual(argv[:6], ['-project', str(self.root / 'runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj'), + '-scheme', 'IPhoneUseRunner', + '-derivedDataPath', str(self.root / 'runner-build')]) + self.assertEqual(argv[-7:], self.suffix()) + + def test_real_prebuild_and_launch_path_forward_signing(self): + result = self.run_shell('''mkdir -p "$RUNNER_PRODUCTS_DIR/$RUNNER_APP_NAME" +: > "$RUNNER_DERIVED_DATA/Build/Products/IPhoneUseRunner_iphoneos27.0-arm64.xctestrun" +_run_runner_prebuild "$FIXTURE_DIR/build.log" _repair_runner_if_invalid() { return 0; } _ensure_launchable_runner +printf '%s\\n' "$WDA_XCTESTRUN" "$RUNNER_APP_PATH" ''', FAKE_ASC) self.assertEqual(result.returncode, 0, result.stderr) calls = [json.loads(line) for line in self.calls.read_text().splitlines()] - # Missing products cause the guarded launch path to prebuild as well. - self.assertEqual(len(calls), 3) - self.assertIn('build-for-testing', calls[0]) - self.assertIn('-showBuildSettings', calls[1]) - self.assertIn('build-for-testing', calls[2]) + # The explicit prebuild plus the one _ensure_launchable_runner always runs. + self.assertEqual(len(calls), 2) for argv in calls: + self.assertEqual(argv[:6], ['-project', str(self.root / 'runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj'), + '-scheme', 'IPhoneUseRunner', + '-derivedDataPath', str(self.root / 'runner-build')]) + self.assertIn('build-for-testing', argv) + self.assertIn('PRODUCT_BUNDLE_IDENTIFIER=com.example.wda', argv) + self.assertIn('DEVELOPMENT_TEAM=ABCDE12345', argv) self.assertEqual(argv[-7:], self.suffix()) + xctestrun, app = result.stdout.splitlines() + self.assertTrue(xctestrun.endswith('/runner-build/Build/Products/IPhoneUseRunner_iphoneos27.0-arm64.xctestrun')) + self.assertTrue(app.endswith('/Debug-iphoneos/iPhoneUse-Runner.app')) + + def test_build_without_a_runner_app_is_not_launchable(self): + result = self.run_shell('''_repair_runner_if_invalid() { return 0; } +if _ensure_launchable_runner; then exit 91; fi +printf '%s' "$WDA_RUNNER_VALIDATION_ERROR" +''', FAKE_ASC) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('produced no iPhoneUse-Runner.app', result.stdout) def test_key_path_is_one_literal_argument(self): asc = {**FAKE_ASC, 'WDA_ASC_KEY_PATH': str(self.root / "spaces ' $(touch MUST_NOT_EXIST) `false`.p8")} - result = self.run_shell('_prepare_runner_args\n_wda_xcodebuild "${RUNNER_ARGV[@]:0:10}"', asc) + result = self.run_shell('WDA_XCTESTRUN=' + XCTESTRUN + '\n_prepare_runner_args\n_wda_xcodebuild "${RUNNER_ARGV[@]:0:10}"', asc) self.assertEqual(result.returncode, 0, result.stderr) argv = json.loads(self.calls.read_text()) self.assertEqual(argv[argv.index('-authenticationKeyPath') + 1], asc['WDA_ASC_KEY_PATH']) @@ -148,7 +172,8 @@ def test_invalid_complete_configuration_fails_without_echoing_values(self): ('WDA_ASC_KEY_ID', 'INVALID ID'), ('WDA_ASC_ISSUER_ID', 'INVALID|ISSUER')): with self.subTest(field=field): - result = self.run_shell('_prepare_runner_args', {**FAKE_ASC, field: value}) + result = self.run_shell('WDA_XCTESTRUN=' + XCTESTRUN + '\n_prepare_runner_args', + {**FAKE_ASC, field: value}) self.assertNotEqual(result.returncode, 0) self.assertNotIn(value, result.stdout + result.stderr) self.assertFalse(self.calls.exists()) @@ -164,24 +189,47 @@ def test_missing_account_message_is_generic_and_preserves_blocker(self): for value in FAKE_ASC.values(): self.assertNotIn(value, visible) - def test_modern_and_legacy_identity_accept_both_forms_with_and_without_key(self): + def test_runner_identity_with_and_without_key(self): for asc in ({}, FAKE_ASC): - for icon in (False, True): - with self.subTest(asc=bool(asc), icon=icon): - body = ('WDA_XCTESTRUN=/fixture/Build/Products/WebDriverAgentRunner_iphoneos.xctestrun\n' if icon else '') - result = self.run_shell(body + '''_prepare_runner_args + with self.subTest(asc=bool(asc)): + result = self.run_shell('WDA_XCTESTRUN=' + XCTESTRUN + ''' +_prepare_runner_args command="/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild $RUNNER_ARGS" _command_matches_expected "$command" "runner:$command" -_command_matches_expected "$command" "legacy-runner:$WDA_UDID:$TEAM_ID:$WDA_BUNDLE_ID" -if _command_matches_expected "$command" "legacy-runner:00008110-0000000000000000:$TEAM_ID:$WDA_BUNDLE_ID"; then exit 91; fi +# PID-only (legacy) records only ever named a WebDriverAgent runner. +if _command_matches_expected "$command" "legacy-runner:$WDA_UDID:$TEAM_ID:$WDA_BUNDLE_ID"; then exit 91; fi if _command_matches_expected "$command changed" "runner:$command"; then exit 92; fi +other="${command/RunnerTests\\/testServe/RunnerTests\\/testOther}" +if _command_matches_expected "$other" "runner:$other"; then exit 93; fi ''', asc) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_webdriveragent_runners_stay_recognised_for_the_upgrade_stop(self): + # The first setup/stop after an upgrade must still recognise (and so be + # able to stop) the WebDriverAgent runner the previous release started. + xcodebuild = '/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild ' + for asc in ({}, FAKE_ASC): + suffix = (' ' + ' '.join(self.suffix())) if asc else '' + for command in ( + xcodebuild + '-project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner ' + '-destination platform=iOS,id=00008110-001234567890001E -allowProvisioningUpdates ' + 'DEVELOPMENT_TEAM=ABCDE12345 PRODUCT_BUNDLE_IDENTIFIER=com.example.wda test' + suffix, + xcodebuild + '-destination platform=iOS,id=00008110-001234567890001E test-without-building ' + '-xctestrun /x/Build/Products/WebDriverAgentRunner_iphoneos27.0-arm64.xctestrun' + + (' -allowProvisioningUpdates' + suffix if asc else ''), + ): + with self.subTest(asc=bool(asc), form='test' if ' test' in command[-60:] else 'xctestrun'): + result = self.run_shell('''command="$OLD_COMMAND" +_command_matches_expected "$command" "runner:$command" +_command_matches_expected "$command" "legacy-runner:$WDA_UDID:$TEAM_ID:$WDA_BUNDLE_ID" +''', asc, {'OLD_COMMAND': command}) self.assertEqual(result.returncode, 0, result.stderr) def test_identity_rejects_incomplete_or_extra_arguments(self): - result = self.run_shell('''_prepare_runner_args + result = self.run_shell('WDA_XCTESTRUN=' + XCTESTRUN + ''' +_prepare_runner_args command="xcodebuild $RUNNER_ARGS" -for bad in "$command -quiet" "${command% -allowProvisioningDeviceRegistration}" "${command/ -authenticationKeyID FAKE123456/}"; do +for bad in "$command -quiet" "${command% -allowProvisioningDeviceRegistration}" "${command/ -authenticationKeyID FAKE123456/}" "${command/ -only-testing:IPhoneUseRunnerUITests\\/RunnerTests\\/testServe/}"; do if _command_matches_expected "$bad" "runner:$bad"; then exit 91; fi if _command_matches_expected "$bad" "legacy-runner:$WDA_UDID:$TEAM_ID:$WDA_BUNDLE_ID"; then exit 92; fi done @@ -196,7 +244,7 @@ def test_saved_trio_is_restored_only_without_any_explicit_override(self): WDA_ASC_ISSUER_ID) printf saved-issuer ;; esac } -''' + RESTORE + '\n_prepare_runner_args\ndump_runner' +''' + RESTORE + '\nWDA_XCTESTRUN=' + XCTESTRUN + '\n_prepare_runner_args\ndump_runner' restored = self.run_shell(body) self.assertEqual(restored.returncode, 0, restored.stderr) self.assertIn('/saved/AuthKey_SAVED.p8', json.loads(restored.stdout)) @@ -207,23 +255,34 @@ def test_saved_trio_is_restored_only_without_any_explicit_override(self): self.assertEqual(disabled.returncode, 0, disabled.stderr) self.assertNotIn('-authenticationKeyPath', json.loads(disabled.stdout)) - def test_supervisor_persists_only_complete_key_references(self): + def supervisor_env(self, asc=None, runner_src=None): install = function('_install_wda_supervisor') loop = ' for key in ' + install.split(' for key in ', 1)[1].split('\n done', 1)[0] + '\n done\n' - body = '''WDA_REF=fixture -WDA_RUNNER_ICON=none + body = '''RUNNER_DEFAULT_SRC=/fixture/home/.iphone-use/runner +RUNNER_SRC="${FIXTURE_RUNNER_SRC:-$RUNNER_DEFAULT_SRC}" WDA_PORT=8100 MJPEG_PORT=9100 env_block="" ''' + loop + '''printf '%s' "$env_block" ''' + extra = {'FIXTURE_RUNNER_SRC': runner_src} if runner_src else {} + result = self.run_shell(body, asc, extra) + self.assertEqual(result.returncode, 0, result.stderr) + return plistlib.loads(result.stdout.encode()) + + def test_supervisor_persists_only_complete_key_references(self): for asc in ({}, {'WDA_ASC_KEY_PATH': FAKE_ASC['WDA_ASC_KEY_PATH']}, FAKE_ASC): with self.subTest(complete=len(asc) == 3): - result = self.run_shell(body, asc) - self.assertEqual(result.returncode, 0, result.stderr) - saved = plistlib.loads(result.stdout.encode()) + saved = self.supervisor_env(asc) found = {key: saved[key] for key in ASC_NAMES if key in saved} self.assertEqual(found, FAKE_ASC if len(asc) == 3 else {}) + for retired in ('WDA_DIR', 'WDA_REF', 'WDA_RUNNER_ICON'): + self.assertNotIn(retired, saved) + + def test_supervisor_persists_only_a_non_default_runner_source(self): + self.assertNotIn('IPU_RUNNER_SRC', self.supervisor_env()) + custom = self.supervisor_env(runner_src='/work/iphone-use/runner') + self.assertEqual(custom['IPU_RUNNER_SRC'], '/work/iphone-use/runner') if __name__ == '__main__': diff --git a/scripts/test-setup-wda-icon-injection.sh b/scripts/test-setup-wda-icon-injection.sh deleted file mode 100755 index dbb4611..0000000 --- a/scripts/test-setup-wda-icon-injection.sh +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env bash -# Deterministic icon compilation/signing/rollback checks; no iPhone required. -# shellcheck disable=SC1091,SC2034,SC2329 -# The test dynamically extracts production helpers, so ShellCheck cannot see -# their reads or the eval-time call used to prove the `none` branch is inert. -set -euo pipefail -umask 077 -unset WDA_ASC_KEY_PATH WDA_ASC_KEY_ID WDA_ASC_ISSUER_ID - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SETUP="$ROOT/scripts/setup-wda.sh" -TMP_ROOT_RAW="$(mktemp -d "${TMPDIR:-/tmp}/iphone-use-icon-test.XXXXXX")" -TMP_ROOT="$(cd -P "$TMP_ROOT_RAW" && pwd)" - -cleanup() { - rm -rf "$TMP_ROOT" -} -trap cleanup EXIT INT TERM - -pass_count=0 -pass() { - pass_count=$((pass_count + 1)) - printf 'ok %d - %s\n' "$pass_count" "$1" -} -fail_test() { - printf 'not ok - %s\n' "$1" >&2 - exit 1 -} - -TEST_BIN="$TMP_ROOT/bin" -STATE_DIR="$TMP_ROOT/state" -WDA_DIR="$TMP_ROOT/wda" -SIGN_LOG="$TMP_ROOT/codesign.log" -mkdir -p "$TEST_BIN" "$STATE_DIR" "$WDA_DIR" - -# Load the exact production helpers without running setup-wda's command body. -awk ' - /^_cleanup_wda_icon_work_dir\(\)/ { copying=1 } - copying && /^if \[ "\$COMMAND" = "setup" \]; then/ { exit } - copying { print } -' "$SETUP" > "$TMP_ROOT/icon-transaction-functions.sh" -awk ' - /^_runner_icon_fail\(\)/ { copying=1 } - copying && /^if \[ -z "\$\{WDA_UDID:-\}" \]; then/ { exit } - copying { print } -' "$SETUP" > "$TMP_ROOT/icon-injection-functions.sh" -awk ' - /^# BEGIN ASC signing helpers\./ { copying=1; next } - /^# END ASC signing helpers\./ { copying=0 } - copying { print } - /^_safe_expected\(\)/ { copying=1; print } - copying && /^}/ { if (safe) copying=0 } - /^_safe_expected\(\)/ { safe=1 } -' "$SETUP" > "$TMP_ROOT/asc-functions.sh" -[ -s "$TMP_ROOT/icon-transaction-functions.sh" ] \ - && [ -s "$TMP_ROOT/icon-injection-functions.sh" ] \ - || fail_test "could not isolate the production icon helpers" - -info() { printf 'info: %s\n' "$*"; } -ok() { printf 'ok: %s\n' "$*"; } -warn() { printf 'warn: %s\n' "$*"; } -_setstatus() { :; } -WDA_ICON_WORK_DIR="" -WDA_ICON_PRODUCTS_DIR="" -WDA_ICON_APP_PATH="" -WDA_ICON_BACKUP_PATH="" -WDA_ICON_MUTATION_ACTIVE=0 -WDA_RUNNER_ICON_INJECTED=0 -WDA_ICON_BUILD_LOCKED=0 -WDA_RUNNER_NAME=iPhoneUse -WDA_UDID=00008110-001234567890001E -TEAM_ID=ABCDE12345 -WDA_BUNDLE_ID=com.example.wda -# Exercise both icon prebuild and showBuildSettings with the complete signing -# suffix; these values are synthetic and the key path is never opened. -WDA_ASC_KEY_PATH="/nonexistent/fixture keys/AuthKey_FAKE123456.p8" -WDA_ASC_KEY_ID=FAKE123456 -WDA_ASC_ISSUER_ID=00000000-1111-2222-3333-444444444444 -. "$TMP_ROOT/icon-transaction-functions.sh" -. "$TMP_ROOT/icon-injection-functions.sh" -. "$TMP_ROOT/asc-functions.sh" - -cat > "$TEST_BIN/iconutil" <<'SH' -#!/usr/bin/env bash -set -eu -output="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "-o" ]; then output="$2"; shift 2; else shift; fi -done -[ -n "$output" ] -mkdir -p "$output" -printf 'fake 1024 png\n' > "$output/icon_512x512@2x.png" -SH - -cat > "$TEST_BIN/sips" <<'SH' -#!/usr/bin/env bash -set -eu -case " $* " in - *" pixelWidth "*) - printf ' pixelWidth: 1024\n pixelHeight: 1024\n' - ;; - *" hasAlpha "*) - printf ' hasAlpha: no\n' - ;; - *) exit 64 ;; -esac -SH - -cat > "$TEST_BIN/xcrun" <<'SH' -#!/usr/bin/env bash -set -eu -if [ "${1:-}" = "--find" ] && [ "${2:-}" = "actool" ]; then - printf '%s/actool\n' "$TEST_BIN" - exit 0 -fi -[ "${1:-}" = "actool" ] || exit 64 -shift -compiled="" -partial="" -while [ "$#" -gt 0 ]; do - case "$1" in - --compile) compiled="$2"; shift 2 ;; - --output-partial-info-plist) partial="$2"; shift 2 ;; - *) shift ;; - esac -done -[ -n "$compiled" ] && [ -n "$partial" ] -mkdir -p "$compiled" -printf 'asset catalog\n' > "$compiled/Assets.car" -printf 'phone icon\n' > "$compiled/AppIcon60x60@2x.png" -printf 'ipad icon\n' > "$compiled/AppIcon76x76@2x~ipad.png" -cat > "$partial" <<'PLIST' - - - - CFBundleIcons - CFBundlePrimaryIcon - CFBundleIconFilesAppIcon60x60 - CFBundleIconNameAppIcon - - - CFBundleIcons~ipad - CFBundlePrimaryIcon - CFBundleIconFilesAppIcon60x60AppIcon76x76 - - - -PLIST -SH - -cat > "$TEST_BIN/xcodebuild-stub" <<'SH' -#!/usr/bin/env bash -set -eu -case " $* " in - *" -showBuildSettings "*) - printf '[{"target":"WebDriverAgentRunner","buildSettings":{"BUILT_PRODUCTS_DIR":"%s"}}]\n' "$PRODUCTS_DIR" - exit 0 - ;; - *" build-for-testing "*) ;; - *) exit 64 ;; -esac -if [ "${XCODE_LOCK_FAIL:-0}" = "1" ]; then - printf 'Error Domain=com.apple.dt.deviceprep Code=-3: Unlock iPhone to Continue\n' - exit 1 -fi -app="$PRODUCTS_DIR/iPhoneUse-Runner.app" -rm -rf "$app" -mkdir -p "$app/Frameworks/Fake.framework" "$app/PlugIns/RunnerTests.xctest" -printf 'framework\n' > "$app/Frameworks/Fake.framework/Fake" -printf 'dylib\n' > "$app/Frameworks/libFake.dylib" -printf 'tests\n' > "$app/PlugIns/RunnerTests.xctest/RunnerTests" -printf 'runner\n' > "$app/iPhoneUse-Runner" -chmod +x "$app/iPhoneUse-Runner" "$app/Frameworks/Fake.framework/Fake" \ - "$app/PlugIns/RunnerTests.xctest/RunnerTests" -printf 'CFBundleExecutableFake\n' \ - > "$app/Frameworks/Fake.framework/Info.plist" -printf 'CFBundleExecutableRunnerTests\n' \ - > "$app/PlugIns/RunnerTests.xctest/Info.plist" -printf 'pristine\n' > "$app/original-marker.txt" -cat > "$app/Info.plist" <<'PLIST' - - -CFBundleNameiPhoneUse-RunnerCFBundleExecutableiPhoneUse-Runner -PLIST -SH - -cat > "$TEST_BIN/codesign" <<'SH' -#!/usr/bin/env bash -set -eu -if [ "${1:-}" = "-dvv" ]; then - printf 'Authority=Apple Development: Fixture (ABCDE12345)\n' >&2 - exit 0 -fi -if [ "${1:-}" = "-d" ] && [ "${2:-}" = "--entitlements" ]; then - cat <<'PLIST' - - -application-identifierABCDE12345.com.example.wda -PLIST - exit 0 -fi -target="" -for argument in "$@"; do target="$argument"; done -case " $* " in - *" --verify "*) - printf 'verify:%s\n' "$target" >> "$SIGN_LOG" - exit 0 - ;; - *" -f "*) - printf 'sign:%s\n' "$target" >> "$SIGN_LOG" - if [ "${CODE_SIGN_FAIL_ON_OUTER:-0}" = "1" ]; then - case "$target" in *-Runner.app) exit 1 ;; esac - fi - exit 0 - ;; - *) exit 64 ;; -esac -SH -chmod 700 "$TEST_BIN"/* - -export PATH="$TEST_BIN:/usr/bin:/bin:/usr/sbin:/sbin" -export TEST_BIN SIGN_LOG -XCODEBUILD_BIN="$TEST_BIN/xcodebuild-stub" -ICON_SOURCE="$TMP_ROOT/source.icns" -printf 'fake icns\n' > "$ICON_SOURCE" - -PRODUCTS_DIR="$TMP_ROOT/success/Build/Products/Debug-iphoneos" -export PRODUCTS_DIR -mkdir -p "$PRODUCTS_DIR" -: > "$SIGN_LOG" -CODE_SIGN_FAIL_ON_OUTER=0 -export CODE_SIGN_FAIL_ON_OUTER -_build_and_inject_runner_icon "$ICON_SOURCE" >"$TMP_ROOT/success.out" \ - || fail_test "icon injection failed with complete fake actool output" -APP="$PRODUCTS_DIR/iPhoneUse-Runner.app" -for artifact in Assets.car AppIcon60x60@2x.png AppIcon76x76@2x~ipad.png; do - [ -f "$APP/$artifact" ] || fail_test "injected app is missing $artifact" -done -[ "$(/usr/libexec/PlistBuddy \ - -c 'Print :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName' \ - "$APP/Info.plist")" = "AppIcon" ] \ - || fail_test "partial plist metadata was not merged" -pass "actool outputs and CFBundleIconName are injected together" - -xctest_line="$(sed -n '/^sign:.*\/PlugIns\/.*\.xctest$/=' "$SIGN_LOG" | head -1)" -outer_line="$(sed -n '/^sign:.*\/iPhoneUse-Runner\.app$/=' "$SIGN_LOG" | head -1)" -[ -n "$xctest_line" ] && [ -n "$outer_line" ] && [ "$xctest_line" -lt "$outer_line" ] \ - || fail_test "outer runner was signed before its xctest bundle" -pass "nested xctest signing precedes the outer runner signature" - -PRODUCTS_DIR="$TMP_ROOT/rollback/Build/Products/Debug-iphoneos" -export PRODUCTS_DIR -mkdir -p "$PRODUCTS_DIR" -: > "$SIGN_LOG" -CODE_SIGN_FAIL_ON_OUTER=1 -export CODE_SIGN_FAIL_ON_OUTER -if _build_and_inject_runner_icon "$ICON_SOURCE" >"$TMP_ROOT/rollback.out"; then - fail_test "forced outer-signature failure unexpectedly succeeded" -fi -APP="$PRODUCTS_DIR/iPhoneUse-Runner.app" -[ "$(cat "$APP/original-marker.txt")" = "pristine" ] \ - || fail_test "failed injection did not restore the pristine app" -[ ! -e "$APP/Assets.car" ] \ - || fail_test "failed injection left a modified asset catalog behind" -if /usr/libexec/PlistBuddy \ - -c 'Print :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName' \ - "$APP/Info.plist" >/dev/null 2>&1; then - fail_test "failed injection left icon metadata behind" -fi -codesign --verify --deep --strict "$APP" \ - || fail_test "restored runner did not pass signature verification" -[ "$WDA_ICON_MUTATION_ACTIVE" = "0" ] \ - || fail_test "rollback left the icon transaction active" -pass "signature failure restores and verifies the pristine runner" - -PRODUCTS_DIR="$TMP_ROOT/locked/Build/Products/Debug-iphoneos" -export PRODUCTS_DIR -mkdir -p "$PRODUCTS_DIR" -WDA_ICON_BUILD_LOCKED=0 -WDA_KEEPALIVE=1 -XCODE_LOCK_FAIL=1 -export WDA_KEEPALIVE XCODE_LOCK_FAIL -if _build_and_inject_runner_icon "$ICON_SOURCE" >"$TMP_ROOT/locked.out"; then - fail_test "lock-blocked build-for-testing unexpectedly succeeded" -fi -[ "$WDA_ICON_BUILD_LOCKED" = "1" ] \ - || fail_test "lock-blocked prebuild did not hand off to lock backoff" -[ -z "$WDA_ICON_WORK_DIR" ] \ - || fail_test "lock-blocked prebuild retained its temporary assets" -pass "lock-blocked prebuild skips the second xcodebuild attempt in that cycle" -unset WDA_KEEPALIVE XCODE_LOCK_FAIL - -SKIP_MARKER="$TMP_ROOT/none-called" -export SKIP_MARKER -selection_block="$(awk ' - /^RUNNER_ICON_SOURCE=""/ { copying=1 } - copying && /^RUNNER_COMMAND=/ { exit } - copying { print } -' "$SETUP")" -[ -n "$selection_block" ] || fail_test "could not isolate WDA_RUNNER_ICON selection" -( - WDA_RUNNER_ICON=none - WDA_ICON_BUILD_LOCKED=0 - _build_and_inject_runner_icon() { : > "$SKIP_MARKER"; } - _ensure_launchable_runner() { return 0; } - eval "$selection_block" -) -[ ! -e "$SKIP_MARKER" ] \ - || fail_test "WDA_RUNNER_ICON=none still entered build or injection" -pass "WDA_RUNNER_ICON=none skips build and injection completely" - -printf '1..%d\n' "$pass_count" diff --git a/scripts/test-setup-wda-lock-backoff.sh b/scripts/test-setup-wda-lock-backoff.sh index 7ee223c..d3e0731 100755 --- a/scripts/test-setup-wda-lock-backoff.sh +++ b/scripts/test-setup-wda-lock-backoff.sh @@ -73,7 +73,7 @@ run_retry locked >"$TMP_ROOT/locked-first.out" [ "$(retry_field '3s/^attempt=//p')" = "1" ] \ || fail_test "switching to lock backoff did not reset the attempt" assert_delay_near 5 -grep -q 'lock screen blocked WDA' "$TMP_ROOT/locked-first.out" \ +grep -q 'lock screen blocked the device runner' "$TMP_ROOT/locked-first.out" \ || fail_test "first lock transition was not reported" # A locked phone is its own blocker. Publishing `wda` here made the daemon # hint and the web client tell the operator to read logs and re-run setup for @@ -107,7 +107,7 @@ run_retry locked >"$TMP_ROOT/locked-second.out" [ "$(retry_field '3s/^attempt=//p')" = "2" ] \ || fail_test "lock retry attempt did not increment" assert_delay_near 10 -if grep -q 'lock screen blocked WDA' "$TMP_ROOT/locked-second.out"; then +if grep -q 'lock screen blocked the device runner' "$TMP_ROOT/locked-second.out"; then fail_test "unchanged lock state repeated the user prompt" fi for _ in 3 4 5 6 7; do @@ -311,14 +311,14 @@ loaded="$(sed -n 's/^loaded=//p' "$LAUNCH_STATE")" disabled="$(sed -n 's/^disabled=//p' "$LAUNCH_STATE")" [ "$loaded:$disabled" = "0:1" ] \ || fail_test "pause did not unload and disable the exact supervisor" -grep -q 'WDA paused' "$TMP_ROOT/pause.out" \ +grep -q 'device runner paused' "$TMP_ROOT/pause.out" \ || fail_test "pause did not report the verified result" pass "pause disables launchd before stopping the managed stack" if run_lifecycle status >"$TMP_ROOT/status.out" 2>&1; then fail_test "status treated an intentional pause as healthy" fi -grep -q 'WDA is paused' "$TMP_ROOT/status.out" \ +grep -q 'the device runner is paused' "$TMP_ROOT/status.out" \ || fail_test "status did not explain the intentional pause" pass "status distinguishes an intentional pause from an unknown outage" @@ -328,7 +328,7 @@ loaded="$(sed -n 's/^loaded=//p' "$LAUNCH_STATE")" disabled="$(sed -n 's/^disabled=//p' "$LAUNCH_STATE")" [ "$loaded:$disabled" = "1:0" ] \ || fail_test "resume did not enable and bootstrap the exact supervisor" -grep -q 'WDA resume requested' "$TMP_ROOT/resume.out" \ +grep -q 'device runner resume requested' "$TMP_ROOT/resume.out" \ || fail_test "resume did not report the verified launchd handoff" pass "resume validates, enables, and bootstraps the managed supervisor" diff --git a/scripts/test-setup-wda-lock-wait.sh b/scripts/test-setup-wda-lock-wait.sh index 564e314..faab381 100755 --- a/scripts/test-setup-wda-lock-wait.sh +++ b/scripts/test-setup-wda-lock-wait.sh @@ -24,7 +24,7 @@ fi FAKE chmod +x "$tmp/bin/xcrun" # The block under test: from the lock-wait header to the build status line. -block="$(sed -n '/^# ── 3b. Wait for the phone to be unlocked/,/^_setstatus building "\$_BUILD_BLOCKER" "building + launching WDA"$/p' "$here/setup-wda.sh")" +block="$(sed -n '/^# ── 3b. Wait for the phone to be unlocked/,/^_setstatus building "\$_BUILD_BLOCKER" "building + launching the device runner"$/p' "$here/setup-wda.sh")" [ -n "$block" ] || { echo "not ok 1 - lock-wait block not found"; exit 1; } devicectl_t="$(sed -n '/^_devicectl_t() {/,/^}/p' "$here/setup-wda.sh")" run() { # $1 = seconds until unlock (or "never"), extra env after diff --git a/scripts/test-setup-wda-product-verdict.py b/scripts/test-setup-wda-product-verdict.py index 7e25082..e00337d 100644 --- a/scripts/test-setup-wda-product-verdict.py +++ b/scripts/test-setup-wda-product-verdict.py @@ -18,7 +18,7 @@ def function(name): GATE_START = ' DAEMON_STATUS_TRIES=0\n DAEMON_PRODUCT_VERDICT=down\n' GATE = SOURCE[SOURCE.index(GATE_START):] -GATE = GATE[:GATE.index('\nif [ "$WDA_MARKER_REFRESH_ALLOWED" = "1" ]; then')] +GATE = GATE[:GATE.index('\nSUPERVISOR_HANDOFF_COMPLETE=1\n')] GATE = 'if [ "$DAEMON_HTTP_READY" = "1" ]; then\n' + GATE + '\n' # A real v0.6.2 body, trimmed to the fields the verdict reads plus decoys. @@ -99,7 +99,7 @@ def run_gate(self, bodies, *, max_tries=6, grace=3): def test_locked_phone_passes_after_grace_with_a_hint(self): code, log = self.run_gate([status(), status(wda=True, wda_locked=True)] + [status(wda=True, wda_locked=True)] * 10) self.assertEqual(code, 0, log) - self.assertIn('ok: daemon product status verified: WDA reachable through the relays', log) + self.assertIn('ok: daemon product status verified: device runner reachable through the relays', log) self.assertTrue(any(line.startswith('warn: the iPhone is locked') for line in log), log) self.assertIn('tries=4 verdict=reachable ready=1', log) # 1 down + 3 reachable (grace) self.assertFalse(any(line.startswith('die:') for line in log), log) @@ -114,7 +114,7 @@ def test_drivable_inside_grace_short_circuits(self): def test_unreachable_wda_still_fails_closed(self): code, log = self.run_gate([status()] * 6) self.assertEqual(code, 1, log) - self.assertIn('status: daemon-fail wda daemon never reached WDA after verified WDA handoff', log) + self.assertIn('status: daemon-fail wda daemon never reached the device runner after a verified handoff', log) self.assertTrue(any(line.startswith('die:') and 'wda=true within 3s' in line for line in log), log) def test_reachable_unlocked_but_not_actionable_warns_without_locked_hint(self): diff --git a/scripts/test-setup-wda-runner-build.sh b/scripts/test-setup-wda-runner-build.sh new file mode 100644 index 0000000..2e3c9f6 --- /dev/null +++ b/scripts/test-setup-wda-runner-build.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# The device-runner build path in setup-wda.sh, with every external tool +# stubbed: source discovery and validation, the source hash that keys the +# product cache, and the build/launch selection (cache hit, build, lock, +# signing failures). No iPhone, no xcodebuild, nothing under the real $HOME. +# shellcheck disable=SC1091,SC2034,SC2329 +set -euo pipefail +umask 077 +unset WDA_ASC_KEY_PATH WDA_ASC_KEY_ID WDA_ASC_ISSUER_ID IPU_RUNNER_SRC + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SETUP="$ROOT/scripts/setup-wda.sh" +TMP_ROOT_RAW="$(mktemp -d "${TMPDIR:-/tmp}/iphone-use-runner-build-test.XXXXXX")" +TMP_ROOT="$(cd -P "$TMP_ROOT_RAW" && pwd)" +trap 'rm -rf "$TMP_ROOT"' EXIT INT TERM + +pass_count=0 +pass() { pass_count=$((pass_count + 1)); printf 'ok %d - %s\n' "$pass_count" "$*"; } +fail_test() { printf 'not ok - %s\n' "$*" >&2; exit 1; } + +extract() { # extract + awk -v start="$1" -v stop="$2" ' + $0 ~ start { copying=1 } + copying && $0 ~ stop { exit } + copying { print } + ' "$SETUP" > "$3" + [ -s "$3" ] || fail_test "could not isolate '$1' from setup-wda.sh" +} + +extract '^# BEGIN runner source helpers\.' '^# END runner source helpers\.' "$TMP_ROOT/source.sh" +extract '^_runner_repo_src[(][)]' '^RUNNER_SRC=' "$TMP_ROOT/repo-src.sh" +extract '^WDA_XCTESTRUN=""$' '^# Keep `RUNNER_COMMAND=`' "$TMP_ROOT/launch.sh" + +make_runner_tree() { # make_runner_tree + mkdir -p "$1/IPhoneUseRunner/IPhoneUseRunner.xcodeproj" \ + "$1/IPhoneUseRunner/IPhoneUseRunnerUITests" \ + "$1/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcuserdata/leo.xcuserdatad" + printf '// !$*UTF8*$!\n{ }\n' > "$1/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj" + printf 'import XCTest\n' > "$1/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift" + printf 'per-user\n' > "$1/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcuserdata/leo.xcuserdatad/state" + chmod -R go-w "$1" +} + +# ── 1. runner source validation and hash ──────────────────────────────────── +UID_NUM="$(id -u)" +. "$TMP_ROOT/source.sh" +RUNNER_DEFAULT_SRC="$TMP_ROOT/home/.iphone-use/runner" +set_src() { RUNNER_SRC="$1"; RUNNER_PROJECT="$RUNNER_SRC/IPhoneUseRunner/IPhoneUseRunner.xcodeproj"; } + +set_src "$TMP_ROOT/src" +make_runner_tree "$RUNNER_SRC" +_runner_source_valid || fail_test "a valid runner tree was refused: $RUNNER_SOURCE_ERROR" +pass "a private runner source tree is accepted" + +hash_a="$(_runner_source_hash)" +[ "${#hash_a}" = 64 ] || fail_test "source hash is not a sha256: '$hash_a'" +printf 'changed per-user state\n' > "$RUNNER_SRC/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/xcuserdata/leo.xcuserdatad/state" +touch "$RUNNER_SRC/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift" +[ "$(_runner_source_hash)" = "$hash_a" ] \ + || fail_test "the hash changed for Xcode per-user state or a bare mtime change" +printf 'import XCTest // edited\n' > "$RUNNER_SRC/IPhoneUseRunner/IPhoneUseRunnerUITests/RunnerTests.swift" +[ "$(_runner_source_hash)" != "$hash_a" ] || fail_test "the hash missed a source edit" +pass "the source hash follows source content only" + +set_src "$TMP_ROOT/missing" +if _runner_source_valid; then fail_test "missing sources were accepted"; fi +case "$RUNNER_SOURCE_ERROR" in + *"Rerun the installer"*) ;; + *) fail_test "missing-source error does not say how to fix it: $RUNNER_SOURCE_ERROR" ;; +esac +pass "missing sources fail with the reinstall hint" + +set_src "relative/runner" +if _runner_source_valid; then fail_test "a relative source path was accepted"; fi +set_src "$TMP_ROOT/with space" +make_runner_tree "$RUNNER_SRC" +if _runner_source_valid; then fail_test "a source path with whitespace was accepted"; fi +pass "relative or whitespace source paths are refused" + +set_src "$TMP_ROOT/shared" +make_runner_tree "$RUNNER_SRC" +chmod o+w "$RUNNER_SRC/IPhoneUseRunner" +if _runner_source_valid; then fail_test "a world-writable source tree was accepted"; fi +chmod o-w "$RUNNER_SRC/IPhoneUseRunner" +chmod g+w "$RUNNER_SRC" +if _runner_source_valid; then fail_test "a group-writable source tree was accepted"; fi +pass "sources other users can change are refused" + +ln -s "$TMP_ROOT/src" "$TMP_ROOT/linked" +set_src "$TMP_ROOT/linked" +if _runner_source_valid; then fail_test "a symlinked source directory was accepted"; fi +pass "a symlinked source directory is refused" + +# ── 2. the repo-adjacent source is used only from a repo checkout ─────────── +. "$TMP_ROOT/repo-src.sh" +HOME="$TMP_ROOT/home" +mkdir -p "$TMP_ROOT/repo/scripts" "$TMP_ROOT/repo/crates/server" "$HOME/.iphone-use" +make_runner_tree "$TMP_ROOT/repo/runner" +# $0 drives the lookup; emulate both locations by running with an explicit $0. +repo_result="$(HOME="$HOME" bash -c '. "$1"; _runner_repo_src' "$TMP_ROOT/repo/scripts/setup-wda.sh" "$TMP_ROOT/repo-src.sh" || true)" +[ "$repo_result" = "$TMP_ROOT/repo/runner" ] \ + || fail_test "a repo checkout did not find its own runner/ (got '$repo_result')" +cp -R "$TMP_ROOT/repo/runner" "$HOME/runner" +mkdir -p "$HOME/crates/server" +installed_result="$(HOME="$HOME" bash -c '. "$1"; _runner_repo_src' "$HOME/.iphone-use/setup-wda.sh" "$TMP_ROOT/repo-src.sh" || true)" +[ -z "$installed_result" ] \ + || fail_test "the installed copy looked next to ~/.iphone-use for sources: '$installed_result'" +pass "only a repo checkout uses its adjacent runner/; the installed copy never does" + +# ── 3. build/launch selection ─────────────────────────────────────────────── +STATE_DIR="$TMP_ROOT/state" +mkdir -p "$STATE_DIR" +STATUS_LOG="$TMP_ROOT/status.log" +run_launch() { # run_launch ; prints the event trace + ( + set -eu + EVENTS="" + event() { EVENTS="$EVENTS $*"; printf '%s\n' "$*" >> "$TMP_ROOT/events"; } + ok() { event "ok:$1"; } + warn() { event "warn:$1"; } + die() { event "die:$1"; exit 3; } + _setstatus() { printf '%s|%s|%s\n' "$1" "${2:-}" "${3:-}" >> "$STATUS_LOG"; } + _runner_cache_read() { [ "$CACHE" = hit ] && { WDA_RUNNER_FROM_CACHE=1; WDA_XCTESTRUN=/x/cached.xctestrun; event cache-hit; }; } + _runner_cache_write() { event cache-write; } + _ensure_launchable_runner() { + event build + case "$BUILD" in + ok) WDA_XCTESTRUN=/x/built.xctestrun; return 0 ;; + locked) RUNNER_BUILD_LOCKED=1 ;; + account) printf 'No Accounts: none\n' > "$STATE_DIR/wda-runner-product-build.log" ;; + profile) printf 'error: No profiles for com.example were found\n' > "$STATE_DIR/wda-runner-product-build.log" ;; + *) : ;; + esac + WDA_RUNNER_VALIDATION_ERROR="build-for-testing failed (log: x)" + return 1 + } + _prepare_locked_retry() { event locked-retry; } + _report_missing_xcode_account() { event account-blocker; exit 4; } + WDA_RUNNER_FROM_CACHE=0 + RUNNER_BUILD_LOCKED=0 + _BUILD_BLOCKER="" + rm -f "$STATE_DIR/wda-runner-product-build.log" + . "$TMP_ROOT/launch.sh" + event "launch:$WDA_XCTESTRUN" + ) || printf 'exit:%s\n' "$?" >> "$TMP_ROOT/events" + cat "$TMP_ROOT/events" + rm -f "$TMP_ROOT/events" +} + +trace="$(CACHE=hit BUILD=ok WDA_RUNNER_REBUILD=0 run_launch)" +if printf '%s\n' "$trace" | grep -qx -e build -e cache-write; then + fail_test "a cache hit rebuilt or re-recorded: $trace" +fi +case "$trace" in *"launch:/x/cached.xctestrun"*) ;; *) fail_test "a cache hit did not launch the cached product: $trace" ;; esac +pass "a matching recorded product launches without building" + +trace="$(CACHE=hit BUILD=ok WDA_RUNNER_REBUILD=1 run_launch)" +case "$trace" in *cache-hit*) fail_test "WDA_RUNNER_REBUILD=1 still used the record: $trace" ;; esac +case "$trace" in *build*cache-write*"launch:/x/built.xctestrun"*) ;; *) fail_test "forced rebuild did not build, record and launch: $trace" ;; esac +pass "WDA_RUNNER_REBUILD=1 forces a build" + +trace="$(CACHE=miss BUILD=ok run_launch)" +case "$trace" in *build*cache-write*"launch:/x/built.xctestrun"*) ;; *) fail_test "a cache miss did not build, record, then launch: $trace" ;; esac +pass "a cache miss builds, records the product before launch, and launches it" + +trace="$(CACHE=miss BUILD=locked WDA_KEEPALIVE=1 run_launch)" +case "$trace" in *locked-retry*exit:1*) ;; *) fail_test "a locked build under KeepAlive did not take the lock backoff: $trace" ;; esac +case "$trace" in *launch:*) fail_test "a locked build launched anyway: $trace" ;; esac +trace="$(CACHE=miss BUILD=locked WDA_KEEPALIVE=0 run_launch)" +case "$trace" in *"die:the phone is locked"*) ;; *) fail_test "an interactive locked build did not say the phone is locked: $trace" ;; esac +pass "a lock screen during the build is a lock retry, not a build failure" + +trace="$(CACHE=miss BUILD=account run_launch)" +case "$trace" in *account-blocker*) ;; *) fail_test "a signed-out Xcode was not reported as the account blocker: $trace" ;; esac +pass "a signed-out Xcode during the build reports the account blocker" + +: > "$STATUS_LOG" +trace="$(CACHE=miss BUILD=profile run_launch)" +case "$trace" in *"could not find or create the WDA development provisioning"*) ;; *) fail_test "the profile failure lost the phrase the daemon maps to 'account': $trace" ;; esac +grep -q '^signing-fail|account|' "$STATUS_LOG" || fail_test "the profile failure did not publish the account blocker" +pass "a provisioning failure keeps the daemon's account phrase and blocker" + +: > "$STATUS_LOG" +trace="$(CACHE=miss BUILD=broken run_launch)" +case "$trace" in *"die:device runner product is not launchable"*) ;; *) fail_test "a broken build did not fail closed: $trace" ;; esac +grep -q '^building-fail|wda|' "$STATUS_LOG" || fail_test "a broken build did not publish the wda blocker" +pass "any other build failure fails closed with the wda blocker" + +printf '1..%d\n' "$pass_count" diff --git a/scripts/test-setup-wda-runner-cache.py b/scripts/test-setup-wda-runner-cache.py index 32b90b7..b0c03c2 100644 --- a/scripts/test-setup-wda-runner-cache.py +++ b/scripts/test-setup-wda-runner-cache.py @@ -18,70 +18,84 @@ def setUp(self): self.root = Path(self.temp.name) self.state = self.root / 'state' self.state.mkdir() - self.products = self.root / 'DerivedData' / 'Build' / 'Products' / 'Debug-iphoneos' + self.products = self.state / 'runner-build' / 'Build' / 'Products' / 'Debug-iphoneos' self.products.mkdir(parents=True) (self.products / 'iPhoneUse-Runner.app').mkdir() - # xcodebuild writes the .xctestrun beside the configuration directory, - # not inside it — the shape that made the first cache never hit. - self.xctestrun = self.products.parent / 'WebDriverAgentRunner_iphoneos26.5-arm64.xctestrun' + # xcodebuild writes the .xctestrun beside the configuration directory. + self.xctestrun = self.products.parent / 'IPhoneUseRunner_iphoneos27.0-arm64.xctestrun' self.xctestrun.write_text('plist') - self.icon = self.root / 'AppIcon.icns' - self.icon.write_bytes(b'icns') def tearDown(self): self.temp.cleanup() - def run_bash(self, body, *, validate_ok=True, env=None): + def run_bash(self, body, *, validate_ok=True, asc=False): script = self.root / 'run.sh' script.write_text( 'set -u\n' f'STATE_DIR="{self.state}"\n' - 'WDA_COMMIT=abc123\nWDA_BUNDLE_ID=com.example.wda\nTEAM_ID=TEAM000000\n' - 'WDA_UDID=00008150-0000000000000000\nWDA_RUNNER_NAME=iPhoneUse\n' - f'RUNNER_ICON_SOURCE="{self.icon}"\n' - 'ok() { printf "ok: %s\\n" "$*"; }\nwarn() { printf "warn: %s\\n" "$*"; }\n' + f'RUNNER_PRODUCTS_DIR="{self.products}"\n' + 'RUNNER_APP_NAME=iPhoneUse-Runner.app\n' + 'RUNNER_SOURCE_HASH=1111aaaa\nWDA_BUNDLE_ID=com.example.wda\nTEAM_ID=TEAM000000\n' + 'WDA_UDID=00008150-0000000000000000\nXCODE_VERSION="Xcode 27.0"\nWDA_IOS_SDK_VERSION=27.0\n' + 'WDA_ASC_KEY_ID=KEY0000001\n' + + ('_asc_signing_enabled() { return 0; }\n' if asc else '_asc_signing_enabled() { return 1; }\n') + + 'ok() { printf "ok: %s\\n" "$*"; }\nwarn() { printf "warn: %s\\n" "$*"; }\n' + ('_validate_runner_bundle() { return 0; }\n' if validate_ok else '_validate_runner_bundle() { return 1; }\n') + HELPERS + '\n' + body + '\n' ) - proc = subprocess.run(['bash', str(script)], capture_output=True, text=True, env={**os.environ, **(env or {})}) + proc = subprocess.run(['bash', str(script)], capture_output=True, text=True, env=dict(os.environ)) return proc.returncode, proc.stdout.strip().splitlines() - def write_body(self): - return (f'WDA_ICON_PRODUCTS_DIR="{self.products}"\nWDA_XCTESTRUN="{self.xctestrun}"\n' + def write_body(self, xctestrun=None, products=None): + return (f'RUNNER_BUILT_PRODUCTS="{products or self.products}"\n' + f'WDA_XCTESTRUN="{xctestrun or self.xctestrun}"\n' '_runner_cache_write || exit 9\n') def read_body(self): - return ('WDA_ICON_PRODUCTS_DIR=""\nWDA_XCTESTRUN=""\n' - 'if _runner_cache_read; then echo "hit $WDA_RUNNER_FROM_CACHE $WDA_ICON_PRODUCTS_DIR $WDA_XCTESTRUN $WDA_ICON_APP_PATH"; else echo miss; fi\n') + return ('RUNNER_BUILT_PRODUCTS=""\nWDA_XCTESTRUN=""\nWDA_RUNNER_FROM_CACHE=0\n' + 'if _runner_cache_read; then echo "hit $WDA_RUNNER_FROM_CACHE $RUNNER_BUILT_PRODUCTS $WDA_XCTESTRUN $RUNNER_APP_PATH"; else echo miss; fi\n') def test_write_then_read_reuses_the_product(self): code, out = self.run_bash(self.write_body() + self.read_body()) self.assertEqual(code, 0, out) record = json.loads((self.state / 'wda-runner-product.json').read_text()) self.assertEqual(record['schema_version'], 1) + self.assertTrue(record['key'].startswith('v3|1111aaaa|'), record['key']) self.assertEqual(record['xctestrun'], str(self.xctestrun)) self.assertEqual(out[-1], f'hit 1 {self.products} {self.xctestrun} {self.products}/iPhoneUse-Runner.app') - def test_key_change_misses(self): - code, out = self.run_bash(self.write_body() + 'WDA_COMMIT=def456\n' + self.read_body()) + def test_every_key_part_invalidates_the_record(self): + for change in ('RUNNER_SOURCE_HASH=2222bbbb', 'WDA_BUNDLE_ID=com.example.other', + 'TEAM_ID=TEAM000001', 'WDA_UDID=00008150-0000000000000001', + 'XCODE_VERSION="Xcode 27.1"', 'WDA_IOS_SDK_VERSION=27.1', + 'WDA_DEPLOYMENT_TARGET_OVERRIDE=16.0', + '_asc_signing_enabled() { return 0; }'): + with self.subTest(change=change): + code, out = self.run_bash(self.write_body() + change + '\n' + self.read_body()) + self.assertEqual(out[-1], 'miss', out) + + def test_signing_with_another_api_key_misses(self): + code, out = self.run_bash(self.write_body() + 'WDA_ASC_KEY_ID=KEY0000002\n' + self.read_body(), asc=True) self.assertEqual(out[-1], 'miss', out) - # a new icon (different content) → miss as well - code, out = self.run_bash(self.write_body() + f'printf other > "{self.icon}"\n' + self.read_body()) - self.assertEqual(out[-1], 'miss', out) - - def test_reinstalling_the_same_icon_still_hits(self): - # an app upgrade rewrites the icon file: new mtime, same bytes - code, out = self.run_bash(self.write_body() + f'touch -t 203001010000 "{self.icon}"\n' + self.read_body()) + code, out = self.run_bash(self.write_body() + self.read_body(), asc=True) self.assertTrue(out[-1].startswith('hit 1 '), out) + def test_unknown_source_hash_never_reads_or_writes(self): + code, out = self.run_bash(self.write_body() + 'RUNNER_SOURCE_HASH=""\n' + self.read_body()) + self.assertEqual(out[-1], 'miss', out) + code, out = self.run_bash('RUNNER_SOURCE_HASH=""\n' + self.write_body()) + self.assertEqual(code, 9, out) + def test_missing_files_or_invalid_bundle_miss(self): code, out = self.run_bash(self.write_body() + f'rm "{self.xctestrun}"\n' + self.read_body()) self.assertEqual(out[-1], 'miss', out) code, out = self.run_bash(self.write_body() + self.read_body(), validate_ok=False) self.assertEqual(out[-1], 'miss', out) + code, out = self.run_bash(self.write_body() + f'rm -r "{self.products}/iPhoneUse-Runner.app"\n' + self.read_body()) + self.assertEqual(out[-1], 'miss', out) def test_write_requires_both_paths_and_refuses_symlinks(self): - code, out = self.run_bash('WDA_ICON_PRODUCTS_DIR=""\nWDA_XCTESTRUN=""\n_runner_cache_write; echo "rc=$?"\n') + code, out = self.run_bash('RUNNER_BUILT_PRODUCTS=""\nWDA_XCTESTRUN=""\n_runner_cache_write; echo "rc=$?"\n') self.assertEqual(out[-1], 'rc=1', out) target = self.root / 'elsewhere.json' (self.state / 'wda-runner-product.json').symlink_to(target) @@ -97,30 +111,28 @@ def test_drop_removes_the_record(self): self.assertEqual(out[-1], 'miss', out) self.assertFalse((self.state / 'wda-runner-product.json').exists()) - def test_xctestrun_inside_the_configuration_dir_is_also_accepted(self): - inside = self.products / 'WebDriverAgentRunner_iphoneos.xctestrun' - inside.write_text('plist') - body = (f'WDA_ICON_PRODUCTS_DIR="{self.products}"\nWDA_XCTESTRUN="{inside}"\n' - '_runner_cache_write || exit 9\n') + self.read_body() - code, out = self.run_bash(body) - self.assertEqual(code, 0, out) - self.assertTrue(out[-1].startswith('hit 1 '), out) - - def test_xctestrun_outside_the_products_tree_is_rejected(self): - stray = self.root / 'stray.xctestrun' - stray.write_text('plist') - body = (f'WDA_ICON_PRODUCTS_DIR="{self.products}"\nWDA_XCTESTRUN="{stray}"\n' - '_runner_cache_write || exit 9\n') + self.read_body() - code, out = self.run_bash(body) + def test_only_this_instances_products_are_reused(self): + other = self.root / 'other-instance' / 'runner-build' / 'Build' / 'Products' / 'Debug-iphoneos' + (other / 'iPhoneUse-Runner.app').mkdir(parents=True) + other_run = other.parent / 'IPhoneUseRunner_iphoneos27.0-arm64.xctestrun' + other_run.write_text('plist') + code, out = self.run_bash(self.write_body(other_run, other) + self.read_body()) self.assertEqual(out[-1], 'miss', out) - def test_record_outside_build_products_is_ignored(self): + def test_xctestrun_must_sit_beside_the_configuration_dir(self): + for stray in (self.products / 'IPhoneUseRunner_iphoneos27.0-arm64.xctestrun', + self.root / 'stray.xctestrun'): + with self.subTest(stray=stray.name): + stray.write_text('plist') + code, out = self.run_bash(self.write_body(stray) + self.read_body()) + self.assertEqual(out[-1], 'miss', out) + + def test_record_from_the_webdriveragent_era_is_ignored(self): bogus = self.state / 'wda-runner-product.json' - code, out = self.run_bash(self.write_body()) - record = json.loads(bogus.read_text()) - record['products_dir'] = str(self.root / 'not-products') - record['xctestrun'] = str(self.root / 'not-products' / 'x.xctestrun') - bogus.write_text(json.dumps(record)) + bogus.write_text(json.dumps({ + 'schema_version': 1, + 'key': 'v2|54f9fc70|com.example.wda|TEAM000000|00008150-0000000000000000|||Xcode 27.0|27.0|', + 'products_dir': str(self.products), 'xctestrun': str(self.xctestrun)})) code, out = self.run_bash(self.read_body()) self.assertEqual(out[-1], 'miss', out) diff --git a/scripts/test-setup-wda-runner-product.py b/scripts/test-setup-wda-runner-product.py index 26b54aa..78eb64c 100644 --- a/scripts/test-setup-wda-runner-product.py +++ b/scripts/test-setup-wda-runner-product.py @@ -12,7 +12,8 @@ SOURCE = (Path(__file__).parent / 'setup-wda.sh').read_text() HELPERS = SOURCE.split('# BEGIN runner product validation.', 1)[1].split('# END runner product validation.', 1)[0] HELPERS += SOURCE.split('# BEGIN ASC signing helpers.', 1)[1].split('# END ASC signing helpers.', 1)[0] -HELPERS += '\n_safe_expected() {' + SOURCE.split('\n_safe_expected() {', 1)[1].split('\n}\n', 1)[0] + '\n}\n' +for _name in ('_safe_expected', '_resolve_xctestrun', '_valid_os_version'): + HELPERS += '\n' + _name + '() {' + SOURCE.split('\n' + _name + '() {', 1)[1].split('\n}\n', 1)[0] + '\n}\n' def create_runner(app, poison=False): @@ -46,9 +47,11 @@ def run_shell(self, body, *, poison=False, repair_used=False, app=None): harness = self.root / 'run.sh' harness.write_text('''set -eu . "$HELPERS" -WDA_RUNNER_NAME=iPhoneUse -WDA_ICON_BUILD_LOCKED=0 +RUNNER_APP_NAME=iPhoneUse-Runner.app +RUNNER_PRODUCTS_DIR="$PRODUCTS" +RUNNER_BUILD_LOCKED=0 WDA_RUNNER_REPAIR_ATTEMPTED="$REPAIR_USED" +_ios_sdk_version() { return 1; } _setstatus() { printf '%s\\n' "$*" >> "$STATUS_LOG"; } warn() { printf '%s\\n' "$*"; } # Signature verification deliberately succeeds even for an empty framework, @@ -127,20 +130,51 @@ def test_app_symlink_is_never_followed_for_removal(self): self.assertTrue((self.app / 'Info.plist').is_file()) self.assertTrue(link.is_symlink()) - def test_plain_path_runs_structure_validation(self): + def test_launch_path_builds_validates_and_picks_the_xctestrun(self): (self.app / 'Frameworks/Testing.framework/Info.plist').unlink() + (self.products.parent / 'IPhoneUseRunner_iphoneos27.0-arm64.xctestrun').write_text('plist') body = '''STATE_DIR="$(dirname "$LOG")" -WDA_DIR="$PRODUCTS" -WDA_UDID=test -TEAM_ID=ABCDE12345 -WDA_BUNDLE_ID=com.example.wda -fake_xcodebuild() { printf '[{"target":"WebDriverAgentRunner","buildSettings":{"BUILT_PRODUCTS_DIR":"%s"}}]' "$PRODUCTS"; } -XCODEBUILD_BIN=fake_xcodebuild _ensure_launchable_runner +printf '%s\\n' "$WDA_XCTESTRUN" "$RUNNER_BUILT_PRODUCTS" "$RUNNER_APP_PATH" ''' result = self.run_shell(body) self.assertEqual(result.returncode, 0, result.stderr) + # The (incremental) build always runs first; it restored the product. self.assertEqual(self.calls.read_text().splitlines(), ['build']) + xctestrun, products, app = result.stdout.splitlines()[-3:] + self.assertEqual(xctestrun, str(self.products.parent / 'IPhoneUseRunner_iphoneos27.0-arm64.xctestrun')) + self.assertEqual(products, str(self.products)) + self.assertEqual(app, str(self.app)) + + def test_launch_path_repairs_a_poisoned_build_once(self): + (self.products.parent / 'IPhoneUseRunner_iphoneos27.0-arm64.xctestrun').write_text('plist') + body = '''STATE_DIR="$(dirname "$LOG")" +_ensure_launchable_runner +''' + result = self.run_shell(body, poison=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls.read_text().splitlines(), ['build', 'build']) + self.assertIn('building-fail', self.status.read_text()) + + def test_ambiguous_xctestrun_is_not_launchable(self): + for sdk in ('26.5', '27.0'): + (self.products.parent / f'IPhoneUseRunner_iphoneos{sdk}-arm64.xctestrun').write_text('plist') + body = '''STATE_DIR="$(dirname "$LOG")" +if _ensure_launchable_runner; then exit 99; fi +printf '%s' "$WDA_RUNNER_VALIDATION_ERROR" +''' + result = self.run_shell(body) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('unique .xctestrun', result.stdout) + + def test_only_this_instances_products_dir_may_be_repaired(self): + other = self.root / 'other/Build/Products/Debug-iphoneos' + other_app = other / 'iPhoneUse-Runner.app' + create_runner(other_app, poison=True) + result = self.run_shell('_repair_runner_if_invalid "' + str(other) + '" "$APP" "$LOG"', app=other_app) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(other_app.exists()) + self.assertFalse(self.calls.exists()) if __name__ == '__main__': diff --git a/scripts/test-setup-wda-runner-repair.sh b/scripts/test-setup-wda-runner-repair.sh index 8fd99a1..85315eb 100755 --- a/scripts/test-setup-wda-runner-repair.sh +++ b/scripts/test-setup-wda-runner-repair.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # #75: KeepAlive rounds destroyed their own evidence and carried a poisoned -# runner into the next round. Deterministic checks on the two guards that -# stop that; no iPhone, no xcodebuild. +# runner into the next round. Deterministic checks on the guards that stop +# that; no iPhone, no xcodebuild. # shellcheck disable=SC1091,SC2034,SC2329 set -euo pipefail umask 077 @@ -17,33 +17,19 @@ fail_test() { printf 'not ok - %s\n' "$*" >&2; exit 1; } info() { :; }; ok() { :; }; warn() { printf 'warn: %s\n' "$*"; } _setstatus() { :; } -# Same extraction the icon test uses: production helpers, no command body. -awk ' - /^_cleanup_wda_icon_work_dir\(\)/ { copying=1 } - copying && /^if \[ "\$COMMAND" = "setup" \]; then/ { exit } - copying { print } -' "$SETUP" > "$TMP_ROOT/transaction.sh" awk ' /^_repair_runner_if_invalid\(\)/ { copying=1 } copying && /^_ensure_launchable_runner\(\)/ { exit } copying { print } ' "$SETUP" > "$TMP_ROOT/repair.sh" -[ -s "$TMP_ROOT/transaction.sh" ] && [ -s "$TMP_ROOT/repair.sh" ] \ - || fail_test "could not isolate the production helpers" -grep -q '^_discard_injected_runner()' "$TMP_ROOT/transaction.sh" \ - || fail_test "_discard_injected_runner is outside the extracted transaction block" -grep -q '^_discard_previous_injection()' "$TMP_ROOT/transaction.sh" \ - || fail_test "_discard_previous_injection is outside the extracted transaction block" -# The selection block calls the discard helper; it must also be defined -# (dc6dd28 shipped the call without the function after a revert). -for fn in _discard_injected_runner _discard_previous_injection; do - grep -q "^$fn()" "$SETUP" || fail_test "$fn is called but not defined in setup-wda.sh" -done +[ -s "$TMP_ROOT/repair.sh" ] || fail_test "could not isolate the production repair helper" # ── 1. repair rebuild keeps the failed build's log ────────────────────────── source "$TMP_ROOT/repair.sh" -PRODUCTS="$TMP_ROOT/DerivedData/Build/Products/Debug-iphoneos" -APP="$PRODUCTS/WebDriverAgentRunner-Runner.app" +PRODUCTS="$TMP_ROOT/state/runner-build/Build/Products/Debug-iphoneos" +RUNNER_PRODUCTS_DIR="$PRODUCTS" +RUNNER_APP_NAME="iPhoneUse-Runner.app" +APP="$PRODUCTS/$RUNNER_APP_NAME" mkdir -p "$APP" LOG="$TMP_ROOT/wda-runner-product-build.log" printf 'ProcessInfoPlistFile evidence from the first build\n' > "$LOG" @@ -72,65 +58,42 @@ grep -q 'evidence from the first build' "$LOG" \ || fail_test "repair rebuild truncated the failed build's log" pass "repair rebuild logs to *.repair.log and leaves the failed build's log intact" -# ── 2. discarding an injected runner is guarded ───────────────────────────── -source "$TMP_ROOT/transaction.sh" -mkdir -p "$APP"; touch "$APP/Info.plist" -WDA_ICON_PRODUCTS_DIR="$PRODUCTS"; WDA_ICON_APP_PATH="$APP" - -WDA_RUNNER_ICON_INJECTED=0 -_discard_injected_runner || fail_test "not-injected discard must be a successful no-op" -[ -d "$APP" ] || fail_test "not-injected discard removed the runner" -pass "discard is a no-op when nothing was injected" - -WDA_RUNNER_ICON_INJECTED=1 -WDA_ICON_PRODUCTS_DIR="$TMP_ROOT/elsewhere" -if _discard_injected_runner; then fail_test "discard accepted a products dir outside Build/Products"; fi -[ -d "$APP" ] || fail_test "out-of-tree discard removed the runner anyway" -pass "discard refuses a products dir outside Build/Products" - -WDA_ICON_PRODUCTS_DIR="$PRODUCTS" -WDA_ICON_APP_PATH="$PRODUCTS/NotARunner.app" -if _discard_injected_runner; then fail_test "discard accepted an app not named *-Runner.app"; fi -pass "discard refuses an app that is not the runner product" - -ln -s "$TMP_ROOT" "$PRODUCTS/Link-Runner.app" -WDA_ICON_APP_PATH="$PRODUCTS/Link-Runner.app" -if _discard_injected_runner; then fail_test "discard followed a symlinked runner"; fi -[ -d "$TMP_ROOT/DerivedData" ] || fail_test "symlinked discard deleted through the link" -pass "discard refuses a symlinked runner" - -WDA_ICON_APP_PATH="$APP"; WDA_RUNNER_ICON_INJECTED=1 -_discard_injected_runner || fail_test "discard refused a valid injected runner" -[ ! -e "$APP" ] || fail_test "discard left the injected runner in place" -[ "$WDA_RUNNER_ICON_INJECTED" = "0" ] || fail_test "discard left the injected flag set" -pass "discard removes a valid injected runner and clears the injected flag" - -# ── 3. the prebuild starts from a pristine baseline ───────────────────────── -mkdir -p "$APP"; touch "$APP/Info.plist" -WDA_RUNNER_ICON_INJECTED=0 -_discard_previous_injection "$PRODUCTS" "$APP" \ - || fail_test "a never-injected product must be left for the incremental build" -[ -d "$APP" ] || fail_test "a never-injected product was removed" -pass "prebuild keeps a product that was never injected" - -touch "$APP/AppIcon60x60@2x.png" -_discard_previous_injection "$PRODUCTS" "$APP" \ - || fail_test "the previous round's injected runner was not discarded" -[ ! -e "$APP" ] || fail_test "prebuild would have built over the previous round's injected runner" -[ "$WDA_RUNNER_ICON_INJECTED" = "0" ] || fail_test "discarding the previous injection left the flag set" -pass "prebuild drops the previous round's injected runner first" +# ── 2. repair only ever removes this instance's own runner app ────────────── +validate_calls=0 +WDA_RUNNER_REPAIR_ATTEMPTED=0 +mkdir -p "$TMP_ROOT/elsewhere/Build/Products/Debug-iphoneos/$RUNNER_APP_NAME" +if _repair_runner_if_invalid "$TMP_ROOT/elsewhere/Build/Products/Debug-iphoneos" \ + "$TMP_ROOT/elsewhere/Build/Products/Debug-iphoneos/$RUNNER_APP_NAME" "$LOG"; then + fail_test "repair accepted another products directory" +fi +[ -d "$TMP_ROOT/elsewhere/Build/Products/Debug-iphoneos/$RUNNER_APP_NAME" ] \ + || fail_test "repair removed an app outside this instance's products" +pass "repair refuses a products dir that is not this instance's" -rm -f "$PRODUCTS/Link-Runner.app" -mkdir -p "$TMP_ROOT/target.app"; touch "$TMP_ROOT/target.app/AppIcon60x60@2x.png" +validate_calls=0 +WDA_RUNNER_REPAIR_ATTEMPTED=0 +mkdir -p "$TMP_ROOT/target.app" ln -s "$TMP_ROOT/target.app" "$PRODUCTS/Link-Runner.app" -if _discard_previous_injection "$PRODUCTS" "$PRODUCTS/Link-Runner.app"; then - fail_test "prebuild baseline followed a symlinked runner" +if _repair_runner_if_invalid "$PRODUCTS" "$PRODUCTS/Link-Runner.app" "$LOG"; then + fail_test "repair followed a symlinked runner" +fi +[ -d "$TMP_ROOT/target.app" ] || fail_test "repair deleted through the link" +pass "repair refuses a symlinked or differently named runner" + +# ── 3. the WebDriverAgent icon transaction is gone for good ───────────────── +for fn in _build_and_inject_runner_icon _discard_injected_runner \ + _discard_previous_injection _restore_wda_icon_app _runner_icon_fail; do + if grep -q "$fn" "$SETUP"; then + fail_test "$fn is still referenced; the device runner is never re-signed after Xcode signs it" + fi +done +if grep -Eq 'codesign (-f|--force)' "$SETUP"; then + fail_test "setup re-signs a built runner (that poisons installs with 0xe8008001)" fi -[ -e "$TMP_ROOT/target.app/AppIcon60x60@2x.png" ] || fail_test "baseline discard deleted through the link" -pass "prebuild baseline refuses a symlinked runner" +pass "setup never re-signs the runner Xcode built" # ── 4. main flow: record before launch, evict on product failure ───────────── -record_line="$(grep -n '^if \[ -n "\${WDA_XCTESTRUN:-}" \] && \[ "\$WDA_RUNNER_FROM_CACHE" != "1" \]; then' "$SETUP" | cut -d: -f1)" +record_line="$(grep -n '^if \[ "\$WDA_RUNNER_FROM_CACHE" != "1" \]; then' "$SETUP" | cut -d: -f1)" launch_line="$(grep -n '^RUNNER_COMMAND=' "$SETUP" | cut -d: -f1)" [ -n "$record_line" ] && [ -n "$launch_line" ] && [ "$record_line" -lt "$launch_line" ] \ || fail_test "the runner product is not recorded before its launch (a failed round rebuilds again)" diff --git a/scripts/test-setup-wda-xcode-compat.py b/scripts/test-setup-wda-xcode-compat.py index de27e25..bb2e634 100644 --- a/scripts/test-setup-wda-xcode-compat.py +++ b/scripts/test-setup-wda-xcode-compat.py @@ -2,9 +2,10 @@ """Xcode-upgrade regressions in setup-wda.sh, with a fake xcodebuild/xcrun and a throwaway state dir — no Xcode, no device, nothing under the real $HOME. -1. Xcode 27 rejects the pinned WDA project's IPHONEOS_DEPLOYMENT_TARGET (12.0). - Setup must raise it through an exported XCODE_XCCONFIG_FILE, never through - the xcodebuild argv (that argv is the runner's PID identity). +1. A newer Xcode can reject the runner project's IPHONEOS_DEPLOYMENT_TARGET (as + Xcode 27 rejected WebDriverAgent's 12.0). Setup must raise it through an + exported XCODE_XCCONFIG_FILE, never through the xcodebuild argv (that argv + is the runner's PID identity). 2. After an SDK upgrade, Build/Products holds one .xctestrun per SDK. The resolver must pick the one for the SDK just built, and still refuse when the choice is genuinely ambiguous. @@ -57,8 +58,8 @@ def setUp(self): self.home = self.root / 'home' self.state = self.home / '.iphone-use' self.state.mkdir(parents=True) - self.wda = self.state / 'WebDriverAgent' - (self.wda / 'WebDriverAgent.xcodeproj').mkdir(parents=True) + self.project = self.state / 'runner' / 'IPhoneUseRunner' / 'IPhoneUseRunner.xcodeproj' + self.project.mkdir(parents=True) self.set_project_targets('12.0', '12.0') self.bin = self.root / 'bin' self.bin.mkdir() @@ -71,8 +72,8 @@ def setUp(self): self.set_sdk_minimum('15.0') self.log = self.root / 'xcodebuild.log' self.xcconfig = self.state / 'wda-xcode-compat.xcconfig' - self.products = (self.home / 'Library/Developer/Xcode/DerivedData' - / 'WebDriverAgent-abcdef/Build/Products') + self.derived = self.state / 'runner-build' + self.products = self.derived / 'Build/Products' (self.products / 'Debug-iphoneos').mkdir(parents=True) def tearDown(self): @@ -80,7 +81,7 @@ def tearDown(self): def set_project_targets(self, *values): lines = ''.join(f'\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = {v};\n' for v in values) - (self.wda / 'WebDriverAgent.xcodeproj' / 'project.pbxproj').write_text( + (self.project / 'project.pbxproj').write_text( '// !$*UTF8*$!\n{\n' + lines + '\t\t\t\tTVOS_DEPLOYMENT_TARGET = 10.0;\n}\n') def set_sdk_minimum(self, minimum): @@ -95,7 +96,7 @@ def set_sdk_minimum(self, minimum): def xctestrun(self, *sdk_arch): paths = [] for name in sdk_arch: - path = self.products / f'WebDriverAgentRunner_iphoneos{name}.xctestrun' + path = self.products / f'IPhoneUseRunner_iphoneos{name}.xctestrun' path.write_text('plist') paths.append(path) return paths @@ -105,7 +106,8 @@ def run_bash(self, body, *, xcode='27.0', sdk_version='27.0', env=None, script = self.root / 'run.sh' script.write_text( 'set -eu\n' - f'STATE_DIR="{self.state}"\nWDA_DIR="{self.wda}"\n' + f'STATE_DIR="{self.state}"\nRUNNER_PROJECT="{self.project}"\n' + f'RUNNER_DERIVED_DATA="{self.derived}"\nRUNNER_SCHEME=IPhoneUseRunner\n' f'SELF_INSTALL="{self_install or self.state / "setup-wda.sh"}"\n' 'ok() { printf "ok: %s\\n" "$*"; }\n' 'warn() { printf "warn: %s\\n" "$*"; }\n' @@ -130,8 +132,7 @@ def test_xcode27_writes_override_and_exports_it_without_touching_argv(self): '_prepare_wda_xcconfig\n' 'echo "override=$WDA_DEPLOYMENT_TARGET_OVERRIDE sdk=$WDA_IOS_SDK_VERSION"\n' 'XCODEBUILD_BIN="$(command -v xcodebuild)"\n' - '_wda_xcodebuild -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner ' - '-destination platform=iOS,id=0000 -allowProvisioningUpdates ' + '_runner_xcodebuild -destination platform=iOS,id=0000 -allowProvisioningUpdates ' 'DEVELOPMENT_TEAM=TEAM000000 PRODUCT_BUNDLE_IDENTIFIER=com.example.wda build-for-testing\n') self.assertEqual(code, 0, out) self.assertIn('override=15.0 sdk=27.0', out) @@ -140,14 +141,14 @@ def test_xcode27_writes_override_and_exports_it_without_touching_argv(self): self.assertNotIn('#include', content) self.assertEqual(self.xcconfig.stat().st_mode & 0o777, 0o600) log = self.log.read_text().splitlines() - self.assertEqual(log[0], 'argv: -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner ' + self.assertEqual(log[0], f'argv: -project {self.project} -scheme IPhoneUseRunner ' + f'-derivedDataPath {self.derived} ' '-destination platform=iOS,id=0000 -allowProvisioningUpdates ' 'DEVELOPMENT_TEAM=TEAM000000 PRODUCT_BUNDLE_IDENTIFIER=com.example.wda ' 'build-for-testing') self.assertEqual(log[1], f'xcconfig:{self.xcconfig}') - # The pinned checkout is never edited. - self.assertIn('IPHONEOS_DEPLOYMENT_TARGET = 12.0;', - (self.wda / 'WebDriverAgent.xcodeproj' / 'project.pbxproj').read_text()) + # The runner sources are never edited. + self.assertIn('IPHONEOS_DEPLOYMENT_TARGET = 12.0;', (self.project / 'project.pbxproj').read_text()) def test_override_uses_highest_project_value_when_above_minimum(self): self.set_project_targets('12.0', '16.0') @@ -228,9 +229,10 @@ def test_no_or_several_matches_stay_ambiguous(self): def test_empty_products_dir_is_ambiguous(self): self.assertEqual(self.resolve('27.0'), 'AMBIGUOUS') - def test_launch_block_passes_the_built_sdk_to_the_resolver(self): - self.assertIn('_resolve_xctestrun "$WDA_ICON_PRODUCTS_DIR" "${WDA_BUILT_SDK_VERSION:-}"', SOURCE) - self.assertIn('die "could not resolve a unique .xctestrun for the injected runner', SOURCE) + def test_launch_path_resolves_the_xctestrun_and_refuses_ambiguity(self): + launch = SOURCE.split('\n_ensure_launchable_runner() {', 1)[1].split('\n}\n', 1)[0] + self.assertIn('_resolve_xctestrun "$products"', launch) + self.assertIn('could not resolve a unique .xctestrun', launch) # ── 3. doctor ───────────────────────────────────────────────────────── def doctor(self, **kwargs): @@ -238,7 +240,7 @@ def doctor(self, **kwargs): def test_doctor_warns_when_xcode27_has_no_override_yet(self): code, out = self.doctor() - self.assertIn('warn: ~ Xcode 27 supports iOS deployment targets from 15.0, but WDA sets 12.0', out) + self.assertIn('warn: ~ Xcode 27 supports iOS deployment targets from 15.0, but the runner project sets 12.0', out) self.assertIn('rc=0', out) def test_doctor_fails_when_installed_script_predates_the_override(self): @@ -265,7 +267,7 @@ def test_doctor_lists_multiple_xctestrun_files(self): self.xctestrun('26.5-arm64', '27.0-arm64') code, out = self.doctor() self.assertIn('warn: ~ multiple .xctestrun files in', out) - self.assertIn('WebDriverAgentRunner_iphoneos26.5-arm64.xctestrun', out) + self.assertIn('IPhoneUseRunner_iphoneos26.5-arm64.xctestrun', out) self.assertIn('setup uses the one for the current SDK (iphoneos27.0)', out) self.assertIn('rc=0', out) code, out = self.doctor(sdk_version='28.0') diff --git a/scripts/test-uninstall-safety.sh b/scripts/test-uninstall-safety.sh index 9902ac8..6b32fe3 100755 --- a/scripts/test-uninstall-safety.sh +++ b/scripts/test-uninstall-safety.sh @@ -5,7 +5,6 @@ umask 077 ROOT="$(cd "$(dirname "$0")/.." && pwd)" UNINSTALL="$ROOT/uninstall.sh" -SETUP="$ROOT/scripts/setup-wda.sh" TMP_ROOT_RAW="$(mktemp -d "${TMPDIR:-/tmp}/iphone-use-uninstall-test.XXXXXX")" TMP_ROOT="$(cd -P "$TMP_ROOT_RAW" && pwd)" SLEEP_PIDS="" @@ -211,7 +210,7 @@ if env HOME="$TEST_HOME" \ /bin/bash "$UNINSTALL" --dry-run >"$TEST_ROOT/out" 2>"$TEST_ROOT/err"; then fail_test "wrong runner cwd unexpectedly passed" fi -grep -q "not a WDA runner rooted in the configured checkout" "$TEST_ROOT/err" \ +grep -q "not a runner rooted in its expected directory" "$TEST_ROOT/err" \ || fail_test "wrong runner cwd rejection reason missing" ! grep -q "send SIGTERM" "$TEST_ROOT/out" \ || fail_test "wrong-cwd runner reached a signal plan" @@ -380,32 +379,87 @@ grep -Eq "Git refs changed|worktree" "$TEST_ROOT/err" \ || fail_test "linked-worktree rejection reason missing" pass "linked worktrees and their Git administration data are preserved" -new_home "setup-marker-helper" -git_init_checkout -marker_functions="$(sed -n \ - '/^_sha256_text() {/,/^# Resolve one signing identity/{ /^# Resolve one signing identity/d; p; }' \ - "$SETUP")" -( - # The extracted production functions consume these globals dynamically. - # shellcheck disable=SC2034 - UID_NUM="$(id -u)" - # shellcheck disable=SC2034 - STATE_DIR="$TEST_HOME/.iphone-use" - # shellcheck disable=SC2034 - WDA_CHECKOUT_MARKER="$STATE_DIR/wda-checkout-owner.v1" - # shellcheck disable=SC2034 - WDA_REF="$WDA_HEAD" - # shellcheck disable=SC2034 - WDA_MARKER_REFRESH_ALLOWED=1 - # shellcheck disable=SC2034 - SHASUM_BIN="$(command -v shasum)" - eval "$marker_functions" - _write_wda_checkout_marker - _existing_marker_matches_checkout \ - "$WDA_DIR" "https://github.com/appium/WebDriverAgent.git" "$WDA_HEAD" -) -[ "$(/usr/bin/stat -f '%Lp' "$TEST_HOME/.iphone-use/wda-checkout-owner.v1")" = "600" ] \ - || fail_test "setup marker mode is not 600" -pass "setup marker helper writes and revalidates atomically" + +# ── device runner (replaces WebDriverAgent) ──────────────────────────────── +device_runner_fixture() { + new_home "$1" + device_runner_command="/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -destination platform=iOS,id=00008110-001234567890001E test-without-building -xctestrun $TEST_HOME/.iphone-use/runner-build/Build/Products/IPhoneUseRunner_iphoneos27.0-arm64.xctestrun -only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe" + cat > "$TEST_BIN/ps" < "$TEST_HOME/.iphone-use/wda-runner.pid" + chmod 600 "$TEST_HOME/.iphone-use/wda-runner.pid" +} + +device_runner_fixture "device-runner-contract" +cat > "$TEST_BIN/lsof" <"$TEST_ROOT/out" 2>"$TEST_ROOT/err" || true +grep -q "send SIGTERM to PID-verified runner process 4343" "$TEST_ROOT/out" \ + || fail_test "the device runner started from the state directory did not reach the signal plan" +pass "device runner command plus state-directory cwd reaches the signal plan" + +device_runner_fixture "device-runner-wrong-cwd" +mkdir -p "$TEST_ROOT/elsewhere" +cat > "$TEST_BIN/lsof" <"$TEST_ROOT/out" 2>"$TEST_ROOT/err"; then + fail_test "a device-runner-shaped xcodebuild outside the state directory was accepted" +fi +grep -q "send SIGTERM" "$TEST_ROOT/out" && fail_test "a foreign cwd reached the signal plan" +pass "device-runner-shaped xcodebuild outside the state directory is rejected" + +runner_state_fixture() { + new_home "$1" + local state="$TEST_HOME/.iphone-use" + mkdir -p "$state/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj" \ + "$state/runner-build/Build/Products/Debug-iphoneos/iPhoneUse-Runner.app" + printf '{}\n' > "$state/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj" + for name in wda-runner-product.json wda-runner-product-build.log \ + wda-runner-product-build.repair.log wda-retry-state.v1 \ + wda-xcode-compat.xcconfig wda-setup-status.json wda-setup-status.json.lock; do + printf 'x\n' > "$state/$name" + chmod 600 "$state/$name" + done +} + +runner_state_fixture "device-runner-state" +run_uninstall >"$TEST_ROOT/out" 2>"$TEST_ROOT/err" \ + || fail_test "uninstall with device-runner state failed: $(cat "$TEST_ROOT/err")" +[ ! -e "$TEST_HOME/.iphone-use" ] \ + || fail_test "device-runner state was left behind: $(ls -A "$TEST_HOME/.iphone-use")" +pass "uninstall removes runner sources, build products and runner state files" + +runner_state_fixture "device-runner-shared" +mkdir -m 700 -p "$TEST_HOME/.iphone-use/instances/lab" +if run_uninstall >"$TEST_ROOT/out" 2>"$TEST_ROOT/err"; then + fail_test "the default instance uninstalled while a named instance remained" +fi +[ -f "$TEST_HOME/.iphone-use/runner/IPhoneUseRunner/IPhoneUseRunner.xcodeproj/project.pbxproj" ] \ + || fail_test "runner sources were removed while a named instance still builds from them" +pass "shared runner sources survive while a named instance remains" printf '1..%d\n' "$pass_count" diff --git a/skills/iphone-use/SKILL.md b/skills/iphone-use/SKILL.md index c2d06c2..ee13402 100644 --- a/skills/iphone-use/SKILL.md +++ b/skills/iphone-use/SKILL.md @@ -6,7 +6,8 @@ description: Use when a task needs a real iPhone — operating iOS apps that hav # iphone-use — drive a real iPhone The [iphone-use](https://github.com/leeguooooo/iphone-use) daemon drives a -physical iPhone over WebDriverAgent. It never touches the Mac's screen or +physical iPhone through its XCTest device runner (WebDriverAgent-compatible; "WDA" in +field names means it). It never touches the Mac's screen or cursor. Use the HTTP API below or the bundled MCP server (`phone_*` tools); the loop is the same. Details live in the reference: `curl -s -H "$AUTH" "$HOST/agent/reference"` serves the copy that matches the running daemon diff --git a/uninstall.sh b/uninstall.sh index d8fe53d..e1b5e35 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -542,7 +542,10 @@ expected_role_valid() { # Keep the uninstaller's process identity contract in lockstep with # scripts/setup-wda.sh. A PID record is evidence, not authority: the command it -# contains must still have one of the exact WDA/relay shapes that setup writes. +# contains must still have one of the exact runner/relay shapes that setup writes. +# The device runner is launched from its built product; the WebDriverAgent form +# is what releases before it wrote, and stays recognised for their leftovers. +RUNNER_ARGV_PATTERN='^(/[^ ]*/)?xcodebuild -destination platform=iOS,id=[0-9A-Fa-f-]+ test-without-building -xctestrun /[^ ]+/IPhoneUseRunner_[^ /]+\.xctestrun -only-testing:IPhoneUseRunnerUITests/RunnerTests/testServe( -allowProvisioningUpdates -authenticationKeyPath /[^|[:cntrl:]]+\.p8 -authenticationKeyID [A-Za-z0-9]+ -authenticationKeyIssuerID [A-Za-z0-9-]+ -allowProvisioningDeviceRegistration)?$' command_matches_expected() { local command="$1" local expected="$2" @@ -551,9 +554,13 @@ command_matches_expected() { case "$expected" in runner:*) signature="${expected#*:}" - printf '%s\n' "$signature" | LC_ALL=C grep -Eq \ - '^(/[^ ]*/)?xcodebuild -project WebDriverAgent\.xcodeproj -scheme WebDriverAgentRunner -destination platform=iOS,id=[0-9A-Fa-f-]+ -allowProvisioningUpdates DEVELOPMENT_TEAM=[A-Z0-9]{10} PRODUCT_BUNDLE_IDENTIFIER=[A-Za-z0-9.-]+ test$' \ - || return 1 + if printf '%s\n' "$signature" | LC_ALL=C grep -Eq "$RUNNER_ARGV_PATTERN"; then + : + else + printf '%s\n' "$signature" | LC_ALL=C grep -Eq \ + '^(/[^ ]*/)?xcodebuild -project WebDriverAgent\.xcodeproj -scheme WebDriverAgentRunner -destination platform=iOS,id=[0-9A-Fa-f-]+ -allowProvisioningUpdates DEVELOPMENT_TEAM=[A-Z0-9]{10} PRODUCT_BUNDLE_IDENTIFIER=[A-Za-z0-9.-]+ test$' \ + || return 1 + fi [ "$command" = "$signature" ] ;; relay:*|mjpeg:*) @@ -661,11 +668,17 @@ verify_loopback_listener() { [ -z "$unexpected" ] } +# setup-wda.sh starts the device runner from the state directory, and a +# WebDriverAgent runner (earlier releases) from its configured checkout. verify_runner_cwd() { local pid="$1" + local command="${2:-}" local configured="${CONFIGURED_WDA_DIR:-}" local expected_dir cwd_data process_cwd [ -n "$configured" ] || configured="$WDA_CHECKOUT" + if printf '%s\n' "$command" | LC_ALL=C grep -Eq "$RUNNER_ARGV_PATTERN"; then + configured="$STATE_DIR" + fi [ -n "$LSOF_BIN" ] && [ -x "$LSOF_BIN" ] || return 1 [ ! -L "$configured" ] && [ -d "$configured" ] || return 1 expected_dir="$(cd -P "$configured" 2>/dev/null && pwd)" || return 1 @@ -781,8 +794,8 @@ stop_pid_record() { } case "$role" in runner) - verify_runner_cwd "$pid" || { - fail "PID $pid is not a WDA runner rooted in the configured checkout; refusing to signal it" + verify_runner_cwd "$pid" "$command" || { + fail "PID $pid is not a runner rooted in its expected directory; refusing to signal it" return 1 } ;; @@ -822,7 +835,7 @@ stop_pid_record() { } case "$role" in runner) - verify_runner_cwd "$pid" || { + verify_runner_cwd "$pid" "$command" || { fail "PID $pid runner cwd changed before signalling; refusing to kill it" return 1 } @@ -1182,6 +1195,10 @@ remove_state_transients() { -o -name 'uninstall.sh.restore.*' \ -o -name 'wda-checkout-owner.v1.new.*' \ -o -name 'wda-checkout-owner.v1.restore.*' \ + -o -name '.wda-runner-product.*' \ + -o -name 'wda-retry-state.v1.new.*' \ + -o -name 'wda-xcode-compat.xcconfig.new.*' \ + -o -name 'wda-setup-status.json.?*' \ -o -name '.lsof-error.*' \ -o -name '.wda-mjpeg-probe.*' \) \ -print0 2>/dev/null @@ -1194,11 +1211,18 @@ state_unknown_entries() { while IFS= read -r -d '' entry; do name="${entry##*/}" case "$name" in - WebDriverAgent) + WebDriverAgent|runner-build) if [ -L "$entry" ] || [ ! -d "$entry" ]; then printf '%s\n' "$entry" fi ;; + runner) + # The installer-owned runner sources live in the default + # instance's state directory only. + if [ "$INSTANCE_NAME" != default ] || [ -L "$entry" ] || [ ! -d "$entry" ]; then + printf '%s\n' "$entry" + fi + ;; uninstall.sh|\ setup-wda.sh|\ setup-wda.rollback.sh|\ @@ -1222,7 +1246,12 @@ state_unknown_entries() { wda-runner.log|wda-runner.pid|\ wda-relay.log|wda-relay.pid|\ wda-mjpeg-relay.log|wda-mjpeg-relay.pid|\ - wda-agent.log|wda-setup-status.json|\ + wda-agent.log|wda-setup-status.json|wda-setup-status.json.lock|\ + wda-runner-product.json|wda-runner-product-build.log|\ + wda-runner-product-build.repair.log|wda-retry-state.v1|\ + wda-xcode-compat.xcconfig|wda-relay.log.?*|\ + .wda-runner-product.*|wda-retry-state.v1.new.*|\ + wda-xcode-compat.xcconfig.new.*|wda-setup-status.json.?*|\ .lsof-error.*|.wda-mjpeg-probe.*) if [ -L "$entry" ] || [ ! -f "$entry" ]; then printf '%s\n' "$entry" @@ -1484,7 +1513,7 @@ if [ "$WDA_RUNTIME_CLEAN" != "1" ] \ warn "preserved WDA supervisor plist for recovery: $WDA_PLIST" fi -info "5/5 removing iphone-use-owned WDA checkout and state" +info "5/5 removing iphone-use-owned runner builds, WDA checkout and state" STATE_TREE_REMOVABLE=1 if [ "$WDA_RUNTIME_CLEAN" != "1" ]; then warn "preserved $STATE_DIR because a WDA process/supervisor could still be active" @@ -1509,7 +1538,26 @@ fi if [ "$FAILED" = "0" ] \ && [ "$WDA_RUNTIME_CLEAN" = "1" ] \ && [ "$STATE_SAFE" = "1" ]; then + # Device runner build products (per instance). Only reached once the + # runner itself is verified stopped. + remove_tree "$STATE_DIR/runner-build" "$STATE_DIR/runner-build" "$STATE_DIR" + # The runner sources install.sh lays down are shared by every instance: + # removed with the default instance, and only when no named instance + # remains to build from them. + if [ "$FAILED" = "0" ] && [ "$INSTANCE_NAME" = default ]; then + if [ -z "$(find "$INSTANCES_DIR" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then + remove_tree "$STATE_DIR/runner" "$STATE_DIR/runner" "$STATE_DIR" + else + warn "kept $STATE_DIR/runner: named instances still build the device runner from it" + fi + fi for state_name in \ + wda-runner-product.json \ + wda-runner-product-build.log \ + wda-runner-product-build.repair.log \ + wda-retry-state.v1 \ + wda-xcode-compat.xcconfig \ + wda-setup-status.json.lock \ wda-runner.log \ wda-runner.pid \ wda-relay.log \