Adds an opt-in dependency supervisor inside the Host Agent that probes,
spawns, and restarts the two local processes the macOS single-machine
real-device workflow depends on: the Appium server (gates Driver.connect())
and the local Runtime API (local inspection). Default-off; gated by
HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED plus per-dependency *_SUPERVISED
flags.
Mitigates the live-incident failure mode where forgetting to start Appium
silently keeps devices offline and tasks queued forever with no error
surfaced in Host Agent logs.
Behavior (per openspec change):
- Adopt-don't-fight: probe (TCP + dependency-specific HTTP health check)
before spawn. Healthy listener → adopted (never killed/restarted).
Unhealthy listener → port-conflict error, skip. No listener → spawn.
- Only supervisor-spawned processes are restarted on crash, with capped
exponential backoff (1s/2s/4s/8s, capped at 30s) and a per-process-lifetime
attempt ceiling (HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS, default 5).
- Spawn failures (e.g. missing executable) logged distinctly from crashes.
- Graceful stop terminates only spawned children; adopted processes untouched.
- Supervisor starts before the heartbeat loop's first connect_devices() pass
and stops alongside existing heartbeat/console teardown.
Validation: ruff check + format clean, compileall clean, openspec validate
--strict valid. Non-integration suite 503 passed / 44 deselected / 2 failed
(both failures pre-existing from unrelated 03c7c30 LLM_PROVIDER_ENC_KEY;
verified by stashing this change). macOS real-device manual verification
(task 6.4) deferred to a macOS host.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
macOS 迁移与 iPhone 真机控制手册
本文用于把 Device Agent Runtime 从 Windows 或其他开发机迁移到 macOS,并在 macOS 上通过 Appium + WebDriverAgent(WDA)控制真实 iPhone。
1. 当前支持范围
- 当前仓库内置
wda(iPhone/iPad 的 XCUITest/WDA)与uiautomator2(Android 的 Appium UiAutomator2)两种 Driver,均在driver/registry.py注册。 - Android 驱动代码已落地但尚未经过真机验证;完整的 Android SDK/adb/Appium 真机 安装手册留待后续变更补充,本文其余章节仍聚焦 iPhone 真机流程。
- iPhone 真机自动化必须在 macOS 上完成,因为 XCUITest、Xcode 和 WDA 签名依赖 Apple 工具链。
- 当前 Web Console 的设备登记接口不会自动连接设备,REST API 也没有公开的 connect/disconnect endpoint。本文提供的 Runtime 启动命令会在启动 API 前显式 注册并连接 iPhone。
2. 迁移前准备
建议迁移源代码和必要配置,不要复制原操作系统生成的虚拟环境或缓存。
需要迁移:
- Git 仓库中的源代码。
- 未提交的必要代码和文档变更。
- 自己维护的 API Key、模型配置等敏感环境变量,使用安全渠道单独迁移。
- 如确实需要保留设备登记和任务历史,可单独备份
tasks/*.sqlite3;通常建议在 Mac 上重新登记设备,避免沿用旧 UDID、端口和路径。
不要迁移:
.venv/__pycache__/.pytest_cache/- IDE 缓存
- Windows 专用路径或 PowerShell profile
在 Mac 上优先通过 Git 重新 clone:
git clone <repository-url> agentic-modile-control
cd agentic-modile-control
git status
注意:仓库目录仍叫 agentic-modile-control,其中 modile 是历史拼写;Python
包名已经是 device-agent-runtime。不要在迁移时顺手改目录名或数据库路径,除非
准备单独处理相关脚本和配置。
3. 准备 Mac、Xcode 和 iPhone
3.1 必要条件
- 一台能运行目标 iOS 对应 Xcode 版本的 Mac。
- 完整版 Xcode,不只是 Command Line Tools。
- Apple ID;真机 WDA 签名需要可用的 Apple Development team。免费 Personal Team 可用于本地验证,但证书和 provisioning profile 有期限限制。
- 数据线连接的 iPhone。第一次配置建议先使用 USB,稳定后再考虑无线调试。
3.2 安装并初始化 Xcode
从 App Store 或 Apple Developer 网站安装 Xcode,至少启动一次,然后执行:
xcode-select --install
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
xcodebuild -version
xcode-select -p
如果 Xcode 不在 /Applications/Xcode.app,将路径替换成实际位置。
打开 Xcode,进入 Settings > Accounts,登录 Apple ID,并确认能看到用于签名的
Team。
3.3 配置 iPhone
- 使用数据线连接 iPhone,解锁手机并选择“信任此电脑”。
- 在 Xcode 中打开
Window > Devices and Simulators,确认设备可见且没有 “Preparing debugger support”之类的未完成状态。 - 在 iPhone 中进入
设置 > 隐私与安全性 > 开发者模式,启用后按提示重启并 再次确认。 - 保持设备解锁,记录 UDID。可从 Xcode 的 Devices and Simulators 窗口复制, 也可运行:
xcrun devicectl list devices
后续示例使用以下环境变量。值必须换成自己的信息:
export DEVICE_UDID="<iPhone UDID>"
export APPLE_TEAM_ID="<Apple Developer Team ID>"
export WDA_BUNDLE_ID="com.<your-name>.WebDriverAgentRunner"
WDA_BUNDLE_ID 必须在自己的 Team 下唯一,不能原样使用示例值。
4. 安装本地工具链
4.1 Homebrew、Python 和 Node.js
本项目当前在 pyproject.toml 中要求 Python >=3.14。Appium 3.1 要求 Node.js
^20.19.0 || ^22.12.0 || >=24.0.0,并要求 npm >=10。
已安装 Homebrew 时执行:
brew update
brew install uv node
uv --version
node --version
npm --version
确认 Node.js/npm 满足上面的版本范围。Apple Silicon 和 Intel Mac 都应使用本机 架构的 Homebrew,不要在 Rosetta shell 与原生 shell 之间混用 Python/Node。
4.2 同步 Python workspace
在仓库根目录执行:
uv python install 3.14
uv sync --locked --all-packages
uv run --package device-agent-runtime python --version
uv run --package device-cloud-platform python -c "import cloud"
uv 会在仓库根目录管理 .venv,共享 uv.lock 同时锁定本地 Runtime 和 cloud
platform 两个 Python member。运行完整的非实机测试使用:
uv run --all-packages pytest -m "not integration"
如果 Apple Silicon 上安装 paddleocr 或其底层推理依赖失败,可以先安装不带 OCR
的控制链路,用于截图、点击、滑动、输入、启动 App 和读取 UI tree:
uv pip install -e . --no-deps
uv pip install \
"anthropic>=0.69.0" \
"Appium-Python-Client>=5.1.1" \
"fastapi>=0.115.0" \
"httpx>=0.27.0" \
"mcp>=1.27,<2" \
"openai>=1.0.0" \
"uvicorn[standard]>=0.30.0" \
"pytest>=8.3.0"
这种降级安装不包含 OCR。find_text 和基于 OCR 的 screen description 可能返回空
结果,但 WDA 基础控制不受影响。不要为了兼容 PaddleOCR 擅自降到 Python 3.13,
因为当前项目元数据明确要求 Python 3.14;如需降级,应先作为独立兼容性变更修改
和验证 pyproject.toml。
4.3 安装 Appium 和 XCUITest Driver
npm install -g appium
appium --version
appium driver install xcuitest
appium driver list --installed
appium driver doctor xcuitest
doctor 中与 Xcode、签名、设备通信相关的错误必须先处理。仅有 warning 时,应根据
是否使用对应功能判断;不要忽略 Xcode command line tools、WDA 或 real-device
相关错误。
5. 配置 WebDriverAgent 签名
本项目会让 Appium 的 XCUITest Driver 创建和管理 WDA session。真机第一次运行时,
必须给 WDA 提供签名信息。仓库的 WDADriverConfig 支持把未知的
connection_info 字段作为 Appium extra capabilities 透传,因此可以直接传入:
xcodeOrgId: Apple Team ID。xcodeSigningId: 推荐使用Apple Development。updatedWDABundleId: 自己 Team 下唯一的 WDA bundle ID。
如果自动签名失败,可查出 XCUITest Driver 的安装路径:
appium driver list --installed
npm root -g
在 Appium 的 XCUITest Driver 目录中找到并用 Xcode 打开
WebDriverAgent.xcodeproj,然后:
- 选择
WebDriverAgentRunnertarget。 - 在
Signing & Capabilities中启用 Automatically manage signing。 - 选择自己的 Team。
- 将 bundle identifier 改成自己可签名的唯一值。
- 选择已连接的 iPhone,运行
WebDriverAgentRunnertest target。 - 如果 iPhone 提示开发者不受信任,在设备的 VPN 与设备管理设置中信任该开发者。
Appium/XCUITest Driver 升级后,WDA 项目位置或内容可能变化;优先使用自动签名 capabilities,只有自动签名无法工作时再手动打开工程。
6. 启动 Appium
在单独的 Terminal 窗口执行:
appium --address 127.0.0.1 --port 4723
保持该进程运行。另开一个 Terminal 检查状态:
curl -s http://127.0.0.1:4723/status
应返回包含 ready 或 Appium build 信息的 JSON。若 4723 端口被占用,可以换端口,
但后续 server_url 必须同步修改。
7. 先做最小真机验证
回到仓库根目录,激活虚拟环境并设置环境变量:
source .venv/bin/activate
export APEX_WDA_SERVER_URL="http://127.0.0.1:4723"
export APEX_WDA_UDID="$DEVICE_UDID"
第一次建议使用下面的直接验证,它会显式传递签名 capabilities,并把截图写到
/tmp/device-agent-runtime.png:
python - <<'PY'
import os
from pathlib import Path
from driver.wda_driver import WDADriver, WDADriverConfig
driver = WDADriver(
WDADriverConfig(
server_url="http://127.0.0.1:4723",
udid=os.environ["DEVICE_UDID"],
device_name="iPhone",
extra_capabilities={
"xcodeOrgId": os.environ["APPLE_TEAM_ID"],
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": os.environ["WDA_BUNDLE_ID"],
},
)
)
driver.connect()
try:
image = driver.screenshot()
Path("/tmp/device-agent-runtime.png").write_bytes(image)
print(f"screenshot ok: {len(image)} bytes")
finally:
driver.disconnect()
PY
验证通过后,可以运行仓库的实机 integration test:
uv run --package device-agent-runtime pytest -m integration tests/test_wda_integration.py -v
该测试只从环境变量读取 server URL、UDID 和 device name,不传签名 capabilities, 因此应在 WDA 已成功签名/安装后运行。
8. 启动可控制真机的 Runtime API
当前不能只运行 README 中的普通 uvicorn ... --factory 命令,因为它只会加载已登记
设备,不会调用 DeviceManager.connect()。使用下面的启动方式,在同一进程中完成
设备注册、WDA 连接和 REST API 启动:
python - <<'PY'
import os
import uvicorn
from api.rest import create_app
from device.manager import DeviceManager
from driver.registry import build_driver_factory
device_id = "iphone-1"
connection_info = {
"server_url": "http://127.0.0.1:4723",
"device_name": "iPhone",
"udid": os.environ["DEVICE_UDID"],
"xcodeOrgId": os.environ["APPLE_TEAM_ID"],
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": os.environ["WDA_BUNDLE_ID"],
}
manager = DeviceManager()
app = create_app(manager=manager)
manager.register_device(
device_id,
build_driver_factory("wda", connection_info),
name="Local iPhone",
driver_type="wda",
connection_info=connection_info,
)
manager.connect(device_id, max_retries=1)
uvicorn.run(app, host="127.0.0.1", port=8000)
PY
保持进程运行,在另一个 Terminal 验证:
curl -s http://127.0.0.1:8000/devices
curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/tap \
-H 'Content-Type: application/json' \
-d '{"x": 100, "y": 200}'
curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \
-H 'Content-Type: application/json' \
-d '{"app_id": "com.apple.Preferences"}'
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免 误操作。
如需启动 Web Console,保持 Runtime API 运行,再在第三个 Terminal 执行:
cd console
npm install
npm run dev
Console 默认连接 http://127.0.0.1:8000。已由上面启动脚本连接的
iphone-1 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
操作只写入配置,不会自动 connect。
9. 启动云端受管 Host Agent
完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。
先把设备连接参数写入本地配置;这里的 device_id 只是 Mac 内部引用,不需要与
云端协调,云端会在首次启动时下发正式 ID:
uv run --package device-agent-runtime python - <<'PY'
import os
from storage.device_config import DeviceConfigStore
DeviceConfigStore("tasks/device_config.sqlite3").add(
device_id="local-iphone-1",
name="Edge iPhone",
driver_type="wda",
connection_info={
"server_url": "http://127.0.0.1:4723",
"device_name": "iPhone",
"udid": os.environ["DEVICE_UDID"],
"wda_local_port": 8100,
"xcodeOrgId": os.environ["APPLE_TEAM_ID"],
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": os.environ["WDA_BUNDLE_ID"],
},
)
PY
首次启动前,先在交互式终端创建一次性本地操作账号(仅用于门禁"首次启动"这个 动作本身,后续无人值守重启不会再次要求):
uv run --package device-host-agent device-host-agent setup
HOST_AGENT_CONTROL_PLANE_URL 默认已固定为 https://amcp.home.jerryyan.top;
仅在连接其他云端环境(如本地/测试用的 cloud-api)时才需要覆盖该变量。未设置
缓存身份不存在时,Host Agent 会直接向云端注册,由云端返回 host_id;后续运行
使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
export AI_PLANNER_ENABLED="true"
export AI_PLANNER_PROVIDER="anthropic"
export ANTHROPIC_API_KEY="<secret>"
uv run --package device-host-agent device-host-agent
首次启动顺序为:持久化候选 Host secret、向云端换取 host_id、为每个本地设备
换取 device_id、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 会在本机回环地址提供 Console。必须保留并保护 tasks/host_identity.json 和
tasks/device_config.sqlite3;前者等同于 Host bearer credential。
直接注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有审批环节, 也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层限制。
使用本地 Web Console
Host Agent 启动时会同时启动本地 Web Console,用于在这台 Mac 上直接查看和管理 正在运行的 Host,无需 SSH 进去读原始状态文件。
不要修改 HOST_AGENT_CONSOLE_BIND_HOST,保持默认回环地址 127.0.0.1;Console
启动后在同一台 Mac 上打开:
http://127.0.0.1:8765
8765 是 HOST_AGENT_CONSOLE_PORT 的默认值(见
apps/device-host-agent/host_agent/config.py)。登录使用与
uv run --package device-host-agent device-host-agent setup 创建的同一个本地账号,
没有单独的 Console 账号体系。
登录后可以看到:
- 状态仪表盘:最近一次 heartbeat 结果与时间、enrollment/identity 状态、本地登记 设备及其连接状态、当前 assignment 执行进度。
- 本地设备管理:新增、编辑、删除登记在这台 Host 上的设备,改动会立即在运行中的
DeviceManager上生效,无需重启 Host Agent。 - 修改密码:更新本地操作账号密码,需要先输入当前密码。
- 最近历史:近期 assignment 与 heartbeat 的执行记录。
完整的 HOST_AGENT_CONSOLE_* 环境变量列表(端口、非回环 bind 的显式 opt-in、
session TTL、历史记录条数上限等)参见 docs/CLOUD_DEPLOYMENT.md;生产/远程场景下
应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
可选:由 Host Agent 托管 Appium 和 Runtime API
默认情况下 Host Agent 不会自动启动 Appium 或本地 Runtime API:必须按
§6 在独立 Terminal 中保持 appium --address 127.0.0.1 --port 4723 运行,按
§8 在另一个 Terminal 中启动 Runtime API。忘记其中任意一个,Host Agent 不会报错,
heartbeat 仍会成功,但设备会静默保持 offline、所有任务卡在 queued。
host-agent-dependency-supervisor 是一个可选模式,让 Host Agent 自己把这两个
外部进程作为子进程托管,覆盖单机真机工作流。它默认关闭,需要显式 opt-in:
export HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED="true"
# 任选其一或两者都开。两者默认 false。
export HOST_AGENT_APPIUM_SUPERVISED="true"
export HOST_AGENT_RUNTIME_SUPERVISED="true"
# 可覆盖默认地址/端口(默认值与 §6/§8 手动流程一致):
# export HOST_AGENT_APPIUM_HOST="127.0.0.1"
# export HOST_AGENT_APPIUM_PORT="4723"
# export HOST_AGENT_RUNTIME_HOST="127.0.0.1"
# export HOST_AGENT_RUNTIME_PORT="8000"
# 单次 Host Agent 进程生命周期内允许的最大重启次数,默认 5。
# export HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS="5"
uv run --package device-host-agent device-host-agent
启用后的行为(详见 openspec/changes/host-agent-dependency-supervisor/):
- 启动顺序:Host Agent 在第一次
connect_devices()之前,先按上面选中的 依赖项依次做"先探测后启动"。这样一旦开启,Appium 不再需要单独的 Terminal。 - Adopt-don't-fight:探测
(host, port)时若已经有进程在监听并通过健康检查 (AppiumGET /status返回 200 JSON,RuntimeGET /devices返回 200 JSON), Host Agent 会以 adopted 方式记录日志,不会再 spawn 一个重复进程,也不会 在退出/崩溃时杀掉或重启它。如果端口被占但健康检查失败,记一条 port-conflict 错误并跳过该依赖,不抢端口、不静默继续。 - 崩溃重启:只有 Host Agent 自己 spawn 出来的子进程才会被监控。子进程意外
退出时,按指数退避(1s、2s、4s、8s,封顶 30s)重启;当某个依赖在本进程生命
周期内累计达到
HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS次重启后,停止 再次尝试直到 Host Agent 重启。被 adopt 的进程永远不会被 Host Agent 重启或杀死。 - spawn 失败 ≠ crash:如果
appium可执行文件不在PATH上,启动会以 "dependency-supervisor: appium spawn failed — executable not found" 形式记一条 依赖主管特有的错误,与正常 crash 区分开。请确认按 §4.3 安装好appium和 XCUITest/UiAutomator2 driver。 - 退出时:Host Agent 在自身 graceful shutdown 阶段只会
terminate它自己 spawn 的子进程;adopted 进程保留不动。 - 不影响 Docker/Compose:本模式只针对 macOS 单机真机工作流;
compose.yaml/compose.deploy.yaml完全不受影响。
如果偏好保持对 Appium 终端日志的完全控制、或者已经在用其他进程管理工具 (launchd、systemd、tmux 等)托管 Appium,可以继续使用 §6/§8 的手动流程, 不开启本模式即可。
10. 多设备与端口
同时连接多台 iPhone 时,每台设备至少需要:
- 唯一的 Runtime
device_id。 - 唯一的 iPhone
udid。 - 独立的
wda_local_port,例如 8100、8101、8102。
示例 connection info:
{
"server_url": "http://127.0.0.1:4723",
"device_name": "iPhone",
"udid": "<device-udid>",
"wda_local_port": 8101,
"xcodeOrgId": "<team-id>",
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": "com.example.WebDriverAgentRunner"
}
如果使用预先启动的 WDA,可以通过 extra capability 传
webDriverAgentUrl。常规单机使用优先让 Appium 管理 WDA,不要一开始就引入
iproxy 或手工 WDA 生命周期。
11. 常见故障
Appium 返回 device offline
项目会把 Appium session 创建阶段的大多数异常统一转换成 device offline。真正原因
通常在 Appium Terminal 日志中。优先查看最早出现的 Xcode、签名、UDID、端口或 WDA
错误,不要只看 Python 端的最终异常。
找不到设备
xcrun devicectl list devices
xcode-select -p
xcodebuild -version
确认手机已解锁、信任电脑、启用 Developer Mode,并能在 Xcode Devices and Simulators 中看到。
WDA 构建或签名失败
- 确认
xcodeOrgId是 Team ID,不是 Apple ID 邮箱。 - 确认
updatedWDABundleId唯一且属于可签名的 Team。 - 确认使用
Apple Developmentsigning identity。 - 打开 WDA Xcode 工程检查
WebDriverAgentRunnertarget 的 Signing & Capabilities。 - 免费 Personal Team 的 profile 过期后需要重新签名和安装。
iOS/Xcode 升级后突然失败
npm install -g appium
appium driver list --updates
appium driver update xcuitest
appium driver doctor xcuitest
升级前记录当前 Appium 和 XCUITest Driver 版本。生产或长期运行环境不要在没有回归 验证时自动更新 Xcode、iOS、Appium 和 XCUITest Driver。
端口冲突
lsof -nP -iTCP:4723 -sTCP:LISTEN
lsof -nP -iTCP:8100 -sTCP:LISTEN
Appium server 默认使用 4723;WDA 通常使用 8100。多设备必须为每台设备设置不同的
wda_local_port。
OCR 不工作但点击/截图正常
这是 Python/PaddleOCR 依赖问题,不是 WDA 问题。先用 screenshot、tap、swipe、 input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS wheel 与 Apple Silicon 兼容性。
12. 完成检查表
- Xcode 能看到已解锁的 iPhone。
- iPhone 已信任 Mac,并启用 Developer Mode。
xcode-select -p指向完整 Xcode。- Node.js/npm 满足 Appium 3 要求。
appium driver doctor xcuitest没有阻断性错误。- WDA 使用自己的 Team ID 和唯一 bundle ID 成功签名。
curl http://127.0.0.1:4723/status返回正常。- 直接 Python 验证能生成
/tmp/device-agent-runtime.png。 - 实机 integration test 通过。
- Runtime API 返回
iphone-1,并能执行 screenshot/tap/launch。 - 如需 OCR,另行确认 PaddleOCR 在当前 Mac/Python 架构下可运行。
- 云端受管部署已保存 Host identity,并能在
/v1/hosts、/v1/devices中看到。
13. 后续代码改进建议
为了让后续执行不再依赖内联 Python 启动脚本,建议另开变更实现:
- 为 Console/REST 增加显式 connect/disconnect endpoint。
- 增加正式 CLI,例如
device-runtime serve --device-config ...。 - 统一将遗留的
APEX_WDA_*环境变量改名为DEVICE_RUNTIME_WDA_*,并保留兼容期。 - 将 PaddleOCR 改成 optional dependency,拆分基础控制与 OCR 安装路径。
- 增加 macOS CI 的无真机 smoke test,以及受控环境中的真机验收脚本。