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 0000000..a60991a Binary files /dev/null and b/resources/AppIcon-256.png differ diff --git a/resources/AppIcon.ico b/resources/AppIcon.ico new file mode 100644 index 0000000..b166ed5 Binary files /dev/null and b/resources/AppIcon.ico differ diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..4ab90cc --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,162 @@ +# iOS 虚拟定位轨迹工具 —— Windows 构建脚本 +# +# 产出: +# dist\iOSLocationTrack\iOSLocationTrack.exe 绿色版(直接双击运行) +# dist\installer\iOSLocationTrack-1.0.0-setup.exe 安装包(需要 Inno Setup 6) +# +# 用法(在项目根目录): +# .\scripts\build.ps1 # 打包 exe + 安装包(若装了 Inno Setup) +# .\scripts\build.ps1 -NoInstaller # 只打包 exe +# .\scripts\build.ps1 -SkipTests # 跳过测试 +# +# 前置: +# 1. 已运行过 .\scripts\run.ps1(或手动建好 .venv 并装好依赖) +# 2. 图标:resources\AppIcon.ico(缺失时自动用 tools\make_ico.py 生成) +# 3. 开发者镜像:resources\ddi\personalized\Image.dmg(缺失时自动下载) +# 4. Inno Setup 6(可选):winget install -e --id JRSoftware.InnoSetup + +[CmdletBinding()] +param( + [switch]$NoInstaller, + [switch]$SkipTests, + [switch]$SkipIcon, + [switch]$SkipDdi +) + +$ErrorActionPreference = 'Stop' +function Write-Bold { param($m) Write-Host "`n=== $m ===" -ForegroundColor White } +function Write-Ok { param($m) Write-Host " [OK] $m" -ForegroundColor Green } +function Write-Warn { param($m) Write-Host " [!!] $m" -ForegroundColor Yellow } +function Write-Fail { param($m) Write-Host " [XX] $m" -ForegroundColor Red } + +$Root = Split-Path -Parent $PSScriptRoot +Set-Location $Root +$VenvPy = Join-Path $Root '.venv\Scripts\python.exe' + +if (-not (Test-Path $VenvPy)) { + Write-Fail "找不到 $VenvPy。请先运行 .\scripts\run.ps1 创建环境。" + exit 1 +} + +# ---------- 0. 构建依赖 ---------- +Write-Bold "0. 构建依赖" +& $VenvPy -c "import PyInstaller, PIL" 2>$null +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())