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

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