让 AI 操作 iPhone 已有不少实践,如 Midscene、Mobile MCP 等,其 iOS 控制主要基于 XCTest/XCUITest。
这类方案的 Agent 与控制端通常运行在电脑上,手机只是被控设备。
本文主要讨论**"如何使 Agent 任务执行不再依赖一台持续运行的 Mac?"**
Eiscue 集成了本文介绍的控制方案,以下是 App 内 Agent 操作所在 iPhone 的深浅色模式的演示:
| App 内通过 XCTest 控制手机 | App 内通过 CoreDevice 控制手机 | ESP32-S3 模拟键鼠控制手机 | ESP32-C3 通过蓝牙控制手机 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
这些演示中的 Agent 均运行在 iPhone 上,无需 Mac 持续参与任务执行。
下文将逐一介绍各方案的原理与实现。
一、从模型决策到设备执行
Agent 根据目标和当前界面决策,调用设备工具执行操作,再根据新画面继续循环运行。
Mac 通常负责配对、签名和开发环境准备,以及运行 Agent、调用模型和维护设备控制会话。

要让手机独立执行任务,需要将 Agent 和设备控制客户端迁入 App,模型仍通过 API 调用。
本文主要介绍开发者服务(Developer Services) 与**外设 HID(External HID)**两类方案:

其中:
- Developer Services 让 App 充当原本电脑一侧的控制端,经本机 VPN 回环或 USB NCM 中继连接 iOS 开发者服务,再用 XCTest 或 DisplayService/HID 完成截图与操作;
- External HID 让 ESP32 充当外接键盘鼠标,App 通过系统接口采集屏幕,经蓝牙把点击、输入等指令发给开发板,开发板再转成 BLE HID 或 USB HID 输入送给 iPhone。
部分环境准备可能仍需要电脑。
整体架构如下:

二、Developer Services
连接开发者服务
iOS 上每个 App 都运行在自己的沙盒里。
系统没有公开接口让一个 App 读到 App 外的界面、或给系统发触摸事件。
Android 的无障碍服务、macOS 的辅助功能权限都能做全局的点击注入,但 iOS 没有对应能力。
但 iPhone 上运行着一组供 Xcode、Instruments 和 XCTest 使用的开发者服务,大致分为三类:
- 连接与发现 :通过配对(Pairing)让手机信任主机,例如连接电脑时"信任此电脑"。主机凭配对记录认证,与手机建立一条加密隧道,再经 RSD(Remote Service Discovery,远程服务发现)查询手机上的服务、端口;
- App 与开发环境 :通过 App Service 列出和启动 App;通过 MobileImageMounter 挂载 DDI(Developer Disk Image,开发者磁盘映像),为系统加入 XCTest 等测试框架与调试组件;
- 自动化 :testmanagerd 是手机上负责测试的系统进程,由它建立 XCTest 会话,在会话中截图、点击和输入。CoreDevice 是 iOS 17 起开发工具连接设备所用的服务框架;在 iOS 27 上可以使用其中的 DisplayService(屏幕画面流)和 HID 服务(触控与按键输入),无需 XCTest 会话。
这些服务在系统层面运行,因此可以跨 App 截图和输入。
但它们原本是给一台已配对的电脑用的。
手机开启开发者模式并挂载 DDI 后,电脑经 USB 或网络连入,凭配对记录访问服务。
开发者服务方案把这些服务的客户端迁入 App,由 App 扮演原本电脑那一方,因此要解决两个问题:
- 身份认证,即 App 如何取得手机的信任;
- 本机通信,即 App 如何以外部连接的方式连回本机的服务。

通过设备配对实现可信连接
配对让手机信任一个控制客户端。
用户在手机上授权后,客户端保存包含身份和密钥的配对记录(Pairing Record),之后连接可复用该认证。
iOS 设备通信涉及两套配对机制:
- Lockdown Pairing :用于 lockdownd 提供的传统设备管理服务,常见入口是电脑连接时弹出的"信任此电脑"。它只能由电脑经 USB 建立;iOS 17.4 起,凭它也能经 CoreDeviceProxy 建立隧道、访问开发者服务;
- Remote Pairing :iOS 17 引入的开发者服务配对方式,可以经 USB 建立,iOS 27 起也可以在手机上直接建立。本文只使用 Remote Pairing,这样 iOS 27 上无需电脑,iOS 26 与 iOS 27 也共用同一种身份。
Remote Pairing 记录的来源取决于系统版本:
- iOS 26 :系统设置中没有这个入口,需要先用电脑经 USB 建立配对,再把配对记录导入 App。电脑端可以使用 idevice_pair 等工具创建、校验和导出配对记录;
- iOS 27 :系统支持首次无线开发者配对,配对记录可由 App 直接建立。Apple 文档描述的是与 Xcode 配对;本文实现由 App 发起,用户在"设置 > 隐私与安全性 > 开发者模式"中选择该 App,再输入 App 显示的验证码。
这些服务的客户端原本随 Xcode 提供,只在 Mac 上运行,也没有公开接口,App 无法直接调用。
本文后端基于 idevice:把它放进 App 并接上本机通信通路后,手机就能直接调用这些服务。
idevice 是用 Rust 重新实现这些协议的开源客户端库,覆盖配对认证、RSD 服务发现以及截图、输入等服务,可以编译进 iOS App;StikDebug(为侧载 App 启用 JIT 的调试工具)等应用已在手机上使用它。
通过 VPN 回环建立网络通路
App 与开发者服务在同一台 iPhone 上,但这些服务本应由外部控制端经网络连入的方式工作。
本机 VPN 回环在手机内虚拟出一个网络对端,让 App 的连接以"从对端发来"的形态到达服务。
VPN 回环由随 App 安装的 App Extension 实现,类型为 Packet Tunnel Provider。Packet Tunnel 是 Network Extension 框架中用来实现自定义 VPN 客户端的 Extension,系统把需要走 VPN 的 IP 数据包交给它,通常由它经隧道发往远端 VPN 服务器,再把收到的数据包交回系统。
该 Extension 由系统在独立进程中运行,基于 NEPacketTunnelProvider,为系统添加一个虚拟网卡,只为虚拟对端配置一条主机路由,不修改默认路由和 DNS。因此只有发往对端的流量进入 Extension,其他请求仍走原有网络。Extension 通过 packetFlow 收发数据包,读出系统发往虚拟网卡的 IP 数据包,或把数据包写回系统,系统会将其当作从虚拟网卡收到的入站数据包处理。
Extension 采用数据包反射(Packet Reflection) :交换数据包的源、目的 IP 地址,端口和载荷保持不变,再写回系统网络栈(参考 LocalDevVPN)。App 发往虚拟对端的数据包由此变成从虚拟对端发来的入站数据包,由系统交给监听该端口的开发者服务,服务看到的是一条外部连接。服务的响应发往虚拟对端,同样经 Extension 反射后回到 App,不需要远程 VPN 服务器。
下图上排是 App 发出的请求,下排是服务返回的响应。其中 10.7.1.1 为虚拟网卡地址,10.7.0.1 为虚拟对端地址:

数据包反射的伪代码如下:
python
PAIR = {LOCAL_IP, PEER_IP} # 虚拟网卡地址与虚拟对端
def reflect(packet, protocol):
# 只处理 IPv4,且只放行这一对地址之间的数据包
if protocol != AF_INET or {packet.src_ip, packet.dst_ip} != PAIR:
return None
# 只交换 IP 地址,保留端口与载荷;
# 交换不改变 IPv4 头校验和与 TCP/UDP 校验和(含伪首部),无需重算
packet.src_ip, packet.dst_ip = packet.dst_ip, packet.src_ip
return packet
while tunnel.active:
# 1. 读取
packets, protocols = await packet_flow.read()
# 2. 反射,丢弃其他数据包
reflected = [r for r in map(reflect, packets, protocols) if r]
# 3. 写回
await packet_flow.write(reflected, [AF_INET] * len(reflected))
需要注意,基于 Packet Tunnel 的 VPN 配置在系统中同一时间只能启用一个(NETunnelProviderManager),开启本机回环后,不能同时启用其他同类 VPN。另外,Apple TN3120 说明,Packet Tunnel 读出的数据包应经隧道发往远端网络,不应重新注入本机系统,回环反射属于不支持的用法。
通过 USB NCM 建立网络通路
USB NCM 路线是用一块外接开发板替代本机 VPN 回环。
NCM(Network Control Model)是 USB 通信设备类(CDC)中用于传输以太网帧的子类规范,iPhone 作为 USB 主机接入 NCM 设备后,会把它识别为一块有线网卡。本文用 ESP32-S3 充当这块网卡,它带有原生 USB OTG 控制器,可基于 TinyUSB 实现 NCM 设备。
链路使用静态 IPv4 地址:开发板固定为 192.168.7.1/24,手机需在"设置 > 以太网"中把这块网卡改为手动配置 192.168.7.2/24,路由器和 DNS 留空。开发板不提供 DHCP、默认路由、DNS 或 NAT,手机的其他流量仍走原有网络。App 通过 requiredInterface 把连接限定在这块网卡上。
开发板在链路上通过 mDNS 发现手机的 RemotePairing 服务。App 连接开发板上的中继端口并请求打开该服务,开发板再以自己的地址连接手机,在两条 TCP 连接之间双向转发字节;认证后协商出的隧道端口也按同样方式转发。与 VPN 回环改写数据包地址不同,服务看到的是一条真正来自外部设备的连接。开发板只连接已发现的服务端点,拒绝其他目标地址。配对认证和隧道加密都在 App 与 iOS 之间端到端完成,开发板不参与认证,也不保存配对密钥。
需要注意,开发板只替换网络通路:截图与输入仍由后文的 XCTest 或 DisplayService/HID 完成,配对记录、开发者模式和 DDI 的要求不变。

开发者服务的设备操作
完成认证并连上本机服务后,App 就可以像电脑一样调用开发者服务来截图和输入。
XCTest 在同一个测试会话中完成截图与输入;
iOS 27 上也可以分别调用 DisplayService 与 HID 服务,无需 XCTest 会话。
两种方式都要求手机已开启开发者模式并挂载 DDI。
通过 XCTest 实现截图与输入
XCTest 的 UI 自动化通过 XCUIAutomation 获取截图、查询界面元素,并执行点击、滑动和文本输入。
通常由测试执行进程(Runner)发起这些调用。
控制端移到手机后,可由 App 建立和维护测试会话:
一种方式是启动独立 Runner;
另一种是让 testmanagerd 授权当前进程,在 App 进程内调用开发环境中 XCTest 的内部类。
两者都需测试授权,并经 testmanagerd 的私有协议建立测试会话。
本文实现采用后者。
点击与滑动会转换为按下、移动、抬起的事件序列。
例如,XCPointerEventPath 描述触控轨迹,XCSynthesizedEventRecord 组织事件,再提交给测试服务执行,截图用于观察执行结果。下文伪代码展示这条路径的主要时序:
python
session = await testmanagerd.authorize(process_id) # 授权当前进程,建立测试会话
try:
frame = await session.screenshot()
target = locate_target(frame)
point = frame.pixels_to_screen_points(target) # 截图像素换算为屏幕点(pt)
path = TouchPath().down(point).hold(0.1).up(point) # 对应 XCPointerEventPath
await session.synthesize(path) # 组装为 XCSynthesizedEventRecord 后提交
updated_frame = await session.screenshot() # 以新截图确认结果
finally:
await session.close() # 释放测试会话
通过 DisplayService 与 HID 实现屏幕采集与输入
另一条路线分别调用系统屏幕与输入服务,无需建立 XCTest 会话。
以在 iOS 27 上接入的 CoreDevice DisplayService/HID 路径为例:
- DisplayService 提供屏幕流, 客户端连接
com.apple.coredevice.displayservice并启动媒体会话,持续接收、解码画面,从最新完整帧生成截图; - **HID(Human Interface Device)服务接收输入事件,**客户端通过软件协议提交触控与按键:点击是按下、抬起,滑动则在两者之间更新触点位置。这是系统提供的软件输入服务,与外接 HID 设备不同。

这条路径有几项系统限制:
- 触控与按键只在 DisplayService 媒体会话存续时生效,见输入服务实现说明;
- HID 服务只接收触控与按键事件,中文等文本无法直接逐键输入;
- 仅关闭本地连接可能在手机上留下媒体会话。
此外,在一台 iOS 27 设备上实测,媒体会话存续时麦克风只能采到静音数据,释放会话后恢复。
本文实现据此采取以下做法:
- **先启动媒体会话,再连接输入:**媒体会话启动并开始解码后才连接 HID 服务,会话不在时直接拒绝输入;
- **文本经剪贴板粘贴:**整段文本写入剪贴板服务,再发送 ⌘V。这会覆盖剪贴板原有内容,过程中可能出现系统粘贴授权提示;
- **结束时完整释放:**依次释放输入、远端媒体会话和本地解码资源,并确认远端会话已结束;
- **录音前临时释放会话:**用户开始语音输入时,App 先释放媒体会话,确认释放后才开始录音,配对与网络通路保持不变;录音结束后在前台恢复原连接。
下文伪代码展示这条路径的主要时序:
python
media = await display.start_media_stream() # 远端开始推送屏幕视频流
frames = hid = None
try:
frames = decode_continuously(media) # 持续解码,保留最新完整帧
hid = await hid_service.open() # 媒体会话启动后再连接
frame = await frames.latest_complete()
target = locate_target(frame)
point = frame.pixels_to_normalized(target) # 归一化到 0--65535
await hid.send(TouchDown(point))
await sleep(0.05)
await hid.send(TouchUp(point))
updated_frame = await frames.newer_than(frame)
finally:
# 依次释放输入、远端媒体会话与本地解码;远端须确认会话已结束
await close_all(hid, media, frames)
三、External HID
外设 HID 路线由 App 采集屏幕、运行 Agent,再将输入指令交给 ESP32。
开发板模拟标准输入设备,以 BLE HID 或 USB HID 报告将操作送回 iPhone。

通过 ReplayKit/ScreenCaptureKit 实现屏幕采集
ReplayKit 通过 Broadcast Upload Extension 采集屏幕。
它是随 App 安装的一种 App Extension,由系统在独立进程中运行。
App 内嵌系统提供的 RPSystemBroadcastPickerView,用户点按并在系统界面确认后开始共享,App 不能自行启动。共享期间即使切换到其他 App,Extension 也会持续采集,并在 processSampleBuffer(_:with:) 中接收视频与音频样本。
这一运行环境有几项系统限制:Extension 的内存预算很紧,Apple 文档未给出具体数值,开发者的崩溃日志显示上限约 50 MB,超出即被系统终止(示例);Extension 与主 App 分属两个进程,通常经 App Group 共享容器交换数据;送来的画面不会自动转正,方向需读取 RPVideoSampleOrientationKey。实测中,屏幕静止时 ReplayKit 不再送帧,设备锁定时也可能停止送帧而不触发暂停回调。
本文实现据此采取以下做法:
- **只交换最新一帧:**只处理视频,不处理音频。每 0.5 秒最多发布一帧:校正方向、缩放到长边不超过 1600 像素、编码为 JPEG 后写入 App Group。目录中只保留最新一帧,不保存视频历史;
- **静止画面靠心跳确认:**Extension 每 0.5 秒写一次心跳,标明已发布的帧是否仍是最新画面。主 App 只在 3 秒内有新帧或心跳确认时,才认为采集仍然有效;
- **采集中断即停止输入:**共享暂停、结束或设备锁定后,App 不再发送输入。帧文件启用数据保护,锁屏后无法读取,Extension 随即主动结束共享。
下文伪代码展示 Extension 的发布与心跳逻辑:
python
# Extension:ReplayKit 每送来一个视频样本时调用
def on_video_sample(buffer, orientation):
newer_pending = True
if now() - last_publish >= 0.5:
publish(buffer, orientation)
else:
pending = (copy(buffer), orientation) # 样本只在回调内有效,先复制
# Extension:每 0.5 秒触发一次
def on_tick():
if store.stop_requested():
return finish_broadcast()
if pending and now() - last_publish >= 0.5:
publish(*pending) # 画面静止前的最后一帧也要发出
store.heartbeat(published_id, newer_pending)
def publish(buffer, orientation):
last_publish = now()
jpeg = encode_jpeg(fit_long_edge(rotate(buffer, orientation), 1600))
published_id = store.write_latest(jpeg) # App Group 中只保留这一帧
pending, newer_pending = None, False
# 主 App:只在采集仍然有效时使用最新帧
frame, beat = store.read_latest(), store.read_heartbeat()
live = frame.age <= 3 or (beat.age <= 3 and beat.frame_id == frame.id
and not beat.newer_pending)
iOS 27 起可使用 ScreenCaptureKit。用户通过系统内容选择器授权全屏采集后,App 由 SCStream 接收画面,无需 Broadcast Upload Extension;后台采集需声明 screen-capture 模式。
ReplayKit 广播接口自 iOS 27 起弃用,兼容 iOS 26 时可保留该路径,见 Apple 的 iOS 屏幕采集说明。
通过 BLE/USB HID 实现外设输入
BLE HID 有两条逻辑通道:App 通过自定义 GATT 服务向 ESP32 下发命令,ESP32 再以 BLE HID 键盘鼠标的身份向 iPhone 发送输入报告。手机沿用正常网络,开发板只需供电,无需开发者服务配对、DDI 或本机 VPN。
iOS 对外接键鼠有几项限制:指针输入需开启辅助触控;HID 报告只能描述相对位移或设备自定义范围内的绝对坐标,不能直接套用截图像素;键盘报告只传按键码,中文等文本无法直接逐键输入。
实测中,按住不动能保持接触,但以绝对坐标移动时接触会提前结束。
本文实现据此采取以下做法:
- **先校准再开放输入:**App 在自有页面上让开发板点击四个已知位置,用实际收到的触点建立截图到 HID 坐标的映射,并检查一次文本输入。命令连接、HID 输出、屏幕共享和校准都就绪后,才向 Agent 开放输入;画面方向或尺寸变化后需重新校准;
- **文本经剪贴板粘贴:**App 把文本写入系统剪贴板(仅限本机,120 秒后过期),再由开发板发送 ⌘V。这会覆盖剪贴板原有内容,但不读取它;
- **滑动改用相对位移:**由开发板按固定节奏发送相对位移。校准阶段先在自有页面跑六种探测轨迹,确认是一次连续接触、方向和速度符合预期后才开放滑动;
- **命令通道认证:**S3 的每条命令和回复都带 HMAC 与递增序号,首次配对只在开发板通电后 60 秒内允许;C3 沿用原有的加密 BLE 命令协议。
ESP32-S3 的原生 USB OTG 可实现 USB HID:iPhone 作为 USB 主机,开发板作为输入设备;
C3 只有 USB Serial/JTAG,不能用作通用 HID。
本文的 USB HID 实现仍通过 BLE 下发命令,只把输入报告改经 USB 发送;输出由 App 显式选择,USB 不可用时不会退回 BLE。参见 Espressif USB Device Stack。
下文伪代码展示一次点击的命令与输出:
python
# iPhone:使用同一次观察的截图与目标,校验时效和校准状态
point = calibration.to_absolute_hid(target, frame.geometry)
await ble.send(Tap(point))
# ESP32:通过已选定、已就绪的 BLE / USB HID 输出
command = await ble.receive() # S3 校验 HMAC 与序号
await output.click_absolute(command.point)
# 定位、按下、短暂保持、抬起;异常时释放按键
四、接入 Agent
Agent 运行时基于 Pi。
App 内的 Agent 循环采用 Pi 的持久化运行时 pi-durable,经 PiDurableKit 运行在 JavaScriptCore 中。
模型调用由 Pi 的模型层完成,支持 OpenAI Chat Completions 兼容接口、OpenAI Responses 与 Anthropic Messages。对话、模型回合和工具调用在展示前先写入每个对话独立的 SQLite,进程中断后可以据此恢复。
App 把设备操作封装为以下工具,只提供当前路线实际支持的部分:
| 工具 | Developer Services | External HID |
|---|---|---|
| 截图 | 支持 | 支持(屏幕采集) |
| 点击 | 支持 | 校准通过后 |
| 滑动 | 支持 | 六种探测轨迹通过后 |
| 文本输入 | XCTest 文本事件; DisplayService 经剪贴板粘贴 | 经剪贴板粘贴 |
| 按键(搜索、回车、Esc) | --- | 固件支持且校准通过后 |
| 回到主屏幕 | 支持 | 辅助触控菜单 |
| 列出/启动 App | 支持 | --- |
一次点击调用如下:
json
{
"name": "device_tap",
"arguments": { "frameId": "最新截图的 ID", "x": 310, "y": 540 }
}
frameId 标识本次决策依据的截图,x、y 是模型所见图片的像素坐标。
一次调用的处理过程如下:

其中:
- **一张截图只授权一次操作,**执行前校验截图时效、屏幕方向、尺寸与会话,任一条件失效就拒绝操作;
- **执行后用新画面确认,**命令发出后自动截图,无法确定时结束任务交还用户确认,避免重复操作;
- **统一坐标约定,**模型图片像素、原始截图像素、屏幕点(point)与 HID 坐标需逐层换算,屏幕方向也要对齐;混用单位或方向,就会出现调用成功却点偏。下图以 1179×2556 的截图缩放到 738×1600 为例:

- 控制图像上下文成本, 截图会增加延迟与 token 消耗。本文实现将模型图片最长边限制为 1600 像素,只保留最近两张设备截图,较早的替换为文字说明。图片上限还应按所用模型设置,服务端若再缩放,返回的坐标会基于缩放后的图,例如 Claude 标准分辨率档的长边上限为 1568 像素。
- 管理后台执行, 跨 App 操作时,Agent 需要在后台继续运行。iOS 26 起,前台发起的任务可申请 BGContinuedProcessingTask;屏幕采集或连接存续并不保证 Agent 持续执行。
五、方案汇总
各路线的信息汇总如下:
| 路线 | 准备条件 | 持续运行条件 | 文本输入方式 |
|---|---|---|---|
| 本机 VPN + XCTest | 配对、开发环境、测试授权 | Wi-Fi 连接、VPN 与测试会话 | XCTest 文本事件,不经剪贴板 |
| 本机 VPN + DisplayService/HID | 配对、开发环境; 需 iOS 27 | Wi-Fi 连接、VPN 与媒体/输入会话 | 写入剪贴板后粘贴 |
| 屏幕采集 + BLE HID | 固件、蓝牙配对、采集授权、辅助触控与校准 | 屏幕采集、BLE 与开发板供电 | 写入剪贴板后粘贴 |
| 屏幕采集 + USB HID | 同 BLE HID,另需 S3 等硬件和 USB 主机连接 | 屏幕采集、BLE 命令与 USB HID 数据连接 | 同上 |
| USB NCM + 软件后端 | NCM 固件、USB 网络,以及所选后端的配对与开发环境 | USB 网络、中继与控制会话 | 取决于所选后端 |
是否需要依赖 Mac 要按环节讨论:
| 环节 | Developer Services | External HID |
|---|---|---|
| 安装 App | 首次通常需要电脑; 本文采用开发签名,到期后需重新签名 | 同左 |
| 建立身份 | iOS 27 无需电脑,由 App 发起、在系统中确认; iOS 26 通常需要电脑经 USB 建立后导入 | 无需电脑,蓝牙配对即可 |
| 环境准备 | 开启开发者模式并挂载 DDI; 挂载可用电脑,也可用支持该能力的手机工具 | 烧录固件,通常需要电脑;无需 DDI |
| 日常任务 | 无需电脑;NCM 路线还需连接中继板 | 无需电脑;开发板需保持供电和输入连接 |
| 设备重启 | 本文实现需借助电脑重新挂载 DDI,再建立控制会话 | 无需电脑; 重连外设、开启屏幕采集,必要时重新校准 |
总的来说,两条路线的日常任务都不需要电脑,差别主要在 Developer Services 依赖的 DDI。
DDI 可由 Xcode 等电脑工具挂载,也可由手机工具经已认证的连接调用 MobileImageMounter 完成;
手机重启后也需要重新挂载,因此每次重启后仍需借助电脑。
选择时,不想增加硬件可选本机 VPN,但它依赖开发环境,按本文实现,手机重启后需借助电脑恢复;
不想依赖开发环境可选 BLE/USB HID,代价是需要开发板,还要保持屏幕采集、完成输入校准;
如果只想用 USB 替代本机 VPN,选 NCM 中继即可。



