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 → 两次下发 → 恢复真实定位 → 干净退出
This commit is contained in:
1 parent
85e9a53750
commit
4d55e2415f
26 files changed
+4515
-306
No files matched your search
@@ -0,0 +1,195 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user