macos-harnessDetail
§ · Nested detail page

PID 输入与焦点守卫 深度解析

这一页拆 raw input 的不变量:必须有目标 PID,不能移动物理鼠标,不能把后台目标拉到前台,不能把 coordinate click 偷换成 AXPress。

§ 00 · IMPORT CHAIN

导入链与契约

输入层依赖 src/macos_harness/macos.py:1128 之后的 direct visual and keyboard input 区块。所有入口都先走 src/macos_harness/macos.py:405、src/macos_harness/macos.py:421 的权限检查,再通过 src/macos_harness/macos.py:1130 到 src/macos_harness/macos.py:1141 找到目标 PID 并拒绝无目标输入。

src/macos_harness/macos.py · L1128-1162Python
1128    # --- direct visual and keyboard input -------------------------------
1129
1130    def _pid(self, app: str | None) -> int | None:
1131        if app is None and self._last_app is None:
1132            return None
1133        _, info = self._resolve_app(app)
1134        self._last_app = info
1135        return int(info["pid"])
1136
1137    @staticmethod
1138    def _post(event: Any, pid: int | None) -> None:
1139        if pid is None:
1140            raise MacOSError("Input requires an app or prior app snapshot")
1141        AS.CGEventPostToPid(pid, event)
1142
1143    def _screen_point(
1144        self, x: float, y: float, coordinate_space: str
1145    ) -> tuple[float, float]:
1146        if coordinate_space == "screen":
1147            return float(x), float(y)
1148        if self._last_screenshot is None:
1149            raise MacOSError(
1150                "Take a screenshot before using window or screenshot coordinates, "
1151                "or pass coordinate_space='screen'"
1152            )
1153        shot = self._last_screenshot
1154        bounds = shot["bounds"]
1155        if coordinate_space == "screenshot":
1156            x = float(x) / float(shot["scale_x"])
1157            y = float(y) / float(shot["scale_y"])
1158        elif coordinate_space != "window":
1159            raise MacOSError(
1160                "coordinate_space must be 'screenshot', 'window', or 'screen'"
1161            )
1162        return bounds["x"] + float(x), bounds["y"] + float(y)
§ 01 · CORE IMPL

核心实现,逐段拆

▸click 保持 raw coordinate 语义

click 的实现 src/macos_harness/macos.py:1206 到 src/macos_harness/macos.py:1249 明确说“never guess an AX action”。它记录 focus_before,移动的是 overlay 指针,然后用 src/macos_harness/macos.py:1231 到 src/macos_harness/macos.py:1243 创建 down/up 事件投递给 PID。src/macos_harness/macos.py:1245 再检查焦点是否被意外改变。

src/macos_harness/macos.py · L1206-1249Python
1206    def click(
1207        self,
1208        x: float,
1209        y: float,
1210        *,
1211        app: str | None = None,
1212        button: str = "left",
1213        clicks: int = 1,
1214        coordinate_space: str = "screenshot",
1215    ) -> dict[str, Any]:
1216        """Send one raw coordinate click to an app PID; never guess an AX action."""
1217        self._ensure_accessibility()
1218        self._ensure_post_events()
1219        button = button.casefold()
1220        if button not in _BUTTONS:
1221            raise MacOSError(f"Unknown mouse button {button!r}")
1222        point = self._screen_point(x, y, coordinate_space)
1223        pid = self._pid(app)
1224        if pid is None:
1225            raise MacOSError("Pointer input requires an app or prior app snapshot")
1226        focus_before = self._frontmost_app()
1227        self._pointer_position = point
1228        self._overlay.move(*point)
1229        down_type, up_type, _ = _MOUSE_EVENTS[button]
1230        for click_count in range(1, max(1, int(clicks)) + 1):
1231            down = AS.CGEventCreateMouseEvent(
1232                self._event_source, down_type, point, _BUTTONS[button]
1233            )
1234            up = AS.CGEventCreateMouseEvent(
1235                self._event_source, up_type, point, _BUTTONS[button]
1236            )
1237            AS.CGEventSetIntegerValueField(
1238                down, AS.kCGMouseEventClickState, click_count
1239            )
1240            AS.CGEventSetIntegerValueField(up, AS.kCGMouseEventClickState, click_count)
1241            self._post(down, pid)
1242            time.sleep(0.03)
1243            self._post(up, pid)
1244            time.sleep(0.03)
1245            self._guard_focus(focus_before, pid, "click")
1246        self._overlay.click()
1247        pointer = self._pointer_info()
1248        assert pointer is not None
1249        return pointer

▸drag 与 scroll 也继承相同约束

拖拽和滚动没有绕开守卫。src/macos_harness/macos.py:1251 到 src/macos_harness/macos.py:1305 用同一 PID、同一 focus guard 和同一 overlay 位置;src/macos_harness/macos.py:1307 到 src/macos_harness/macos.py:1356 把大 delta 拆成小步,避免某些 App 对过大滚轮事件处理不稳定。

src/macos_harness/macos.py · L1251-1356Python
1251    def drag(
1252        self,
1253        from_x: float,
1254        from_y: float,
1255        to_x: float,
1256        to_y: float,
1257        *,
1258        app: str | None = None,
1259        button: str = "left",
1260        coordinate_space: str = "screenshot",
1261        duration: float = 0.25,
1262        steps: int = 12,
1263    ) -> None:
1264        self._ensure_accessibility()
1265        self._ensure_post_events()
1266        button = button.casefold()
1267        if button not in _BUTTONS:
1268            raise MacOSError(f"Unknown mouse button {button!r}")
1269        start = self._screen_point(from_x, from_y, coordinate_space)
1270        end = self._screen_point(to_x, to_y, coordinate_space)
1271        pid = self._pid(app)
1272        if pid is None:
1273            raise MacOSError("Pointer input requires an app or prior app snapshot")
1274        focus_before = self._frontmost_app()
1275        self._pointer_position = start
1276        self._overlay.move(*start, duration=0)
1277        self._overlay.move(*end, duration=duration)
1278        down_type, up_type, drag_type = _MOUSE_EVENTS[button]
1279        self._post(
1280            AS.CGEventCreateMouseEvent(
1281                self._event_source, down_type, start, _BUTTONS[button]
1282            ),
1283            pid,
1284        )
1285        self._pointer_position = end
1286        for index in range(1, max(1, steps) + 1):
1287            ratio = index / max(1, steps)
1288            point = (
1289                start[0] + (end[0] - start[0]) * ratio,
1290                start[1] + (end[1] - start[1]) * ratio,
1291            )
1292            self._post(
1293                AS.CGEventCreateMouseEvent(
1294                    self._event_source, drag_type, point, _BUTTONS[button]
1295                ),
1296                pid,
1297            )
1298            time.sleep(max(0.0, duration) / max(1, steps))
1299        self._post(
1300            AS.CGEventCreateMouseEvent(
1301                self._event_source, up_type, end, _BUTTONS[button]
1302            ),
1303            pid,
1304        )
1305        self._guard_focus(focus_before, pid, "drag")
1306
1307    def scroll(
1308        self,
1309        delta_y: int,
1310        delta_x: int = 0,
1311        *,
1312        app: str | None = None,
1313        unit: str = "pixel",
1314        x: float | None = None,
1315        y: float | None = None,
1316        coordinate_space: str = "screenshot",
1317    ) -> None:
1318        self._ensure_accessibility()
1319        self._ensure_post_events()
1320        units = {
1321            "pixel": AS.kCGScrollEventUnitPixel,
1322            "line": AS.kCGScrollEventUnitLine,
1323        }
1324        try:
1325            scroll_unit = units[unit]
1326        except KeyError as exc:
1327            raise MacOSError("Scroll unit must be 'pixel' or 'line'") from exc
1328        if (x is None) != (y is None):
1329            raise MacOSError("Provide both x and y when targeting a scroll point")
1330        point: tuple[float, float] | None = None
1331        if x is not None and y is not None:
1332            point = self._screen_point(x, y, coordinate_space)
1333            self._pointer_position = point
1334            self._overlay.move(*point)
1335
1336        pid = self._pid(app)
1337        if pid is None:
1338            raise MacOSError("Scroll input requires an app or prior app snapshot")
1339        focus_before = self._frontmost_app()
1340
1341        maximum = 100 if unit == "pixel" else 10
1342        y_steps = _split_scroll_delta(delta_y, maximum)
1343        x_steps = _split_scroll_delta(delta_x, maximum)
1344        count = max(len(y_steps), len(x_steps))
1345        y_steps.extend([0] * (count - len(y_steps)))
1346        x_steps.extend([0] * (count - len(x_steps)))
1347
1348        for step_y, step_x in zip(y_steps, x_steps, strict=True):
1349            event = AS.CGEventCreateScrollWheelEvent(
1350                self._event_source, scroll_unit, 2, int(step_y), int(step_x)
1351            )
1352            if point:
1353                AS.CGEventSetLocation(event, point)
1354            self._post(event, pid)
1355            time.sleep(0.01)
1356            self._guard_focus(focus_before, pid, "scroll")

▸type/key 保留真实键盘事件形态

文本输入 src/macos_harness/macos.py:1358 到 src/macos_harness/macos.py:1387 为每个字符设置 Unicode 字符串,组合键 src/macos_harness/macos.py:1388 到 src/macos_harness/macos.py:1445 则逐个按下 modifier 并反向释放。测试 tests/test_macos.py:218 到 tests/test_macos.py:251 覆盖 emoji 和 Shift 字符,证明它不是 ASCII-only 的简化输入。

src/macos_harness/macos.py · L1358-1445Python
1358    def type(self, text: str, *, app: str | None = None) -> None:
1359        self._ensure_accessibility()
1360        self._ensure_post_events()
1361        pid = self._pid(app)
1362        if pid is None:
1363            raise MacOSError("Keyboard input requires an app or prior app snapshot")
1364        focus_before = self._frontmost_app()
1365        aliases = {" ": "space", "\n": "return", "\r": "return", "\t": "tab"}
1366        for character in text:
1367            base = _SHIFTED_CHARACTERS.get(
1368                character, aliases.get(character, character.casefold())
1369            )
1370            flags = (
1371                AS.kCGEventFlagMaskShift
1372                if character.isupper() or character in _SHIFTED_CHARACTERS
1373                else 0
1374            )
1375            keycode = _KEYCODES.get(base, 0)
1376            down = AS.CGEventCreateKeyboardEvent(self._event_source, keycode, True)
1377            up = AS.CGEventCreateKeyboardEvent(self._event_source, keycode, False)
1378            length = len(character.encode("utf-16-le")) // 2
1379            AS.CGEventKeyboardSetUnicodeString(down, length, character)
1380            AS.CGEventKeyboardSetUnicodeString(up, length, character)
1381            AS.CGEventSetFlags(down, flags)
1382            AS.CGEventSetFlags(up, flags)
1383            self._post(down, pid)
1384            self._post(up, pid)
1385            time.sleep(0.01)
1386            self._guard_focus(focus_before, pid, "typing")
1387
1388    def key(self, key: str, *, app: str | None = None) -> None:
1389        self._ensure_accessibility()
1390        self._ensure_post_events()
1391        parts = [part.casefold() for part in re.split(r"[+-]", key) if part]
1392        if not parts:
1393            raise MacOSError("Key must not be empty")
1394        base = parts[-1]
1395        modifiers = parts[:-1]
1396        try:
1397            keycode = _KEYCODES[base]
1398        except KeyError as exc:
1399            raise MacOSError(
1400                f"Unsupported key {base!r}; use mac.type() for arbitrary text"
1401            ) from exc
1402        parsed_modifiers: list[tuple[int, int]] = []
1403        seen_keycodes: set[int] = set()
1404        for modifier in modifiers:
1405            try:
1406                modifier_keycode = _MODIFIER_KEYCODES[modifier]
1407                modifier_flag = _MODIFIER_FLAGS[modifier]
1408            except KeyError as exc:
1409                raise MacOSError(f"Unsupported modifier {modifier!r}") from exc
1410            if modifier_keycode not in seen_keycodes:
1411                seen_keycodes.add(modifier_keycode)
1412                parsed_modifiers.append((modifier_keycode, modifier_flag))
1413        pid = self._pid(app)
1414        if pid is None:
1415            raise MacOSError("Keyboard input requires an app or prior app snapshot")
1416        focus_before = self._frontmost_app()
1417        active_flags = 0
1418        pressed: list[tuple[int, int]] = []
1419        try:
1420            for modifier_keycode, modifier_flag in parsed_modifiers:
1421                active_flags |= modifier_flag
1422                event = AS.CGEventCreateKeyboardEvent(
1423                    self._event_source, modifier_keycode, True
1424                )
1425                AS.CGEventSetFlags(event, active_flags)
1426                self._post(event, pid)
1427                pressed.append((modifier_keycode, modifier_flag))
1428                time.sleep(0.005)
1429
1430            down = AS.CGEventCreateKeyboardEvent(self._event_source, keycode, True)
1431            up = AS.CGEventCreateKeyboardEvent(self._event_source, keycode, False)
1432            AS.CGEventSetFlags(down, active_flags)
1433            AS.CGEventSetFlags(up, active_flags)
1434            self._post(down, pid)
1435            self._post(up, pid)
1436        finally:
1437            for modifier_keycode, modifier_flag in reversed(pressed):
1438                active_flags &= ~modifier_flag
1439                event = AS.CGEventCreateKeyboardEvent(
1440                    self._event_source, modifier_keycode, False
1441                )
1442                AS.CGEventSetFlags(event, active_flags)
1443                self._post(event, pid)
1444        time.sleep(0.01)
1445        self._guard_focus(focus_before, pid, f"key {key!r}")
§ 02 · KEY FLOW

关键流程

click(x,y,app) → permissions → coordinate transform → pid → focus snapshot → CGEvent down/up → focus guard → virtual pointer click

测试把这些不变量固定下来:tests/test_macos.py:179 到 tests/test_macos.py:199 断言 background click 只投递到 PID 且不会 warp 光标;tests/test_macos.py:326 到 tests/test_macos.py:346 断言使用 private event source;tests/test_macos.py:289 到 tests/test_macos.py:323 断言目标变成 frontmost 时停止打字。

tests/test_macos.py · L179-215Python
179def test_background_click_posts_to_pid_without_warp_or_activate(monkeypatch) -> None:
180    mac = MacOS()
181    posted = []
182    monkeypatch.setattr(mac, "_ensure_accessibility", lambda: None)
183    monkeypatch.setattr(mac, "_ensure_post_events", lambda: None)
184    monkeypatch.setattr(mac, "_pid", lambda app: 42)
185    monkeypatch.setattr(mac, "_post", lambda event, pid: posted.append(pid))
186    monkeypatch.setattr(
187        macos_module.AS,
188        "CGWarpMouseCursorPosition",
189        lambda point: pytest.fail("background click must not warp the cursor"),
190    )
191
192    mac.click(
193        10,
194        20,
195        app="Slack",
196        coordinate_space="screen",
197    )
198
199    assert posted == [42, 42]
200
201
202def test_input_without_target_is_refused(monkeypatch) -> None:
203    mac = MacOS()
204    monkeypatch.setattr(mac, "_ensure_accessibility", lambda: None)
205    monkeypatch.setattr(mac, "_ensure_post_events", lambda: None)
206    monkeypatch.setattr(
207        macos_module.AS,
208        "AXUIElementCopyElementAtPosition",
209        lambda *args: pytest.fail("untargeted input must stop before AX hit testing"),
210    )
211
212    with pytest.raises(MacOSError, match="requires an app"):
213        mac.type("hello")
214    with pytest.raises(MacOSError, match="requires an app"):
215        mac.click(10, 20, coordinate_space="screen")
§ 03 · EDGE CASES

边界 / 失败模式

  • src/macos_harness/macos.py:1148 到 src/macos_harness/macos.py:1152 要求截图坐标必须来自最近 screenshot,否则调用方要显式使用 screen 坐标。
  • src/macos_harness/macos.py:1220 到 src/macos_harness/macos.py:1225 拒绝未知鼠标按钮和无目标 pointer input。
  • src/macos_harness/macos.py:1396 到 src/macos_harness/macos.py:1409 对不支持的 key 或 modifier 直接报错。
§ 04 · RELATED

关联模块

pointer.py
src/macos_harness/pointer.py:5 到 src/macos_harness/pointer.py:24 定义虚拟指针几何和 pressed 缩放。
overlay.py
src/macos_harness/overlay.py:280 到 src/macos_harness/overlay.py:297 把 move/show/hide/click/quit 转成 AppKit 面板状态。