Skip to content

Latest commit

 

History

History
159 lines (132 loc) · 32.6 KB

architecture.md

File metadata and controls

159 lines (132 loc) · 32.6 KB

架构

1. 总览

┌──────────────────── Linux / macOS 主机(以普通用户运行)────────────────────────┐
│                                                                               │
│  Claude Code ──stdio──▶ iphone-control mcp ──unix socket──┐                   │
│  (每个会话一个,短命)    (ictl-mcp,经 ictl-protocol)      │                   │
│                                                           ▼                   │
│                         iphone-control daemon(ictl-daemon,常驻,唯一持有设备)  │
│                         ┌───────────────────────────────────────────────────┐ │
│   usbmuxd(系统的)◀──────┤ 设备状态机   usbmux 监听   重签调度   通知          │ │
│        │                │     │                                             │ │
│        │                │  ictl-device: lockdown · CoreDeviceProxy 隧道       │ │
│        │                │               (jktcp 用户态 TCP) · RSD · cryptexd   │ │
│        │                │               · HID · 截图 · App 列表/启动          │ │
│        │                │  ictl-wda:    XCUITestService 启动 WDA ·            │ │
│        │                │               WDA HTTP(直连设备端口,不监听本机)    │ │
│        │                │  ictl-unlock: 锁屏解锁(可选)                        │ │
│        │                │  ictl-sign:   plume_core 登录 · 描述文件 · 签名       │ │
│        │                └───────────────────────────────────────────────────┘ │
└────────┼──────────────────────────────────────────────────────────────────────┘
         │ USB
   ┌─────▼───────────────────────────────┐
   │ iPhone / iPad(iOS 17+,开发者模式)   │
   │ 开发者镜像(Cryptex,iOS 27 含 dtuhidd)│
   │ WebDriverAgent(免费 Apple ID 签名)   │
   └─────────────────────────────────────┘

和 toolkit 的根本区别:toolkit 每条 HID 命令都要重新建一次隧道(3~5 秒),WDA 端口转发要单独一个进程,各工具各建各的隧道。D 方案里守护进程独占设备,隧道、HID 会话(含显示媒体流)、WDA 会话都常驻复用;MCP 进程只是一个薄客户端。

2. 进程模型

进程 生命周期 职责
iphone-control daemon 常驻,作为用户服务运行(macOS 是 launchd,Linux 是 systemd 用户服务;由 install.sh、iphone-control service install、brew services 或 .deb 的 unit 装好),也可以在终端里前台运行 唯一和设备通信的进程。维护设备状态机,对外提供本地控制接口
iphone-control mcp 每个 agent 会话一个 MCP stdio 服务,把工具调用翻译成对守护进程的请求;--url 时改为把 stdio 转接到另一台机器控制台的 /mcp(mcp_bridge.rs)
iphone-control viewer 显式开启,可作为单独的后台服务(service viewer install) 浏览器里的控制台和同一个端口上的 HTTP MCP(/mcp);要令牌登录,--lan 时对局域网开放、走 HTTPS(viewer/:auth.rs 登录、lan.rs 地址检查、tls.rs 按首字节分 TLS 和明文、mcp.rs 接 rmcp、peer.rs 查本机调用方进程、api.rs 把守护进程的数据转成页面的 JSON)
iphone-control task run 一个外部 AI 任务一个 开任务、持有租约、在任务里运行命令(task.rs);--url 时经另一台机器的控制台(remote.rs)。命令在自己的进程组里运行,停任务时整组结束(procgroup.rs,控制台的 agent 也用它)
usbmuxd 系统的服务:Linux 上是发行版的包(.deb 依赖它,udev 在插上手机时启动),macOS 自带;守护进程不启动也不重启它 Linux 上是系统包,GPL-3.0,独立进程运行,不链接
iphone-control(不带命令)、setup、service、doctor、sign 等 一次性 首次运行引导(guide.rs)、后台服务管理(service.rs)和运维命令,通过控制接口或直接调用库。启动时先把 ~/.config/iphone-control/env 读进环境(envfile.rs,已有的环境变量优先)

控制接口:Unix socket(root 默认 /run/iphone-control/ctl.sock,普通用户默认 $XDG_RUNTIME_DIR/iphone-control/ctl.sock,没有这个变量时(macOS)在数据目录下,权限 0600),JSON 行协议。请求/响应类型和客户端 call(socket, req, timeout) 在 ictl-protocol,MCP 和 CLI 共用。理由:

  • MCP 进程由 AI 客户端直接调用二进制拉起,短命且可能同时有多个;设备连接(尤其是 HID 需要的显示媒体流、WDA 的 XCTest 会话)只能有一个持有者。
  • 不开 TCP 端口,避免重蹈 ios forward 监听所有网卡的覆辙。WDA 本身也通过 idevice 的 provider 直连设备端口,本机不再监听 8100。如需给外部工具用,iphone-control wda-bridge 显式开启,只绑 127.0.0.1,且只转发 Host 头是 localhost/127.0.0.1/[::1] 的请求(挡住 DNS rebinding)。注意本机的任何用户都能连上 bridge,而 WDA 没有认证,用完就停。
  • iphone-control viewer 同样显式开启、默认只绑 127.0.0.1、检查 Host 头;每个 HTTP 请求都要登录令牌(本机也一样),--lan 时也对局域网开放、走 HTTPS,同一个端口上还有 /mcp(MCP over HTTP)和给外部平台开任务的接口(见 task-control.md 第 7 节、D3):页面看 WDA 的 MJPEG 流(手机上的 9100 端口,经 usbmuxd,只读);点击、滑动、输入、按键是 POST,还要检查 Origin 是页面本身、内容类型是 JSON,然后作为普通请求发给守护进程,和 agent 的调用排队、自动解锁。它跟着守护进程控制的设备:在本页、别的页面、iphone-control select、MCP 工具里切换,或拔掉后自动换了一台,/api/info 的 udid 都会变,页面最迟在下一次 10 秒轮询时发现,把画面(/stream?udid=)和各面板换过去。每个操作带上页面显示的那台的 UDID(X-Ictl-Device),守护进程控制的不是这台就拒绝,不会发到用户没在看的手机上。还没装 WDA 的设备,登录了 Apple ID 时页面上可以直接签名安装(/api/sign,即 Request::Resign { force: true },时限 16 分钟)。AI 任务执行中不能切换设备,设了 IPHONE_UDID 时也不能。
  • viewer 的画面像触摸屏:iOS 27 起按下、移动、松开各发一个 Request::Touch(prepare/down/move/up),守护进程在已开的 HID 会话上发一个 touchscreen 报告(约 2 毫秒);解锁、核对方向只在 down 时做一次。页面一次只发一个请求,没发出去的移动只留最新的一个,按住不动时每 100 毫秒再报一次位置。滚轮、触控板换成同样的手指拖动。iOS 27 以下松开后发整段手势(WDA)。画面的 MJPEG 流在 WdaClient::fast_stream 下最多每秒 60 帧、原尺寸、不转正:缩放或转正时 WDA 要逐帧解码、处理、重新编码,CPU 超过 iOS 的上限(60 秒内平均 80%)会被杀掉;这样设置时它直接转发 testmanagerd 用硬件编好的 JPEG。iPhone 横屏时这些帧仍是竖的:JPEG 带 EXIF 方向,浏览器显示 MJPEG 的 img 时自己转正;本机编码的 H.264 不带方向,页面按界面方向(/api/info 的 display.rotation)在 canvas 上转正,和手机自己的视频一样。页面的文字中英两套,请求带 X-Ictl-Lang,viewer 在这种语言下处理,AI 任务记下开始时的语言。
  • viewer 的画面编码(video/):macOS 上和装了 ffmpeg 时页面拉 /video,是 H.264。每个连接读手机的 MJPEG,丢掉和上一帧相同的(静止时 WDA 每秒重复约 25 次),交给一个 ffmpeg 子进程(mpjpeg 进,FLV 出;FLV 每帧带长度,收完最后一个字节就能发,裸 H.264 要等下一帧开头才知道这一帧结束),再按「1 字节类型 + 4 字节长度 + 内容」发给页面,页面用 WebCodecs 解码画到 canvas。发出去的解码配置先改写 SPS,写明不重排帧(video/sps.rs):没写的话 Chrome 的硬件解码器按级别的最坏情况扣着几帧不出(990×2151 时 4 帧),画面滞后,静止后停在动作中间。macOS 上先用本程序直接调的 VideoToolbox(video/vt.rs,每个连接一个线程:VideoToolbox 的 JPEG 解码器把 JPEG 解成 NV12,奇数尺寸顺带拉成偶数,直接交给 H.264 编码器;不重排、不扣帧,每帧编完才取下一帧,不用补帧;输出本来就是 avcC 格式,avcC 取自格式描述;qp 用 MinAllowedFrameQP/MaxAllowedFrameQP 定住,macOS 12 以前的系统按 Quality 近似;macOS 11 以后才有的键运行时用 dlsym 找,免得在旧系统上起不来),不需要 ffmpeg。其余编码器在 viewer 启动时按顺序试(NVENC、各渲染节点上的 VAAPI(先显卡解 JPEG,再 CPU 解)、QSV、V4L2 M2M、ffmpeg 的 VideoToolbox(原生的备用)、x264),处理真实画面失败就换下一个。ffmpeg 会扣住最新的一帧等下一帧来(AMD 780M 的 VAAPI 是 1 帧),画面静止时最后一次变化就出不来,所以每帧后面紧跟着把它再写 K 遍,K 在探测时测出(写入的帧数减去出来的帧数),这几份副本各编成约 100 字节。流量控制:系统和 SSH 隧道的缓冲能吞下几 MB,光看队列满不满判断不了页面跟不跟得上(每秒读 30 KB 的客户端曾落后 40 秒),所以页面每 100 毫秒 POST /api/video/ack 报告收到多少字节;最早一个没确认的消息比平常的往返时间晚 300 毫秒以上,或者在路上的字节超过「链路速度 ×(往返时间 + 300 毫秒)」时,新画面先不交给编码器,追上后只交最新一张。编好的帧一个不丢,帧链完整,不需要关键帧。没有 ffmpeg 或浏览器不支持 WebCodecs 时用 /stream:同样丢掉重复帧、只发最新一张,每帧后面紧跟下一部分的分隔线和头部(Chrome 要看到这些才显示上一帧)。
  • 手机自己的视频(iOS 27 及以上,页面优先用它):CoreDevice 屏幕媒体流本来就由 HID 会话持有(HID 事件要它在才生效)。ictl-device 的 media.rs 不再丢掉 RTP:用上游的 HevcDepacketizer 还原成 Annex-B,按 RTP marker 组成一帧,记下 VPS/SPS/PPS 补到手机按请求发来、不带参数集的关键帧前面,经 broadcast 发给订阅者(ScreenFeed);并照 Device Hub 回传 RTCP(每帧一个帧确认、每 50 毫秒一个 RCTL、每秒一个接收报告),按需发 PLI+FIR 要关键帧(最多每 300 毫秒一次)。守护进程的 screen.rs 处理流式请求 ScreenStream:先回一行 JSON,之后在同一连接上发「类型 + 长度 + 内容」的消息(0 解码配置、1 关键帧、2 其他帧、3 界面方向),客户端写 K 要关键帧;还没收到关键帧时每 250 毫秒再要一次(刚开流时要的那次手机可能不理);界面方向每秒问一次 WDA。有页面在看时(screen_viewers),HID 会话不因空闲关闭。viewer 的 video/device.rs 转发到 /video?src=device,流量控制沿用页面的回报:落后时丢掉非关键帧、再要一个关键帧。页面不带 description 配置 HEVC 解码器(Annex-B),按 /api/info 的屏幕尺寸裁掉宽高补到 16 倍数的边(1320×2868 编成 1328×2896),按方向转正;isConfigSupported 不支持或从没出过画面就退回 H.264,再退回 MJPEG。
  • AI 任务(守护进程的 tasks.rs,设计见 task-control.md,D17):任务由守护进程管理,Daemon::handle 在排队拿锁之前先过 Tasks::admit。开任务(task_open)的连接就是租约,连接断了任务就是 abandoned;任务绑定开任务时控制的设备。有任务在进行时,带它口令(请求键 task)的请求固定发往那台设备,任务暂停、接管、等确认时在守护进程里等着,用户交还或别人动过手机后要先看一次屏幕才能再操作;不带口令的请求可以看,但操作一律拒绝(用户接管时除外,抬起手指总是可以);切换设备一律拒绝;已结束任务的口令什么都做不了。工具调用先报一步(task_step,它的请求带上步骤号 task_step),没报的操作也自己记成一步。事件逐条写进 tasks/<id>.jsonl(最多 50 个),重启后没结束的记成 interrupted。开任务时可以限定 App(apps,开任务时解析成 Bundle ID):有范围的任务每个操作前先查它碰到哪个 App(启动的目标、链接、或问 WDA 当时的前台),超出范围就像 iphone_confirm 一样等人确认,同意后那个 App 加入范围,看不出前台时不操作(task-control.md 5.5)。控制台、命令行(iphone-control task …)和外部平台用的是同一套请求。
  • viewer 的 AI 任务(agent.rs):页面上写的任务交给本机的 Claude Code(claude -p --output-format stream-json,--tools ""、--strict-mcp-config,只有 iphone 的 MCP 工具)或 Codex(codex exec --json,只读沙箱,-c 挂上 iphone 的 MCP 服务),作为子进程(单独的进程组,停止时整组结束)。viewer 先在守护进程里开任务(带上页面选的 App 范围 apps,并在给 agent 的说明里写明范围)、拿住租约,再带着 IPHONE_CONTROL_TASK_TOKEN 启动 agent,它的 MCP 服务就加入了这个任务(ictl_mcp::task),并多列出一个 iphone_confirm。agent 自己的输出只取它说的话(作为 task_note 挂到下一步上)和最后的总结(task_finish);任务在别处结束(页面上停止、task stop、换了设备)时,viewer 结束 agent 的进程组。Codex 对标了 destructive 的 MCP 工具默认要审批,非交互运行时会全部拒绝,所以给这个服务设 default_tools_approval_mode="approve",由 iphone_confirm 把关。页面按 SSE 收任务事件(viewer 转发守护进程的 task_events:step/confirm/state/done),重连时先重放;页面每 3 秒问一次 /api/agent/tasks,刷新前在跟的任务和在别处开的任务都会接上。

并发模型

见 crates/ictl-daemon/src/core.rs 开头的模块文档。

  • 全部跑在一个 LocalSet 里(单线程协作);设备相关状态(隧道、HID 会话、runner、WDA 客户端)在一把锁后面。
  • 每个连接一个任务,请求在任务里等锁;主循环本身从不阻塞。
  • 10 秒巡检用 try_lock:有请求在用设备就跳过这一轮,不排队。
  • 耗时的事不持锁:
    • 启动 WDA(单独开一条隧道查/挂开发者镜像,再启动 runner,最长约 2 分钟)是后台任务,巡检收结果;期间工具调用照常走 HID 后备路线。
    • 重签分三段:持锁取参数 → 不持锁签名、安装 → 持锁写结果。
    • Apple ID 会话检查在后台跑。
  • status(Daemon 请求)读 watch 通道发布的快照,不碰锁。
  • 安装(iphone_install_app)同样不持设备锁;安装之间互斥(iphone_install_app、重签、sign,锁在 Device::install),因为 idevice 把每个 .ipa 都上传到手机上同一个文件。
  • Web 请求(iphone_web_targets、iphone_web_eval)用独立的锁,不占设备锁,慢页面不挡点击和截图。
  • 改变手机状态的请求(含 iphone_web_eval、iphone_install_app)排队超过 30 秒就放弃,免得点击落到已经变了的屏幕上;调用方取消后还在排队的请求也不执行。
  • 停止信号优先处理(biased select):取消排队和执行中的请求、巡检,再停 HID 媒体流、runner。
  • 卡死兜底:约 10 分钟没有完成任何巡检或请求(设备锁被占死、运行时卡住)时,守护进程自己退出,交给服务管理器(launchd 或 systemd)重启,免得一直占着设备又不干活。

3. 守护进程状态机

状态定义在 crates/ictl-daemon/src/machine.rs(DeviceState),推进在 supervise.rs。同一时间只控制一台设备:IPHONE_UDID 固定 > 运行中选定的(select,存在 state.json)> 自动选(正在控制的那台一直用到它断开,没有才取第一台)。选定的那台没接时就是 Absent,不换成别的(machine::pick_device、devices.rs)。

每 10 秒巡检一次,从上往下判断,第一个不满足的就是当前状态:

  usbmuxd 看得到手机? ──────── 否 ─▶ Absent(离线超过 10 分钟推送通知)
        │ 是
  有配对记录? ──────────────── 否 ─▶ NeedsTrust(iphone-control setup,手机上点「信任」)
        │ 是
  开发者模式已开? ──────────── 否 ─▶ DeveloperModeOff(只在未就绪时检查)
        │ 是
  WDA 签名安装过? ──────────── 否 ─▶ NeedsSigning(iphone-control sign)
        │ 是
  本进程 runner 存活且 /status ready? ── 是 ─▶ Ready
        │ 否(runner 还活着时连续 3 次才算)→ 丢掉 runner、WDA 客户端、隧道
        ▼
  WdaStarting:后台任务 开隧道 → 查/挂开发者镜像 → 关隧道 → 启动 runner(最长 120 秒)
        │ 结果由下一轮巡检收
        ├─ 成功 ──────────────────────────────▶ 健康检查通过后 Ready
        ├─ 隧道报 PasswordProtected(锁屏/BFU)─▶ NeedsUnlock(BFU 必须人工,定期重试,状态不来回变)
        ├─「Enable UI Automation」超时 ─────────▶ NeedsAutomationPin(推送通知,每次只等 25 秒、5 秒后再启动,不指数退避)
        └─ 镜像或 WDA 其它失败 ─────────────────▶ WdaStarting { last_error },按退避重试

要点(每一条都来自 toolkit 实际踩过的坑):

  • 热插拔:交给系统的 usbmuxd(Linux 上由 udev 通知)。守护进程每 10 秒向 usbmuxd 列一次设备;列设备连续 3 轮出错才当作离线,usbmuxd 抖一下不拆会话。doctor 另数 sysfs /sys/bus/usb/devices/(Linux)或 IORegistry(macOS)里的 iPhone/iPad/iPod(厂商号 05ac 且产品名匹配,键盘等配件不算),区分「USB 上没有手机」和「usbmuxd 看不到」。(以前的 Docker 镜像里没有 udev,守护进程自己看护 usbmuxd,台数对不上就重启它;去掉 Docker 后这部分删了,见 D5、D14。)
  • 隧道:CoreDeviceProxy + jktcp 用户态 TCP 栈(idevice tunnel_tcp_stack),不需要 root,不创建 TUN 网卡。锁屏且从未解锁过(BFU)时报 PasswordProtected,进入 NeedsUnlock,只能人工解锁。HID 和截图用的隧道按需建立、常驻复用;WDA 掉线或 usbmuxd 重启时连隧道一起丢。
  • HID 会话:归 Tunnel 所有(Tunnel::hid / close_hid / close),持有屏幕媒体流。关闭时必须先停媒体流再断隧道,否则手机一直挂着「配对的电脑」会话(灵动岛图标常驻、不自动锁屏)。空闲 30 秒关闭,重开约 0.5 秒。
  • 开发者镜像:每次启动 WDA 前都先 installed_ddi() 查询(toolkit 实测已挂载时约 1 秒),未挂载才走 install_ddi()(个性化 + TSS)。手机重启后镜像可能会掉,所以这一步不能省。默认缓存(ddi/)连续 3 次挂载失败才清掉重新下载,隧道建不起来不计入。设了 ICTL_DDI_DIR 时不下载、也不删里面的文件,缺文件时报错说明缺哪个。
  • WDA 就绪判定:必须同时满足"本进程持有的 XCTest 会话存活"和 /status 返回 ready。toolkit 踩过:host 侧 runner 退出后,手机上残留的 WDA 还会响应几十秒再自杀,单看 /status 会误判。实验开关 ICTL_WDA_DETACHED=1 下 WDA 在后台启动、本进程不持有 XCTest 会话,就绪只看 /status,守护进程重启后接上原来的进程(state.json 的 detached_wda),见 WDA 依赖调研。
  • 自动化密码框:手机重启后首次启用 UI 自动化会弹「为 XCTest 输入 iPhone 密码」,没人输就报 Timed out while enabling automation mode。识别到这个错误:进入 NeedsAutomationPin、只推送一次通知;之后每次启动只等 25 秒,没弹出密码框就 5 秒后再启动一次(密码框只在启动时出现、每次约 45 秒,且不是每次启动都出现;弹出来的那次会一直等到密码框消失),这样手机上大部分时间都有框可输。恢复后再推送一次。
  • 退避:其它 WDA 启动失败按 60s → 120s → 240s → 300s 退避,上限 5 分钟;手机重新连上、WDA 就绪、重签完成时重置。

4. 模块划分

crate 职责 主要依赖
ictl-device Device 句柄:usbmux 发现与监听、lockdown、配对状态、开发者模式状态、隧道(Tunnel,持有 HID 会话和显示媒体流)、开发者镜像(含下载缓存)、截图、App 列表/启动/结束、安装 IPA、卸载、崩溃报告、系统日志流(oslog,开发者工具用)、打开 Web Inspector;所有设备调用都有时限 idevice(锁上游 git 提交)
ictl-wda WdaRunner(XCUITestService 启动/停止)、WdaClient(会话、点击、W3C actions、keys、source、screenshot、appium settings、锁屏核对)、经 usbmuxd 直连设备 8100 端口的 HTTP 传输(区分连不上和请求超时)、元素树解析(纯函数,可单测) idevice(xctest)、quick-xml
ictl-webinspector Web Inspector 服务(com.apple.webinspector,经 lockdown)的客户端:列出可检查的网页和 WebView、在页面里执行 JavaScript(Target 域和旧版直接收命令两种都支持,Promise 用 Runtime.awaitPromise 等待)。纯协议,消息解析可单测 idevice、plist
ictl-l10n 界面文字的语言:取当前语言,tr! / trf! / log_tr! / log_trf! 宏(每条文字在代码里原地写成中英一对,见 D12) 无
ictl-protocol 控制接口的 Request/Response 类型和客户端 call(socket, req, timeout)、任务的流式请求;Response::Text 除了给人和 agent 看的文字,还可以带 data(状态、元素、App 列表、系统弹窗的结构化字段,给控制台这类程序用,文字措辞改了也不影响它们);默认的数据目录、socket 路径和 root 判断 serde、serde_json、tokio、libc、ictl-l10n
ictl-sign Apple ID 登录(含双重认证交互)、会话持久化、注册设备、App ID、描述文件、签名 .app/.ipa(包括 .xctest;bundle 逻辑改写自 plume_utils)、签后自检 vendored 的 plume_core/plume_store(MPL-2.0/MIT;经 plume_core 用 vendored 的 omnisette 和打过补丁的 apple-codesign)
ictl-unlock 锁屏密码键盘定位、逐位输入并核对密码栏、从第 2 位起先写停用标记 ictl-wda
ictl-daemon core(共享状态、请求分发、快照)、supervise(巡检、后台启动 WDA、重签、会话检查)、tools(工具的实现、HID 后备)、devtools(日志采集、崩溃报告、安装、卸载)、web(Web Inspector 工具,保留一条连接复用)、devices(列出、选择设备)、doctor;通知(Bark/Telegram)、截图缩放/裁剪、控制接口服务端 以上全部、tokio、reqwest、image
ictl-mcp MCP stdio 服务,iphone_* 工具 17 个,开启开发者工具后 23 个:toolkit 的 13 个 + iphone_wait_for、iphone_batch、iphone_list_devices、iphone_select_device,以及 6 个可选的开发者工具(D13);操作类工具可带 then 顺带返回操作后的界面;任务里另有 iphone_confirm;经控制接口调用守护进程。同一套工具也给控制台的 /mcp 用(http_service,Streamable HTTP) ictl-protocol、ictl-l10n、rmcp(server、transport-io、transport-streamable-http-server)、http
apps/iphone-control CLI 入口(main.rs;和守护进程打交道的公共函数在 client.rs,控制台、引导和更新器都用它):不带命令时是首次运行引导或状态(guide.rs)、daemon、mcp、mcp-config、status、doctor、setup [--reconfigure]、service(service.rs:launchd / systemd 用户服务)、login、sign、resign、passcode、unlock-reset、devices、select、wda-bridge、viewer(viewer.rs:本机控制台网页,实时画面、设备面板;video/:画面的 H.264 编码(外部 ffmpeg)和 MJPEG 去重;agent.rs:页面上的 AI 任务,viewer.html 是页面)、viewer token …(命名令牌、只读令牌)、task …(task.rs、remote.rs)、mcp --url(mcp_bridge.rs)、update …(update.rs、update/:签名清单、事务式替换)、completions、notify-test;隐藏的 debug <ddi|hid|screenshot|soak|wda|e2e> 真机诊断(原第 0 步 spike,不经守护进程;feature debug-cmds,默认不编译) clap

依赖方向:apps → ictl-daemon / ictl-mcp,还直接依赖 ictl-device、ictl-sign;ictl-daemon → ictl-unlock → ictl-wda → ictl-device;ictl-daemon 还直接依赖 ictl-webinspector、ictl-sign 和 ictl-protocol。ictl-device 也依赖 ictl-webinspector(打开连接)。ictl-mcp 只依赖 ictl-protocol 和 ictl-l10n,不依赖守护进程和设备层。ictl-l10n 不依赖其他 crate,除 ictl-webinspector 外都用它。apps 的 debug(feature debug-cmds)直接调用 ictl-device、ictl-wda。

5. 截图与坐标

  • 坐标单位统一用点(pt),与 WDA 一致,随界面方向变化;屏幕点尺寸从 WDA /wda/screen 读取。WDA 就绪时把竖放尺寸按设备 UDID 记到 state.json(每台设备各一份)。WDA 不可用时:记录是这台的就用记录,否则用设置里的 SCREEN_W/SCREEN_H(不分设备),都没有就报错(不写死 430×932)。
  • HID 坐标是 0~65535 归一化:先按界面方向转回竖放(Rotation::to_portrait),再换算 x / w * 65535。每次 HID 触摸前重新截图核对横竖;横屏要有 30 秒内且匹配的 WDA 旋转角,否则拒绝触摸。
  • 截图优先走 WDA(toolkit 实测约 0.4 秒);WDA 不可用时走 CoreDevice screencaptureservice(不需要签名)。
  • 区域放大:从原始像素截图裁剪,长边 < 600 放大到 600,> 1200 缩到 1200,返回倍数和坐标换算式(三位有效数字)。

6. 数据与秘密目录

全部在一个数据根目录下(普通用户默认 $XDG_DATA_HOME/iphone-control,没设时 ~/.local/share/iphone-control,macOS 相同;以 root 运行时是 /var/lib/iphone-control,安装方式都不这样用)。数据根目录是 0700;下表权限写「默认」的没有单独设,靠它保护:

路径 内容 权限 进 git / 进发布包
lockdown/ 不再使用(以前容器里的 usbmuxd 把配对记录放这里)。配对记录由系统的 usbmuxd 保存:Linux 在 /var/lib/lockdown,macOS 在 /var/db/lockdown,和 Finder 共用 0700 否
account/ Apple ID 会话、开发私钥、证书 0700 否
ddi/ 开发者镜像缓存(运行时下载的苹果文件);设了 ICTL_DDI_DIR 时用那个目录,不用这里 默认 否,不得分发
wda/ 当前签好的包 WDA-signed.ipa,和未签名包 WDA-unsigned.ipa:第一次签名前下载 Appium 官方的未签名真机包(WebDriverAgentRunner-Runner.zip v16.15.3,校验 sha256)重新打包(crates/ictl-daemon/src/wda_pkg.rs);计划改为本项目 macOS CI 编译。描述文件只嵌在签好的包里,到期时间记在 state.json 默认 否(在本机下载)
secrets/passcodes/<UDID> 各设备的锁屏密码(仅启用自动解锁时);旧版本的 secrets/passcode 在只接着一台时归到那台名下 0600 否
logs/ 事件日志 daemon.log(超过 5 MB 轮转成 .1);自动解锁停用标记 UNLOCK-DISABLED(与 toolkit 一致,便于人工发现;全局,不分设备) 默认 否
state.json 最近一次签名的 Team ID、WDA Bundle ID、描述文件到期时间;选定的设备;每台设备的竖放屏幕尺寸(按 UDID);上次重签结果 默认 否
viewer-tokens.json 控制台的登录令牌(名字、权限、令牌) 0600 否
viewer-tls/ 控制台局域网 HTTPS 的自签证书和私钥 0700 否
tasks/ 最近 50 个 AI 任务的事件记录,每个任务一个 <id>.jsonl 默认 否

守护进程的配置全部来自环境变量(crates/ictl-daemon/src/config.rs),都有默认值。整个项目的环境变量都登记在 crates/ictl-protocol/src/env.rs(名字、用途、是否在设置文件模板里),代码只经它的常量读;它的测试核对代码、配置页和 packaging/env.example 三者一致。设置文件 ~/.config/iphone-control/env(KEY=value,0600,模板 packaging/env.example)由首次运行引导和 setup --reconfigure 写(envfile.rs:改一项就地替换,注释和其他项保留);后台服务(launchd、systemd、brew services)把它读进守护进程的环境,其他命令启动时也读一遍,已有的环境变量优先。通知渠道的密钥也在这个文件里;其余秘密只放数据目录里的文件。

7. 重签与通知

  • 守护进程每 RESIGN_CHECK_HOURS(默认 6 小时)检查一次:描述文件剩余 < RESIGN_MIN_DAYS(默认 2 天)才重签;另外每天验证一次 Apple ID 会话(toolkit 的教训:只在要重签时才发现登录过期,只剩 2 天处理)。两者都在后台跑,不持设备锁。只检查正在控制的那台;手机重新连上或切换设备后 1 分钟内也查一次。
  • 重签流程:plume_core 取新描述文件 → ictl-sign 签名(含 .xctest)→ installation_proxy 安装 → 下一轮巡检重启 WDA。安装成功后才替换旧文件。整个重签有总时限,超时按失败处理并推送通知。没装过当前 Apple ID 签的 WDA 的设备不自动重签(注册设备占 Apple ID 的名额,交给用户决定)。
  • 签名证书:每个账号目录有自己的私钥(account/keys/<团队>/key.pem)和对应的开发证书;免费团队同时最多两张,新建时满了 plume 会吊销一张,不挑是谁的,用它签的 App 在所有设备上都起不来,描述文件却仍有效。所以:读设备上的 WDA 描述文件时顺带离线判断里面有没有本机公钥的证书(foreign_wda);每天的会话检查顺带联网查本机证书还在不在团队里(own_cert)。设备拒绝启动 WDA(只报 unexpected response)时,两者任一有问题就自动重签这台,同一台 6 小时内最多一次;都没问题就多半是还没信任开发者,留给用户。签名新建或吊销证书时写日志、推送。doctor 显示这两项。
  • 通知事件:重签失败、Apple ID 会话失效、需要输入自动化密码、WDA 恢复、自动解锁失败已停用、手机离线超过 10 分钟、恢复在线、签名证书换了。通知标题带上是哪台设备(设备名和型号,离线时用离线前记下的)。去重:重签失败、会话失效 20 小时内不重复;等密码、离线、自动解锁停用推一次,直到恢复或 unlock-reset;只有等密码和离线有恢复推送;签名证书换了每次都推。去重记录只在内存里,守护进程重启后清空。
  • 守护进程整个挂掉时发不了通知,这部分仍需宿主机侧的监控,本项目还没有提供。后台服务(iphone-control service install 装的 launchd 或 systemd 服务)只负责崩溃后重启,同样不报警。