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 @@
-
+
@@ -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://
-
+
@@ -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://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” 即可。
", "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
- CFBundleIconFiles AppIcon60x60
- CFBundleIconName AppIcon
-
-
- CFBundleIcons~ipad
- CFBundlePrimaryIcon
- CFBundleIconFiles AppIcon60x60 AppIcon76x76
-
-
-
-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 'CFBundleExecutable Fake \n' \
- > "$app/Frameworks/Fake.framework/Info.plist"
-printf 'CFBundleExecutable RunnerTests \n' \
- > "$app/PlugIns/RunnerTests.xctest/Info.plist"
-printf 'pristine\n' > "$app/original-marker.txt"
-cat > "$app/Info.plist" <<'PLIST'
-
-
-CFBundleName iPhoneUse-Runner CFBundleExecutable iPhoneUse-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-identifier ABCDE12345.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 \