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:
1 parent
85e9a53750
commit
4d55e2415f
26 files changed
+4444
-235
No files matched your search
@@ -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
@@ -1,20 +1,29 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- 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 正常识别;
|
- **onedir**(不是 onefile):启动更快,onefile 每次启动都要解包到临时目录,反而更慢。
|
||||||
真要单文件也可以,但 onefile 每次启动都要解包到 /tmp,反而更慢。
|
|
||||||
- 前端资源(web/)与图标打进包内,运行时通过 sys._MEIPASS 定位。
|
- 前端资源(web/)与图标打进包内,运行时通过 sys._MEIPASS 定位。
|
||||||
- 排除 pymobiledevice3 带进来但本工具用不到的重型依赖(IPython / fastapi / uvicorn 等),
|
- 排除 pymobiledevice3 带进来但本工具用不到的重型依赖(IPython / pytest 等),
|
||||||
它们只被 pymobiledevice3 的 CLI 交互模式使用;本工具只用 Python API。
|
它们只被 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 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()
|
ROOT = Path(SPECPATH).resolve()
|
||||||
|
|
||||||
# 有些包在运行期用 importlib.metadata 读自己的版本(例如 apple_compress 的 __version__,
|
# 有些包在运行期用 importlib.metadata 读自己的版本(例如 apple_compress 的 __version__,
|
||||||
@@ -23,6 +32,8 @@ ROOT = Path(SPECPATH).resolve()
|
|||||||
METADATA_PACKAGES = [
|
METADATA_PACKAGES = [
|
||||||
"apple_compress", "pymobiledevice3", "pyimg4", "construct",
|
"apple_compress", "pymobiledevice3", "pyimg4", "construct",
|
||||||
"cryptography", "bpylist2", "ipsw_parser", "opack2",
|
"cryptography", "bpylist2", "ipsw_parser", "opack2",
|
||||||
|
# Windows 用户态隧道依赖链
|
||||||
|
"pytun_pmd3", "pmd_pytcp", "pmd_net_addr", "pmd_net_proto",
|
||||||
]
|
]
|
||||||
|
|
||||||
_metadata_datas: list = []
|
_metadata_datas: list = []
|
||||||
@@ -32,39 +43,35 @@ for _pkg in METADATA_PACKAGES:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# pymobiledevice3 的 CLI 会拉起这些重型依赖,但本工具只用它的 Python API,全部排除
|
# pytun_pmd3 自带各架构的 wintun.dll(数据文件,PyInstaller 不会自动收集)。
|
||||||
# 策略:**尽量完整保留**,只排除"构建期工具"和"确定与运行无关的开发工具"。
|
# Windows 的 userspace 隧道走纯 Python PyTCP 栈,通常不需要它;但某些路径会用
|
||||||
# 依据(实测):questionary / prompt_toolkit / pygments 被 pymobiledevice3 运行期导入,
|
# pytun_pmd3.TunTapDevice,带上以免运行期缺 dll。整包只有 ~1.5MB。
|
||||||
# 曾因误排导致打包版真机不可用;因此与 pymobiledevice3 相关的一切都保留。
|
_extra_datas: list = []
|
||||||
# pip / setuptools / PyInstaller 只在构建期使用,运行 .app 时完全用不到。
|
try:
|
||||||
|
_extra_datas += collect_data_files("pytun_pmd3", includes=["wintun/**"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 只排除"构建期工具"和"确定与运行无关的开发工具"。
|
||||||
|
# 依据(macOS 侧实测):questionary / prompt_toolkit / pygments 被 pymobiledevice3
|
||||||
|
# 运行期导入,曾因误排导致打包版真机不可用;因此与 pymobiledevice3 相关的一律保留。
|
||||||
EXCLUDES = [
|
EXCLUDES = [
|
||||||
# 构建期工具(打包产物里绝不需要)
|
# 注意:**不要**排除 distutils / pkg_resources / setuptools / wheel / pip
|
||||||
# 注意:不要排除 distutils / pkg_resources —— PyInstaller 的 hook 会强制导入它们,
|
# —— PyInstaller 与 setuptools 的 hook 会强制导入/别名它们,排除后构建直接失败:
|
||||||
# 排除后构建直接失败(ValueError: Target module "distutils" already imported as ExcludedModule)
|
# ValueError: Target module "distutils" already imported as ExcludedModule('distutils',)
|
||||||
"pip", "wheel",
|
# ValueError: Target module "wheel" already imported as ExcludedModule('wheel',)
|
||||||
"PyInstaller", "pyinstaller", "_pyinstaller_hooks_contrib",
|
# (两个坑都在本项目上实测复现过;wheel 那条在 Windows + setuptools 84 上触发。)
|
||||||
|
# 它们体积很小,排除掉的收益远小于构建失败的风险。
|
||||||
|
#
|
||||||
# 开发/测试工具(pymobiledevice3 的交互式 CLI 才会用)
|
# 开发/测试工具(pymobiledevice3 的交互式 CLI 才会用)
|
||||||
"IPython", "ipython", "ipykernel", "jupyter", "jupyter_client", "jupyter_core",
|
"IPython", "ipython", "ipykernel", "jupyter", "jupyter_client", "jupyter_core",
|
||||||
"ipywidgets", "notebook", "nbformat", "nbconvert",
|
"ipywidgets", "notebook", "nbformat", "nbconvert",
|
||||||
"pytest", "_pytest", "matplotlib", "matplotlib_inline", "tkinter",
|
"pytest", "_pytest", "matplotlib", "matplotlib_inline",
|
||||||
|
# 注意:**不要**排除 tkinter —— 「环境准备」窗口用它实现(标准库,随 exe 打包)。
|
||||||
]
|
]
|
||||||
|
|
||||||
a = Analysis(
|
# 运行期动态 import 的模块,PyInstaller 静态分析看不到
|
||||||
["main.py"],
|
HIDDENIMPORTS = [
|
||||||
pathex=[str(ROOT)],
|
|
||||||
binaries=[],
|
|
||||||
datas=_metadata_datas + [
|
|
||||||
("web", "web"), # 前端(index.html / style.css / js/)
|
|
||||||
("resources/AppIcon.icns", "resources"),
|
|
||||||
# 开发者镜像素材(iOS 17+ 全系共用的个性化镜像;iOS 16.x 的经典镜像按需放)
|
|
||||||
("resources/ddi", "resources/ddi"),
|
|
||||||
("docs/PROTOCOL.md", "docs"),
|
|
||||||
("README.md", "."),
|
|
||||||
],
|
|
||||||
hiddenimports=[
|
|
||||||
# 这些是运行期动态 import 的,PyInstaller 静态分析看不到
|
|
||||||
"pymobiledevice3.remote.native_tunnel",
|
|
||||||
"pymobiledevice3.remote.userspace_tunnel",
|
|
||||||
"pymobiledevice3.remote.remote_service_discovery",
|
"pymobiledevice3.remote.remote_service_discovery",
|
||||||
"pymobiledevice3.remote.tunnel_service",
|
"pymobiledevice3.remote.tunnel_service",
|
||||||
"pymobiledevice3.services.dvt.instruments.dvt_provider",
|
"pymobiledevice3.services.dvt.instruments.dvt_provider",
|
||||||
@@ -73,8 +80,52 @@ a = Analysis(
|
|||||||
"pymobiledevice3.usbmux",
|
"pymobiledevice3.usbmux",
|
||||||
"pymobiledevice3.service_connection",
|
"pymobiledevice3.service_connection",
|
||||||
"pymobiledevice3.exceptions",
|
"pymobiledevice3.exceptions",
|
||||||
|
"pymobiledevice3.services.mobile_image_mounter",
|
||||||
"developer_disk_image.repo",
|
"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=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
@@ -85,6 +136,8 @@ a = Analysis(
|
|||||||
|
|
||||||
pyz = PYZ(a.pure)
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
if IS_WINDOWS:
|
||||||
|
# ---------------- Windows:onedir + .exe(启动时请求管理员权限)----------------
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
@@ -95,7 +148,42 @@ exe = EXE(
|
|||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=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,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
@@ -126,7 +214,6 @@ app = BUNDLE(
|
|||||||
"CFBundleShortVersionString": "1.0.0",
|
"CFBundleShortVersionString": "1.0.0",
|
||||||
"CFBundleVersion": "1.0.0",
|
"CFBundleVersion": "1.0.0",
|
||||||
"CFBundlePackageType": "APPL",
|
"CFBundlePackageType": "APPL",
|
||||||
# Finder/Dock 取图标用;缺这个字段时系统有时会显示通用图标
|
|
||||||
"CFBundleIconFile": "AppIcon",
|
"CFBundleIconFile": "AppIcon",
|
||||||
"CFBundleIconName": "AppIcon",
|
"CFBundleIconName": "AppIcon",
|
||||||
"LSMinimumSystemVersion": "12.0",
|
"LSMinimumSystemVersion": "12.0",
|
||||||
|
|||||||
+9
-2
@@ -1,13 +1,20 @@
|
|||||||
# iOS Virtual Location & Route Simulator (macOS)
|
# iOS Virtual Location & Route Simulator (macOS / Windows)
|
||||||
|
|
||||||
[中文](README.md) | [**English**](README.en.md)
|
[中文](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**.
|
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.
|
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
|
## 1. Quick Start
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
# iOS 虚拟定位轨迹工具(macOS)
|
# iOS 虚拟定位轨迹工具(macOS / Windows)
|
||||||
|
|
||||||
[**中文**](README.md) | [English](README.en.md)
|
[**中文**](README.md) | [English](README.en.md)
|
||||||
|
|
||||||
在 Mac 上给 iPhone / iPad 做**虚拟定位**与**轨迹模拟**的桌面工具:在地图上绘制路线、添加途经点、按真实速度沿轨迹驱动手机定位。
|
在电脑上给 iPhone / iPad 做**虚拟定位**与**轨迹模拟**的桌面工具:在地图上绘制路线、添加途经点、按真实速度沿轨迹驱动手机定位。
|
||||||
|
|
||||||
支持 **iOS 17 ~ iOS 27.2**(RSD 隧道 + DVT `SimulateLocation`),应用内内置了全系共用的开发者镜像素材,**不需要安装 Python 环境**。
|
支持 **iOS 17 ~ iOS 27.2**(RSD 隧道 + DVT `SimulateLocation`),应用内内置了全系共用的开发者镜像素材,**不需要安装 Python 环境**。
|
||||||
|
|
||||||
界面为本机网页应用,用 Chrome / Edge 的应用窗口打开(无地址栏,像原生 App),也可以直接用浏览器访问 `http://127.0.0.1:8787`。地图、轨迹、播放引擎、设备通信全部在本机完成,只有地图瓦片和(可选的)路线规划需要联网。
|
界面为本机网页应用,用 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. 快速开始
|
## 1. 快速开始
|
||||||
|
|||||||
@@ -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)"}
|
||||||
@@ -39,6 +39,8 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
from . import platform as plat
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RealLocationProvider",
|
"RealLocationProvider",
|
||||||
"LocationProviderError",
|
"LocationProviderError",
|
||||||
@@ -48,16 +50,31 @@ __all__ = [
|
|||||||
|
|
||||||
logger = logging.getLogger("iosloc.location.real")
|
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 时的中文修复建议(含本机实测可用的阿里云镜像)
|
#: 未安装 pymobiledevice3 时的中文修复建议(含本机实测可用的阿里云镜像)
|
||||||
PMD3_MISSING_HINT = (
|
PMD3_MISSING_HINT = (
|
||||||
"未检测到 pymobiledevice3 库。请在项目目录执行:"
|
"未检测到 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 隧道」的分界线
|
#: iOS 17 是「必须走 RSD 隧道」的分界线
|
||||||
RSD_REQUIRED_IOS_MAJOR = 17
|
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 = (
|
TUNNEL_HINT = (
|
||||||
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
|
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
|
||||||
"请以管理员权限运行 run.sh(sudo ./run.sh);"
|
"请以管理员权限运行 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:
|
elif "developerdiskimage" in low or "developer disk image" in low or "mounter mount" in low:
|
||||||
detail = (
|
detail = (
|
||||||
"设备未挂载开发者镜像(DeveloperDiskImage),DVT 服务不可用。"
|
"设备未挂载开发者镜像(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:
|
elif "developer mode" in low or "developermode" in low:
|
||||||
detail = "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(需重启设备)"
|
detail = "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(需重启设备)"
|
||||||
elif "invalidservice" in low or "no such service" in low or "startserviceerror" in low:
|
elif "invalidservice" in low or "no such service" in low or "startserviceerror" in low:
|
||||||
detail = (
|
detail = (
|
||||||
"设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,请执行:"
|
"设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,请执行:"
|
||||||
"./.venv/bin/python -m pymobiledevice3 mounter auto-mount;"
|
f"{_PY} -m pymobiledevice3 mounter auto-mount;"
|
||||||
"iOS 17+ 则需要 RSD 隧道(隧道提示见下)"
|
"iOS 17+ 则需要 RSD 隧道(隧道提示见下)"
|
||||||
)
|
)
|
||||||
elif "not paired" in low or "notpaired" in low or "pair" in low and "invalid" in low:
|
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._session_info: dict = {}
|
||||||
self._last_location: Optional[tuple] = None
|
self._last_location: Optional[tuple] = None
|
||||||
self._send_count = 0
|
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
|
self._connected_at: Optional[float] = None
|
||||||
|
|
||||||
# pymobiledevice3 对象
|
# pymobiledevice3 对象
|
||||||
@@ -325,8 +346,8 @@ class RealLocationProvider:
|
|||||||
|
|
||||||
if state in ("unpaired",):
|
if state in ("unpaired",):
|
||||||
raise LocationProviderError(
|
raise LocationProviderError(
|
||||||
"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
|
f"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
|
||||||
"./.venv/bin/python -m pymobiledevice3 lockdown pair)"
|
f"{_PY} -m pymobiledevice3 lockdown pair)"
|
||||||
)
|
)
|
||||||
if state in ("unavailable",):
|
if state in ("unavailable",):
|
||||||
raise LocationProviderError("设备当前不可用:请重新插拔 USB 线并解锁 iPhone 后重试")
|
raise LocationProviderError("设备当前不可用:请重新插拔 USB 线并解锁 iPhone 后重试")
|
||||||
@@ -353,6 +374,8 @@ class RealLocationProvider:
|
|||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._connected_at = time.time()
|
self._connected_at = time.time()
|
||||||
|
# 会话建立后启动保活(见 _keepalive_loop 的说明)
|
||||||
|
self._start_keepalive()
|
||||||
self._session_info = {
|
self._session_info = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"mock": False,
|
"mock": False,
|
||||||
@@ -534,9 +557,62 @@ class RealLocationProvider:
|
|||||||
|
|
||||||
self._last_location = (lat, lon)
|
self._last_location = (lat, lon)
|
||||||
self._send_count += 1
|
self._send_count += 1
|
||||||
|
self._last_send_mono = time.monotonic()
|
||||||
if self._send_count == 1 or self._send_count % 25 == 0:
|
if self._send_count == 1 or self._send_count % 25 == 0:
|
||||||
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
|
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:
|
async def clear_location(self) -> None:
|
||||||
"""让设备恢复真实定位(``play.stop`` / ``location.clear`` 语义)。"""
|
"""让设备恢复真实定位(``play.stop`` / ``location.clear`` 语义)。"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
@@ -550,10 +626,13 @@ class RealLocationProvider:
|
|||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
raise _wrap("清除虚拟定位", exc) from exc
|
raise _wrap("清除虚拟定位", exc) from exc
|
||||||
self._last_location = None
|
self._last_location = None
|
||||||
|
self._last_send_mono = 0.0
|
||||||
|
self._stop_keepalive()
|
||||||
self._log("info", "已清除虚拟定位,设备恢复真实定位")
|
self._log("info", "已清除虚拟定位,设备恢复真实定位")
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""释放会话:先 clear(恢复真实定位)再关闭 DVT / RSD / lockdown。幂等。"""
|
"""释放会话:先 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:
|
if not self._connected and self._dvt is None and self._rsd_obj is None and self._lockdown_obj is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
@@ -85,8 +85,12 @@ class AsyncRunner:
|
|||||||
class App:
|
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.root = Path(root)
|
||||||
|
self.resources = Path(resources) if resources is not None else self.root
|
||||||
self.mock = mock
|
self.mock = mock
|
||||||
self.port = port
|
self.port = port
|
||||||
self.host = protocol.DEFAULT_HOST
|
self.host = protocol.DEFAULT_HOST
|
||||||
@@ -100,13 +104,18 @@ class App:
|
|||||||
pass
|
pass
|
||||||
self.runner = AsyncRunner()
|
self.runner = AsyncRunner()
|
||||||
self.hub = ws.Hub()
|
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.devices = device_mod.DeviceManager(mock=mock, on_change=self._on_devices_change)
|
||||||
self.routes = route_engine.RouteEngine(self.config)
|
self.routes = route_engine.RouteEngine(self.config)
|
||||||
self.tiles = tilecache.TileCache(
|
self.tiles = tilecache.TileCache(
|
||||||
self.config.cache_dir(), int(self.config.get("tile_cache_max_mb") or 1024)
|
self.config.cache_dir(), int(self.config.get("tile_cache_max_mb") or 1024)
|
||||||
)
|
)
|
||||||
self.logs: deque[dict[str, Any]] = deque(maxlen=500)
|
self.logs: deque[dict[str, Any]] = deque(maxlen=500)
|
||||||
|
self._log_file = self._open_log_file()
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self.provider: Any = None
|
self.provider: Any = None
|
||||||
self.provider_udid: str | None = None
|
self.provider_udid: str | None = None
|
||||||
@@ -126,10 +135,27 @@ class App:
|
|||||||
self._mount_running = False
|
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:
|
def log(self, level: str, message: str) -> None:
|
||||||
entry = {"ts": protocol.now_ts(), "level": level, "msg": str(message)}
|
entry = {"ts": protocol.now_ts(), "level": level, "msg": str(message)}
|
||||||
self.logs.append(entry)
|
self.logs.append(entry)
|
||||||
self.hub.broadcast({"type": "log", **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]]:
|
def recent_logs(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
@@ -218,7 +244,13 @@ class App:
|
|||||||
# 而且如果同时插着另一台可用的设备,要直接告诉用户换哪台。
|
# 而且如果同时插着另一台可用的设备,要直接告诉用户换哪台。
|
||||||
self._preflight(device)
|
self._preflight(device)
|
||||||
|
|
||||||
|
# RSD 有两种形态,必须区分(这是 Windows 与 macOS 最大的架构差异):
|
||||||
|
# - macOS native 隧道:地址内核可路由 → 传 (host, port) 让 provider 自己连
|
||||||
|
# - Windows userspace 隧道:地址只在**本进程的用户态协议栈**里可达,
|
||||||
|
# 直接 TCP 连接会得到 OSError [WinError 1231] 网络位置不可达 →
|
||||||
|
# 必须把隧道返回的那个已连接 RSD 对象本身注入给 provider
|
||||||
rsd = None
|
rsd = None
|
||||||
|
rsd_obj = None
|
||||||
if (device.ios_major or 0) >= 17:
|
if (device.ios_major or 0) >= 17:
|
||||||
if not self.tunnel.ready:
|
if not self.tunnel.ready:
|
||||||
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
|
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
|
||||||
@@ -228,8 +260,11 @@ class App:
|
|||||||
"tunnel_required",
|
"tunnel_required",
|
||||||
status=409,
|
status=409,
|
||||||
)
|
)
|
||||||
|
rsd_obj = self.tunnel.rsd_object()
|
||||||
|
if rsd_obj is None:
|
||||||
rsd = self.tunnel.address()
|
rsd = self.tunnel.address()
|
||||||
provider = RealLocationProvider(
|
provider = RealLocationProvider(
|
||||||
|
rsd=rsd_obj,
|
||||||
rsd_host=rsd[0] if rsd else None,
|
rsd_host=rsd[0] if rsd else None,
|
||||||
rsd_port=rsd[1] if rsd else None,
|
rsd_port=rsd[1] if rsd else None,
|
||||||
udid=device.udid,
|
udid=device.udid,
|
||||||
@@ -264,7 +299,7 @@ class App:
|
|||||||
hint = f";建议改用另一台设备:{best.name}(iOS {best.ios_version},{best.connection.upper()})"
|
hint = f";建议改用另一台设备:{best.name}(iOS {best.ios_version},{best.connection.upper()})"
|
||||||
raise protocol.AppError(
|
raise protocol.AppError(
|
||||||
f"{device.name}(iOS {device.ios_version})尚未挂载开发者镜像,无法做虚拟定位。"
|
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",
|
"developer_image_missing",
|
||||||
status=409,
|
status=409,
|
||||||
)
|
)
|
||||||
@@ -779,6 +814,8 @@ class App:
|
|||||||
base = getattr(sys, "_MEIPASS", None)
|
base = getattr(sys, "_MEIPASS", None)
|
||||||
if base:
|
if base:
|
||||||
sources.append(Path(base) / "resources" / "ddi" / "personalized")
|
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(self.root / "resources" / "ddi" / "personalized")
|
||||||
sources.append(Path.home() / ".ios-location-ddi" / "personalized")
|
sources.append(Path.home() / ".ios-location-ddi" / "personalized")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 上支持
|
iOS 17 起 DVT 服务不再走 usbmux,必须先在主机与设备之间建立 RSD 隧道。
|
||||||
`--native`:借用 Apple 自带 remoted 的隧道,**不需要 root**,并把内核可路由的 RSD 地址打印出来
|
|
||||||
(--script-mode 输出 "HOST PORT"),因此可以被其它进程直接使用。
|
两个平台的建立方式**本质不同**,这是本模块最需要注意的地方:
|
||||||
- 该 native 隧道与 remoted 共用设备的单条 RSD 连接,会互相挤掉,所以同一时间只应有一个客户端
|
|
||||||
(即:本工具运行时应关闭爱思助手等同类工具)。
|
macOS
|
||||||
- 如果 native 不可用,退化为 `osascript` 弹窗向用户索取管理员密码,再跑经典隧道。
|
用 ``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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from . import platform as plat
|
||||||
from . import protocol
|
from . import protocol
|
||||||
|
|
||||||
POLL_INTERVAL = 0.25
|
POLL_INTERVAL = 0.25
|
||||||
START_TIMEOUT = 75.0
|
START_TIMEOUT = 75.0
|
||||||
_HINT_NATIVE_FAILED = (
|
|
||||||
|
#: Windows 上不需要提权;macOS 的 sudo 兜底路径保留
|
||||||
|
_HINT_MAC_FAILED = (
|
||||||
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
|
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
|
||||||
"② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);"
|
"② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);"
|
||||||
"④ 若仍失败,可尝试管理员模式:在终端执行 sudo ./run.sh"
|
"④ 若仍失败,可尝试管理员模式:在终端执行 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:
|
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._lock = threading.RLock()
|
||||||
self.state = "idle" # idle|starting|connected|error|unsupported
|
self.state = "idle" # idle|starting|connected|error|unsupported
|
||||||
self.message = ""
|
self.message = ""
|
||||||
self.host: str | None = None
|
self.host: str | None = None
|
||||||
self.port: int | None = None
|
self.port: int | None = None
|
||||||
self.udid: str | 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._process: subprocess.Popen[str] | None = None
|
||||||
self._reader: threading.Thread | None = None
|
self._reader: threading.Thread | None = None
|
||||||
self._needs_root = False
|
self._needs_root = False
|
||||||
self._log = log
|
self._log = log
|
||||||
self._on_change = on_change
|
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]:
|
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,
|
"rsd": f"{self.host} {self.port}" if self.host and self.port else None,
|
||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
"udid": self.udid,
|
"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:
|
def _emit(self, level: str, message: str) -> None:
|
||||||
if self._log:
|
if self._log:
|
||||||
try:
|
try:
|
||||||
@@ -101,19 +155,84 @@ class TunnelManager:
|
|||||||
return self.start(udid=udid, allow_sudo=allow_sudo)
|
return self.start(udid=udid, allow_sudo=allow_sudo)
|
||||||
|
|
||||||
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict[str, Any]:
|
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 连接:残留的旧隧道进程会与新的互相挤掉,
|
# 设备在 iOS 17+ 上只有一条 RSD 连接:残留的旧隧道进程会与新的互相挤掉,
|
||||||
# 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。
|
# 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。
|
||||||
if not allow_sudo:
|
if not allow_sudo:
|
||||||
self._kill_stale_tunnels()
|
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)
|
command, mode = self._build_command(udid=udid, allow_sudo=allow_sudo)
|
||||||
if command is None:
|
if command is None:
|
||||||
self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None)
|
self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None)
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
env.setdefault("PYTHONUNBUFFERED", "1") # 不加的话 stdout 是块缓冲,HOST PORT 读不到
|
||||||
env.setdefault("PYTHONPATH", str(_project_root()))
|
env.setdefault("PYTHONPATH", str(plat.resolve_roots()[0]))
|
||||||
self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}")
|
self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}")
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
@@ -124,7 +243,7 @@ class TunnelManager:
|
|||||||
text=True,
|
text=True,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
env=env,
|
env=env,
|
||||||
start_new_session=True,
|
**plat.spawn_detached_kwargs(),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode)
|
self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode)
|
||||||
@@ -136,7 +255,8 @@ class TunnelManager:
|
|||||||
self._needs_root = mode == "sudo"
|
self._needs_root = mode == "sudo"
|
||||||
if mode != "sudo":
|
if mode != "sudo":
|
||||||
_write_pidfile([process.pid])
|
_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()
|
self._reader.start()
|
||||||
|
|
||||||
deadline = time.time() + START_TIMEOUT
|
deadline = time.time() + START_TIMEOUT
|
||||||
@@ -153,18 +273,15 @@ class TunnelManager:
|
|||||||
if not self.ready:
|
if not self.ready:
|
||||||
message = _HINT_NATIVE_FAILED_SHORT
|
message = _HINT_NATIVE_FAILED_SHORT
|
||||||
if mode == "sudo":
|
if mode == "sudo":
|
||||||
message = "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错"
|
message = ("管理员隧道启动失败:请查看具体报错后重试"
|
||||||
|
if plat.IS_WINDOWS else
|
||||||
|
"管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错")
|
||||||
self.stop()
|
self.stop()
|
||||||
self._set_state("error", message, mode=mode)
|
self._set_state("error", message, mode=mode)
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]:
|
def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]:
|
||||||
"""拼出"建隧道"命令。
|
"""拼出"建隧道"命令(**仅 macOS 使用**;Windows 走进程内 API)。"""
|
||||||
|
|
||||||
- 打包成 .app(sys.frozen):`<本可执行文件> tunnel-helper --udid X`
|
|
||||||
—— 这条路径不依赖机器上有 Python/pymobiledevice3;
|
|
||||||
- 源码运行:`python -m pymobiledevice3 remote start-tunnel --native --script-mode`。
|
|
||||||
"""
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
@@ -179,65 +296,51 @@ class TunnelManager:
|
|||||||
command += ["--udid", udid]
|
command += ["--udid", udid]
|
||||||
if not allow_sudo:
|
if not allow_sudo:
|
||||||
return command, "native"
|
return command, "native"
|
||||||
|
if plat.IS_MACOS:
|
||||||
inner = " ".join(_shell_quote(part) for part in command)
|
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"
|
return ["osascript", "-e", script], "sudo"
|
||||||
|
# Windows 的 userspace 隧道不需要提权;走到这里说明上层误用了 sudo 路径
|
||||||
|
return command, "native"
|
||||||
|
|
||||||
def _kill_stale_tunnels(self) -> None:
|
def _kill_stale_tunnels(self) -> None:
|
||||||
"""清理**本工具自己**遗留在后台的 start-tunnel 进程。
|
"""清理**本工具自己**遗留在后台的 start-tunnel 进程。
|
||||||
|
|
||||||
iOS 17+ 设备只有一条 RSD 连接:残留进程会与新建的隧道互相挤掉。
|
iOS 17+ 设备只有一条 RSD 连接:残留进程会与新建的隧道互相挤掉。
|
||||||
为了避免误杀用户在终端手动起的隧道或其它工具,这里只处理本项目上次运行时
|
为避免误杀用户手动起的隧道或其它工具,只处理本项目记录在 pid 文件里的进程,
|
||||||
记录在 pid 文件里的进程(会先用 ps 校验命令行确实是 start-tunnel)。
|
并且会先校验命令行确实属于本工具。
|
||||||
"""
|
"""
|
||||||
pids = _read_pidfile()
|
pids = _read_pidfile()
|
||||||
if not pids:
|
if not pids:
|
||||||
return
|
return
|
||||||
alive: list[int] = []
|
alive: list[int] = []
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
command = _command_of(pid)
|
if not plat.pid_alive(pid):
|
||||||
if command and "start-tunnel" in command:
|
continue
|
||||||
|
command = plat.process_cmdline(pid) or ""
|
||||||
|
if _looks_like_our_tunnel(command):
|
||||||
alive.append(pid)
|
alive.append(pid)
|
||||||
_write_pidfile([])
|
_write_pidfile([])
|
||||||
if not alive:
|
if not alive:
|
||||||
return
|
return
|
||||||
self._emit("warn", f"发现 {len(alive)} 个上次遗留的隧道进程,正在清理…")
|
self._emit("warn", f"发现 {len(alive)} 个上次遗留的隧道进程,正在清理…")
|
||||||
for pid in alive:
|
for pid in alive:
|
||||||
try:
|
plat.terminate_process(pid, force=False, timeout=3.0)
|
||||||
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
|
|
||||||
|
|
||||||
def _cli_command(self) -> list[str] | None:
|
def _cli_command(self) -> list[str] | None:
|
||||||
"""返回"建隧道"命令的前缀。
|
"""返回"建隧道"命令的前缀(源码运行时的 macOS 路径)。"""
|
||||||
|
|
||||||
- 打包成 .app 独立运行时(sys.frozen):用本可执行文件 + `tunnel-helper` 子命令
|
|
||||||
(递归调用自己),因此**不需要机器上装 Python 或 pymobiledevice3**;
|
|
||||||
- 源码运行时:优先 venv 里的 `python -m pymobiledevice3`,其次 PATH 里的可执行文件。
|
|
||||||
"""
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
return [sys.executable, "tunnel-helper"]
|
return [sys.executable, "tunnel-helper"]
|
||||||
candidate = _detect_python()
|
candidate = plat.python_for_subprocess()
|
||||||
if candidate:
|
if candidate:
|
||||||
try:
|
try:
|
||||||
probe = subprocess.run(
|
probe = subprocess.run(
|
||||||
[candidate, "-c", "import pymobiledevice3, sys; print(pymobiledevice3.__version__ if hasattr(pymobiledevice3,'__version__') else 'ok')"],
|
[candidate, "-c",
|
||||||
capture_output=True,
|
"import pymobiledevice3; print('ok')"],
|
||||||
text=True,
|
capture_output=True, text=True, timeout=25,
|
||||||
timeout=25,
|
|
||||||
)
|
)
|
||||||
if probe.returncode == 0:
|
if probe.returncode == 0:
|
||||||
return [candidate, "-m", "pymobiledevice3"]
|
return [candidate, "-m", "pymobiledevice3"]
|
||||||
@@ -271,7 +374,8 @@ class TunnelManager:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
code = process.poll()
|
code = process.poll()
|
||||||
if code is not None and not self.ready:
|
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]:
|
def use_manual(self, host: str, port: int, udid: str | None = None) -> dict[str, Any]:
|
||||||
@@ -283,28 +387,32 @@ class TunnelManager:
|
|||||||
if not host:
|
if not host:
|
||||||
raise protocol.AppError("RSD 地址不能为空", "bad_rsd")
|
raise protocol.AppError("RSD 地址不能为空", "bad_rsd")
|
||||||
self.stop()
|
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()
|
return self.snapshot()
|
||||||
|
|
||||||
def stop(self) -> None:
|
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:
|
with self._lock:
|
||||||
process = self._process
|
process = self._process
|
||||||
self._process = None
|
self._process = None
|
||||||
if process is not None and process.poll() is None:
|
if process is not None and process.poll() is None:
|
||||||
try:
|
plat.terminate_process_tree(process)
|
||||||
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
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.host = None
|
self.host = None
|
||||||
self.port = None
|
self.port = None
|
||||||
@@ -315,6 +423,37 @@ class TunnelManager:
|
|||||||
_write_pidfile([])
|
_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:
|
def _parse_rsd_line(text: str, pattern: re.Pattern[str]) -> tuple[str, int] | None:
|
||||||
if text.startswith("--rsd "):
|
if text.startswith("--rsd "):
|
||||||
parts = text.split()
|
parts = text.split()
|
||||||
@@ -355,7 +494,7 @@ def _mask(command: list[str]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _pidfile_path() -> 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]:
|
def _read_pidfile() -> list[int]:
|
||||||
@@ -379,18 +518,6 @@ def _write_pidfile(pids: list[int]) -> None:
|
|||||||
pass
|
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:
|
def _shell_quote(text: str) -> str:
|
||||||
return "'" + text.replace("'", "'\\''") + "'"
|
return "'" + text.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
@@ -400,35 +527,36 @@ def _project_root() -> str:
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
if getattr(sys, "frozen", False):
|
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__)))
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
def _writable_data_dir() -> str:
|
def _writable_data_dir() -> str:
|
||||||
"""打包运行时可写的数据目录(~/Library/Application Support/...)。"""
|
return str(plat.data_dir())
|
||||||
base = os.path.expanduser("~/Library/Application Support/iOSLocationTrack")
|
|
||||||
try:
|
|
||||||
os.makedirs(base, exist_ok=True)
|
|
||||||
except OSError:
|
|
||||||
base = os.path.expanduser("~")
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_python() -> str | None:
|
def _detect_python() -> str | None:
|
||||||
"""返回项目 venv 的 python,找不到就退回当前解释器。"""
|
return plat.python_for_subprocess()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def tunnel_supported() -> bool:
|
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"
|
||||||
@@ -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 分支)**全部保留**,同一份代码库两个平台都能构建。
|
||||||
@@ -417,3 +417,45 @@ python main.py --mock # 界面能开、能画轨迹、能播放(
|
|||||||
- pymobiledevice3 文档:https://github.com/doronz88/pymobiledevice3
|
- pymobiledevice3 文档:https://github.com/doronz88/pymobiledevice3
|
||||||
- userspace 隧道源码:`pymobiledevice3/remote/userspace_tunnel.py`
|
- userspace 隧道源码:`pymobiledevice3/remote/userspace_tunnel.py`
|
||||||
- 开发者镜像素材来源:https://github.com/doronz88/DeveloperDiskImage
|
- 开发者镜像素材来源: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`。
|
||||||
@@ -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;
|
||||||
@@ -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您确定要继续吗?
|
||||||
@@ -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])]),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""iOS 虚拟定位轨迹工具 — 程序入口。
|
"""iOS 虚拟定位轨迹工具 — 程序入口(macOS / Windows 通用)。
|
||||||
|
|
||||||
用法:
|
用法(Windows):
|
||||||
./run.sh # 正常启动(自动打开界面)
|
.\\scripts\\run.ps1 # 正常启动(自动装依赖、自动检查/安装 Apple 驱动)
|
||||||
./.venv/bin/python main.py --mock # 无设备模拟模式(验收/演示用)
|
.venv\\Scripts\\python.exe main.py --mock # 无设备模拟模式
|
||||||
./.venv/bin/python main.py --port 8899 --no-browser
|
.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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,28 +22,15 @@ import time
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
def _resolve_roots() -> tuple[Path, Path]:
|
from core import platform as plat # noqa: E402
|
||||||
"""返回 (资源根, 数据根)。
|
|
||||||
|
|
||||||
- 源码运行:两者都是项目目录
|
ROOT, DATA_ROOT = plat.resolve_roots()
|
||||||
- 打包成 .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()
|
|
||||||
sys.path.insert(0, str(ROOT))
|
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 protocol, server # noqa: E402
|
||||||
|
from core import device as device_mod # noqa: E402
|
||||||
|
|
||||||
BANNER = r"""
|
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:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="ios-location",
|
prog="ios-location",
|
||||||
description=f"{protocol.APP_NAME} v{protocol.APP_VERSION}",
|
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("--host", default=protocol.DEFAULT_HOST,
|
||||||
parser.add_argument("--port", type=int, default=protocol.DEFAULT_PORT, help=f"监听端口(默认 {protocol.DEFAULT_PORT})")
|
help="监听地址(默认 127.0.0.1,仅本机可访问)")
|
||||||
parser.add_argument("--mock", action="store_true", help="无设备模拟模式:不连真机,用于界面演示与验收")
|
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("--no-browser", action="store_true", help="启动后不自动打开界面")
|
||||||
parser.add_argument("--window", choices=["app", "browser", "none"], default="app",
|
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
|
# 内部助手模式:`<可执行文件> tunnel-helper --udid XXX` 建立 RSD 隧道并打印 HOST PORT
|
||||||
parser.add_argument("command", nargs="?", default=None,
|
parser.add_argument("command", nargs="?", default=None, help=argparse.SUPPRESS)
|
||||||
help=argparse.SUPPRESS)
|
|
||||||
parser.add_argument("--udid", default=None,
|
parser.add_argument("--udid", default=None,
|
||||||
help=argparse.SUPPRESS) # 供 tunnel-helper 指定设备
|
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)
|
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:
|
def run_tunnel_helper(udid: str | None) -> int:
|
||||||
"""建立 iOS 17+ 的 RSD 隧道并保持进程存活,向 stdout 打印「HOST PORT」。
|
"""建立 iOS 17+ 的 RSD 隧道并保持进程存活,向 stdout 打印「HOST PORT」。
|
||||||
|
|
||||||
打包成 .app 后不能再调用 `python -m pymobiledevice3`,因此把助手模式做进本程序:
|
- macOS:借 Apple ``remoted`` 的 native 隧道(内核可路由,父进程能直接连)。
|
||||||
主进程用 `sys.executable tunnel-helper` 递归启动自己(见 core/tunnel.py)。
|
- Windows:userspace 隧道(进程内 PyTCP)。**它的地址父进程连不上**,
|
||||||
|
因此本助手模式在 Windows 上只用于诊断/兼容,正式路径是
|
||||||
|
``core/tunnel.py`` 在应用自己的事件循环里直接建隧道。
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
try:
|
def _emit(line: str) -> None:
|
||||||
from pymobiledevice3.remote.native_tunnel import establish_native_rsd
|
# 打包成窗口程序时 sys.stdout 可能是 None,stdout/stderr 各写一份
|
||||||
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}"
|
|
||||||
for stream in (sys.stdout, sys.stderr):
|
for stream in (sys.stdout, sys.stderr):
|
||||||
if stream is None:
|
if stream is None:
|
||||||
continue
|
continue
|
||||||
@@ -108,8 +206,44 @@ def run_tunnel_helper(udid: str | None) -> int:
|
|||||||
stream.flush()
|
stream.flush()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
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:
|
except asyncio.CancelledError:
|
||||||
return 0
|
return 0
|
||||||
return 0
|
return 0
|
||||||
@@ -142,20 +276,20 @@ def open_ui(url: str, mode: str) -> None:
|
|||||||
if mode == "none":
|
if mode == "none":
|
||||||
return
|
return
|
||||||
if mode == "app":
|
if mode == "app":
|
||||||
for binary in APP_MODE_BROWSERS:
|
binary = plat.find_app_browser()
|
||||||
if os.path.exists(binary):
|
if binary is not None:
|
||||||
try:
|
try:
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
[binary, f"--app={url}", "--window-size=1440,900",
|
[str(binary), f"--app={url}", "--window-size=1440,900",
|
||||||
"--user-data-dir=" + str(Path.home() / ".ios-location-appwindow")],
|
"--user-data-dir=" + str(plat.app_window_profile_dir())],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
pass
|
||||||
try:
|
try:
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -172,9 +306,28 @@ def find_free_port(host: str, preferred: int) -> int:
|
|||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
if args.command == "tunnel-helper":
|
if args.command == "tunnel-helper":
|
||||||
# 内部助手模式:由 TunnelManager 递归调用,只负责建隧道并打印 HOST PORT
|
|
||||||
return run_tunnel_helper(args.udid)
|
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
|
host = args.host
|
||||||
url_host = "127.0.0.1" if host in ("0.0.0.0", "::") else 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(BANNER)
|
||||||
print(f" {protocol.APP_NAME} v{protocol.APP_VERSION}")
|
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:
|
if mock:
|
||||||
print(" 模式:无设备模拟(--mock),不会连接真机")
|
print(" 模式:无设备模拟(--mock),不会连接真机")
|
||||||
else:
|
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.log("info", f"{protocol.APP_NAME} 启动,端口 {args.port}")
|
||||||
# 关键依赖自检:打包成 .app 后若漏打模块,这里会立刻暴露,
|
|
||||||
# 而不是等到"设备发现莫名退化成 libimobiledevice"才发现。
|
# 关键依赖自检:打包后漏打模块会立刻暴露,而不是等到"设备发现莫名退化成
|
||||||
|
# libimobiledevice"才发现。Windows 需要额外检查 userspace 隧道链路。
|
||||||
missing = []
|
missing = []
|
||||||
for module in (
|
modules = [
|
||||||
"pymobiledevice3",
|
"pymobiledevice3",
|
||||||
"pymobiledevice3.lockdown",
|
"pymobiledevice3.lockdown",
|
||||||
"pymobiledevice3.usbmux",
|
"pymobiledevice3.usbmux",
|
||||||
"pymobiledevice3.remote.native_tunnel",
|
|
||||||
"pymobiledevice3.remote.remote_service_discovery",
|
"pymobiledevice3.remote.remote_service_discovery",
|
||||||
"pymobiledevice3.services.dvt.instruments.dvt_provider",
|
"pymobiledevice3.services.dvt.instruments.dvt_provider",
|
||||||
"pymobiledevice3.services.dvt.instruments.location_simulation",
|
"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:
|
try:
|
||||||
__import__(module)
|
__import__(module)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
missing.append(f"{module}({exc})")
|
missing.append(f"{module}({exc})")
|
||||||
if missing:
|
if missing:
|
||||||
app.log("error", "关键模块缺失,真机功能将不可用:" + ";".join(missing))
|
app.log("error", "关键模块缺失,真机功能将不可用:" + ";".join(missing))
|
||||||
|
print(" [错误] 关键模块缺失:" + ";".join(missing))
|
||||||
elif not mock:
|
elif not mock:
|
||||||
app.log("info", "关键模块自检通过(pymobiledevice3 / RSD 隧道 / DVT 定位)")
|
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):
|
if getattr(app.config, "_repaired", False):
|
||||||
app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值")
|
app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值")
|
||||||
|
|
||||||
# 兜底清理:无论怎么退出(SIGINT/SIGTERM/异常),都不能把隧道子进程留在后台,
|
# 兜底清理:无论怎么退出(SIGINT/SIGTERM/异常),都不能把隧道留在后台,
|
||||||
# 否则 iOS 17+ 的单条 RSD 连接会被残留进程占住,下一次定位会失败。
|
# 否则 iOS 17+ 的单条 RSD 连接会被残留占住,下一次定位会失败。
|
||||||
import atexit
|
import atexit
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
atexit.register(app.shutdown)
|
atexit.register(app.shutdown)
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
for sig in plat.termination_signals():
|
||||||
try:
|
try:
|
||||||
signal.signal(sig, lambda *_a: app.shutdown_event.set())
|
signal.signal(sig, lambda *_a: app.shutdown_event.set())
|
||||||
except (ValueError, OSError, AttributeError):
|
except (ValueError, OSError, AttributeError):
|
||||||
pass
|
pass
|
||||||
if mock:
|
if mock:
|
||||||
app.log("warn", "当前为模拟模式:定位不会真正下发到设备")
|
app.log("warn", "当前为模拟模式:定位不会真正下发到设备")
|
||||||
|
|
||||||
app.devices.refresh()
|
app.devices.refresh()
|
||||||
app.devices.start_monitor()
|
app.devices.start_monitor()
|
||||||
last_device = app.config.get("last_device")
|
last_device = app.config.get("last_device")
|
||||||
@@ -246,6 +433,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
for device in devices:
|
for device in devices:
|
||||||
app.log("info", f"发现设备:{device.name}(iOS {device.ios_version or '未知'},{device.udid})")
|
app.log("info", f"发现设备:{device.name}(iOS {device.ios_version or '未知'},{device.udid})")
|
||||||
elif not mock:
|
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 设备:请用数据线连接、解锁并在手机上点「信任」")
|
app.log("warn", "暂未检测到 iOS 设备:请用数据线连接、解锁并在手机上点「信任」")
|
||||||
|
|
||||||
httpd = server.serve(app, host=host, port=args.port)
|
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 |
@@ -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
@@ -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
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import statistics
|
import statistics
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -81,7 +82,7 @@ def make_engine(config: config_store.ConfigStore):
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
print("\033[1m真实感模拟验证(GPS 漂移 + 速度浮动)\033[0m")
|
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({
|
cfg.update({
|
||||||
"drift_enabled": True, "drift_m": 5.0,
|
"drift_enabled": True, "drift_m": 5.0,
|
||||||
"speed_jitter_enabled": True, "speed_jitter": 0.10,
|
"speed_jitter_enabled": True, "speed_jitter": 0.10,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -37,7 +38,7 @@ def check(name: str, ok: bool, detail: str = "") -> None:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
print("\033[1m瓦片本地缓存 / 离线可用验证\033[0m")
|
print("\033[1m瓦片本地缓存 / 离线可用验证\033[0m")
|
||||||
cache_dir = Path("/tmp/iosloc-offline-tiles")
|
cache_dir = Path(tempfile.gettempdir()) / "iosloc-offline-tiles"
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
shutil.rmtree(cache_dir, ignore_errors=True)
|
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in new issue
Block a user