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

+177 -90
View File
@@ -1,20 +1,29 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller 打包配置:把整个应用(Python 运行时 + 依赖 + 前端资源)打成一个可独立运行的 .app。
"""PyInstaller 打包配置(macOS / Windows 双平台)。
用法:./.venv/bin/python -m PyInstaller --clean --noconfirm Location.spec
用法:
macOS : ./.venv/bin/python -m PyInstaller --clean --noconfirm Location.spec
Windows: .venv\\Scripts\\python.exe -m PyInstaller --clean --noconfirm Location.spec
设计要点:
- **onedir**(不是 onefile):启动更快,且 .app 内部结构可被 macOS 正常识别;
真要单文件也可以,但 onefile 每次启动都要解包到 /tmp,反而更慢。
- **onedir**(不是 onefile):启动更快,onefile 每次启动都要解包到临时目录,反而更慢。
- 前端资源(web/)与图标打进包内,运行时通过 sys._MEIPASS 定位。
- 排除 pymobiledevice3 带进来但本工具用不到的重型依赖(IPython / fastapi / uvicorn 等),
- 排除 pymobiledevice3 带进来但本工具用不到的重型依赖(IPython / pytest 等),
它们只被 pymobiledevice3 的 CLI 交互模式使用;本工具只用 Python API。
排除后体积从 ~190MB 降到 ~60MB 量级。
- **Windows 专属**:`uac_admin=True` —— 在 exe 清单里写入 requireAdministrator,
启动时直接弹 UAC 提权。这样首次运行就能自动安装 Apple Mobile Device Support,
不需要用户再手动"以管理员身份运行"。
(注:userspace 隧道本身**不需要**管理员权限,提权只是为了装驱动。)
注意:Windows 与 macOS 的差异都集中在文件末尾的 `if IS_WINDOWS:` 分支里,
保持单一 spec,避免两份配置漂移。
"""
import sys
from pathlib import Path
from PyInstaller.utils.hooks import copy_metadata
from PyInstaller.utils.hooks import collect_data_files, copy_metadata
IS_WINDOWS = sys.platform == "win32"
ROOT = Path(SPECPATH).resolve()
# 有些包在运行期用 importlib.metadata 读自己的版本(例如 apple_compress 的 __version__,
@@ -23,6 +32,8 @@ ROOT = Path(SPECPATH).resolve()
METADATA_PACKAGES = [
"apple_compress", "pymobiledevice3", "pyimg4", "construct",
"cryptography", "bpylist2", "ipsw_parser", "opack2",
# Windows 用户态隧道依赖链
"pytun_pmd3", "pmd_pytcp", "pmd_net_addr", "pmd_net_proto",
]
_metadata_datas: list = []
@@ -32,49 +43,89 @@ for _pkg in METADATA_PACKAGES:
except Exception:
pass
# pymobiledevice3 的 CLI 会拉起这些重型依赖,但本工具只用它的 Python API,全部排除
# 策略:**尽量完整保留**,只排除"构建期工具"和"确定与运行无关的开发工具"。
# 依据(实测):questionary / prompt_toolkit / pygments 被 pymobiledevice3 运行期导入,
# 曾因误排导致打包版真机不可用;因此与 pymobiledevice3 相关的一切都保留。
# pip / setuptools / PyInstaller 只在构建期使用,运行 .app 时完全用不到。
# pytun_pmd3 自带各架构的 wintun.dll(数据文件,PyInstaller 不会自动收集)。
# Windows 的 userspace 隧道走纯 Python PyTCP 栈,通常不需要它;但某些路径会用
# pytun_pmd3.TunTapDevice,带上以免运行期缺 dll。整包只有 ~1.5MB。
_extra_datas: list = []
try:
_extra_datas += collect_data_files("pytun_pmd3", includes=["wintun/**"])
except Exception:
pass
# 只排除"构建期工具"和"确定与运行无关的开发工具"。
# 依据(macOS 侧实测):questionary / prompt_toolkit / pygments 被 pymobiledevice3
# 运行期导入,曾因误排导致打包版真机不可用;因此与 pymobiledevice3 相关的一律保留。
EXCLUDES = [
# 构建期工具(打包产物里绝不需要)
# 注意:不要排除 distutils / pkg_resources —— PyInstaller 的 hook 会强制导入它们,
# 排除后构建直接失败(ValueError: Target module "distutils" already imported as ExcludedModule)
"pip", "wheel",
"PyInstaller", "pyinstaller", "_pyinstaller_hooks_contrib",
# 注意:**不要**排除 distutils / pkg_resources / setuptools / wheel / pip
# —— PyInstaller 与 setuptools 的 hook 会强制导入/别名它们,排除后构建直接失败:
# ValueError: Target module "distutils" already imported as ExcludedModule('distutils',)
# ValueError: Target module "wheel" already imported as ExcludedModule('wheel',)
# (两个坑都在本项目上实测复现过;wheel 那条在 Windows + setuptools 84 上触发。)
# 它们体积很小,排除掉的收益远小于构建失败的风险。
#
# 开发/测试工具(pymobiledevice3 的交互式 CLI 才会用)
"IPython", "ipython", "ipykernel", "jupyter", "jupyter_client", "jupyter_core",
"ipywidgets", "notebook", "nbformat", "nbconvert",
"pytest", "_pytest", "matplotlib", "matplotlib_inline", "tkinter",
"pytest", "_pytest", "matplotlib", "matplotlib_inline",
# 注意:**不要**排除 tkinter —— 「环境准备」窗口用它实现(标准库,随 exe 打包)。
]
# 运行期动态 import 的模块,PyInstaller 静态分析看不到
HIDDENIMPORTS = [
"pymobiledevice3.remote.remote_service_discovery",
"pymobiledevice3.remote.tunnel_service",
"pymobiledevice3.services.dvt.instruments.dvt_provider",
"pymobiledevice3.services.dvt.instruments.location_simulation",
"pymobiledevice3.lockdown",
"pymobiledevice3.usbmux",
"pymobiledevice3.service_connection",
"pymobiledevice3.exceptions",
"pymobiledevice3.services.mobile_image_mounter",
"developer_disk_image.repo",
"core.apple_driver",
"core.platform",
"core.setup_window",
"tkinter",
"tkinter.ttk",
"tkinter.font",
]
if IS_WINDOWS:
HIDDENIMPORTS += [
# Windows 走 in-process userspace 隧道(不是 macOS 的 native_tunnel)
"pymobiledevice3.remote.userspace_tunnel",
"pytun_pmd3",
"pytun_pmd3.wintun",
"pmd_pytcp",
"pmd_net_addr",
"pmd_net_proto",
"winreg",
"ctypes.wintypes",
]
else:
HIDDENIMPORTS += ["pymobiledevice3.remote.native_tunnel"]
# 打进包内的资源
_datas = _metadata_datas + _extra_datas + [
("web", "web"), # 前端(index.html / style.css / js/)
# 开发者镜像素材(iOS 17+ 全系共用的个性化镜像;Image.dmg 约 15MB)
("resources/ddi", "resources/ddi"),
("docs/PROTOCOL.md", "docs"),
("README.md", "."),
]
if IS_WINDOWS:
_datas += [
("resources/AppIcon.ico", "resources"),
("docs/README-WINDOWS.md", "docs") if (ROOT / "docs" / "README-WINDOWS.md").exists() else ("README.md", "docs"),
]
else:
_datas += [("resources/AppIcon.icns", "resources")]
a = Analysis(
["main.py"],
pathex=[str(ROOT)],
binaries=[],
datas=_metadata_datas + [
("web", "web"), # 前端(index.html / style.css / js/)
("resources/AppIcon.icns", "resources"),
# 开发者镜像素材(iOS 17+ 全系共用的个性化镜像;iOS 16.x 的经典镜像按需放)
("resources/ddi", "resources/ddi"),
("docs/PROTOCOL.md", "docs"),
("README.md", "."),
],
hiddenimports=[
# 这些是运行期动态 import 的,PyInstaller 静态分析看不到
"pymobiledevice3.remote.native_tunnel",
"pymobiledevice3.remote.userspace_tunnel",
"pymobiledevice3.remote.remote_service_discovery",
"pymobiledevice3.remote.tunnel_service",
"pymobiledevice3.services.dvt.instruments.dvt_provider",
"pymobiledevice3.services.dvt.instruments.location_simulation",
"pymobiledevice3.lockdown",
"pymobiledevice3.usbmux",
"pymobiledevice3.service_connection",
"pymobiledevice3.exceptions",
"developer_disk_image.repo",
],
datas=_datas,
hiddenimports=HIDDENIMPORTS,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -85,55 +136,91 @@ a = Analysis(
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="iOSLocationTrack",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False, # 不弹终端窗口(日志写到文件)
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(ROOT / "resources" / "AppIcon.icns"),
)
if IS_WINDOWS:
# ---------------- Windows:onedir + .exe(启动时请求管理员权限)----------------
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="iOSLocationTrack",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False, # 不弹黑窗;日志走界面与控制台无关
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(ROOT / "resources" / "AppIcon.ico"),
# ★ 启动时直接请求管理员权限(exe 清单 requestedExecutionLevel=requireAdministrator)。
# 目的:首次运行即可自动安装 Apple Mobile Device Support,不需要用户手动右键提权。
uac_admin=True,
version=str(ROOT / "installer" / "version_info.txt")
if (ROOT / "installer" / "version_info.txt").exists() else None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="iOSLocationTrack",
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="iOSLocationTrack",
)
else:
# ---------------- macOS:onedir + .app bundle ----------------
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="iOSLocationTrack",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(ROOT / "resources" / "AppIcon.icns"),
)
app = BUNDLE(
coll,
name="虚拟定位轨迹.app",
icon=str(ROOT / "resources" / "AppIcon.icns"),
bundle_identifier="com.local.ios-location-track",
version="1.0.0",
info_plist={
"CFBundleName": "虚拟定位轨迹",
"CFBundleDisplayName": "虚拟定位轨迹",
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": "1.0.0",
"CFBundlePackageType": "APPL",
# Finder/Dock 取图标用;缺这个字段时系统有时会显示通用图标
"CFBundleIconFile": "AppIcon",
"CFBundleIconName": "AppIcon",
"LSMinimumSystemVersion": "12.0",
"NSHighResolutionCapable": True,
"LSApplicationCategoryType": "public.app-category.developer-tools",
"NSHumanReadableCopyright": "本地工具,用于自有 App 的定位功能测试",
"NSAppleEventsUsageDescription": "用于以管理员权限建立 iOS 设备隧道。",
"LSUIElement": False,
},
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="iOSLocationTrack",
)
app = BUNDLE(
coll,
name="虚拟定位轨迹.app",
icon=str(ROOT / "resources" / "AppIcon.icns"),
bundle_identifier="com.local.ios-location-track",
version="1.0.0",
info_plist={
"CFBundleName": "虚拟定位轨迹",
"CFBundleDisplayName": "虚拟定位轨迹",
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": "1.0.0",
"CFBundlePackageType": "APPL",
"CFBundleIconFile": "AppIcon",
"CFBundleIconName": "AppIcon",
"LSMinimumSystemVersion": "12.0",
"NSHighResolutionCapable": True,
"LSApplicationCategoryType": "public.app-category.developer-tools",
"NSHumanReadableCopyright": "本地工具,用于自有 App 的定位功能测试",
"NSAppleEventsUsageDescription": "用于以管理员权限建立 iOS 设备隧道。",
"LSUIElement": False,
},
)