按 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 → 两次下发 → 恢复真实定位 → 干净退出
112 lines
4.1 KiB
Python
112 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
||
"""瓦片离线可用验证:回源成功后断网,验证缓存仍能供图。
|
||
|
||
做法:用真实的 core.tilecache.TileCache,先把 urllib 换成"永远失败"的实现(模拟断网),
|
||
再请求已经缓存过的瓦片,断言仍然返回 200 + 合法图片;同时断言未缓存过的瓦片会优雅降级
|
||
(返回占位图 + status != 200,由上层回 404 让前端走重试)。
|
||
|
||
运行:./.venv/bin/python tests/offline_tiles.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from core import config_store, tilecache # 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}")
|
||
|
||
|
||
def main() -> int:
|
||
print("\033[1m瓦片本地缓存 / 离线可用验证\033[0m")
|
||
cache_dir = Path(tempfile.gettempdir()) / "iosloc-offline-tiles"
|
||
import shutil
|
||
|
||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||
|
||
source_key = "amap"
|
||
source = config_store.DEFAULT_TILE_SOURCES[source_key]
|
||
# 北京天安门附近、z12 —— 本机实测可达
|
||
z, x, y = 12, 3367, 1580
|
||
|
||
cache = tilecache.TileCache(cache_dir, max_mb=64)
|
||
|
||
print("\n1) 联网回源并落盘")
|
||
online = cache.get_tile(source_key, source, z, x, y)
|
||
check("回源返回真实图片", online.status == 200 and online.body[:4] in (b"\x89PNG", b"\xff\xd8"),
|
||
f"status={online.status} source={online.source}")
|
||
check("标记为 network", online.source == "network", online.source)
|
||
check("已写入磁盘缓存", any(cache_dir.rglob("*")), "缓存目录为空")
|
||
|
||
print("\n2) 断网(把 urllib 换成永远失败)后请求同一瓦片")
|
||
real_urlopen = urllib.request.urlopen
|
||
|
||
def offline(*_args, **_kwargs):
|
||
raise OSError("模拟断网:Network is unreachable")
|
||
|
||
urllib.request.urlopen = offline
|
||
try:
|
||
cached = cache.get_tile(source_key, source, z, x, y)
|
||
check("离线仍返回 200", cached.status == 200, f"status={cached.status}")
|
||
check("来源为 cache", cached.source == "cache", cached.source)
|
||
check("图片内容与联网时一致", cached.body == online.body,
|
||
f"{len(cached.body)}B vs {len(online.body)}B")
|
||
|
||
print("\n3) 断网 + 未缓存瓦片 → 优雅降级(不抛异常)")
|
||
t0 = time.time()
|
||
miss = cache.get_tile(source_key, source, z, x, y + 1)
|
||
elapsed = time.time() - t0
|
||
check("未命中不抛异常", True, "")
|
||
check("返回占位图且 status!=200(上层回 404 让前端重试)",
|
||
miss.status != 200 and miss.body[:4] == b"\x89PNG",
|
||
f"status={miss.status} source={miss.source}")
|
||
check("失败快速返回(<5s)", elapsed < 5.0, f"{elapsed:.2f}s")
|
||
|
||
print("\n4) 缓存统计")
|
||
stats = cache.stats
|
||
check("命中计数增加", stats["hit"] >= 1, str(stats))
|
||
check("统计到离线失败", stats["failed"] >= 1, str(stats))
|
||
finally:
|
||
urllib.request.urlopen = real_urlopen
|
||
|
||
print("\n5) 恢复网络后仍可正常回源")
|
||
cache2 = tilecache.TileCache(cache_dir, max_mb=64)
|
||
other = cache2.get_tile(source_key, source, z, x + 1, y)
|
||
check("恢复后回源成功", other.status == 200 and other.body[:4] in (b"\x89PNG", b"\xff\xd8"),
|
||
f"status={other.status} source={other.source}")
|
||
|
||
print("\n6) 容量上限淘汰不破坏可用性")
|
||
small = tilecache.TileCache(cache_dir, max_mb=1)
|
||
small._sweep()
|
||
check("淘汰后仍能读取", small.get_tile(source_key, source, z, x, y).status in (200, 404))
|
||
|
||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||
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())
|