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
+4515
-306
No files matched your search
@@ -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)"}
|
||||
+91
-12
@@ -39,6 +39,8 @@ import logging
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from . import platform as plat
|
||||
|
||||
__all__ = [
|
||||
"RealLocationProvider",
|
||||
"LocationProviderError",
|
||||
@@ -48,22 +50,37 @@ __all__ = [
|
||||
|
||||
logger = logging.getLogger("iosloc.location.real")
|
||||
|
||||
#: 保活重发间隔(秒)。参考同类工具经验:<3s 没有额外收益,还容易在隧道里堆积消息。
|
||||
KEEPALIVE_INTERVAL = 15.0
|
||||
|
||||
#: 当前平台的解释器路径 / 启动脚本(错误文案里直接给用户可粘贴的命令)
|
||||
_PY = ".venv\\Scripts\\python.exe" if plat.IS_WINDOWS else "./.venv/bin/python"
|
||||
_RUN = ".\\scripts\\run.ps1" if plat.IS_WINDOWS else "./run.sh"
|
||||
|
||||
#: 未安装 pymobiledevice3 时的中文修复建议(含本机实测可用的阿里云镜像)
|
||||
PMD3_MISSING_HINT = (
|
||||
"未检测到 pymobiledevice3 库。请在项目目录执行:"
|
||||
"./.venv/bin/python -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3"
|
||||
f"{_PY} -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3"
|
||||
)
|
||||
|
||||
#: iOS 17 是「必须走 RSD 隧道」的分界线
|
||||
RSD_REQUIRED_IOS_MAJOR = 17
|
||||
|
||||
#: 根目录 README/run.sh 的隧道提示(PROTOCOL.md 的 tunnel_hint 语义)
|
||||
TUNNEL_HINT = (
|
||||
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
|
||||
"请以管理员权限运行 run.sh(sudo ./run.sh);"
|
||||
"macOS 上也可用免 root 的隧道:"
|
||||
"./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode"
|
||||
)
|
||||
#: 隧道提示(PROTOCOL.md 的 tunnel_hint 语义)。两个平台的建立方式不同,文案必须分开:
|
||||
#: macOS 借 Apple remoted(--native,免 root);Windows 用 userspace 隧道(免管理员)。
|
||||
if plat.IS_WINDOWS:
|
||||
TUNNEL_HINT = (
|
||||
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位。"
|
||||
"本程序会自动建立 userspace 隧道(不需要管理员权限);若失败请重新运行 "
|
||||
f"{_RUN} 并查看日志。也请确认已关闭爱思助手等会抢占 RSD 连接的工具。"
|
||||
)
|
||||
else:
|
||||
TUNNEL_HINT = (
|
||||
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
|
||||
"请以管理员权限运行 run.sh(sudo ./run.sh);"
|
||||
"macOS 上也可用免 root 的隧道:"
|
||||
"./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode"
|
||||
)
|
||||
|
||||
|
||||
class LocationProviderError(RuntimeError):
|
||||
@@ -174,14 +191,14 @@ def _wrap(action: str, exc: BaseException) -> LocationProviderError:
|
||||
elif "developerdiskimage" in low or "developer disk image" in low or "mounter mount" in low:
|
||||
detail = (
|
||||
"设备未挂载开发者镜像(DeveloperDiskImage),DVT 服务不可用。"
|
||||
"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount"
|
||||
f"请执行:{_PY} -m pymobiledevice3 mounter auto-mount"
|
||||
)
|
||||
elif "developer mode" in low or "developermode" in low:
|
||||
detail = "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(需重启设备)"
|
||||
elif "invalidservice" in low or "no such service" in low or "startserviceerror" in low:
|
||||
detail = (
|
||||
"设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,请执行:"
|
||||
"./.venv/bin/python -m pymobiledevice3 mounter auto-mount;"
|
||||
f"{_PY} -m pymobiledevice3 mounter auto-mount;"
|
||||
"iOS 17+ 则需要 RSD 隧道(隧道提示见下)"
|
||||
)
|
||||
elif "not paired" in low or "notpaired" in low or "pair" in low and "invalid" in low:
|
||||
@@ -249,6 +266,10 @@ class RealLocationProvider:
|
||||
self._session_info: dict = {}
|
||||
self._last_location: Optional[tuple] = None
|
||||
self._send_count = 0
|
||||
#: 保活:最后一次成功下发的单调时间 + 后台任务
|
||||
self._last_send_mono = 0.0
|
||||
self._keepalive_task: Optional[asyncio.Task] = None
|
||||
self._keepalive_count = 0
|
||||
self._connected_at: Optional[float] = None
|
||||
|
||||
# pymobiledevice3 对象
|
||||
@@ -325,8 +346,8 @@ class RealLocationProvider:
|
||||
|
||||
if state in ("unpaired",):
|
||||
raise LocationProviderError(
|
||||
"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
|
||||
"./.venv/bin/python -m pymobiledevice3 lockdown pair)"
|
||||
f"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
|
||||
f"{_PY} -m pymobiledevice3 lockdown pair)"
|
||||
)
|
||||
if state in ("unavailable",):
|
||||
raise LocationProviderError("设备当前不可用:请重新插拔 USB 线并解锁 iPhone 后重试")
|
||||
@@ -353,6 +374,8 @@ class RealLocationProvider:
|
||||
|
||||
self._connected = True
|
||||
self._connected_at = time.time()
|
||||
# 会话建立后启动保活(见 _keepalive_loop 的说明)
|
||||
self._start_keepalive()
|
||||
self._session_info = {
|
||||
"ok": True,
|
||||
"mock": False,
|
||||
@@ -534,9 +557,62 @@ class RealLocationProvider:
|
||||
|
||||
self._last_location = (lat, lon)
|
||||
self._send_count += 1
|
||||
self._last_send_mono = time.monotonic()
|
||||
if self._send_count == 1 or self._send_count % 25 == 0:
|
||||
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
|
||||
|
||||
# ------------------------------------------------------------------ 保活
|
||||
async def _keepalive_loop(self) -> None:
|
||||
"""定期重发最后一个点,防止"隧道静默断开后设备偷偷恢复真实定位"。
|
||||
|
||||
为什么需要:Windows 的 userspace 隧道是纯 Python 用户态协议栈,实测会**静默断开**
|
||||
—— 断开后设备自己恢复真实定位,而界面看起来一切正常(同类工具
|
||||
fake-location-cli 也独立踩到了这个坑,其 flc_set.py 里明确记录了这一点)。
|
||||
播放时每秒都在下发,不会触发;主要是「设了点但暂停/静止」时的保险。
|
||||
"""
|
||||
try:
|
||||
while self._connected:
|
||||
await asyncio.sleep(KEEPALIVE_INTERVAL)
|
||||
target = self._last_location
|
||||
if target is None or not self._connected:
|
||||
continue
|
||||
# 播放引擎刚发过就不重复发,避免和播放线程抢通道
|
||||
if time.monotonic() - self._last_send_mono < KEEPALIVE_INTERVAL * 0.8:
|
||||
continue
|
||||
try:
|
||||
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
|
||||
await self._location.set(target[0], target[1])
|
||||
elif self._strategy == "legacy":
|
||||
await self._legacy.set(target[0], target[1])
|
||||
else:
|
||||
continue
|
||||
self._last_send_mono = time.monotonic()
|
||||
self._keepalive_count += 1
|
||||
self._log("debug",
|
||||
f"保活重发 lat={target[0]:.6f}, lon={target[1]:.6f}"
|
||||
f"(第 {self._keepalive_count} 次)")
|
||||
except Exception as exc:
|
||||
# 不抛出去:保活失败不应打断播放,但要留下痕迹供排查
|
||||
self._log("warning", f"保活重发失败(隧道可能已断开):{exc}")
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
def _start_keepalive(self) -> None:
|
||||
if self._keepalive_task is not None and not self._keepalive_task.done():
|
||||
return
|
||||
try:
|
||||
self._keepalive_task = asyncio.get_running_loop().create_task(
|
||||
self._keepalive_loop(), name="location-keepalive"
|
||||
)
|
||||
except RuntimeError:
|
||||
self._keepalive_task = None
|
||||
|
||||
def _stop_keepalive(self) -> None:
|
||||
task = self._keepalive_task
|
||||
self._keepalive_task = None
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
|
||||
async def clear_location(self) -> None:
|
||||
"""让设备恢复真实定位(``play.stop`` / ``location.clear`` 语义)。"""
|
||||
if not self._connected:
|
||||
@@ -550,10 +626,13 @@ class RealLocationProvider:
|
||||
except BaseException as exc:
|
||||
raise _wrap("清除虚拟定位", exc) from exc
|
||||
self._last_location = None
|
||||
self._last_send_mono = 0.0
|
||||
self._stop_keepalive()
|
||||
self._log("info", "已清除虚拟定位,设备恢复真实定位")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放会话:先 clear(恢复真实定位)再关闭 DVT / RSD / lockdown。幂等。"""
|
||||
self._stop_keepalive()
|
||||
if not self._connected and self._dvt is None and self._rsd_obj is None and self._lockdown_obj is None:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -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"
|
||||
+41
-4
@@ -85,8 +85,12 @@ class AsyncRunner:
|
||||
class App:
|
||||
"""应用装配:配置、设备、隧道、定位会话、路由、播放、瓦片、日志。"""
|
||||
|
||||
def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT) -> None:
|
||||
def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT,
|
||||
resources: Path | str | None = None) -> None:
|
||||
# root = 可写数据目录(打包后是 %LOCALAPPDATA%\\iOSLocationTrack)
|
||||
# resources = 只读资源目录(打包后是 PyInstaller 解包目录),默认与 root 相同
|
||||
self.root = Path(root)
|
||||
self.resources = Path(resources) if resources is not None else self.root
|
||||
self.mock = mock
|
||||
self.port = port
|
||||
self.host = protocol.DEFAULT_HOST
|
||||
@@ -100,13 +104,18 @@ class App:
|
||||
pass
|
||||
self.runner = AsyncRunner()
|
||||
self.hub = ws.Hub()
|
||||
self.tunnel = tunnel_mod.TunnelManager(log=self.log, on_change=self._on_tunnel_change)
|
||||
# Windows 的 userspace 隧道必须建在应用自己的事件循环上(地址仅本进程可达),
|
||||
# 因此把 runner 注入 TunnelManager;macOS 只用它做子进程管理,传了也无害。
|
||||
self.tunnel = tunnel_mod.TunnelManager(
|
||||
log=self.log, on_change=self._on_tunnel_change, runner=self.runner
|
||||
)
|
||||
self.devices = device_mod.DeviceManager(mock=mock, on_change=self._on_devices_change)
|
||||
self.routes = route_engine.RouteEngine(self.config)
|
||||
self.tiles = tilecache.TileCache(
|
||||
self.config.cache_dir(), int(self.config.get("tile_cache_max_mb") or 1024)
|
||||
)
|
||||
self.logs: deque[dict[str, Any]] = deque(maxlen=500)
|
||||
self._log_file = self._open_log_file()
|
||||
self._lock = threading.RLock()
|
||||
self.provider: Any = None
|
||||
self.provider_udid: str | None = None
|
||||
@@ -126,10 +135,27 @@ class App:
|
||||
self._mount_running = False
|
||||
|
||||
# ---------------- 日志 ----------------
|
||||
def _open_log_file(self):
|
||||
"""打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。"""
|
||||
try:
|
||||
directory = self.root / "logs"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
handle = open(directory / "app.log", "a", encoding="utf-8")
|
||||
return handle
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def log(self, level: str, message: str) -> None:
|
||||
entry = {"ts": protocol.now_ts(), "level": level, "msg": str(message)}
|
||||
self.logs.append(entry)
|
||||
self.hub.broadcast({"type": "log", **entry})
|
||||
handle = getattr(self, "_log_file", None)
|
||||
if handle is not None:
|
||||
try:
|
||||
handle.write(f"{entry['ts']}\t{level}\t{message}\n")
|
||||
handle.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def recent_logs(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||
try:
|
||||
@@ -218,7 +244,13 @@ class App:
|
||||
# 而且如果同时插着另一台可用的设备,要直接告诉用户换哪台。
|
||||
self._preflight(device)
|
||||
|
||||
# RSD 有两种形态,必须区分(这是 Windows 与 macOS 最大的架构差异):
|
||||
# - macOS native 隧道:地址内核可路由 → 传 (host, port) 让 provider 自己连
|
||||
# - Windows userspace 隧道:地址只在**本进程的用户态协议栈**里可达,
|
||||
# 直接 TCP 连接会得到 OSError [WinError 1231] 网络位置不可达 →
|
||||
# 必须把隧道返回的那个已连接 RSD 对象本身注入给 provider
|
||||
rsd = None
|
||||
rsd_obj = None
|
||||
if (device.ios_major or 0) >= 17:
|
||||
if not self.tunnel.ready:
|
||||
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
|
||||
@@ -228,8 +260,11 @@ class App:
|
||||
"tunnel_required",
|
||||
status=409,
|
||||
)
|
||||
rsd = self.tunnel.address()
|
||||
rsd_obj = self.tunnel.rsd_object()
|
||||
if rsd_obj is None:
|
||||
rsd = self.tunnel.address()
|
||||
provider = RealLocationProvider(
|
||||
rsd=rsd_obj,
|
||||
rsd_host=rsd[0] if rsd else None,
|
||||
rsd_port=rsd[1] if rsd else None,
|
||||
udid=device.udid,
|
||||
@@ -264,7 +299,7 @@ class App:
|
||||
hint = f";建议改用另一台设备:{best.name}(iOS {best.ios_version},{best.connection.upper()})"
|
||||
raise protocol.AppError(
|
||||
f"{device.name}(iOS {device.ios_version})尚未挂载开发者镜像,无法做虚拟定位。"
|
||||
f"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid {device.udid}{hint}",
|
||||
f"请执行:{tunnel_mod.mounter_hint(device.udid)}{hint}",
|
||||
"developer_image_missing",
|
||||
status=409,
|
||||
)
|
||||
@@ -779,6 +814,8 @@ class App:
|
||||
base = getattr(sys, "_MEIPASS", None)
|
||||
if base:
|
||||
sources.append(Path(base) / "resources" / "ddi" / "personalized")
|
||||
# 打包后 root 是可写数据目录,素材在 resources(只读包内目录)
|
||||
sources.append(self.resources / "resources" / "ddi" / "personalized")
|
||||
sources.append(self.root / "resources" / "ddi" / "personalized")
|
||||
sources.append(Path.home() / ".ios-location-ddi" / "personalized")
|
||||
|
||||
|
||||
@@ -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
|
||||
+239
-111
@@ -1,54 +1,96 @@
|
||||
"""iOS 17+ 的 RSD 隧道管理。
|
||||
"""iOS 17+ 的 RSD 隧道管理(macOS / Windows 双平台)。
|
||||
|
||||
背景(见 docs/DEVICE_NOTES.md 的取证结论):
|
||||
- iOS 17 及以上,DVT 服务不再走 usbmux 代理,必须先在 Mac 与设备之间建立 RSD 隧道。
|
||||
- 经典隧道(kernel utun)需要 root;`pymobiledevice3 remote start-tunnel` 在 macOS 上支持
|
||||
`--native`:借用 Apple 自带 remoted 的隧道,**不需要 root**,并把内核可路由的 RSD 地址打印出来
|
||||
(--script-mode 输出 "HOST PORT"),因此可以被其它进程直接使用。
|
||||
- 该 native 隧道与 remoted 共用设备的单条 RSD 连接,会互相挤掉,所以同一时间只应有一个客户端
|
||||
(即:本工具运行时应关闭爱思助手等同类工具)。
|
||||
- 如果 native 不可用,退化为 `osascript` 弹窗向用户索取管理员密码,再跑经典隧道。
|
||||
背景
|
||||
----
|
||||
iOS 17 起 DVT 服务不再走 usbmux,必须先在主机与设备之间建立 RSD 隧道。
|
||||
|
||||
两个平台的建立方式**本质不同**,这是本模块最需要注意的地方:
|
||||
|
||||
macOS
|
||||
用 ``remote start-tunnel --native``:借 Apple 自带 ``remoted`` 的隧道
|
||||
(不建内核 utun,因而不需要 root),打印出**内核可路由**的 ``HOST PORT``,
|
||||
主进程用普通 TCP 连上去即可 → 走"子进程 + 解析 stdout"的老路。
|
||||
|
||||
Windows
|
||||
没有 XPC/Objective-C,native 隧道不存在。改用 ``UserspaceRsdTunnel``:
|
||||
用纯 Python 的 PyTCP 用户态协议栈实现 iOS 17+ 隧道,**不需要管理员权限、
|
||||
不需要 Wintun 驱动**。
|
||||
|
||||
⚠️ 关键差异:用户态隧道的设备地址**只在本进程的协议栈里可达**
|
||||
(``rsd.is_in_process_tunnel is True``)。用普通 socket 去连 ``(host, port)``
|
||||
会直接失败(Windows 上表现为 ``OSError [WinError 1231] 网络位置不可达``)。
|
||||
pymobiledevice3 的做法是把一个 relay dialer 注入它返回的
|
||||
``RemoteServiceDiscoveryService`` 对象(``open_connection=dial_plane.dial``),
|
||||
所以**必须把这个 rsd 对象本身交给 DVT 层**(``RealLocationProvider(rsd=...)``),
|
||||
而不是把 ``(host, port)`` 传出去另建连接。
|
||||
|
||||
这也意味着 Windows 上 **不能**沿用 macOS 的"隧道助手子进程"架构:
|
||||
子进程的协议栈不能给父进程用。隧道必须在应用自己的事件循环里建立并持有。
|
||||
|
||||
实测(iPhone 11 / iOS 26.6.1,Windows 11 x64):
|
||||
隧道建立耗时约 0.3 秒,无需管理员权限,无需安装 Wintun 驱动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from . import platform as plat
|
||||
from . import protocol
|
||||
|
||||
POLL_INTERVAL = 0.25
|
||||
START_TIMEOUT = 75.0
|
||||
_HINT_NATIVE_FAILED = (
|
||||
|
||||
#: Windows 上不需要提权;macOS 的 sudo 兜底路径保留
|
||||
_HINT_MAC_FAILED = (
|
||||
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
|
||||
"② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);"
|
||||
"④ 若仍失败,可尝试管理员模式:在终端执行 sudo ./run.sh"
|
||||
)
|
||||
_HINT_NATIVE_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动"
|
||||
_HINT_MAC_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动"
|
||||
|
||||
_HINT_WIN_FAILED = (
|
||||
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
|
||||
"② 已在手机上开启「开发者模式」并点过「信任此电脑」;"
|
||||
"③ 关闭爱思助手 / i4Tools 等同类工具(同一个 RSD 连接会被互相挤掉);"
|
||||
"④ 若提示缺少 Apple Mobile Device Support,请运行「修复驱动」。"
|
||||
)
|
||||
_HINT_WIN_FAILED_SHORT = "隧道建立失败:请确认设备已解锁并信任本电脑,且已关闭同类工具"
|
||||
|
||||
_HINT_NATIVE_FAILED = _HINT_WIN_FAILED if plat.IS_WINDOWS else _HINT_MAC_FAILED
|
||||
_HINT_NATIVE_FAILED_SHORT = (
|
||||
_HINT_WIN_FAILED_SHORT if plat.IS_WINDOWS else _HINT_MAC_FAILED_SHORT
|
||||
)
|
||||
|
||||
|
||||
class TunnelManager:
|
||||
"""建立/持有/释放 RSD 隧道,并对外暴露 (host, port)。"""
|
||||
"""建立/持有/释放 RSD 隧道,并对外暴露 (host, port) 与(Windows)rsd 对象。"""
|
||||
|
||||
def __init__(self, venv_python: str | None = None, log=None, on_change=None) -> None:
|
||||
def __init__(self, venv_python: str | None = None, log=None, on_change=None,
|
||||
runner=None) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.state = "idle" # idle|starting|connected|error|unsupported
|
||||
self.message = ""
|
||||
self.host: str | None = None
|
||||
self.port: int | None = None
|
||||
self.udid: str | None = None
|
||||
self.mode: str | None = None # native|sudo|manual
|
||||
self.mode: str | None = None # native|userspace|manual|sudo
|
||||
self._process: subprocess.Popen[str] | None = None
|
||||
self._reader: threading.Thread | None = None
|
||||
self._needs_root = False
|
||||
self._log = log
|
||||
self._on_change = on_change
|
||||
self.venv_python = venv_python or _detect_python()
|
||||
#: Windows:应用的事件循环运行器(core/server.py:AsyncRunner)。
|
||||
#: 用户态隧道必须建在这个循环上,并被本对象一直持有。
|
||||
self._runner = runner
|
||||
self._userspace_tunnel: Any = None
|
||||
self._rsd: Any = None
|
||||
self.venv_python = venv_python or plat.python_for_subprocess()
|
||||
|
||||
# ---------------- 状态 ----------------
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
@@ -59,8 +101,20 @@ class TunnelManager:
|
||||
"rsd": f"{self.host} {self.port}" if self.host and self.port else None,
|
||||
"mode": self.mode,
|
||||
"udid": self.udid,
|
||||
# 前端据此提示"隧道只在本进程内可用",不要尝试用外部工具连它
|
||||
"in_process": bool(self._rsd is not None
|
||||
and getattr(self._rsd, "is_in_process_tunnel", False)),
|
||||
"platform": plat.platform_label(),
|
||||
}
|
||||
|
||||
def rsd_object(self) -> Any:
|
||||
"""返回**已连接**的 RSD 对象(Windows 用户态隧道必需)。
|
||||
|
||||
DVT 层必须复用它:它的拨号器绑定在本进程的用户态协议栈上。
|
||||
"""
|
||||
with self._lock:
|
||||
return self._rsd
|
||||
|
||||
def _emit(self, level: str, message: str) -> None:
|
||||
if self._log:
|
||||
try:
|
||||
@@ -101,19 +155,84 @@ class TunnelManager:
|
||||
return self.start(udid=udid, allow_sudo=allow_sudo)
|
||||
|
||||
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict[str, Any]:
|
||||
self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None,
|
||||
host=None, port=None)
|
||||
if plat.IS_WINDOWS:
|
||||
return self._start_userspace(udid)
|
||||
return self._start_subprocess(udid, allow_sudo)
|
||||
|
||||
# ---------------- Windows:进程内用户态隧道 ----------------
|
||||
def _start_userspace(self, udid: str | None) -> dict[str, Any]:
|
||||
"""Windows:在应用事件循环上建立 userspace 隧道,并持有 rsd 对象。"""
|
||||
if self._runner is None:
|
||||
self._set_state(
|
||||
"error",
|
||||
"内部错误:Windows 用户态隧道需要事件循环运行器(runner),但未注入",
|
||||
mode="userspace",
|
||||
)
|
||||
return self.snapshot()
|
||||
try:
|
||||
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel
|
||||
except Exception as exc:
|
||||
self._set_state("unsupported", f"无法加载 userspace 隧道模块:{exc}",
|
||||
mode="userspace")
|
||||
return self.snapshot()
|
||||
|
||||
self._emit("info", "正在建立 userspace RSD 隧道(进程内 PyTCP 协议栈,无需管理员权限)…")
|
||||
|
||||
async def _open() -> tuple[Any, Any]:
|
||||
tunnel = UserspaceRsdTunnel(serial=udid, autopair=True,
|
||||
remotepairing_fallback=True)
|
||||
rsd = await tunnel.aopen()
|
||||
return tunnel, rsd
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
tunnel, rsd = self._runner.run(_open(), timeout=START_TIMEOUT)
|
||||
except Exception as exc:
|
||||
message = f"隧道建立失败:{exc}"
|
||||
if "already active" in str(exc):
|
||||
message = ("本进程已有一条 userspace 隧道(PyTCP 协议栈是进程内全局单例),"
|
||||
"请先断开后再重试")
|
||||
self._set_state("error", message, mode="userspace")
|
||||
self._emit("error", f"[tunnel] {message}")
|
||||
return self.snapshot()
|
||||
|
||||
address = tuple(getattr(rsd.service, "address", ()) or ())
|
||||
if len(address) < 2:
|
||||
try:
|
||||
self._runner.run(_close_userspace(tunnel), timeout=20)
|
||||
except Exception:
|
||||
pass
|
||||
self._set_state("error", "隧道已建立但未拿到 RSD 地址", mode="userspace")
|
||||
return self.snapshot()
|
||||
|
||||
host, port = str(address[0]), int(address[1])
|
||||
in_process = bool(getattr(rsd, "is_in_process_tunnel", False))
|
||||
with self._lock:
|
||||
self._userspace_tunnel = tunnel
|
||||
self._rsd = rsd
|
||||
elapsed = time.monotonic() - started
|
||||
detail = (f"隧道已建立(userspace,{elapsed:.1f}s):{host} 端口 {port}"
|
||||
+ ("(进程内,仅供本程序使用)" if in_process else ""))
|
||||
self._emit("info", f"[tunnel] {detail}")
|
||||
self._set_state("connected", detail, host=host, port=port, mode="userspace")
|
||||
return self.snapshot()
|
||||
|
||||
# ---------------- macOS:子进程 + 解析 stdout ----------------
|
||||
def _start_subprocess(self, udid: str | None, allow_sudo: bool) -> dict[str, Any]:
|
||||
# 设备在 iOS 17+ 上只有一条 RSD 连接:残留的旧隧道进程会与新的互相挤掉,
|
||||
# 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。
|
||||
if not allow_sudo:
|
||||
self._kill_stale_tunnels()
|
||||
self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None, host=None, port=None)
|
||||
command, mode = self._build_command(udid=udid, allow_sudo=allow_sudo)
|
||||
if command is None:
|
||||
self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None)
|
||||
return self.snapshot()
|
||||
|
||||
env = dict(os.environ)
|
||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||
env.setdefault("PYTHONPATH", str(_project_root()))
|
||||
env.setdefault("PYTHONUNBUFFERED", "1") # 不加的话 stdout 是块缓冲,HOST PORT 读不到
|
||||
env.setdefault("PYTHONPATH", str(plat.resolve_roots()[0]))
|
||||
self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
@@ -124,7 +243,7 @@ class TunnelManager:
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
**plat.spawn_detached_kwargs(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode)
|
||||
@@ -136,7 +255,8 @@ class TunnelManager:
|
||||
self._needs_root = mode == "sudo"
|
||||
if mode != "sudo":
|
||||
_write_pidfile([process.pid])
|
||||
self._reader = threading.Thread(target=self._read_output, args=(process,), name="tunnel-reader", daemon=True)
|
||||
self._reader = threading.Thread(target=self._read_output, args=(process,),
|
||||
name="tunnel-reader", daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
deadline = time.time() + START_TIMEOUT
|
||||
@@ -153,18 +273,15 @@ class TunnelManager:
|
||||
if not self.ready:
|
||||
message = _HINT_NATIVE_FAILED_SHORT
|
||||
if mode == "sudo":
|
||||
message = "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错"
|
||||
message = ("管理员隧道启动失败:请查看具体报错后重试"
|
||||
if plat.IS_WINDOWS else
|
||||
"管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错")
|
||||
self.stop()
|
||||
self._set_state("error", message, mode=mode)
|
||||
return self.snapshot()
|
||||
|
||||
def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]:
|
||||
"""拼出"建隧道"命令。
|
||||
|
||||
- 打包成 .app(sys.frozen):`<本可执行文件> tunnel-helper --udid X`
|
||||
—— 这条路径不依赖机器上有 Python/pymobiledevice3;
|
||||
- 源码运行:`python -m pymobiledevice3 remote start-tunnel --native --script-mode`。
|
||||
"""
|
||||
"""拼出"建隧道"命令(**仅 macOS 使用**;Windows 走进程内 API)。"""
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
@@ -179,65 +296,51 @@ class TunnelManager:
|
||||
command += ["--udid", udid]
|
||||
if not allow_sudo:
|
||||
return command, "native"
|
||||
inner = " ".join(_shell_quote(part) for part in command)
|
||||
script = f'do shell script "{inner} > /tmp/iosloc-tunnel.log 2>&1 &" with administrator privileges'
|
||||
return ["osascript", "-e", script], "sudo"
|
||||
if plat.IS_MACOS:
|
||||
inner = " ".join(_shell_quote(part) for part in command)
|
||||
script = (f'do shell script "{inner} > /tmp/iosloc-tunnel.log 2>&1 &" '
|
||||
f"with administrator privileges")
|
||||
return ["osascript", "-e", script], "sudo"
|
||||
# Windows 的 userspace 隧道不需要提权;走到这里说明上层误用了 sudo 路径
|
||||
return command, "native"
|
||||
|
||||
def _kill_stale_tunnels(self) -> None:
|
||||
"""清理**本工具自己**遗留在后台的 start-tunnel 进程。
|
||||
|
||||
iOS 17+ 设备只有一条 RSD 连接:残留进程会与新建的隧道互相挤掉。
|
||||
为了避免误杀用户在终端手动起的隧道或其它工具,这里只处理本项目上次运行时
|
||||
记录在 pid 文件里的进程(会先用 ps 校验命令行确实是 start-tunnel)。
|
||||
为避免误杀用户手动起的隧道或其它工具,只处理本项目记录在 pid 文件里的进程,
|
||||
并且会先校验命令行确实属于本工具。
|
||||
"""
|
||||
pids = _read_pidfile()
|
||||
if not pids:
|
||||
return
|
||||
alive: list[int] = []
|
||||
for pid in pids:
|
||||
command = _command_of(pid)
|
||||
if command and "start-tunnel" in command:
|
||||
if not plat.pid_alive(pid):
|
||||
continue
|
||||
command = plat.process_cmdline(pid) or ""
|
||||
if _looks_like_our_tunnel(command):
|
||||
alive.append(pid)
|
||||
_write_pidfile([])
|
||||
if not alive:
|
||||
return
|
||||
self._emit("warn", f"发现 {len(alive)} 个上次遗留的隧道进程,正在清理…")
|
||||
for pid in alive:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
for _ in range(10):
|
||||
time.sleep(0.3)
|
||||
if _command_of(pid) is None:
|
||||
break
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
plat.terminate_process(pid, force=False, timeout=3.0)
|
||||
|
||||
def _cli_command(self) -> list[str] | None:
|
||||
"""返回"建隧道"命令的前缀。
|
||||
|
||||
- 打包成 .app 独立运行时(sys.frozen):用本可执行文件 + `tunnel-helper` 子命令
|
||||
(递归调用自己),因此**不需要机器上装 Python 或 pymobiledevice3**;
|
||||
- 源码运行时:优先 venv 里的 `python -m pymobiledevice3`,其次 PATH 里的可执行文件。
|
||||
"""
|
||||
"""返回"建隧道"命令的前缀(源码运行时的 macOS 路径)。"""
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, "tunnel-helper"]
|
||||
candidate = _detect_python()
|
||||
candidate = plat.python_for_subprocess()
|
||||
if candidate:
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[candidate, "-c", "import pymobiledevice3, sys; print(pymobiledevice3.__version__ if hasattr(pymobiledevice3,'__version__') else 'ok')"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=25,
|
||||
[candidate, "-c",
|
||||
"import pymobiledevice3; print('ok')"],
|
||||
capture_output=True, text=True, timeout=25,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
return [candidate, "-m", "pymobiledevice3"]
|
||||
@@ -271,7 +374,8 @@ class TunnelManager:
|
||||
with self._lock:
|
||||
code = process.poll()
|
||||
if code is not None and not self.ready:
|
||||
self._set_state("error", f"隧道进程已退出(代码 {code}):{_HINT_NATIVE_FAILED_SHORT}")
|
||||
self._set_state("error",
|
||||
f"隧道进程已退出(代码 {code}):{_HINT_NATIVE_FAILED_SHORT}")
|
||||
|
||||
# ---------------- 手动 / 释放 ----------------
|
||||
def use_manual(self, host: str, port: int, udid: str | None = None) -> dict[str, Any]:
|
||||
@@ -283,28 +387,32 @@ class TunnelManager:
|
||||
if not host:
|
||||
raise protocol.AppError("RSD 地址不能为空", "bad_rsd")
|
||||
self.stop()
|
||||
self._set_state("connected", f"已使用手动指定的 RSD:{host} 端口 {iport}", host=host, port=iport, mode="manual", udid=udid)
|
||||
self._set_state("connected", f"已使用手动指定的 RSD:{host} 端口 {iport}",
|
||||
host=host, port=iport, mode="manual", udid=udid)
|
||||
return self.snapshot()
|
||||
|
||||
def stop(self) -> None:
|
||||
# ---- Windows:释放进程内用户态隧道 ----
|
||||
with self._lock:
|
||||
tunnel = self._userspace_tunnel
|
||||
self._userspace_tunnel = None
|
||||
self._rsd = None
|
||||
if tunnel is not None:
|
||||
try:
|
||||
if self._runner is not None:
|
||||
self._runner.run(_close_userspace(tunnel), timeout=25)
|
||||
else: # pragma: no cover
|
||||
tunnel.close()
|
||||
except Exception as exc:
|
||||
self._emit("warn", f"释放用户态隧道时出错(可忽略):{exc}")
|
||||
|
||||
# ---- macOS:结束子进程 ----
|
||||
with self._lock:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=4)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
plat.terminate_process_tree(process)
|
||||
|
||||
with self._lock:
|
||||
self.host = None
|
||||
self.port = None
|
||||
@@ -315,6 +423,37 @@ class TunnelManager:
|
||||
_write_pidfile([])
|
||||
|
||||
|
||||
async def _close_userspace(tunnel: Any) -> None:
|
||||
"""释放 userspace 隧道。
|
||||
|
||||
注意:``UserspaceRsdTunnel.close()`` 是同步壳,内部会 ``aclose()`` 但**不 await**,
|
||||
直接调用会留下未 await 的协程并且隧道不会真正释放 —— 必须 await ``aclose()``。
|
||||
"""
|
||||
aclose = getattr(tunnel, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
return
|
||||
close = getattr(tunnel, "close", None)
|
||||
if close is not None:
|
||||
result = close()
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
|
||||
def _looks_like_our_tunnel(command: str) -> bool:
|
||||
"""判断某个进程是否"本工具建立的隧道进程",避免误杀。
|
||||
|
||||
macOS 子进程模式:``... -m pymobiledevice3 remote start-tunnel --native ...``
|
||||
打包后的自调用:``iOSLocationTrack.exe tunnel-helper ...``
|
||||
"""
|
||||
if not command:
|
||||
return False
|
||||
lowered = command.lower()
|
||||
if "tunnel-helper" in lowered:
|
||||
return True
|
||||
return "pymobiledevice3" in lowered and "start-tunnel" in lowered
|
||||
|
||||
|
||||
def _parse_rsd_line(text: str, pattern: re.Pattern[str]) -> tuple[str, int] | None:
|
||||
if text.startswith("--rsd "):
|
||||
parts = text.split()
|
||||
@@ -355,7 +494,7 @@ def _mask(command: list[str]) -> list[str]:
|
||||
|
||||
|
||||
def _pidfile_path() -> str:
|
||||
return os.path.join(_project_root(), ".tunnel.pids")
|
||||
return os.path.join(str(plat.data_dir()), ".tunnel.pids")
|
||||
|
||||
|
||||
def _read_pidfile() -> list[int]:
|
||||
@@ -379,18 +518,6 @@ def _write_pidfile(pids: list[int]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _command_of(pid: int) -> str | None:
|
||||
"""返回进程命令行;进程不存在返回 None。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
text = (result.stdout or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _shell_quote(text: str) -> str:
|
||||
return "'" + text.replace("'", "'\\''") + "'"
|
||||
|
||||
@@ -400,35 +527,36 @@ def _project_root() -> str:
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
return _writable_data_dir()
|
||||
return str(plat.data_dir())
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _writable_data_dir() -> str:
|
||||
"""打包运行时可写的数据目录(~/Library/Application Support/...)。"""
|
||||
base = os.path.expanduser("~/Library/Application Support/iOSLocationTrack")
|
||||
try:
|
||||
os.makedirs(base, exist_ok=True)
|
||||
except OSError:
|
||||
base = os.path.expanduser("~")
|
||||
return base
|
||||
return str(plat.data_dir())
|
||||
|
||||
|
||||
def _detect_python() -> str | None:
|
||||
"""返回项目 venv 的 python,找不到就退回当前解释器。"""
|
||||
import sys
|
||||
|
||||
root = _project_root()
|
||||
for candidate in (
|
||||
os.path.join(root, ".venv", "bin", "python"),
|
||||
os.path.join(root, ".venv", "bin", "python3"),
|
||||
):
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return sys.executable
|
||||
return plat.python_for_subprocess()
|
||||
|
||||
|
||||
def tunnel_supported() -> bool:
|
||||
import platform
|
||||
"""Windows(userspace)与 macOS(native)都支持自动建隧道。"""
|
||||
return plat.IS_WINDOWS or plat.IS_MACOS
|
||||
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
def mounter_hint(udid: str | None = None) -> str:
|
||||
"""给用户的"挂载开发者镜像"命令行提示(按平台给出可直接粘贴的命令)。"""
|
||||
tail = f" --udid {udid}" if udid else ""
|
||||
if plat.IS_WINDOWS:
|
||||
return (f".venv\\Scripts\\python.exe -m pymobiledevice3 mounter auto-mount{tail}")
|
||||
return f"./.venv/bin/python -m pymobiledevice3 mounter auto-mount{tail}"
|
||||
|
||||
|
||||
def python_hint() -> str:
|
||||
"""解释器路径提示(错误文案里用)。"""
|
||||
return ".venv\\Scripts\\python.exe" if plat.IS_WINDOWS else "./.venv/bin/python"
|
||||
|
||||
|
||||
def run_hint() -> str:
|
||||
"""启动脚本提示。"""
|
||||
return ".\\scripts\\run.ps1" if plat.IS_WINDOWS else "./run.sh"
|
||||
Reference in new issue
Block a user