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:
jbjf6666 committed 2026-10-16 01:41:59 +08:00
1 parent 85e9a53750
commit 4d55e2415f
26 files changed
+4515 -306

No files matched your search

+91 -12
View File
@@ -39,6 +39,8 @@ import logging
import time
from typing import Any, Callable, Optional
from . import platform as plat
__all__ = [
"RealLocationProvider",
"LocationProviderError",
@@ -48,22 +50,37 @@ __all__ = [
logger = logging.getLogger("iosloc.location.real")
#: 保活重发间隔(秒)。参考同类工具经验:<3s 没有额外收益,还容易在隧道里堆积消息。
KEEPALIVE_INTERVAL = 15.0
#: 当前平台的解释器路径 / 启动脚本(错误文案里直接给用户可粘贴的命令)
_PY = ".venv\\Scripts\\python.exe" if plat.IS_WINDOWS else "./.venv/bin/python"
_RUN = ".\\scripts\\run.ps1" if plat.IS_WINDOWS else "./run.sh"
#: 未安装 pymobiledevice3 时的中文修复建议(含本机实测可用的阿里云镜像)
PMD3_MISSING_HINT = (
"未检测到 pymobiledevice3 库。请在项目目录执行:"
"./.venv/bin/python -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3"
f"{_PY} -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3"
)
#: iOS 17 是「必须走 RSD 隧道」的分界线
RSD_REQUIRED_IOS_MAJOR = 17
#: 根目录 README/run.sh 的隧道提示(PROTOCOL.md 的 tunnel_hint 语义)
TUNNEL_HINT = (
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
"请以管理员权限运行 run.sh(sudo ./run.sh);"
"macOS 上也可用免 root 的隧道:"
"./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode"
)
#: 隧道提示(PROTOCOL.md 的 tunnel_hint 语义)。两个平台的建立方式不同,文案必须分开:
#: macOS 借 Apple remoted(--native,免 root);Windows 用 userspace 隧道(免管理员)。
if plat.IS_WINDOWS:
TUNNEL_HINT = (
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位。"
"本程序会自动建立 userspace 隧道(不需要管理员权限);若失败请重新运行 "
f"{_RUN} 并查看日志。也请确认已关闭爱思助手等会抢占 RSD 连接的工具。"
)
else:
TUNNEL_HINT = (
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
"请以管理员权限运行 run.sh(sudo ./run.sh);"
"macOS 上也可用免 root 的隧道:"
"./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode"
)
class LocationProviderError(RuntimeError):
@@ -174,14 +191,14 @@ def _wrap(action: str, exc: BaseException) -> LocationProviderError:
elif "developerdiskimage" in low or "developer disk image" in low or "mounter mount" in low:
detail = (
"设备未挂载开发者镜像(DeveloperDiskImage),DVT 服务不可用。"
"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount"
f"请执行:{_PY} -m pymobiledevice3 mounter auto-mount"
)
elif "developer mode" in low or "developermode" in low:
detail = "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(需重启设备)"
elif "invalidservice" in low or "no such service" in low or "startserviceerror" in low:
detail = (
"设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,请执行:"
"./.venv/bin/python -m pymobiledevice3 mounter auto-mount;"
f"{_PY} -m pymobiledevice3 mounter auto-mount;"
"iOS 17+ 则需要 RSD 隧道(隧道提示见下)"
)
elif "not paired" in low or "notpaired" in low or "pair" in low and "invalid" in low:
@@ -249,6 +266,10 @@ class RealLocationProvider:
self._session_info: dict = {}
self._last_location: Optional[tuple] = None
self._send_count = 0
#: 保活:最后一次成功下发的单调时间 + 后台任务
self._last_send_mono = 0.0
self._keepalive_task: Optional[asyncio.Task] = None
self._keepalive_count = 0
self._connected_at: Optional[float] = None
# pymobiledevice3 对象
@@ -325,8 +346,8 @@ class RealLocationProvider:
if state in ("unpaired",):
raise LocationProviderError(
"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
"./.venv/bin/python -m pymobiledevice3 lockdown pair)"
f"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
f"{_PY} -m pymobiledevice3 lockdown pair)"
)
if state in ("unavailable",):
raise LocationProviderError("设备当前不可用:请重新插拔 USB 线并解锁 iPhone 后重试")
@@ -353,6 +374,8 @@ class RealLocationProvider:
self._connected = True
self._connected_at = time.time()
# 会话建立后启动保活(见 _keepalive_loop 的说明)
self._start_keepalive()
self._session_info = {
"ok": True,
"mock": False,
@@ -534,9 +557,62 @@ class RealLocationProvider:
self._last_location = (lat, lon)
self._send_count += 1
self._last_send_mono = time.monotonic()
if self._send_count == 1 or self._send_count % 25 == 0:
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
# ------------------------------------------------------------------ 保活
async def _keepalive_loop(self) -> None:
"""定期重发最后一个点,防止"隧道静默断开后设备偷偷恢复真实定位"。
为什么需要:Windows 的 userspace 隧道是纯 Python 用户态协议栈,实测会**静默断开**
—— 断开后设备自己恢复真实定位,而界面看起来一切正常(同类工具
fake-location-cli 也独立踩到了这个坑,其 flc_set.py 里明确记录了这一点)。
播放时每秒都在下发,不会触发;主要是「设了点但暂停/静止」时的保险。
"""
try:
while self._connected:
await asyncio.sleep(KEEPALIVE_INTERVAL)
target = self._last_location
if target is None or not self._connected:
continue
# 播放引擎刚发过就不重复发,避免和播放线程抢通道
if time.monotonic() - self._last_send_mono < KEEPALIVE_INTERVAL * 0.8:
continue
try:
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
await self._location.set(target[0], target[1])
elif self._strategy == "legacy":
await self._legacy.set(target[0], target[1])
else:
continue
self._last_send_mono = time.monotonic()
self._keepalive_count += 1
self._log("debug",
f"保活重发 lat={target[0]:.6f}, lon={target[1]:.6f}"
f"(第 {self._keepalive_count} 次)")
except Exception as exc:
# 不抛出去:保活失败不应打断播放,但要留下痕迹供排查
self._log("warning", f"保活重发失败(隧道可能已断开):{exc}")
except asyncio.CancelledError:
return
def _start_keepalive(self) -> None:
if self._keepalive_task is not None and not self._keepalive_task.done():
return
try:
self._keepalive_task = asyncio.get_running_loop().create_task(
self._keepalive_loop(), name="location-keepalive"
)
except RuntimeError:
self._keepalive_task = None
def _stop_keepalive(self) -> None:
task = self._keepalive_task
self._keepalive_task = None
if task is not None and not task.done():
task.cancel()
async def clear_location(self) -> None:
"""让设备恢复真实定位(``play.stop`` / ``location.clear`` 语义)。"""
if not self._connected:
@@ -550,10 +626,13 @@ class RealLocationProvider:
except BaseException as exc:
raise _wrap("清除虚拟定位", exc) from exc
self._last_location = None
self._last_send_mono = 0.0
self._stop_keepalive()
self._log("info", "已清除虚拟定位,设备恢复真实定位")
async def close(self) -> None:
"""释放会话:先 clear(恢复真实定位)再关闭 DVT / RSD / lockdown。幂等。"""
self._stop_keepalive()
if not self._connected and self._dvt is None and self._rsd_obj is None and self._lockdown_obj is None:
return
try: