docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
225 lines
8.6 KiB
Python
225 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
"""列出 iOS 设备并打印 iOS 版本 / DVT 虚拟定位可用性。
|
||
|
||
用法(项目根目录下执行):
|
||
|
||
./.venv/bin/python tools/probe_device.py
|
||
./.venv/bin/python tools/probe_device.py --rsd 10.7.0.1 51234
|
||
./.venv/bin/python tools/probe_device.py --json
|
||
|
||
说明:
|
||
* 没有连接设备时打印中文提示并以退出码 2 结束,不会抛栈。
|
||
* ``--rsd HOST PORT`` 时会对 iOS 17+ 设备真正建立一次 RSD+DVT 会话做连通性验证
|
||
(不设置任何位置,验证后立即断开)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from core.location_provider import ( # noqa: E402
|
||
PMD3_MISSING_HINT,
|
||
RealLocationProvider,
|
||
pymobiledevice3_version,
|
||
pymobiledevice3_available,
|
||
)
|
||
|
||
#: iOS 17 起 DVT 服务只能通过 RSD 隧道访问
|
||
RSD_REQUIRED_IOS_MAJOR = 17
|
||
|
||
|
||
def _print(text: str = "") -> None:
|
||
print(text, flush=True)
|
||
|
||
|
||
async def _enumerate_devices(udid: str | None) -> list[dict]:
|
||
"""优先用 lead 的 core/device.py,不可用时退回直接查 usbmux。"""
|
||
devices: list[dict] = []
|
||
try: # lead 的 DeviceManager(可能尚未实现或签名不同,失败则退化)
|
||
from core.device import DeviceManager # type: ignore
|
||
|
||
for factory in (lambda: DeviceManager(), lambda: DeviceManager(mock=False)):
|
||
try:
|
||
manager = factory()
|
||
raw = manager.list_devices()
|
||
if asyncio.iscoroutine(raw):
|
||
raw = await raw
|
||
for item in raw or []:
|
||
devices.append(dict(item))
|
||
if devices:
|
||
return devices
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
# 退化路径:直接用 pymobiledevice3 的 usbmux
|
||
from pymobiledevice3 import usbmux
|
||
from pymobiledevice3.lockdown import create_using_usbmux
|
||
|
||
for mux_device in await usbmux.list_devices():
|
||
if udid and not mux_device.matches_udid(udid):
|
||
continue
|
||
entry: dict = {
|
||
"udid": mux_device.serial,
|
||
"connection": (mux_device.connection_type or "").lower(),
|
||
"name": None,
|
||
"product_type": None,
|
||
"ios_version": None,
|
||
"ios_major": 0,
|
||
"developer_mode": None,
|
||
"state": "connected",
|
||
"error": None,
|
||
}
|
||
try:
|
||
lockdown = await create_using_usbmux(serial=mux_device.serial, autopair=False)
|
||
except Exception as exc:
|
||
entry["state"] = "unpaired"
|
||
entry["error"] = f"{type(exc).__name__}: {exc}"
|
||
devices.append(entry)
|
||
continue
|
||
try:
|
||
entry["name"] = lockdown.all_values.get("DeviceName")
|
||
entry["product_type"] = lockdown.all_values.get("ProductType")
|
||
version = str(lockdown.product_version or "")
|
||
entry["ios_version"] = version
|
||
try:
|
||
entry["ios_major"] = int(version.split(".", 1)[0])
|
||
except (ValueError, IndexError):
|
||
entry["ios_major"] = 0
|
||
try:
|
||
entry["developer_mode"] = await lockdown.get_developer_mode_status()
|
||
except Exception:
|
||
entry["developer_mode"] = None
|
||
finally:
|
||
try:
|
||
await lockdown.close()
|
||
except Exception:
|
||
pass
|
||
devices.append(entry)
|
||
return devices
|
||
|
||
|
||
def _judge(device: dict, rsd: tuple | None) -> tuple[bool, str]:
|
||
"""返回 (DVT 是否可能可用, 中文说明)。"""
|
||
major = int(device.get("ios_major") or 0)
|
||
state = device.get("state")
|
||
if state == "unpaired":
|
||
return False, "未配对:请在 iPhone 上点击「信任此电脑」后重试"
|
||
if device.get("developer_mode") is False:
|
||
return False, "未开启开发者模式:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启)"
|
||
if major >= RSD_REQUIRED_IOS_MAJOR:
|
||
if rsd:
|
||
return True, f"iOS {major} ≥ 17:需要 RSD 隧道,已提供 {rsd[0]}:{rsd[1]}(待实测)"
|
||
return False, (
|
||
f"iOS {major} ≥ 17:必须提供 RSD 隧道(--rsd HOST PORT)。"
|
||
"免 root:pymobiledevice3 remote start-tunnel --native --script-mode;"
|
||
"需 root:sudo pymobiledevice3 remote start-tunnel --script-mode"
|
||
)
|
||
if major == 0:
|
||
return False, "无法识别 iOS 版本:设备可能未解锁或未配对"
|
||
return True, f"iOS {major} < 17:可经 usbmux/lockdown 直连 DVT(或老版 simulatelocation 服务)"
|
||
|
||
|
||
async def _probe_dvt(device: dict, rsd: tuple | None) -> dict:
|
||
"""真正建立一次会话验证 DVT 是否可用(不设置位置)。"""
|
||
provider = RealLocationProvider(
|
||
rsd_host=rsd[0] if rsd else None,
|
||
rsd_port=rsd[1] if rsd else None,
|
||
udid=device.get("udid"),
|
||
)
|
||
try:
|
||
info = await provider.connect(device)
|
||
except Exception as exc:
|
||
return {"ok": False, "detail": str(exc)}
|
||
try:
|
||
await provider.close()
|
||
except Exception:
|
||
pass
|
||
return {"ok": True, "detail": info.get("detail", "连接成功"), "strategy": info.get("strategy")}
|
||
|
||
|
||
async def _main_async(args: argparse.Namespace) -> int:
|
||
rsd = (args.rsd[0], int(args.rsd[1])) if args.rsd else None
|
||
|
||
if not pymobiledevice3_available():
|
||
_print(f"[错误] {PMD3_MISSING_HINT}")
|
||
return 1
|
||
_print(f"pymobiledevice3 版本:{pymobiledevice3_version()}")
|
||
_print(f"Python:{sys.version.split()[0]}")
|
||
if rsd:
|
||
_print(f"RSD 隧道:{rsd[0]}:{rsd[1]}")
|
||
_print("")
|
||
|
||
try:
|
||
devices = await _enumerate_devices(args.udid)
|
||
except Exception as exc:
|
||
_print(f"[错误] 枚举设备失败:{type(exc).__name__}: {exc}")
|
||
return 1
|
||
|
||
if not devices:
|
||
_print("未发现 iOS 设备。请:")
|
||
_print(" 1) 用 USB 数据线连接 iPhone(不是仅充电线);")
|
||
_print(" 2) 解锁手机并在弹窗中点击「信任此电脑」;")
|
||
_print(" 3) 若使用 Wi-Fi 连接,请确认与电脑在同一网络。")
|
||
return 2
|
||
|
||
results = []
|
||
for device in devices:
|
||
available, reason = _judge(device, rsd)
|
||
entry = {"device": device, "available": available, "reason": reason, "probe": None}
|
||
if args.probe and available:
|
||
entry["probe"] = await _probe_dvt(device, rsd)
|
||
entry["available"] = bool(entry["probe"].get("ok"))
|
||
results.append(entry)
|
||
|
||
if args.json:
|
||
_print(json.dumps(results, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
for index, entry in enumerate(results, 1):
|
||
device = entry["device"]
|
||
_print(f"=== 设备 {index} ===")
|
||
_print(f" UDID : {device.get('udid')}")
|
||
_print(f" 名称 : {device.get('name') or '(未知)'}")
|
||
_print(f" 型号 : {device.get('product_type') or '(未知)'}")
|
||
_print(f" iOS 版本 : {device.get('ios_version') or '(未知)'} (major={device.get('ios_major')})")
|
||
_print(f" 连接方式 : {device.get('connection') or '(未知)'}")
|
||
_print(f" 配对状态 : {device.get('state')}")
|
||
developer_mode = device.get("developer_mode")
|
||
_print(f" 开发者模式 : {'已开启' if developer_mode else ('未开启' if developer_mode is False else '未知')}")
|
||
if device.get("error"):
|
||
_print(f" 错误 : {device['error']}")
|
||
_print(f" DVT 可用性 : {'可能可用' if entry['available'] else '不可用'} —— {entry['reason']}")
|
||
if entry["probe"]:
|
||
probe = entry["probe"]
|
||
_print(f" 实测连接 : {'成功' if probe.get('ok') else '失败'} —— {probe.get('detail')}")
|
||
_print("")
|
||
|
||
_print("提示:iOS 17+ 必须先建立 RSD 隧道;不带 --rsd 时只做静态判断。")
|
||
_print(" 加 --probe --rsd HOST PORT 可实测建立一次 DVT 会话。")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="列出 iOS 设备并检查 DVT 虚拟定位可用性")
|
||
parser.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), help="RSD 隧道地址(iOS 17+ 必需)")
|
||
parser.add_argument("--udid", help="只检查指定 UDID")
|
||
parser.add_argument("--probe", action="store_true", help="真正建立一次 DVT 会话做连通性验证")
|
||
parser.add_argument("--json", action="store_true", help="以 JSON 输出")
|
||
args = parser.parse_args()
|
||
try:
|
||
return asyncio.run(_main_async(args))
|
||
except KeyboardInterrupt:
|
||
return 130
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|