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
+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