docs: 新增 Windows 移植交接文档

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 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
jbjf6666 committed 2026-10-16 01:05:09 +08:00
commit 85e9a53750
65 files changed
+18603

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""真机端到端验证:连接 iOS 设备 → 建立隧道 → 沿轨迹播放虚拟定位 → 停止恢复真实定位。
这是**会真的改变设备定位**的测试(结束后会 clear_location 恢复真实定位)。
只有在你明确同意时才运行:
./.venv/bin/python tests/real_device_e2e.py --confirm
./.venv/bin/python tests/real_device_e2e.py --confirm --udid 00008030-...
./.venv/bin/python tests/real_device_e2e.py --confirm --duration 6 --speed 20
"""
from __future__ import annotations
import argparse
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import protocol, server as server_mod # noqa: E402
from selftest import WSClient # noqa: E402
PASS = 0
FAIL = 0
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--confirm", action="store_true", help="确认会真的改变设备定位")
parser.add_argument("--udid", default=None, help="指定设备 UDID(默认用发现的唯一 USB 设备)")
parser.add_argument("--port", type=int, default=8811)
parser.add_argument("--duration", type=float, default=6.0, help="播放时长(秒)")
parser.add_argument("--speed", type=float, default=20.0, help="播放倍速")
parser.add_argument("--keep", action="store_true", help="结束后不恢复真实定位(默认会恢复)")
args = parser.parse_args()
if not args.confirm:
print("这是真机测试,会临时改变 iPhone 的定位。请加 --confirm 运行。")
return 2
print("\033[1miOS 虚拟轨迹 — 真机端到端验证\033[0m")
app = server_mod.App(root=ROOT, mock=False, port=args.port)
devices = app.devices.refresh()
if not devices:
print(" \033[31m✗\033[0m 未发现设备")
return 1
print(" 发现的设备:")
for d in devices:
print(f" - {d.name} | iOS {d.ios_version} | {d.connection} | 开发者模式={d.developer_mode} | {d.udid}")
target = None
for d in devices:
if args.udid and d.udid != args.udid:
continue
if not args.udid and d.connection != "usb":
continue
target = d
break
target = target or devices[0]
print(f" 目标设备:{target.name}(iOS {target.ios_version})\n")
app.devices.select(target.udid)
httpd = server_mod.serve(app, host="127.0.0.1", port=args.port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
client = WSClient("127.0.0.1", args.port, timeout=120)
exit_code = 0
try:
client.send({"type": "hello", "id": "h1", "client": "e2e"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "h1", timeout=20)
check("连接服务端", ack is not None)
print("\n → 建立设备会话(iOS 17+ 会自动建 RSD 隧道,最长约 75 秒)…")
started = time.time()
client.send({"type": "device.connect", "id": "c1"})
connected = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=180)
took = time.time() - started
ok = bool(connected and connected.get("ok", True))
check(f"设备连接成功({took:.1f}s)", ok, str(connected)[:400])
if not ok:
print(f" 错误:{connected}")
return 1
probe = connected.get("probe") or {}
check("DVT 自检通过", bool(probe.get("ok")), str(probe)[:300])
print(f" 自检详情:{probe.get('detail')}")
print("\n → 单点定位测试…")
client.send({"type": "location.set", "id": "l1", "lat": 39.9088, "lng": 116.3975})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "l1", timeout=30)
check("单点定位下发", bool(ack and ack.get("ok", True)), str(ack)[:300])
location = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "manual", timeout=15)
check("收到单点 location 事件", location is not None, str(location)[:200])
print(f"\n → 轨迹播放测试({args.duration:.0f} 秒,倍速 {args.speed}x)…")
route = app.routes.build(
[[116.3975, 39.9088], [116.4050, 39.9120], [116.4120, 39.9080]],
mode="driving", use_osrm=False,
)
print(f" 轨迹:{route.distance / 1000:.2f} 公里 / {len(route.path)} 点 / 预计 {route.duration:.0f} 秒")
client.send({"type": "play.start", "id": "s1", "route": route.to_json(),
"speed_scale": args.speed, "loop": False})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=30)
check("播放启动", bool(ack and ack.get("ok", True)), str(ack)[:300])
first = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "play", timeout=20)
check("播放下发首个定位", first is not None, str(first)[:200])
progressed = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.1, timeout=30)
check("定位沿轨迹推进", progressed is not None, str(progressed)[:200])
if progressed:
print(f" 当前进度 {progressed.get('progress'):.1%} → {progressed.get('lat')}, {progressed.get('lng')}")
sent_before = None
with client.lock:
sent_before = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
time.sleep(args.duration)
with client.lock:
sent_after = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
expect = args.duration * float(app.config.get("send_hz") or 5) * 0.5
check(f"持续下发定位({sent_after - sent_before} 次 / {args.duration:.0f}s)",
(sent_after - sent_before) >= expect, f"期望 ≥{expect:.0f} 次")
client.send({"type": "play.pause", "id": "p1"})
paused = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is True, timeout=15)
check("暂停", paused is not None, str(paused)[:200])
client.send({"type": "play.resume", "id": "r1"})
resumed = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is False and m.get("playing") is True, timeout=15)
check("继续", resumed is not None, str(resumed)[:200])
if not args.keep:
print("\n → 停止并恢复真实定位…")
client.send({"type": "play.stop", "id": "st1"})
stopped = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is False, timeout=25)
check("停止播放", stopped is not None, str(stopped)[:200])
cleared = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "cleared", timeout=20)
check("已恢复设备真实定位", cleared is not None, str(cleared)[:200])
except Exception as exc:
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
FAIL += 1
finally:
try:
if not args.keep:
app.playback.stop(notify=False, clear_provider=True)
except Exception:
pass
client.close()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
print(f"\n{'=' * 50}\n通过 {PASS} 项,失败 {FAIL} 项")
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())