PID 输入与焦点守卫 深度解析
这一页拆 raw input 的不变量:必须有目标 PID,不能移动物理鼠标,不能把后台目标拉到前台,不能把 coordinate click 偷换成 AXPress。
导入链与契约
输入层依赖 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 并拒绝无目标输入。
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)
核心实现,逐段拆
▸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 再检查焦点是否被意外改变。
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 对过大滚轮事件处理不稳定。
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 的简化输入。
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}")
关键流程
测试把这些不变量固定下来: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 时停止打字。
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")
边界 / 失败模式
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 直接报错。
关联模块
src/macos_harness/pointer.py:5 到 src/macos_harness/pointer.py:24 定义虚拟指针几何和 pressed 缩放。src/macos_harness/overlay.py:280 到 src/macos_harness/overlay.py:297 把 move/show/hide/click/quit 转成 AppKit 面板状态。