在 Mac 上跑一台能被 AI 操作的虚拟 iPhone:vphone-cli 是怎么做到的?

平时做 iOS 开发,我们最熟悉的是:
Xcode
↓
iOS Simulator
↓
启动 App
↓
调试
但最近看到一个很有意思的开源项目:
vphone-cli
项目地址:
https://github.com/Lakr233/vphone-cli
它做的事情和普通 iOS Simulator 不太一样。
项目的定位是:
使用 Apple 的 Virtualization.framework 和 PCC Research VM 基础设施,在 Apple Silicon Mac 上启动一台虚拟 iPhone。
更有意思的是,这台虚拟 iPhone 不是只能拿鼠标点。
项目还暴露了一套程序控制接口:
截图
触摸
滑动
Home / Power / 音量键
剪贴板
甚至每执行一步操作,都可以把当前屏幕截图直接返回给调用方。
于是整个系统就变成:
AI Agent
↓
看手机截图
↓
决定下一步
↓
点击 / 滑动 / 按键
↓
重新截图
↓
继续判断
这已经非常接近:
AI 驱动的 iPhone Computer Use
项目 README 也明确把这个能力定位到了 AI-driven E2E testing。
这不是普通的 iOS Simulator
这一点必须先说清楚。
普通 Xcode Simulator 更接近:
macOS
↓
Simulator Runtime
↓
模拟 iOS 用户空间环境
而 vphone-cli 的目标是:
Apple Silicon Mac
↓
Virtualization.framework
↓
虚拟硬件
↓
iOS 启动链
↓
iOS 系统
也就是说,它实际上围绕:
VZVirtualMachine
构建了一整套虚拟设备。
项目源码里直接使用:
import Virtualization
核心类:
class VPhoneVirtualMachine:
NSObject,
VZVirtualMachineDelegate
最终创建:
virtualMachine =
VZVirtualMachine(
configuration: config
)
项目使用 Swift 6.0,最低 macOS 15,并链接了 Virtualization、AppKit、SwiftUI、CoreLocation、AVFoundation 等 Framework。
一、一台虚拟 iPhone,本质上要配置什么?
如果暂时忽略项目里的私有 API 和研究型实现,一台 VM 最核心的组成其实很好理解:
CPU
内存
磁盘
显示器
声卡
网络
键盘
触摸屏
串口
Host ↔ Guest 通信
可以先写一个简化版:
import Virtualization
func createConfiguration(
diskURL: URL
) throws -> VZVirtualMachineConfiguration {
let config =
VZVirtualMachineConfiguration()
config.cpuCount = 8
config.memorySize =
8 * 1024 * 1024 * 1024
let attachment =
try VZDiskImageStorageDeviceAttachment(
url: diskURL,
readOnly: false
)
let disk =
VZVirtioBlockDeviceConfiguration(
attachment: attachment
)
config.storageDevices = [
disk
]
try config.validate()
return config
}
然后:
let config =
try createConfiguration(
diskURL: diskURL
)
let vm =
VZVirtualMachine(
configuration: config
)
这其实就是 VM 最基础的骨架。
二、vphone-cli 的配置明显复杂得多
真正的项目里,需要模拟的是一整台移动设备。
源码中的 Options 包含:
struct Options {
var configURL: URL
var romURL: URL?
var nvramURL: URL
var diskURL: URL
var cpuCount: Int = 8
var memorySize: UInt64 =
8 * 1024 * 1024 * 1024
var sepStorageURL: URL
var screenWidth: Int = 1290
var screenHeight: Int = 2796
var screenPPI: Int = 460
var screenScale: Double = 3.0
var kernelDebugPort: Int?
var variant: Variant
var noVphoned: Bool
}
也就是说,一台 vPhone Bundle 里不仅有:
Disk Image
还有:
ROM
NVRAM
Machine Identifier
SEP Storage
Screen Configuration
Network Configuration
项目还会保存 machineIdentifier,让设备身份在多次启动之间保持稳定。
这点很重要。
如果每次启动:
设备身份都发生变化
那么系统里的:
ECID
UDID
设备注册
缓存状态
测试状态
都会非常混乱。
三、屏幕其实也是一个虚拟硬件
项目会配置:
VZMacGraphicsDeviceConfiguration
概念上可以理解成:
let graphics =
VZMacGraphicsDeviceConfiguration()
let display =
VZMacGraphicsDisplayConfiguration(
widthInPixels: 1290,
heightInPixels: 2796,
pixelsPerInch: 460
)
graphics.displays = [
display
]
config.graphicsDevices = [
graphics
]
于是虚拟机看到的是:
1290 × 2796
的一块显示设备。
这也是后面为什么:
AI 点击坐标
可以直接映射到:
iPhone 屏幕像素坐标。
四、声音也可以直接映射到宿主 Mac
项目同时配置了 Virtio Sound。
简化一下:
let sound =
VZVirtioSoundDeviceConfiguration()
let input =
VZVirtioSoundDeviceInputStreamConfiguration()
let output =
VZVirtioSoundDeviceOutputStreamConfiguration()
input.source =
VZHostAudioInputStreamSource()
output.sink =
VZHostAudioOutputStreamSink()
sound.streams = [
input,
output
]
config.audioDevices = [
sound
]
这意味着:
虚拟 iPhone 麦克风
←
Mac 音频输入
以及:
虚拟 iPhone 扬声器
→
Mac 音频输出
所以从架构上看,它已经越来越像:
一台真正运行在 Mac 内部的设备。
五、网络也不是简单写死的
vphone-cli 支持:
NAT
Bridged
None
不同网络模式。
最终都是转换成:
config.networkDevices
如果你想做自动化测试,这其实特别重要。
例如:
正常网络测试
弱网测试
完全断网测试
都可以围绕 VM 网络层设计。
甚至未来完全可以做:
Agent Test Case
↓
切换网络
↓
打开 App
↓
执行登录
↓
观察错误页面
这就比普通 UI 自动化更接近真实测试环境。
六、真正关键的一步:Host 和 Guest 怎么通信?
这才是整个项目最值得看的地方之一。
如果 Mac 上的程序想告诉虚拟 iPhone:
给我读取一个文件
设置剪贴板
发送按键
获取状态
怎么办?
最笨的方法:
TCP
但是 VM 内部 IP 可能变化。
而且:
Host
↔
Guest
其实不一定需要走真正的网络。
vphone-cli 使用的是:
Virtio Socket / vsock
VM 配置:
config.socketDevices = [
VZVirtioSocketDeviceConfiguration()
]
也就是说:
macOS Host
│
│ vsock
│
▼
iOS Guest
完全不需要:
192.168.x.x
这种 IP 通信。
项目里的 Guest Daemon 叫:
vphoned
运行在虚拟 iPhone 里面。
Host 侧对应:
VPhoneControl
架构变成:
vphone-cli
│
│ VZVirtioSocket
▼
vphoned
│
▼
iOS
项目内部固定使用 vsock Port 1337,并通过一套带版本号的 JSON 协议通信。
七、他们没有直接裸传 JSON
这里还有一个很典型的协议设计。
如果在 Socket 里不断发送:
{"t":"hello"}
{"t":"clipboard"}
接收方怎么知道:
一条消息到底在哪里结束?
TCP / Stream Socket 本身没有“消息边界”。
可能收到:
{"t":"hello"}{"t":"clip
下一次才收到:
board"}
所以 vphone-cli 使用:
Length-Prefixed JSON
格式类似:
┌──────────────┬─────────────────────┐
│ uint32 长度 │ JSON Payload │
│ Big Endian │ UTF-8 │
└──────────────┴─────────────────────┘
比如:
{
"v": 1,
"t": "hello"
}
先编码:
let jsonData =
try JSONSerialization.data(
withJSONObject: message
)
然后:
var length =
UInt32(jsonData.count).bigEndian
写入:
4 Byte Length
+
JSON
八、我们自己实现一个最小协议编码器
可以写:
func encodeMessage(
_ object: [String: Any]
) throws -> Data {
let payload =
try JSONSerialization.data(
withJSONObject: object
)
var length =
UInt32(payload.count).bigEndian
var result =
Data(
bytes: &length,
count: 4
)
result.append(payload)
return result
}
发送:
let message =
try encodeMessage([
"v": 1,
"t": "hello"
])
socket.write(
message
)
Guest 先读取:
4 Byte
得到:
Payload Length
再按长度读取完整 JSON。
这是一个非常经典的:
自定义二进制 Frame Protocol。
九、为什么每条消息还有 Request ID?
如果同时发多个请求:
Request A
Request B
Request C
Guest 返回:
Response ?
Host 怎么知道是谁的?
所以协议里还有:
{
"v": 1,
"t": "request",
"id": "1001"
}
返回:
{
"v": 1,
"t": "response",
"id": "1001"
}
Host 保存:
pendingRequests[id]
项目当前实现就是维护:
private var pendingRequests:
[String: PendingRequest] = [:]
然后响应回来以后:
ID
↓
找到 Callback
↓
完成 Request
这已经很像:
一个轻量 RPC 协议。
十、再往外还有一层:vphone.sock
vsock 解决的是:
Host
↔
iOS Guest
但是如果:
Codex
Claude Code
Python 脚本
测试框架
想控制这台手机呢?
直接让每个工具实现一遍 vsock 协议很麻烦。
所以项目又暴露了一层:
Unix Domain Socket
每台 VM Bundle 下会出现:
vphone.sock
调用链就变成:
Python / Codex / MCP
│
│ Unix Socket
▼
vphone.sock
│
▼
vphone-cli
│
│ vsock
▼
vphoned
│
▼
iOS
这层设计非常漂亮。
因为外部程序根本不用知道:
Virtualization.framework
VZVirtioSocket
Guest Daemon
它只需要:
给 Unix Socket 发 JSON。
十一、Host Control 协议有多简单?
例如截图:
{
"t": "screenshot"
}
点击:
{
"t": "tap",
"x": 645,
"y": 1398
}
滑动:
{
"t": "swipe",
"x1": 645,
"y1": 2600,
"x2": 645,
"y2": 1400,
"ms": 300
}
按 Home:
{
"t": "key",
"name": "home"
}
设置剪贴板:
{
"t": "type",
"text": "Hello"
}
项目当前 Host Control 就支持 screenshot、tap、swipe、home/power/音量键以及剪贴板控制。
十二、甚至可以直接用 nc 控制
假设 VM Bundle:
~/.vphone/VMs/myphone
那么 Socket 可以理解成:
SOCK="$HOME/.vphone/VMs/myphone/vphone.sock"
截图:
printf '%s\n' \
'{"t":"screenshot"}' \
| nc -U "$SOCK"
点击:
printf '%s\n' \
'{"t":"tap","x":645,"y":1398}' \
| nc -U "$SOCK"
Home:
printf '%s\n' \
'{"t":"key","name":"home"}' \
| nc -U "$SOCK"
滑动:
printf '%s\n' \
'{
"t":"swipe",
"x1":645,
"y1":2200,
"x2":645,
"y2":800,
"ms":300
}' \
| nc -U "$SOCK"
这意味着:
任何会操作 Unix Socket 的语言,都可以控制 iPhone。
十三、Python 控制就更简单了
自己写一个最小 Client。
import json
import socket
class VPhoneClient:
def __init__(self, socket_path):
self.socket_path = socket_path
def send(self, command):
client = socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM,
)
client.connect(
self.socket_path
)
payload = (
json.dumps(command)
+ "\n"
)
client.sendall(
payload.encode("utf-8")
)
chunks = []
while True:
data = client.recv(4096)
if not data:
break
chunks.append(data)
if b"\n" in data:
break
client.close()
raw = b"".join(chunks)
return json.loads(
raw.decode("utf-8")
)
然后:
phone = VPhoneClient(
"/Users/me/.vphone/VMs/myphone/vphone.sock"
)
截图:
result = phone.send({
"t": "screenshot"
})
print(result)
点击:
phone.send({
"t": "tap",
"x": 645,
"y": 1398,
})
滑动:
phone.send({
"t": "swipe",
"x1": 645,
"y1": 2300,
"x2": 645,
"y2": 900,
"ms": 300,
})
这时候你已经有一个最小的:
iPhone Automation SDK
十四、最关键的设计:每执行一步,都返回截图
普通自动化:
执行点击
↓
不知道发生了什么
Agent 自动化最大的问题就是:
操作以后怎么知道结果?
所以 vphone-cli 做了一件很关键的事情:
除了截图命令以外,tap、swipe、key、type 等动作执行完后,默认也会重新抓取屏幕。
返回:
{
"ok": true,
"image": "/9j/4AAQSkZJRg..."
}
这个 image:
是:
Base64
编码后的压缩截图。
项目为了减少传输量,还会:
缩小到约 1/3
转灰度
降低 JPEG Quality
最终让每一步操作都形成:
Action
+
Observation
这就是 AI Agent 最喜欢的接口设计。
十五、为什么截图要压缩?
假设原始屏幕:
1290 × 2796
RGBA 原始数据大约:
1290
×
2796
×
4
≈
13.7 MB
如果 Agent 每一步都传:
10 MB+
连续执行 100 步:
1GB
非常夸张。
所以项目把截图缩到大约:
430 × 932
再:
灰度 JPEG
+
低质量压缩
通常已经足够:
给视觉模型判断下一步点哪里。
这其实是一个很现实的 Agent 工程优化。
十六、AI 操作手机,本质是一个闭环
现在我们可以把逻辑写出来:
while True:
screen = phone.send({
"t": "screenshot"
})
action = agent.decide(
screen["image"]
)
if action["type"] == "done":
break
phone.send(action)
逻辑其实就是:
Observe
↓
Reason
↓
Act
↓
Observe
这就是典型的:
Agent Loop
十七、比如让 AI 自动测试登录
任务:
打开测试 App
输入账号密码
点击登录
判断是否进入首页
Agent 第一步:
{
"t": "screenshot"
}
看到登录页。
然后判断输入框位置。
点击:
{
"t": "tap",
"x": 300,
"y": 900
}
设置剪贴板:
{
"t": "type",
"text": "test@example.com"
}
再操作密码框。
点击:
{
"t": "tap",
"x": 645,
"y": 1700
}
执行完成:
系统重新返回:
当前截图
Agent 判断:
有没有进入首页?
如果没有:
继续处理。
十八、这和传统 Appium 最大区别是什么?
传统 Appium 更喜欢:
Selector
Accessibility ID
XPath
Element Tree
比如:
driver.find_element(
AppiumBy.ACCESSIBILITY_ID,
"Login"
).click()
这是:
结构化 UI 自动化
而 vphone-cli 这种方式更适合:
Screenshot
+
Coordinate Action
+
Vision Agent
也就是:
视觉型 Computer Use
两者各有优缺点。
传统 Appium:
快
稳定
适合确定性测试
视觉 Agent:
更灵活
不用提前写大量 Selector
更适合未知 UI
更适合探索式测试
真正合理的未来可能不是二选一。
而是:
Selector 优先
↓
Selector 不可用
↓
Vision Agent 兜底
十九、项目甚至已经留好了 MCP 方向
README 直接提到了:
vphone-mcp
用 MCP 包装:
vphone.sock
之后架构就会变成:
Codex
Claude Code
Agent
│
│ MCP
▼
vphone-mcp
│
│ Unix Socket
▼
vphone-cli
│
│ vsock
▼
Virtual iPhone
这件事情真正有意思的地方是:
以前 Codex 的能力范围大概是:
代码
Shell
文件
Git
现在:
Codex
↓
MCP
↓
虚拟 iPhone
它开始拥有:
手机操作能力
二十、我们甚至可以自己写一个 MCP Tool
下面只是一个简化示意。
Python:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("vphone")
phone = VPhoneClient(
"/Users/me/.vphone/VMs/myphone/vphone.sock"
)
@mcp.tool()
def screenshot():
"""
Get current iPhone screen.
"""
return phone.send({
"t": "screenshot"
})
@mcp.tool()
def tap(x: int, y: int):
"""
Tap the iPhone screen.
"""
return phone.send({
"t": "tap",
"x": x,
"y": y,
})
@mcp.tool()
def swipe(
x1: int,
y1: int,
x2: int,
y2: int,
ms: int = 300,
):
"""
Swipe on the iPhone screen.
"""
return phone.send({
"t": "swipe",
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"ms": ms,
})
@mcp.tool()
def press_home():
return phone.send({
"t": "key",
"name": "home",
})
if __name__ == "__main__":
mcp.run()
这时候 Codex 可以拿到:
screenshot
tap
swipe
press_home
几个 Tool。
然后你告诉它:
打开设置。
进入电池。
检查当前电量页面能否正常打开。
Agent 就可以:
截图
↓
看 UI
↓
点击设置
↓
截图
↓
找到电池
↓
点击
↓
截图
↓
判断结果
二十一、这对 AI Coding 意味着什么?
这里其实有一个比“虚拟 iPhone”更大的故事。
现在 Coding Agent 最明显的问题是:
代码写完了
↓
它怎么知道真的能用?
Web 领域已经有:
Playwright
BrowserSkill
Computer Use
于是 Agent 可以:
写网页
↓
打开浏览器
↓
自己测试
移动端以前比较麻烦。
如果有:
Virtual iPhone
+
Control Socket
+
Screenshot
+
MCP
Agent 就可以形成:
修改 iOS App
↓
编译
↓
安装
↓
启动
↓
点击
↓
截图
↓
发现 Bug
↓
回去改代码
↓
重新测试
真正闭环。
二十二、比如未来可以做一个 Mobile Coding Loop
伪代码:
def coding_loop(task):
while True:
result = coding_agent.modify_code(
task
)
build_result = build_app()
if not build_result.ok:
coding_agent.fix(
build_result.logs
)
continue
install_app(
build_result.ipa
)
test_result =
mobile_agent.run_test(
task
)
if test_result.ok:
break
coding_agent.fix(
test_result
)
最终:
开发
+
编译
+
安装
+
测试
+
修复
都可以在一个 Agent Workflow 里。
这可能才是 vphone-cli 最值得关注的方向。
二十三、虚拟机管理能力也非常完整
项目不是:
只能启动一个 VM。
它已经提供:
vphone-cli vm list
查看:
所有虚拟 iPhone
创建:
vphone-cli vm new myphone
配置:
vphone-cli vm config myphone \
--cpu 8 \
--memory 8192
克隆:
vphone-cli vm clone \
myphone \
myphone-2
导出:
vphone-cli vm export \
myphone \
--out myphone.tzst
导入:
vphone-cli vm import \
myphone.tzst \
--name restored
这些能力都已经在官方 CLI 里。
二十四、Clone 特别适合测试
假设你有:
base-ios
已经:
安装 App
登录测试账号
初始化数据
然后:
vphone-cli vm clone \
base-ios \
test-001
再:
vphone-cli vm clone \
base-ios \
test-002
再:
vphone-cli vm clone \
base-ios \
test-003
就可以形成:
测试环境 1
测试环境 2
测试环境 3
README 说明这里使用的是快速 APFS Clone,并为新 VM 创建新的设备身份。
这特别适合:
并行 E2E 测试。
二十五、甚至可以做多 Agent 手机测试
比如:
Agent A
↓
test-001
↓
登录流程
Agent B
↓
test-002
↓
订单流程
Agent C
↓
test-003
↓
支付失败页面
最后三个 Agent:
并行测试。
如果再配合:
Git Worktree
甚至可以做到:
Agent A
代码分支 A
+
虚拟 iPhone A
Agent B
代码分支 B
+
虚拟 iPhone B
Agent C
代码分支 C
+
虚拟 iPhone C
这就开始有:
AI Mobile QA Farm
的感觉了。
二十六、虚拟 iPhone 还有哪些使用场景?
我觉得至少有 5 个。
1. iOS E2E 自动化
编译
↓
安装
↓
点击
↓
截图
↓
验证
2. Coding Agent 自动验证
Codex 修改 App 后:
自己打开手机测试。
3. UI 回归测试
比如:
版本 A 截图
vs
版本 B 截图
做:
Visual Diff
4. Agent Benchmark
同一个任务:
打开设置
找到 Wi-Fi
修改某项配置
分别交给:
GPT
Claude
Gemini
测试:
成功率
步骤数
Token
执行时间
5. 移动端 Computer Use 研究
这可能是最有意思的一个。
以后:
Browser Agent
之外,很可能还有:
Phone Agent。
二十七、当然,这个项目目前门槛并不低
这一点一定要讲清楚。
项目官方要求:
Apple Silicon
macOS 15+
Xcode
iOS SDK
而且因为用到了:
私有 PV=3 能力
私有 Entitlement
宿主机还需要对 SIP / AMFI 做研究用途的放宽。
所以它目前并不是:
brew install
↓
下一步
↓
下一步
↓
完成
这种普通用户软件。
更适合:
安全研究人员
iOS 底层开发者
虚拟化研究者
Agent / E2E 自动化开发者
而且我不建议在日常主力生产机上,为了体验一个项目就随意降低系统安全保护。
最好:
专用测试 Mac
或
明确隔离的研究设备。
二十八、另外它大量使用 Private API
项目的 AGENTS.md 直接写明:
Swift 6.0
Virtualization.framework
Private API
Dynamic Runtime Dispatch
而源码中除了公开的:
VZVirtualMachine
之外,还能看到诸如:
_VZPL011SerialPortConfiguration
_VZUSBTouchScreenConfiguration
_VZMacSyntheticBatterySource
_VZSEPCoprocessorConfiguration
等私有能力。
这意味着:
系统版本升级以后,兼容性风险会明显高于纯公开 API 项目。
所以它更像:
Research Tool
而不是:
Stable Production SDK
二十九、我觉得这个项目最值得看的,其实是三层架构
如果把复杂的 Firmware 和底层研究全部拿掉。
vphone-cli 最漂亮的一部分架构其实特别清楚。
第一层:
Virtualization.framework
负责:
虚拟硬件
启动 VM
显示
音频
网络
存储
第二层:
vsock
+
vphoned
负责:
Host
↔
Guest
第三层:
Unix Socket
负责:
外部工具
↔
vphone-cli
最终:
Codex / Claude / Python
│
│
Unix Socket
│
▼
vphone-cli
│
│
vsock
│
▼
vphoned
│
▼
iOS
每一层职责非常明确。
三十、为什么我认为它最值得关注的是 Agent,而不是虚拟化?
因为能把 iPhone 启动起来本身当然已经很难。
但从开发者生态的角度来看:
真正有扩展性的能力是:
这台 iPhone 可以被程序控制。
尤其是:
执行动作
↓
自动返回截图
这个设计天然就是给:
Vision Agent
准备的。
Agent 不需要:
理解 Virtualization.framework。
也不需要:
理解 iOS 内部控制协议。
只需要几个工具:
screenshot()
tap()
swipe()
key()
clipboard()
就够了。
这其实也是 Agent 工具设计一个很好的案例:
底层可以极其复杂,但暴露给 Agent 的 Tool 一定要足够简单。
最后
我觉得 vphone-cli 真正有意思的地方,不只是:
终于可以在 Mac 上跑一台虚拟 iPhone。
更值得关注的是,它开始把移动设备变成:
可编程基础设施
以前一台 iPhone 是:
人
↓
手指
↓
屏幕
现在:
程序
↓
Unix Socket
↓
vphone-cli
↓
Virtual iPhone
再往前一步:
AI Agent
↓
MCP
↓
截图
↓
理解 UI
↓
点击 / 滑动
↓
截图
↓
继续执行
它就变成了一套完整的:
Mobile Computer Use 基础设施
过去 AI Coding 的重点一直是:
让 AI 写更多代码。
但我觉得接下来更重要的一件事会变成:
让 AI 能够验证自己写出来的东西到底能不能用。
Web 已经开始通过浏览器 Agent 补上这一环。
桌面软件开始通过 Computer Use 补上这一环。
而像 vphone-cli 这样的项目,则正在把:
真实移动系统操作能力
也接进 Agent Workflow。
当一个 Coding Agent 可以:
写代码
↓
编译 App
↓
安装到虚拟 iPhone
↓
自己打开
↓
自己点击
↓
自己截图
↓
发现问题
↓
回去修改
AI Coding 才真正从:
“代码生成器”
开始变成:
能够完成开发—验证闭环的软件工程 Agent。
我觉得这才是 vphone-cli 最值得看的地方。

浙公网安备 33010602011771号