Files
ios-location-track/tests/win_e2e.py
T
jbjf6666 4d55e2415f feat(windows): 新增 Windows 移植(免管理员隧道 + 自动装 Apple 驱动 + 环境自检)
按 docs/WINDOWS_PORT.md 完成 Windows 移植,并修正该文档中若干与实测不符之处。
macOS 构建链完整保留,单一代码库双平台构建。

核心:iOS 17+ RSD 隧道在 Windows 上换了实现方式
- macOS 借 Apple remoted(--native,内核可路由),Windows 改走 UserspaceRsdTunnel
  (纯 Python PyTCP 用户态协议栈)
- 实测:不需要管理员权限、不需要 Wintun 驱动(pytun_pmd3 自带 wintun.dll)、
  建立耗时约 0.3 秒
- 关键坑:用户态隧道的设备地址**只在本进程可达**。拿 (host, port) 另建 RSD 会得到
  OSError [WinError 1231] 网络位置不可达。必须把 aopen() 返回的 rsd 对象本身注入
  DVT 层(RealLocationProvider 早有 rsd= 注入口),因此 Windows 不能沿用
  「隧道助手子进程 + 解析 HOST PORT」那套架构
- 修正 WINDOWS_PORT.md 的错误:类名是 UserspaceRsdTunnel 不是 UserspaceTunnel;
  remote start-tunnel 没有 --userspace;Wintun 不是必需项

新增
- core/platform.py      平台抽象层(数据目录/浏览器探测/进程管理/提权/信号)
- core/apple_driver.py  Apple Mobile Device Support 检测与自动安装
                        (服务启动 → 独立 AMDS MSI → 完整 iTunes 包 → winget 逐级降级)
                        + 开发者镜像下载
- core/setup_window.py  启动时的「环境准备」窗口:逐项列出依赖,缺失项右侧可点安装,
                        必需项全部就绪后「进入程序」才可点击
- scripts/run.ps1, build.ps1, installer/iosloc.iss, installer/version_info.txt
- tools/make_ico.py(由已有 PNG 图标集生成多尺寸 .ico)
- tools/probe_tunnel_win.py(隧道可行性诊断)
- tests/win_e2e.py(7 项)、tests/win_app_e2e.py(9 项)真机验收
- docs/README-WINDOWS.md
- .gitattributes(避免 Windows 写出 CRLF 的 .sh 把 macOS 构建搞坏)

修改
- core/tunnel.py            Windows 走进程内 userspace 隧道;macOS 路径不变
- core/server.py            TunnelManager 注入 runner;provider_for 注入 rsd 对象;
                            资源目录与数据目录分离;日志落盘
- core/location_provider.py 平台化错误提示;新增保活(隧道静默断开会让设备
                            偷偷恢复真实定位,停止播放时每 15s 重发)
- main.py                   「环境准备」窗口、Apple 驱动后台检查、
                            --check-driver / --install-driver、跨平台浏览器与信号
- Location.spec             平台分支;Windows 用 .ico + uac_admin=True(启动即请求管理员)
                            + version_info.txt;单一 spec 不分叉
- tests/drift_sim.py / offline_tiles.py  去掉硬编码 /tmp,改用 tempfile.gettempdir()

打包
- exe 清单写入 requireAdministrator(用户要求:启动时直接请求管理员权限)
- 绿色版 dist\iOSLocationTrack\iOSLocationTrack.exe
- 安装包 dist\installer\iOSLocationTrack-1.0.0-setup.exe(Inno Setup)

实测(Windows 11 x64 / Python 3.12.14 / pymobiledevice3 11.31.0 / iPhone 11 iOS 26.6.1)
- 隧道→镜像→DVT→下发定位→恢复真实定位 全链路跑通,连接 0.8s
- selftest 83/83、drift_sim 40/40、offline_tiles 13/13
- win_e2e 7/7、win_app_e2e 9/9
- 打包版 exe:发现设备 → 0.3s 隧道 → DVT → 两次下发 → 恢复真实定位 → 干净退出
2026-10-16 01:41:59 +08:00

196 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Windows 真机端到端验收:userspace 隧道 → 开发者镜像 → DVT → 下发定位 → 恢复。
这是 docs/WINDOWS_PORT.md 第 7 节「真机验收」的自动化版本,也是 Windows 版
**唯一的可行性判定脚本**(第 3 节要求先验证隧道再写界面)。
用法(项目根目录):
.venv\\Scripts\\python.exe tests\\win_e2e.py --confirm
.venv\\Scripts\\python.exe tests\\win_e2e.py --confirm --lat 39.908 --lon 116.397 --hold 8
.venv\\Scripts\\python.exe tests\\win_e2e.py --confirm --rsd 10.7.0.1 51234 # 用已有隧道
不加 --confirm 只做只读检查(设备发现 + 隧道建立),不下发定位。
退出前一定会调用 clear_location(),让设备恢复真实定位。
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import time
import traceback
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
CHECKS: list[tuple[str, bool, str]] = []
def check(name: str, ok: bool, detail: str = "") -> bool:
CHECKS.append((name, ok, detail))
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {name}" + (f" — {detail}" if detail else ""), flush=True)
return ok
async def list_devices() -> list:
from pymobiledevice3.usbmux import list_devices
return await list_devices()
async def main_async(args: argparse.Namespace) -> int:
print("=" * 68)
print("Windows 真机端到端验收")
print("=" * 68)
# ---------- 1. 依赖 ----------
try:
import pymobiledevice3
version = getattr(pymobiledevice3, "__version__", "?")
check("pymobiledevice3 可导入", True, f"v{version}")
except Exception as exc:
check("pymobiledevice3 可导入", False, str(exc))
return 1
# ---------- 2. 设备发现 ----------
try:
devices = await list_devices()
except Exception as exc:
check("usbmux 设备发现", False, repr(exc))
return 1
check("usbmux 设备发现", len(devices) > 0,
f"{len(devices)} 台" + (f":{devices[0].serial}" if devices else "(请插线并信任)"))
if not devices:
return 1
udid = args.udid or devices[0].serial
# ---------- 3. 隧道 ----------
#
# 关键(Windows 与 macOS 的架构差异):
# Windows 的 userspace 隧道把设备地址放在**本进程的 PyTCP 用户态协议栈**里,
# 内核路由不到它(直接 TCP connect 会得到 WinError 1231 网络位置不可达)。
# pymobiledevice3 的做法是把一个 relay dialer 注入到它返回的 RemoteServiceDiscoveryService
# 对象上(`open_connection=dial_plane.dial`)。因此**必须复用这个 rsd 对象**,
# 不能拿 (host, port) 另建一个 RSD —— 后者用的是 stdlib dialer,必然失败。
# RealLocationProvider 已经预留了 `rsd=` 注入口(见 core/location_provider.py:242/395)。
tunnel = None
rsd = None
rsd_host = rsd_port = None
if args.rsd:
rsd_host, rsd_port = args.rsd[0], int(args.rsd[1])
check("隧道(手动指定)", True, f"{rsd_host}:{rsd_port}")
else:
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel
tunnel = UserspaceRsdTunnel(serial=udid, autopair=True,
remotepairing_fallback=True)
started = time.monotonic()
try:
rsd = await asyncio.wait_for(tunnel.aopen(), timeout=args.tunnel_timeout)
rsd_host, rsd_port = rsd.service.address
inproc = getattr(rsd, "is_in_process_tunnel", False)
check("userspace 隧道建立", True,
f"{rsd_host} {rsd_port}({time.monotonic()-started:.1f}s,"
f"进程内={inproc})")
except Exception as exc:
check("userspace 隧道建立", False, repr(exc))
traceback.print_exc()
return 1
# ---------- 4. 开发者镜像 ----------
from core.location_provider import RealLocationProvider
logs: list[str] = []
def on_log(level: str, msg: str) -> None:
logs.append(f"[{level}] {msg}")
print(f" · {msg}", flush=True)
provider = RealLocationProvider(
rsd=rsd,
rsd_host=rsd_host, rsd_port=rsd_port, udid=udid,
connect_timeout=args.connect_timeout, on_log=on_log,
)
exit_code = 0
connected = False
try:
print(" --- 挂载镜像并建立 DVT 会话 ---", flush=True)
info = await provider.connect(None)
connected = True
check("开发者镜像 + DVT 会话", True,
f"策略={info.get('strategy')} iOS={info.get('ios_version') or info.get('ios_major')}")
if not args.confirm:
print("\n (未加 --confirm:只验证到会话就绪,不下发定位)")
return 0
# ---------- 5. 下发定位 ----------
before = args.lat, args.lon
await provider.set_location(before[0], before[1])
check("下发虚拟定位", True, f"lat={before[0]:.6f} lon={before[1]:.6f}")
print(f" --- 保持 {args.hold:.0f} 秒(请观察手机定位是否移动)---", flush=True)
await asyncio.sleep(args.hold)
# ---------- 6. 再下发一个点,验证连接是长连接 ----------
if args.hold2 is not None:
await provider.set_location(before[0] + 0.001, before[1] + 0.001)
check("同一会话连续下发", True, "第二点成功(证明 DVT 长连接有效)")
await asyncio.sleep(1.0)
except Exception as exc:
check("真机定位流程", False, repr(exc))
traceback.print_exc()
exit_code = 1
finally:
if connected:
try:
await provider.clear_location()
check("恢复真实定位", True, "已调用 clear_location()")
except Exception as exc:
check("恢复真实定位", False, repr(exc))
exit_code = 1
try:
await provider.close()
except Exception:
pass
if tunnel is not None:
try:
await tunnel.aclose()
except Exception:
pass
print("\n" + "=" * 68)
failed = [c for c in CHECKS if not c[1]]
print(f"结果:{len(CHECKS)-len(failed)}/{len(CHECKS)} 项通过")
for name, _ok, detail in failed:
print(f" FAIL {name}: {detail}")
print("=" * 68)
return exit_code or (1 if failed else 0)
def main() -> int:
parser = argparse.ArgumentParser(description="Windows 真机端到端验收")
parser.add_argument("--confirm", action="store_true",
help="确认对真机下发虚拟定位(不加则只读到会话就绪)")
parser.add_argument("--lat", type=float, default=39.9087, help="纬度(默认 39.9087 天安门)")
parser.add_argument("--lon", type=float, default=116.3975, help="经度")
parser.add_argument("--hold", type=float, default=8.0, help="首个点保持秒数(默认 8)")
parser.add_argument("--hold2", action="store_true", help="额外下发第二个点验证长连接")
parser.add_argument("--udid", default=None)
parser.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), default=None)
parser.add_argument("--tunnel-timeout", type=float, default=90.0)
parser.add_argument("--connect-timeout", type=float, default=30.0)
args = parser.parse_args()
try:
return asyncio.run(main_async(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
raise SystemExit(main())