修复掉画面的bug

This commit is contained in:
2025-10-24 22:04:28 +08:00
parent fe3c19fb21
commit 23f63e42c8
12 changed files with 796 additions and 470 deletions

3
.idea/iOSAI.iml generated
View File

@@ -4,4 +4,7 @@
<option name="format" value="PLAIN" /> <option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" /> <option name="myDocStringFormat" value="Plain" />
</component> </component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module> </module>

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.12 (AI-IOS)" /> <option name="sdkName" value="Python 3.12 (AI-IOS)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (AI-IOS)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
</project> </project>

View File

@@ -6,7 +6,7 @@ import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, List from typing import Dict, Optional, List, Any
import random import random
import socket import socket
import http.client import http.client
@@ -55,6 +55,15 @@ class DeviceInfo:
# WDA Ready 等待HTTP 轮询方式,不触发 xctest # WDA Ready 等待HTTP 轮询方式,不触发 xctest
WDA_READY_TIMEOUT = float(os.getenv("WDA_READY_TIMEOUT", "35.0")) WDA_READY_TIMEOUT = float(os.getenv("WDA_READY_TIMEOUT", "35.0"))
# WDA 轻量复位策略
MJPEG_BAD_THRESHOLD = int(os.getenv("MJPEG_BAD_THRESHOLD", "3")) # 连续几次 mjpeg 健康失败才重置 WDA
WDA_RESET_COOLDOWN = float(os.getenv("WDA_RESET_COOLDOWN", "10")) # WDA 复位冷却,避免风暴
# 防连坐参数(支持环境变量)
GLITCH_SUPPRESS_SEC = float(os.getenv("GLITCH_SUPPRESS_SEC", "6.0")) # 扫描异常后抑制移除的秒数
MASS_DROP_RATIO = float(os.getenv("MASS_DROP_RATIO", "0.6")) # 一次性丢失占比阈值
ABSENT_TICKS_BEFORE_REMOVE = int(os.getenv("ABSENT_TICKS_BEFORE_REMOVE", "3")) # 连续缺席轮数
def __init__(self): def __init__(self):
# 自增端口游标仅作兜底扫描使用 # 自增端口游标仅作兜底扫描使用
self._port = 9110 self._port = 9110
@@ -69,7 +78,7 @@ class DeviceInfo:
# 并发保护 & 状态表 # 并发保护 & 状态表
self._lock = threading.RLock() self._lock = threading.RLock()
self._port_by_udid: Dict[str, int] = {} # UDID -> 当前使用的本地端口 self._port_by_udid: Dict[str, int] = {} # UDID -> 当前使用的本地端口(映射 wdaScreenPort
self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID
# 抗抖 # 抗抖
@@ -81,6 +90,17 @@ class DeviceInfo:
self._trusted_cache: Dict[str, float] = {} # udid -> expire_ts self._trusted_cache: Dict[str, float] = {} # udid -> expire_ts
self._wda_ok_cache: Dict[str, float] = {} # udid -> expire_ts self._wda_ok_cache: Dict[str, float] = {} # udid -> expire_ts
# 新增MJPEG 连续坏计数 + 最近一次 WDA 复位时间
self._mjpeg_bad_count: Dict[str, int] = {}
self._last_wda_reset: Dict[str, float] = {}
# 新增:按 UDID 的 /status 探测单飞锁,避免临时 iproxy 并发
self._probe_locks: Dict[str, threading.Lock] = {}
# 防连坐
self._scan_glitch_until = 0.0 # 截止到该时间前,认为扫描不可靠,跳过移除
self._absent_ticks: Dict[str, int] = {} # udid -> 连续缺席次数
LogManager.info("DeviceInfo init 完成;日志已启用", udid="system") LogManager.info("DeviceInfo init 完成;日志已启用", udid="system")
# ---------------- 主循环 ---------------- # ---------------- 主循环 ----------------
@@ -108,13 +128,70 @@ class DeviceInfo:
with self._lock: with self._lock:
known = set(self._models.keys()) known = set(self._models.keys())
# 真正移除(连续缺席超过宽限期) # -------- 全局扫描异常检测(防连坐)--------
missing = [u for u in known if u not in online_now]
mass_drop = (len(known) > 0) and (
(len(online_now) == 0) or
(len(missing) / max(1, len(known)) >= self.MASS_DROP_RATIO)
)
if mass_drop:
self._scan_glitch_until = now + self.GLITCH_SUPPRESS_SEC
LogManager.method_warning(
f"检测到扫描异常known={len(known)}, online={len(online_now)}, "
f"missing={len(missing)},进入抑制窗口 {self.GLITCH_SUPPRESS_SEC}s",
method, udid="system"
)
# 真正移除(仅在非抑制窗口内 + 连续缺席达到阈值 才移除)
for udid in list(known): for udid in list(known):
if udid in online_now:
# 在线:清空缺席计数
self._absent_ticks.pop(udid, None)
continue
# 离线:记录一次缺席
miss = self._absent_ticks.get(udid, 0) + 1
self._absent_ticks[udid] = miss
last = self._last_seen.get(udid, 0.0) last = self._last_seen.get(udid, 0.0)
if udid not in online_now and (now - last) >= self.REMOVE_GRACE_SEC: exceed_grace = (now - last) >= self.REMOVE_GRACE_SEC
LogManager.info(f"设备判定离线(超过宽限期 {self.REMOVE_GRACE_SEC}slast_seen={last}", udid=udid) exceed_ticks = miss >= self.ABSENT_TICKS_BEFORE_REMOVE
# 抑制窗口内:跳过任何移除
if now < self._scan_glitch_until:
continue
if exceed_grace and exceed_ticks:
# --- 移除前的“可达性”反校验 ---
try:
with self._lock:
model = self._models.get(udid)
port = model.screenPort if model else -1
reachable = False
# 1) ip:port 的 MJPEG 是否还在
if port and port > 0 and self._health_check_mjpeg(port, timeout=0.8):
reachable = True
# 2) WDA /status 是否仍然正常
if not reachable and self._health_check_wda(udid):
reachable = True
if reachable:
# 误报:续命
self._last_seen[udid] = now
self._absent_ticks[udid] = 0
LogManager.method_info("离线误报:反校验可达,取消移除并续命", method, udid=udid)
continue
except Exception as e:
LogManager.method_warning(f"离线反校验异常:{e}", method, udid=udid)
LogManager.info(
f"设备判定离线(超过宽限期 {self.REMOVE_GRACE_SEC}s 且 连续缺席 {self._absent_ticks[udid]} 次)",
udid=udid
)
self._remove_device(udid) self._remove_device(udid)
self._last_topology_change_ts = now self._last_topology_change_ts = now
# 清理计数
self._absent_ticks.pop(udid, None)
# 真正新增(连续在线超过稳定期) # 真正新增(连续在线超过稳定期)
new_candidates = [u for u in online_now if u not in known] new_candidates = [u for u in online_now if u not in known]
@@ -164,7 +241,7 @@ class DeviceInfo:
LogManager.method_warning(f"读取系统版本失败:{e}", method, udid=udid) LogManager.method_warning(f"读取系统版本失败:{e}", method, udid=udid)
system_version_major = 0 # 保底 system_version_major = 0 # 保底
# === iOS>17先“被动探测WDA未运行则交给 IOSActivator并通过 HTTP 轮询等待 === # === iOS>17被动探测 WDA未运行则交给 IOSActivator并通过 HTTP 轮询等待 ===
if system_version_major > 17: if system_version_major > 17:
if self._wda_is_running(udid): if self._wda_is_running(udid):
LogManager.method_info("检测到 WDA 已运行,直接映射", method, udid=udid) LogManager.method_info("检测到 WDA 已运行,直接映射", method, udid=udid)
@@ -209,6 +286,8 @@ class DeviceInfo:
model.ready = True model.ready = True
self._models[udid] = model self._models[udid] = model
self._procs[udid] = proc self._procs[udid] = proc
# 初始化计数
self._mjpeg_bad_count[udid] = 0
LogManager.method_info(f"设备添加完成port={port}, {w}x{h}@{s}", method, udid=udid) LogManager.method_info(f"设备添加完成port={port}, {w}x{h}@{s}", method, udid=udid)
self._manager_send(model) self._manager_send(model)
@@ -227,6 +306,9 @@ class DeviceInfo:
self._wda_ok_cache.pop(udid, None) self._wda_ok_cache.pop(udid, None)
self._last_seen.pop(udid, None) self._last_seen.pop(udid, None)
self._first_seen.pop(udid, None) self._first_seen.pop(udid, None)
self._mjpeg_bad_count.pop(udid, None)
self._last_wda_reset.pop(udid, None)
self._absent_ticks.pop(udid, None)
self._kill(proc) self._kill(proc)
if pid: if pid:
@@ -266,52 +348,96 @@ class DeviceInfo:
return False return False
# ======= WDA 探测/等待(仅走 iproxy+HTTP不触发 xctest ======= # ======= WDA 探测/等待(仅走 iproxy+HTTP不触发 xctest =======
def _wda_http_status_ok(self, udid: str, timeout_sec: float = 1.2) -> bool: def _get_probe_lock(self, udid: str) -> threading.Lock:
"""临时 iproxy 转发到 wdaFunctionPortGET /status 成功视为 OK。""" with self._lock:
method = "_wda_http_status_ok" lk = self._probe_locks.get(udid)
tmp_port = self._pick_new_port() if lk is None:
proc = None lk = threading.Lock()
try: self._probe_locks[udid] = lk
cmd = [self._iproxy_path, "-u", udid, str(tmp_port), str(wdaFunctionPort)] return lk
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not self._wait_until_listening(tmp_port, initial_timeout=0.8):
LogManager.method_info(f"WDA探测临时端口未监听{tmp_port}", method, udid=udid)
return False
conn = http.client.HTTPConnection("127.0.0.1", tmp_port, timeout=timeout_sec) def _wda_http_status_ok(self, udid: str, timeout_sec: float = 1.2) -> bool:
"""起临时 iproxy 到 wdaFunctionPort探测 /status。增加单飞锁与严格清理。"""
method = "_wda_http_status_ok"
lock = self._get_probe_lock(udid)
if not lock.acquire(timeout=3.0):
# 有并发探测在进行,避免同时起多个 iproxy直接返回“未知→False”
LogManager.method_info("状态探测被并发锁抑制", method, udid=udid)
return False
try:
tmp_port = self._pick_new_port()
proc = None
try: try:
conn.request("GET", "/status") cmd = [self._iproxy_path, "-u", udid, str(tmp_port), str(wdaFunctionPort)]
resp = conn.getresponse()
_ = resp.read(256) # --- Windows 下隐藏 iproxy 控制台 ---
code = getattr(resp, "status", 0) creationflags = 0
ok = 200 <= code < 400 startupinfo = None
LogManager.method_info(f"WDA探测/status code={code}, ok={ok}", method, udid=udid) if os.name == "nt":
return ok creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | \
except Exception as e: getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
LogManager.method_info(f"WDA探测异常{e}", method, udid=udid) si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0
startupinfo = si
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
startupinfo=startupinfo
)
if not self._wait_until_listening(tmp_port, initial_timeout=1.0):
LogManager.method_info(f"WDA探测临时端口未监听{tmp_port}", method, udid=udid)
return False
# /status 双重尝试,减少瞬态抖动
for _ in (1, 2):
try:
conn = http.client.HTTPConnection("127.0.0.1", tmp_port, timeout=timeout_sec)
conn.request("GET", "/status")
resp = conn.getresponse()
_ = resp.read(256)
code = getattr(resp, "status", 0)
ok = 200 <= code < 400
LogManager.method_info(f"WDA探测/status code={code}, ok={ok}", method, udid=udid)
try:
conn.close()
except Exception:
pass
if ok:
return True
time.sleep(0.2)
except Exception as e:
LogManager.method_info(f"WDA探测异常{e}", method, udid=udid)
time.sleep(0.2)
return False return False
finally: finally:
try: if proc:
conn.close()
except Exception:
pass
finally:
if proc:
try:
p = psutil.Process(proc.pid)
p.terminate()
p.wait(timeout=0.6)
except Exception:
try: try:
p.kill() p = psutil.Process(proc.pid)
p.terminate()
try:
p.wait(timeout=1.2)
except psutil.TimeoutExpired:
p.kill()
p.wait(timeout=1.2)
except Exception: except Exception:
pass # 兜底强杀
try:
os.kill(proc.pid, signal.SIGTERM)
except Exception:
pass
finally:
try:
lock.release()
except Exception:
pass
def _wait_wda_ready_http(self, udid: str, total_timeout_sec: float = None, interval_sec: float = 0.6) -> bool: def _wait_wda_ready_http(self, udid: str, total_timeout_sec: float = None, interval_sec: float = 0.6) -> bool:
""" """通过 _wda_http_status_ok 轮询等待 WDA Ready。"""
通过 _wda_http_status_ok 轮询等待 WDA Ready。
total_timeout_sec 默认取环境变量 WDA_READY_TIMEOUT默认 35s
"""
method = "_wait_wda_ready_http" method = "_wait_wda_ready_http"
if total_timeout_sec is None: if total_timeout_sec is None:
total_timeout_sec = self.WDA_READY_TIMEOUT total_timeout_sec = self.WDA_READY_TIMEOUT
@@ -387,11 +513,11 @@ class DeviceInfo:
deadline = _monotonic() + to deadline = _monotonic() + to
while _monotonic() < deadline: while _monotonic() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.2) s.settimeout(0.25)
if s.connect_ex(("127.0.0.1", port)) == 0: if s.connect_ex(("127.0.0.1", port)) == 0:
LogManager.method_info(f"端口已开始监听:{port}", method, udid="system") LogManager.method_info(f"端口已开始监听:{port}", method, udid="system")
return True return True
time.sleep(0.05) time.sleep(0.06)
LogManager.method_info(f"监听验收阶段超时:{port},扩展等待", method, udid="system") LogManager.method_info(f"监听验收阶段超时:{port},扩展等待", method, udid="system")
LogManager.method_warning(f"监听验收最终超时:{port}", method, udid="system") LogManager.method_warning(f"监听验收最终超时:{port}", method, udid="system")
return False return False
@@ -490,6 +616,7 @@ class DeviceInfo:
LogManager.method_info(f"自愈被退避抑制,剩余 {delta}s", method, udid=udid) LogManager.method_info(f"自愈被退避抑制,剩余 {delta}s", method, udid=udid)
return return
old_port = None
with self._lock: with self._lock:
proc = self._procs.get(udid) proc = self._procs.get(udid)
if proc: if proc:
@@ -499,6 +626,7 @@ class DeviceInfo:
if not model: if not model:
LogManager.method_warning("模型不存在,取消自愈", method, udid=udid) LogManager.method_warning("模型不存在,取消自愈", method, udid=udid)
return return
old_port = model.screenPort
proc2 = self._start_iproxy(udid, port=None) proc2 = self._start_iproxy(udid, port=None)
if not proc2: if not proc2:
@@ -518,26 +646,78 @@ class DeviceInfo:
model.screenPort = self._port_by_udid.get(udid, model.screenPort) model.screenPort = self._port_by_udid.get(udid, model.screenPort)
self._models[udid] = model self._models[udid] = model
self._manager_send(model) self._manager_send(model)
LogManager.method_info(f"[PORT-SWITCH] {udid} {old_port} -> {self._port_by_udid.get(udid)}", method, udid=udid)
LogManager.method_info(f"重启成功,使用新端口 {self._port_by_udid.get(udid)}", method, udid=udid) LogManager.method_info(f"重启成功,使用新端口 {self._port_by_udid.get(udid)}", method, udid=udid)
# ---------------- 健康检查 ---------------- # ---------------- 健康检查 ----------------
def _health_check_mjpeg(self, port: int, timeout: float = 0.8) -> bool: def _health_check_mjpeg(self, port: int, timeout: float = 1.8) -> bool:
"""使用 GET 真实探测 MJPEG校验 Content-Type 和 boundary。尝试 /mjpeg -> /mjpegstream -> /"""
method = "_health_check_mjpeg" method = "_health_check_mjpeg"
try: paths = ["/mjpeg", "/mjpegstream", "/"]
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout) for path in paths:
conn.request("HEAD", "/") try:
resp = conn.getresponse() conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
_ = resp.read(128) conn.request("GET", path, headers={"Connection": "close"})
code = getattr(resp, "status", 0) resp = conn.getresponse()
conn.close() ctype = (resp.getheader("Content-Type") or "").lower()
return 200 <= code < 400 ok_hdr = (200 <= resp.status < 300) and ("multipart/x-mixed-replace" in ctype)
except Exception: # 仅读少量字节,不阻塞
return False chunk = resp.read(1024)
try:
conn.close()
except Exception:
pass
if ok_hdr and (b"--" in chunk):
return True
except Exception:
pass
return False
def _health_check_wda(self, udid: str) -> bool: def _health_check_wda(self, udid: str) -> bool:
# 使用 HTTP 探测(带短缓存),避免触发 xctest """使用 HTTP 探测(带短缓存),避免触发 xctest"""
# 加一次重试,减少瞬态波动
if self._wda_is_running(udid, cache_sec=1.0):
return True
time.sleep(0.2)
return self._wda_is_running(udid, cache_sec=1.0) return self._wda_is_running(udid, cache_sec=1.0)
def _maybe_reset_wda_lightweight(self, udid: str) -> bool:
"""在 MJPEG 多次异常但 /status 正常时,做 WDA 轻量复位。成功返回 True。"""
method = "_maybe_reset_wda_lightweight"
now = _monotonic()
last = self._last_wda_reset.get(udid, 0.0)
if now - last < self.WDA_RESET_COOLDOWN:
return False
LogManager.method_warning("MJPEG 连续异常,尝试 WDA 轻量复位", method, udid=udid)
try:
dev = tidevice.Device(udid)
# 先尝试 stop/start
try:
dev.app_stop(WdaAppBundleId)
time.sleep(1.0)
except Exception:
pass
dev.app_start(WdaAppBundleId)
# 等待就绪(缩短等待)
if self._wait_wda_ready_http(udid, total_timeout_sec=12.0):
self._last_wda_reset[udid] = _monotonic()
return True
except Exception as e:
LogManager.method_warning(f"WDA stop/start 失败:{e}", method, udid=udid)
# 兜底iOS18+ 用 IOSActivator 再尝试
try:
ios = IOSActivator()
ios.activate(udid)
if self._wait_wda_ready_http(udid, total_timeout_sec=12.0):
self._last_wda_reset[udid] = _monotonic()
return True
except Exception as e:
LogManager.method_warning(f"IOSActivator 复位失败:{e}", method, udid=udid)
return False
def _check_and_heal_tunnels(self, interval: float = 5.0): def _check_and_heal_tunnels(self, interval: float = 5.0):
method = "_check_and_heal_tunnels" method = "_check_and_heal_tunnels"
now = _monotonic() now = _monotonic()
@@ -557,21 +737,41 @@ class DeviceInfo:
if port <= 0: if port <= 0:
continue continue
ok_local = self._health_check_mjpeg(port, timeout=0.8) ok_local = self._health_check_mjpeg(port, timeout=1.8)
ok_wda = self._health_check_wda(udid) ok_wda = self._health_check_wda(udid)
LogManager.method_info(f"健康检查mjpeg={ok_local}, wda={ok_wda}, port={port}", method, udid=udid) LogManager.method_info(f"健康检查mjpeg={ok_local}, wda={ok_wda}, port={port}", method, udid=udid)
if not (ok_local and ok_wda): if ok_local and ok_wda:
self._mjpeg_bad_count[udid] = 0
continue
# 分层自愈MJPEG 连续异常而 WDA 正常 → 优先复位 WDA
if (not ok_local) and ok_wda:
cnt = self._mjpeg_bad_count.get(udid, 0) + 1
self._mjpeg_bad_count[udid] = cnt
if cnt >= self.MJPEG_BAD_THRESHOLD:
if self._maybe_reset_wda_lightweight(udid):
# 复位成功后重启 iproxy确保新流映射
self._restart_iproxy(udid)
self._mjpeg_bad_count[udid] = 0
continue # 下一个设备
# 若未达门槛或复位失败,仍执行 iproxy 重启
LogManager.method_warning(f"检测到不健康触发重启port={port}", method, udid=udid) LogManager.method_warning(f"检测到不健康触发重启port={port}", method, udid=udid)
self._restart_iproxy(udid) self._restart_iproxy(udid)
continue
# ---------------- Windows/*nix列出所有 iproxy 命令行 ---------------- # 其他情况wda 不健康或两者都不健康):先重启 iproxy
def _get_all_iproxy_cmdlines(self) -> List[str]: LogManager.method_warning(f"检测到不健康触发重启port={port}", method, udid=udid)
method = "_get_all_iproxy_cmdlines" self._restart_iproxy(udid)
lines: List[str] = []
with self._lock:
live_pids = set(self._pid_by_udid.values())
# ---------------- 进程枚举(结构化返回) ----------------
def _get_all_iproxy_entries(self) -> List[Dict[str, Any]]:
"""
返回结构化 iproxy 进程项:
{ 'pid': int, 'name': str, 'cmdline': List[str], 'udid': str|None, 'local_port': int|None, 'remote_port': int|None }
"""
method = "_get_all_iproxy_entries"
entries: List[Dict[str, Any]] = []
is_windows = os.name == "nt" is_windows = os.name == "nt"
target_name = "iproxy.exe" if is_windows else "iproxy" target_name = "iproxy.exe" if is_windows else "iproxy"
@@ -580,58 +780,106 @@ class DeviceInfo:
name = (p.info.get("name") or "").lower() name = (p.info.get("name") or "").lower()
if name != target_name: if name != target_name:
continue continue
if p.info["pid"] in live_pids:
continue
cmdline = p.info.get("cmdline") or [] cmdline = p.info.get("cmdline") or []
if not cmdline: if not cmdline:
continue continue
udid = None
local_port = None
remote_port = None
# 解析 -u <udid> 与后续的两个端口LOCAL_PORT, REMOTE_PORT
if "-u" in cmdline: if "-u" in cmdline:
cmd = " ".join(cmdline) try:
lines.append(f"{cmd} {p.info['pid']}") i = cmdline.index("-u")
if i + 1 < len(cmdline):
udid = cmdline[i + 1]
# 在 -u udid 之后扫描数字端口
ints = []
for token in cmdline[i + 2:]:
if token.isdigit():
ints.append(int(token))
# 停止条件:拿到两个
if len(ints) >= 2:
break
if len(ints) >= 2:
local_port, remote_port = ints[0], ints[1]
else:
# 兜底:全局找两个数字
ints2 = [int(t) for t in cmdline if t.isdigit()]
if len(ints2) >= 2:
local_port, remote_port = ints2[-2], ints2[-1]
except Exception:
pass
entries.append({
"pid": p.info["pid"],
"name": name,
"cmdline": cmdline,
"udid": udid,
"local_port": local_port,
"remote_port": remote_port
})
except (psutil.NoSuchProcess, psutil.AccessDenied): except (psutil.NoSuchProcess, psutil.AccessDenied):
continue continue
LogManager.method_info(f"扫描到候选 iproxy 进程数={len(lines)}", method, udid="system")
return lines
# ---------------- 杀孤儿 ---------------- LogManager.method_info(f"扫描到候选 iproxy 进程数={len(entries)}", method, udid="system")
return entries
# ---------------- 杀孤儿(含“同 UDID 的非当前实例”清理) ----------------
def _cleanup_orphan_iproxy(self): def _cleanup_orphan_iproxy(self):
method = "_cleanup_orphan_iproxy" method = "_cleanup_orphan_iproxy"
with self._lock: with self._lock:
live_udids = set(self._models.keys()) live_udids = set(self._models.keys())
live_pids = set(self._pid_by_udid.values()) live_pid_by_udid = dict(self._pid_by_udid)
live_port_by_udid = dict(self._port_by_udid)
cleaned = 0 cleaned = 0
for ln in self._get_all_iproxy_cmdlines(): for ent in self._get_all_iproxy_entries():
parts = ln.split() pid = ent["pid"]
try: udid = ent.get("udid")
if "-u" not in parts: local_port = ent.get("local_port")
continue # 完全不认识的进程(无法解析 udid跳过
udid = parts[parts.index('-u') + 1] if not udid:
pid = int(parts[-1])
if pid not in live_pids and udid not in live_udids:
self._kill_pid_gracefully(pid)
cleaned += 1
LogManager.method_warning(f"孤儿 iproxy 已清理udid={udid}, pid={pid}", method, udid=udid)
except (ValueError, IndexError):
continue continue
# 1) 完全孤儿udid 不在活跃设备集,且 pid 不是任何已跟踪 pid → 杀
if udid not in live_udids and pid not in live_pid_by_udid.values():
self._kill_pid_gracefully(pid, silent=True)
cleaned += 1
LogManager.method_info(f"孤儿 iproxy 已清理udid={udid}, pid={pid}", method)
continue
# 2) 同 UDID 的非当前实例udid 活跃,但 pid != 当前 pid且本地端口也不是当前端口 → 杀
live_pid = live_pid_by_udid.get(udid)
live_port = live_port_by_udid.get(udid)
if udid in live_udids and pid != live_pid:
if (local_port is None) or (live_port is None) or (local_port != live_port):
self._kill_pid_gracefully(pid, silent=True)
cleaned += 1
LogManager.method_info(f"清理同UDID旧实例udid={udid}, pid={pid}, local_port={local_port}", method)
if cleaned: if cleaned:
LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method, udid="system") LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method)
# ---------------- 按 PID 强杀 ---------------- # ---------------- 按 PID 强杀 ----------------
def _kill_pid_gracefully(self, pid: int): def _kill_pid_gracefully(self, pid: int, silent: bool = False):
method = "_kill_pid_gracefully" """优雅地结束进程不弹出cmd窗口"""
try: try:
p = psutil.Process(pid) if platform.system() == "Windows":
p.terminate() # 不弹窗方式
try: subprocess.run(
p.wait(timeout=1.0) ["taskkill", "/PID", str(pid), "/F", "/T"],
LogManager.method_info(f"进程已终止pid={pid}", method, udid="system") stdout=subprocess.DEVNULL if silent else None,
except psutil.TimeoutExpired: stderr=subprocess.DEVNULL if silent else None,
p.kill() creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
LogManager.method_warning(f"进程被强制 killpid={pid}", method, udid="system") )
else:
# Linux / macOS
os.kill(pid, signal.SIGTERM)
except Exception as e: except Exception as e:
LogManager.method_warning(f"kill 进程异常pid={pid}, err={e}", method, udid="system") LogManager.method_error(f"结束进程 {pid} 失败: {e}", "_kill_pid_gracefully")
# ---------------- 端口工具(兜底) ---------------- # ---------------- 端口工具(兜底) ----------------
def _pick_free_port(self, start: int = None, limit: int = 2000) -> int: def _pick_free_port(self, start: int = None, limit: int = 2000) -> int:

View File

@@ -716,6 +716,11 @@ def test():
# 关闭会话 # 关闭会话
session.close() session.close()
# 获取ai配置
@app.route("/getAiConfig", methods=['GET'])
def getAiConfig():
data = IOSAIStorage.load("aiConfig.json")
return ResultData(data=data).toJson()
if __name__ == '__main__': if __name__ == '__main__':
app.run("0.0.0.0", port=5000, debug=True, use_reloader=False) app.run("0.0.0.0", port=5000, debug=True, use_reloader=False)

View File

@@ -1,32 +1,307 @@
# -*- coding: utf-8 -*-
import os import os
import re import re
import sys import sys
import atexit
import signal
import socket import socket
import subprocess import subprocess
from typing import Optional from typing import Optional, List, Tuple, Dict, Set
from Entity.Variables import WdaAppBundleId from Entity.Variables import WdaAppBundleId
class IOSActivator: class IOSActivator:
""" """
轻量 iOS 激活器(仅代码调用) - 打包安全 轻量 iOS 激活器(仅代码调用) - 进程/网卡可控
1) 启动 `pymobiledevice3 remote tunneld`子进程常驻 1) 启动 `pymobiledevice3 remote tunneld`可常驻/可一次性
2) 自动挂载 Developer Disk Image进程内调用 CLI 2) 自动挂载 DDI
3) 设备隧道就绪后启动 WDA(进程内调用 CLI 3) 隧道就绪后启动 WDA
- 优先使用 `--rsd <host> <port>`(支持 IPv6 4) 程序退出或 keep_tunnel=False 时,确保 tunneld 进程与虚拟网卡被清理
- 失败再用 `PYMOBILEDEVICE3_TUNNEL=127.0.0.1:<port>` 回退(仅 IPv4
""" """
def __init__(self, python_executable: Optional[str] = None): # ---------- 正则 ----------
# 仅用于旧逻辑兼容;本版本已不依赖 self.python 去 -m 调 CLI HTTP_RE = re.compile(r"http://([0-9.]+):(\d+)")
self.python = python_executable or None RSD_CREATED_RE = re.compile(r"Created tunnel\s+--rsd\s+([^\s]+)\s+(\d+)")
RSD_FALLBACK_RE = re.compile(r"--rsd\s+(\S+?)[\s:](\d+)")
IFACE_RE = re.compile(r"\b(pymobiledevice3-tunnel-[^\s/\\]+)\b", re.IGNORECASE)
# ========================================================================= def __init__(self, python_executable: Optional[str] = None):
# 内部工具:进程内调用 pymobiledevice3 CLI self.python = python_executable or None
# ========================================================================= self._live_procs: Dict[str, subprocess.Popen] = {} # udid -> tunneld proc
def _pmd3_run(self, args: list[str], udid: str, extra_env: Optional[dict] = None) -> str: self._live_ifaces: Dict[str, Set[str]] = {} # udid -> {iface names}
import subprocess self._registered = False
# =============== 公共入口 ===============
def activate(
self,
udid: str,
wda_bundle_id: Optional[str] = WdaAppBundleId,
ready_timeout_sec: float = 120.0,
mount_retries: int = 3,
backoff_seconds: float = 2.0,
rsd_probe_retries: int = 5,
rsd_probe_delay_sec: float = 3.0,
pre_mount_first: bool = True,
keep_tunnel: bool = False, # << 新增:默认 False一次性用完就关
broad_cleanup_on_exit: bool = True, # << 退出时顺带清理所有 pmd3 残留网卡
) -> str:
"""
执行:挂镜像(可选) -> 开隧道 -> (等待 RSD 就绪)-> 启动 WDA
- 默认 keep_tunnel=FalseWDA 启动后关闭隧道(避免虚拟网卡常驻)
- keep_tunnel=True让隧道常驻交由 atexit/signal 或上层调用 stop_tunnel() 清理
"""
if not udid or not isinstance(udid, str):
raise ValueError("udid is required and must be a non-empty string")
print(f"[activate] UDID = {udid}")
self._ensure_exit_hooks(broad_cleanup_on_exit=broad_cleanup_on_exit)
# 管理员检测Windows 清理网卡需要)
if os.name == "nt":
import ctypes
try:
is_admin = ctypes.windll.shell32.IsUserAnAdmin() != 0
except Exception:
is_admin = False
if not is_admin:
print("[⚠] 未以管理员运行:若需要移除虚拟网卡,可能失败。")
import time as _t
start_ts = _t.time()
# 1) 预挂载
if pre_mount_first:
try:
self._auto_mount_developer_disk(udid, retries=mount_retries, backoff_seconds=backoff_seconds)
_t.sleep(2)
except Exception as e:
print(f"[activate] 预挂载失败(继续尝试开隧道后再挂载一次):{e}")
# 2) 启动 tunneld
http_host = http_port = rsd_host = rsd_port = None
iface_names: Set[str] = set()
proc, port = self._start_tunneld(udid)
self._live_procs[udid] = proc
self._live_ifaces[udid] = iface_names
captured: List[str] = []
wda_started = False
mount_done = pre_mount_first
try:
assert proc.stdout is not None
for line in proc.stdout:
captured.append(line)
print(f"[tunneld] {line}", end="")
# 捕获虚拟网卡名
for m in self.IFACE_RE.finditer(line):
iface_names.add(m.group(1))
if proc.poll() is not None:
break
# 捕获 HTTP 网关端口
if http_port is None:
m = self.HTTP_RE.search(line)
if m:
http_host, http_port = m.group(1), m.group(2)
print(f"[tunneld] Tunnel API: {http_host}:{http_port}")
# 捕获 RSD仅识别当前 UDID 的行)
if not self._line_is_for_udid(line, udid):
continue
m = self.RSD_CREATED_RE.search(line) or self.RSD_FALLBACK_RE.search(line)
if m and not rsd_host and not rsd_port:
rsd_host, rsd_port = m.group(1), m.group(2)
print(f"[tunneld] Device-level tunnel ready (RSD {rsd_host}:{rsd_port}).")
# 启动 WDA
if (not wda_started) and wda_bundle_id and (rsd_host and rsd_port):
if not mount_done:
self._auto_mount_developer_disk(udid, retries=mount_retries, backoff_seconds=backoff_seconds)
_t.sleep(2)
mount_done = True
if self._wait_for_rsd_ready(rsd_host, rsd_port, retries=rsd_probe_retries, delay=rsd_probe_delay_sec):
self._launch_wda_via_rsd(bundle_id=wda_bundle_id, rsd_host=rsd_host, rsd_port=rsd_port, udid=udid)
wda_started = True
elif http_host and http_port:
self._launch_wda_via_http_tunnel(bundle_id=wda_bundle_id, http_host=http_host, http_port=http_port, udid=udid)
wda_started = True
else:
raise RuntimeError("No valid tunnel endpoint for WDA.")
# 超时保护
if (not wda_started) and ready_timeout_sec > 0 and (_t.time() - start_ts > ready_timeout_sec):
print(f"[tunneld] Timeout waiting for device tunnel ({ready_timeout_sec}s). Aborting.")
break
# 结束/收尾
out = "".join(captured)
finally:
if not keep_tunnel:
# 一次性模式WDA 已启动后就关闭隧道并清理网卡
self.stop_tunnel(udid, broad_cleanup=broad_cleanup_on_exit)
print("[activate] Done.")
return out
# =============== 外部可显式调用的清理 ===============
def stop_tunnel(self, udid: str, broad_cleanup: bool = True):
"""关闭某 UDID 的 tunneld并清理已知/残留的 pmd3 虚拟网卡。"""
proc = self._live_procs.pop(udid, None)
ifaces = self._live_ifaces.pop(udid, set())
# 1) 结束进程
if proc:
try:
proc.terminate()
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
proc.kill()
proc.wait(timeout=3)
except Exception:
pass
# 兜底:杀掉本进程树内可能残留的 tunneld
self._kill_stray_tunneld_children()
# 2) 清理网卡
try:
if os.name == "nt":
# 先按已知名精确删除
for name in sorted(ifaces):
self._win_remove_adapter(name)
# 宽匹配兜底
if broad_cleanup:
self._win_remove_all_pmd3_adapters()
else:
# *nix 基本不需要手动删,若有需要可在此处添加 ip link delete 等
pass
except Exception as e:
print(f"[cleanup] adapter cleanup error: {e}")
# =============== 内部:启动 tunneld ===============
def _start_tunneld(self, udid: str) -> Tuple[subprocess.Popen, int]:
port = self._pick_available_port()
launcher, env2 = self._resolve_pmd3_argv_and_env()
env2["PYTHONUNBUFFERED"] = "1"
env2.setdefault("PYTHONIOENCODING", "utf-8")
env2["PYMOBILEDEVICE3_UDID"] = udid
cmd = self._ensure_str_list([*launcher, "remote", "tunneld", "--port", str(port)])
print("[activate] 启动隧道:", " ".join(cmd))
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
env=env2,
**self._win_hidden_popen_kwargs()
)
return proc, port
# =============== 退出/信号回收 ===============
def _ensure_exit_hooks(self, broad_cleanup_on_exit: bool):
if self._registered:
return
self._registered = True
def _on_exit():
# 逐个关闭存活隧道
for udid in list(self._live_procs.keys()):
try:
self.stop_tunnel(udid, broad_cleanup=broad_cleanup_on_exit)
except Exception:
pass
atexit.register(_on_exit)
def _signal_handler(signum, frame):
_on_exit()
# 默认行为:再次发出信号退出
try:
signal.signal(signum, signal.SIG_DFL)
except Exception:
pass
os.kill(os.getpid(), signum)
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, _signal_handler)
except Exception:
pass # 某些环境不允许设置
# =============== Windows 虚拟网卡清理 ===============
def _win_remove_adapter(self, name: str):
"""按名删除一个虚拟网卡。"""
print(f"[cleanup] remove adapter: {name}")
# 先尝试 PowerShell需要管理员
ps = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
f"$a=Get-NetAdapter -Name '{name}' -ErrorAction SilentlyContinue; "
f"if($a){{ Disable-NetAdapter -Name '{name}' -Confirm:$false -PassThru | Out-Null; "
f"Remove-NetAdapter -Name '{name}' -Confirm:$false -ErrorAction SilentlyContinue; }}"
]
try:
subprocess.run(ps, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **self._win_hidden_popen_kwargs())
return
except Exception:
pass
# 兜底netsh 禁用
try:
subprocess.run(
["netsh", "interface", "set", "interface", name, "disable"],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **self._win_hidden_popen_kwargs()
)
except Exception:
pass
def _win_remove_all_pmd3_adapters(self):
"""宽匹配删除所有 pymobiledevice3-tunnel-* 网卡(防残留)。"""
print("[cleanup] sweeping all pymobiledevice3-tunnel-* adapters")
ps = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
r"$nics=Get-NetAdapter -Name 'pymobiledevice3-tunnel-*' -ErrorAction SilentlyContinue; "
r"foreach($n in $nics){ Disable-NetAdapter -Name $n.Name -Confirm:$false -PassThru | Out-Null; "
r"Remove-NetAdapter -Name $n.Name -Confirm:$false -ErrorAction SilentlyContinue; }"
]
try:
subprocess.run(ps, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **self._win_hidden_popen_kwargs())
except Exception:
pass
def _kill_stray_tunneld_children(self):
"""在当前进程空间内尽量清理残留的 tunneld 子进程。"""
import psutil
try:
me = psutil.Process(os.getpid())
for ch in me.children(recursive=True):
try:
cmd = " ".join(ch.cmdline()).lower()
except Exception:
cmd = ""
if "pymobiledevice3" in cmd and "remote" in cmd and "tunneld" in cmd:
try:
ch.terminate()
ch.wait(2)
except Exception:
try:
ch.kill()
except Exception:
pass
except Exception:
pass
# =============== 其它工具 & 你原有的方法(未改动核心逻辑) ===============
def _pmd3_run(self, args: List[str], udid: str, extra_env: Optional[dict] = None) -> str:
launcher, env = self._resolve_pmd3_argv_and_env() launcher, env = self._resolve_pmd3_argv_and_env()
env["PYMOBILEDEVICE3_UDID"] = udid env["PYMOBILEDEVICE3_UDID"] = udid
env["PYTHONUNBUFFERED"] = "1" env["PYTHONUNBUFFERED"] = "1"
@@ -50,92 +325,61 @@ class IOSActivator:
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
raise RuntimeError(exc.output or f"pymobiledevice3 执行失败,退出码 {exc.returncode}") raise RuntimeError(exc.output or f"pymobiledevice3 执行失败,退出码 {exc.returncode}")
def _pmd3_run_subprocess(self, full_args: list[str], extra_env: Optional[dict] = None) -> str: def _ensure_str_list(self, seq):
""" return [str(x) for x in seq]
兜底:通过子进程执行 pymobiledevice3使用 _resolve_pmd3_argv_and_env())。
"""
import subprocess
launcher, env = self._resolve_pmd3_argv_and_env() def _win_hidden_popen_kwargs(self):
if extra_env: if os.name != "nt":
for k, v in extra_env.items(): return {}
if v is None: si = subprocess.STARTUPINFO()
env.pop(k, None) si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
else: si.wShowWindow = 0 # SW_HIDE
env[k] = str(v) return {
"startupinfo": si,
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
}
cmd = [*launcher, *full_args]
cmd = self._ensure_str_list(cmd)
print("[pmd3-subproc]", " ".join(cmd))
try:
out = subprocess.check_output(
cmd,
text=True,
stderr=subprocess.STDOUT,
env=env,
**self._win_hidden_popen_kwargs()
)
return out or ""
except subprocess.CalledProcessError as exc:
raise RuntimeError(exc.output or f"pymobiledevice3 子进程执行失败,代码 {exc.returncode}")
# =========================================================================
# 旧版环境依赖的替代方案:仅用于启动 tunneld 的子进程(需要常驻)
# =========================================================================
def _resolve_pmd3_argv_and_env(self): def _resolve_pmd3_argv_and_env(self):
import os, sys, shutil, subprocess import shutil, subprocess
from pathlib import Path from pathlib import Path
env = os.environ.copy() env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1" env["PYTHONUNBUFFERED"] = "1"
env.setdefault("PYTHONIOENCODING", "utf-8") env.setdefault("PYTHONIOENCODING", "utf-8")
# 1) 明确使用 IOSAI_PYTHON如果上层已设置
prefer_py = env.get("IOSAI_PYTHON") prefer_py = env.get("IOSAI_PYTHON")
# 2) 构造一批候选路径(先根目录,再 Scripts
base = Path(sys.argv[0]).resolve() base = Path(sys.argv[0]).resolve()
base_dir = base.parent if base.is_file() else base base_dir = base.parent if base.is_file() else base
py_name = "python.exe" if os.name == "nt" else "python" py_name = "python.exe" if os.name == "nt" else "python"
sidecar_candidates = [ sidecar_candidates = [
base_dir / "python-rt" / py_name, # ✅ 嵌入式/你现在的摆放方式 base_dir / "python-rt" / py_name,
base_dir / "python-rt" / "Scripts" / py_name, # 兼容虚拟环境式 base_dir / "python-rt" / "Scripts" / py_name,
base_dir.parent / "python-rt" / py_name, base_dir.parent / "python-rt" / py_name,
base_dir.parent / "python-rt" / "Scripts" / py_name, base_dir.parent / "python-rt" / "Scripts" / py_name,
] ]
# 若外层已通过 IOSAI_PYTHON 指了路径,也放进候选
if prefer_py: if prefer_py:
sidecar_candidates.insert(0, Path(prefer_py)) sidecar_candidates.insert(0, Path(prefer_py))
# 打印探测
for cand in sidecar_candidates: for cand in sidecar_candidates:
print(f"[IOSAI] 🔎 probing sidecar at: {cand}") print(f"[IOSAI] 🔎 probing sidecar at: {cand}")
if cand.is_file(): if cand.is_file():
try: try:
out = subprocess.check_output( out = subprocess.check_output(
[str(cand), "-c", "import pymobiledevice3;print('ok')"], [str(cand), "-c", "import pymobiledevice3;print('ok')"],
text=True, text=True, stderr=subprocess.STDOUT, env=env, timeout=6, **self._win_hidden_popen_kwargs()
stderr=subprocess.STDOUT,
env=env,
timeout=6,
**self._win_hidden_popen_kwargs()
) )
if "ok" in out: if "ok" in out:
print(f"[IOSAI] ✅ sidecar selected: {cand}") print(f"[IOSAI] ✅ sidecar selected: {cand}")
return ([str(cand), "-u", "-m", "pymobiledevice3"], env) return ([str(cand), "-u", "-m", "pymobiledevice3"], env)
except Exception: except Exception:
# 候选解释器存在但没装 pmd3就继续找下一个
pass pass
# 3) 回退:系统 PATH 里直接找 pymobiledevice3 可执行
exe = shutil.which("pymobiledevice3") exe = shutil.which("pymobiledevice3")
if exe: if exe:
print(f"[IOSAI] ✅ use PATH executable: {exe}") print(f"[IOSAI] ✅ use PATH executable: {exe}")
return ([exe], env) return ([exe], env)
# 4) 兜底:从系统 Python 里找装了 pmd3 的解释器
py_candidates = [] py_candidates = []
base_exec = getattr(sys, "_base_executable", None) base_exec = getattr(sys, "_base_executable", None)
if base_exec and os.path.isfile(base_exec): if base_exec and os.path.isfile(base_exec):
@@ -150,11 +394,7 @@ class IOSActivator:
try: try:
out = subprocess.check_output( out = subprocess.check_output(
[py, "-c", "import pymobiledevice3;print('ok')"], [py, "-c", "import pymobiledevice3;print('ok')"],
text=True, text=True, stderr=subprocess.STDOUT, env=env, timeout=6, **self._win_hidden_popen_kwargs()
stderr=subprocess.STDOUT,
env=env,
timeout=6,
**self._win_hidden_popen_kwargs()
) )
if "ok" in out: if "ok" in out:
print(f"[IOSAI] ✅ system python selected: {py}") print(f"[IOSAI] ✅ system python selected: {py}")
@@ -164,31 +404,10 @@ class IOSActivator:
raise RuntimeError("未检测到可用的 pymobiledevice3建议携带 python-rt 或安装系统 Python+pmd3") raise RuntimeError("未检测到可用的 pymobiledevice3建议携带 python-rt 或安装系统 Python+pmd3")
def _ensure_str_list(self, seq): # -------- DDI / RSD / 启动 WDA (与你原逻辑一致) --------
return [str(x) for x in seq]
def _win_hidden_popen_kwargs(self):
"""在 Windows 上隐藏子进程窗口;非 Windows 返回空参数。"""
if os.name != "nt":
return {}
import subprocess as _sp
si = _sp.STARTUPINFO()
si.dwFlags |= _sp.STARTF_USESHOWWINDOW
si.wShowWindow = 0 # SW_HIDE
return {
"startupinfo": si,
"creationflags": getattr(_sp, "CREATE_NO_WINDOW", 0x08000000),
}
# =========================================================================
# 功能函数-
# =========================================================================
def _auto_mount_developer_disk(self, udid: str, retries: int = 3, backoff_seconds: float = 2.0) -> None: def _auto_mount_developer_disk(self, udid: str, retries: int = 3, backoff_seconds: float = 2.0) -> None:
""" import time as _t
使用进程内 CLIpymobiledevice3 mounter auto-mount带重试 last_err = ""
"""
import time
last_err_text = ""
for i in range(max(1, retries)): for i in range(max(1, retries)):
try: try:
out = self._pmd3_run(["mounter", "auto-mount"], udid) out = self._pmd3_run(["mounter", "auto-mount"], udid)
@@ -201,24 +420,17 @@ class IOSActivator:
print("[mounter] Developer disk image mounted.") print("[mounter] Developer disk image mounted.")
return return
except Exception as e: except Exception as e:
last_err_text = str(e) last_err = str(e)
if i < retries - 1: if i < retries - 1:
print(f"[mounter] attempt {i+1}/{retries} failed, retrying in {backoff_seconds}s ...") print(f"[mounter] attempt {i+1}/{retries} failed, retrying in {backoff_seconds}s ...")
try: _t.sleep(backoff_seconds)
time.sleep(backoff_seconds)
except Exception:
pass
else: else:
raise RuntimeError(f"Auto-mount failed after {retries} attempts.\n{last_err_text}") raise RuntimeError(f"Auto-mount failed after {retries} attempts.\n{last_err}")
def _is_ipv4_host(self, host: str) -> bool: def _is_ipv4_host(self, host: str) -> bool:
import re as _re return bool(re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host))
return bool(_re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host))
def _wait_for_rsd_ready(self, rsd_host: str, rsd_port: str, retries: int = 5, delay: float = 3.0) -> bool: def _wait_for_rsd_ready(self, rsd_host: str, rsd_port: str, retries: int = 5, delay: float = 3.0) -> bool:
"""
探测 RSD 通道是否就绪:直接尝试 TCP 连接。
"""
port_int = int(rsd_port) port_int = int(rsd_port)
for i in range(1, retries + 1): for i in range(1, retries + 1):
print(f"[rsd] Probing RSD {rsd_host}:{rsd_port} (attempt {i}/{retries}) ...") print(f"[rsd] Probing RSD {rsd_host}:{rsd_port} (attempt {i}/{retries}) ...")
@@ -234,42 +446,27 @@ class IOSActivator:
return False return False
def _launch_wda_via_rsd(self, bundle_id: str, rsd_host: str, rsd_port: str, udid: str) -> None: def _launch_wda_via_rsd(self, bundle_id: str, rsd_host: str, rsd_port: str, udid: str) -> None:
"""
使用进程内 CLIpymobiledevice3 developer dvt launch <bundle> --rsd <host> <port>
"""
print(f"[wda] Launch via RSD {rsd_host}:{rsd_port}, bundle: {bundle_id}") print(f"[wda] Launch via RSD {rsd_host}:{rsd_port}, bundle: {bundle_id}")
out = self._pmd3_run( out = self._pmd3_run(["developer", "dvt", "launch", bundle_id, "--rsd", rsd_host, rsd_port], udid=udid)
["developer", "dvt", "launch", bundle_id, "--rsd", rsd_host, rsd_port],
udid=udid,
)
if out: if out:
for line in out.splitlines(): for line in out.splitlines():
print(f"[wda] {line}") print(f"[wda] {line}")
print("[wda] Launch via RSD completed.") print("[wda] Launch via RSD completed.")
def _launch_wda_via_http_tunnel(self, bundle_id: str, http_host: str, http_port: str, udid: str) -> None: def _launch_wda_via_http_tunnel(self, bundle_id: str, http_host: str, http_port: str, udid: str) -> None:
"""
退路:通过 HTTP 网关端口设置 PYMOBILEDEVICE3_TUNNEL仅 IPv4
使用进程内 CLI 启动 WDA。
"""
if not self._is_ipv4_host(http_host): if not self._is_ipv4_host(http_host):
raise RuntimeError(f"HTTP tunnel host must be IPv4, got {http_host}") raise RuntimeError(f"HTTP tunnel host must be IPv4, got {http_host}")
tunnel_endpoint = f"{http_host}:{http_port}" tunnel_endpoint = f"{http_host}:{http_port}"
print(f"[wda] Launch via HTTP tunnel {tunnel_endpoint}, bundle: {bundle_id}") print(f"[wda] Launch via HTTP tunnel {tunnel_endpoint}, bundle: {bundle_id}")
out = self._pmd3_run(["developer", "dvt", "launch", bundle_id], udid=udid,
out = self._pmd3_run( extra_env={"PYMOBILEDEVICE3_TUNNEL": tunnel_endpoint})
["developer", "dvt", "launch", bundle_id],
udid=udid,
extra_env={"PYMOBILEDEVICE3_TUNNEL": tunnel_endpoint},
)
if out: if out:
for line in out.splitlines(): for line in out.splitlines():
print(f"[wda] {line}") print(f"[wda] {line}")
print("[wda] Launch via HTTP tunnel completed.") print("[wda] Launch via HTTP tunnel completed.")
# -------- 端口挑选 --------
def _pick_available_port(self, base=49151, step=10) -> int: def _pick_available_port(self, base=49151, step=10) -> int:
"""挑选一个可用端口(避免重复)"""
for i in range(0, 1000, step): for i in range(0, 1000, step):
port = base + i port = base + i
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -277,196 +474,8 @@ class IOSActivator:
return port return port
raise RuntimeError("No free port found for tunneld") raise RuntimeError("No free port found for tunneld")
# ========================================================================= # -------- UDID 过滤 --------
# 对外方法
# =========================================================================
def activate(
self,
udid: str,
wda_bundle_id: Optional[str] = WdaAppBundleId,
ready_timeout_sec: float = 120.0,
mount_retries: int = 3,
backoff_seconds: float = 2.0,
rsd_probe_retries: int = 5,
rsd_probe_delay_sec: float = 3.0,
pre_mount_first: bool = True,
) -> str:
"""
执行:挂镜像(可选) -> 开隧道 -> (等待 RSD 就绪)-> 启动 WDA
- 优先用 `--rsd` 启动
- 失败再用 HTTP 网关作为退路
"""
if not udid or not isinstance(udid, str):
raise ValueError("udid is required and must be a non-empty string")
print(f"[activate] UDID = {udid}")
# ⚠️ 检查管理员权限
if os.name == "nt":
import ctypes
try:
is_admin = ctypes.windll.shell32.IsUserAnAdmin() != 0
except Exception:
is_admin = False
if not is_admin:
print("[⚠️] 当前进程未以管理员身份运行tunneld 可能无法创建 USB 隧道。")
import time as _t
start_ts = _t.time()
# 1) (可选) 先挂载,避免 tidevice 并发 DDI 竞态
if pre_mount_first:
try:
self._auto_mount_developer_disk(udid, retries=mount_retries, backoff_seconds=backoff_seconds)
_t.sleep(2) # 稳定 DDI
except Exception as e:
print(f"[activate] 预挂载失败(继续尝试开隧道后再挂载一次):{e}")
# 2) 启动 tunneld 子进程
port = self._pick_available_port()
launcher, env2 = self._resolve_pmd3_argv_and_env()
env2["PYTHONUNBUFFERED"] = "1"
env2.setdefault("PYTHONIOENCODING", "utf-8")
env2["PYMOBILEDEVICE3_UDID"] = udid
cmd = self._ensure_str_list([*launcher, "remote", "tunneld", "--port", str(port)])
print("[activate] 使用命令启动隧道:", " ".join(cmd))
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
env=env2,
**self._win_hidden_popen_kwargs()
)
captured: list[str] = []
http_host: Optional[str] = None
http_port: Optional[str] = None
rsd_host: Optional[str] = None
rsd_port: Optional[str] = None
device_tunnel_ready = False
wda_started = False
mount_done = pre_mount_first
HTTP_RE = re.compile(r"http://([0-9.]+):(\d+)")
RSD_CREATED_RE = re.compile(r"Created tunnel\s+--rsd\s+([^\s]+)\s+(\d+)")
RSD_FALLBACK_RE = re.compile(r"--rsd\s+(\S+?)[\s:](\d+)")
try:
assert proc.stdout is not None
for line in proc.stdout:
captured.append(line)
print(f"[tunneld] {line}", end="")
if proc.poll() is not None:
break
# 捕获 HTTP 网关端口
if http_port is None:
m = HTTP_RE.search(line)
if m:
http_host, http_port = m.group(1), m.group(2)
print(f"[tunneld] Tunnel API: {http_host}:{http_port}")
# ✅ 捕获 RSD仅识别当前 UDID 的行)
if self._line_is_for_udid(line, udid):
m = RSD_CREATED_RE.search(line) or RSD_FALLBACK_RE.search(line)
if m and not device_tunnel_ready:
rsd_host, rsd_port = m.group(1), m.group(2)
device_tunnel_ready = True
print(f"[tunneld] Device-level tunnel ready (RSD {rsd_host}:{rsd_port}).")
else:
continue # 其它设备的隧道日志忽略
# 当设备隧道准备好后启动 WDA
if (not wda_started) and wda_bundle_id and device_tunnel_ready:
try:
if not mount_done:
self._auto_mount_developer_disk(
udid, retries=mount_retries, backoff_seconds=backoff_seconds
)
_t.sleep(2)
mount_done = True
rsd_ok = False
if rsd_host and rsd_port:
rsd_ok = self._wait_for_rsd_ready(
rsd_host, rsd_port,
retries=rsd_probe_retries,
delay=rsd_probe_delay_sec,
)
if rsd_ok:
self._launch_wda_via_rsd(
bundle_id=wda_bundle_id,
rsd_host=rsd_host,
rsd_port=rsd_port,
udid=udid,
)
else:
if http_host and http_port:
self._launch_wda_via_http_tunnel(
bundle_id=wda_bundle_id,
http_host=http_host,
http_port=http_port,
udid=udid,
)
else:
raise RuntimeError("No valid tunnel endpoint for fallback.")
wda_started = True
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
raise
# 超时保护
if (not wda_started) and ready_timeout_sec > 0 and (_t.time() - start_ts > ready_timeout_sec):
print(f"[tunneld] Timeout waiting for device tunnel ({ready_timeout_sec}s). Aborting.")
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
break
# 结束清理
try:
return_code = proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
return_code = proc.returncode or -9
output = "".join(captured)
if return_code != 0 and not wda_started:
raise RuntimeError(f"tunneld exited with code {return_code}.\n{output}")
print("[activate] Done.")
return output
except KeyboardInterrupt:
print("\n[activate] Interrupted, cleaning up ...", file=sys.stderr)
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
raise
def _line_is_for_udid(self, line: str, udid: str) -> bool: def _line_is_for_udid(self, line: str, udid: str) -> bool:
"""日志行是否属于目标 UDID。"""
try: try:
return udid.lower() in (line or "").lower() return udid.lower() in (line or "").lower()
except Exception: except Exception:

View File

@@ -49,7 +49,7 @@ def main(arg):
if __name__ == "__main__": if __name__ == "__main__":
# 获取启动时候传递的参数 # 获取启动时候传递的参数
# main(sys.argv) main(sys.argv)
# 添加iOS开发包到电脑上 # 添加iOS开发包到电脑上
deployer = DevDiskImageDeployer(verbose=True) deployer = DevDiskImageDeployer(verbose=True)

View File

@@ -1,80 +1,141 @@
import ctypes import ctypes
import threading import threading
import time
from typing import Dict, Tuple, List from typing import Dict, Tuple, List
from Utils.LogManager import LogManager from Utils.LogManager import LogManager
def _async_raise(tid: int, exc_type=KeyboardInterrupt): def _async_raise(tid: int, exc_type=KeyboardInterrupt) -> bool:
"""向指定线程抛异常,强制跳出""" """向指定线程抛异常(兜底方案)"""
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), ctypes.py_object(exc_type)) try:
if res == 0: res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
raise ValueError("线程不存在") ctypes.c_long(tid), ctypes.py_object(exc_type)
elif res > 1: )
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0) if res == 0:
LogManager.method_info(f"线程 {tid} 不存在", "task")
return False
elif res > 1:
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
LogManager.method_info(f"线程 {tid} 命中多个线程,已回滚", "task")
return False
return True
except Exception as e:
LogManager.method_error(f"强杀线程失败: {e}", "task")
return False
class ThreadManager: class ThreadManager:
_tasks: Dict[str, Dict] = {} _tasks: Dict[str, Dict] = {}
_lock = threading.Lock() _lock = threading.RLock()
@classmethod @classmethod
def add(cls, udid: str, thread: threading.Thread, event: threading.Event) -> Tuple[int, str]: def _cleanup_if_dead(cls, udid: str):
LogManager.method_info(f"准备创建任务:{udid}", "task") """如果任务线程已结束,清理占位"""
LogManager.method_info("创建线程成功", "监控消息") obj = cls._tasks.get(udid)
if obj and not obj["thread"].is_alive():
cls._tasks.pop(udid, None)
LogManager.method_info(f"检测到 [{udid}] 线程已结束,自动清理。", "task")
@classmethod
def add(cls, udid: str, thread: threading.Thread, event: threading.Event, force: bool = False) -> Tuple[int, str]:
with cls._lock: with cls._lock:
# 判断当前设备是否有任务 cls._cleanup_if_dead(udid)
if cls._tasks.get(udid, None) is not None:
return 1001, "当前设备已存在任务"
thread.start()
print(thread.ident)
cls._tasks[udid] = { # 已存在任务还在运行
"id": thread.ident, old = cls._tasks.get(udid)
"thread": thread, if old and old["thread"].is_alive():
"event": event if not force:
} return 1001, "当前设备已存在任务"
return 200, "创建成功" LogManager.method_info(f"[{udid}] 检测到旧任务,尝试强制停止", "task")
cls._force_stop_locked(udid)
# 启动新任务
try:
thread.start()
cls._tasks[udid] = {
"id": thread.ident,
"thread": thread,
"event": event,
"start_time": time.time(),
}
LogManager.method_info(f"创建任务成功 [{udid}]线程ID={thread.ident}", "task")
return 200, "创建成功"
except Exception as e:
LogManager.method_error(f"线程启动失败: {e}", "task")
return 1002, f"线程启动失败: {e}"
@classmethod @classmethod
def stop(cls, udid: str) -> Tuple[int, str]: def stop(cls, udid: str, stop_timeout: float = 5.0, kill_timeout: float = 2.0) -> Tuple[int, str]:
try: """安全停止单个任务"""
print(cls._tasks) with cls._lock:
obj = cls._tasks.get(udid, None) obj = cls._tasks.get(udid)
obj["event"].set() if not obj:
r = cls._kill_thread(obj.get("id")) return 200, "任务不存在"
if r:
thread = obj["thread"]
event = obj["event"]
tid = obj["id"]
LogManager.method_info(f"请求停止 [{udid}] 线程ID={tid}", "task")
# 已经结束
if not thread.is_alive():
cls._tasks.pop(udid, None) cls._tasks.pop(udid, None)
else: return 200, "已结束"
print("好像有问题")
# 1. 协作式停止
try:
event.set()
except Exception as e:
LogManager.method_error(f"[{udid}] 设置停止事件失败: {e}", "task")
thread.join(timeout=stop_timeout)
if not thread.is_alive():
cls._tasks.pop(udid, None)
LogManager.method_info(f"[{udid}] 协作式停止成功", "task")
return 200, "已停止"
# 2. 强杀兜底
LogManager.method_info(f"[{udid}] 协作式超时,尝试强杀", "task")
if _async_raise(tid):
thread.join(timeout=kill_timeout)
if not thread.is_alive():
cls._tasks.pop(udid, None)
LogManager.method_info(f"[{udid}] 强杀成功", "task")
return 200, "已停止"
# 3. 最终兜底:标记释放占位
LogManager.method_error(f"[{udid}] 无法停止(线程可能卡死),已释放占位", "task")
cls._tasks.pop(udid, None)
return 206, "停止超时,线程可能仍在后台运行"
@classmethod
def _force_stop_locked(cls, udid: str):
"""内部用,带锁强制停止旧任务"""
obj = cls._tasks.get(udid)
if not obj:
return
try:
event = obj["event"]
event.set()
obj["thread"].join(timeout=2)
if obj["thread"].is_alive():
_async_raise(obj["id"])
obj["thread"].join(timeout=1)
except Exception as e: except Exception as e:
print(e) LogManager.method_error(f"[{udid}] 强制停止失败: {e}", "task")
return 200, "操作成功" finally:
cls._tasks.pop(udid, None)
@classmethod @classmethod
def batch_stop(cls, ids: List[str]) -> Tuple[int, str]: def batch_stop(cls, ids: List[str]) -> Tuple[int, str]:
try: failed = []
for udid in ids: for udid in ids:
cls.stop(udid) code, msg = cls.stop(udid)
except Exception as e: if code != 200:
print(e) failed.append(udid)
return 200, "停止成功." if failed:
return 207, f"部分任务未成功停止: {failed}"
@classmethod return 200, "全部停止成功"
def _kill_thread(cls, tid: int) -> bool:
"""向原生线程 ID 抛 KeyboardInterrupt强制跳出"""
try:
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid),
ctypes.py_object(KeyboardInterrupt))
# LogManager.method_info(f"向原生线程 {tid} 抛 KeyboardInterrupt强制跳出", "task")
if res == 0: # 线程已不存在
print("线程不存在")
return False
if res > 1: # 命中多个线程,重置
print("命中了多个线程")
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
LogManager.method_info("杀死线程创建成功", "监控消息")
print("杀死线程成功")
return True
except Exception as e:
print("杀死线程出现问题 错误的原因:",e)