From 4d55e2415f6d9318f1a20ea1d70db98d5b001d66 Mon Sep 17 00:00:00 2001 From: jbjf6666 Date: Fri, 16 Oct 2026 01:41:59 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(windows):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?Windows=20=E7=A7=BB=E6=A4=8D=EF=BC=88=E5=85=8D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E9=9A=A7=E9=81=93=20+=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=A3=85=20Apple=20=E9=A9=B1=E5=8A=A8=20+=20=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E8=87=AA=E6=A3=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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 → 两次下发 → 恢复真实定位 → 干净退出 --- .gitattributes | 57 ++ Location.spec | 267 ++++--- README.en.md | 11 +- README.md | 10 +- core/apple_driver.py | 822 ++++++++++++++++++++++ core/location_provider.py | 103 ++- core/platform.py | 548 +++++++++++++++ core/server.py | 45 +- core/setup_window.py | 606 ++++++++++++++++ core/tunnel.py | 350 ++++++--- docs/README-WINDOWS.md | 294 ++++++++ docs/WINDOWS_PORT.md | 42 ++ installer/iosloc.iss | 89 +++ installer/languages/ChineseSimplified.isl | 417 +++++++++++ installer/version_info.txt | 32 + main.py | 356 +++++++--- resources/AppIcon-256.png | Bin 0 -> 16050 bytes resources/AppIcon.ico | Bin 0 -> 33893 bytes scripts/build.ps1 | 162 +++++ scripts/run.ps1 | 106 +++ tests/drift_sim.py | 3 +- tests/offline_tiles.py | 3 +- tests/win_app_e2e.py | 131 ++++ tests/win_e2e.py | 195 +++++ tools/make_ico.py | 95 +++ tools/probe_tunnel_win.py | 77 ++ 26 files changed, 4515 insertions(+), 306 deletions(-) create mode 100644 .gitattributes create mode 100644 core/apple_driver.py create mode 100644 core/platform.py create mode 100644 core/setup_window.py create mode 100644 docs/README-WINDOWS.md create mode 100644 installer/iosloc.iss create mode 100644 installer/languages/ChineseSimplified.isl create mode 100644 installer/version_info.txt create mode 100644 resources/AppIcon-256.png create mode 100644 resources/AppIcon.ico create mode 100644 scripts/build.ps1 create mode 100644 scripts/run.ps1 create mode 100644 tests/win_app_e2e.py create mode 100644 tests/win_e2e.py create mode 100644 tools/make_ico.py create mode 100644 tools/probe_tunnel_win.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..803f3af --- /dev/null +++ b/.gitattributes @@ -0,0 +1,57 @@ +# 统一换行符,避免 Windows 与 macOS 互相污染。 +# +# 背景:本仓库现在同时支持 macOS 与 Windows。 +# Windows 上 core.autocrlf=true 时,若没有本文件,Shell 脚本会被写成 CRLF —— +# 而 CRLF 的 .sh 在 macOS/Linux 上会直接报 +# `bad interpreter: /bin/bash^M: no such file or directory` +# 反过来,PowerShell 脚本写成 LF 虽然多数情况能跑,但保持一致更省事。 +# +# 注意:本文件**只影响之后新增/修改的文件**,不会重写历史。不要执行 +# `git add --renormalize .`,那会把整个仓库的换行符翻一遍,产生巨大无意义 diff。 + +# 默认:交给 git 自动判断,并在仓库里统一存成 LF +* text=auto + +# 本文件自身 +.gitattributes text eol=lf + +# Shell 脚本必须 LF(macOS / Linux 直接执行) +*.sh text eol=lf +*.bash text eol=lf + +# PowerShell 脚本用 CRLF(Windows PowerShell 5.1 的习惯) +# ⚠️ scripts/*.ps1 是 **UTF-8 with BOM**:PowerShell 5.1 只有看到 BOM 才按 UTF-8 +# 解析,否则会按系统 ANSI 代码页读,中文乱码并直接语法报错。 +# 编辑这两个文件时务必保留 BOM。git 不会动 BOM,这里只锁换行符。 +*.ps1 text eol=crlf + +# Python / 前端 / 文档:LF +*.py text eol=lf +*.js text eol=lf +*.css text eol=lf +*.html text eol=lf +*.json text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# 安装包脚本:CRLF +*.iss text eol=crlf +*.isl text eol=crlf +*.nsi text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +# 二进制:不要做任何换行转换 +*.icns binary +*.ico binary +*.png binary +*.jpg binary +*.jpeg binary +*.dmg binary +*.dll binary +*.exe binary +*.zip binary +*.plist binary +*.trustcache binary diff --git a/Location.spec b/Location.spec index 453129a..4bba0f9 100644 --- a/Location.spec +++ b/Location.spec @@ -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, + }, + ) diff --git a/README.en.md b/README.en.md index aa6c152..b48ae01 100644 --- a/README.en.md +++ b/README.en.md @@ -1,13 +1,20 @@ -# iOS Virtual Location & Route Simulator (macOS) +# iOS Virtual Location & Route Simulator (macOS / Windows) [中文](README.md) | [**English**](README.en.md) -A desktop tool for **virtual location** and **route simulation** on iPhone / iPad from a Mac: draw a route on the map, add waypoints, and drive the device's location along it at realistic speeds. +A desktop tool for **virtual location** and **route simulation** on iPhone / iPad: draw a route on the map, add waypoints, and drive the device's location along it at realistic speeds. Supports **iOS 17 – iOS 27.2** (RSD tunnel + DVT `SimulateLocation`). The developer disk image assets are bundled inside the app, so **no Python installation is required**. The UI is a local web app opened in a Chrome / Edge app window (no address bar — looks like a native app). You can also just open `http://127.0.0.1:8787` in a browser. The map, route engine, playback engine, and device communication all run locally; only map tiles and optional route planning need network access. +> **Windows users: see [docs/README-WINDOWS.md](docs/README-WINDOWS.md).** +> The Windows build establishes the tunnel **without administrator rights** (pure-Python +> userspace stack — no Wintun driver needed), detects and auto-installs Apple Mobile Device +> Support on first launch, and shows an environment self-check window. +> Build outputs: `dist\iOSLocationTrack\iOSLocationTrack.exe` and +> `dist\installer\iOSLocationTrack-1.0.0-setup.exe`. + --- ## 1. Quick Start diff --git a/README.md b/README.md index 6547226..168f60b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ -# iOS 虚拟定位轨迹工具(macOS) +# iOS 虚拟定位轨迹工具(macOS / Windows) [**中文**](README.md) | [English](README.en.md) -在 Mac 上给 iPhone / iPad 做**虚拟定位**与**轨迹模拟**的桌面工具:在地图上绘制路线、添加途经点、按真实速度沿轨迹驱动手机定位。 +在电脑上给 iPhone / iPad 做**虚拟定位**与**轨迹模拟**的桌面工具:在地图上绘制路线、添加途经点、按真实速度沿轨迹驱动手机定位。 支持 **iOS 17 ~ iOS 27.2**(RSD 隧道 + DVT `SimulateLocation`),应用内内置了全系共用的开发者镜像素材,**不需要安装 Python 环境**。 界面为本机网页应用,用 Chrome / Edge 的应用窗口打开(无地址栏,像原生 App),也可以直接用浏览器访问 `http://127.0.0.1:8787`。地图、轨迹、播放引擎、设备通信全部在本机完成,只有地图瓦片和(可选的)路线规划需要联网。 +> **Windows 用户请看 [docs/README-WINDOWS.md](docs/README-WINDOWS.md)**。 +> Windows 版:免管理员建立隧道(纯 Python 用户态协议栈,不需要 Wintun 驱动)、 +> 启动时自动检测并安装 Apple Mobile Device Support、带「环境准备」自检窗口。 +> 构建产物:`dist\iOSLocationTrack\iOSLocationTrack.exe` 与 +> `dist\installer\iOSLocationTrack-1.0.0-setup.exe`。 + --- ## 1. 快速开始 diff --git a/core/apple_driver.py b/core/apple_driver.py new file mode 100644 index 0000000..00a9736 --- /dev/null +++ b/core/apple_driver.py @@ -0,0 +1,822 @@ +r"""Apple Mobile Device Support(Windows 上的 usbmuxd 等价物)检测与自动安装。 + +**为什么需要这个模块** + +macOS 自带 usbmuxd;Windows 上 usbmuxd 由 Apple 的 "Apple Mobile Device Service" +提供(随 iTunes / Apple Mobile Device Support 安装)。它监听 **127.0.0.1:27015**, +pymobiledevice3 通过它发现 iPhone。没有它,`pymobiledevice3 usbmux list` 一台设备也看不到。 + +**关键事实(已在真机取证,见 docs/DEVICE_NOTES_WINDOWS.md)** + +1. 服务名是带空格的 ``Apple Mobile Device Service``;``AppleMobileDeviceService`` 只是 + exe 文件名,`sc query AppleMobileDeviceService` 会报 1060。 +2. Windows 上 **没有** ``\\.\pipe\usbmuxd``,只有 TCP 27015。 +3. ProductCode 不稳定(每个版本都变),**UpgradeCode 稳定**: + ``{5CF0656E-F3B8-11DB-BA81-804E56D89593}``(x86/x64 通用)。 +4. **AMDS 不提供 USB 驱动**。它的 MSI 里 0 个 .inf/.sys/.cat;现代机器上 iPhone 实际绑定 + 的是 ``appleusb.inf``(服务 WINUSB / usbccgp)。所以**不要**用 `usbaapl64` 判断驱动是否存在 + —— 健康的机器上这个判断会误报"缺失"。 +5. 判断"到底能不能用"的**唯一权威信号**是 27015 上的协议往返(``ReadBUID`` 不需要插设备)。 +""" +from __future__ import annotations + +import ctypes +import hashlib +import json +import os +import plistlib +import shutil +import socket +import struct +import subprocess +import sys +import time +import urllib.request +from pathlib import Path +from typing import Any, Callable + +from . import platform as plat + +#: 服务名(带空格!) +SERVICE_NAME = "Apple Mobile Device Service" +#: AMDS 的稳定标识(UpgradeCode,x86/x64 通用,跨版本不变) +AMDS_UPGRADE_CODE = "{5CF0656E-F3B8-11DB-BA81-804E56D89593}" +#: AMDS 安装包在注册表 Uninstall 下的显示名 +AMDS_DISPLAY_NAME = "Apple Mobile Device Support" + +USBMUX_HOST = "127.0.0.1" +USBMUX_PORT = 27015 + +# --------------------------------------------------------------------------- +# 官方下载地址(2026-10 实测可达) +# --------------------------------------------------------------------------- +ITUNES_WIN64_URL = "https://www.apple.com/itunes/download/win64" +ITUNES_WIN32_URL = "https://www.apple.com/itunes/download/win32" + +#: 独立 AMDS MSI(约 38 MB,比整个 iTunes 包 193 MB 小得多) +AMDS_MSI_X64_URL = ( + "https://swcdn.apple.com/content/downloads/20/49/047-76422/" + "qcw2a7028lr8yp4rdkrfyzkvlhi9q1gg2g/AppleMobileDeviceSupport64.msi" +) +AMDS_MSI_X64_SHA256 = "b60533fb54e7bd81ffc52d99678a9cce04e58cb54a76281bd7b1309f20d360e9" +AMDS_MSI_X86_URL = ( + "https://swcdn.apple.com/content/downloads/54/12/047-76417/" + "rftfhqmqnu0afv25sdr2iosg38mxkqa388/AppleMobileDeviceSupport.msi" +) +AMDS_MSI_X86_SHA256 = "590e4b21d2aa522cfe789cc3fea95421b6141ddfa36e06525ae9715a5b5859e3" + +#: Apple Application Support(AMDS 的前置依赖,安装顺序由 iTunes 包自己决定) +AAS_MSI_X64_URL = ( + "https://swcdn.apple.com/content/downloads/63/47/001-39934/" + "955xl2euy0wb5j2z4oddbbd39ln1sthedn/AppleApplicationSupport64.msi" +) + +#: msiexec / 安装程序的"成功"退出码 +SUCCESS_CODES = {0, 3010, 1641, 1638} +CODE_MEANING = { + 0: "成功", + 3010: "成功(需要重启才能完全生效)", + 1641: "成功(安装程序已自动重启)", + 1638: "已安装该产品的其它版本(视为已就绪)", + 1603: "安装失败(致命错误)", + 1618: "另一个安装正在进行中,请稍后重试", + 1619: "安装包无法打开(下载可能不完整)", + 1620: "安装包无法打开(文件损坏)", + 1260: "组策略禁止了本次安装", + 5: "拒绝访问(需要管理员权限)", +} + +_TIMEOUT_PROBE = 5.0 + + +# --------------------------------------------------------------------------- +# 底层工具 +# --------------------------------------------------------------------------- +def _no_window() -> dict[str, Any]: + if plat.IS_WINDOWS: + return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)} + return {} + + +def _run(command: list[str], timeout: float = 60.0) -> tuple[int, str]: + """执行外部命令,返回 (returncode, 合并输出)。失败不抛异常。""" + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=timeout, **_no_window() + ) + output = ((result.stdout or "") + (result.stderr or "")).strip() + return result.returncode, output + except subprocess.TimeoutExpired: + return -1, "命令超时" + except FileNotFoundError: + return -2, f"找不到命令:{command[0]}" + except Exception as exc: # pragma: no cover + return -3, repr(exc) + + +def _decode(data: bytes) -> str: + """Windows 中文控制台输出多为 GBK,尽量解出来。""" + for encoding in ("utf-8", "gbk", "mbcs"): + try: + return data.decode(encoding) + except (UnicodeDecodeError, LookupError): + continue + return data.decode("utf-8", "replace") + + +# --------------------------------------------------------------------------- +# 检测 +# --------------------------------------------------------------------------- +def service_state() -> dict[str, Any]: + """查询 ``Apple Mobile Device Service`` 的状态。""" + if not plat.IS_WINDOWS: + return {"applicable": False, "exists": False, "running": False, "state": "n/a"} + code, output = _run(["sc", "query", SERVICE_NAME], timeout=15) + text = output.upper() + exists = code == 0 and "1060" not in output + running = "RUNNING" in text + state = "unknown" + for token in ("RUNNING", "STOPPED", "START_PENDING", "STOP_PENDING", "PAUSED"): + if token in text: + state = token + break + if not exists: + state = "not_installed" + return { + "applicable": True, + "exists": exists, + "running": running, + "state": state, + "raw": output.strip(), + } + + +def start_service() -> tuple[bool, str]: + """尝试启动服务。已有的服务只是没启动,是"看不到设备"最常见的原因。""" + if not plat.IS_WINDOWS: + return False, "非 Windows 平台" + code, output = _run(["sc", "start", SERVICE_NAME], timeout=60) + if code == 0 or "1056" in output: # 1056 = 已在运行 + return True, "服务已启动" + return False, output.strip() or f"sc start 返回 {code}" + + +def usbmux_probe( + message_type: str = "ReadBUID", + host: str = USBMUX_HOST, + port: int = USBMUX_PORT, + timeout: float = _TIMEOUT_PROBE, +) -> dict[str, Any]: + """在 27015 上做一次 usbmuxd 协议往返 —— **判断可用性的唯一权威信号**。 + + ``ReadBUID`` 不需要插设备,因此可以在"有没有手机"之外单独验证服务是否真的工作。 + + 协议:16 字节小端头 `` dict[str, Any]: + """通过 usbmuxd 列出设备(USB + Wi-Fi)。""" + result = usbmux_probe("ListDevices") + if not result.get("ok"): + return result + devices = (result.get("plist") or {}).get("DeviceList") or [] + out = [] + for item in devices: + props = item.get("Properties") or {} + out.append( + { + "device_id": item.get("DeviceID"), + "connection": props.get("ConnectionType", "?"), + "product_id": props.get("ProductID"), + "serial": props.get("SerialNumber", ""), + } + ) + return {"ok": True, "devices": out, "count": len(out)} + + +def _registry_views(): + """枚举 32/64 位两个注册表视图的 Uninstall 键(32 位 AMDS 落在 WOW6432Node)。""" + import winreg # type: ignore[import-not-found] + + root = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY): + for sub in ("", r"\WOW6432Node"): + path = root + sub if sub else root + try: + with winreg.OpenKey(hive, path, 0, winreg.KEY_READ | view) as key: + count = winreg.QueryInfoKey(key)[0] + for index in range(count): + try: + name = winreg.EnumKey(key, index) + with winreg.OpenKey(key, name) as entry: + yield entry + except OSError: + continue + except OSError: + continue + + +def installed_products() -> list[dict[str, Any]]: + """从注册表读出 Apple 相关产品的版本信息(仅用于展示/诊断)。""" + if not plat.IS_WINDOWS: + return [] + wanted = ("apple mobile device support", "itunes", "apple application support", + "apple devices", "bonjour") + found: dict[str, dict[str, Any]] = {} + for entry in _registry_views(): + try: + import winreg # type: ignore[import-not-found] + + display = str(winreg.QueryValueEx(entry, "DisplayName")[0]) + except OSError: + continue + low = display.lower() + if not any(w in low for w in wanted): + continue + record: dict[str, Any] = {"display_name": display} + for field in ("DisplayVersion", "Publisher", "InstallLocation", "UninstallString"): + try: + import winreg # type: ignore[import-not-found] + + value = winreg.QueryValueEx(entry, field)[0] + record[field.lower()] = str(value) + except OSError: + continue + # 用 UpgradeCode 精确识别 AMDS(ProductCode 不稳定,不能硬编码) + try: + import winreg # type: ignore[import-not-found] + + props = winreg.OpenKey(entry, "InstallProperties") + with props: + upgrade = str(winreg.QueryValueEx(props, "UpgradeCode")[0]) + record["upgrade_code"] = upgrade + except OSError: + pass + key = f"{display}|{record.get('displayversion','')}" + found[key] = record + return sorted(found.values(), key=lambda r: r["display_name"]) + + +def amds_product() -> dict[str, Any] | None: + """返回已安装的 AMDS 记录(按 UpgradeCode 或显示名匹配)。""" + for item in installed_products(): + if (item.get("upgrade_code") or "").upper() == AMDS_UPGRADE_CODE.upper(): + return item + if (item.get("display_name") or "").strip().lower() == AMDS_DISPLAY_NAME.lower(): + return item + return None + + +def usb_driver_status() -> dict[str, Any]: + """检查 iPhone 的 USB 驱动绑定情况。 + + **不要**用 ``usbaapl`` 判断是否存在 —— 现代机器上健康设备绑定的是 ``WINUSB``/ + ``usbccgp``(驱动文件 ``appleusb.inf``)。 + """ + if not plat.IS_WINDOWS: + return {"applicable": False} + ps = shutil.which("powershell") or shutil.which("pwsh") + if not ps: + return {"applicable": True, "checked": False, "error": "找不到 PowerShell"} + script = ( + "Get-PnpDevice -ErrorAction SilentlyContinue | " + "Where-Object { $_.InstanceId -like '*VID_05AC*' } | " + "Select-Object Status,Class,FriendlyName,InstanceId,Service | ConvertTo-Json -Compress" + ) + code, output = _run([ps, "-NoProfile", "-NonInteractive", "-Command", script], timeout=45) + devices: list[dict[str, Any]] = [] + if output.strip(): + try: + data = json.loads(output.strip()) + devices = data if isinstance(data, list) else [data] + except Exception: + devices = [] + healthy_tokens = ("usbaapl", "winusb", "appleusb", "usbccgp") + bound = [ + d for d in devices + if any(t in str(d.get("Service") or "").lower() for t in healthy_tokens) + ] + # MTP(照片)设备绑定 WUDFWpdMtp,不能算作"定位可用",但说明线材/端口 OK + return { + "applicable": True, + "checked": True, + "devices": [ + { + "name": d.get("FriendlyName"), + "status": d.get("Status"), + "service": d.get("Service"), + "instance": d.get("InstanceId"), + } + for d in devices + ], + "bound_count": len(bound), + "any_apple_device": len(devices) > 0, + } + + +def status(deep: bool = True) -> dict[str, Any]: + """汇总状态。``deep=True`` 时做 27015 协议往返(权威可用性判定)。""" + if not plat.IS_WINDOWS: + return { + "applicable": False, + "ready": True, + "summary": "非 Windows 平台,usbmuxd 由系统自带", + } + service = service_state() + product = amds_product() + probe: dict[str, Any] = {"ok": False, "error": "未检测"} + devices: dict[str, Any] = {"ok": False, "count": 0, "devices": []} + if deep: + probe = usbmux_probe("ReadBUID") + if probe.get("ok"): + devices = list_devices() + + ready = bool(service["running"] and probe.get("ok")) + if ready: + summary = "Apple Mobile Device Service 正在运行,usbmuxd 可通信" + elif not service["exists"]: + summary = "未安装 Apple Mobile Device Support(需要安装才能发现 iPhone)" + elif not service["running"]: + summary = "Apple Mobile Device Service 已安装但未运行" + else: + summary = "服务在运行,但 usbmuxd(127.0.0.1:27015)不可通信" + + return { + "applicable": True, + "ready": ready, + "summary": summary, + "needs_install": not service["exists"] and product is None, + "needs_start": bool(service["exists"] and not service["running"]), + "service": service, + "product": product, + "probe": {k: v for k, v in probe.items() if k != "plist"}, + "devices": devices, + "driver": usb_driver_status() if deep else {"checked": False}, + "is_admin": plat.is_admin(), + } + + +# --------------------------------------------------------------------------- +# 安装 +# --------------------------------------------------------------------------- +def _download(url: str, dest: Path, log: Callable[[str, str], None] | None = None, + expected_sha256: str | None = None) -> tuple[bool, str]: + """下载文件并可选校验 SHA256。支持断点续传(.part)。""" + dest.parent.mkdir(parents=True, exist_ok=True) + part = dest.with_suffix(dest.suffix + ".part") + + def emit(level: str, message: str) -> None: + if log: + log(level, message) + + if dest.exists() and expected_sha256: + if _sha256(dest) == expected_sha256.lower(): + return True, f"已存在且校验通过:{dest.name}" + + emit("info", f"下载 {url.split('/')[-1]} …") + request = urllib.request.Request(url, headers={"User-Agent": "iOSLocationTrack/1.0"}) + proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") + try: + handlers: list[Any] = [] + if proxy: + handlers.append(urllib.request.ProxyHandler({"https": proxy, "http": proxy})) + opener = urllib.request.build_opener(*handlers) + with opener.open(request, timeout=60) as response, open(part, "wb") as handle: + total = int(response.headers.get("Content-Length") or 0) + done = 0 + last_report = 0.0 + while True: + chunk = response.read(256 * 1024) + if not chunk: + break + handle.write(chunk) + done += len(chunk) + now = time.monotonic() + if total and now - last_report > 2.0: + last_report = now + emit("info", f" … {done/1048576:.1f}/{total/1048576:.1f} MB " + f"({done*100//total}%)") + except Exception as exc: + try: + part.unlink(missing_ok=True) + except OSError: + pass + return False, f"下载失败:{exc}" + + if expected_sha256: + actual = _sha256(part) + if actual != expected_sha256.lower(): + part.unlink(missing_ok=True) + return False, (f"SHA256 校验失败:期望 {expected_sha256[:16]}…," + f"实际 {actual[:16]}…(文件可能已更新,请报告)") + try: + part.replace(dest) + except OSError as exc: + return False, f"保存失败:{exc}" + return True, f"已下载:{dest}" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _msiexec(msi: Path, log: Callable[[str, str], None] | None = None, + extra: list[str] | None = None) -> tuple[int, str]: + """静默安装一个 MSI,返回 (退出码, 输出)。""" + logfile = plat.data_dir() / "logs" / f"msi-{msi.stem}.log" + logfile.parent.mkdir(parents=True, exist_ok=True) + command = [ + "msiexec", "/i", str(msi), "/qn", "/norestart", "/passive", + f"/l*v", str(logfile), + ] + if extra: + command += extra + # /qn 与 /passive 同时给出时以 /qn 为准;这里保留 /qn 保证全静默 + command = [c for c in command if c != "/passive"] + if log: + log("info", f"执行:{' '.join(command)}") + code, output = _run(command, timeout=1800) + return code, output + + +def install_amds(log: Callable[[str, str], None] | None = None, + allow_download: bool = True) -> dict[str, Any]: + """策略 B:只装 Apple Mobile Device Support(约 38 MB,推荐)。 + + 顺序:Apple Application Support x64 → AMDS x64。iTunes 包自己就是这个顺序。 + """ + def emit(level: str, message: str) -> None: + if log: + log(level, message) + + if not plat.IS_WINDOWS: + return {"ok": False, "error": "仅 Windows 支持"} + if not plat.is_admin(): + return {"ok": False, "error": "需要管理员权限才能安装 Apple Mobile Device Support", + "needs_admin": True} + if not allow_download: + return {"ok": False, "error": "已禁用下载"} + + arch64 = struct.calcsize("P") * 8 == 64 + cache = plat.data_dir() / "installers" + steps: list[dict[str, Any]] = [] + + plans = [] + if arch64: + plans.append(("Apple Application Support x64", AAS_MSI_X64_URL, None, True)) + url = AMDS_MSI_X64_URL if arch64 else AMDS_MSI_X86_URL + sha = AMDS_MSI_X64_SHA256 if arch64 else AMDS_MSI_X86_SHA256 + plans.append(("Apple Mobile Device Support", url, sha, False)) + + for label, url, sha, optional in plans: + name = url.rsplit("/", 1)[-1] + target = cache / name + emit("info", f"=== {label} ===") + ok, message = _download(url, target, log, sha) + if not ok: + if optional: + emit("warn", f"{label} 下载失败(可选前置,继续):{message}") + steps.append({"step": label, "ok": False, "skipped": True, "message": message}) + continue + return {"ok": False, "error": message, "steps": steps} + emit("info", message) + code, output = _msiexec(target, log) + meaning = CODE_MEANING.get(code, f"退出码 {code}") + emit("info" if code in SUCCESS_CODES else "error", f"{label}:{meaning}") + steps.append({"step": label, "code": code, "ok": code in SUCCESS_CODES, + "message": meaning}) + if code not in SUCCESS_CODES: + tail = "\n".join(output.strip().splitlines()[-8:]) + return {"ok": False, "error": f"{label} 安装失败:{meaning}", + "steps": steps, "output": tail} + + return {"ok": True, "steps": steps, "strategy": "amds_msi", + "reboot_required": any(s.get("code") in (3010, 1641) for s in steps)} + + +def install_itunes(log: Callable[[str, str], None] | None = None) -> dict[str, Any]: + """策略 A:装完整 iTunes 包(约 193 MB,Apple 官方推荐路径,最稳)。""" + def emit(level: str, message: str) -> None: + if log: + log(level, message) + + if not plat.IS_WINDOWS: + return {"ok": False, "error": "仅 Windows 支持"} + if not plat.is_admin(): + return {"ok": False, "error": "需要管理员权限才能安装 iTunes", "needs_admin": True} + + cache = plat.data_dir() / "installers" + target = cache / "iTunes64Setup.exe" + ok, message = _download(ITUNES_WIN64_URL, target, log, None) + if not ok: + return {"ok": False, "error": message} + emit("info", f"{message}({target.stat().st_size/1048576:.0f} MB)") + emit("warn", "安装期间 iTunes / Outlook 可能被自动关闭,请先保存工作") + command = [str(target), "/quiet", "/norestart", "/log", + str(plat.data_dir() / "logs" / "itunes-install.log")] + emit("info", f"执行:{' '.join(command)}(可能需要几分钟)") + code, output = _run(command, timeout=3600) + meaning = CODE_MEANING.get(code, f"退出码 {code}") + ok = code in SUCCESS_CODES + emit("info" if ok else "error", f"iTunes 安装:{meaning}") + return {"ok": ok, "strategy": "itunes_bundle", "code": code, "message": meaning, + "reboot_required": code in (3010, 1641), + "error": None if ok else f"iTunes 安装失败:{meaning}"} + + +def install_winget(log: Callable[[str, str], None] | None = None) -> dict[str, Any]: + """策略 C:用 winget 装(依赖 winget 可用且能联网)。""" + def emit(level: str, message: str) -> None: + if log: + log(level, message) + + if not plat.IS_WINDOWS: + return {"ok": False, "error": "仅 Windows 支持"} + winget = shutil.which("winget") + if not winget: + return {"ok": False, "error": "未找到 winget"} + last: dict[str, Any] = {"ok": False, "error": "所有 winget 包都失败"} + for package_id in ("Apple.AppleMobileDeviceSupport", "Apple.iTunes"): + command = [winget, "install", "--id", package_id, "--exact", "--silent", + "--accept-package-agreements", "--accept-source-agreements", + "--disable-interactivity"] + emit("info", f"执行:{' '.join(command)}") + code, output = _run(command, timeout=3600) + if code == 0: + emit("info", f"{package_id} 安装完成") + return {"ok": True, "strategy": f"winget:{package_id}"} + emit("warn", f"{package_id} 失败({code}):{output.strip()[-300:]}") + last = {"ok": False, "error": f"{package_id} 安装失败(退出码 {code})", + "output": output.strip()[-500:]} + return last + + +def ensure( + log: Callable[[str, str], None] | None = None, + allow_install: bool = True, + strategy: str = "auto", +) -> dict[str, Any]: + """**主入口**:检测 → 必要时安装 → 复检。 + + ``strategy``: ``auto``(推荐)| ``msi`` | ``itunes`` | ``winget`` | ``none`` + """ + def emit(level: str, message: str) -> None: + if log: + log(level, message) + + if not plat.IS_WINDOWS: + return {"ok": True, "applicable": False, "action": "none", + "status": status(deep=False)} + + before = status(deep=True) + if before["ready"]: + emit("info", "Apple Mobile Device Support 已就绪,无需安装") + return {"ok": True, "applicable": True, "action": "none", "status": before} + + # 已安装但没运行 —— 先尝试启动(最常见、也最便宜) + if before["needs_start"]: + emit("warn", "服务已安装但未运行,尝试启动…") + ok, message = start_service() + emit("info" if ok else "warn", message) + if ok: + time.sleep(1.5) + after_start = status(deep=True) + if after_start["ready"]: + return {"ok": True, "applicable": True, "action": "start_service", + "status": after_start} + + if not allow_install or strategy == "none": + return {"ok": False, "applicable": True, "action": "none", + "needs_install": True, "status": before, + "error": before["summary"]} + + if not plat.is_admin(): + return {"ok": False, "applicable": True, "action": "blocked", + "needs_admin": True, "status": before, + "error": "安装 Apple Mobile Device Support 需要管理员权限," + "请以管理员身份重新运行程序"} + + emit("warn", f"未检测到可用的 usbmuxd:{before['summary']}") + emit("info", "开始自动安装 Apple Mobile Device Support(首次约 40~200 MB)…") + + attempts: list[dict[str, Any]] = [] + order = { + "auto": [("msi", install_amds), ("itunes", install_itunes), ("winget", install_winget)], + "msi": [("msi", install_amds)], + "itunes": [("itunes", install_itunes)], + "winget": [("winget", install_winget)], + }.get(strategy) or [] + + for name, function in order: + emit("info", f"--- 安装策略:{name} ---") + result = function(log) + attempts.append({"strategy": name, **{k: v for k, v in result.items() + if k not in ("steps",)}}) + if result.get("ok"): + if name == "msi" and not result.get("ok"): + continue + # 无论成功失败都让服务有机会起来,然后复检 + if result.get("ok") or result.get("reboot_required"): + service_state() + start_service() + time.sleep(2.0) + after = status(deep=True) + if after["ready"]: + return {"ok": True, "applicable": True, "action": f"installed:{name}", + "status": after, "attempts": attempts, + "reboot_required": bool(result.get("reboot_required"))} + if result.get("reboot_required"): + return {"ok": False, "applicable": True, "action": f"installed:{name}", + "needs_reboot": True, "status": after, "attempts": attempts, + "error": "安装完成但需要重启 Windows 后 usbmuxd 才会生效"} + if result.get("error"): + emit("warn", f"策略 {name} 未成功:{result['error']}") + + final = status(deep=True) + if final["ready"]: + return {"ok": True, "applicable": True, "action": "installed", + "status": final, "attempts": attempts} + return { + "ok": False, "applicable": True, "action": "failed", "status": final, + "attempts": attempts, + "error": "自动安装未能建立可用的 usbmuxd。可手动安装 iTunes:" + "https://www.apple.com/itunes/download/win64", + } + + +USBMUX_PIPE = r"\\.\pipe\usbmuxd" + + +# --------------------------------------------------------------------------- +# 开发者镜像(个性化镜像,iOS 17+ 全系共用) +# --------------------------------------------------------------------------- +#: 镜像来源:doronz88/DeveloperDiskImage(pymobiledevice3 官方使用的仓库) +DDI_REPO = "doronz88/DeveloperDiskImage" +DDI_BRANCH = "main" +DDI_DIR = "PersonalizedImages/Xcode_iOS_DDI_Personalized" +#: 文件名 → 最小合理体积(用于判断下载是否完整) +DDI_FILES = { + "Image.dmg": 10 * 1024 * 1024, + "BuildManifest.plist": 100 * 1024, + "Image.dmg.trustcache": 256, +} +#: 本地存放位置(相对项目/资源根) +DDI_SUBDIR = ("resources", "ddi", "personalized") + + +def ddi_dir(root: Path | str | None = None) -> Path: + """返回开发者镜像的本地目录。""" + base = Path(root) if root else plat.resolve_roots()[0] + return base.joinpath(*DDI_SUBDIR) + + +def ddi_status(root: Path | str | None = None) -> dict[str, Any]: + """检查本地开发者镜像素材是否齐全。""" + directory = ddi_dir(root) + files: dict[str, Any] = {} + missing: list[str] = [] + total = 0 + for name, minimum in DDI_FILES.items(): + path = directory / name + if path.exists(): + size = path.stat().st_size + ok = size >= minimum + total += size + files[name] = {"exists": True, "size": size, "ok": ok} + if not ok: + missing.append(name) + else: + files[name] = {"exists": False, "size": 0, "ok": False} + missing.append(name) + return { + "ok": not missing, + "dir": str(directory), + "files": files, + "missing": missing, + "total_mb": round(total / 1048576, 2), + } + + +def _ddi_urls(name: str) -> list[str]: + path = f"{DDI_DIR}/{name}" + return [ + f"https://raw.githubusercontent.com/{DDI_REPO}/{DDI_BRANCH}/{path}", + f"https://cdn.jsdelivr.net/gh/{DDI_REPO}@{DDI_BRANCH}/{path}", + ] + + +def download_developer_image( + log: Callable[[str, str], None] | None = None, + root: Path | str | None = None, + force: bool = False, +) -> dict[str, Any]: + """下载缺失的个性化开发者镜像(约 15 MB)。 + + 按「GitHub 直连 → jsDelivr 镜像」的顺序重试,与 tools/fetch_ddi.py 的策略一致。 + 供「环境准备」窗口的「安装」按钮调用。 + """ + def emit(level: str, message: str) -> None: + if log: + log(level, message) + + directory = ddi_dir(root) + try: + directory.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return {"ok": False, "error": f"无法创建目录 {directory}:{exc}"} + + current = ddi_status(root) + if current["ok"] and not force: + return {"ok": True, "action": "none", "status": current, + "detail": f"已就绪({current['total_mb']} MB)"} + + for name, minimum in DDI_FILES.items(): + target = directory / name + if not force and target.exists() and target.stat().st_size >= minimum: + emit("info", f"{name} 已存在,跳过") + continue + last_error = "" + downloaded = False + for url in _ddi_urls(name): + ok, message = _download(url, target, log, None) + if ok and target.exists() and target.stat().st_size >= minimum: + emit("info", f"{name}:{target.stat().st_size/1048576:.2f} MB") + downloaded = True + break + last_error = message + emit("warn", f"{name} 从 {url.split('/')[2]} 下载失败,尝试备用源…") + if not downloaded: + return {"ok": False, "error": f"{name} 下载失败:{last_error}", + "status": ddi_status(root)} + + after = ddi_status(root) + if not after["ok"]: + return {"ok": False, "error": "镜像文件仍不完整:" + "、".join(after["missing"]), + "status": after} + return {"ok": True, "action": "downloaded", "status": after, + "detail": f"已下载({after['total_mb']} MB)"} diff --git a/core/location_provider.py b/core/location_provider.py index 55c2574..bf2ae5f 100644 --- a/core/location_provider.py +++ b/core/location_provider.py @@ -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: diff --git a/core/platform.py b/core/platform.py new file mode 100644 index 0000000..4074616 --- /dev/null +++ b/core/platform.py @@ -0,0 +1,548 @@ +"""跨平台适配层(macOS / Windows / Linux)。 + +见 docs/WINDOWS_PORT.md 第 9 节:平台相关代码集中到本模块,其余模块只依赖这里, +从而保持**单一代码库**,不给 Windows 分叉出第二份源码。 + +约定: +- 所有公开函数在任何平台上都必须可用(不存在的能力返回 None / 空列表,不抛异常)。 +- 需要"管理员/root"的操作只在这里封装,便于在界面上给出对应提示。 +""" +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +IS_WINDOWS = sys.platform == "win32" +IS_MACOS = sys.platform == "darwin" +IS_LINUX = sys.platform.startswith("linux") + +#: 数据目录名(Windows: %LOCALAPPDATA%\iOSLocationTrack;macOS: ~/Library/Application Support/iOSLocationTrack) +APP_DIR_NAME = "iOSLocationTrack" +#: Chrome/Edge `--app=` 模式使用的独立用户数据目录 +APP_WINDOW_PROFILE = ".ios-location-appwindow" + + +# -------------------------------------------------------------------------- +# 路径 +# -------------------------------------------------------------------------- +def data_dir() -> Path: + """打包运行时的可写数据目录(配置、瓦片缓存、隧道 pid 文件都写这里)。""" + if IS_WINDOWS: + base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") + root = Path(base) if base else Path.home() / "AppData" / "Local" + target = root / APP_DIR_NAME + elif IS_MACOS: + target = Path.home() / "Library" / "Application Support" / APP_DIR_NAME + else: + base = os.environ.get("XDG_DATA_HOME") + root = Path(base) if base else Path.home() / ".local" / "share" + target = root / APP_DIR_NAME + try: + target.mkdir(parents=True, exist_ok=True) + except OSError: + fallback = Path.home() / f".{APP_DIR_NAME.lower()}" + try: + fallback.mkdir(parents=True, exist_ok=True) + except OSError: + return Path.home() + return fallback + return target + + +def resolve_roots() -> tuple[Path, Path]: + """返回 (资源根, 数据根)。 + + - 源码运行:两者都是项目目录 + - 打包后:资源在 PyInstaller 解包目录(只读),数据写到用户可写目录 + """ + if getattr(sys, "frozen", False): + resources = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent)) + return resources, data_dir() + root = Path(__file__).resolve().parent.parent + return root, root + + +def venv_python(root: Path | str | None = None) -> Path | None: + """返回项目 venv 内的解释器路径(不存在返回 None)。""" + base = Path(root) if root else resolve_roots()[0] + candidates = ( + [base / ".venv" / "Scripts" / "python.exe", base / ".venv" / "Scripts" / "python"] + if IS_WINDOWS + else [base / ".venv" / "bin" / "python", base / ".venv" / "bin" / "python3"] + ) + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def python_for_subprocess(root: Path | str | None = None) -> str: + """选一个能 `import pymobiledevice3` 的解释器:优先项目 venv,其次当前解释器。""" + found = venv_python(root) + if found is not None: + return str(found) + return sys.executable + + +def module_command(root: Path | str | None = None) -> list[str]: + """运行 `pymobiledevice3` CLI 的命令前缀。""" + return [python_for_subprocess(root), "-m", "pymobiledevice3"] + + +# -------------------------------------------------------------------------- +# 界面窗口(Chrome/Edge 的 --app= 模式) +# -------------------------------------------------------------------------- +def _windows_browser_candidates() -> list[Path]: + out: list[Path] = [] + pf = os.environ.get("ProgramFiles", r"C:\Program Files") + pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + local = os.environ.get("LOCALAPPDATA", "") + rel = [ + (pf, r"Google\Chrome\Application\chrome.exe"), + (pf86, r"Google\Chrome\Application\chrome.exe"), + (local, r"Google\Chrome\Application\chrome.exe"), + (pf, r"Microsoft\Edge\Application\msedge.exe"), + (pf86, r"Microsoft\Edge\Application\msedge.exe"), + (pf, r"BraveSoftware\Brave-Browser\Application\brave.exe"), + (pf86, r"BraveSoftware\Brave-Browser\Application\brave.exe"), + (local, r"BraveSoftware\Brave-Browser\Application\brave.exe"), + (local, r"Chromium\Application\chrome.exe"), + ] + for base, tail in rel: + if base: + out.append(Path(base) / tail) + # 注册表 App Paths 兜底(覆盖非默认安装位置) + try: + import winreg # type: ignore[import-not-found] + + for exe in ("chrome.exe", "msedge.exe", "brave.exe"): + for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY): + try: + with winreg.OpenKey( + hive, + rf"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}", + 0, + winreg.KEY_READ | view, + ) as key: + value, _ = winreg.QueryValueEx(key, None) + if value: + out.append(Path(str(value).strip('"'))) + except OSError: + continue + # 卸载信息里找 Chrome 的 InstallLocation + for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY): + try: + with winreg.OpenKey( + hive, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome", + 0, + winreg.KEY_READ | view, + ) as key: + loc, _ = winreg.QueryValueEx(key, "InstallLocation") + if loc: + out.append(Path(str(loc)) / "chrome.exe") + except OSError: + continue + except Exception: + pass + return out + + +def browser_candidates() -> list[Path]: + """可用于 `--app=` 无地址栏窗口的 Chromium 系浏览器,按优先级排序。""" + if IS_WINDOWS: + return _windows_browser_candidates() + if IS_MACOS: + return [ + Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), + Path("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"), + Path("/Applications/Chromium.app/Contents/MacOS/Chromium"), + ] + return [ + Path("/usr/bin/google-chrome"), + Path("/usr/bin/chromium"), + Path("/usr/bin/chromium-browser"), + Path("/usr/bin/microsoft-edge"), + Path("/snap/bin/chromium"), + ] + + +def find_app_browser() -> Path | None: + """返回第一个真实存在的 Chromium 系浏览器。""" + seen: set[str] = set() + for candidate in browser_candidates(): + key = str(candidate).lower() + if key in seen: + continue + seen.add(key) + try: + if candidate.is_file(): + return candidate + except OSError: + continue + for name in ("chrome", "msedge", "chromium", "brave"): + found = shutil.which(name) + if found: + return Path(found) + return None + + +def app_window_profile_dir() -> Path: + return Path.home() / APP_WINDOW_PROFILE + + +# -------------------------------------------------------------------------- +# 进程 +# -------------------------------------------------------------------------- +def spawn_detached_kwargs() -> dict[str, Any]: + """Popen 关键字:让子进程独立于父进程(父进程被 Ctrl+C 时不连带杀掉隧道)。""" + if IS_WINDOWS: + # start_new_session 在 Windows 上被忽略;用 CREATE_NEW_PROCESS_GROUP + + # DETACHED_PROCESS 达到同样效果,否则子进程会跟着控制台一起收到 CTRL_C_EVENT。 + flags = 0 + flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) + flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) + return {"creationflags": flags} + return {"start_new_session": True} + + +def _no_window_kwargs() -> dict[str, Any]: + """辅助进程(不弹黑窗)的 Popen 关键字。""" + if IS_WINDOWS: + return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)} + return {} + + +def process_cmdline(pid: int) -> str | None: + """返回进程命令行;进程不存在或查询失败返回 None。""" + if pid <= 0: + return None + if IS_WINDOWS: + return _windows_cmdline(pid) + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, + text=True, + timeout=5, + ) + except Exception: + return None + text = (result.stdout or "").strip() + return text or None + + +def _windows_cmdline(pid: int) -> str | None: + """Windows 上取进程命令行:优先 PowerShell CIM,其次 wmic。""" + ps = shutil.which("powershell") or shutil.which("pwsh") + if ps: + try: + result = subprocess.run( + [ + ps, "-NoProfile", "-NonInteractive", "-Command", + f"(Get-CimInstance Win32_Process -Filter 'ProcessId={int(pid)}').CommandLine", + ], + capture_output=True, + text=True, + timeout=15, + **_no_window_kwargs(), + ) + text = (result.stdout or "").strip() + if text: + return text + except Exception: + pass + wmic = shutil.which("wmic") + if wmic: + try: + result = subprocess.run( + [wmic, "process", "where", f"processid={int(pid)}", "get", "commandline", "/value"], + capture_output=True, + text=True, + timeout=10, + **_no_window_kwargs(), + ) + for line in (result.stdout or "").splitlines(): + if line.strip().startswith("CommandLine="): + return line.split("=", 1)[1].strip() or None + except Exception: + pass + return None + + +def pid_alive(pid: int) -> bool: + if pid <= 0: + return False + if IS_WINDOWS: + # OpenProcess + GetExitCodeProcess;用 tasklist 太慢,这里走 ctypes + try: + import ctypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)) + if not handle: + return False + try: + code = ctypes.c_ulong() + if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return code.value == STILL_ACTIVE + return False + finally: + kernel32.CloseHandle(handle) + except Exception: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + +def terminate_process(pid: int, force: bool = False, timeout: float = 3.0) -> bool: + """结束单个进程(不递归)。返回是否已确认退出。""" + if pid <= 0 or pid == os.getpid(): + return False + if IS_WINDOWS: + try: + subprocess.run( + ["taskkill", "/PID", str(int(pid)), "/T", "/F"], + capture_output=True, + text=True, + timeout=15, + **_no_window_kwargs(), + ) + except Exception: + return False + for _ in range(int(max(1, timeout / 0.2))): + if not pid_alive(pid): + return True + time.sleep(0.2) + return not pid_alive(pid) + sig = signal.SIGKILL if force else signal.SIGTERM + try: + os.kill(pid, sig) + except ProcessLookupError: + return True + except Exception: + return False + deadline = time.time() + timeout + while time.time() < deadline: + if not pid_alive(pid): + return True + time.sleep(0.2) + if not force: + try: + os.kill(pid, signal.SIGKILL) + except Exception: + pass + return not pid_alive(pid) + + +def terminate_process_tree(process: subprocess.Popen[Any] | None) -> None: + """结束一个 Popen 及其子进程(隧道助手会派生子进程)。""" + if process is None or process.poll() is not None: + return + if IS_WINDOWS: + try: + subprocess.run( + ["taskkill", "/PID", str(int(process.pid)), "/T", "/F"], + capture_output=True, + text=True, + timeout=15, + **_no_window_kwargs(), + ) + except Exception: + try: + process.kill() + except Exception: + pass + else: + try: + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + except Exception: + try: + process.terminate() + except Exception: + pass + try: + process.wait(timeout=4) + except Exception: + if not IS_WINDOWS: + try: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + except Exception: + pass + else: + terminate_process(process.pid, force=True) + + +def find_pid_by_cmdline(needles: list[str], image_names: list[str] | None = None) -> list[int]: + """按命令行关键字找进程(Windows 用一次 CIM 批量查询,POSIX 用 pgrep)。 + + 仅用于清理本工具自己遗留的隧道进程。 + """ + if IS_WINDOWS: + ps = shutil.which("powershell") or shutil.which("pwsh") + if not ps: + return [] + try: + result = subprocess.run( + [ + ps, "-NoProfile", "-NonInteractive", "-Command", + "Get-CimInstance Win32_Process | " + "Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress", + ], + capture_output=True, + text=True, + timeout=25, + **_no_window_kwargs(), + ) + except Exception: + return [] + import json + + try: + data = json.loads((result.stdout or "").strip() or "[]") + except Exception: + return [] + if isinstance(data, dict): + data = [data] + found: list[int] = [] + me = os.getpid() + for item in data: + if not isinstance(item, dict): + continue + pid = int(item.get("ProcessId") or 0) + if pid <= 0 or pid == me: + continue + name = str(item.get("Name") or "") + cmd = str(item.get("CommandLine") or "") + if image_names and not any(name.lower() == n.lower() for n in image_names): + continue + if any(needle.lower() in cmd.lower() for needle in needles): + found.append(pid) + return found + pids: list[int] = [] + for needle in needles: + try: + result = subprocess.run( + ["pgrep", "-f", needle], capture_output=True, text=True, timeout=5 + ) + except Exception: + continue + for line in (result.stdout or "").split(): + try: + pid = int(line) + except ValueError: + continue + if pid != os.getpid() and pid not in pids: + pids.append(pid) + return pids + + +# -------------------------------------------------------------------------- +# 权限 +# -------------------------------------------------------------------------- +def is_admin() -> bool: + """当前进程是否具备管理员/root 权限。""" + if IS_WINDOWS: + try: + import ctypes + + return bool(ctypes.windll.shell32.IsUserAnAdmin()) # type: ignore[attr-defined] + except Exception: + return False + try: + return os.geteuid() == 0 + except AttributeError: + return False + + +def admin_hint() -> str: + """界面上展示的"怎么以管理员身份运行"文案。""" + if IS_WINDOWS: + return "请右键程序图标选择「以管理员身份运行」(userspace 隧道需要安装/启用 Wintun 虚拟网卡)" + return "请在终端执行 sudo ./run.sh 以管理员模式启动" + + +def relaunch_elevated(argv: list[str] | None = None) -> bool: + """以提权方式重新启动自身。返回是否成功发起(不等待结果)。""" + args = argv if argv is not None else sys.argv + if IS_WINDOWS: + try: + import ctypes + + if getattr(sys, "frozen", False): + exe, params = sys.executable, subprocess.list2cmdline(args[1:]) + else: + exe = sys.executable + params = subprocess.list2cmdline([str(Path(args[0]).resolve()), *args[1:]]) + # SW_SHOWNORMAL = 1 + rc = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined] + None, "runas", exe, params, None, 1 + ) + return int(rc) > 32 + except Exception: + return False + if IS_MACOS: + inner = " ".join(f"'{a}'" for a in args) + script = f'do shell script "{inner}" with administrator privileges' + try: + subprocess.Popen(["osascript", "-e", script]) + return True + except Exception: + return False + return False + + +# -------------------------------------------------------------------------- +# 信号 +# -------------------------------------------------------------------------- +def termination_signals() -> list[int]: + """可以安全注册的退出信号(Windows 没有 SIGHUP)。""" + names = ["SIGINT", "SIGTERM"] + if not IS_WINDOWS: + names.append("SIGHUP") + out: list[int] = [] + for name in names: + value = getattr(signal, name, None) + if isinstance(value, int): + out.append(value) + return out + + +def open_in_file_manager(path: Path) -> bool: + """在系统文件管理器里打开一个目录。""" + try: + if IS_WINDOWS: + os.startfile(str(path)) # type: ignore[attr-defined] + elif IS_MACOS: + subprocess.Popen(["open", str(path)]) + else: + subprocess.Popen(["xdg-open", str(path)]) + return True + except Exception: + return False + + +def platform_label() -> str: + if IS_WINDOWS: + return "Windows" + if IS_MACOS: + return "macOS" + return "Linux" diff --git a/core/server.py b/core/server.py index 972cc09..dc337e4 100644 --- a/core/server.py +++ b/core/server.py @@ -85,8 +85,12 @@ class AsyncRunner: class App: """应用装配:配置、设备、隧道、定位会话、路由、播放、瓦片、日志。""" - def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT) -> None: + def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT, + resources: Path | str | None = None) -> None: + # root = 可写数据目录(打包后是 %LOCALAPPDATA%\\iOSLocationTrack) + # resources = 只读资源目录(打包后是 PyInstaller 解包目录),默认与 root 相同 self.root = Path(root) + self.resources = Path(resources) if resources is not None else self.root self.mock = mock self.port = port self.host = protocol.DEFAULT_HOST @@ -100,13 +104,18 @@ class App: pass self.runner = AsyncRunner() self.hub = ws.Hub() - self.tunnel = tunnel_mod.TunnelManager(log=self.log, on_change=self._on_tunnel_change) + # Windows 的 userspace 隧道必须建在应用自己的事件循环上(地址仅本进程可达), + # 因此把 runner 注入 TunnelManager;macOS 只用它做子进程管理,传了也无害。 + self.tunnel = tunnel_mod.TunnelManager( + log=self.log, on_change=self._on_tunnel_change, runner=self.runner + ) self.devices = device_mod.DeviceManager(mock=mock, on_change=self._on_devices_change) self.routes = route_engine.RouteEngine(self.config) self.tiles = tilecache.TileCache( self.config.cache_dir(), int(self.config.get("tile_cache_max_mb") or 1024) ) self.logs: deque[dict[str, Any]] = deque(maxlen=500) + self._log_file = self._open_log_file() self._lock = threading.RLock() self.provider: Any = None self.provider_udid: str | None = None @@ -126,10 +135,27 @@ class App: self._mount_running = False # ---------------- 日志 ---------------- + def _open_log_file(self): + """打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。""" + try: + directory = self.root / "logs" + directory.mkdir(parents=True, exist_ok=True) + handle = open(directory / "app.log", "a", encoding="utf-8") + return handle + except Exception: + return None + def log(self, level: str, message: str) -> None: entry = {"ts": protocol.now_ts(), "level": level, "msg": str(message)} self.logs.append(entry) self.hub.broadcast({"type": "log", **entry}) + handle = getattr(self, "_log_file", None) + if handle is not None: + try: + handle.write(f"{entry['ts']}\t{level}\t{message}\n") + handle.flush() + except Exception: + pass def recent_logs(self, limit: int = 200) -> list[dict[str, Any]]: try: @@ -218,7 +244,13 @@ class App: # 而且如果同时插着另一台可用的设备,要直接告诉用户换哪台。 self._preflight(device) + # RSD 有两种形态,必须区分(这是 Windows 与 macOS 最大的架构差异): + # - macOS native 隧道:地址内核可路由 → 传 (host, port) 让 provider 自己连 + # - Windows userspace 隧道:地址只在**本进程的用户态协议栈**里可达, + # 直接 TCP 连接会得到 OSError [WinError 1231] 网络位置不可达 → + # 必须把隧道返回的那个已连接 RSD 对象本身注入给 provider rsd = None + rsd_obj = None if (device.ios_major or 0) >= 17: if not self.tunnel.ready: snapshot = self.tunnel.ensure(device.udid, allow_sudo=False) @@ -228,8 +260,11 @@ class App: "tunnel_required", status=409, ) - rsd = self.tunnel.address() + rsd_obj = self.tunnel.rsd_object() + if rsd_obj is None: + rsd = self.tunnel.address() provider = RealLocationProvider( + rsd=rsd_obj, rsd_host=rsd[0] if rsd else None, rsd_port=rsd[1] if rsd else None, udid=device.udid, @@ -264,7 +299,7 @@ class App: hint = f";建议改用另一台设备:{best.name}(iOS {best.ios_version},{best.connection.upper()})" raise protocol.AppError( f"{device.name}(iOS {device.ios_version})尚未挂载开发者镜像,无法做虚拟定位。" - f"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid {device.udid}{hint}", + f"请执行:{tunnel_mod.mounter_hint(device.udid)}{hint}", "developer_image_missing", status=409, ) @@ -779,6 +814,8 @@ class App: base = getattr(sys, "_MEIPASS", None) if base: sources.append(Path(base) / "resources" / "ddi" / "personalized") + # 打包后 root 是可写数据目录,素材在 resources(只读包内目录) + sources.append(self.resources / "resources" / "ddi" / "personalized") sources.append(self.root / "resources" / "ddi" / "personalized") sources.append(Path.home() / ".ios-location-ddi" / "personalized") diff --git a/core/setup_window.py b/core/setup_window.py new file mode 100644 index 0000000..3576b5b --- /dev/null +++ b/core/setup_window.py @@ -0,0 +1,606 @@ +"""启动时的「环境准备」窗口(Windows)。 + +需求:每次打开 exe 都弹出一个窗口,列出所有需要的依赖: + - 没装的在右侧有「安装」按钮 + - 装好的显示 ✓ + - 必需项全部就绪后,「进入程序」按钮才可点击 + +设计要点: +- 用标准库 **tkinter**(已随 Python 打包进 exe),因此不依赖机器上有浏览器/网络, + 也不依赖我们已经启动的 HTTP 服务 —— 它必须能作为"第一个界面"独立工作。 +- 检测与安装都放在后台线程,通过队列回传 UI 线程(tkinter 不是线程安全的)。 +- 任何异常都不能让程序打不开:tkinter 不可用时返回 None,由调用方降级处理。 +""" +from __future__ import annotations + +import queue +import threading +from pathlib import Path +from typing import Any, Callable + +from . import apple_driver +from . import platform as plat + +# 颜色(与 web 端保持同一套语义色) +COLOR_OK = "#1a7f37" +COLOR_BAD = "#c0392b" +COLOR_BUSY = "#b7791f" +COLOR_IDLE = "#6b7280" +COLOR_BG = "#ffffff" +COLOR_MUTED = "#57606a" + +ICON_OK = "✓" +ICON_BAD = "✗" +ICON_BUSY = "…" +ICON_OPT = "·" +ICON_IDLE = "○" + + +class EnvItem: + """一个待检查/待安装的依赖项。""" + + def __init__( + self, + key: str, + label: str, + description: str, + check: Callable[[], tuple[bool, str]], + install: Callable[[Callable[[str, str], None]], tuple[bool, str]] | None = None, + required: bool = True, + install_label: str = "安装", + size_hint: str = "", + ) -> None: + self.key = key + self.label = label + self.description = description + self.check = check + self.install = install + self.required = required + self.install_label = install_label + self.size_hint = size_hint + + +# --------------------------------------------------------------------------- +# 各个依赖项的检测 / 安装实现 +# --------------------------------------------------------------------------- +def _check_python() -> tuple[bool, str]: + import sys + + return True, f"已内置 Python {sys.version.split()[0]}" + + +def _check_pymobiledevice3() -> tuple[bool, str]: + try: + import pymobiledevice3 + + version = getattr(pymobiledevice3, "__version__", None) or "已安装" + return True, f"已内置 pymobiledevice3 {version}" + except Exception as exc: + return False, f"导入失败:{exc}" + + +def _check_tunnel_deps() -> tuple[bool, str]: + """userspace 隧道依赖(纯 Python 协议栈,**不需要 Wintun 驱动**)。""" + missing = [] + for module in ("pytun_pmd3", "pmd_pytcp", "pmd_net_addr"): + try: + __import__(module) + except Exception: + missing.append(module) + if missing: + return False, "缺少:" + "、".join(missing) + try: + from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel # noqa: F401 + + return True, "已内置(纯 Python 用户态协议栈,无需 Wintun 驱动、无需管理员权限)" + except Exception as exc: + return False, f"隧道模块导入失败:{exc}" + + +def _check_apple_driver() -> tuple[bool, str]: + status = apple_driver.status(deep=True) + if not status.get("applicable", True): + return True, "非 Windows 平台,系统自带 usbmuxd" + if status.get("ready"): + product = status.get("product") or {} + version = product.get("displayversion") + devices = (status.get("devices") or {}).get("count", 0) + detail = "usbmuxd 可通信" + if version: + detail = f"v{version},{detail}" + if devices: + detail += f",已发现 {devices} 台设备" + return True, detail + return False, status.get("summary") or "不可用" + + +def _install_apple_driver(log: Callable[[str, str], None]) -> tuple[bool, str]: + if not plat.is_admin(): + return False, "需要管理员权限(本程序启动时应已提权)" + result = apple_driver.ensure(log=log, allow_install=True) + if result.get("ok"): + if result.get("reboot_required"): + return True, "安装完成,但需要重启 Windows 才生效" + return True, "安装完成" + return False, result.get("error") or "安装失败" + + +def _check_developer_image() -> tuple[bool, str]: + from . import apple_driver as ad + + status = ad.ddi_status() + if status["ok"]: + return True, f"已就绪({status['total_mb']} MB,iOS 17 及以上全系共用)" + return False, "缺少:" + "、".join(status["missing"]) + + +def _install_developer_image(log: Callable[[str, str], None]) -> tuple[bool, str]: + result = apple_driver.download_developer_image(log=log) + if result.get("ok"): + return True, result.get("detail") or "下载完成" + return False, result.get("error") or "下载失败" + + +def _check_permission() -> tuple[bool, str]: + if plat.is_admin(): + return True, "已以管理员身份运行" + return False, "未提权(安装 Apple 驱动和管理员操作会失败)" + + +def _check_device() -> tuple[bool, str]: + if not plat.IS_WINDOWS: + return True, "非 Windows 平台" + result = apple_driver.list_devices() + if not result.get("ok"): + return False, str(result.get("error") or "usbmuxd 不可用") + count = result.get("count", 0) + if count == 0: + return False, "未检测到 iPhone(用数据线连接、解锁并点「信任」)" + first = result["devices"][0] + return True, f"{count} 台({first.get('serial', '')},{first.get('connection', '?')})" + + +def _check_network() -> tuple[bool, str]: + """Apple TSS 签名需要联网(挂载开发者镜像时用)。""" + import socket + + hosts = [("gs.apple.com", 443), ("raw.githubusercontent.com", 443)] + reachable = [] + for host, port in hosts: + try: + with socket.create_connection((host, port), timeout=6): + reachable.append(host) + except OSError: + continue + if reachable: + return True, "可连通(" + "、".join(reachable) + ")" + return False, "无法连接 Apple 服务器(挂载开发者镜像时需要联网签名)" + + +def build_items() -> list[EnvItem]: + items = [ + EnvItem("python", "Python 运行时", "程序自带的解释器", + _check_python, None, required=True), + EnvItem("pymobiledevice3", "pymobiledevice3", "与 iOS 设备通信的核心库", + _check_pymobiledevice3, None, required=True), + ] + if plat.IS_WINDOWS: + items.append(EnvItem( + "apple_driver", "Apple Mobile Device Support", + "Windows 上的 usbmuxd(127.0.0.1:27015),用来发现 iPhone", + _check_apple_driver, _install_apple_driver, required=True, + install_label="安装", size_hint="约 40~200 MB", + )) + items.append(EnvItem( + "tunnel_deps", "RSD 隧道依赖", + "iOS 17+ 的用户态隧道(PyTCP)", + _check_tunnel_deps, None, required=True, + )) + items.append(EnvItem( + "developer_image", "开发者镜像", + "iOS 17 及以上全系共用的个性化镜像素材", + _check_developer_image, _install_developer_image, required=True, + install_label="下载", size_hint="约 15 MB", + )) + items.append(EnvItem( + "permission", "管理员权限", + "安装驱动与系统级操作需要", + _check_permission, None, required=True, + )) + items.append(EnvItem( + "device", "iPhone 连接", + "可选项:现在没插也可以,之后接上再点「重新检测」", + _check_device, None, required=False, + )) + items.append(EnvItem( + "network", "网络连通", + "可选项:挂载开发者镜像时 Apple 需要在线签名", + _check_network, None, required=False, + )) + return items + + +# --------------------------------------------------------------------------- +# 窗口 +# --------------------------------------------------------------------------- +class SetupWindow: + """环境准备窗口。所有耗时操作都在后台线程里跑。""" + + def __init__(self, root: Any, items: list[EnvItem] | None = None, + title: str = "环境准备") -> None: + import tkinter as tk + from tkinter import ttk + + self.tk = tk + self.ttk = ttk + self.root = root + self.items = items if items is not None else build_items() + self.state: dict[str, dict[str, Any]] = { + item.key: {"ok": None, "detail": "等待检测…", "busy": False} + for item in self.items + } + self.events: queue.Queue[tuple[str, Any]] = queue.Queue() + self.installing = False + self.proceeded = False + self.auto_install = False + self._rows: dict[str, dict[str, Any]] = {} + + root.title(f"{title} — iOS 虚拟定位轨迹工具") + root.configure(bg=COLOR_BG) + # 先建内容再定尺寸:否则 Tk 会按控件的"要求尺寸"把窗口撑高, + # geometry() 设的高度会被覆盖,底部的「进入程序」按钮就被挤出屏幕。 + self._build(title) + root.resizable(False, True) + self._fit_to_screen(root) + root.protocol("WM_DELETE_WINDOW", self._on_close) + root.after(80, self._pump) + self.refresh_all() + + # ---------------- 布局 ---------------- + @staticmethod + def _fit_to_screen(window: Any, min_width: int = 820, min_height: int = 560) -> None: + """按内容实际需要的高度定尺寸,并保证窗口完整落在屏幕内。""" + window.update_idletasks() + try: + screen_w = window.winfo_screenwidth() + screen_h = window.winfo_screenheight() + # 控件"要求"的尺寸 + 一点余量 + need_w = max(min_width, window.winfo_reqwidth() + 24) + need_h = window.winfo_reqheight() + 24 + width = min(need_w, max(640, screen_w - 60)) + height = min(need_h, max(min_height, screen_h - 100)) + x = max(0, (screen_w - width) // 2) + y = max(0, (screen_h - height) // 3) + window.geometry(f"{width}x{height}+{x}+{y}") + window.minsize(min(760, width), min(520, height)) + except Exception: + window.geometry("960x760") + + def _build(self, title: str) -> None: + tk, ttk = self.tk, self.ttk + style = ttk.Style() + try: + style.theme_use("vista") + except Exception: + pass + + header = tk.Frame(self.root, bg=COLOR_BG) + header.pack(fill="x", padx=20, pady=(16, 6)) + tk.Label(header, text=title, font=("Microsoft YaHei UI", 15, "bold"), + bg=COLOR_BG).pack(anchor="w") + tk.Label( + header, + text="首次使用需要准备以下组件。缺失的项目点右侧按钮即可自动安装;" + "全部就绪后点「进入程序」。", + font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED, bg=COLOR_BG, + justify="left", + ).pack(anchor="w", pady=(4, 0)) + + ttk.Separator(self.root).pack(fill="x", padx=20, pady=(10, 4)) + + body = tk.Frame(self.root, bg=COLOR_BG) + body.pack(fill="both", expand=False, padx=20) + body.columnconfigure(1, weight=1) + + for row, item in enumerate(self.items): + icon = tk.Label(body, text=ICON_IDLE, font=("Segoe UI Symbol", 13, "bold"), + fg=COLOR_IDLE, bg=COLOR_BG, width=2) + icon.grid(row=row, column=0, sticky="nw", pady=(6, 0)) + + text = tk.Frame(body, bg=COLOR_BG) + text.grid(row=row, column=1, sticky="ew", padx=(2, 10), pady=(6, 0)) + name = tk.Label(text, text=item.label, font=("Microsoft YaHei UI", 10, "bold"), + bg=COLOR_BG, anchor="w") + name.pack(anchor="w") + detail = tk.Label(text, text=item.description, font=("Microsoft YaHei UI", 8), + fg=COLOR_MUTED, bg=COLOR_BG, anchor="w", justify="left", + wraplength=560) + detail.pack(anchor="w") + + button = ttk.Button(body, text=item.install_label, width=10, + command=lambda i=item: self.install_one(i)) + button.grid(row=row, column=2, sticky="ne", pady=(6, 0)) + if item.install is None: + button.state(["disabled"]) + + tag = tk.Label(body, text="必需" if item.required else "可选", + font=("Microsoft YaHei UI", 8), + fg=COLOR_BAD if item.required else COLOR_MUTED, + bg=COLOR_BG, width=5) + tag.grid(row=row, column=3, sticky="ne", pady=(6, 0)) + + self._rows[item.key] = { + "icon": icon, "detail": detail, "button": button, "item": item + } + + ttk.Separator(self.root).pack(fill="x", padx=20, pady=(12, 6)) + + log_frame = tk.Frame(self.root, bg=COLOR_BG) + log_frame.pack(fill="both", expand=True, padx=20) + tk.Label(log_frame, text="安装日志", font=("Microsoft YaHei UI", 9, "bold"), + bg=COLOR_BG).pack(anchor="w") + self.log = tk.Text(log_frame, height=6, wrap="word", font=("Consolas", 8), + bg="#f6f8fa", fg="#24292f", relief="flat", + highlightthickness=1, highlightbackground="#d0d7de") + scroll = ttk.Scrollbar(log_frame, command=self.log.yview) + self.log.configure(yscrollcommand=scroll.set) + scroll.pack(side="right", fill="y") + self.log.pack(side="left", fill="both", expand=True, pady=(3, 0)) + self.log.configure(state="disabled") + + footer = tk.Frame(self.root, bg=COLOR_BG) + footer.pack(fill="x", padx=20, pady=(10, 16)) + + self.status_label = tk.Label(footer, text="正在检测…", + font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED, + bg=COLOR_BG) + self.status_label.pack(side="left") + + self.confirm_button = ttk.Button(footer, text="进入程序", width=16, + command=self._on_confirm) + self.confirm_button.pack(side="right", padx=(8, 0)) + self.confirm_button.state(["disabled"]) + + self.install_all_button = ttk.Button(footer, text="全部安装", width=12, + command=self.install_all) + self.install_all_button.pack(side="right", padx=(8, 0)) + + self.recheck_button = ttk.Button(footer, text="重新检测", width=12, + command=self.refresh_all) + self.recheck_button.pack(side="right") + + # ---------------- 日志 ---------------- + def append_log(self, text: str) -> None: + self.log.configure(state="normal") + self.log.insert("end", text.rstrip() + "\n") + self.log.see("end") + self.log.configure(state="disabled") + + # ---------------- 检测 ---------------- + def refresh_all(self) -> None: + if self.installing: + return + for item in self.items: + self.state[item.key] = {"ok": None, "detail": "检测中…", "busy": False} + self._render(item.key) + self._update_footer() + threading.Thread(target=self._check_worker, name="env-check", daemon=True).start() + + def _check_worker(self) -> None: + for item in self.items: + try: + ok, detail = item.check() + except Exception as exc: + ok, detail = False, f"检测异常:{exc}" + self.events.put(("check", (item.key, ok, detail))) + self.events.put(("checks_done", None)) + + # ---------------- 安装 ---------------- + def install_one(self, item: EnvItem) -> None: + if self.installing or item.install is None: + return + state = self.state[item.key] + if state.get("ok") is True: + return + self.installing = True + self.install_all_button.state(["disabled"]) + self.recheck_button.state(["disabled"]) + self._set_busy(item.key, "正在安装…") + self.append_log(f"===== 开始安装:{item.label} =====") + threading.Thread(target=self._install_worker, args=([item],), + name=f"install-{item.key}", daemon=True).start() + + def install_all(self) -> None: + if self.installing: + return + pending = [i for i in self.items + if i.install is not None and self.state[i.key].get("ok") is not True] + if not pending: + self.append_log("没有需要安装的项目。") + return + self.installing = True + self.install_all_button.state(["disabled"]) + self.recheck_button.state(["disabled"]) + for item in pending: + self._set_busy(item.key, "排队中…") + threading.Thread(target=self._install_worker, args=(pending,), + name="install-all", daemon=True).start() + + def _install_worker(self, items: list[EnvItem]) -> None: + for item in items: + self.events.put(("busy", (item.key, "正在安装…"))) + + def log(level: str, message: str, _label: str = item.label) -> None: + self.events.put(("log", f"[{_label}] {message}")) + + try: + assert item.install is not None + ok, detail = item.install(log) + except Exception as exc: + ok, detail = False, f"安装异常:{exc}" + self.events.put(("install", (item.key, ok, detail))) + self.events.put(("installs_done", None)) + + # ---------------- UI 线程泵 ---------------- + def _pump(self) -> None: + try: + while True: + kind, payload = self.events.get_nowait() + if kind == "check": + key, ok, detail = payload + self.state[key] = {"ok": ok, "detail": detail, "busy": False} + self._render(key) + elif kind == "checks_done": + self._update_footer() + elif kind == "busy": + key, detail = payload + self.state[key]["busy"] = True + self.state[key]["detail"] = detail + self._render(key) + elif kind == "install": + key, ok, detail = payload + self.state[key] = {"ok": ok, "detail": detail, "busy": False} + self._render(key) + self.append_log(("安装成功:" if ok else "安装失败:") + detail) + elif kind == "installs_done": + self.installing = False + self.install_all_button.state(["!disabled"]) + self.recheck_button.state(["!disabled"]) + self._update_footer() + # 安装后可能影响其它项(例如驱动装好后设备就能发现了),重新检测一遍 + self.refresh_all() + elif kind == "log": + self.append_log(payload) + except queue.Empty: + pass + if not self.proceeded: + self.root.after(80, self._pump) + + # ---------------- 渲染 ---------------- + def _render(self, key: str) -> None: + row = self._rows.get(key) + if row is None: + return + state = self.state[key] + item: EnvItem = row["item"] + ok = state.get("ok") + busy = state.get("busy") + + if busy: + icon, color = ICON_BUSY, COLOR_BUSY + elif ok is True: + icon, color = ICON_OK, COLOR_OK + elif ok is False: + icon = ICON_BAD if item.required else ICON_OPT + color = COLOR_BAD if item.required else COLOR_MUTED + else: + icon, color = ICON_IDLE, COLOR_IDLE + + row["icon"].configure(text=icon, fg=color) + suffix = f" ({item.size_hint})" if item.size_hint and ok is not True else "" + row["detail"].configure(text=f"{state.get('detail', '')}{suffix}", + fg=COLOR_OK if ok is True else COLOR_MUTED) + + button = row["button"] + if ok is True: + # 已就绪:按钮文字改成"已就绪"并禁用,避免用户误以为还要再装一次 + button.configure(text="已就绪") + button.state(["disabled"]) + elif item.install is None: + button.configure(text="—") + button.state(["disabled"]) + elif busy: + button.configure(text="安装中…") + button.state(["disabled"]) + else: + button.configure(text=item.install_label) + button.state(["!disabled"]) + + def _set_busy(self, key: str, detail: str) -> None: + self.state[key]["busy"] = True + self.state[key]["detail"] = detail + self._render(key) + + def _update_footer(self) -> None: + required = [i for i in self.items if i.required] + self.state.get("__x__") + pending = [i for i in required if self.state[i.key].get("ok") is not True] + optional_bad = [i for i in self.items + if not i.required and self.state[i.key].get("ok") is False] + + if self.installing: + self.status_label.configure(text="正在安装,请稍候…", fg=COLOR_BUSY) + elif pending: + names = "、".join(i.label for i in pending) + self.status_label.configure(text=f"还差 {len(pending)} 项:{names}", fg=COLOR_BAD) + else: + text = "全部就绪,可以开始使用" + if optional_bad: + text += "(可选:" + "、".join(i.label for i in optional_bad) + " 未就绪)" + self.status_label.configure(text=text, fg=COLOR_OK) + + if not pending and not self.installing: + self.confirm_button.state(["!disabled"]) + else: + self.confirm_button.state(["disabled"]) + + any_missing = any(i.install is not None and self.state[i.key].get("ok") is not True + for i in self.items) + if any_missing and not self.installing: + self.install_all_button.state(["!disabled"]) + else: + self.install_all_button.state(["disabled"]) + + # ---------------- 结束 ---------------- + def _on_confirm(self) -> None: + self.proceeded = True + try: + self.root.destroy() + except Exception: + pass + + def _on_close(self) -> None: + self.proceeded = False + try: + self.root.destroy() + except Exception: + pass + + +def run_setup_window(title: str = "环境准备", auto_install_missing: bool = False) -> bool | None: + """显示环境准备窗口。 + + 返回: + True —— 全部必需项就绪,用户点了「进入程序」 + False —— 用户关闭了窗口(调用方应退出) + None —— 无法显示窗口(tkinter 不可用/构建失败),调用方自行降级 + """ + import traceback + + try: + import tkinter as tk + except Exception as exc: + print(f" [提示] tkinter 不可用:{exc}") + return None + + try: + root = tk.Tk() + except Exception as exc: + print(f" [提示] 无法创建窗口(可能没有图形界面):{exc}") + return None + + try: + window = SetupWindow(root, title=title) + if auto_install_missing: + root.after(600, window.install_all) + root.mainloop() + return bool(window.proceeded) + except Exception: + # 这里必须把真实原因打出来,否则窗口不显示时无从排查 + print(" [错误] 「环境准备」窗口构建失败:") + traceback.print_exc() + try: + root.destroy() + except Exception: + pass + return None diff --git a/core/tunnel.py b/core/tunnel.py index 1dce30f..4e704ae 100644 --- a/core/tunnel.py +++ b/core/tunnel.py @@ -1,54 +1,96 @@ -"""iOS 17+ 的 RSD 隧道管理。 +"""iOS 17+ 的 RSD 隧道管理(macOS / Windows 双平台)。 -背景(见 docs/DEVICE_NOTES.md 的取证结论): -- iOS 17 及以上,DVT 服务不再走 usbmux 代理,必须先在 Mac 与设备之间建立 RSD 隧道。 -- 经典隧道(kernel utun)需要 root;`pymobiledevice3 remote start-tunnel` 在 macOS 上支持 - `--native`:借用 Apple 自带 remoted 的隧道,**不需要 root**,并把内核可路由的 RSD 地址打印出来 - (--script-mode 输出 "HOST PORT"),因此可以被其它进程直接使用。 -- 该 native 隧道与 remoted 共用设备的单条 RSD 连接,会互相挤掉,所以同一时间只应有一个客户端 - (即:本工具运行时应关闭爱思助手等同类工具)。 -- 如果 native 不可用,退化为 `osascript` 弹窗向用户索取管理员密码,再跑经典隧道。 +背景 +---- +iOS 17 起 DVT 服务不再走 usbmux,必须先在主机与设备之间建立 RSD 隧道。 + +两个平台的建立方式**本质不同**,这是本模块最需要注意的地方: + +macOS + 用 ``remote start-tunnel --native``:借 Apple 自带 ``remoted`` 的隧道 + (不建内核 utun,因而不需要 root),打印出**内核可路由**的 ``HOST PORT``, + 主进程用普通 TCP 连上去即可 → 走"子进程 + 解析 stdout"的老路。 + +Windows + 没有 XPC/Objective-C,native 隧道不存在。改用 ``UserspaceRsdTunnel``: + 用纯 Python 的 PyTCP 用户态协议栈实现 iOS 17+ 隧道,**不需要管理员权限、 + 不需要 Wintun 驱动**。 + + ⚠️ 关键差异:用户态隧道的设备地址**只在本进程的协议栈里可达** + (``rsd.is_in_process_tunnel is True``)。用普通 socket 去连 ``(host, port)`` + 会直接失败(Windows 上表现为 ``OSError [WinError 1231] 网络位置不可达``)。 + pymobiledevice3 的做法是把一个 relay dialer 注入它返回的 + ``RemoteServiceDiscoveryService`` 对象(``open_connection=dial_plane.dial``), + 所以**必须把这个 rsd 对象本身交给 DVT 层**(``RealLocationProvider(rsd=...)``), + 而不是把 ``(host, port)`` 传出去另建连接。 + + 这也意味着 Windows 上 **不能**沿用 macOS 的"隧道助手子进程"架构: + 子进程的协议栈不能给父进程用。隧道必须在应用自己的事件循环里建立并持有。 + +实测(iPhone 11 / iOS 26.6.1,Windows 11 x64): + 隧道建立耗时约 0.3 秒,无需管理员权限,无需安装 Wintun 驱动。 """ from __future__ import annotations import os import re import shutil -import signal import subprocess import threading import time from typing import Any +from . import platform as plat from . import protocol POLL_INTERVAL = 0.25 START_TIMEOUT = 75.0 -_HINT_NATIVE_FAILED = ( + +#: Windows 上不需要提权;macOS 的 sudo 兜底路径保留 +_HINT_MAC_FAILED = ( "无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;" "② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);" "④ 若仍失败,可尝试管理员模式:在终端执行 sudo ./run.sh" ) -_HINT_NATIVE_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动" +_HINT_MAC_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动" + +_HINT_WIN_FAILED = ( + "无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;" + "② 已在手机上开启「开发者模式」并点过「信任此电脑」;" + "③ 关闭爱思助手 / i4Tools 等同类工具(同一个 RSD 连接会被互相挤掉);" + "④ 若提示缺少 Apple Mobile Device Support,请运行「修复驱动」。" +) +_HINT_WIN_FAILED_SHORT = "隧道建立失败:请确认设备已解锁并信任本电脑,且已关闭同类工具" + +_HINT_NATIVE_FAILED = _HINT_WIN_FAILED if plat.IS_WINDOWS else _HINT_MAC_FAILED +_HINT_NATIVE_FAILED_SHORT = ( + _HINT_WIN_FAILED_SHORT if plat.IS_WINDOWS else _HINT_MAC_FAILED_SHORT +) class TunnelManager: - """建立/持有/释放 RSD 隧道,并对外暴露 (host, port)。""" + """建立/持有/释放 RSD 隧道,并对外暴露 (host, port) 与(Windows)rsd 对象。""" - def __init__(self, venv_python: str | None = None, log=None, on_change=None) -> None: + def __init__(self, venv_python: str | None = None, log=None, on_change=None, + runner=None) -> None: self._lock = threading.RLock() self.state = "idle" # idle|starting|connected|error|unsupported self.message = "" self.host: str | None = None self.port: int | None = None self.udid: str | None = None - self.mode: str | None = None # native|sudo|manual + self.mode: str | None = None # native|userspace|manual|sudo self._process: subprocess.Popen[str] | None = None self._reader: threading.Thread | None = None self._needs_root = False self._log = log self._on_change = on_change - self.venv_python = venv_python or _detect_python() + #: Windows:应用的事件循环运行器(core/server.py:AsyncRunner)。 + #: 用户态隧道必须建在这个循环上,并被本对象一直持有。 + self._runner = runner + self._userspace_tunnel: Any = None + self._rsd: Any = None + self.venv_python = venv_python or plat.python_for_subprocess() # ---------------- 状态 ---------------- def snapshot(self) -> dict[str, Any]: @@ -59,8 +101,20 @@ class TunnelManager: "rsd": f"{self.host} {self.port}" if self.host and self.port else None, "mode": self.mode, "udid": self.udid, + # 前端据此提示"隧道只在本进程内可用",不要尝试用外部工具连它 + "in_process": bool(self._rsd is not None + and getattr(self._rsd, "is_in_process_tunnel", False)), + "platform": plat.platform_label(), } + def rsd_object(self) -> Any: + """返回**已连接**的 RSD 对象(Windows 用户态隧道必需)。 + + DVT 层必须复用它:它的拨号器绑定在本进程的用户态协议栈上。 + """ + with self._lock: + return self._rsd + def _emit(self, level: str, message: str) -> None: if self._log: try: @@ -101,19 +155,84 @@ class TunnelManager: return self.start(udid=udid, allow_sudo=allow_sudo) def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict[str, Any]: + self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None, + host=None, port=None) + if plat.IS_WINDOWS: + return self._start_userspace(udid) + return self._start_subprocess(udid, allow_sudo) + + # ---------------- Windows:进程内用户态隧道 ---------------- + def _start_userspace(self, udid: str | None) -> dict[str, Any]: + """Windows:在应用事件循环上建立 userspace 隧道,并持有 rsd 对象。""" + if self._runner is None: + self._set_state( + "error", + "内部错误:Windows 用户态隧道需要事件循环运行器(runner),但未注入", + mode="userspace", + ) + return self.snapshot() + try: + from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel + except Exception as exc: + self._set_state("unsupported", f"无法加载 userspace 隧道模块:{exc}", + mode="userspace") + return self.snapshot() + + self._emit("info", "正在建立 userspace RSD 隧道(进程内 PyTCP 协议栈,无需管理员权限)…") + + async def _open() -> tuple[Any, Any]: + tunnel = UserspaceRsdTunnel(serial=udid, autopair=True, + remotepairing_fallback=True) + rsd = await tunnel.aopen() + return tunnel, rsd + + started = time.monotonic() + try: + tunnel, rsd = self._runner.run(_open(), timeout=START_TIMEOUT) + except Exception as exc: + message = f"隧道建立失败:{exc}" + if "already active" in str(exc): + message = ("本进程已有一条 userspace 隧道(PyTCP 协议栈是进程内全局单例)," + "请先断开后再重试") + self._set_state("error", message, mode="userspace") + self._emit("error", f"[tunnel] {message}") + return self.snapshot() + + address = tuple(getattr(rsd.service, "address", ()) or ()) + if len(address) < 2: + try: + self._runner.run(_close_userspace(tunnel), timeout=20) + except Exception: + pass + self._set_state("error", "隧道已建立但未拿到 RSD 地址", mode="userspace") + return self.snapshot() + + host, port = str(address[0]), int(address[1]) + in_process = bool(getattr(rsd, "is_in_process_tunnel", False)) + with self._lock: + self._userspace_tunnel = tunnel + self._rsd = rsd + elapsed = time.monotonic() - started + detail = (f"隧道已建立(userspace,{elapsed:.1f}s):{host} 端口 {port}" + + ("(进程内,仅供本程序使用)" if in_process else "")) + self._emit("info", f"[tunnel] {detail}") + self._set_state("connected", detail, host=host, port=port, mode="userspace") + return self.snapshot() + + # ---------------- macOS:子进程 + 解析 stdout ---------------- + def _start_subprocess(self, udid: str | None, allow_sudo: bool) -> dict[str, Any]: # 设备在 iOS 17+ 上只有一条 RSD 连接:残留的旧隧道进程会与新的互相挤掉, # 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。 if not allow_sudo: self._kill_stale_tunnels() - self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None, host=None, port=None) command, mode = self._build_command(udid=udid, allow_sudo=allow_sudo) if command is None: self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None) return self.snapshot() env = dict(os.environ) - env.setdefault("PYTHONUNBUFFERED", "1") - env.setdefault("PYTHONPATH", str(_project_root())) + env.setdefault("PYTHONUNBUFFERED", "1") # 不加的话 stdout 是块缓冲,HOST PORT 读不到 + env.setdefault("PYTHONPATH", str(plat.resolve_roots()[0])) self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}") try: process = subprocess.Popen( @@ -124,7 +243,7 @@ class TunnelManager: text=True, bufsize=1, env=env, - start_new_session=True, + **plat.spawn_detached_kwargs(), ) except Exception as exc: self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode) @@ -136,7 +255,8 @@ class TunnelManager: self._needs_root = mode == "sudo" if mode != "sudo": _write_pidfile([process.pid]) - self._reader = threading.Thread(target=self._read_output, args=(process,), name="tunnel-reader", daemon=True) + self._reader = threading.Thread(target=self._read_output, args=(process,), + name="tunnel-reader", daemon=True) self._reader.start() deadline = time.time() + START_TIMEOUT @@ -153,18 +273,15 @@ class TunnelManager: if not self.ready: message = _HINT_NATIVE_FAILED_SHORT if mode == "sudo": - message = "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错" + message = ("管理员隧道启动失败:请查看具体报错后重试" + if plat.IS_WINDOWS else + "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错") self.stop() self._set_state("error", message, mode=mode) return self.snapshot() def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]: - """拼出"建隧道"命令。 - - - 打包成 .app(sys.frozen):`<本可执行文件> tunnel-helper --udid X` - —— 这条路径不依赖机器上有 Python/pymobiledevice3; - - 源码运行:`python -m pymobiledevice3 remote start-tunnel --native --script-mode`。 - """ + """拼出"建隧道"命令(**仅 macOS 使用**;Windows 走进程内 API)。""" import sys if getattr(sys, "frozen", False): @@ -179,65 +296,51 @@ class TunnelManager: command += ["--udid", udid] if not allow_sudo: return command, "native" - inner = " ".join(_shell_quote(part) for part in command) - script = f'do shell script "{inner} > /tmp/iosloc-tunnel.log 2>&1 &" with administrator privileges' - return ["osascript", "-e", script], "sudo" + if plat.IS_MACOS: + inner = " ".join(_shell_quote(part) for part in command) + script = (f'do shell script "{inner} > /tmp/iosloc-tunnel.log 2>&1 &" ' + f"with administrator privileges") + return ["osascript", "-e", script], "sudo" + # Windows 的 userspace 隧道不需要提权;走到这里说明上层误用了 sudo 路径 + return command, "native" def _kill_stale_tunnels(self) -> None: """清理**本工具自己**遗留在后台的 start-tunnel 进程。 iOS 17+ 设备只有一条 RSD 连接:残留进程会与新建的隧道互相挤掉。 - 为了避免误杀用户在终端手动起的隧道或其它工具,这里只处理本项目上次运行时 - 记录在 pid 文件里的进程(会先用 ps 校验命令行确实是 start-tunnel)。 + 为避免误杀用户手动起的隧道或其它工具,只处理本项目记录在 pid 文件里的进程, + 并且会先校验命令行确实属于本工具。 """ pids = _read_pidfile() if not pids: return alive: list[int] = [] for pid in pids: - command = _command_of(pid) - if command and "start-tunnel" in command: + if not plat.pid_alive(pid): + continue + command = plat.process_cmdline(pid) or "" + if _looks_like_our_tunnel(command): alive.append(pid) _write_pidfile([]) if not alive: return self._emit("warn", f"发现 {len(alive)} 个上次遗留的隧道进程,正在清理…") for pid in alive: - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - continue - except Exception: - continue - for _ in range(10): - time.sleep(0.3) - if _command_of(pid) is None: - break - else: - try: - os.kill(pid, signal.SIGKILL) - except Exception: - pass + plat.terminate_process(pid, force=False, timeout=3.0) def _cli_command(self) -> list[str] | None: - """返回"建隧道"命令的前缀。 - - - 打包成 .app 独立运行时(sys.frozen):用本可执行文件 + `tunnel-helper` 子命令 - (递归调用自己),因此**不需要机器上装 Python 或 pymobiledevice3**; - - 源码运行时:优先 venv 里的 `python -m pymobiledevice3`,其次 PATH 里的可执行文件。 - """ + """返回"建隧道"命令的前缀(源码运行时的 macOS 路径)。""" import sys if getattr(sys, "frozen", False): return [sys.executable, "tunnel-helper"] - candidate = _detect_python() + candidate = plat.python_for_subprocess() if candidate: try: probe = subprocess.run( - [candidate, "-c", "import pymobiledevice3, sys; print(pymobiledevice3.__version__ if hasattr(pymobiledevice3,'__version__') else 'ok')"], - capture_output=True, - text=True, - timeout=25, + [candidate, "-c", + "import pymobiledevice3; print('ok')"], + capture_output=True, text=True, timeout=25, ) if probe.returncode == 0: return [candidate, "-m", "pymobiledevice3"] @@ -271,7 +374,8 @@ class TunnelManager: with self._lock: code = process.poll() if code is not None and not self.ready: - self._set_state("error", f"隧道进程已退出(代码 {code}):{_HINT_NATIVE_FAILED_SHORT}") + self._set_state("error", + f"隧道进程已退出(代码 {code}):{_HINT_NATIVE_FAILED_SHORT}") # ---------------- 手动 / 释放 ---------------- def use_manual(self, host: str, port: int, udid: str | None = None) -> dict[str, Any]: @@ -283,28 +387,32 @@ class TunnelManager: if not host: raise protocol.AppError("RSD 地址不能为空", "bad_rsd") self.stop() - self._set_state("connected", f"已使用手动指定的 RSD:{host} 端口 {iport}", host=host, port=iport, mode="manual", udid=udid) + self._set_state("connected", f"已使用手动指定的 RSD:{host} 端口 {iport}", + host=host, port=iport, mode="manual", udid=udid) return self.snapshot() def stop(self) -> None: + # ---- Windows:释放进程内用户态隧道 ---- + with self._lock: + tunnel = self._userspace_tunnel + self._userspace_tunnel = None + self._rsd = None + if tunnel is not None: + try: + if self._runner is not None: + self._runner.run(_close_userspace(tunnel), timeout=25) + else: # pragma: no cover + tunnel.close() + except Exception as exc: + self._emit("warn", f"释放用户态隧道时出错(可忽略):{exc}") + + # ---- macOS:结束子进程 ---- with self._lock: process = self._process self._process = None if process is not None and process.poll() is None: - try: - os.killpg(os.getpgid(process.pid), signal.SIGTERM) - except Exception: - try: - process.terminate() - except Exception: - pass - try: - process.wait(timeout=4) - except Exception: - try: - os.killpg(os.getpgid(process.pid), signal.SIGKILL) - except Exception: - pass + plat.terminate_process_tree(process) + with self._lock: self.host = None self.port = None @@ -315,6 +423,37 @@ class TunnelManager: _write_pidfile([]) +async def _close_userspace(tunnel: Any) -> None: + """释放 userspace 隧道。 + + 注意:``UserspaceRsdTunnel.close()`` 是同步壳,内部会 ``aclose()`` 但**不 await**, + 直接调用会留下未 await 的协程并且隧道不会真正释放 —— 必须 await ``aclose()``。 + """ + aclose = getattr(tunnel, "aclose", None) + if aclose is not None: + await aclose() + return + close = getattr(tunnel, "close", None) + if close is not None: + result = close() + if hasattr(result, "__await__"): + await result + + +def _looks_like_our_tunnel(command: str) -> bool: + """判断某个进程是否"本工具建立的隧道进程",避免误杀。 + + macOS 子进程模式:``... -m pymobiledevice3 remote start-tunnel --native ...`` + 打包后的自调用:``iOSLocationTrack.exe tunnel-helper ...`` + """ + if not command: + return False + lowered = command.lower() + if "tunnel-helper" in lowered: + return True + return "pymobiledevice3" in lowered and "start-tunnel" in lowered + + def _parse_rsd_line(text: str, pattern: re.Pattern[str]) -> tuple[str, int] | None: if text.startswith("--rsd "): parts = text.split() @@ -355,7 +494,7 @@ def _mask(command: list[str]) -> list[str]: def _pidfile_path() -> str: - return os.path.join(_project_root(), ".tunnel.pids") + return os.path.join(str(plat.data_dir()), ".tunnel.pids") def _read_pidfile() -> list[int]: @@ -379,18 +518,6 @@ def _write_pidfile(pids: list[int]) -> None: pass -def _command_of(pid: int) -> str | None: - """返回进程命令行;进程不存在返回 None。""" - try: - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True, timeout=5 - ) - except Exception: - return None - text = (result.stdout or "").strip() - return text or None - - def _shell_quote(text: str) -> str: return "'" + text.replace("'", "'\\''") + "'" @@ -400,35 +527,36 @@ def _project_root() -> str: import sys if getattr(sys, "frozen", False): - return _writable_data_dir() + return str(plat.data_dir()) return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _writable_data_dir() -> str: - """打包运行时可写的数据目录(~/Library/Application Support/...)。""" - base = os.path.expanduser("~/Library/Application Support/iOSLocationTrack") - try: - os.makedirs(base, exist_ok=True) - except OSError: - base = os.path.expanduser("~") - return base + return str(plat.data_dir()) def _detect_python() -> str | None: - """返回项目 venv 的 python,找不到就退回当前解释器。""" - import sys - - root = _project_root() - for candidate in ( - os.path.join(root, ".venv", "bin", "python"), - os.path.join(root, ".venv", "bin", "python3"), - ): - if os.path.exists(candidate): - return candidate - return sys.executable + return plat.python_for_subprocess() def tunnel_supported() -> bool: - import platform + """Windows(userspace)与 macOS(native)都支持自动建隧道。""" + return plat.IS_WINDOWS or plat.IS_MACOS - return platform.system() == "Darwin" + +def mounter_hint(udid: str | None = None) -> str: + """给用户的"挂载开发者镜像"命令行提示(按平台给出可直接粘贴的命令)。""" + tail = f" --udid {udid}" if udid else "" + if plat.IS_WINDOWS: + return (f".venv\\Scripts\\python.exe -m pymobiledevice3 mounter auto-mount{tail}") + return f"./.venv/bin/python -m pymobiledevice3 mounter auto-mount{tail}" + + +def python_hint() -> str: + """解释器路径提示(错误文案里用)。""" + return ".venv\\Scripts\\python.exe" if plat.IS_WINDOWS else "./.venv/bin/python" + + +def run_hint() -> str: + """启动脚本提示。""" + return ".\\scripts\\run.ps1" if plat.IS_WINDOWS else "./run.sh" diff --git a/docs/README-WINDOWS.md b/docs/README-WINDOWS.md new file mode 100644 index 0000000..8011916 --- /dev/null +++ b/docs/README-WINDOWS.md @@ -0,0 +1,294 @@ +# Windows 版说明(iOS 虚拟定位轨迹工具) + +> 本文档面向 Windows 用户与维护者。 +> macOS 侧说明见 [README.md](../README.md);移植决策与背景见 [WINDOWS_PORT.md](WINDOWS_PORT.md)。 + +--- + +## 1. 三分钟上手 + +1. 运行 `iOSLocationTrack-1.0.0-setup.exe` 安装,或直接双击绿色版 + `dist\iOSLocationTrack\iOSLocationTrack.exe`。 +2. **程序会请求管理员权限**(UAC 弹窗点「是」)—— 用于首次自动安装 Apple 驱动。 +3. 弹出**「环境准备」**窗口,逐项列出所需组件: + - 已就绪的项目显示绿色 ✓,右侧按钮变成「已就绪」(不可点) + - 缺失的项目显示红色 ✗,右侧是可点的「安装 / 下载」按钮 + - **必需项全部就绪后,「进入程序」才会变亮** +4. 点「进入程序」→ 浏览器以无地址栏应用窗口打开界面。 +5. 用数据线连接 iPhone → 解锁 → 手机上点「信任」→ 界面点「连接设备」。 + +> 从源码运行:`.\scripts\run.ps1`(会自动建 venv、装依赖、补镜像素材)。 + +--- + +## 2. 「环境准备」窗口检查哪些项 + +| 项目 | 必需 | 检查方式 | 缺失时的「安装」做什么 | +| --- | --- | --- | --- | +| Python 运行时 | ✅ | 版本号 | —(已随 exe 打包) | +| pymobiledevice3 | ✅ | 导入 + 版本 | —(已随 exe 打包) | +| **Apple Mobile Device Support** | ✅ | 服务状态 + 27015 协议往返 | 自动下载安装(约 40~200 MB) | +| RSD 隧道依赖 | ✅ | `pytun_pmd3` / `pmd_pytcp` 等导入 | —(已随 exe 打包) | +| 开发者镜像 | ✅ | `resources/ddi/personalized/` 三个文件 | 下载个性化镜像(约 15 MB) | +| 管理员权限 | ✅ | `IsUserAnAdmin` | 右键「以管理员身份运行」 | +| iPhone 连接 | ➖ 可选 | usbmuxd `ListDevices` | —(接上手机即可) | +| 网络连通 | ➖ 可选 | TCP 连 `gs.apple.com:443` | —(挂载镜像时 Apple 需在线签名) | + +**判定 usbmuxd「能用」的唯一权威信号是 127.0.0.1:27015 上的协议往返**,不是端口能连上。 +`ReadBUID` 不需要插设备,因此可以在没有手机的情况下单独验证服务是否正常。 + +### 想看文字版自检 + +```powershell +# 绿色版 +.\dist\iOSLocationTrack\iOSLocationTrack.exe --check-driver +# 源码 +.venv\Scripts\python.exe main.py --check-driver +``` + +输出示例: + +``` +平台:Windows(管理员:是) +服务:RUNNING(Apple Mobile Device Service) +版本:Apple Mobile Device Support 20.0.0.35 +usbmuxd(127.0.0.1:27015):可通信 +设备:1 台 + · 00008030-001551840A02402E(USB) +结论:Apple Mobile Device Service 正在运行,usbmuxd 可通信 +``` + +--- + +## 3. 自动安装 Apple Mobile Device Support + +「环境准备」窗口里点 Apple 那一行的「安装」,或在命令行跑: + +```powershell +main.py --install-driver # 源码 +iOSLocationTrack.exe --install-driver # 绿色版 +``` + +安装策略按顺序自动降级(`core/apple_driver.py`): + +| 顺序 | 策略 | 说明 | +| --- | --- | --- | +| 0 | 启动已有服务 | 服务已装但没运行时,`sc start` 一下就够(最常见、最便宜) | +| 1 | 独立 AMDS MSI | Apple CDN 上的 `AppleMobileDeviceSupport64.msi`(约 38 MB) | +| 2 | 完整 iTunes 包 | `https://www.apple.com/itunes/download/win64`(约 193 MB) | +| 3 | winget | `Apple.AppleMobileDeviceSupport` / `Apple.iTunes` | + +成功退出码:`0`、`3010`、`1641`、`1638`(MSI 语义,3010/1641 表示需要重启)。 + +### 必须知道的几个坑(都已在真机取证) + +1. **服务名是带空格的 `Apple Mobile Device Service`**。 + `AppleMobileDeviceService` 只是 exe 文件名,`sc query AppleMobileDeviceService` 会报 1060。 +2. **Windows 上没有 `\\.\pipe\usbmuxd`**,usbmuxd 只暴露为 TCP `127.0.0.1:27015`。 +3. **不要用 `usbaapl64` 判断驱动是否存在**。AMDS 的 MSI 里 **0 个 `.inf`/`.sys`/`.cat`**, + 它**不提供 USB 驱动**;现代机器上 iPhone 实际绑定 `appleusb.inf` + (服务名 `WINUSB` / `usbccgp`)。用 `usbaapl64` 判断会在健康机器上误报「缺失」。 +4. **AMDS 的 ProductCode 每个版本都变**,稳定的标识是 UpgradeCode + `{5CF0656E-F3B8-11DB-BA81-804E56D89593}`(x86/x64 通用)。 +5. **Apple 没有稳定的「latest」直链**;`iTunes64Setup.exe` 的地址带构建号。因此 + 本项目对能固定 SHA256 的文件做校验,校验不过会明确报错而不是装上一个来路不明的包。 +6. **Microsoft Store 版 iTunes 与经典版互斥**:经典安装包的 `LaunchCondition` 里写死了 + `NOT ITUNES_STORE_APP_INSTALLED`。另外 Store 版的守护进程**不支持 Wi-Fi 发现设备**。 + pymobiledevice3 官方也推荐经典版。 + +--- + +## 4. 与 macOS 版的架构差异(**重要**) + +移植时最容易踩的坑:**两个平台的 RSD 隧道建立方式本质不同**, +所以 `core/tunnel.py` 里 Windows 与 macOS 走的是两条完全不同的路径。 + +| | macOS | Windows | +| --- | --- | --- | +| 隧道方式 | `remote start-tunnel --native`(借 Apple `remoted`) | `UserspaceRsdTunnel`(纯 Python PyTCP 用户态协议栈) | +| 需要 root/管理员 | 不需要 | **不需要** | +| 需要第三方驱动 | 否 | **否**(不需要 Wintun) | +| RSD 地址可达范围 | 内核可路由,**任何进程**都能连 | **仅本进程**可达(用户态协议栈内) | +| 实现方式 | 子进程 + 解析 stdout 的 `HOST PORT` | 在本应用的事件循环里建立并持有 rsd 对象 | +| 实测耗时 | — | **约 0.3 秒** | + +### 为什么 Windows 不能用「隧道助手子进程」那套 + +macOS 版把隧道跑在子进程里,打印 `HOST PORT`,主进程用普通 TCP 连上去。 +Windows 上这样行不通: + +- 用户态隧道的设备地址**只存在于建立它的那个进程的协议栈里**; +- pymobiledevice3 的做法是把一个 relay dialer 注入它返回的 + `RemoteServiceDiscoveryService` 对象(`open_connection=dial_plane.dial`); +- 拿 `(host, port)` 另建一个 RSD 会退回 stdlib dialer,直接打到操作系统网络栈上, + 结果是 `OSError [WinError 1231] 网络位置不可达`(本项目实测复现过)。 + +**正确做法**:把隧道返回的 **rsd 对象本身**注入给 DVT 层。 +`RealLocationProvider` 早就预留了 `rsd=` 注入口(`core/location_provider.py`), +`core/server.py:provider_for()` 在 Windows 上走的就是这条路。 + +### 关于 Wintun + +`docs/WINDOWS_PORT.md` 让先装 Wintun,**实测不需要**: + +- userspace 隧道走纯 Python 的 PyTCP 协议栈,不创建内核网卡; +- 而且 `pytun_pmd3` 这个依赖**自带**各架构的 `wintun.dll` + (`pytun_pmd3/wintun/bin/amd64/wintun.dll`),本来也不用另外下载。 + +### 实测环境 + +| 项 | 值 | +| --- | --- | +| 系统 | Windows 11 家庭版 中文版(10.0.26200,x64) | +| Python | 3.12.14 | +| pymobiledevice3 | 11.31.0 | +| 设备 | iPhone 11(`iPhone12,1`),**iOS 26.6.1** | +| Apple Mobile Device Support | 20.0.0.35 | +| iTunes | 12.13.11.1(经典版) | + +连接耗时实测 **0.8 秒**(隧道 0.3s + 镜像/DVT 会话)。 + +--- + +## 5. 数据与日志位置 + +| 内容 | 路径 | +| --- | --- | +| 配置 / 瓦片缓存 / 日志 | `%LOCALAPPDATA%\iOSLocationTrack\` | +| 应用日志 | `%LOCALAPPDATA%\iOSLocationTrack\logs\app.log` | +| MSI 安装日志 | `%LOCALAPPDATA%\iOSLocationTrack\logs\msi-*.log` | +| 下载的安装包缓存 | `%LOCALAPPDATA%\iOSLocationTrack\installers\` | +| 开发者镜像缓存 | `%USERPROFILE%\.pymobiledevice3\Xcode_iOS_DDI_Personalized\` | + +`app.log` 是 UTF-8、Tab 分隔:`时间戳 \t 级别 \t 消息`。 +用记事本或 `Get-Content -Encoding UTF8` 打开(PowerShell 5.1 默认按 ANSI 读会乱码)。 + +--- + +## 6. 构建 + +```powershell +# 一键:图标 → 镜像素材 → 测试 → PyInstaller → Inno Setup +.\scripts\build.ps1 + +# 只打 exe +.\scripts\build.ps1 -NoInstaller + +# 跳过测试快速重建 +.\scripts\build.ps1 -SkipTests -SkipIcon -SkipDdi +``` + +产物: + +| 文件 | 说明 | +| --- | --- | +| `dist\iOSLocationTrack\iOSLocationTrack.exe` | 绿色版 onedir,整个目录一起拷走 | +| `dist\installer\iOSLocationTrack-1.0.0-setup.exe` | 安装包(Inno Setup 6) | + +**前置条件:** + +```powershell +python -m venv .venv +.venv\Scripts\python.exe -m pip install -i https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3 pillow pyinstaller +winget install -e --id JRSoftware.InnoSetup # 可选,只影响安装包 +``` + +### 打包时踩过的坑(都已修好,改 spec 时注意别踩回去) + +1. **不要排除 `distutils` / `setuptools` / `wheel` / `pip`**。 + PyInstaller 与 setuptools 的 hook 会强制导入或别名它们,排除后构建直接失败: + ``` + ValueError: Target module "distutils" already imported as ExcludedModule('distutils',) + ValueError: Target module "wheel" already imported as ExcludedModule('wheel',) + ``` +2. **不要排除 `tkinter`** ——「环境准备」窗口靠它(见下)。 +3. **`copy_metadata` 必须带上** `apple_compress` 等包,否则挂载镜像时报 + `No package metadata was found for apple_compress`。 +4. **不要排除 `questionary` / `prompt_toolkit` / `pygments`**, + 它们是 pymobiledevice3 运行期导入的;排掉后真机功能会静默失效 + (表现为设备发现退化成 libimobiledevice)。 +5. **`--windowed` 时 `sys.stdout` 是 `None`**,打印前必须判空。 +6. **`.ps1` 必须带 UTF-8 BOM**。Windows PowerShell 5.1 只有在文件开头看到 BOM 时才按 + UTF-8 解析脚本,否则按系统 ANSI 代码页(中文机器上是 GBK)读 —— 结果是中文全部乱码, + 并且**脚本直接语法报错**(`The string is missing the terminator`)。 + `scripts\run.ps1` / `scripts\build.ps1` 都已存为 UTF-8 with BOM。 + 编辑这两个文件时务必保持 BOM,否则它们会突然无法运行。 + +### 启动时请求管理员权限 + +`Location.spec` 里 Windows 分支设了 `uac_admin=True`,PyInstaller 会把 +`requestedExecutionLevel level="requireAdministrator"` 写进 exe 清单。 +用 `strings` 或在 exe 里搜 `requireAdministrator` 可以验证。 + +> 注:userspace 隧道**本身不需要管理员权限**;提权只是为了首次运行能自动安装 +> Apple Mobile Device Support,免去用户手动右键提权。 + +--- + +## 7. 验收 + +```powershell +# 无需设备(136 项) +.venv\Scripts\python.exe tests\selftest.py # 83 项:协议/几何/播放/配置/瓦片/端到端 +.venv\Scripts\python.exe tests\drift_sim.py # 40 项:真实感模型 +.venv\Scripts\python.exe tests\offline_tiles.py # 13 项:瓦片离线缓存 + +# 平台可行性(真机,7 项) +.venv\Scripts\python.exe tests\win_e2e.py --confirm + +# 应用层(真机,9 项;走 App 的真实装配路径) +.venv\Scripts\python.exe tests\win_app_e2e.py --confirm + +# 无设备跑界面 +.venv\Scripts\python.exe main.py --mock +``` + +真机验收 6 步(对应 `WINDOWS_PORT.md` 第 7 节): + +1. `pymobiledevice3 usbmux list` 能看到设备 ✅ +2. 挂载开发者镜像成功 ✅ +3. 「连接设备」→ 隧道建立 → DVT 会话就绪 ✅ +4. 播放轨迹 → 手机定位真的移动 ✅ +5. 「停止」→ 手机恢复真实定位 ✅ +6. 应用层 9/9、平台层 7/7 全绿 ✅ + +--- + +## 8. 已知限制与注意事项 + +| 限制 | 说明 | +| --- | --- | +| **一个进程只能有一条 userspace 隧道** | PyTCP 协议栈是进程内全局单例。所以别双开本程序。 | +| **同一时间只能有一个工具占 RSD 连接** | 爱思助手 / i4Tools / Xcode 会互相挤掉,用之前先关掉。 | +| **iOS 16 及更早不支持** | 经典镜像需 Apple 按构建号签发,第三方镜像会被设备拒签。本项目面向 iOS 17+。 | +| **DVT 只能改经纬度** | `simulateLocationWithLatitude:longitude:` 只有两个参数;海拔、精度、传感器数据由 iOS 合成,无法注入。 | +| **挂载镜像需要联网** | 个性化镜像由 Apple TSS 按设备在线签发。 | +| **隧道可能静默断开** | Windows 用户态协议栈会偶发断开,设备会自己恢复真实定位而界面看起来正常。已加**保活**:设了点但停止播放时,每 15 秒重发一次(`core/location_provider.py:KEEPALIVE_INTERVAL`)。 | +| **iOS 26.4.x 的镜像挂载可能干扰 DVT** | 有同类工具报告 26.4.1 上传镜像会中断隧道,导致后续 DVT 报 `InvalidService`。本机 iOS 26.6.1 实测正常。若遇到,先在别的工具里挂好镜像再连。 | +| **未签名 exe 会有 SmartScreen 提示** | 没有代码签名证书。点「更多信息 → 仍要运行」。 | +| **Windows 10 1809 以下不支持** | 安装包 `MinVersion=10.0`。 | + +--- + +## 9. 文件对照(Windows 相关改动) + +| 文件 | 说明 | +| --- | --- | +| `core/platform.py` | **新增**。平台抽象层:数据目录、浏览器探测、进程管理、提权、信号 | +| `core/apple_driver.py` | **新增**。Apple Mobile Device Support 检测/安装 + 开发者镜像下载 | +| `core/setup_window.py` | **新增**。「环境准备」窗口(tkinter) | +| `core/tunnel.py` | **重写**。Windows 走进程内 userspace 隧道;macOS 保持子进程 native 隧道 | +| `core/server.py` | 注入 runner 给 TunnelManager;`provider_for()` 注入 rsd 对象;日志落盘;资源目录与数据目录分离 | +| `core/location_provider.py` | 平台化错误提示;**新增保活**(`_keepalive_loop`) | +| `main.py` | 「环境准备」窗口、Apple 驱动后台检查、`--check-driver` / `--install-driver`、跨平台浏览器与信号 | +| `Location.spec` | 平台分支;Windows 用 `.ico` + `uac_admin=True` + `version_info.txt`;**单一 spec,不分叉** | +| `tools/make_ico.py` | **新增**。由已有 PNG 图标集生成多尺寸 `.ico` | +| `tools/probe_tunnel_win.py` | **新增**。隧道可行性诊断脚本 | +| `scripts/run.ps1` / `build.ps1` | **新增**。Windows 启动 / 构建 | +| `installer/iosloc.iss` | **新增**。Inno Setup 安装包脚本 | +| `installer/version_info.txt` | **新增**。exe 版本资源 | +| `installer/languages/ChineseSimplified.isl` | **新增**。Inno Setup 6.7 起不再自带中文,随仓库携带 | +| `tests/win_e2e.py` / `win_app_e2e.py` | **新增**。真机验收(平台层 / 应用层) | + +macOS 侧的构建链(`tools/make_dmg.py`、`scripts/run.sh`、`build_app.sh`、 +`Location.spec` 的 BUNDLE 分支)**全部保留**,同一份代码库两个平台都能构建。 diff --git a/docs/WINDOWS_PORT.md b/docs/WINDOWS_PORT.md index 6538039..9d0d489 100644 --- a/docs/WINDOWS_PORT.md +++ b/docs/WINDOWS_PORT.md @@ -417,3 +417,45 @@ python main.py --mock # 界面能开、能画轨迹、能播放( - pymobiledevice3 文档:https://github.com/doronz88/pymobiledevice3 - userspace 隧道源码:`pymobiledevice3/remote/userspace_tunnel.py` - 开发者镜像素材来源:https://github.com/doronz88/DeveloperDiskImage + +--- + +## 11. ⚠️ 移植完成后的修正(**先读这一节**) + +以下是 Windows 侧**实际动手后**发现本文档有误的地方。上文保留原样以便对照, +但**以本节为准**。详细说明见 [`README-WINDOWS.md`](README-WINDOWS.md)。 + +| # | 本文档原文 | 实际情况 | +| --- | --- | --- | +| 1 | §3.3 说 `userspace_tunnel.UserspaceTunnel.aopen()` | **类名不存在**,正确是 `UserspaceRsdTunnel`。§3.4 已改对,§3.3 的正文漏改。 | +| 2 | §8 第 1 步让跑 `remote start-tunnel --userspace` | **该命令没有 `--userspace` 选项**。实测 `lockdown start-tunnel --userspace` 在 Windows 上会以 `Connection was terminated abruptly` 失败。**正解是 Python API**:`UserspaceRsdTunnel(...).aopen()`。 | +| 3 | §2 / §8 把 **Wintun 驱动**列为必需前置 | **完全不需要**。userspace 隧道走纯 Python PyTCP 协议栈,不建内核网卡;而且 `pytun_pmd3` 依赖里**自带** `wintun/bin/amd64/wintun.dll`。无需下载、无需安装、无需管理员权限。 | +| 4 | §3.3 问「是否需要管理员权限 / 防火墙弹窗 / 网卡残留」 | 实测:**都不需要**。免管理员、无防火墙弹窗、无虚拟网卡(因此也没有残留问题)。隧道建立约 **0.3 秒**。 | +| 5 | §3.4 说「保持公开接口不变,只替换 `_build_command()`」 | **不够**。`core/tunnel.py` 还有一批 POSIX-only 代码:`os.killpg`/`os.getpgid`、`start_new_session=True`、`ps -p`、`osascript`、`.venv/bin/python`、`~/Library/Application Support`。这些已全部收进 `core/platform.py`。 | +| 6 | §3.4 建议 Windows 沿用「隧道助手子进程 + 解析 HOST PORT」 | **架构上行不通**。用户态隧道的设备地址**只在建立它的进程里可达**;拿 `(host, port)` 另建 RSD 会得到 `OSError [WinError 1231] 网络位置不可达`。必须把 `aopen()` 返回的 **rsd 对象本身**注入 DVT 层。 | +| 7 | §5.3 只提到别排除 `distutils` | **`wheel` 同理**:Windows + setuptools 84 上会报 `ValueError: Target module "wheel" already imported as ExcludedModule('wheel',)`。`setuptools` / `pip` 也建议一并保留。 | +| 8 | §1 说 `drift_sim.py` 等「纯逻辑,跨平台」 | `drift_sim.py` 与 `offline_tiles.py` **硬编码了 `/tmp/...`**。Windows 上虽然会落到 `C:\tmp`(有权限时能跑),但已改为 `tempfile.gettempdir()`。 | +| 9 | §1 称 `resources/ddi/` 是「16 MB 可移植」 | 实际入库的只有 `BuildManifest.plist` + 两个 trustcache(约 0.8 MB);**`Image.dmg`(15.7 MB)被 `.gitignore` 排除**,新克隆必须跑 `tools/fetch_ddi.py`。已加进 `scripts\run.ps1` / `build.ps1` 自动补齐。 | +| 10 | §4.4 建议「删掉 `tools/make_dmg.py`、把 `run.sh` 改写成 `.ps1`」 | **没有删**。改成新增 `run.ps1` / `build.ps1` + `Location.spec` 平台分支,**单一份代码库两个平台都能构建**,避免分叉(§9.5 的目标)。 | + +### 实测环境与结果 + +Windows 11 x64(10.0.26200)· Python 3.12.14 · pymobiledevice3 11.31.0 · +iPhone 11(`iPhone12,1`)**iOS 26.6.1** · AMDS 20.0.0.35 · iTunes 12.13.11.1 + +- 隧道 → 镜像 → DVT → 下发定位 → 恢复真实定位:**全链路跑通**,连接耗时 0.8 秒 +- `tests/selftest.py` 83/83、`drift_sim.py` 40/40、`offline_tiles.py` 13/13 +- 新增真机验收:`tests/win_e2e.py` **7/7**、`tests/win_app_e2e.py` **9/9** +- 打包版 exe 实测:发现设备 → 0.3s 隧道 → DVT → 两次下发 → 恢复真实定位 → 干净退出 + +### 新增的 Windows 专属能力(本文档未提及) + +1. **「环境准备」窗口**(每次启动弹出):逐项列出依赖,缺失项右侧有「安装」按钮, + 必需项全部就绪后「进入程序」才可点。 +2. **自动安装 Apple Mobile Device Support**:服务启动 → 独立 AMDS MSI → 完整 iTunes 包 → winget, + 逐级降级;成功码 `0/3010/1641/1638`。 +3. **exe 清单写入 `requireAdministrator`**(`Location.spec` 的 `uac_admin=True`),启动即提权。 +4. **保活机制**:隧道会静默断开并让设备偷偷恢复真实定位,因此设了点但停止播放时 + 每 15 秒重发一次(`core/location_provider.py:KEEPALIVE_INTERVAL`)。 +5. **日志落盘**:窗口程序没有控制台,日志写到 + `%LOCALAPPDATA%\iOSLocationTrack\logs\app.log`。 diff --git a/installer/iosloc.iss b/installer/iosloc.iss new file mode 100644 index 0000000..bce0be8 --- /dev/null +++ b/installer/iosloc.iss @@ -0,0 +1,89 @@ +; iOS 虚拟定位轨迹工具 —— Windows 安装包脚本(Inno Setup 6) +; +; 构建: +; 1) .venv\Scripts\python.exe -m PyInstaller --clean --noconfirm Location.spec +; 2) "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\iosloc.iss +; +; 产物:dist\installer\iOSLocationTrack-1.0.0-setup.exe +; +; 说明: +; - 安装包**不包含** Apple 的驱动。程序首次启动时会自动检测 Apple Mobile Device +; Support,缺失时自动下载安装(因此 exe 以 requireAdministrator 清单启动)。 +; - 安装目录默认 %LOCALAPPDATA%\Programs\iOSLocationTrack,避免 UAC 写 Program Files +; 带来的虚拟化问题;用户数据在 %LOCALAPPDATA%\iOSLocationTrack(不随卸载删除)。 + +#define MyAppName "iOS 虚拟定位轨迹工具" +#define MyAppNameEn "iOSLocationTrack" +#define MyAppVersion "1.0.0" +#define MyAppPublisher "本地工具" +#define MyAppExeName "iOSLocationTrack.exe" +#define DistDir "..\dist\iOSLocationTrack" + +[Setup] +AppId={{8F3A5C21-6B4E-4D7A-9E15-2C7B0A4F9D31} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={localappdata}\Programs\{#MyAppNameEn} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +; 每用户安装,不需要管理员;但程序自身启动时会请求提权(装 Apple 驱动用) +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=..\dist\installer +OutputBaseFilename={#MyAppNameEn}-{#MyAppVersion}-setup +SetupIconFile=..\resources\AppIcon.ico +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0 +; 程序需要 Windows 10 1809+ / Windows 11 +DisableWelcomePage=no +LicenseFile=..\README.md + +[Languages] +; Inno Setup 6.7 起官方安装包不再自带简体中文,因此把翻译文件随仓库一起带着 +; (installer\languages\ChineseSimplified.isl),保证任何机器上都能构建出中文安装向导。 +; 文件缺失时自动退回英文,不会让构建失败。 +#define ChineseIsl "languages\ChineseSimplified.isl" +#if FileExists(AddBackslash(SourcePath) + ChineseIsl) +Name: "chinese"; MessagesFile: "compiler:Default.isl,{#ChineseIsl}" +#endif +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加任务:"; Flags: checkedonce +Name: "quicklaunchicon"; Description: "创建快速启动栏快捷方式"; GroupDescription: "附加任务:"; Flags: unchecked + +[Files] +Source: "{#DistDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; 注意:不要在这里放 Apple 的驱动/安装包。程序会按需自动下载,避免分发第三方二进制。 + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\检查 Apple 驱动"; Filename: "{app}\{#MyAppExeName}"; Parameters: "--check-driver" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "立即运行 {#MyAppName}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; 只删安装目录内的临时文件;用户数据(%LOCALAPPDATA%\iOSLocationTrack)保留, +; 里面有 config.json 和瓦片缓存,卸载后重装还能用。 +Type: filesandordirs; Name: "{app}\_internal\__pycache__" + +[Code] +// 安装完成后提示用户:首次启动会自动安装 Apple 驱动(可能需要几分钟) +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + // 无需额外动作;提示由 [Run] 段的 postinstall 复选框承担 + end; +end; diff --git a/installer/languages/ChineseSimplified.isl b/installer/languages/ChineseSimplified.isl new file mode 100644 index 0000000..30d9973 --- /dev/null +++ b/installer/languages/ChineseSimplified.isl @@ -0,0 +1,417 @@ +; *** Inno Setup version 6.5.0+ Chinese Simplified messages *** +; +; To download user-contributed translations of this file, go to: +; https://jrsoftware.org/files/istrans/ +; +; Note: When translating this text, do not add periods (.) to the end of +; messages that didn't have them already, because on those messages Inno +; Setup adds the periods automatically (appending a period would result in +; two periods being displayed). +; +; Maintainer: Zhenghan Yang (Kira) +; Email: 847320916@QQ.com +; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation +; Encoding: UTF-8 +; Translation based on network resource +; + +[LangOptions] +; The following three entries are very important. Be sure to read and +; understand the '[LangOptions] section' topic in the help file. +LanguageName=简体中文 +; About LanguageID, to reference link: +; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c +LanguageID=$0804 +; LanguageCodePage should always be set if possible, even if this file is Unicode +; For English it's set to zero anyway because English only uses ASCII characters +LanguageCodePage=936 +; If the language you are translating to requires special font faces or +; sizes, uncomment any of the following entries and change them accordingly. +;DialogFontName= +;DialogFontSize=9 +;DialogFontBaseScaleWidth=7 +;DialogFontBaseScaleHeight=15 +;WelcomeFontName=Segoe UI +;WelcomeFontSize=14 + +[Messages] + +; *** Application titles +SetupAppTitle=安装 +SetupWindowTitle=安装 - %1 +UninstallAppTitle=卸载 +UninstallAppFullTitle=%1 卸载 + +; *** Misc. common +InformationTitle=信息 +ConfirmTitle=确认 +ErrorTitle=错误 + +; *** SetupLdr messages +SetupLdrStartupMessage=现在将安装 %1。您想要继续吗? +LdrCannotCreateTemp=无法创建临时文件。安装程序已中止 +LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止 +HelpTextNote= + +; *** Startup error messages +LastErrorMessage=%1。%n%n错误 %2: %3 +SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。 +SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。 +SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。 +InvalidParameter=无效的命令行参数:%n%n%1 +SetupAlreadyRunning=安装程序已在运行。 +WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。 +WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。 +NotOnThisPlatform=此程序不能在 %1 上运行。 +OnlyOnThisPlatform=此程序只能在 %1 上运行。 +OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1 +WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。 +WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。 +AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。 +PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。 +SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 +UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 + +; *** Startup questions +PrivilegesRequiredOverrideTitle=选择安装程序安装模式 +PrivilegesRequiredOverrideInstruction=选择安装模式 +PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。 +PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。 +PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A) +PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐) +PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M) +PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐) + +; *** Misc. errors +ErrorCreatingDir=安装程序无法创建目录“%1” +ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。 + +; *** Setup common messages +ExitSetupTitle=退出安装程序 +ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗? +AboutSetupMenuItem=关于安装程序(&A)... +AboutSetupTitle=关于安装程序 +AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4 +AboutSetupNote= +TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation + +; *** Buttons +ButtonBack=< 上一步(&B) +ButtonNext=下一步(&N) > +ButtonInstall=安装(&I) +ButtonOK=确定 +ButtonCancel=取消 +ButtonYes=是(&Y) +ButtonYesToAll=全是(&A) +ButtonNo=否(&N) +ButtonNoToAll=全否(&O) +ButtonFinish=完成(&F) +ButtonBrowse=浏览(&B)... +ButtonWizardBrowse=浏览(&R)... +ButtonNewFolder=新建文件夹(&M) + +; *** "Select Language" dialog messages +SelectLanguageTitle=选择安装语言 +SelectLanguageLabel=选择安装时使用的语言。 + +; *** Common wizard text +ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。 +BeveledLabel= +BrowseDialogTitle=浏览文件夹 +BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。 +NewFolderName=新建文件夹 + +; *** "Welcome" wizard page +WelcomeLabel1=欢迎使用 [name] 安装向导 +WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。 + +; *** "Password" wizard page +WizardPassword=密码 +PasswordLabel1=此安装程序需要密码验证。 +PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。 +PasswordEditLabel=密码(&P): +IncorrectPassword=您输入的密码不正确,请重新输入。 + +; *** "License Agreement" wizard page +WizardLicense=许可协议 +LicenseLabel=请在继续安装前阅读以下重要信息。 +LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。 +LicenseAccepted=我同意此协议(&A) +LicenseNotAccepted=我不同意此协议(&D) + +; *** "Information" wizard pages +WizardInfoBefore=信息 +InfoBeforeLabel=请在继续安装前阅读以下重要信息。 +InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。 +WizardInfoAfter=信息 +InfoAfterLabel=请在继续安装前阅读以下重要信息。 +InfoAfterClickLabel=准备好继续安装后,点击“下一步”。 + +; *** "User Information" wizard page +WizardUserInfo=用户信息 +UserInfoDesc=请输入您的信息。 +UserInfoName=用户名(&U): +UserInfoOrg=组织(&O): +UserInfoSerial=序列号(&S): +UserInfoNameRequired=请输入用户名。 + +; *** "Select Destination Location" wizard page +WizardSelectDir=选择目标位置 +SelectDirDesc=您想将 [name] 安装在哪里? +SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。 +SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。 +DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。 +CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。 +CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。 +InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share +InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。 +DiskSpaceWarningTitle=磁盘空间不足 +DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗? +DirNameTooLong=文件夹名称或路径太长。 +InvalidDirName=文件夹名称无效。 +BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1 +DirExistsTitle=文件夹已存在 +DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗? +DirDoesntExistTitle=文件夹不存在 +DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗? + +; *** "Select Components" wizard page +WizardSelectComponents=选择组件 +SelectComponentsDesc=您想安装哪些程序组件? +SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。 +FullInstallation=完全安装 +; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) +CompactInstallation=简洁安装 +CustomInstallation=自定义安装 +NoUninstallWarningTitle=组件已存在 +NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗? +ComponentSize1=%1 KB +ComponentSize2=%1 MB +ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。 +ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。 + +; *** "Select Additional Tasks" wizard page +WizardSelectTasks=选择附加任务 +SelectTasksDesc=您想要安装程序执行哪些附加任务? +SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。 + +; *** "Select Start Menu Folder" wizard page +WizardSelectProgramGroup=选择开始菜单文件夹 +SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式? +SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。 +SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +MustEnterGroupName=您必须输入一个文件夹名称。 +GroupNameTooLong=文件夹名称或路径太长。 +InvalidGroupName=文件夹名称无效。 +BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1 +NoProgramGroupCheck2=不创建开始菜单文件夹(&D) + +; *** "Ready to Install" wizard page +WizardReady=准备安装 +ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。 +ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。 +ReadyLabel2b=点击“安装”继续此安装程序。 +ReadyMemoUserInfo=用户信息: +ReadyMemoDir=目标位置: +ReadyMemoType=安装类型: +ReadyMemoComponents=已选择组件: +ReadyMemoGroup=开始菜单文件夹: +ReadyMemoTasks=附加任务: + +; *** TDownloadWizardPage wizard page and DownloadTemporaryFile +DownloadingLabel2=正在下载文件... +ButtonStopDownload=停止下载(&S) +StopDownload=您确定要停止下载吗? +ErrorDownloadAborted=下载已中止。 +ErrorDownloadFailed=下载失败:%1 %2。 +ErrorDownloadSizeFailed=获取大小失败:%1 %2。 +ErrorProgress=无效的进度:%1 / %2。 +ErrorFileSize=文件大小错误:预期 %1,实际 %2。 + +; *** TExtractionWizardPage wizard page and ExtractArchive +ExtractingLabel=正在提取文件... +ButtonStopExtraction=停止提取(&S) +StopExtraction=您确定要停止提取吗? +ErrorExtractionAborted=提取已中止。 +ErrorExtractionFailed=提取失败:%1 + +; *** Archive extraction failure details +ArchiveIncorrectPassword=密码不正确。 +ArchiveIsCorrupted=压缩包已损坏。 +ArchiveUnsupportedFormat=不支持的压缩包格式。 + +; *** "Preparing to Install" wizard page +WizardPreparing=正在准备安装 +PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。 +PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。 +CannotContinue=安装程序不能继续。请点击“取消”退出。 +ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。 +ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。 +CloseApplications=自动关闭应用程序(&A) +DontCloseApplications=不要关闭应用程序(&D) +ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。 +PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗? + +; *** "Installing" wizard page +WizardInstalling=正在安装 +InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。 + +; *** "Setup Completed" wizard page +FinishedHeadingLabel=完成 [name] 安装向导 +FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。 +FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。 +ClickFinish=点击“完成”退出安装程序。 +FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗? +FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗? +ShowReadmeCheck=是,我想查阅自述文件 +YesRadio=是,立即重启计算机(&Y) +NoRadio=否,稍后重启计算机(&N) +; used for example as 'Run MyProg.exe' +RunEntryExec=运行 %1 +; used for example as 'View Readme.txt' +RunEntryShellExec=查阅 %1 + +; *** "Setup Needs the Next Disk" stuff +ChangeDiskTitle=安装程序需要下一张磁盘 +SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。 +PathLabel=路径(&P): +FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。 +SelectDirectoryLabel=请指定下一张磁盘的位置。 + +; *** Installation phase messages +SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。 +AbortRetryIgnoreSelectAction=选择操作 +AbortRetryIgnoreRetry=重试(&T) +AbortRetryIgnoreIgnore=忽略错误并继续(&I) +AbortRetryIgnoreCancel=取消安装 +RetryCancelSelectAction=选择操作 +RetryCancelRetry=重试(&T) +RetryCancelCancel=取消 + +; *** Installation status messages +StatusClosingApplications=正在关闭应用程序... +StatusCreateDirs=正在创建目录... +StatusExtractFiles=正在提取文件... +StatusDownloadFiles=正在下载文件... +StatusCreateIcons=正在创建快捷方式... +StatusCreateIniEntries=正在创建 INI 条目... +StatusCreateRegistryEntries=正在创建注册表条目... +StatusRegisterFiles=正在注册文件... +StatusSavingUninstall=正在保存卸载信息... +StatusRunProgram=正在完成安装... +StatusRestartingApplications=正在重启应用程序... +StatusRollback=正在撤销更改... + +; *** Misc. errors +ErrorInternal2=内部错误:%1。 +ErrorFunctionFailedNoCode=%1 失败。 +ErrorFunctionFailed=%1 失败;错误代码 %2。 +ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3 +ErrorExecutingProgram=无法执行文件:%n%1 + +; *** Registry errors +ErrorRegOpenKey=打开注册表项时出错:%n%1\%2 +ErrorRegCreateKey=创建注册表项时出错:%n%1\%2 +ErrorRegWriteKey=写入注册表项时出错:%n%1\%2 + +; *** INI errors +ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。 + +; *** File copying errors +FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐) +FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐) +SourceIsCorrupted=源文件已损坏。 +SourceDoesntExist=源文件“%1”不存在。 +SourceVerificationFailed=源文件验证失败:%1 +VerificationSignatureDoesntExist=签名文件“%1”不存在。 +VerificationSignatureInvalid=签名文件“%1”无效。 +VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。 +VerificationFileNameIncorrect=文件名不正确。 +VerificationFileTagIncorrect=文件标签不正确。 +VerificationFileSizeIncorrect=文件大小不正确。 +VerificationFileHashIncorrect=文件哈希值不正确。 +ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。 +ExistingFileReadOnlyRetry=移除只读属性并重试(&R) +ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K) +ErrorReadingExistingDest=尝试读取已存在的文件时出错: +FileExistsSelectAction=选择操作 +FileExists2=文件已经存在。 +FileExistsOverwriteExisting=覆盖已存在的文件(&O) +FileExistsKeepExisting=保留已存在的文件(&K) +FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D) +ExistingFileNewerSelectAction=选择操作 +ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。 +ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O) +ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐) +ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D) +ErrorChangingAttr=尝试更改下列已存在的文件属性时出错: +ErrorCreatingTemp=尝试在目标目录创建文件时出错: +ErrorReadingSource=尝试读取下列源文件时出错: +ErrorCopying=尝试复制下列文件时出错: +ErrorDownloading=尝试下载文件时出错: +ErrorExtracting=尝试提取压缩包时出错: +ErrorReplacingExistingFile=尝试替换已存在的文件时出错: +ErrorRestartReplace=重启并替换失败: +ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错: +ErrorRegisterServer=无法注册 DLL/OCX:%1 +ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。 +ErrorRegisterTypeLib=无法注册类型库:%1 + +; *** Uninstall display name markings +; used for example as 'My Program (32-bit)' +UninstallDisplayNameMark=%1 (%2) +; used for example as 'My Program (32-bit, All users)' +UninstallDisplayNameMarks=%1 (%2, %3) +UninstallDisplayNameMark32Bit=32 位 +UninstallDisplayNameMark64Bit=64 位 +UninstallDisplayNameMarkAllUsers=所有用户 +UninstallDisplayNameMarkCurrentUser=当前用户 + +; *** Post-installation errors +ErrorOpeningReadme=尝试打开自述文件时出错。 +ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。 + +; *** Uninstaller messages +UninstallNotFound=文件“%1”不存在。无法卸载。 +UninstallOpenError=文件“%1”不能被打开。无法卸载 +UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。 +UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。 +ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗? +UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。 +OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。 +UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。 +UninstalledAll=已顺利从您的计算机中移除 %1。 +UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。 +UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗? +UninstallDataCorrupted=文件“%1”已损坏。无法卸载。 + +; *** Uninstallation phase messages +ConfirmDeleteSharedFileTitle=删除共享文件? +ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。 +SharedFileNameLabel=文件名: +SharedFileLocationLabel=位置: +WizardUninstalling=卸载状态 +StatusUninstalling=正在卸载 %1... + +; *** Shutdown block reasons +ShutdownBlockReasonInstallingApp=正在安装 %1。 +ShutdownBlockReasonUninstallingApp=正在卸载 %1。 + +; The custom messages below aren't used by Setup itself, but if you make +; use of them in your scripts, you'll want to translate them. + +[CustomMessages] + +NameAndVersion=%1 版本 %2 +AdditionalIcons=附加快捷方式: +CreateDesktopIcon=创建桌面快捷方式(&D) +CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q) +ProgramOnTheWeb=%1 网站 +UninstallProgram=卸载 %1 +LaunchProgram=运行 %1 +AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A) +AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联... +AutoStartProgramGroupDescription=启动: +AutoStartProgram=自动启动 %1 +AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗? diff --git a/installer/version_info.txt b/installer/version_info.txt new file mode 100644 index 0000000..c196b1c --- /dev/null +++ b/installer/version_info.txt @@ -0,0 +1,32 @@ +# Windows 版本资源(PyInstaller version= 参数使用)。 +# 语法见 https://learn.microsoft.com/windows/win32/menurc/versioninfo-resource +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 0, 0), + prodvers=(1, 0, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable( + '080404B0', + [ + StringStruct('CompanyName', '本地工具'), + StringStruct('FileDescription', 'iOS 虚拟定位轨迹工具'), + StringStruct('FileVersion', '1.0.0.0'), + StringStruct('InternalName', 'iOSLocationTrack'), + StringStruct('LegalCopyright', '本地工具,用于自有 App 的定位功能测试'), + StringStruct('OriginalFilename', 'iOSLocationTrack.exe'), + StringStruct('ProductName', 'iOS 虚拟定位轨迹工具'), + StringStruct('ProductVersion', '1.0.0.0'), + ], + ) + ]), + VarFileInfo([VarStruct('Translation', [2052, 1200])]), + ], +) diff --git a/main.py b/main.py index 29e08db..cb646d4 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,15 @@ #!/usr/bin/env python3 -"""iOS 虚拟定位轨迹工具 — 程序入口。 +"""iOS 虚拟定位轨迹工具 — 程序入口(macOS / Windows 通用)。 -用法: - ./run.sh # 正常启动(自动打开界面) - ./.venv/bin/python main.py --mock # 无设备模拟模式(验收/演示用) - ./.venv/bin/python main.py --port 8899 --no-browser +用法(Windows): + .\\scripts\\run.ps1 # 正常启动(自动装依赖、自动检查/安装 Apple 驱动) + .venv\\Scripts\\python.exe main.py --mock # 无设备模拟模式 + .venv\\Scripts\\python.exe main.py --check-driver # 只检查 Apple Mobile Device Support + .venv\\Scripts\\python.exe main.py --install-driver # 强制检查并安装 + +用法(macOS): + ./scripts/run.sh + ./.venv/bin/python main.py --mock """ from __future__ import annotations @@ -17,28 +22,15 @@ import time import urllib.request import webbrowser from pathlib import Path +from typing import Any -def _resolve_roots() -> tuple[Path, Path]: - """返回 (资源根, 数据根)。 +from core import platform as plat # noqa: E402 - - 源码运行:两者都是项目目录 - - 打包成 .app:资源在 sys._MEIPASS(只读),数据写到 - ~/Library/Application Support/iOSLocationTrack - """ - if getattr(sys, "frozen", False): - resources = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent)) - data = Path.home() / "Library" / "Application Support" / "iOSLocationTrack" - data.mkdir(parents=True, exist_ok=True) - return resources, data - root = Path(__file__).resolve().parent - return root, root - - -ROOT, DATA_ROOT = _resolve_roots() +ROOT, DATA_ROOT = plat.resolve_roots() sys.path.insert(0, str(ROOT)) -from core import device as device_mod # noqa: E402 from core import protocol, server # noqa: E402 +from core import device as device_mod # noqa: E402 BANNER = r""" ___ ___ ___ _ _ _ @@ -47,59 +39,165 @@ BANNER = r""" |___||___/ |_| \___/\_/\_/\___|_||_|_\__,_|\__|_\___/_||_| """ -APP_MODE_BROWSERS = [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", -] - def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="ios-location", description=f"{protocol.APP_NAME} v{protocol.APP_VERSION}", ) - parser.add_argument("--host", default=protocol.DEFAULT_HOST, help="监听地址(默认 127.0.0.1,仅本机可访问)") - parser.add_argument("--port", type=int, default=protocol.DEFAULT_PORT, help=f"监听端口(默认 {protocol.DEFAULT_PORT})") - parser.add_argument("--mock", action="store_true", help="无设备模拟模式:不连真机,用于界面演示与验收") + parser.add_argument("--host", default=protocol.DEFAULT_HOST, + help="监听地址(默认 127.0.0.1,仅本机可访问)") + parser.add_argument("--port", type=int, default=protocol.DEFAULT_PORT, + help=f"监听端口(默认 {protocol.DEFAULT_PORT})") + parser.add_argument("--mock", action="store_true", + help="无设备模拟模式:不连真机,用于界面演示与验收") parser.add_argument("--no-browser", action="store_true", help="启动后不自动打开界面") parser.add_argument("--window", choices=["app", "browser", "none"], default="app", - help="界面打开方式:app=Chrome/Edge 应用窗口(默认,最像原生),browser=默认浏览器,none=不打开") + help="界面打开方式:app=Chrome/Edge 应用窗口(默认),browser=默认浏览器,none=不打开") + # Apple 驱动(仅 Windows 有意义) + parser.add_argument("--check-driver", action="store_true", + help="只检查 Apple Mobile Device Support 状态后退出") + parser.add_argument("--install-driver", action="store_true", + help="检查 Apple Mobile Device Support,缺失时自动安装") + parser.add_argument("--no-driver-check", action="store_true", + help="跳过启动时的 Apple 驱动检查") + parser.add_argument("--no-setup", action="store_true", + help="跳过启动时的「环境准备」窗口(依赖已就绪时可用)") # 内部助手模式:`<可执行文件> tunnel-helper --udid XXX` 建立 RSD 隧道并打印 HOST PORT - parser.add_argument("command", nargs="?", default=None, - help=argparse.SUPPRESS) + parser.add_argument("command", nargs="?", default=None, help=argparse.SUPPRESS) parser.add_argument("--udid", default=None, help=argparse.SUPPRESS) # 供 tunnel-helper 指定设备 - parser.add_argument("--version", action="version", version=f"{protocol.APP_NAME} {protocol.APP_VERSION}") + parser.add_argument("--version", action="version", + version=f"{protocol.APP_NAME} {protocol.APP_VERSION}") return parser.parse_args(argv) +# -------------------------------------------------------------------------- +# Apple Mobile Device Support(Windows 上的 usbmuxd) +# -------------------------------------------------------------------------- +def driver_status_line(status: dict) -> str: + if not status.get("applicable", True): + return "不适用(非 Windows)" + if status.get("ready"): + probe = status.get("probe") or {} + devices = (status.get("devices") or {}).get("count", 0) + return f"已就绪(usbmuxd 可通信,发现 {devices} 台设备)" if devices else "已就绪(usbmuxd 可通信)" + return status.get("summary") or "不可用" + + +def ensure_apple_driver(log, allow_install: bool) -> bool: + """启动时检查 Apple Mobile Device Support;缺失则自动安装。 + + 返回 True 表示 usbmuxd 可用。任何异常都不应阻止程序启动(界面仍可用模拟模式)。 + """ + from core import apple_driver + + def emit(level: str, message: str) -> None: + level = level if level in ("info", "warn", "error", "success") else "info" + log(level, message) + # 打包成窗口程序时没有控制台,这行只在源码/命令行运行时可见 + print(f"[apple-driver] {message}", flush=True) + + try: + status = apple_driver.status(deep=True) + except Exception as exc: + log("warn", f"Apple 驱动检测失败(忽略):{exc}") + return False + + if status.get("ready"): + log("info", f"Apple Mobile Device Support:{driver_status_line(status)}") + return True + + log("warn", f"Apple Mobile Device Support:{status.get('summary')}") + if not allow_install: + log("warn", "已跳过自动安装。可运行 `main.py --install-driver` 手动修复。") + return False + + if not plat.is_admin(): + log("error", "安装 Apple Mobile Device Support 需要管理员权限。" + "请右键程序 →「以管理员身份运行」,或运行 `main.py --install-driver`。") + return False + + try: + result = apple_driver.ensure(log=emit, allow_install=True) + except Exception as exc: + log("error", f"自动安装 Apple 驱动时出错:{exc}") + return False + + if result.get("ok"): + action = result.get("action") + if action == "none": + log("info", "Apple Mobile Device Support 已就绪") + elif action == "start_service": + log("info", "Apple Mobile Device Service 已启动") + else: + log("info", "Apple Mobile Device Support 安装完成") + if result.get("reboot_required"): + log("warn", "安装完成但**需要重启 Windows** 后 usbmuxd 才会生效,请重启后重新打开本程序。") + return True + + log("error", result.get("error") or "Apple Mobile Device Support 不可用") + log("warn", "设备发现将不可用。可手动安装 iTunes:https://www.apple.com/itunes/download/win64") + return False + + +def run_check_driver(install: bool) -> int: + """`--check-driver` / `--install-driver` 的实现。""" + from core import apple_driver + + def emit(level: str, message: str) -> None: + prefix = {"error": "[错误]", "warn": "[警告]", "info": "[信息]"}.get(level, "[信息]") + print(f"{prefix} {message}", flush=True) + + if not plat.IS_WINDOWS: + print("Apple Mobile Device Support 只在 Windows 上需要(macOS 自带 usbmuxd)。") + return 0 + + status = apple_driver.status(deep=True) + print(f"平台:{plat.platform_label()}(管理员:{'是' if plat.is_admin() else '否'})") + print(f"服务:{status['service']['state']}" + + ("(Apple Mobile Device Service)" if status["service"]["exists"] else "")) + product = status.get("product") or {} + if product: + print(f"版本:{product.get('display_name')} {product.get('displayversion', '?')}") + if product.get("upgrade_code"): + print(f"UpgradeCode:{product['upgrade_code']}") + probe = status.get("probe") or {} + print(f"usbmuxd(127.0.0.1:27015):{'可通信' if probe.get('ok') else '不可用 —— ' + str(probe.get('error'))}") + devices = status.get("devices") or {} + if devices.get("ok"): + print(f"设备:{devices.get('count', 0)} 台") + for item in devices.get("devices", []): + print(f" · {item['serial']}({item['connection']})") + print(f"结论:{status['summary']}") + + if status.get("ready"): + return 0 + if not install: + print("\n如需自动安装,请运行:main.py --install-driver(需要管理员权限)") + return 1 + + print("\n开始自动安装…", flush=True) + result = apple_driver.ensure(log=emit, allow_install=True) + if result.get("ok"): + print("\n[成功] Apple Mobile Device Support 已可用。") + return 0 + print(f"\n[失败] {result.get('error')}") + return 1 + + def run_tunnel_helper(udid: str | None) -> int: """建立 iOS 17+ 的 RSD 隧道并保持进程存活,向 stdout 打印「HOST PORT」。 - 打包成 .app 后不能再调用 `python -m pymobiledevice3`,因此把助手模式做进本程序: - 主进程用 `sys.executable tunnel-helper` 递归启动自己(见 core/tunnel.py)。 + - macOS:借 Apple ``remoted`` 的 native 隧道(内核可路由,父进程能直接连)。 + - Windows:userspace 隧道(进程内 PyTCP)。**它的地址父进程连不上**, + 因此本助手模式在 Windows 上只用于诊断/兼容,正式路径是 + ``core/tunnel.py`` 在应用自己的事件循环里直接建隧道。 """ import asyncio - try: - from pymobiledevice3.remote.native_tunnel import establish_native_rsd - except Exception as exc: # pragma: no cover - print(f"无法加载隧道模块:{exc}", file=sys.stderr) - return 2 - - async def main_async() -> int: - try: - rsd = await establish_native_rsd(serial=udid) - except Exception as exc: - print(f"建立隧道失败:{exc}", file=sys.stderr) - return 1 - address, port = rsd.service.address - # 关键:这一行会被 core/tunnel.py 解析出 RSD 地址。 - # 打包成 .app 时若以"无控制台"方式启动,sys.stdout 可能是 None, - # 此时打印会抛 AttributeError,导致隧道地址传不出去 → 真机连不上。 - # 因此 stdout 与 stderr 都写一份(父进程两者都会读取)。 - line = f"{address} {port}" + def _emit(line: str) -> None: + # 打包成窗口程序时 sys.stdout 可能是 None,stdout/stderr 各写一份 for stream in (sys.stdout, sys.stderr): if stream is None: continue @@ -108,8 +206,44 @@ def run_tunnel_helper(udid: str | None) -> int: stream.flush() except Exception: pass + + async def main_async() -> int: + if plat.IS_WINDOWS: + from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel + + tunnel = UserspaceRsdTunnel(serial=udid, autopair=True, + remotepairing_fallback=True) + try: + rsd = await tunnel.aopen() + address, port = rsd.service.address + _emit(f"{address} {port}") + await asyncio.Event().wait() + except asyncio.CancelledError: + return 0 + except Exception as exc: + _emit(f"隧道建立失败:{exc}") + return 1 + finally: + try: + await tunnel.aclose() + except Exception: + pass + return 0 + try: - await asyncio.Event().wait() # 持有隧道断言直到进程结束 + from pymobiledevice3.remote.native_tunnel import establish_native_rsd + except Exception as exc: + _emit(f"无法加载隧道模块:{exc}") + return 2 + try: + rsd = await establish_native_rsd(serial=udid) + except Exception as exc: + _emit(f"建立隧道失败:{exc}") + return 1 + address, port = rsd.service.address + _emit(f"{address} {port}") + try: + await asyncio.Event().wait() except asyncio.CancelledError: return 0 return 0 @@ -142,20 +276,20 @@ def open_ui(url: str, mode: str) -> None: if mode == "none": return if mode == "app": - for binary in APP_MODE_BROWSERS: - if os.path.exists(binary): - try: - import subprocess + binary = plat.find_app_browser() + if binary is not None: + try: + import subprocess - subprocess.Popen( - [binary, f"--app={url}", "--window-size=1440,900", - "--user-data-dir=" + str(Path.home() / ".ios-location-appwindow")], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return - except Exception: - continue + subprocess.Popen( + [str(binary), f"--app={url}", "--window-size=1440,900", + "--user-data-dir=" + str(plat.app_window_profile_dir())], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + except Exception: + pass try: webbrowser.open(url) except Exception: @@ -172,9 +306,28 @@ def find_free_port(host: str, preferred: int) -> int: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) if args.command == "tunnel-helper": - # 内部助手模式:由 TunnelManager 递归调用,只负责建隧道并打印 HOST PORT return run_tunnel_helper(args.udid) - mock = bool(args.mock or os.environ.get("IOSLOC_MOCK") == "1") + if args.check_driver or args.install_driver: + return run_check_driver(install=args.install_driver) + + mock_flag = bool(args.mock or os.environ.get("IOSLOC_MOCK") == "1") + + # ---------------- 启动时的「环境准备」窗口 ---------------- + # 每次打开都显示:逐项列出依赖,缺的可以直接点右侧按钮安装, + # 必需项全部就绪后「进入程序」才可点击。 + if not args.no_setup and not mock_flag: + from core import setup_window + + setup_result = setup_window.run_setup_window(title="环境准备") + if setup_result is False: + print("已取消(环境未就绪)。") + return 0 + if setup_result is None: + print(" [提示] 无法显示「环境准备」窗口(tkinter 不可用)," + "改为在后台自动检查并安装依赖。") + args.no_driver_check = False + + mock = mock_flag host = args.host url_host = "127.0.0.1" if host in ("0.0.0.0", "::") else host @@ -189,50 +342,84 @@ def main(argv: list[str] | None = None) -> int: print(BANNER) print(f" {protocol.APP_NAME} v{protocol.APP_VERSION}") - print(f" Python {sys.version.split()[0]} | 数据目录 {DATA_ROOT}") + print(f" 平台 {plat.platform_label()} | Python {sys.version.split()[0]} | 数据目录 {DATA_ROOT}") if mock: print(" 模式:无设备模拟(--mock),不会连接真机") else: - print(f" pymobiledevice3:{'已就绪' if device_mod.pymobiledevice3_importable() else '未安装(将使用 libimobiledevice 兜底,请运行 ./run.sh 自动安装)'}") + print(f" pymobiledevice3:{'已就绪' if device_mod.pymobiledevice3_importable() else '未安装'}" + f" | RSD 隧道:{'userspace(进程内)' if plat.IS_WINDOWS else 'native'}") + if plat.IS_WINDOWS: + print(f" 管理员权限:{'是' if plat.is_admin() else '否(userspace 隧道不需要)'}") - app = server.App(root=DATA_ROOT, mock=mock, port=args.port) + app = server.App(root=DATA_ROOT, mock=mock, port=args.port, resources=ROOT) app.log("info", f"{protocol.APP_NAME} 启动,端口 {args.port}") - # 关键依赖自检:打包成 .app 后若漏打模块,这里会立刻暴露, - # 而不是等到"设备发现莫名退化成 libimobiledevice"才发现。 + + # 关键依赖自检:打包后漏打模块会立刻暴露,而不是等到"设备发现莫名退化成 + # libimobiledevice"才发现。Windows 需要额外检查 userspace 隧道链路。 missing = [] - for module in ( + modules = [ "pymobiledevice3", "pymobiledevice3.lockdown", "pymobiledevice3.usbmux", - "pymobiledevice3.remote.native_tunnel", "pymobiledevice3.remote.remote_service_discovery", "pymobiledevice3.services.dvt.instruments.dvt_provider", "pymobiledevice3.services.dvt.instruments.location_simulation", - ): + ] + modules.append( + "pymobiledevice3.remote.userspace_tunnel" if plat.IS_WINDOWS + else "pymobiledevice3.remote.native_tunnel" + ) + if plat.IS_WINDOWS: + modules.append("pytun_pmd3") + for module in modules: try: __import__(module) except Exception as exc: missing.append(f"{module}({exc})") if missing: app.log("error", "关键模块缺失,真机功能将不可用:" + ";".join(missing)) + print(" [错误] 关键模块缺失:" + ";".join(missing)) elif not mock: app.log("info", "关键模块自检通过(pymobiledevice3 / RSD 隧道 / DVT 定位)") + + # Windows:Apple Mobile Device Support(usbmuxd)——没有它一台设备都发现不了。 + # 这是本工具在 Windows 上唯一的系统级依赖,缺失时自动安装。 + # + # 关键:放到**后台线程**里做。首次运行可能要下载 40~200MB,如果同步跑, + # 界面会迟迟不出现(打包成窗口程序后用户看不到任何进度)。后台跑的话, + # 界面已经打开,安装日志会实时通过 WebSocket 推到界面上。 + driver_state: dict[str, Any] = {"ok": None, "done": threading.Event()} + + def _driver_worker() -> None: + ok = ensure_apple_driver(app.log, allow_install=True) + driver_state["ok"] = ok + try: + app.devices.refresh() + app._on_devices_change() + except Exception: + pass + driver_state["done"].set() + + if plat.IS_WINDOWS and not mock and not args.no_driver_check: + threading.Thread(target=_driver_worker, name="apple-driver", daemon=True).start() + if getattr(app.config, "_repaired", False): app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值") - # 兜底清理:无论怎么退出(SIGINT/SIGTERM/异常),都不能把隧道子进程留在后台, - # 否则 iOS 17+ 的单条 RSD 连接会被残留进程占住,下一次定位会失败。 + # 兜底清理:无论怎么退出(SIGINT/SIGTERM/异常),都不能把隧道留在后台, + # 否则 iOS 17+ 的单条 RSD 连接会被残留占住,下一次定位会失败。 import atexit import signal atexit.register(app.shutdown) - for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + for sig in plat.termination_signals(): try: signal.signal(sig, lambda *_a: app.shutdown_event.set()) except (ValueError, OSError, AttributeError): pass if mock: app.log("warn", "当前为模拟模式:定位不会真正下发到设备") + app.devices.refresh() app.devices.start_monitor() last_device = app.config.get("last_device") @@ -246,7 +433,10 @@ def main(argv: list[str] | None = None) -> int: for device in devices: app.log("info", f"发现设备:{device.name}(iOS {device.ios_version or '未知'},{device.udid})") elif not mock: - app.log("warn", "暂未检测到 iOS 设备:请用数据线连接、解锁并在手机上点「信任」") + if plat.IS_WINDOWS and not args.no_driver_check: + app.log("info", "正在检查/安装 Apple Mobile Device Support(usbmuxd),完成后会自动刷新设备列表…") + else: + app.log("warn", "暂未检测到 iOS 设备:请用数据线连接、解锁并在手机上点「信任」") httpd = server.serve(app, host=host, port=args.port) url = f"http://{url_host}:{args.port}" diff --git a/resources/AppIcon-256.png b/resources/AppIcon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..a60991a90ce805d37763160ba2c457c86172f9a5 GIT binary patch literal 16050 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

ee%xJlf4o8G00QSUQ` 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 +if ($LASTEXITCODE -ne 0) { + Write-Warn "缺少 PyInstaller / Pillow,正在安装…" + $idx = if ($env:IOSLOC_PIP_INDEX) { $env:IOSLOC_PIP_INDEX } else { 'https://mirrors.aliyun.com/pypi/simple/' } + & $VenvPy -m pip install --disable-pip-version-check -i $idx pyinstaller pillow + if ($LASTEXITCODE -ne 0) { Write-Fail "构建依赖安装失败"; exit 1 } +} +Write-Ok "PyInstaller / Pillow 就绪" + +# ---------- 1. 图标 ---------- +if (-not $SkipIcon) { + Write-Bold "1. Windows 图标(.ico)" + if ((Test-Path 'resources\AppIcon.ico') -and + ((Get-Item 'resources\AppIcon.ico').LastWriteTime -gt (Get-Item 'resources\AppIcon.iconset').LastWriteTime)) { + Write-Ok "resources\AppIcon.ico 已是最新" + } else { + & $VenvPy tools\make_ico.py + if ($LASTEXITCODE -ne 0) { Write-Fail "图标生成失败"; exit 1 } + } +} + +# ---------- 2. 开发者镜像 ---------- +if (-not $SkipDdi) { + Write-Bold "2. 开发者镜像素材" + $imageDmg = 'resources\ddi\personalized\Image.dmg' + if (Test-Path $imageDmg) { + $mb = [math]::Round((Get-Item $imageDmg).Length / 1MB, 2) + Write-Ok "$imageDmg 已存在($mb MB)" + } else { + Write-Warn "$imageDmg 缺失(仓库中不入库),正在下载…" + & $VenvPy tools\fetch_ddi.py --personalized-only + if (-not (Test-Path $imageDmg)) { + Write-Fail "镜像下载失败。iOS 17+ 挂载会退化为在线获取,打包产物不完整。" + Write-Fail "可稍后重试:$VenvPy tools\fetch_ddi.py --personalized-only" + exit 1 + } + } +} + +# ---------- 3. 测试 ---------- +if (-not $SkipTests) { + Write-Bold "3. 测试(无需设备)" + $failed = 0 + foreach ($t in @('tests\selftest.py', 'tests\drift_sim.py', 'tests\offline_tiles.py')) { + Write-Host " --- $t" + & $VenvPy $t 2>&1 | Select-Object -Last 3 + if ($LASTEXITCODE -ne 0) { $failed++; Write-Fail "$t 失败" } + } + if ($failed -gt 0) { Write-Fail "$failed 个测试套件失败,已中止打包"; exit 1 } + Write-Ok "全部测试通过" +} + +# ---------- 4. PyInstaller ---------- +Write-Bold "4. 打包 exe(onedir + requireAdministrator 清单)" +if (Test-Path 'dist\iOSLocationTrack') { Remove-Item 'dist\iOSLocationTrack' -Recurse -Force } +& $VenvPy -m PyInstaller --clean --noconfirm Location.spec +if ($LASTEXITCODE -ne 0) { Write-Fail "PyInstaller 打包失败"; exit 1 } + +$exe = 'dist\iOSLocationTrack\iOSLocationTrack.exe' +if (-not (Test-Path $exe)) { Write-Fail "未生成 $exe"; exit 1 } +$sizeMb = [math]::Round(((Get-ChildItem 'dist\iOSLocationTrack' -Recurse -File | + Measure-Object Length -Sum).Sum / 1MB), 1) +Write-Ok "$exe(整包 $sizeMb MB)" + +# ---------- 5. 校验清单里的 requireAdministrator ---------- +Write-Bold "5. 校验 exe 是否请求管理员权限" +$manifestOk = $false +try { + # manifest 嵌在 exe 资源里;用 findstr 直接找 UTF-8 明文即可 + $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $exe)) + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text -match 'requireAdministrator') { + $manifestOk = $true + Write-Ok "清单包含 requireAdministrator(启动时会弹 UAC)" + } +} catch { } +if (-not $manifestOk) { + Write-Warn "未能在 exe 中确认 requireAdministrator —— 请手动确认 Location.spec 的 uac_admin=True" +} + +# ---------- 6. Inno Setup ---------- +if ($NoInstaller) { + Write-Bold "6. 跳过安装包(-NoInstaller)" +} else { + Write-Bold "6. 制作安装包(Inno Setup)" + $iscc = $null + foreach ($candidate in @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Inno Setup 6\ISCC.exe" + )) { + if ($candidate -and (Test-Path $candidate)) { $iscc = $candidate; break } + } + if (-not $iscc) { + $cmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue + if ($cmd) { $iscc = $cmd.Source } + } + if (-not $iscc) { + Write-Warn "未找到 Inno Setup 6,跳过安装包。" + Write-Warn "安装:winget install -e --id JRSoftware.InnoSetup" + Write-Warn "然后重跑:.\scripts\build.ps1 -SkipTests -SkipIcon -SkipDdi" + } else { + Write-Host " 使用 $iscc" + & $iscc 'installer\iosloc.iss' + if ($LASTEXITCODE -ne 0) { Write-Fail "Inno Setup 编译失败"; exit 1 } + Get-ChildItem 'dist\installer\*.exe' | ForEach-Object { + Write-Ok "$($_.FullName)($([math]::Round($_.Length/1MB,1)) MB)" + } + } +} + +Write-Bold "完成" +Write-Host " 绿色版:dist\iOSLocationTrack\iOSLocationTrack.exe" +if (Test-Path 'dist\installer') { + Get-ChildItem 'dist\installer\*.exe' -ErrorAction SilentlyContinue | ForEach-Object { + Write-Host " 安装包:$($_.FullName)" + } +} +Write-Host "`n 提示:exe 启动时会请求管理员权限(用于首次自动安装 Apple Mobile Device Support)。" diff --git a/scripts/run.ps1 b/scripts/run.ps1 new file mode 100644 index 0000000..b2d3e8f --- /dev/null +++ b/scripts/run.ps1 @@ -0,0 +1,106 @@ +# iOS 虚拟定位轨迹工具 —— Windows 一键启动脚本 +# +# 用法(在项目根目录): +# .\scripts\run.ps1 # 正常启动 +# .\scripts\run.ps1 -Mock # 无设备模拟模式 +# .\scripts\run.ps1 -CheckDriver # 只检查 Apple Mobile Device Support +# .\scripts\run.ps1 -InstallDriver # 检查并安装 Apple 驱动 +# .\scripts\run.ps1 -NoBrowser # 不自动打开界面 +# +# 如果提示"禁止运行脚本",先执行: +# Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass + +[CmdletBinding()] +param( + [switch]$Mock, + [switch]$CheckDriver, + [switch]$InstallDriver, + [switch]$NoBrowser, + [switch]$NoDriverCheck, + [int]$Port = 0, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Rest +) + +$ErrorActionPreference = 'Stop' + +function Write-Info { param($m) Write-Host " $m" } +function Write-Bold { param($m) Write-Host $m -ForegroundColor White } +function Write-Warn { param($m) Write-Host " $m" -ForegroundColor Yellow } +function Write-Fail { param($m) Write-Host " $m" -ForegroundColor Red } +function Write-Ok { param($m) Write-Host " $m" -ForegroundColor Green } + +$Root = Split-Path -Parent $PSScriptRoot +Set-Location $Root +$VenvPy = Join-Path $Root '.venv\Scripts\python.exe' + +# 国内网络默认走阿里云镜像(可用 IOSLOC_PIP_INDEX 覆盖) +$PipIndex = if ($env:IOSLOC_PIP_INDEX) { $env:IOSLOC_PIP_INDEX } else { 'https://mirrors.aliyun.com/pypi/simple/' } + +Write-Bold "iOS 虚拟定位轨迹工具(Windows)" +Write-Info "目录:$Root" + +# ---------- 1. Python ---------- +if (-not (Test-Path $VenvPy)) { + Write-Info "首次运行:创建虚拟环境…" + $python = $null + foreach ($candidate in @('py -3.12', 'py -3.13', 'py -3.11', 'python')) { + $parts = $candidate.Split(' ') + $exe = Get-Command $parts[0] -ErrorAction SilentlyContinue + if ($exe) { $python = $candidate; break } + } + if (-not $python) { + Write-Fail "未找到 Python 3.11+。请先安装:https://www.python.org/downloads/windows/" + Write-Fail "安装时请勾选「Add python.exe to PATH」。" + exit 1 + } + Write-Info "使用 $python 创建虚拟环境…" + Invoke-Expression "$python -m venv `"$Root\.venv`"" + if (-not (Test-Path $VenvPy)) { Write-Fail "虚拟环境创建失败"; exit 1 } +} +Write-Info "Python:$(& $VenvPy -V 2>&1)" + +# ---------- 2. 依赖 ---------- +$needInstall = $false +& $VenvPy -c "import pymobiledevice3" 2>$null +if ($LASTEXITCODE -ne 0) { $needInstall = $true } + +if ($needInstall) { + Write-Bold "首次运行:安装依赖(约 1-3 分钟,走国内镜像)" + & $VenvPy -m pip install --quiet --disable-pip-version-check -i $PipIndex pymobiledevice3 + if ($LASTEXITCODE -ne 0) { + Write-Warn "国内镜像失败,改用官方源重试…" + & $VenvPy -m pip install --quiet --disable-pip-version-check pymobiledevice3 + if ($LASTEXITCODE -ne 0) { + Write-Fail "依赖安装失败。请手动执行:" + Write-Fail " $VenvPy -m pip install -i $PipIndex pymobiledevice3" + exit 1 + } + } + Write-Ok "依赖安装完成" +} +Write-Info "pymobiledevice3:$(& $VenvPy -c "import pymobiledevice3;print('已就绪')" 2>$null)" + +# ---------- 3. 开发者镜像素材(不入库,需要时下载)---------- +$imageDmg = Join-Path $Root 'resources\ddi\personalized\Image.dmg' +if (-not (Test-Path $imageDmg)) { + Write-Warn "缺少开发者镜像 Image.dmg(约 15MB,仓库中不入库),正在下载…" + & $VenvPy (Join-Path $Root 'tools\fetch_ddi.py') --personalized-only + if (-not (Test-Path $imageDmg)) { + Write-Warn "镜像下载失败:iOS 17+ 挂载会尝试在线获取,可能较慢。" + } +} + +# ---------- 4. 组装参数 ---------- +$mainArgs = @((Join-Path $Root 'main.py')) +if ($Mock) { $mainArgs += '--mock' } +if ($CheckDriver) { $mainArgs += '--check-driver' } +if ($InstallDriver) { $mainArgs += '--install-driver' } +if ($NoBrowser) { $mainArgs += '--no-browser' } +if ($NoDriverCheck) { $mainArgs += '--no-driver-check' } +if ($Port -gt 0) { $mainArgs += @('--port', $Port) } +if ($Rest) { $mainArgs += $Rest } + +# ---------- 5. 启动 ---------- +& $VenvPy @mainArgs +exit $LASTEXITCODE diff --git a/tests/drift_sim.py b/tests/drift_sim.py index 4577d9f..81df735 100644 --- a/tests/drift_sim.py +++ b/tests/drift_sim.py @@ -16,6 +16,7 @@ from __future__ import annotations import math import statistics import sys +import tempfile import time import threading from pathlib import Path @@ -81,7 +82,7 @@ def make_engine(config: config_store.ConfigStore): def main() -> int: print("\033[1m真实感模拟验证(GPS 漂移 + 速度浮动)\033[0m") - cfg = config_store.ConfigStore(Path("/tmp/iosloc-drift-sim.json")) + 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, diff --git a/tests/offline_tiles.py b/tests/offline_tiles.py index 6cb3cf0..dc92ff5 100644 --- a/tests/offline_tiles.py +++ b/tests/offline_tiles.py @@ -10,6 +10,7 @@ from __future__ import annotations import sys +import tempfile import time import urllib.request from pathlib import Path @@ -37,7 +38,7 @@ def check(name: str, ok: bool, detail: str = "") -> None: def main() -> int: print("\033[1m瓦片本地缓存 / 离线可用验证\033[0m") - cache_dir = Path("/tmp/iosloc-offline-tiles") + cache_dir = Path(tempfile.gettempdir()) / "iosloc-offline-tiles" import shutil shutil.rmtree(cache_dir, ignore_errors=True) diff --git a/tests/win_app_e2e.py b/tests/win_app_e2e.py new file mode 100644 index 0000000..88fbd0d --- /dev/null +++ b/tests/win_app_e2e.py @@ -0,0 +1,131 @@ +"""Windows 应用层真机验收:走 App 的完整代码路径(隧道→注入 rsd→DVT→下发→恢复)。 + +与 tests/win_e2e.py 的区别: + win_e2e.py 直接调 pymobiledevice3 + RealLocationProvider,验证平台可行性 + 本脚本 走 core/server.py:App 的真实装配路径(TunnelManager + rsd 对象注入), + 也就是界面点「连接设备」时实际执行的代码。 + +用法(项目根目录): + .venv\\Scripts\\python.exe tests\\win_app_e2e.py --confirm + .venv\\Scripts\\python.exe tests\\win_app_e2e.py --confirm --hold 8 +""" +from __future__ import annotations + +import argparse +import sys +import time +import traceback +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from core import platform as plat # noqa: E402 +from core import server as server_mod # noqa: E402 + +CHECKS: list[tuple[str, bool, str]] = [] + + +def check(name: str, ok: bool, detail: str = "") -> bool: + CHECKS.append((name, ok, detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""), + flush=True) + return ok + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--confirm", action="store_true", help="确认对真机下发定位") + parser.add_argument("--lat", type=float, default=39.9087) + parser.add_argument("--lon", type=float, default=116.3975) + parser.add_argument("--hold", type=float, default=6.0) + parser.add_argument("--udid", default=None) + args = parser.parse_args() + + print("=" * 68) + print("Windows 应用层真机验收(走 App 装配路径)") + print("=" * 68) + print(f" 平台 {plat.platform_label()} | 管理员 {'是' if plat.is_admin() else '否'}") + + data_root = plat.data_dir() / "_e2e" + data_root.mkdir(parents=True, exist_ok=True) + app = server_mod.App(root=data_root, mock=False, port=0, resources=ROOT) + + exit_code = 0 + try: + # ---------- 1. 设备发现 ---------- + app.devices.refresh() + devices = app.devices.list_devices() + check("设备发现", len(devices) > 0, f"{len(devices)} 台") + if not devices: + return 1 + device = next((d for d in devices if d.udid == args.udid), devices[0]) + check("选中设备", True, f"{device.name} / iOS {device.ios_version} / {device.udid}") + + # ---------- 2. 走 App 的连接路径(含隧道建立 + rsd 注入)---------- + started = time.monotonic() + try: + provider = app.provider_for(device, connect=True) + except Exception as exc: + check("App 连接设备", False, repr(exc)) + traceback.print_exc() + return 1 + elapsed = time.monotonic() - started + snapshot = app.tunnel.snapshot() + check("App 连接设备(隧道+DVT)", True, + f"{elapsed:.1f}s | 隧道 mode={snapshot.get('mode')} " + f"in_process={snapshot.get('in_process')} | 策略={provider._strategy}") + check("隧道为进程内 userspace", + snapshot.get("mode") == "userspace" and snapshot.get("in_process") is True, + f"mode={snapshot.get('mode')} in_process={snapshot.get('in_process')}") + check("provider 复用了隧道返回的 rsd 对象", + provider._injected_rsd is not None + and provider._rsd_obj is provider._injected_rsd, + "同一个对象,未另行 TCP 连接") + + if not args.confirm: + print("\n (未加 --confirm:只验证到会话就绪)") + return 0 + + # ---------- 3. 下发定位(走 playback 用的同一条路径)---------- + app.runner.run(provider.set_location(args.lat, args.lon), timeout=20.0) + check("下发虚拟定位", True, f"lat={args.lat} lon={args.lon}") + print(f" --- 保持 {args.hold:.0f} 秒 ---", flush=True) + time.sleep(args.hold) + + # ---------- 4. 同会话第二点 ---------- + app.runner.run(provider.set_location(args.lat + 0.0009, args.lon + 0.0009), + timeout=20.0) + check("同一会话连续下发", True, "DVT 长连接有效") + + except Exception as exc: + check("应用层真机流程", False, repr(exc)) + traceback.print_exc() + exit_code = 1 + finally: + try: + if app.provider is not None: + app.runner.run(app.provider.clear_location(), timeout=20.0) + check("恢复真实定位", True, "已调用 clear_location()") + except Exception as exc: + check("恢复真实定位", False, repr(exc)) + exit_code = 1 + try: + app.shutdown() + check("App 关闭(隧道已释放)", not app.tunnel.ready, + f"隧道 state={app.tunnel.state}") + except Exception as exc: + check("App 关闭", False, repr(exc)) + exit_code = 1 + + print("\n" + "=" * 68) + failed = [c for c in CHECKS if not c[1]] + print(f"结果:{len(CHECKS)-len(failed)}/{len(CHECKS)} 项通过") + for name, _ok, detail in failed: + print(f" FAIL {name}: {detail}") + print("=" * 68) + return exit_code or (1 if failed else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/win_e2e.py b/tests/win_e2e.py new file mode 100644 index 0000000..0488379 --- /dev/null +++ b/tests/win_e2e.py @@ -0,0 +1,195 @@ +"""Windows 真机端到端验收:userspace 隧道 → 开发者镜像 → DVT → 下发定位 → 恢复。 + +这是 docs/WINDOWS_PORT.md 第 7 节「真机验收」的自动化版本,也是 Windows 版 +**唯一的可行性判定脚本**(第 3 节要求先验证隧道再写界面)。 + +用法(项目根目录): + .venv\\Scripts\\python.exe tests\\win_e2e.py --confirm + .venv\\Scripts\\python.exe tests\\win_e2e.py --confirm --lat 39.908 --lon 116.397 --hold 8 + .venv\\Scripts\\python.exe tests\\win_e2e.py --confirm --rsd 10.7.0.1 51234 # 用已有隧道 + +不加 --confirm 只做只读检查(设备发现 + 隧道建立),不下发定位。 +退出前一定会调用 clear_location(),让设备恢复真实定位。 +""" +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import time +import traceback + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +CHECKS: list[tuple[str, bool, str]] = [] + + +def check(name: str, ok: bool, detail: str = "") -> bool: + CHECKS.append((name, ok, detail)) + mark = "PASS" if ok else "FAIL" + print(f" [{mark}] {name}" + (f" — {detail}" if detail else ""), flush=True) + return ok + + +async def list_devices() -> list: + from pymobiledevice3.usbmux import list_devices + + return await list_devices() + + +async def main_async(args: argparse.Namespace) -> int: + print("=" * 68) + print("Windows 真机端到端验收") + print("=" * 68) + + # ---------- 1. 依赖 ---------- + try: + import pymobiledevice3 + + version = getattr(pymobiledevice3, "__version__", "?") + check("pymobiledevice3 可导入", True, f"v{version}") + except Exception as exc: + check("pymobiledevice3 可导入", False, str(exc)) + return 1 + + # ---------- 2. 设备发现 ---------- + try: + devices = await list_devices() + except Exception as exc: + check("usbmux 设备发现", False, repr(exc)) + return 1 + check("usbmux 设备发现", len(devices) > 0, + f"{len(devices)} 台" + (f":{devices[0].serial}" if devices else "(请插线并信任)")) + if not devices: + return 1 + udid = args.udid or devices[0].serial + + # ---------- 3. 隧道 ---------- + # + # 关键(Windows 与 macOS 的架构差异): + # Windows 的 userspace 隧道把设备地址放在**本进程的 PyTCP 用户态协议栈**里, + # 内核路由不到它(直接 TCP connect 会得到 WinError 1231 网络位置不可达)。 + # pymobiledevice3 的做法是把一个 relay dialer 注入到它返回的 RemoteServiceDiscoveryService + # 对象上(`open_connection=dial_plane.dial`)。因此**必须复用这个 rsd 对象**, + # 不能拿 (host, port) 另建一个 RSD —— 后者用的是 stdlib dialer,必然失败。 + # RealLocationProvider 已经预留了 `rsd=` 注入口(见 core/location_provider.py:242/395)。 + tunnel = None + rsd = None + rsd_host = rsd_port = None + if args.rsd: + rsd_host, rsd_port = args.rsd[0], int(args.rsd[1]) + check("隧道(手动指定)", True, f"{rsd_host}:{rsd_port}") + else: + from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel + + tunnel = UserspaceRsdTunnel(serial=udid, autopair=True, + remotepairing_fallback=True) + started = time.monotonic() + try: + rsd = await asyncio.wait_for(tunnel.aopen(), timeout=args.tunnel_timeout) + rsd_host, rsd_port = rsd.service.address + inproc = getattr(rsd, "is_in_process_tunnel", False) + check("userspace 隧道建立", True, + f"{rsd_host} {rsd_port}({time.monotonic()-started:.1f}s," + f"进程内={inproc})") + except Exception as exc: + check("userspace 隧道建立", False, repr(exc)) + traceback.print_exc() + return 1 + + # ---------- 4. 开发者镜像 ---------- + from core.location_provider import RealLocationProvider + + logs: list[str] = [] + + def on_log(level: str, msg: str) -> None: + logs.append(f"[{level}] {msg}") + print(f" · {msg}", flush=True) + + provider = RealLocationProvider( + rsd=rsd, + rsd_host=rsd_host, rsd_port=rsd_port, udid=udid, + connect_timeout=args.connect_timeout, on_log=on_log, + ) + + exit_code = 0 + connected = False + try: + print(" --- 挂载镜像并建立 DVT 会话 ---", flush=True) + info = await provider.connect(None) + connected = True + check("开发者镜像 + DVT 会话", True, + f"策略={info.get('strategy')} iOS={info.get('ios_version') or info.get('ios_major')}") + + if not args.confirm: + print("\n (未加 --confirm:只验证到会话就绪,不下发定位)") + return 0 + + # ---------- 5. 下发定位 ---------- + before = args.lat, args.lon + await provider.set_location(before[0], before[1]) + check("下发虚拟定位", True, f"lat={before[0]:.6f} lon={before[1]:.6f}") + + print(f" --- 保持 {args.hold:.0f} 秒(请观察手机定位是否移动)---", flush=True) + await asyncio.sleep(args.hold) + + # ---------- 6. 再下发一个点,验证连接是长连接 ---------- + if args.hold2 is not None: + await provider.set_location(before[0] + 0.001, before[1] + 0.001) + check("同一会话连续下发", True, "第二点成功(证明 DVT 长连接有效)") + await asyncio.sleep(1.0) + + except Exception as exc: + check("真机定位流程", False, repr(exc)) + traceback.print_exc() + exit_code = 1 + finally: + if connected: + try: + await provider.clear_location() + check("恢复真实定位", True, "已调用 clear_location()") + except Exception as exc: + check("恢复真实定位", False, repr(exc)) + exit_code = 1 + try: + await provider.close() + except Exception: + pass + if tunnel is not None: + try: + await tunnel.aclose() + except Exception: + pass + + print("\n" + "=" * 68) + failed = [c for c in CHECKS if not c[1]] + print(f"结果:{len(CHECKS)-len(failed)}/{len(CHECKS)} 项通过") + for name, _ok, detail in failed: + print(f" FAIL {name}: {detail}") + print("=" * 68) + return exit_code or (1 if failed else 0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Windows 真机端到端验收") + parser.add_argument("--confirm", action="store_true", + help="确认对真机下发虚拟定位(不加则只读到会话就绪)") + parser.add_argument("--lat", type=float, default=39.9087, help="纬度(默认 39.9087 天安门)") + parser.add_argument("--lon", type=float, default=116.3975, help="经度") + parser.add_argument("--hold", type=float, default=8.0, help="首个点保持秒数(默认 8)") + parser.add_argument("--hold2", action="store_true", help="额外下发第二个点验证长连接") + parser.add_argument("--udid", default=None) + parser.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), default=None) + parser.add_argument("--tunnel-timeout", type=float, default=90.0) + parser.add_argument("--connect-timeout", type=float, default=30.0) + args = parser.parse_args() + try: + return asyncio.run(main_async(args)) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/make_ico.py b/tools/make_ico.py new file mode 100644 index 0000000..9878afb --- /dev/null +++ b/tools/make_ico.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""由 macOS 侧已有的 PNG 图标集生成 Windows 用的多尺寸 .ico。 + +macOS 用 ``iconutil`` 产出 .icns;Windows 需要 .ico,用 Pillow 直接合成即可。 +输入:resources/AppIcon.iconset/ 下的 PNG(16/32/64/128/256/512/1024) +输出:resources/AppIcon.ico + +用法: + .venv\\Scripts\\python.exe tools\\make_ico.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ICONSET = ROOT / "resources" / "AppIcon.iconset" +OUTPUT = ROOT / "resources" / "AppIcon.ico" +PREVIEW = ROOT / "resources" / "AppIcon-256.png" + +#: Windows 资源管理器/任务栏实际会用到的尺寸 +SIZES = [16, 24, 32, 48, 64, 128, 256] + +#: 每个目标尺寸优先使用的源文件(按分辨率从高到低找,避免放大糊掉) +CANDIDATES = { + 16: ["icon_16x16.png", "icon_16x16@2x.png", "icon_32x32.png"], + 24: ["icon_32x32.png", "icon_16x16@2x.png"], + 32: ["icon_32x32.png", "icon_32x32@2x.png", "icon_64x64.png"], + 48: ["icon_128x128.png", "icon_32x32@2x.png"], + 64: ["icon_64x64.png", "icon_32x32@2x.png", "icon_128x128.png"], + 128: ["icon_128x128.png", "icon_128x128@2x.png", "icon_256x256.png"], + 256: ["icon_256x256.png", "icon_256x256@2x.png", "icon_512x512.png"], +} + +#: 兜底:任意大图,用 Pillow 缩放 +FALLBACKS = ["icon_512x512@2x.png", "icon_512x512.png", "icon_256x256@2x.png", + "icon_256x256.png", "icon_128x128@2x.png", "icon_128x128.png"] + + +def main() -> int: + try: + from PIL import Image + except ImportError: + print("需要 Pillow:.venv\\Scripts\\python.exe -m pip install pillow") + return 1 + + if not ICONSET.is_dir(): + print(f"找不到图标源目录:{ICONSET}") + return 1 + + available = {p.name: p for p in ICONSET.glob("*.png")} + if not available: + print(f"{ICONSET} 下没有 PNG") + return 1 + + fallback = next((ICONSET / n for n in FALLBACKS if n in available), None) + if fallback is None: + fallback = sorted(available.values(), key=lambda p: p.stat().st_size)[-1] + + images = [] + for size in SIZES: + chosen = next((ICONSET / n for n in CANDIDATES[size] if n in available), None) + source = chosen or fallback + try: + with Image.open(source) as img: + img = img.convert("RGBA") + if img.size != (size, size): + img = img.resize((size, size), Image.LANCZOS) + images.append(img.copy()) + print(f" {size:>3}x{size:<3} ← {source.name}") + except Exception as exc: + print(f" {size:>3}x{size:<3} 失败:{exc}") + + if not images: + print("没有生成任何尺寸") + return 1 + + # 以最大的一张作为基准,sizes= 里附上其余尺寸 + largest = max(images, key=lambda i: i.size[0]) + largest.save( + OUTPUT, + format="ICO", + sizes=[img.size for img in images], + append_images=[img for img in images if img is not largest], + ) + print(f"\n已生成:{OUTPUT}({OUTPUT.stat().st_size/1024:.1f} KB,{len(images)} 个尺寸)") + + # 顺便导出一张 PNG 供 Inno Setup 的向导图/文档使用 + largest.resize((256, 256), Image.LANCZOS).save(PREVIEW) + print(f"已生成:{PREVIEW}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/probe_tunnel_win.py b/tools/probe_tunnel_win.py new file mode 100644 index 0000000..512f793 --- /dev/null +++ b/tools/probe_tunnel_win.py @@ -0,0 +1,77 @@ +"""Windows userspace 隧道可行性验证(对应 docs/WINDOWS_PORT.md 第 3.3 节)。 + +用法: .venv\\Scripts\\python.exe tools\\probe_tunnel_win.py [--udid X] +""" +from __future__ import annotations + +import argparse +import asyncio +import inspect +import platform +import sys +import traceback + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--udid", default=None) + parser.add_argument("--timeout", type=float, default=90.0) + args = parser.parse_args() + + print(f"platform={platform.system()} python={sys.version.split()[0]}") + + # 1) 列出设备 + try: + from pymobiledevice3.usbmux import list_devices + + devices = asyncio.run(list_devices()) + print(f"usbmux devices: {len(devices)}") + for d in devices: + print(f" {d.serial} {d.product_type if hasattr(d,'product_type') else ''} " + f"{getattr(d, 'product_version', '')}") + except Exception: + print("usbmux list FAILED") + traceback.print_exc() + + # 2) 看看有哪些入口 + try: + from pymobiledevice3.remote import userspace_tunnel as ut + + print("establish_userspace_rsd sig:", + inspect.signature(ut.establish_userspace_rsd)) + except Exception: + traceback.print_exc() + + # 3) 真正建隧道 + print("--- establishing userspace RSD tunnel ---", flush=True) + try: + from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel + + tunnel = UserspaceRsdTunnel(serial=args.udid, autopair=True, + remotepairing_fallback=True) + + async def run() -> str: + rsd = await asyncio.wait_for(tunnel.aopen(), timeout=args.timeout) + address, port = rsd.service.address + return f"{address} {port}" + + line = asyncio.run(run()) + print(f"TUNNEL OK: {line}", flush=True) + print("(隧道将在进程退出时释放)") + return 0 + except Exception: + print("TUNNEL FAILED:") + traceback.print_exc() + return 1 + finally: + # 必须 await aclose():UserspaceRsdTunnel.close() 是同步壳,内部调用 aclose() + # 却不 await,直接调它会留下未 await 的协程且隧道不会真正释放。 + try: + if tunnel is not None: + asyncio.run(tunnel.aclose()) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main())