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

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

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

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

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

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

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

No files matched your search

+822
View File
@@ -0,0 +1,822 @@
r"""Apple Mobile Device Support(Windows 上的 usbmuxd 等价物)检测与自动安装。
**为什么需要这个模块**
macOS 自带 usbmuxd;Windows 上 usbmuxd 由 Apple 的 "Apple Mobile Device Service"
提供(随 iTunes / Apple Mobile Device Support 安装)。它监听 **127.0.0.1:27015**,
pymobiledevice3 通过它发现 iPhone。没有它,`pymobiledevice3 usbmux list` 一台设备也看不到。
**关键事实(已在真机取证,见 docs/DEVICE_NOTES_WINDOWS.md)**
1. 服务名是带空格的 ``Apple Mobile Device Service``;``AppleMobileDeviceService`` 只是
exe 文件名,`sc query AppleMobileDeviceService` 会报 1060。
2. Windows 上 **没有** ``\\.\pipe\usbmuxd``,只有 TCP 27015。
3. ProductCode 不稳定(每个版本都变),**UpgradeCode 稳定**:
``{5CF0656E-F3B8-11DB-BA81-804E56D89593}``(x86/x64 通用)。
4. **AMDS 不提供 USB 驱动**。它的 MSI 里 0 个 .inf/.sys/.cat;现代机器上 iPhone 实际绑定
的是 ``appleusb.inf``(服务 WINUSB / usbccgp)。所以**不要**用 `usbaapl64` 判断驱动是否存在
—— 健康的机器上这个判断会误报"缺失"。
5. 判断"到底能不能用"的**唯一权威信号**是 27015 上的协议往返(``ReadBUID`` 不需要插设备)。
"""
from __future__ import annotations
import ctypes
import hashlib
import json
import os
import plistlib
import shutil
import socket
import struct
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from typing import Any, Callable
from . import platform as plat
#: 服务名(带空格!)
SERVICE_NAME = "Apple Mobile Device Service"
#: AMDS 的稳定标识(UpgradeCode,x86/x64 通用,跨版本不变)
AMDS_UPGRADE_CODE = "{5CF0656E-F3B8-11DB-BA81-804E56D89593}"
#: AMDS 安装包在注册表 Uninstall 下的显示名
AMDS_DISPLAY_NAME = "Apple Mobile Device Support"
USBMUX_HOST = "127.0.0.1"
USBMUX_PORT = 27015
# ---------------------------------------------------------------------------
# 官方下载地址(2026-10 实测可达)
# ---------------------------------------------------------------------------
ITUNES_WIN64_URL = "https://www.apple.com/itunes/download/win64"
ITUNES_WIN32_URL = "https://www.apple.com/itunes/download/win32"
#: 独立 AMDS MSI(约 38 MB,比整个 iTunes 包 193 MB 小得多)
AMDS_MSI_X64_URL = (
"https://swcdn.apple.com/content/downloads/20/49/047-76422/"
"qcw2a7028lr8yp4rdkrfyzkvlhi9q1gg2g/AppleMobileDeviceSupport64.msi"
)
AMDS_MSI_X64_SHA256 = "b60533fb54e7bd81ffc52d99678a9cce04e58cb54a76281bd7b1309f20d360e9"
AMDS_MSI_X86_URL = (
"https://swcdn.apple.com/content/downloads/54/12/047-76417/"
"rftfhqmqnu0afv25sdr2iosg38mxkqa388/AppleMobileDeviceSupport.msi"
)
AMDS_MSI_X86_SHA256 = "590e4b21d2aa522cfe789cc3fea95421b6141ddfa36e06525ae9715a5b5859e3"
#: Apple Application Support(AMDS 的前置依赖,安装顺序由 iTunes 包自己决定)
AAS_MSI_X64_URL = (
"https://swcdn.apple.com/content/downloads/63/47/001-39934/"
"955xl2euy0wb5j2z4oddbbd39ln1sthedn/AppleApplicationSupport64.msi"
)
#: msiexec / 安装程序的"成功"退出码
SUCCESS_CODES = {0, 3010, 1641, 1638}
CODE_MEANING = {
0: "成功",
3010: "成功(需要重启才能完全生效)",
1641: "成功(安装程序已自动重启)",
1638: "已安装该产品的其它版本(视为已就绪)",
1603: "安装失败(致命错误)",
1618: "另一个安装正在进行中,请稍后重试",
1619: "安装包无法打开(下载可能不完整)",
1620: "安装包无法打开(文件损坏)",
1260: "组策略禁止了本次安装",
5: "拒绝访问(需要管理员权限)",
}
_TIMEOUT_PROBE = 5.0
# ---------------------------------------------------------------------------
# 底层工具
# ---------------------------------------------------------------------------
def _no_window() -> dict[str, Any]:
if plat.IS_WINDOWS:
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)}
return {}
def _run(command: list[str], timeout: float = 60.0) -> tuple[int, str]:
"""执行外部命令,返回 (returncode, 合并输出)。失败不抛异常。"""
try:
result = subprocess.run(
command, capture_output=True, text=True, timeout=timeout, **_no_window()
)
output = ((result.stdout or "") + (result.stderr or "")).strip()
return result.returncode, output
except subprocess.TimeoutExpired:
return -1, "命令超时"
except FileNotFoundError:
return -2, f"找不到命令:{command[0]}"
except Exception as exc: # pragma: no cover
return -3, repr(exc)
def _decode(data: bytes) -> str:
"""Windows 中文控制台输出多为 GBK,尽量解出来。"""
for encoding in ("utf-8", "gbk", "mbcs"):
try:
return data.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return data.decode("utf-8", "replace")
# ---------------------------------------------------------------------------
# 检测
# ---------------------------------------------------------------------------
def service_state() -> dict[str, Any]:
"""查询 ``Apple Mobile Device Service`` 的状态。"""
if not plat.IS_WINDOWS:
return {"applicable": False, "exists": False, "running": False, "state": "n/a"}
code, output = _run(["sc", "query", SERVICE_NAME], timeout=15)
text = output.upper()
exists = code == 0 and "1060" not in output
running = "RUNNING" in text
state = "unknown"
for token in ("RUNNING", "STOPPED", "START_PENDING", "STOP_PENDING", "PAUSED"):
if token in text:
state = token
break
if not exists:
state = "not_installed"
return {
"applicable": True,
"exists": exists,
"running": running,
"state": state,
"raw": output.strip(),
}
def start_service() -> tuple[bool, str]:
"""尝试启动服务。已有的服务只是没启动,是"看不到设备"最常见的原因。"""
if not plat.IS_WINDOWS:
return False, "非 Windows 平台"
code, output = _run(["sc", "start", SERVICE_NAME], timeout=60)
if code == 0 or "1056" in output: # 1056 = 已在运行
return True, "服务已启动"
return False, output.strip() or f"sc start 返回 {code}"
def usbmux_probe(
message_type: str = "ReadBUID",
host: str = USBMUX_HOST,
port: int = USBMUX_PORT,
timeout: float = _TIMEOUT_PROBE,
) -> dict[str, Any]:
"""在 27015 上做一次 usbmuxd 协议往返 —— **判断可用性的唯一权威信号**。
``ReadBUID`` 不需要插设备,因此可以在"有没有手机"之外单独验证服务是否真的工作。
协议:16 字节小端头 ``<IIII`` = (length, version=1, message=8, tag),
然后是 XML plist;``length`` 含头。
"""
body = {
"MessageType": message_type,
"ProgName": "iOSLocationTrack",
"ClientVersionString": "iOSLocationTrack 1.0",
"kLibUSBMuxVersion": 3,
}
try:
payload = plistlib.dumps(body)
except Exception as exc: # pragma: no cover
return {"ok": False, "error": f"构造 plist 失败:{exc}"}
header = struct.pack("<IIII", 16 + len(payload), 1, 8, 1)
try:
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.sendall(header + payload)
raw = b""
while len(raw) < 4:
chunk = sock.recv(4 - len(raw))
if not chunk:
break
raw += chunk
if len(raw) < 4:
return {"ok": False, "error": "连接被对端关闭(无响应头)"}
total = struct.unpack("<I", raw[:4])[0]
if not (16 <= total <= 4 * 1024 * 1024):
return {"ok": False, "error": f"响应长度异常:{total}"}
while len(raw) < total:
chunk = sock.recv(total - len(raw))
if not chunk:
break
raw += chunk
except ConnectionRefusedError:
return {"ok": False, "error": f"{host}:{port} 拒绝连接(服务未运行或未安装)"}
except socket.timeout:
return {"ok": False, "error": f"{host}:{port} 连接超时"}
except OSError as exc:
return {"ok": False, "error": f"{host}:{port} 不可达:{exc}"}
try:
length, version, message, tag = struct.unpack("<IIII", raw[:16])
parsed = plistlib.loads(raw[16:total])
except Exception as exc:
return {"ok": False, "error": f"解析响应失败:{exc}"}
# 成功的两种形态:{"MessageType":"Result","Number":0} 或直接带数据(如 ListDevices)
if isinstance(parsed, dict):
number = parsed.get("Number")
if parsed.get("MessageType") == "Result" and number not in (0, None):
return {
"ok": False,
"error": f"usbmuxd 返回错误码 {number}",
"plist": parsed,
}
return {
"ok": True,
"plist": parsed,
"header": {"length": length, "version": version, "message": message, "tag": tag},
}
def list_devices() -> dict[str, Any]:
"""通过 usbmuxd 列出设备(USB + Wi-Fi)。"""
result = usbmux_probe("ListDevices")
if not result.get("ok"):
return result
devices = (result.get("plist") or {}).get("DeviceList") or []
out = []
for item in devices:
props = item.get("Properties") or {}
out.append(
{
"device_id": item.get("DeviceID"),
"connection": props.get("ConnectionType", "?"),
"product_id": props.get("ProductID"),
"serial": props.get("SerialNumber", ""),
}
)
return {"ok": True, "devices": out, "count": len(out)}
def _registry_views():
"""枚举 32/64 位两个注册表视图的 Uninstall 键(32 位 AMDS 落在 WOW6432Node)。"""
import winreg # type: ignore[import-not-found]
root = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
for sub in ("", r"\WOW6432Node"):
path = root + sub if sub else root
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_READ | view) as key:
count = winreg.QueryInfoKey(key)[0]
for index in range(count):
try:
name = winreg.EnumKey(key, index)
with winreg.OpenKey(key, name) as entry:
yield entry
except OSError:
continue
except OSError:
continue
def installed_products() -> list[dict[str, Any]]:
"""从注册表读出 Apple 相关产品的版本信息(仅用于展示/诊断)。"""
if not plat.IS_WINDOWS:
return []
wanted = ("apple mobile device support", "itunes", "apple application support",
"apple devices", "bonjour")
found: dict[str, dict[str, Any]] = {}
for entry in _registry_views():
try:
import winreg # type: ignore[import-not-found]
display = str(winreg.QueryValueEx(entry, "DisplayName")[0])
except OSError:
continue
low = display.lower()
if not any(w in low for w in wanted):
continue
record: dict[str, Any] = {"display_name": display}
for field in ("DisplayVersion", "Publisher", "InstallLocation", "UninstallString"):
try:
import winreg # type: ignore[import-not-found]
value = winreg.QueryValueEx(entry, field)[0]
record[field.lower()] = str(value)
except OSError:
continue
# 用 UpgradeCode 精确识别 AMDS(ProductCode 不稳定,不能硬编码)
try:
import winreg # type: ignore[import-not-found]
props = winreg.OpenKey(entry, "InstallProperties")
with props:
upgrade = str(winreg.QueryValueEx(props, "UpgradeCode")[0])
record["upgrade_code"] = upgrade
except OSError:
pass
key = f"{display}|{record.get('displayversion','')}"
found[key] = record
return sorted(found.values(), key=lambda r: r["display_name"])
def amds_product() -> dict[str, Any] | None:
"""返回已安装的 AMDS 记录(按 UpgradeCode 或显示名匹配)。"""
for item in installed_products():
if (item.get("upgrade_code") or "").upper() == AMDS_UPGRADE_CODE.upper():
return item
if (item.get("display_name") or "").strip().lower() == AMDS_DISPLAY_NAME.lower():
return item
return None
def usb_driver_status() -> dict[str, Any]:
"""检查 iPhone 的 USB 驱动绑定情况。
**不要**用 ``usbaapl`` 判断是否存在 —— 现代机器上健康设备绑定的是 ``WINUSB``/
``usbccgp``(驱动文件 ``appleusb.inf``)。
"""
if not plat.IS_WINDOWS:
return {"applicable": False}
ps = shutil.which("powershell") or shutil.which("pwsh")
if not ps:
return {"applicable": True, "checked": False, "error": "找不到 PowerShell"}
script = (
"Get-PnpDevice -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like '*VID_05AC*' } | "
"Select-Object Status,Class,FriendlyName,InstanceId,Service | ConvertTo-Json -Compress"
)
code, output = _run([ps, "-NoProfile", "-NonInteractive", "-Command", script], timeout=45)
devices: list[dict[str, Any]] = []
if output.strip():
try:
data = json.loads(output.strip())
devices = data if isinstance(data, list) else [data]
except Exception:
devices = []
healthy_tokens = ("usbaapl", "winusb", "appleusb", "usbccgp")
bound = [
d for d in devices
if any(t in str(d.get("Service") or "").lower() for t in healthy_tokens)
]
# MTP(照片)设备绑定 WUDFWpdMtp,不能算作"定位可用",但说明线材/端口 OK
return {
"applicable": True,
"checked": True,
"devices": [
{
"name": d.get("FriendlyName"),
"status": d.get("Status"),
"service": d.get("Service"),
"instance": d.get("InstanceId"),
}
for d in devices
],
"bound_count": len(bound),
"any_apple_device": len(devices) > 0,
}
def status(deep: bool = True) -> dict[str, Any]:
"""汇总状态。``deep=True`` 时做 27015 协议往返(权威可用性判定)。"""
if not plat.IS_WINDOWS:
return {
"applicable": False,
"ready": True,
"summary": "非 Windows 平台,usbmuxd 由系统自带",
}
service = service_state()
product = amds_product()
probe: dict[str, Any] = {"ok": False, "error": "未检测"}
devices: dict[str, Any] = {"ok": False, "count": 0, "devices": []}
if deep:
probe = usbmux_probe("ReadBUID")
if probe.get("ok"):
devices = list_devices()
ready = bool(service["running"] and probe.get("ok"))
if ready:
summary = "Apple Mobile Device Service 正在运行,usbmuxd 可通信"
elif not service["exists"]:
summary = "未安装 Apple Mobile Device Support(需要安装才能发现 iPhone)"
elif not service["running"]:
summary = "Apple Mobile Device Service 已安装但未运行"
else:
summary = "服务在运行,但 usbmuxd(127.0.0.1:27015)不可通信"
return {
"applicable": True,
"ready": ready,
"summary": summary,
"needs_install": not service["exists"] and product is None,
"needs_start": bool(service["exists"] and not service["running"]),
"service": service,
"product": product,
"probe": {k: v for k, v in probe.items() if k != "plist"},
"devices": devices,
"driver": usb_driver_status() if deep else {"checked": False},
"is_admin": plat.is_admin(),
}
# ---------------------------------------------------------------------------
# 安装
# ---------------------------------------------------------------------------
def _download(url: str, dest: Path, log: Callable[[str, str], None] | None = None,
expected_sha256: str | None = None) -> tuple[bool, str]:
"""下载文件并可选校验 SHA256。支持断点续传(.part)。"""
dest.parent.mkdir(parents=True, exist_ok=True)
part = dest.with_suffix(dest.suffix + ".part")
def emit(level: str, message: str) -> None:
if log:
log(level, message)
if dest.exists() and expected_sha256:
if _sha256(dest) == expected_sha256.lower():
return True, f"已存在且校验通过:{dest.name}"
emit("info", f"下载 {url.split('/')[-1]} …")
request = urllib.request.Request(url, headers={"User-Agent": "iOSLocationTrack/1.0"})
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
try:
handlers: list[Any] = []
if proxy:
handlers.append(urllib.request.ProxyHandler({"https": proxy, "http": proxy}))
opener = urllib.request.build_opener(*handlers)
with opener.open(request, timeout=60) as response, open(part, "wb") as handle:
total = int(response.headers.get("Content-Length") or 0)
done = 0
last_report = 0.0
while True:
chunk = response.read(256 * 1024)
if not chunk:
break
handle.write(chunk)
done += len(chunk)
now = time.monotonic()
if total and now - last_report > 2.0:
last_report = now
emit("info", f" … {done/1048576:.1f}/{total/1048576:.1f} MB "
f"({done*100//total}%)")
except Exception as exc:
try:
part.unlink(missing_ok=True)
except OSError:
pass
return False, f"下载失败:{exc}"
if expected_sha256:
actual = _sha256(part)
if actual != expected_sha256.lower():
part.unlink(missing_ok=True)
return False, (f"SHA256 校验失败:期望 {expected_sha256[:16]}…,"
f"实际 {actual[:16]}…(文件可能已更新,请报告)")
try:
part.replace(dest)
except OSError as exc:
return False, f"保存失败:{exc}"
return True, f"已下载:{dest}"
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _msiexec(msi: Path, log: Callable[[str, str], None] | None = None,
extra: list[str] | None = None) -> tuple[int, str]:
"""静默安装一个 MSI,返回 (退出码, 输出)。"""
logfile = plat.data_dir() / "logs" / f"msi-{msi.stem}.log"
logfile.parent.mkdir(parents=True, exist_ok=True)
command = [
"msiexec", "/i", str(msi), "/qn", "/norestart", "/passive",
f"/l*v", str(logfile),
]
if extra:
command += extra
# /qn 与 /passive 同时给出时以 /qn 为准;这里保留 /qn 保证全静默
command = [c for c in command if c != "/passive"]
if log:
log("info", f"执行:{' '.join(command)}")
code, output = _run(command, timeout=1800)
return code, output
def install_amds(log: Callable[[str, str], None] | None = None,
allow_download: bool = True) -> dict[str, Any]:
"""策略 B:只装 Apple Mobile Device Support(约 38 MB,推荐)。
顺序:Apple Application Support x64 → AMDS x64。iTunes 包自己就是这个顺序。
"""
def emit(level: str, message: str) -> None:
if log:
log(level, message)
if not plat.IS_WINDOWS:
return {"ok": False, "error": "仅 Windows 支持"}
if not plat.is_admin():
return {"ok": False, "error": "需要管理员权限才能安装 Apple Mobile Device Support",
"needs_admin": True}
if not allow_download:
return {"ok": False, "error": "已禁用下载"}
arch64 = struct.calcsize("P") * 8 == 64
cache = plat.data_dir() / "installers"
steps: list[dict[str, Any]] = []
plans = []
if arch64:
plans.append(("Apple Application Support x64", AAS_MSI_X64_URL, None, True))
url = AMDS_MSI_X64_URL if arch64 else AMDS_MSI_X86_URL
sha = AMDS_MSI_X64_SHA256 if arch64 else AMDS_MSI_X86_SHA256
plans.append(("Apple Mobile Device Support", url, sha, False))
for label, url, sha, optional in plans:
name = url.rsplit("/", 1)[-1]
target = cache / name
emit("info", f"=== {label} ===")
ok, message = _download(url, target, log, sha)
if not ok:
if optional:
emit("warn", f"{label} 下载失败(可选前置,继续):{message}")
steps.append({"step": label, "ok": False, "skipped": True, "message": message})
continue
return {"ok": False, "error": message, "steps": steps}
emit("info", message)
code, output = _msiexec(target, log)
meaning = CODE_MEANING.get(code, f"退出码 {code}")
emit("info" if code in SUCCESS_CODES else "error", f"{label}:{meaning}")
steps.append({"step": label, "code": code, "ok": code in SUCCESS_CODES,
"message": meaning})
if code not in SUCCESS_CODES:
tail = "\n".join(output.strip().splitlines()[-8:])
return {"ok": False, "error": f"{label} 安装失败:{meaning}",
"steps": steps, "output": tail}
return {"ok": True, "steps": steps, "strategy": "amds_msi",
"reboot_required": any(s.get("code") in (3010, 1641) for s in steps)}
def install_itunes(log: Callable[[str, str], None] | None = None) -> dict[str, Any]:
"""策略 A:装完整 iTunes 包(约 193 MB,Apple 官方推荐路径,最稳)。"""
def emit(level: str, message: str) -> None:
if log:
log(level, message)
if not plat.IS_WINDOWS:
return {"ok": False, "error": "仅 Windows 支持"}
if not plat.is_admin():
return {"ok": False, "error": "需要管理员权限才能安装 iTunes", "needs_admin": True}
cache = plat.data_dir() / "installers"
target = cache / "iTunes64Setup.exe"
ok, message = _download(ITUNES_WIN64_URL, target, log, None)
if not ok:
return {"ok": False, "error": message}
emit("info", f"{message}({target.stat().st_size/1048576:.0f} MB)")
emit("warn", "安装期间 iTunes / Outlook 可能被自动关闭,请先保存工作")
command = [str(target), "/quiet", "/norestart", "/log",
str(plat.data_dir() / "logs" / "itunes-install.log")]
emit("info", f"执行:{' '.join(command)}(可能需要几分钟)")
code, output = _run(command, timeout=3600)
meaning = CODE_MEANING.get(code, f"退出码 {code}")
ok = code in SUCCESS_CODES
emit("info" if ok else "error", f"iTunes 安装:{meaning}")
return {"ok": ok, "strategy": "itunes_bundle", "code": code, "message": meaning,
"reboot_required": code in (3010, 1641),
"error": None if ok else f"iTunes 安装失败:{meaning}"}
def install_winget(log: Callable[[str, str], None] | None = None) -> dict[str, Any]:
"""策略 C:用 winget 装(依赖 winget 可用且能联网)。"""
def emit(level: str, message: str) -> None:
if log:
log(level, message)
if not plat.IS_WINDOWS:
return {"ok": False, "error": "仅 Windows 支持"}
winget = shutil.which("winget")
if not winget:
return {"ok": False, "error": "未找到 winget"}
last: dict[str, Any] = {"ok": False, "error": "所有 winget 包都失败"}
for package_id in ("Apple.AppleMobileDeviceSupport", "Apple.iTunes"):
command = [winget, "install", "--id", package_id, "--exact", "--silent",
"--accept-package-agreements", "--accept-source-agreements",
"--disable-interactivity"]
emit("info", f"执行:{' '.join(command)}")
code, output = _run(command, timeout=3600)
if code == 0:
emit("info", f"{package_id} 安装完成")
return {"ok": True, "strategy": f"winget:{package_id}"}
emit("warn", f"{package_id} 失败({code}):{output.strip()[-300:]}")
last = {"ok": False, "error": f"{package_id} 安装失败(退出码 {code})",
"output": output.strip()[-500:]}
return last
def ensure(
log: Callable[[str, str], None] | None = None,
allow_install: bool = True,
strategy: str = "auto",
) -> dict[str, Any]:
"""**主入口**:检测 → 必要时安装 → 复检。
``strategy``: ``auto``(推荐)| ``msi`` | ``itunes`` | ``winget`` | ``none``
"""
def emit(level: str, message: str) -> None:
if log:
log(level, message)
if not plat.IS_WINDOWS:
return {"ok": True, "applicable": False, "action": "none",
"status": status(deep=False)}
before = status(deep=True)
if before["ready"]:
emit("info", "Apple Mobile Device Support 已就绪,无需安装")
return {"ok": True, "applicable": True, "action": "none", "status": before}
# 已安装但没运行 —— 先尝试启动(最常见、也最便宜)
if before["needs_start"]:
emit("warn", "服务已安装但未运行,尝试启动…")
ok, message = start_service()
emit("info" if ok else "warn", message)
if ok:
time.sleep(1.5)
after_start = status(deep=True)
if after_start["ready"]:
return {"ok": True, "applicable": True, "action": "start_service",
"status": after_start}
if not allow_install or strategy == "none":
return {"ok": False, "applicable": True, "action": "none",
"needs_install": True, "status": before,
"error": before["summary"]}
if not plat.is_admin():
return {"ok": False, "applicable": True, "action": "blocked",
"needs_admin": True, "status": before,
"error": "安装 Apple Mobile Device Support 需要管理员权限,"
"请以管理员身份重新运行程序"}
emit("warn", f"未检测到可用的 usbmuxd:{before['summary']}")
emit("info", "开始自动安装 Apple Mobile Device Support(首次约 40~200 MB)…")
attempts: list[dict[str, Any]] = []
order = {
"auto": [("msi", install_amds), ("itunes", install_itunes), ("winget", install_winget)],
"msi": [("msi", install_amds)],
"itunes": [("itunes", install_itunes)],
"winget": [("winget", install_winget)],
}.get(strategy) or []
for name, function in order:
emit("info", f"--- 安装策略:{name} ---")
result = function(log)
attempts.append({"strategy": name, **{k: v for k, v in result.items()
if k not in ("steps",)}})
if result.get("ok"):
if name == "msi" and not result.get("ok"):
continue
# 无论成功失败都让服务有机会起来,然后复检
if result.get("ok") or result.get("reboot_required"):
service_state()
start_service()
time.sleep(2.0)
after = status(deep=True)
if after["ready"]:
return {"ok": True, "applicable": True, "action": f"installed:{name}",
"status": after, "attempts": attempts,
"reboot_required": bool(result.get("reboot_required"))}
if result.get("reboot_required"):
return {"ok": False, "applicable": True, "action": f"installed:{name}",
"needs_reboot": True, "status": after, "attempts": attempts,
"error": "安装完成但需要重启 Windows 后 usbmuxd 才会生效"}
if result.get("error"):
emit("warn", f"策略 {name} 未成功:{result['error']}")
final = status(deep=True)
if final["ready"]:
return {"ok": True, "applicable": True, "action": "installed",
"status": final, "attempts": attempts}
return {
"ok": False, "applicable": True, "action": "failed", "status": final,
"attempts": attempts,
"error": "自动安装未能建立可用的 usbmuxd。可手动安装 iTunes:"
"https://www.apple.com/itunes/download/win64",
}
USBMUX_PIPE = r"\\.\pipe\usbmuxd"
# ---------------------------------------------------------------------------
# 开发者镜像(个性化镜像,iOS 17+ 全系共用)
# ---------------------------------------------------------------------------
#: 镜像来源:doronz88/DeveloperDiskImage(pymobiledevice3 官方使用的仓库)
DDI_REPO = "doronz88/DeveloperDiskImage"
DDI_BRANCH = "main"
DDI_DIR = "PersonalizedImages/Xcode_iOS_DDI_Personalized"
#: 文件名 → 最小合理体积(用于判断下载是否完整)
DDI_FILES = {
"Image.dmg": 10 * 1024 * 1024,
"BuildManifest.plist": 100 * 1024,
"Image.dmg.trustcache": 256,
}
#: 本地存放位置(相对项目/资源根)
DDI_SUBDIR = ("resources", "ddi", "personalized")
def ddi_dir(root: Path | str | None = None) -> Path:
"""返回开发者镜像的本地目录。"""
base = Path(root) if root else plat.resolve_roots()[0]
return base.joinpath(*DDI_SUBDIR)
def ddi_status(root: Path | str | None = None) -> dict[str, Any]:
"""检查本地开发者镜像素材是否齐全。"""
directory = ddi_dir(root)
files: dict[str, Any] = {}
missing: list[str] = []
total = 0
for name, minimum in DDI_FILES.items():
path = directory / name
if path.exists():
size = path.stat().st_size
ok = size >= minimum
total += size
files[name] = {"exists": True, "size": size, "ok": ok}
if not ok:
missing.append(name)
else:
files[name] = {"exists": False, "size": 0, "ok": False}
missing.append(name)
return {
"ok": not missing,
"dir": str(directory),
"files": files,
"missing": missing,
"total_mb": round(total / 1048576, 2),
}
def _ddi_urls(name: str) -> list[str]:
path = f"{DDI_DIR}/{name}"
return [
f"https://raw.githubusercontent.com/{DDI_REPO}/{DDI_BRANCH}/{path}",
f"https://cdn.jsdelivr.net/gh/{DDI_REPO}@{DDI_BRANCH}/{path}",
]
def download_developer_image(
log: Callable[[str, str], None] | None = None,
root: Path | str | None = None,
force: bool = False,
) -> dict[str, Any]:
"""下载缺失的个性化开发者镜像(约 15 MB)。
按「GitHub 直连 → jsDelivr 镜像」的顺序重试,与 tools/fetch_ddi.py 的策略一致。
供「环境准备」窗口的「安装」按钮调用。
"""
def emit(level: str, message: str) -> None:
if log:
log(level, message)
directory = ddi_dir(root)
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as exc:
return {"ok": False, "error": f"无法创建目录 {directory}:{exc}"}
current = ddi_status(root)
if current["ok"] and not force:
return {"ok": True, "action": "none", "status": current,
"detail": f"已就绪({current['total_mb']} MB)"}
for name, minimum in DDI_FILES.items():
target = directory / name
if not force and target.exists() and target.stat().st_size >= minimum:
emit("info", f"{name} 已存在,跳过")
continue
last_error = ""
downloaded = False
for url in _ddi_urls(name):
ok, message = _download(url, target, log, None)
if ok and target.exists() and target.stat().st_size >= minimum:
emit("info", f"{name}:{target.stat().st_size/1048576:.2f} MB")
downloaded = True
break
last_error = message
emit("warn", f"{name} 从 {url.split('/')[2]} 下载失败,尝试备用源…")
if not downloaded:
return {"ok": False, "error": f"{name} 下载失败:{last_error}",
"status": ddi_status(root)}
after = ddi_status(root)
if not after["ok"]:
return {"ok": False, "error": "镜像文件仍不完整:" + "、".join(after["missing"]),
"status": after}
return {"ok": True, "action": "downloaded", "status": after,
"detail": f"已下载({after['total_mb']} MB)"}
+91 -12
View File
@@ -39,6 +39,8 @@ import logging
import time
from typing import Any, Callable, Optional
from . import platform as plat
__all__ = [
"RealLocationProvider",
"LocationProviderError",
@@ -48,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:
+548
View File
@@ -0,0 +1,548 @@
"""跨平台适配层(macOS / Windows / Linux)。
见 docs/WINDOWS_PORT.md 第 9 节:平台相关代码集中到本模块,其余模块只依赖这里,
从而保持**单一代码库**,不给 Windows 分叉出第二份源码。
约定:
- 所有公开函数在任何平台上都必须可用(不存在的能力返回 None / 空列表,不抛异常)。
- 需要"管理员/root"的操作只在这里封装,便于在界面上给出对应提示。
"""
from __future__ import annotations
import os
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
IS_LINUX = sys.platform.startswith("linux")
#: 数据目录名(Windows: %LOCALAPPDATA%\iOSLocationTrack;macOS: ~/Library/Application Support/iOSLocationTrack)
APP_DIR_NAME = "iOSLocationTrack"
#: Chrome/Edge `--app=` 模式使用的独立用户数据目录
APP_WINDOW_PROFILE = ".ios-location-appwindow"
# --------------------------------------------------------------------------
# 路径
# --------------------------------------------------------------------------
def data_dir() -> Path:
"""打包运行时的可写数据目录(配置、瓦片缓存、隧道 pid 文件都写这里)。"""
if IS_WINDOWS:
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
root = Path(base) if base else Path.home() / "AppData" / "Local"
target = root / APP_DIR_NAME
elif IS_MACOS:
target = Path.home() / "Library" / "Application Support" / APP_DIR_NAME
else:
base = os.environ.get("XDG_DATA_HOME")
root = Path(base) if base else Path.home() / ".local" / "share"
target = root / APP_DIR_NAME
try:
target.mkdir(parents=True, exist_ok=True)
except OSError:
fallback = Path.home() / f".{APP_DIR_NAME.lower()}"
try:
fallback.mkdir(parents=True, exist_ok=True)
except OSError:
return Path.home()
return fallback
return target
def resolve_roots() -> tuple[Path, Path]:
"""返回 (资源根, 数据根)。
- 源码运行:两者都是项目目录
- 打包后:资源在 PyInstaller 解包目录(只读),数据写到用户可写目录
"""
if getattr(sys, "frozen", False):
resources = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
return resources, data_dir()
root = Path(__file__).resolve().parent.parent
return root, root
def venv_python(root: Path | str | None = None) -> Path | None:
"""返回项目 venv 内的解释器路径(不存在返回 None)。"""
base = Path(root) if root else resolve_roots()[0]
candidates = (
[base / ".venv" / "Scripts" / "python.exe", base / ".venv" / "Scripts" / "python"]
if IS_WINDOWS
else [base / ".venv" / "bin" / "python", base / ".venv" / "bin" / "python3"]
)
for candidate in candidates:
if candidate.exists():
return candidate
return None
def python_for_subprocess(root: Path | str | None = None) -> str:
"""选一个能 `import pymobiledevice3` 的解释器:优先项目 venv,其次当前解释器。"""
found = venv_python(root)
if found is not None:
return str(found)
return sys.executable
def module_command(root: Path | str | None = None) -> list[str]:
"""运行 `pymobiledevice3` CLI 的命令前缀。"""
return [python_for_subprocess(root), "-m", "pymobiledevice3"]
# --------------------------------------------------------------------------
# 界面窗口(Chrome/Edge 的 --app= 模式)
# --------------------------------------------------------------------------
def _windows_browser_candidates() -> list[Path]:
out: list[Path] = []
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LOCALAPPDATA", "")
rel = [
(pf, r"Google\Chrome\Application\chrome.exe"),
(pf86, r"Google\Chrome\Application\chrome.exe"),
(local, r"Google\Chrome\Application\chrome.exe"),
(pf, r"Microsoft\Edge\Application\msedge.exe"),
(pf86, r"Microsoft\Edge\Application\msedge.exe"),
(pf, r"BraveSoftware\Brave-Browser\Application\brave.exe"),
(pf86, r"BraveSoftware\Brave-Browser\Application\brave.exe"),
(local, r"BraveSoftware\Brave-Browser\Application\brave.exe"),
(local, r"Chromium\Application\chrome.exe"),
]
for base, tail in rel:
if base:
out.append(Path(base) / tail)
# 注册表 App Paths 兜底(覆盖非默认安装位置)
try:
import winreg # type: ignore[import-not-found]
for exe in ("chrome.exe", "msedge.exe", "brave.exe"):
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
try:
with winreg.OpenKey(
hive,
rf"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}",
0,
winreg.KEY_READ | view,
) as key:
value, _ = winreg.QueryValueEx(key, None)
if value:
out.append(Path(str(value).strip('"')))
except OSError:
continue
# 卸载信息里找 Chrome 的 InstallLocation
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
try:
with winreg.OpenKey(
hive,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome",
0,
winreg.KEY_READ | view,
) as key:
loc, _ = winreg.QueryValueEx(key, "InstallLocation")
if loc:
out.append(Path(str(loc)) / "chrome.exe")
except OSError:
continue
except Exception:
pass
return out
def browser_candidates() -> list[Path]:
"""可用于 `--app=` 无地址栏窗口的 Chromium 系浏览器,按优先级排序。"""
if IS_WINDOWS:
return _windows_browser_candidates()
if IS_MACOS:
return [
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
Path("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
return [
Path("/usr/bin/google-chrome"),
Path("/usr/bin/chromium"),
Path("/usr/bin/chromium-browser"),
Path("/usr/bin/microsoft-edge"),
Path("/snap/bin/chromium"),
]
def find_app_browser() -> Path | None:
"""返回第一个真实存在的 Chromium 系浏览器。"""
seen: set[str] = set()
for candidate in browser_candidates():
key = str(candidate).lower()
if key in seen:
continue
seen.add(key)
try:
if candidate.is_file():
return candidate
except OSError:
continue
for name in ("chrome", "msedge", "chromium", "brave"):
found = shutil.which(name)
if found:
return Path(found)
return None
def app_window_profile_dir() -> Path:
return Path.home() / APP_WINDOW_PROFILE
# --------------------------------------------------------------------------
# 进程
# --------------------------------------------------------------------------
def spawn_detached_kwargs() -> dict[str, Any]:
"""Popen 关键字:让子进程独立于父进程(父进程被 Ctrl+C 时不连带杀掉隧道)。"""
if IS_WINDOWS:
# start_new_session 在 Windows 上被忽略;用 CREATE_NEW_PROCESS_GROUP +
# DETACHED_PROCESS 达到同样效果,否则子进程会跟着控制台一起收到 CTRL_C_EVENT。
flags = 0
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
return {"creationflags": flags}
return {"start_new_session": True}
def _no_window_kwargs() -> dict[str, Any]:
"""辅助进程(不弹黑窗)的 Popen 关键字。"""
if IS_WINDOWS:
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)}
return {}
def process_cmdline(pid: int) -> str | None:
"""返回进程命令行;进程不存在或查询失败返回 None。"""
if pid <= 0:
return None
if IS_WINDOWS:
return _windows_cmdline(pid)
try:
result = subprocess.run(
["ps", "-p", str(pid), "-o", "command="],
capture_output=True,
text=True,
timeout=5,
)
except Exception:
return None
text = (result.stdout or "").strip()
return text or None
def _windows_cmdline(pid: int) -> str | None:
"""Windows 上取进程命令行:优先 PowerShell CIM,其次 wmic。"""
ps = shutil.which("powershell") or shutil.which("pwsh")
if ps:
try:
result = subprocess.run(
[
ps, "-NoProfile", "-NonInteractive", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={int(pid)}').CommandLine",
],
capture_output=True,
text=True,
timeout=15,
**_no_window_kwargs(),
)
text = (result.stdout or "").strip()
if text:
return text
except Exception:
pass
wmic = shutil.which("wmic")
if wmic:
try:
result = subprocess.run(
[wmic, "process", "where", f"processid={int(pid)}", "get", "commandline", "/value"],
capture_output=True,
text=True,
timeout=10,
**_no_window_kwargs(),
)
for line in (result.stdout or "").splitlines():
if line.strip().startswith("CommandLine="):
return line.split("=", 1)[1].strip() or None
except Exception:
pass
return None
def pid_alive(pid: int) -> bool:
if pid <= 0:
return False
if IS_WINDOWS:
# OpenProcess + GetExitCodeProcess;用 tasklist 太慢,这里走 ctypes
try:
import ctypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
if not handle:
return False
try:
code = ctypes.c_ulong()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return code.value == STILL_ACTIVE
return False
finally:
kernel32.CloseHandle(handle)
except Exception:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def terminate_process(pid: int, force: bool = False, timeout: float = 3.0) -> bool:
"""结束单个进程(不递归)。返回是否已确认退出。"""
if pid <= 0 or pid == os.getpid():
return False
if IS_WINDOWS:
try:
subprocess.run(
["taskkill", "/PID", str(int(pid)), "/T", "/F"],
capture_output=True,
text=True,
timeout=15,
**_no_window_kwargs(),
)
except Exception:
return False
for _ in range(int(max(1, timeout / 0.2))):
if not pid_alive(pid):
return True
time.sleep(0.2)
return not pid_alive(pid)
sig = signal.SIGKILL if force else signal.SIGTERM
try:
os.kill(pid, sig)
except ProcessLookupError:
return True
except Exception:
return False
deadline = time.time() + timeout
while time.time() < deadline:
if not pid_alive(pid):
return True
time.sleep(0.2)
if not force:
try:
os.kill(pid, signal.SIGKILL)
except Exception:
pass
return not pid_alive(pid)
def terminate_process_tree(process: subprocess.Popen[Any] | None) -> None:
"""结束一个 Popen 及其子进程(隧道助手会派生子进程)。"""
if process is None or process.poll() is not None:
return
if IS_WINDOWS:
try:
subprocess.run(
["taskkill", "/PID", str(int(process.pid)), "/T", "/F"],
capture_output=True,
text=True,
timeout=15,
**_no_window_kwargs(),
)
except Exception:
try:
process.kill()
except Exception:
pass
else:
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except Exception:
try:
process.terminate()
except Exception:
pass
try:
process.wait(timeout=4)
except Exception:
if not IS_WINDOWS:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
pass
else:
terminate_process(process.pid, force=True)
def find_pid_by_cmdline(needles: list[str], image_names: list[str] | None = None) -> list[int]:
"""按命令行关键字找进程(Windows 用一次 CIM 批量查询,POSIX 用 pgrep)。
仅用于清理本工具自己遗留的隧道进程。
"""
if IS_WINDOWS:
ps = shutil.which("powershell") or shutil.which("pwsh")
if not ps:
return []
try:
result = subprocess.run(
[
ps, "-NoProfile", "-NonInteractive", "-Command",
"Get-CimInstance Win32_Process | "
"Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
],
capture_output=True,
text=True,
timeout=25,
**_no_window_kwargs(),
)
except Exception:
return []
import json
try:
data = json.loads((result.stdout or "").strip() or "[]")
except Exception:
return []
if isinstance(data, dict):
data = [data]
found: list[int] = []
me = os.getpid()
for item in data:
if not isinstance(item, dict):
continue
pid = int(item.get("ProcessId") or 0)
if pid <= 0 or pid == me:
continue
name = str(item.get("Name") or "")
cmd = str(item.get("CommandLine") or "")
if image_names and not any(name.lower() == n.lower() for n in image_names):
continue
if any(needle.lower() in cmd.lower() for needle in needles):
found.append(pid)
return found
pids: list[int] = []
for needle in needles:
try:
result = subprocess.run(
["pgrep", "-f", needle], capture_output=True, text=True, timeout=5
)
except Exception:
continue
for line in (result.stdout or "").split():
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid() and pid not in pids:
pids.append(pid)
return pids
# --------------------------------------------------------------------------
# 权限
# --------------------------------------------------------------------------
def is_admin() -> bool:
"""当前进程是否具备管理员/root 权限。"""
if IS_WINDOWS:
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin()) # type: ignore[attr-defined]
except Exception:
return False
try:
return os.geteuid() == 0
except AttributeError:
return False
def admin_hint() -> str:
"""界面上展示的"怎么以管理员身份运行"文案。"""
if IS_WINDOWS:
return "请右键程序图标选择「以管理员身份运行」(userspace 隧道需要安装/启用 Wintun 虚拟网卡)"
return "请在终端执行 sudo ./run.sh 以管理员模式启动"
def relaunch_elevated(argv: list[str] | None = None) -> bool:
"""以提权方式重新启动自身。返回是否成功发起(不等待结果)。"""
args = argv if argv is not None else sys.argv
if IS_WINDOWS:
try:
import ctypes
if getattr(sys, "frozen", False):
exe, params = sys.executable, subprocess.list2cmdline(args[1:])
else:
exe = sys.executable
params = subprocess.list2cmdline([str(Path(args[0]).resolve()), *args[1:]])
# SW_SHOWNORMAL = 1
rc = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined]
None, "runas", exe, params, None, 1
)
return int(rc) > 32
except Exception:
return False
if IS_MACOS:
inner = " ".join(f"'{a}'" for a in args)
script = f'do shell script "{inner}" with administrator privileges'
try:
subprocess.Popen(["osascript", "-e", script])
return True
except Exception:
return False
return False
# --------------------------------------------------------------------------
# 信号
# --------------------------------------------------------------------------
def termination_signals() -> list[int]:
"""可以安全注册的退出信号(Windows 没有 SIGHUP)。"""
names = ["SIGINT", "SIGTERM"]
if not IS_WINDOWS:
names.append("SIGHUP")
out: list[int] = []
for name in names:
value = getattr(signal, name, None)
if isinstance(value, int):
out.append(value)
return out
def open_in_file_manager(path: Path) -> bool:
"""在系统文件管理器里打开一个目录。"""
try:
if IS_WINDOWS:
os.startfile(str(path)) # type: ignore[attr-defined]
elif IS_MACOS:
subprocess.Popen(["open", str(path)])
else:
subprocess.Popen(["xdg-open", str(path)])
return True
except Exception:
return False
def platform_label() -> str:
if IS_WINDOWS:
return "Windows"
if IS_MACOS:
return "macOS"
return "Linux"
+41 -4
View File
@@ -85,8 +85,12 @@ class AsyncRunner:
class App:
"""应用装配:配置、设备、隧道、定位会话、路由、播放、瓦片、日志。"""
def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT) -> None:
def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT,
resources: Path | str | None = None) -> None:
# root = 可写数据目录(打包后是 %LOCALAPPDATA%\\iOSLocationTrack)
# resources = 只读资源目录(打包后是 PyInstaller 解包目录),默认与 root 相同
self.root = Path(root)
self.resources = Path(resources) if resources is not None else self.root
self.mock = mock
self.port = port
self.host = protocol.DEFAULT_HOST
@@ -100,13 +104,18 @@ class App:
pass
self.runner = AsyncRunner()
self.hub = ws.Hub()
self.tunnel = tunnel_mod.TunnelManager(log=self.log, on_change=self._on_tunnel_change)
# Windows 的 userspace 隧道必须建在应用自己的事件循环上(地址仅本进程可达),
# 因此把 runner 注入 TunnelManager;macOS 只用它做子进程管理,传了也无害。
self.tunnel = tunnel_mod.TunnelManager(
log=self.log, on_change=self._on_tunnel_change, runner=self.runner
)
self.devices = device_mod.DeviceManager(mock=mock, on_change=self._on_devices_change)
self.routes = route_engine.RouteEngine(self.config)
self.tiles = tilecache.TileCache(
self.config.cache_dir(), int(self.config.get("tile_cache_max_mb") or 1024)
)
self.logs: deque[dict[str, Any]] = deque(maxlen=500)
self._log_file = self._open_log_file()
self._lock = threading.RLock()
self.provider: Any = None
self.provider_udid: str | None = None
@@ -126,10 +135,27 @@ class App:
self._mount_running = False
# ---------------- 日志 ----------------
def _open_log_file(self):
"""打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。"""
try:
directory = self.root / "logs"
directory.mkdir(parents=True, exist_ok=True)
handle = open(directory / "app.log", "a", encoding="utf-8")
return handle
except Exception:
return None
def log(self, level: str, message: str) -> None:
entry = {"ts": protocol.now_ts(), "level": level, "msg": str(message)}
self.logs.append(entry)
self.hub.broadcast({"type": "log", **entry})
handle = getattr(self, "_log_file", None)
if handle is not None:
try:
handle.write(f"{entry['ts']}\t{level}\t{message}\n")
handle.flush()
except Exception:
pass
def recent_logs(self, limit: int = 200) -> list[dict[str, Any]]:
try:
@@ -218,7 +244,13 @@ class App:
# 而且如果同时插着另一台可用的设备,要直接告诉用户换哪台。
self._preflight(device)
# RSD 有两种形态,必须区分(这是 Windows 与 macOS 最大的架构差异):
# - macOS native 隧道:地址内核可路由 → 传 (host, port) 让 provider 自己连
# - Windows userspace 隧道:地址只在**本进程的用户态协议栈**里可达,
# 直接 TCP 连接会得到 OSError [WinError 1231] 网络位置不可达 →
# 必须把隧道返回的那个已连接 RSD 对象本身注入给 provider
rsd = None
rsd_obj = None
if (device.ios_major or 0) >= 17:
if not self.tunnel.ready:
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
@@ -228,8 +260,11 @@ class App:
"tunnel_required",
status=409,
)
rsd = 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")
+606
View File
@@ -0,0 +1,606 @@
"""启动时的「环境准备」窗口(Windows)。
需求:每次打开 exe 都弹出一个窗口,列出所有需要的依赖:
- 没装的在右侧有「安装」按钮
- 装好的显示 ✓
- 必需项全部就绪后,「进入程序」按钮才可点击
设计要点:
- 用标准库 **tkinter**(已随 Python 打包进 exe),因此不依赖机器上有浏览器/网络,
也不依赖我们已经启动的 HTTP 服务 —— 它必须能作为"第一个界面"独立工作。
- 检测与安装都放在后台线程,通过队列回传 UI 线程(tkinter 不是线程安全的)。
- 任何异常都不能让程序打不开:tkinter 不可用时返回 None,由调用方降级处理。
"""
from __future__ import annotations
import queue
import threading
from pathlib import Path
from typing import Any, Callable
from . import apple_driver
from . import platform as plat
# 颜色(与 web 端保持同一套语义色)
COLOR_OK = "#1a7f37"
COLOR_BAD = "#c0392b"
COLOR_BUSY = "#b7791f"
COLOR_IDLE = "#6b7280"
COLOR_BG = "#ffffff"
COLOR_MUTED = "#57606a"
ICON_OK = "✓"
ICON_BAD = "✗"
ICON_BUSY = "…"
ICON_OPT = "·"
ICON_IDLE = "○"
class EnvItem:
"""一个待检查/待安装的依赖项。"""
def __init__(
self,
key: str,
label: str,
description: str,
check: Callable[[], tuple[bool, str]],
install: Callable[[Callable[[str, str], None]], tuple[bool, str]] | None = None,
required: bool = True,
install_label: str = "安装",
size_hint: str = "",
) -> None:
self.key = key
self.label = label
self.description = description
self.check = check
self.install = install
self.required = required
self.install_label = install_label
self.size_hint = size_hint
# ---------------------------------------------------------------------------
# 各个依赖项的检测 / 安装实现
# ---------------------------------------------------------------------------
def _check_python() -> tuple[bool, str]:
import sys
return True, f"已内置 Python {sys.version.split()[0]}"
def _check_pymobiledevice3() -> tuple[bool, str]:
try:
import pymobiledevice3
version = getattr(pymobiledevice3, "__version__", None) or "已安装"
return True, f"已内置 pymobiledevice3 {version}"
except Exception as exc:
return False, f"导入失败:{exc}"
def _check_tunnel_deps() -> tuple[bool, str]:
"""userspace 隧道依赖(纯 Python 协议栈,**不需要 Wintun 驱动**)。"""
missing = []
for module in ("pytun_pmd3", "pmd_pytcp", "pmd_net_addr"):
try:
__import__(module)
except Exception:
missing.append(module)
if missing:
return False, "缺少:" + "、".join(missing)
try:
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel # noqa: F401
return True, "已内置(纯 Python 用户态协议栈,无需 Wintun 驱动、无需管理员权限)"
except Exception as exc:
return False, f"隧道模块导入失败:{exc}"
def _check_apple_driver() -> tuple[bool, str]:
status = apple_driver.status(deep=True)
if not status.get("applicable", True):
return True, "非 Windows 平台,系统自带 usbmuxd"
if status.get("ready"):
product = status.get("product") or {}
version = product.get("displayversion")
devices = (status.get("devices") or {}).get("count", 0)
detail = "usbmuxd 可通信"
if version:
detail = f"v{version},{detail}"
if devices:
detail += f",已发现 {devices} 台设备"
return True, detail
return False, status.get("summary") or "不可用"
def _install_apple_driver(log: Callable[[str, str], None]) -> tuple[bool, str]:
if not plat.is_admin():
return False, "需要管理员权限(本程序启动时应已提权)"
result = apple_driver.ensure(log=log, allow_install=True)
if result.get("ok"):
if result.get("reboot_required"):
return True, "安装完成,但需要重启 Windows 才生效"
return True, "安装完成"
return False, result.get("error") or "安装失败"
def _check_developer_image() -> tuple[bool, str]:
from . import apple_driver as ad
status = ad.ddi_status()
if status["ok"]:
return True, f"已就绪({status['total_mb']} MB,iOS 17 及以上全系共用)"
return False, "缺少:" + "、".join(status["missing"])
def _install_developer_image(log: Callable[[str, str], None]) -> tuple[bool, str]:
result = apple_driver.download_developer_image(log=log)
if result.get("ok"):
return True, result.get("detail") or "下载完成"
return False, result.get("error") or "下载失败"
def _check_permission() -> tuple[bool, str]:
if plat.is_admin():
return True, "已以管理员身份运行"
return False, "未提权(安装 Apple 驱动和管理员操作会失败)"
def _check_device() -> tuple[bool, str]:
if not plat.IS_WINDOWS:
return True, "非 Windows 平台"
result = apple_driver.list_devices()
if not result.get("ok"):
return False, str(result.get("error") or "usbmuxd 不可用")
count = result.get("count", 0)
if count == 0:
return False, "未检测到 iPhone(用数据线连接、解锁并点「信任」)"
first = result["devices"][0]
return True, f"{count} 台({first.get('serial', '')},{first.get('connection', '?')})"
def _check_network() -> tuple[bool, str]:
"""Apple TSS 签名需要联网(挂载开发者镜像时用)。"""
import socket
hosts = [("gs.apple.com", 443), ("raw.githubusercontent.com", 443)]
reachable = []
for host, port in hosts:
try:
with socket.create_connection((host, port), timeout=6):
reachable.append(host)
except OSError:
continue
if reachable:
return True, "可连通(" + "、".join(reachable) + ")"
return False, "无法连接 Apple 服务器(挂载开发者镜像时需要联网签名)"
def build_items() -> list[EnvItem]:
items = [
EnvItem("python", "Python 运行时", "程序自带的解释器",
_check_python, None, required=True),
EnvItem("pymobiledevice3", "pymobiledevice3", "与 iOS 设备通信的核心库",
_check_pymobiledevice3, None, required=True),
]
if plat.IS_WINDOWS:
items.append(EnvItem(
"apple_driver", "Apple Mobile Device Support",
"Windows 上的 usbmuxd(127.0.0.1:27015),用来发现 iPhone",
_check_apple_driver, _install_apple_driver, required=True,
install_label="安装", size_hint="约 40~200 MB",
))
items.append(EnvItem(
"tunnel_deps", "RSD 隧道依赖",
"iOS 17+ 的用户态隧道(PyTCP)",
_check_tunnel_deps, None, required=True,
))
items.append(EnvItem(
"developer_image", "开发者镜像",
"iOS 17 及以上全系共用的个性化镜像素材",
_check_developer_image, _install_developer_image, required=True,
install_label="下载", size_hint="约 15 MB",
))
items.append(EnvItem(
"permission", "管理员权限",
"安装驱动与系统级操作需要",
_check_permission, None, required=True,
))
items.append(EnvItem(
"device", "iPhone 连接",
"可选项:现在没插也可以,之后接上再点「重新检测」",
_check_device, None, required=False,
))
items.append(EnvItem(
"network", "网络连通",
"可选项:挂载开发者镜像时 Apple 需要在线签名",
_check_network, None, required=False,
))
return items
# ---------------------------------------------------------------------------
# 窗口
# ---------------------------------------------------------------------------
class SetupWindow:
"""环境准备窗口。所有耗时操作都在后台线程里跑。"""
def __init__(self, root: Any, items: list[EnvItem] | None = None,
title: str = "环境准备") -> None:
import tkinter as tk
from tkinter import ttk
self.tk = tk
self.ttk = ttk
self.root = root
self.items = items if items is not None else build_items()
self.state: dict[str, dict[str, Any]] = {
item.key: {"ok": None, "detail": "等待检测…", "busy": False}
for item in self.items
}
self.events: queue.Queue[tuple[str, Any]] = queue.Queue()
self.installing = False
self.proceeded = False
self.auto_install = False
self._rows: dict[str, dict[str, Any]] = {}
root.title(f"{title} — iOS 虚拟定位轨迹工具")
root.configure(bg=COLOR_BG)
# 先建内容再定尺寸:否则 Tk 会按控件的"要求尺寸"把窗口撑高,
# geometry() 设的高度会被覆盖,底部的「进入程序」按钮就被挤出屏幕。
self._build(title)
root.resizable(False, True)
self._fit_to_screen(root)
root.protocol("WM_DELETE_WINDOW", self._on_close)
root.after(80, self._pump)
self.refresh_all()
# ---------------- 布局 ----------------
@staticmethod
def _fit_to_screen(window: Any, min_width: int = 820, min_height: int = 560) -> None:
"""按内容实际需要的高度定尺寸,并保证窗口完整落在屏幕内。"""
window.update_idletasks()
try:
screen_w = window.winfo_screenwidth()
screen_h = window.winfo_screenheight()
# 控件"要求"的尺寸 + 一点余量
need_w = max(min_width, window.winfo_reqwidth() + 24)
need_h = window.winfo_reqheight() + 24
width = min(need_w, max(640, screen_w - 60))
height = min(need_h, max(min_height, screen_h - 100))
x = max(0, (screen_w - width) // 2)
y = max(0, (screen_h - height) // 3)
window.geometry(f"{width}x{height}+{x}+{y}")
window.minsize(min(760, width), min(520, height))
except Exception:
window.geometry("960x760")
def _build(self, title: str) -> None:
tk, ttk = self.tk, self.ttk
style = ttk.Style()
try:
style.theme_use("vista")
except Exception:
pass
header = tk.Frame(self.root, bg=COLOR_BG)
header.pack(fill="x", padx=20, pady=(16, 6))
tk.Label(header, text=title, font=("Microsoft YaHei UI", 15, "bold"),
bg=COLOR_BG).pack(anchor="w")
tk.Label(
header,
text="首次使用需要准备以下组件。缺失的项目点右侧按钮即可自动安装;"
"全部就绪后点「进入程序」。",
font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED, bg=COLOR_BG,
justify="left",
).pack(anchor="w", pady=(4, 0))
ttk.Separator(self.root).pack(fill="x", padx=20, pady=(10, 4))
body = tk.Frame(self.root, bg=COLOR_BG)
body.pack(fill="both", expand=False, padx=20)
body.columnconfigure(1, weight=1)
for row, item in enumerate(self.items):
icon = tk.Label(body, text=ICON_IDLE, font=("Segoe UI Symbol", 13, "bold"),
fg=COLOR_IDLE, bg=COLOR_BG, width=2)
icon.grid(row=row, column=0, sticky="nw", pady=(6, 0))
text = tk.Frame(body, bg=COLOR_BG)
text.grid(row=row, column=1, sticky="ew", padx=(2, 10), pady=(6, 0))
name = tk.Label(text, text=item.label, font=("Microsoft YaHei UI", 10, "bold"),
bg=COLOR_BG, anchor="w")
name.pack(anchor="w")
detail = tk.Label(text, text=item.description, font=("Microsoft YaHei UI", 8),
fg=COLOR_MUTED, bg=COLOR_BG, anchor="w", justify="left",
wraplength=560)
detail.pack(anchor="w")
button = ttk.Button(body, text=item.install_label, width=10,
command=lambda i=item: self.install_one(i))
button.grid(row=row, column=2, sticky="ne", pady=(6, 0))
if item.install is None:
button.state(["disabled"])
tag = tk.Label(body, text="必需" if item.required else "可选",
font=("Microsoft YaHei UI", 8),
fg=COLOR_BAD if item.required else COLOR_MUTED,
bg=COLOR_BG, width=5)
tag.grid(row=row, column=3, sticky="ne", pady=(6, 0))
self._rows[item.key] = {
"icon": icon, "detail": detail, "button": button, "item": item
}
ttk.Separator(self.root).pack(fill="x", padx=20, pady=(12, 6))
log_frame = tk.Frame(self.root, bg=COLOR_BG)
log_frame.pack(fill="both", expand=True, padx=20)
tk.Label(log_frame, text="安装日志", font=("Microsoft YaHei UI", 9, "bold"),
bg=COLOR_BG).pack(anchor="w")
self.log = tk.Text(log_frame, height=6, wrap="word", font=("Consolas", 8),
bg="#f6f8fa", fg="#24292f", relief="flat",
highlightthickness=1, highlightbackground="#d0d7de")
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
self.log.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self.log.pack(side="left", fill="both", expand=True, pady=(3, 0))
self.log.configure(state="disabled")
footer = tk.Frame(self.root, bg=COLOR_BG)
footer.pack(fill="x", padx=20, pady=(10, 16))
self.status_label = tk.Label(footer, text="正在检测…",
font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED,
bg=COLOR_BG)
self.status_label.pack(side="left")
self.confirm_button = ttk.Button(footer, text="进入程序", width=16,
command=self._on_confirm)
self.confirm_button.pack(side="right", padx=(8, 0))
self.confirm_button.state(["disabled"])
self.install_all_button = ttk.Button(footer, text="全部安装", width=12,
command=self.install_all)
self.install_all_button.pack(side="right", padx=(8, 0))
self.recheck_button = ttk.Button(footer, text="重新检测", width=12,
command=self.refresh_all)
self.recheck_button.pack(side="right")
# ---------------- 日志 ----------------
def append_log(self, text: str) -> None:
self.log.configure(state="normal")
self.log.insert("end", text.rstrip() + "\n")
self.log.see("end")
self.log.configure(state="disabled")
# ---------------- 检测 ----------------
def refresh_all(self) -> None:
if self.installing:
return
for item in self.items:
self.state[item.key] = {"ok": None, "detail": "检测中…", "busy": False}
self._render(item.key)
self._update_footer()
threading.Thread(target=self._check_worker, name="env-check", daemon=True).start()
def _check_worker(self) -> None:
for item in self.items:
try:
ok, detail = item.check()
except Exception as exc:
ok, detail = False, f"检测异常:{exc}"
self.events.put(("check", (item.key, ok, detail)))
self.events.put(("checks_done", None))
# ---------------- 安装 ----------------
def install_one(self, item: EnvItem) -> None:
if self.installing or item.install is None:
return
state = self.state[item.key]
if state.get("ok") is True:
return
self.installing = True
self.install_all_button.state(["disabled"])
self.recheck_button.state(["disabled"])
self._set_busy(item.key, "正在安装…")
self.append_log(f"===== 开始安装:{item.label} =====")
threading.Thread(target=self._install_worker, args=([item],),
name=f"install-{item.key}", daemon=True).start()
def install_all(self) -> None:
if self.installing:
return
pending = [i for i in self.items
if i.install is not None and self.state[i.key].get("ok") is not True]
if not pending:
self.append_log("没有需要安装的项目。")
return
self.installing = True
self.install_all_button.state(["disabled"])
self.recheck_button.state(["disabled"])
for item in pending:
self._set_busy(item.key, "排队中…")
threading.Thread(target=self._install_worker, args=(pending,),
name="install-all", daemon=True).start()
def _install_worker(self, items: list[EnvItem]) -> None:
for item in items:
self.events.put(("busy", (item.key, "正在安装…")))
def log(level: str, message: str, _label: str = item.label) -> None:
self.events.put(("log", f"[{_label}] {message}"))
try:
assert item.install is not None
ok, detail = item.install(log)
except Exception as exc:
ok, detail = False, f"安装异常:{exc}"
self.events.put(("install", (item.key, ok, detail)))
self.events.put(("installs_done", None))
# ---------------- UI 线程泵 ----------------
def _pump(self) -> None:
try:
while True:
kind, payload = self.events.get_nowait()
if kind == "check":
key, ok, detail = payload
self.state[key] = {"ok": ok, "detail": detail, "busy": False}
self._render(key)
elif kind == "checks_done":
self._update_footer()
elif kind == "busy":
key, detail = payload
self.state[key]["busy"] = True
self.state[key]["detail"] = detail
self._render(key)
elif kind == "install":
key, ok, detail = payload
self.state[key] = {"ok": ok, "detail": detail, "busy": False}
self._render(key)
self.append_log(("安装成功:" if ok else "安装失败:") + detail)
elif kind == "installs_done":
self.installing = False
self.install_all_button.state(["!disabled"])
self.recheck_button.state(["!disabled"])
self._update_footer()
# 安装后可能影响其它项(例如驱动装好后设备就能发现了),重新检测一遍
self.refresh_all()
elif kind == "log":
self.append_log(payload)
except queue.Empty:
pass
if not self.proceeded:
self.root.after(80, self._pump)
# ---------------- 渲染 ----------------
def _render(self, key: str) -> None:
row = self._rows.get(key)
if row is None:
return
state = self.state[key]
item: EnvItem = row["item"]
ok = state.get("ok")
busy = state.get("busy")
if busy:
icon, color = ICON_BUSY, COLOR_BUSY
elif ok is True:
icon, color = ICON_OK, COLOR_OK
elif ok is False:
icon = ICON_BAD if item.required else ICON_OPT
color = COLOR_BAD if item.required else COLOR_MUTED
else:
icon, color = ICON_IDLE, COLOR_IDLE
row["icon"].configure(text=icon, fg=color)
suffix = f" ({item.size_hint})" if item.size_hint and ok is not True else ""
row["detail"].configure(text=f"{state.get('detail', '')}{suffix}",
fg=COLOR_OK if ok is True else COLOR_MUTED)
button = row["button"]
if ok is True:
# 已就绪:按钮文字改成"已就绪"并禁用,避免用户误以为还要再装一次
button.configure(text="已就绪")
button.state(["disabled"])
elif item.install is None:
button.configure(text="—")
button.state(["disabled"])
elif busy:
button.configure(text="安装中…")
button.state(["disabled"])
else:
button.configure(text=item.install_label)
button.state(["!disabled"])
def _set_busy(self, key: str, detail: str) -> None:
self.state[key]["busy"] = True
self.state[key]["detail"] = detail
self._render(key)
def _update_footer(self) -> None:
required = [i for i in self.items if i.required]
self.state.get("__x__")
pending = [i for i in required if self.state[i.key].get("ok") is not True]
optional_bad = [i for i in self.items
if not i.required and self.state[i.key].get("ok") is False]
if self.installing:
self.status_label.configure(text="正在安装,请稍候…", fg=COLOR_BUSY)
elif pending:
names = "、".join(i.label for i in pending)
self.status_label.configure(text=f"还差 {len(pending)} 项:{names}", fg=COLOR_BAD)
else:
text = "全部就绪,可以开始使用"
if optional_bad:
text += "(可选:" + "、".join(i.label for i in optional_bad) + " 未就绪)"
self.status_label.configure(text=text, fg=COLOR_OK)
if not pending and not self.installing:
self.confirm_button.state(["!disabled"])
else:
self.confirm_button.state(["disabled"])
any_missing = any(i.install is not None and self.state[i.key].get("ok") is not True
for i in self.items)
if any_missing and not self.installing:
self.install_all_button.state(["!disabled"])
else:
self.install_all_button.state(["disabled"])
# ---------------- 结束 ----------------
def _on_confirm(self) -> None:
self.proceeded = True
try:
self.root.destroy()
except Exception:
pass
def _on_close(self) -> None:
self.proceeded = False
try:
self.root.destroy()
except Exception:
pass
def run_setup_window(title: str = "环境准备", auto_install_missing: bool = False) -> bool | None:
"""显示环境准备窗口。
返回:
True —— 全部必需项就绪,用户点了「进入程序」
False —— 用户关闭了窗口(调用方应退出)
None —— 无法显示窗口(tkinter 不可用/构建失败),调用方自行降级
"""
import traceback
try:
import tkinter as tk
except Exception as exc:
print(f" [提示] tkinter 不可用:{exc}")
return None
try:
root = tk.Tk()
except Exception as exc:
print(f" [提示] 无法创建窗口(可能没有图形界面):{exc}")
return None
try:
window = SetupWindow(root, title=title)
if auto_install_missing:
root.after(600, window.install_all)
root.mainloop()
return bool(window.proceeded)
except Exception:
# 这里必须把真实原因打出来,否则窗口不显示时无从排查
print(" [错误] 「环境准备」窗口构建失败:")
traceback.print_exc()
try:
root.destroy()
except Exception:
pass
return None
+239 -111
View File
@@ -1,54 +1,96 @@
"""iOS 17+ 的 RSD 隧道管理。
"""iOS 17+ 的 RSD 隧道管理(macOS / Windows 双平台)。
背景(见 docs/DEVICE_NOTES.md 的取证结论):
- iOS 17 及以上,DVT 服务不再走 usbmux 代理,必须先在 Mac 与设备之间建立 RSD 隧道。
- 经典隧道(kernel utun)需要 root;`pymobiledevice3 remote start-tunnel` 在 macOS 上支持
`--native`:借用 Apple 自带 remoted 的隧道,**不需要 root**,并把内核可路由的 RSD 地址打印出来
(--script-mode 输出 "HOST PORT"),因此可以被其它进程直接使用。
- 该 native 隧道与 remoted 共用设备的单条 RSD 连接,会互相挤掉,所以同一时间只应有一个客户端
(即:本工具运行时应关闭爱思助手等同类工具)。
- 如果 native 不可用,退化为 `osascript` 弹窗向用户索取管理员密码,再跑经典隧道。
背景
----
iOS 17 起 DVT 服务不再走 usbmux,必须先在主机与设备之间建立 RSD 隧道。
两个平台的建立方式**本质不同**,这是本模块最需要注意的地方:
macOS
用 ``remote start-tunnel --native``:借 Apple 自带 ``remoted`` 的隧道
(不建内核 utun,因而不需要 root),打印出**内核可路由**的 ``HOST PORT``,
主进程用普通 TCP 连上去即可 → 走"子进程 + 解析 stdout"的老路。
Windows
没有 XPC/Objective-C,native 隧道不存在。改用 ``UserspaceRsdTunnel``:
用纯 Python 的 PyTCP 用户态协议栈实现 iOS 17+ 隧道,**不需要管理员权限、
不需要 Wintun 驱动**。
⚠️ 关键差异:用户态隧道的设备地址**只在本进程的协议栈里可达**
(``rsd.is_in_process_tunnel is True``)。用普通 socket 去连 ``(host, port)``
会直接失败(Windows 上表现为 ``OSError [WinError 1231] 网络位置不可达``)。
pymobiledevice3 的做法是把一个 relay dialer 注入它返回的
``RemoteServiceDiscoveryService`` 对象(``open_connection=dial_plane.dial``),
所以**必须把这个 rsd 对象本身交给 DVT 层**(``RealLocationProvider(rsd=...)``),
而不是把 ``(host, port)`` 传出去另建连接。
这也意味着 Windows 上 **不能**沿用 macOS 的"隧道助手子进程"架构:
子进程的协议栈不能给父进程用。隧道必须在应用自己的事件循环里建立并持有。
实测(iPhone 11 / iOS 26.6.1,Windows 11 x64):
隧道建立耗时约 0.3 秒,无需管理员权限,无需安装 Wintun 驱动。
"""
from __future__ import annotations
import os
import re
import shutil
import signal
import subprocess
import threading
import time
from typing import Any
from . import platform as plat
from . import protocol
POLL_INTERVAL = 0.25
START_TIMEOUT = 75.0
_HINT_NATIVE_FAILED = (
#: Windows 上不需要提权;macOS 的 sudo 兜底路径保留
_HINT_MAC_FAILED = (
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
"② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);"
"④ 若仍失败,可尝试管理员模式:在终端执行 sudo ./run.sh"
)
_HINT_NATIVE_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动"
_HINT_MAC_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动"
_HINT_WIN_FAILED = (
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
"② 已在手机上开启「开发者模式」并点过「信任此电脑」;"
"③ 关闭爱思助手 / i4Tools 等同类工具(同一个 RSD 连接会被互相挤掉);"
"④ 若提示缺少 Apple Mobile Device Support,请运行「修复驱动」。"
)
_HINT_WIN_FAILED_SHORT = "隧道建立失败:请确认设备已解锁并信任本电脑,且已关闭同类工具"
_HINT_NATIVE_FAILED = _HINT_WIN_FAILED if plat.IS_WINDOWS else _HINT_MAC_FAILED
_HINT_NATIVE_FAILED_SHORT = (
_HINT_WIN_FAILED_SHORT if plat.IS_WINDOWS else _HINT_MAC_FAILED_SHORT
)
class TunnelManager:
"""建立/持有/释放 RSD 隧道,并对外暴露 (host, port)。"""
"""建立/持有/释放 RSD 隧道,并对外暴露 (host, port) 与(Windows)rsd 对象。"""
def __init__(self, venv_python: str | None = None, log=None, on_change=None) -> None:
def __init__(self, venv_python: str | None = None, log=None, on_change=None,
runner=None) -> None:
self._lock = threading.RLock()
self.state = "idle" # idle|starting|connected|error|unsupported
self.message = ""
self.host: str | None = None
self.port: int | None = None
self.udid: str | None = None
self.mode: str | None = None # native|sudo|manual
self.mode: str | None = None # native|userspace|manual|sudo
self._process: subprocess.Popen[str] | None = None
self._reader: threading.Thread | None = None
self._needs_root = False
self._log = log
self._on_change = on_change
self.venv_python = venv_python or _detect_python()
#: Windows:应用的事件循环运行器(core/server.py:AsyncRunner)。
#: 用户态隧道必须建在这个循环上,并被本对象一直持有。
self._runner = runner
self._userspace_tunnel: Any = None
self._rsd: Any = None
self.venv_python = venv_python or plat.python_for_subprocess()
# ---------------- 状态 ----------------
def snapshot(self) -> dict[str, Any]:
@@ -59,8 +101,20 @@ class TunnelManager:
"rsd": f"{self.host} {self.port}" if self.host and self.port else None,
"mode": self.mode,
"udid": self.udid,
# 前端据此提示"隧道只在本进程内可用",不要尝试用外部工具连它
"in_process": bool(self._rsd is not None
and getattr(self._rsd, "is_in_process_tunnel", False)),
"platform": plat.platform_label(),
}
def rsd_object(self) -> Any:
"""返回**已连接**的 RSD 对象(Windows 用户态隧道必需)。
DVT 层必须复用它:它的拨号器绑定在本进程的用户态协议栈上。
"""
with self._lock:
return self._rsd
def _emit(self, level: str, message: str) -> None:
if self._log:
try:
@@ -101,19 +155,84 @@ class TunnelManager:
return self.start(udid=udid, allow_sudo=allow_sudo)
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict[str, Any]:
self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None,
host=None, port=None)
if plat.IS_WINDOWS:
return self._start_userspace(udid)
return self._start_subprocess(udid, allow_sudo)
# ---------------- Windows:进程内用户态隧道 ----------------
def _start_userspace(self, udid: str | None) -> dict[str, Any]:
"""Windows:在应用事件循环上建立 userspace 隧道,并持有 rsd 对象。"""
if self._runner is None:
self._set_state(
"error",
"内部错误:Windows 用户态隧道需要事件循环运行器(runner),但未注入",
mode="userspace",
)
return self.snapshot()
try:
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel
except Exception as exc:
self._set_state("unsupported", f"无法加载 userspace 隧道模块:{exc}",
mode="userspace")
return self.snapshot()
self._emit("info", "正在建立 userspace RSD 隧道(进程内 PyTCP 协议栈,无需管理员权限)…")
async def _open() -> tuple[Any, Any]:
tunnel = UserspaceRsdTunnel(serial=udid, autopair=True,
remotepairing_fallback=True)
rsd = await tunnel.aopen()
return tunnel, rsd
started = time.monotonic()
try:
tunnel, rsd = self._runner.run(_open(), timeout=START_TIMEOUT)
except Exception as exc:
message = f"隧道建立失败:{exc}"
if "already active" in str(exc):
message = ("本进程已有一条 userspace 隧道(PyTCP 协议栈是进程内全局单例),"
"请先断开后再重试")
self._set_state("error", message, mode="userspace")
self._emit("error", f"[tunnel] {message}")
return self.snapshot()
address = tuple(getattr(rsd.service, "address", ()) or ())
if len(address) < 2:
try:
self._runner.run(_close_userspace(tunnel), timeout=20)
except Exception:
pass
self._set_state("error", "隧道已建立但未拿到 RSD 地址", mode="userspace")
return self.snapshot()
host, port = str(address[0]), int(address[1])
in_process = bool(getattr(rsd, "is_in_process_tunnel", False))
with self._lock:
self._userspace_tunnel = tunnel
self._rsd = rsd
elapsed = time.monotonic() - started
detail = (f"隧道已建立(userspace,{elapsed:.1f}s):{host} 端口 {port}"
+ ("(进程内,仅供本程序使用)" if in_process else ""))
self._emit("info", f"[tunnel] {detail}")
self._set_state("connected", detail, host=host, port=port, mode="userspace")
return self.snapshot()
# ---------------- macOS:子进程 + 解析 stdout ----------------
def _start_subprocess(self, udid: str | None, allow_sudo: bool) -> dict[str, Any]:
# 设备在 iOS 17+ 上只有一条 RSD 连接:残留的旧隧道进程会与新的互相挤掉,
# 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。
if not allow_sudo:
self._kill_stale_tunnels()
self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None, host=None, port=None)
command, mode = self._build_command(udid=udid, allow_sudo=allow_sudo)
if command is None:
self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None)
return self.snapshot()
env = dict(os.environ)
env.setdefault("PYTHONUNBUFFERED", "1")
env.setdefault("PYTHONPATH", str(_project_root()))
env.setdefault("PYTHONUNBUFFERED", "1") # 不加的话 stdout 是块缓冲,HOST PORT 读不到
env.setdefault("PYTHONPATH", str(plat.resolve_roots()[0]))
self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}")
try:
process = subprocess.Popen(
@@ -124,7 +243,7 @@ class TunnelManager:
text=True,
bufsize=1,
env=env,
start_new_session=True,
**plat.spawn_detached_kwargs(),
)
except Exception as exc:
self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode)
@@ -136,7 +255,8 @@ class TunnelManager:
self._needs_root = mode == "sudo"
if mode != "sudo":
_write_pidfile([process.pid])
self._reader = threading.Thread(target=self._read_output, args=(process,), name="tunnel-reader", daemon=True)
self._reader = threading.Thread(target=self._read_output, args=(process,),
name="tunnel-reader", daemon=True)
self._reader.start()
deadline = time.time() + START_TIMEOUT
@@ -153,18 +273,15 @@ class TunnelManager:
if not self.ready:
message = _HINT_NATIVE_FAILED_SHORT
if mode == "sudo":
message = "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错"
message = ("管理员隧道启动失败:请查看具体报错后重试"
if plat.IS_WINDOWS else
"管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错")
self.stop()
self._set_state("error", message, mode=mode)
return self.snapshot()
def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]:
"""拼出"建隧道"命令。
- 打包成 .app(sys.frozen):`<本可执行文件> tunnel-helper --udid X`
—— 这条路径不依赖机器上有 Python/pymobiledevice3;
- 源码运行:`python -m pymobiledevice3 remote start-tunnel --native --script-mode`。
"""
"""拼出"建隧道"命令(**仅 macOS 使用**;Windows 走进程内 API)。"""
import sys
if getattr(sys, "frozen", False):
@@ -179,65 +296,51 @@ class TunnelManager:
command += ["--udid", udid]
if not allow_sudo:
return command, "native"
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"