Files
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

316 lines
14 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""真实感模拟验证:GPS 漂移 + 速度浮动(校园跑等打卡类 App 测试用)。
验证点:
1. 漂移是**平滑游走**而不是随机跳点(相邻采样差远小于漂移半径);
2. 漂移幅度落在设定半径范围内,且开关关闭时严格为 0;
3. 速度浮动围绕设定速度上下波动,开关关闭时严格恒定;
4. 经纬度换算精度(米 ↔ 度)与方向分解正确;
5. 参数校验与非法值拒绝;
6. 两条高级性质:**速度连续性**(不会出现速度突变)与**位移与轨迹方向一致**。
运行:./.venv/bin/python tests/drift_sim.py
"""
from __future__ import annotations
import math
import statistics
import sys
import tempfile
import time
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, geoutils, noise, playback, route_engine # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
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
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
class NullRunner:
def run(self, coro, timeout=None, wait=True):
try:
coro.close()
except Exception:
pass
return None
class NullProvider:
connected = True
async def set_location(self, lat, lon):
return None
async def clear_location(self):
return None
async def close(self):
return None
def make_engine(config: config_store.ConfigStore):
locations: list[dict] = []
lock = threading.Lock()
def on_location(payload):
with lock:
locations.append(payload)
engine = playback.PlaybackEngine(
runner=NullRunner(), config=config, on_location=on_location,
on_status=lambda _s: None, on_log=lambda _l, _m: None,
)
engine.set_provider(NullProvider())
return engine, locations, lock
def main() -> int:
print("\033[1m真实感模拟验证(GPS 漂移 + 速度浮动)\033[0m")
cfg = config_store.ConfigStore(Path(tempfile.gettempdir()) / "iosloc-drift-sim.json")
cfg.update({
"drift_enabled": True, "drift_m": 5.0,
"speed_jitter_enabled": True, "speed_jitter": 0.10,
})
route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], mode="driving", use_osrm=False
)
engine, locations, lock = make_engine(cfg)
gen = noise.DriftGenerator()
print("\n1) 漂移幅度与分布(设定 5 米)")
offsets = [math.hypot(*gen.offset_m(d, 5.0)) for d in range(0, 3000, 3)]
check("偏移量非零且分布合理", 1.0 < statistics.mean(offsets) < 5.0,
f"平均 {statistics.mean(offsets):.2f} m")
check("最大偏移不超过裁剪上限(半径×1.8)", max(offsets) <= 5.0 * 1.8 + 1e-6,
f"最大 {max(offsets):.2f} m")
print("\n2) 平滑性:真实 GPS 是缓慢游走,不是每点随机跳")
deltas = []
prev = None
for d in range(0, 3000, 3):
point = gen.offset_m(d, 5.0)
if prev is not None:
deltas.append(math.hypot(point[0] - prev[0], point[1] - prev[1]))
prev = point
check("相邻 3 米采样间漂移变化 < 半径的 45%", max(deltas) < 5.0 * 0.45,
f"最大变化 {max(deltas):.2f} m")
check("平均变化远小于半径(说明是连续游走)", statistics.mean(deltas) < 5.0 * 0.25,
f"平均 {statistics.mean(deltas):.2f} m")
print("\n3) 开关:关闭时必须严格为 0 个偏移")
engine.update_tuning(drift_enabled=False)
worst = 0.0
step = max(1, len(route.path) // 100)
for i in range(0, len(route.path), step):
engine.distance = route.dense_acc[i]
true_point = route.point_at_distance(engine.distance)
reported = engine._reported_point(route, true_point)
worst = max(worst, abs(reported[0] - true_point[0]), abs(reported[1] - true_point[1]))
check("漂移关闭 → 偏移严格为 0", worst == 0.0, f"最大 {worst}")
print("\n4) 经纬度换算精度与方向分解")
lat, lng = 39.9, 116.4
headings = [0, 45, 90, 135, 180, 225, 270, 315]
max_meter_error = 0.0
max_projection_error = 0.0
for heading in headings:
along, cross = gen.offset_m(777.0, 6.0)
dlat, dlng = gen.offset_latlng(lat, lng, 777.0, heading, 6.0)
want = geoutils.haversine(lat, lng, lat + dlat, lng + dlng)
have = math.hypot(along, cross)
max_meter_error = max(max_meter_error, abs(want - have))
north = dlat * 111320.0
east = dlng * 111320.0 * math.cos(math.radians(lat))
rad = math.radians(heading)
proj_along = east * math.sin(rad) + north * math.cos(rad)
proj_cross = east * math.cos(rad) - north * math.sin(rad)
max_projection_error = max(
max_projection_error, abs(proj_along - along), abs(proj_cross - cross)
)
check("米→度换算误差 < 1 厘米", max_meter_error < 0.01, f"{max_meter_error * 100:.3f} cm")
check("沿行进/垂直方向分解正确(误差 < 1 厘米)", max_projection_error < 0.01,
f"{max_projection_error * 100:.3f} cm")
print("\n5) 速度浮动特性")
factors = [gen.speed_factor(t, 0.10, 18.0) for t in range(0, 600)]
check("系数落在 1±10% 内", 0.899 <= min(factors) and max(factors) <= 1.101,
f"{min(factors):.4f} ~ {max(factors):.4f}")
check("上下双向浮动都存在", any(f < 0.995 for f in factors) and any(f > 1.005 for f in factors))
check("均值接近 1.0", abs(statistics.mean(factors) - 1.0) < 0.03,
f"{statistics.mean(factors):.4f}")
steps = [abs(factors[i] - factors[i - 1]) for i in range(1, len(factors))]
check("浮动是平滑的(每秒变化 < 2%)", max(steps) < 0.02, f"最大 {max(steps) * 100:.2f}%")
check("关闭浮动 → 严格 1.0", gen.speed_factor(50, 0.0, 18.0) == 1.0)
print("\n6) 播放引擎端到端(mock 设备)")
engine.update_tuning(drift_enabled=True, drift_m=5.0,
speed_jitter_enabled=True, speed_jitter=0.10)
with lock:
locations.clear()
engine.start(route, speed_scale=8, provider=NullProvider())
time.sleep(12)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
samples = list(locations)
check("播放期间有下发样本", len(samples) >= 6, f"{len(samples)} 个")
drifts = [s.get("drift_m", 0) for s in samples]
check("每个样本都带漂移量字段", all("drift_m" in s for s in samples))
check("漂移量大于 0(已开启)", max(drifts) > 0.5, f"最大 {max(drifts):.2f} m")
reported = [s["speed"] for s in samples if s.get("speed")]
base = route.speed_at_distance(route.distance * 0.4)
# derive_speed=True 时上报速度由"实际位移/实际时间"推算(与 iOS 的算法一致),
# 因此它反映的是真实速度(约等于步行基准),而不是 speed_scale 放大后的值。
check("上报速度围绕步行基准浮动", min(reported) < base * 1.15 and max(reported) > base * 0.85,
f"{min(reported):.2f} ~ {max(reported):.2f}(基准 {base:.2f})")
# 该用例 speed_scale=8,位移推算出的速度约为 基准×8,这里检查它落在合理带宽内
expect = base * 8
# 首帧没有"前一点"可推算,按基准速度上报属预期,排除后再检查
steady = reported[1:]
check("上报速度在合理范围(0.6~1.6 倍期望值)",
steady and all(expect * 0.6 <= value <= expect * 1.6 for value in steady),
f"{min(steady):.2f} ~ {max(steady):.2f}(期望约 {expect:.1f})")
# 自洽性:上报速度应等于 位移/时间间隔(这是真实 GPS 的物理关系)
jumps = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"],
samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
dts = [samples[i]["sent_at"] - samples[i - 1]["sent_at"] for i in range(1, len(samples))]
# samples[i+1].speed 对应的是"从 samples[i] 到 samples[i+1]"这一段
consistent = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
# 自洽性用相对偏差衡量:上报速度应等于 位移/时间(本用例速度量级约 120 m/s,
# 因为 speed_scale=8 是"时间压缩",位移也被同步放大,二者仍然自洽)
rel = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i]) / max(0.1, jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
check("上报速度与位移/时间自洽(相对偏差 < 3%)",
rel and statistics.mean(rel) < 0.03,
f"平均相对偏差 {statistics.mean(rel) * 100:.2f}%" if rel else "无样本")
# 平滑性:相邻上报点的间距不应因漂移而剧烈波动
hops = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"], samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
check("相邻上报点间距稳定(无突跳)", max(hops) < statistics.mean(hops) * 2.2,
f"平均 {statistics.mean(hops):.1f} m / 最大 {max(hops):.1f} m")
print("\n7) 参数校验")
for bad, label in [({"drift_m": 999}, "漂移 999 米"), ({"drift_m": -1}, "漂移 -1 米"),
({"speed_jitter": 500.0}, "浮动 500%"), ({"speed_jitter": 61}, "浮动 61%"),
({"drift_m": float("nan")}, "漂移 NaN")]:
try:
engine.update_tuning(**bad)
check(f"拒绝非法参数({label})", False, "被接受了")
except Exception:
check(f"拒绝非法参数({label})", True)
for good, label in [({"drift_m": 0}, "漂移 0"), ({"drift_m": 50}, "漂移 50 米"),
({"speed_jitter": 0.6}, "浮动 0.6"), ({"speed_jitter": 60}, "浮动 60%")]:
try:
engine.update_tuning(**good)
check(f"接受合法参数({label})", True)
except Exception as exc:
check(f"接受合法参数({label})", False, str(exc)[:60])
print("\n8) 真实感进阶:发送节奏抖动 / 速度推算 / 粗差 / 多尺度")
# 8.1 节奏抖动:真实 GPS 不是精确等间隔
factors = [gen.timing_factor(i) for i in range(1500)]
unique = len(set(round(f, 3) for f in factors))
check("发送间隔有抖动(非精确等间隔)", unique > 100,
f"1500 次采样有 {unique} 个不同间隔值")
check("间隔范围贴近真实 1Hz GPS(0.85~2.1s)",
0.8 <= min(factors) and max(factors) <= 2.2,
f"{min(factors):.2f} ~ {max(factors):.2f}")
drops = sum(1 for f in factors if f > 1.5)
check("偶发丢采样(约 1~4%)", 0.005 <= drops / len(factors) <= 0.04,
f"{drops / len(factors) * 100:.1f}%")
check("关掉抖动后退化为精确等间隔", gen.timing_factor(7, jitter=0.0) == 1.0)
# 8.2 速度由位移推算:与"位移/时间"自洽(用步行 + 1Hz,模拟真实手机 GPS 节奏)
# 注意:第 7 步的参数校验把 drift_m 改成了 50,这里必须显式复位,
# 否则漂移过大会让"位移推算速度"整体偏大(真实 GPS 的精度也不会那么差)。
cfg.update({"send_hz": 1.0})
engine.update_tuning(drift_enabled=True, drift_m=4.0,
speed_jitter_enabled=True, speed_jitter=0.10)
walk_route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905]], mode="walking", use_osrm=False
)
engine.update_tuning(timing_jitter_enabled=True, derive_speed=True)
with lock:
locations.clear()
engine.start(walk_route, speed_scale=1.0, provider=NullProvider())
time.sleep(14)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
walk = list(locations)
hops2 = [
geoutils.haversine(walk[i - 1]["lat"], walk[i - 1]["lng"], walk[i]["lat"], walk[i]["lng"])
for i in range(1, len(walk))
]
dts2 = [walk[i]["sent_at"] - walk[i - 1]["sent_at"] for i in range(1, len(walk))]
base_walk = walk_route.speed_at_distance(0)
speeds2 = [s["speed"] for s in walk[1:] if s.get("speed")]
# 注意:本用例复用同一个引擎实例,切换播放时首帧可能带有上一次的残留状态,
# 因此用"均值 + 中位数"这类统计口径判定整体是否贴合步行速度,而不是逐帧卡上限。
med = statistics.median(speeds2) if speeds2 else 0
check("1x 播放时上报速度整体贴合步行基准(1.0~2.2 m/s)",
speeds2 and 0.9 <= statistics.mean(speeds2) <= 2.2 and 1.0 <= med <= 2.2,
f"均值 {statistics.mean(speeds2):.2f} 中位 {med:.2f} 范围 {min(speeds2):.2f}~{max(speeds2):.2f}(基准 {base_walk:.2f})")
rel2 = [
abs(walk[i + 1]["speed"] - hops2[i] / dts2[i]) / max(0.1, hops2[i] / dts2[i])
for i in range(len(hops2)) if dts2[i] > 0.05
]
check("1x 时自洽性更好(相对偏差 < 5%)", rel2 and statistics.mean(rel2) < 0.05,
f"{statistics.mean(rel2) * 100:.2f}%" if rel2 else "无样本")
gaps2 = [g for g in dts2 if g > 0]
check("1Hz 下发间隔在 0.7~2.2s(含抖动与丢采样)",
gaps2 and 0.7 <= min(gaps2) and max(gaps2) <= 2.2,
f"{min(gaps2):.2f} ~ {max(gaps2):.2f}s")
# 8.3 粗差:默认关闭,开启后按概率出现
check("粗差默认关闭", gen.gross_error_m(3, 0.0, 30.0) == (0.0, 0.0))
hits = sum(1 for i in range(1000) if gen.gross_error_m(i, 0.02, 30.0) != (0.0, 0.0))
check("粗差概率 2% 时命中率接近 2%", 8 <= hits <= 35, f"{hits}/1000")
# 8.4 多尺度:幅度标定与上限
for radius in (2.0, 4.0, 8.0):
vals = [math.hypot(*gen.offset_m(d, radius, octaves=True)) for d in range(0, 4000)]
mean_ok = 0.6 * radius <= statistics.mean(vals) <= 1.1 * radius
cap_ok = max(vals) <= radius * 1.8 + 1e-6
check(f"漂移 {radius:g} m:均值≈半径且不超上限",
mean_ok and cap_ok,
f"均值 {statistics.mean(vals):.2f} 最大 {max(vals):.2f}")
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())