phone-harness 真实 iPhone 控制 harness
phone-harness 用 macOS iPhone Mirroring 窗口做真实手机传输层:src/phone_harness/helpers.py:31、src/phone_harness/helpers.py:45、src/phone_harness/mirror.py:91、src/phone_harness/ocr.py:19 串起连接检查、截图、OCR 和 HID 事件,让 agent 能看见并操作用户的 iPhone,同时把需要物理确认的连接步骤留给用户。
它解决什么,凭什么值得拆
这个项目的架构取舍很直接:不跑 Appium、不装 WebDriverAgent、不越狱,而是把 Apple 已经提供的 iPhone Mirroring 当成视频与输入通道。src/phone_harness/helpers.py:31、src/phone_harness/helpers.py:45、src/phone_harness/mirror.py:91、src/phone_harness/ocr.py:19 分别对应状态闸门、用户连接边界、窗口截图和 OCR 坐标换算,说明作者把“能操作手机”和“不能替用户完成物理连接”分得很清楚。
源码规模只有约 793 行,但完整闭环存在:CLI 在 src/phone_harness/run.py:16、src/phone_harness/run.py:30、src/phone_harness/run.py:36 读取 stdin 并注入 helper;镜像层在 src/phone_harness/mirror.py:24、src/phone_harness/mirror.py:91、src/phone_harness/mirror.py:133 负责窗口、截图和点击;滚动与等待逻辑在 src/phone_harness/helpers.py:174、src/phone_harness/helpers.py:316、src/phone_harness/helpers.py:332 处理移动判断、稳定等待和 agent helper 注入。
▸核心数据
深度阅读 → 入口展开。
iPhone Mirroring 作为传输层
架构起点是窗口发现而不是设备协议。src/phone_harness/mirror.py:14、src/phone_harness/mirror.py:24、src/phone_harness/mirror.py:33 锁定系统 iPhone Mirroring 应用和主窗口,src/phone_harness/mirror.py:48、src/phone_harness/mirror.py:60、src/phone_harness/helpers.py:45 进一步保证 agent 只在已连接、可见、用户已授权的状态下行动。
▸系统拓扑
▸模块清单
模块边界清晰:src/phone_harness/run.py:21、src/phone_harness/run.py:24、src/phone_harness/run.py:33 管 CLI 分流,src/phone_harness/mirror.py:84、src/phone_harness/mirror.py:123、src/phone_harness/mirror.py:198 管 capture/input,src/phone_harness/helpers.py:89、src/phone_harness/helpers.py:239、src/phone_harness/helpers.py:300 提供 agent 可用的读屏、列表和导航原语。
src/phone_harness/helpers.py:31、src/phone_harness/helpers.py:53、src/phone_harness/helpers.py:66,它先看 App、窗口和 OCR 文案,再明确把 blocked 状态交还给用户处理。src/phone_harness/mirror.py:24、src/phone_harness/mirror.py:48、src/phone_harness/mirror.py:91 说明它直接枚举 macOS 窗口、激活前台并截图,没有 WebDriverAgent 或后台 daemon。src/phone_harness/ocr.py:19、src/phone_harness/ocr.py:34、src/phone_harness/ocr.py:48 把 Vision 归一化坐标换算成全局屏幕点,输出可直接 tap 的文本框。src/phone_harness/helpers.py:174、src/phone_harness/helpers.py:206、src/phone_harness/helpers.py:239 不是靠解析器是否发现新行判断结束,而是用 OCR 文本重叠判断屏幕是否真的移动。src/phone_harness/admin.py:16、src/phone_harness/admin.py:24、src/phone_harness/admin.py:51 把 PyObjC、辅助功能、录屏权限、窗口和 OCR 都串成 doctor 阶梯。src/phone_harness/helpers.py:332、agent-workspace/agent_helpers.py:9、agent-workspace/agent_helpers.py:21 让任务特定 helper 自动注入运行命名空间,核心库保持小而稳定。▸关键数据结构
31def connection_state(): 32 """'ready' | 'blocked' | 'no-window' | 'not-running'. 33 34 'blocked' means a connect / 'iPhone in Use' / paused interstitial is on 35 screen. Cheap to call; use it to decide whether to proceed.""" 36 if mirror.running_app() is None: 37 return "not-running" 38 if mirror.find_window() is None: 39 return "no-window" 40 path, win = mirror.capture() # window exists, so this won't launch anything 41 texts = " ".join(o["text"] for o in _ocr.recognize(path, win)).lower() 42 return "blocked" if any(m in texts for m in _BLOCKED_MARKERS) else "ready"
OCR、手势与连接状态三组核心模块
▸连接状态:先识别 blocked,再拒绝越权
连接判断不是简单 find_window。src/phone_harness/helpers.py:31、src/phone_harness/helpers.py:36、src/phone_harness/helpers.py:40、src/phone_harness/helpers.py:42 先判断 App 是否运行、窗口是否存在,再用 OCR 读取 connect / iPhone in Use 等文案;src/phone_harness/helpers.py:45、src/phone_harness/helpers.py:53、src/phone_harness/helpers.py:66 把 blocked 状态转成面向用户的错误,明确 agent 不会代替用户恢复连接。
45def ensure_mirroring(): 46 """Return the mirroring window if the phone is connected and ready. 47 48 Never launches the app, taps Connect/Continue, or polls to reconnect — 49 resuming mirroring is physical and only the user can do it. If the session 50 isn't ready, raises with instructions for the user. STOP and relay that 51 message; do not try to tap through the connect screen yourself. 52 """ 53 state = connection_state() 54 if state == "ready": 55 mirror.activate() 56 return mirror.find_window() 57 if state == "not-running": 58 raise RuntimeError( 59 "iPhone Mirroring isn't running. Please open the iPhone Mirroring " 60 "app and connect your phone, then retry — reconnecting is physical, " 61 "so I can't do it for you.") 62 if state == "no-window": 63 raise RuntimeError( 64 "iPhone Mirroring is open but no phone is connected. Please connect " 65 "your phone in the app, then retry.") 66 raise RuntimeError( 67 "iPhone Mirroring is not connected — it's showing a connect / 'iPhone " 68 "in Use' screen. This needs you: open iPhone Mirroring and connect the " 69 "phone, and if it says 'iPhone in Use', LOCK your iPhone so mirroring " 70 "can resume. Then retry. I will not tap Connect for you.")
▸截图与 OCR:把视频窗口变成可点击文本树
窗口截图在 src/phone_harness/mirror.py:91、src/phone_harness/mirror.py:105、src/phone_harness/mirror.py:112、src/phone_harness/mirror.py:118 先尝试 window capture,再回退区域 capture;OCR 在 src/phone_harness/ocr.py:19、src/phone_harness/ocr.py:34、src/phone_harness/ocr.py:48、src/phone_harness/ocr.py:51 把 Vision 结果转为屏幕点,所以上层 tap_text 可以直接点中心。
19def recognize(path, window): 20 """OCR a capture of `window` ({x, y, w, h} screen points). 21 22 Returns [{text, confidence, x, y, w, h}] where (x, y) is the box center in 23 screen points — pass straight to tap(). Vision's normalized boxes have a 24 bottom-left origin; screen points have a top-left origin, hence the flip. 25 """ 26 handler = Vision.VNImageRequestHandler.alloc().initWithURL_options_( 27 NSURL.fileURLWithPath_(path), {}) 28 request = Vision.VNRecognizeTextRequest.alloc().init() 29 request.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate) 30 ok, err = handler.performRequests_error_([request], None) 31 if not ok: 32 raise RuntimeError(f"Vision OCR failed: {err}") 33 34 img_w, img_h = image_size(path) 35 sx = window["w"] / img_w # image px -> screen points 36 sy = window["h"] / img_h 37 38 out = [] 39 for obs in request.results() or []: 40 cand = obs.topCandidates_(1) 41 if not cand: 42 continue 43 bb = obs.boundingBox() 44 px = bb.origin.x * img_w 45 py_top = (1.0 - bb.origin.y - bb.size.height) * img_h 46 pw = bb.size.width * img_w 47 ph = bb.size.height * img_h 48 out.append({ 49 "text": str(cand[0].string()), 50 "confidence": round(float(cand[0].confidence()), 3), 51 "x": round(window["x"] + (px + pw / 2) * sx, 1), 52 "y": round(window["y"] + (py_top + ph / 2) * sy, 1), 53 "w": round(pw * sx, 1), 54 "h": round(ph * sy, 1), 55 }) 56 return out
从截图到点击,再到滚动收集
真实手机控制的难点在反馈闭环,而不是单次点击。src/phone_harness/helpers.py:105、src/phone_harness/helpers.py:123、src/phone_harness/helpers.py:139、src/phone_harness/helpers.py:316 把文本点击、方向滑动、wheel scroll 和稳定等待组合起来;src/phone_harness/helpers.py:174、src/phone_harness/helpers.py:206、src/phone_harness/helpers.py:239 再用文本集合重叠判断列表是否真的前进。
▸一次点击动作如何闭环
▸列表遍历如何避免误判结束
Doctor 与运行时验证
验证路径主要依赖 doctor 与运行时自检。src/phone_harness/admin.py:12、src/phone_harness/admin.py:16、src/phone_harness/admin.py:24、src/phone_harness/admin.py:30 先检查 PyObjC、Accessibility 和 Screen Recording;src/phone_harness/admin.py:43、src/phone_harness/admin.py:51、src/phone_harness/admin.py:60 再检查窗口、截图和 OCR。
▸测试覆盖
仓库没有 tests 目录,实际验收被集中进 doctor 和小型 CLI 演示:src/phone_harness/admin.py:55、src/phone_harness/admin.py:60、src/phone_harness/admin.py:65 确认 capture/OCR 是否可用,src/phone_harness/run.py:21、src/phone_harness/run.py:24、src/phone_harness/run.py:28、src/phone_harness/run.py:36 确认命令入口能分流 doctor、skill 和 stdin 执行。
12def run_doctor(): 13 print("phone-harness doctor\n") 14 ok = True 15 16 try: 17 import Quartz, Vision, AppKit # noqa: F401 18 ok &= _check("pyobjc frameworks (Quartz, Vision, AppKit)", True) 19 except ImportError as e: 20 _check("pyobjc frameworks", False, 21 f"pip install pyobjc-framework-Quartz pyobjc-framework-Vision ({e})") 22 return 1
▸已知边界 / 失败模式
- 连接暂停或 iPhone in Use 时,
src/phone_harness/helpers.py:66直接要求用户锁屏/连接,不让 agent 乱点。 - window capture 失败时,
src/phone_harness/mirror.py:112回退到区域截图,降低合成窗口不可截图的概率。 - 列表结束需要两次以上不移动确认,
src/phone_harness/helpers.py:280避免 lazy-load 和 OCR 漏行导致提前停止。 - 无法通过 keycode 输入的字符在
src/phone_harness/mirror.py:251抛错,避免静默丢字。
为什么这个小项目值得拆
src/phone_harness/helpers.py:45、src/phone_harness/helpers.py:57、src/phone_harness/helpers.py:66:未运行、无窗口、连接暂停都会抛出给用户看的错误,而不是让 agent 去点 Connect。src/phone_harness/ocr.py:26、src/phone_harness/ocr.py:39、src/phone_harness/ocr.py:51 让每个可见文本带置信度与中心点,足以支撑 tap_text、find_text 和滚动解析。30 code = sys.stdin.read() 31 if not code.strip(): 32 sys.exit(USAGE) 33 from . import helpers 34 g = {k: v for k, v in vars(helpers).items() if not k.startswith("_")} 35 g["__name__"] = "__main__" 36 exec(code, g)
11def image_size(path): 12 src = Quartz.CGImageSourceCreateWithURL(NSURL.fileURLWithPath_(path), None) 13 if src is None: 14 raise RuntimeError(f"cannot read image {path}") 15 props = Quartz.CGImageSourceCopyPropertiesAtIndex(src, 0, None) 16 return int(props["PixelWidth"]), int(props["PixelHeight"])
技术亮点之外,别忽略这些
这个项目的边界同样清楚:它没有尝试绕过系统权限,也没有把视频流伪装成完整 DOM。src/phone_harness/helpers.py:48、src/phone_harness/mirror.py:5、src/phone_harness/ocr.py:3、src/phone_harness/mirror.py:216 说明它接受 iPhone Mirroring 的限制,然后用截图、OCR、HID keycode 和用户确认建立可审计的最小闭环。
src/phone_harness/helpers.py:24、src/phone_harness/helpers.py:67、src/phone_harness/mirror.py:60 已经写明 mirroring 断开后只能让用户介入;这不是 bug,而是产品边界。src/phone_harness/helpers.py:89、src/phone_harness/helpers.py:105、src/phone_harness/ocr.py:48 支持按文字点击,但无标签图标、复杂布局和个人内容仍需要截图确认与人工约束。src/phone_harness/mirror.py:198、src/phone_harness/mirror.py:242、src/phone_harness/mirror.py:251 使用 US keycode 逐字符输入,不支持的字符会抛错,emoji、多语言输入和系统键盘状态都要额外处理。src/phone_harness/admin.py:12、src/phone_harness/admin.py:65、src/phone_harness/mirror.py:84 的 doctor 与运行时截图,不是常规 pytest 覆盖。