phone-harnessHarness
§ 00 · Source-level Observatory

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,同时把需要物理确认的连接步骤留给用户。

★ 942
GitHub Stars
793
LOC
43
Files
2026-08-07
Last commit
agent script ↓ phone-harness stdin helpers namespace ↓ capture + OCR iPhone Mirroring window ↓ Quartz CGEvent real iPhone UI
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

这个项目的架构取舍很直接:不跑 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 注入。

▸核心数据

架构模式
无 daemon 的本地 CLI harness:每次命令重新发现窗口、截图、OCR、发事件,失败时通过异常把控制权交回用户。
核心抽象
全局屏幕点坐标 + Vision OCR 文本框 + Quartz HID 事件 + agent-workspace 可编辑 helper。
性能/精度数据
没有 benchmark;关键经验写在源码注释里,例如滚动重叠阈值 0.6、稳定等待 2.5s、window capture 失败后回退区域截图。
下游依赖
适合 iOS-only app、手机号/2FA、本机 App 视觉测试;不适合可通过网页/API 完成的任务。
本文导航 — 6 个章节依次:概述 → 架构图 → 核心模块 → 关键流程 → 测试与验证 → 风险。子页面在每个 H3 末尾以 深度阅读 → 入口展开。
§ 01 · ARCHITECTURE

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 只在已连接、可见、用户已授权的状态下行动。

▸系统拓扑

agent code → phone-harness stdin → helpers namespace → connection_state / ensure_mirroring → screencapture + Vision OCR → Quartz CGEvent tap / key / scroll → screenshot again for verification

▸模块清单

模块边界清晰: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 状态交还给用户处理。
Mirroring 传输层
src/phone_harness/mirror.py:24、src/phone_harness/mirror.py:48、src/phone_harness/mirror.py:91 说明它直接枚举 macOS 窗口、激活前台并截图,没有 WebDriverAgent 或后台 daemon。
Vision OCR 元素树
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 阶梯。
Agent 可编辑层
src/phone_harness/helpers.py:332、agent-workspace/agent_helpers.py:9、agent-workspace/agent_helpers.py:21 让任务特定 helper 自动注入运行命名空间,核心库保持小而稳定。

▸关键数据结构

src/phone_harness/helpers.py · L31-42Python
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"
深度阅读 · Mirroring 传输层与输入事件
§ 02 · CORE MODULES

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 不会代替用户恢复连接。

src/phone_harness/helpers.py · L45-70Python
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 可以直接点中心。

src/phone_harness/ocr.py · L19-56Python
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
深度阅读 · 滚动、稳定等待与列表收集
§ 03 · KEY FLOW

从截图到点击,再到滚动收集

真实手机控制的难点在反馈闭环,而不是单次点击。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 再用文本集合重叠判断列表是否真的前进。

▸一次点击动作如何闭环

find_text(query) → ocr() captures window → choose hit center → mirror.tap(x,y) → wait_stable() → screenshot / ocr verify

▸列表遍历如何避免误判结束

_content_texts() before → scroll_screen() wheel event → wait until OCR set settles → overlap(before, after) → moved? ingest items : stale += 1 → end_after stale screens = reached-end
深度阅读 · Doctor、权限阶梯与 agent helper
§ 04 · TESTS & VALIDATION

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 执行。

src/phone_harness/admin.py · L12-22Python
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 抛错,避免静默丢字。
§ 05 · HIGHLIGHTS

为什么这个小项目值得拆

亮点一:不替用户连接手机
最关键的安全设计在 src/phone_harness/helpers.py:45、src/phone_harness/helpers.py:57、src/phone_harness/helpers.py:66:未运行、无窗口、连接暂停都会抛出给用户看的错误,而不是让 agent 去点 Connect。
亮点二:用 OCR 当作贫血 DOM
src/phone_harness/ocr.py:26、src/phone_harness/ocr.py:39、src/phone_harness/ocr.py:51 让每个可见文本带置信度与中心点,足以支撑 tap_text、find_text 和滚动解析。
补充代码块 · CLI 注入
src/phone_harness/run.py · src/phone_harness/run.py:30-36Python
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)
补充代码块 · 图像尺寸
src/phone_harness/ocr.py · src/phone_harness/ocr.py:11-16Python
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"])
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

这个项目的边界同样清楚:它没有尝试绕过系统权限,也没有把视频流伪装成完整 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,而是产品边界。
OCR 不是语义树
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 覆盖。
诚实记录 — 这部分列的是我在源码里看到、但作者没有明确文档化的限制。读者请按需取舍。