按 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 → 两次下发 → 恢复真实定位 → 干净退出
132 lines
5.2 KiB
Python
132 lines
5.2 KiB
Python
"""Windows 应用层真机验收:走 App 的完整代码路径(隧道→注入 rsd→DVT→下发→恢复)。
|
||
|
||
与 tests/win_e2e.py 的区别:
|
||
win_e2e.py 直接调 pymobiledevice3 + RealLocationProvider,验证平台可行性
|
||
本脚本 走 core/server.py:App 的真实装配路径(TunnelManager + rsd 对象注入),
|
||
也就是界面点「连接设备」时实际执行的代码。
|
||
|
||
用法(项目根目录):
|
||
.venv\\Scripts\\python.exe tests\\win_app_e2e.py --confirm
|
||
.venv\\Scripts\\python.exe tests\\win_app_e2e.py --confirm --hold 8
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
import time
|
||
import traceback
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from core import platform as plat # noqa: E402
|
||
from core import server as server_mod # noqa: E402
|
||
|
||
CHECKS: list[tuple[str, bool, str]] = []
|
||
|
||
|
||
def check(name: str, ok: bool, detail: str = "") -> bool:
|
||
CHECKS.append((name, ok, detail))
|
||
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""),
|
||
flush=True)
|
||
return ok
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--confirm", action="store_true", help="确认对真机下发定位")
|
||
parser.add_argument("--lat", type=float, default=39.9087)
|
||
parser.add_argument("--lon", type=float, default=116.3975)
|
||
parser.add_argument("--hold", type=float, default=6.0)
|
||
parser.add_argument("--udid", default=None)
|
||
args = parser.parse_args()
|
||
|
||
print("=" * 68)
|
||
print("Windows 应用层真机验收(走 App 装配路径)")
|
||
print("=" * 68)
|
||
print(f" 平台 {plat.platform_label()} | 管理员 {'是' if plat.is_admin() else '否'}")
|
||
|
||
data_root = plat.data_dir() / "_e2e"
|
||
data_root.mkdir(parents=True, exist_ok=True)
|
||
app = server_mod.App(root=data_root, mock=False, port=0, resources=ROOT)
|
||
|
||
exit_code = 0
|
||
try:
|
||
# ---------- 1. 设备发现 ----------
|
||
app.devices.refresh()
|
||
devices = app.devices.list_devices()
|
||
check("设备发现", len(devices) > 0, f"{len(devices)} 台")
|
||
if not devices:
|
||
return 1
|
||
device = next((d for d in devices if d.udid == args.udid), devices[0])
|
||
check("选中设备", True, f"{device.name} / iOS {device.ios_version} / {device.udid}")
|
||
|
||
# ---------- 2. 走 App 的连接路径(含隧道建立 + rsd 注入)----------
|
||
started = time.monotonic()
|
||
try:
|
||
provider = app.provider_for(device, connect=True)
|
||
except Exception as exc:
|
||
check("App 连接设备", False, repr(exc))
|
||
traceback.print_exc()
|
||
return 1
|
||
elapsed = time.monotonic() - started
|
||
snapshot = app.tunnel.snapshot()
|
||
check("App 连接设备(隧道+DVT)", True,
|
||
f"{elapsed:.1f}s | 隧道 mode={snapshot.get('mode')} "
|
||
f"in_process={snapshot.get('in_process')} | 策略={provider._strategy}")
|
||
check("隧道为进程内 userspace",
|
||
snapshot.get("mode") == "userspace" and snapshot.get("in_process") is True,
|
||
f"mode={snapshot.get('mode')} in_process={snapshot.get('in_process')}")
|
||
check("provider 复用了隧道返回的 rsd 对象",
|
||
provider._injected_rsd is not None
|
||
and provider._rsd_obj is provider._injected_rsd,
|
||
"同一个对象,未另行 TCP 连接")
|
||
|
||
if not args.confirm:
|
||
print("\n (未加 --confirm:只验证到会话就绪)")
|
||
return 0
|
||
|
||
# ---------- 3. 下发定位(走 playback 用的同一条路径)----------
|
||
app.runner.run(provider.set_location(args.lat, args.lon), timeout=20.0)
|
||
check("下发虚拟定位", True, f"lat={args.lat} lon={args.lon}")
|
||
print(f" --- 保持 {args.hold:.0f} 秒 ---", flush=True)
|
||
time.sleep(args.hold)
|
||
|
||
# ---------- 4. 同会话第二点 ----------
|
||
app.runner.run(provider.set_location(args.lat + 0.0009, args.lon + 0.0009),
|
||
timeout=20.0)
|
||
check("同一会话连续下发", True, "DVT 长连接有效")
|
||
|
||
except Exception as exc:
|
||
check("应用层真机流程", False, repr(exc))
|
||
traceback.print_exc()
|
||
exit_code = 1
|
||
finally:
|
||
try:
|
||
if app.provider is not None:
|
||
app.runner.run(app.provider.clear_location(), timeout=20.0)
|
||
check("恢复真实定位", True, "已调用 clear_location()")
|
||
except Exception as exc:
|
||
check("恢复真实定位", False, repr(exc))
|
||
exit_code = 1
|
||
try:
|
||
app.shutdown()
|
||
check("App 关闭(隧道已释放)", not app.tunnel.ready,
|
||
f"隧道 state={app.tunnel.state}")
|
||
except Exception as exc:
|
||
check("App 关闭", False, repr(exc))
|
||
exit_code = 1
|
||
|
||
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)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|