feat(windows): 新增 Windows 移植(免管理员隧道 + 自动装 Apple 驱动 + 环境自检)

按 docs/WINDOWS_PORT.md 完成 Windows 移植,并修正该文档中若干与实测不符之处。
macOS 构建链完整保留,单一代码库双平台构建。

核心:iOS 17+ RSD 隧道在 Windows 上换了实现方式
- macOS 借 Apple remoted(--native,内核可路由),Windows 改走 UserspaceRsdTunnel
  (纯 Python PyTCP 用户态协议栈)
- 实测:不需要管理员权限、不需要 Wintun 驱动(pytun_pmd3 自带 wintun.dll)、
  建立耗时约 0.3 秒
- 关键坑:用户态隧道的设备地址**只在本进程可达**。拿 (host, port) 另建 RSD 会得到
  OSError [WinError 1231] 网络位置不可达。必须把 aopen() 返回的 rsd 对象本身注入
  DVT 层(RealLocationProvider 早有 rsd= 注入口),因此 Windows 不能沿用
  「隧道助手子进程 + 解析 HOST PORT」那套架构
- 修正 WINDOWS_PORT.md 的错误:类名是 UserspaceRsdTunnel 不是 UserspaceTunnel;
  remote start-tunnel 没有 --userspace;Wintun 不是必需项

新增
- core/platform.py      平台抽象层(数据目录/浏览器探测/进程管理/提权/信号)
- core/apple_driver.py  Apple Mobile Device Support 检测与自动安装
                        (服务启动 → 独立 AMDS MSI → 完整 iTunes 包 → winget 逐级降级)
                        + 开发者镜像下载
- core/setup_window.py  启动时的「环境准备」窗口:逐项列出依赖,缺失项右侧可点安装,
                        必需项全部就绪后「进入程序」才可点击
- scripts/run.ps1, build.ps1, installer/iosloc.iss, installer/version_info.txt
- tools/make_ico.py(由已有 PNG 图标集生成多尺寸 .ico)
- tools/probe_tunnel_win.py(隧道可行性诊断)
- tests/win_e2e.py(7 项)、tests/win_app_e2e.py(9 项)真机验收
- docs/README-WINDOWS.md
- .gitattributes(避免 Windows 写出 CRLF 的 .sh 把 macOS 构建搞坏)

修改
- core/tunnel.py            Windows 走进程内 userspace 隧道;macOS 路径不变
- core/server.py            TunnelManager 注入 runner;provider_for 注入 rsd 对象;
                            资源目录与数据目录分离;日志落盘
- core/location_provider.py 平台化错误提示;新增保活(隧道静默断开会让设备
                            偷偷恢复真实定位,停止播放时每 15s 重发)
- main.py                   「环境准备」窗口、Apple 驱动后台检查、
                            --check-driver / --install-driver、跨平台浏览器与信号
- Location.spec             平台分支;Windows 用 .ico + uac_admin=True(启动即请求管理员)
                            + version_info.txt;单一 spec 不分叉
- tests/drift_sim.py / offline_tiles.py  去掉硬编码 /tmp,改用 tempfile.gettempdir()

打包
- exe 清单写入 requireAdministrator(用户要求:启动时直接请求管理员权限)
- 绿色版 dist\iOSLocationTrack\iOSLocationTrack.exe
- 安装包 dist\installer\iOSLocationTrack-1.0.0-setup.exe(Inno Setup)

实测(Windows 11 x64 / Python 3.12.14 / pymobiledevice3 11.31.0 / iPhone 11 iOS 26.6.1)
- 隧道→镜像→DVT→下发定位→恢复真实定位 全链路跑通,连接 0.8s
- selftest 83/83、drift_sim 40/40、offline_tiles 13/13
- win_e2e 7/7、win_app_e2e 9/9
- 打包版 exe:发现设备 → 0.3s 隧道 → DVT → 两次下发 → 恢复真实定位 → 干净退出
This commit is contained in:
jbjf6666 committed 2026-10-16 01:41:59 +08:00
1 parent 85e9a53750
commit 4d55e2415f
26 files changed
+4444 -235

No files matched your search

+57
View File
@@ -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
+124 -37
View File
@@ -1,20 +1,29 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller 打包配置:把整个应用(Python 运行时 + 依赖 + 前端资源)打成一个可独立运行的 .app。
"""PyInstaller 打包配置(macOS / Windows 双平台)。
用法:./.venv/bin/python -m PyInstaller --clean --noconfirm Location.spec
用法:
macOS : ./.venv/bin/python -m PyInstaller --clean --noconfirm Location.spec
Windows: .venv\\Scripts\\python.exe -m PyInstaller --clean --noconfirm Location.spec
设计要点:
- **onedir**(不是 onefile):启动更快,且 .app 内部结构可被 macOS 正常识别;
真要单文件也可以,但 onefile 每次启动都要解包到 /tmp,反而更慢。
- **onedir**(不是 onefile):启动更快,onefile 每次启动都要解包到临时目录,反而更慢。
- 前端资源(web/)与图标打进包内,运行时通过 sys._MEIPASS 定位。
- 排除 pymobiledevice3 带进来但本工具用不到的重型依赖(IPython / fastapi / uvicorn 等),
- 排除 pymobiledevice3 带进来但本工具用不到的重型依赖(IPython / pytest 等),
它们只被 pymobiledevice3 的 CLI 交互模式使用;本工具只用 Python API。
排除后体积从 ~190MB 降到 ~60MB 量级。
- **Windows 专属**:`uac_admin=True` —— 在 exe 清单里写入 requireAdministrator,
启动时直接弹 UAC 提权。这样首次运行就能自动安装 Apple Mobile Device Support,
不需要用户再手动"以管理员身份运行"。
(注:userspace 隧道本身**不需要**管理员权限,提权只是为了装驱动。)
注意:Windows 与 macOS 的差异都集中在文件末尾的 `if IS_WINDOWS:` 分支里,
保持单一 spec,避免两份配置漂移。
"""
import sys
from pathlib import Path
from PyInstaller.utils.hooks import copy_metadata
from PyInstaller.utils.hooks import collect_data_files, copy_metadata
IS_WINDOWS = sys.platform == "win32"
ROOT = Path(SPECPATH).resolve()
# 有些包在运行期用 importlib.metadata 读自己的版本(例如 apple_compress 的 __version__,
@@ -23,6 +32,8 @@ ROOT = Path(SPECPATH).resolve()
METADATA_PACKAGES = [
"apple_compress", "pymobiledevice3", "pyimg4", "construct",
"cryptography", "bpylist2", "ipsw_parser", "opack2",
# Windows 用户态隧道依赖链
"pytun_pmd3", "pmd_pytcp", "pmd_net_addr", "pmd_net_proto",
]
_metadata_datas: list = []
@@ -32,39 +43,35 @@ 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 打包)。
]
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",
# 运行期动态 import 的模块,PyInstaller 静态分析看不到
HIDDENIMPORTS = [
"pymobiledevice3.remote.remote_service_discovery",
"pymobiledevice3.remote.tunnel_service",
"pymobiledevice3.services.dvt.instruments.dvt_provider",
@@ -73,8 +80,52 @@ a = Analysis(
"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=_datas,
hiddenimports=HIDDENIMPORTS,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -85,6 +136,8 @@ a = Analysis(
pyz = PYZ(a.pure)
if IS_WINDOWS:
# ---------------- Windows:onedir + .exe(启动时请求管理员权限)----------------
exe = EXE(
pyz,
a.scripts,
@@ -95,7 +148,42 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=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",
)
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,
@@ -126,7 +214,6 @@ app = BUNDLE(
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": "1.0.0",
"CFBundlePackageType": "APPL",
# Finder/Dock 取图标用;缺这个字段时系统有时会显示通用图标
"CFBundleIconFile": "AppIcon",
"CFBundleIconName": "AppIcon",
"LSMinimumSystemVersion": "12.0",
+9 -2
View File
@@ -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
+8 -2
View File
@@ -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. 快速开始
+822
View File
@@ -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 字节小端头 ``<IIII`` = (length, version=1, message=8, tag),
然后是 XML plist;``length`` 含头。
"""
body = {
"MessageType": message_type,
"ProgName": "iOSLocationTrack",
"ClientVersionString": "iOSLocationTrack 1.0",
"kLibUSBMuxVersion": 3,
}
try:
payload = plistlib.dumps(body)
except Exception as exc: # pragma: no cover
return {"ok": False, "error": f"构造 plist 失败:{exc}"}
header = struct.pack("<IIII", 16 + len(payload), 1, 8, 1)
try:
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.sendall(header + payload)
raw = b""
while len(raw) < 4:
chunk = sock.recv(4 - len(raw))
if not chunk:
break
raw += chunk
if len(raw) < 4:
return {"ok": False, "error": "连接被对端关闭(无响应头)"}
total = struct.unpack("<I", raw[:4])[0]
if not (16 <= total <= 4 * 1024 * 1024):
return {"ok": False, "error": f"响应长度异常:{total}"}
while len(raw) < total:
chunk = sock.recv(total - len(raw))
if not chunk:
break
raw += chunk
except ConnectionRefusedError:
return {"ok": False, "error": f"{host}:{port} 拒绝连接(服务未运行或未安装)"}
except socket.timeout:
return {"ok": False, "error": f"{host}:{port} 连接超时"}
except OSError as exc:
return {"ok": False, "error": f"{host}:{port} 不可达:{exc}"}
try:
length, version, message, tag = struct.unpack("<IIII", raw[:16])
parsed = plistlib.loads(raw[16:total])
except Exception as exc:
return {"ok": False, "error": f"解析响应失败:{exc}"}
# 成功的两种形态:{"MessageType":"Result","Number":0} 或直接带数据(如 ListDevices)
if isinstance(parsed, dict):
number = parsed.get("Number")
if parsed.get("MessageType") == "Result" and number not in (0, None):
return {
"ok": False,
"error": f"usbmuxd 返回错误码 {number}",
"plist": parsed,
}
return {
"ok": True,
"plist": parsed,
"header": {"length": length, "version": version, "message": message, "tag": tag},
}
def list_devices() -> 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)"}
+85 -6
View File
@@ -39,6 +39,8 @@ import logging
import time
from typing import Any, Callable, Optional
from . import platform as plat
__all__ = [
"RealLocationProvider",
"LocationProviderError",
@@ -48,16 +50,31 @@ __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 语义)
#: 隧道提示(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);"
@@ -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:
+548
View File
@@ -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"
+40 -3
View File
@@ -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_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")
+606
View File
@@ -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
+237 -109
View File
@@ -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"
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 &" with administrator privileges'
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"
+294
View File
@@ -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 分支)**全部保留**,同一份代码库两个平台都能构建。
+42
View File
@@ -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`。
+89
View File
@@ -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;
+417
View File
@@ -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您确定要继续吗?
+32
View File
@@ -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])]),
],
)
+264 -74
View File
@@ -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:
await asyncio.Event().wait() # 持有隧道断言直到进程结束
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:
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):
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")],
[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:
continue
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,6 +433,9 @@ 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:
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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+162
View File
@@ -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)。"
+106
View File
@@ -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
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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)
+131
View File
@@ -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())
+195
View File
@@ -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())
+95
View File
@@ -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())
+77
View File
@@ -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())