diff --git a/.idea/iOSAI.iml b/.idea/iOSAI.iml
index ec63674..aad402c 100644
--- a/.idea/iOSAI.iml
+++ b/.idea/iOSAI.iml
@@ -4,4 +4,7 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 575b414..a37b124 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -3,5 +3,5 @@
-
+
\ No newline at end of file
diff --git a/Module/DeviceInfo.py b/Module/DeviceInfo.py
index 4df70e7..00b4960 100644
--- a/Module/DeviceInfo.py
+++ b/Module/DeviceInfo.py
@@ -6,7 +6,7 @@ import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
from pathlib import Path
-from typing import Dict, Optional, List
+from typing import Dict, Optional, List, Any
import random
import socket
import http.client
@@ -55,6 +55,15 @@ class DeviceInfo:
# WDA Ready 等待(HTTP 轮询方式,不触发 xctest)
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):
# 自增端口游标仅作兜底扫描使用
self._port = 9110
@@ -69,7 +78,7 @@ class DeviceInfo:
# 并发保护 & 状态表
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
# 抗抖
@@ -81,6 +90,17 @@ class DeviceInfo:
self._trusted_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")
# ---------------- 主循环 ----------------
@@ -108,13 +128,70 @@ class DeviceInfo:
with self._lock:
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):
+ 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)
- if udid not in online_now and (now - last) >= self.REMOVE_GRACE_SEC:
- LogManager.info(f"设备判定离线(超过宽限期 {self.REMOVE_GRACE_SEC}s),last_seen={last}", udid=udid)
+ exceed_grace = (now - last) >= self.REMOVE_GRACE_SEC
+ 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._last_topology_change_ts = now
+ # 清理计数
+ self._absent_ticks.pop(udid, None)
# 真正新增(连续在线超过稳定期)
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)
system_version_major = 0 # 保底
- # === iOS>17:先“被动探测”WDA,未运行则交给 IOSActivator,并通过 HTTP 轮询等待 ===
+ # === iOS>17:被动探测 WDA;未运行则交给 IOSActivator,并通过 HTTP 轮询等待 ===
if system_version_major > 17:
if self._wda_is_running(udid):
LogManager.method_info("检测到 WDA 已运行,直接映射", method, udid=udid)
@@ -209,6 +286,8 @@ class DeviceInfo:
model.ready = True
self._models[udid] = model
self._procs[udid] = proc
+ # 初始化计数
+ self._mjpeg_bad_count[udid] = 0
LogManager.method_info(f"设备添加完成,port={port}, {w}x{h}@{s}", method, udid=udid)
self._manager_send(model)
@@ -227,6 +306,9 @@ class DeviceInfo:
self._wda_ok_cache.pop(udid, None)
self._last_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)
if pid:
@@ -266,52 +348,96 @@ class DeviceInfo:
return False
# ======= WDA 探测/等待(仅走 iproxy+HTTP,不触发 xctest) =======
- def _wda_http_status_ok(self, udid: str, timeout_sec: float = 1.2) -> bool:
- """临时 iproxy 转发到 wdaFunctionPort,GET /status 成功视为 OK。"""
- method = "_wda_http_status_ok"
- tmp_port = self._pick_new_port()
- proc = None
- try:
- cmd = [self._iproxy_path, "-u", udid, str(tmp_port), str(wdaFunctionPort)]
- 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
+ def _get_probe_lock(self, udid: str) -> threading.Lock:
+ with self._lock:
+ lk = self._probe_locks.get(udid)
+ if lk is None:
+ lk = threading.Lock()
+ self._probe_locks[udid] = lk
+ return lk
- 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:
- 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)
- return ok
- except Exception as e:
- LogManager.method_info(f"WDA探测异常:{e}", method, udid=udid)
+ cmd = [self._iproxy_path, "-u", udid, str(tmp_port), str(wdaFunctionPort)]
+
+ # --- Windows 下隐藏 iproxy 控制台 ---
+ creationflags = 0
+ startupinfo = None
+ if os.name == "nt":
+ creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | \
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
+ 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
finally:
- try:
- conn.close()
- except Exception:
- pass
- finally:
- if proc:
- try:
- p = psutil.Process(proc.pid)
- p.terminate()
- p.wait(timeout=0.6)
- except Exception:
+ if proc:
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:
- 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:
- """
- 通过 _wda_http_status_ok 轮询等待 WDA Ready。
- total_timeout_sec 默认取环境变量 WDA_READY_TIMEOUT(默认 35s)。
- """
+ """通过 _wda_http_status_ok 轮询等待 WDA Ready。"""
method = "_wait_wda_ready_http"
if total_timeout_sec is None:
total_timeout_sec = self.WDA_READY_TIMEOUT
@@ -387,11 +513,11 @@ class DeviceInfo:
deadline = _monotonic() + to
while _monotonic() < deadline:
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:
LogManager.method_info(f"端口已开始监听:{port}", method, udid="system")
return True
- time.sleep(0.05)
+ time.sleep(0.06)
LogManager.method_info(f"监听验收阶段超时:{port},扩展等待", method, udid="system")
LogManager.method_warning(f"监听验收最终超时:{port}", method, udid="system")
return False
@@ -490,6 +616,7 @@ class DeviceInfo:
LogManager.method_info(f"自愈被退避抑制,剩余 {delta}s", method, udid=udid)
return
+ old_port = None
with self._lock:
proc = self._procs.get(udid)
if proc:
@@ -499,6 +626,7 @@ class DeviceInfo:
if not model:
LogManager.method_warning("模型不存在,取消自愈", method, udid=udid)
return
+ old_port = model.screenPort
proc2 = self._start_iproxy(udid, port=None)
if not proc2:
@@ -518,26 +646,78 @@ class DeviceInfo:
model.screenPort = self._port_by_udid.get(udid, model.screenPort)
self._models[udid] = 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)
# ---------------- 健康检查 ----------------
- 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"
- try:
- conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
- conn.request("HEAD", "/")
- resp = conn.getresponse()
- _ = resp.read(128)
- code = getattr(resp, "status", 0)
- conn.close()
- return 200 <= code < 400
- except Exception:
- return False
+ paths = ["/mjpeg", "/mjpegstream", "/"]
+ for path in paths:
+ try:
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
+ conn.request("GET", path, headers={"Connection": "close"})
+ resp = conn.getresponse()
+ ctype = (resp.getheader("Content-Type") or "").lower()
+ ok_hdr = (200 <= resp.status < 300) and ("multipart/x-mixed-replace" in ctype)
+ # 仅读少量字节,不阻塞
+ 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:
- # 使用 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)
+ 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):
method = "_check_and_heal_tunnels"
now = _monotonic()
@@ -557,21 +737,41 @@ class DeviceInfo:
if port <= 0:
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)
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)
self._restart_iproxy(udid)
+ continue
- # ---------------- Windows/*nix:列出所有 iproxy 命令行 ----------------
- def _get_all_iproxy_cmdlines(self) -> List[str]:
- method = "_get_all_iproxy_cmdlines"
- lines: List[str] = []
- with self._lock:
- live_pids = set(self._pid_by_udid.values())
+ # 其他情况(wda 不健康或两者都不健康):先重启 iproxy
+ LogManager.method_warning(f"检测到不健康,触发重启;port={port}", method, udid=udid)
+ self._restart_iproxy(udid)
+ # ---------------- 进程枚举(结构化返回) ----------------
+ 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"
target_name = "iproxy.exe" if is_windows else "iproxy"
@@ -580,58 +780,106 @@ class DeviceInfo:
name = (p.info.get("name") or "").lower()
if name != target_name:
continue
- if p.info["pid"] in live_pids:
- continue
cmdline = p.info.get("cmdline") or []
if not cmdline:
continue
+
+ udid = None
+ local_port = None
+ remote_port = None
+
+ # 解析 -u 与后续的两个端口(LOCAL_PORT, REMOTE_PORT)
if "-u" in cmdline:
- cmd = " ".join(cmdline)
- lines.append(f"{cmd} {p.info['pid']}")
+ try:
+ 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):
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):
method = "_cleanup_orphan_iproxy"
+
with self._lock:
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
- for ln in self._get_all_iproxy_cmdlines():
- parts = ln.split()
- try:
- if "-u" not in parts:
- continue
- udid = parts[parts.index('-u') + 1]
- 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):
+ for ent in self._get_all_iproxy_entries():
+ pid = ent["pid"]
+ udid = ent.get("udid")
+ local_port = ent.get("local_port")
+ # 完全不认识的进程(无法解析 udid),跳过
+ if not udid:
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:
- LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method, udid="system")
+ LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method)
# ---------------- 按 PID 强杀 ----------------
- def _kill_pid_gracefully(self, pid: int):
- method = "_kill_pid_gracefully"
+ def _kill_pid_gracefully(self, pid: int, silent: bool = False):
+ """优雅地结束进程,不弹出cmd窗口"""
try:
- p = psutil.Process(pid)
- p.terminate()
- try:
- p.wait(timeout=1.0)
- LogManager.method_info(f"进程已终止:pid={pid}", method, udid="system")
- except psutil.TimeoutExpired:
- p.kill()
- LogManager.method_warning(f"进程被强制 kill:pid={pid}", method, udid="system")
+ if platform.system() == "Windows":
+ # 不弹窗方式
+ subprocess.run(
+ ["taskkill", "/PID", str(pid), "/F", "/T"],
+ stdout=subprocess.DEVNULL if silent else None,
+ stderr=subprocess.DEVNULL if silent else None,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
+ )
+ else:
+ # Linux / macOS
+ os.kill(pid, signal.SIGTERM)
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:
diff --git a/Module/FlaskService.py b/Module/FlaskService.py
index f93e25a..02bf6aa 100644
--- a/Module/FlaskService.py
+++ b/Module/FlaskService.py
@@ -716,6 +716,11 @@ def test():
# 关闭会话
session.close()
+# 获取ai配置
+@app.route("/getAiConfig", methods=['GET'])
+def getAiConfig():
+ data = IOSAIStorage.load("aiConfig.json")
+ return ResultData(data=data).toJson()
if __name__ == '__main__':
app.run("0.0.0.0", port=5000, debug=True, use_reloader=False)
diff --git a/Module/IOSActivator.py b/Module/IOSActivator.py
index fdb1977..8651f1d 100644
--- a/Module/IOSActivator.py
+++ b/Module/IOSActivator.py
@@ -1,32 +1,307 @@
+# -*- coding: utf-8 -*-
import os
import re
import sys
+import atexit
+import signal
import socket
import subprocess
-from typing import Optional
+from typing import Optional, List, Tuple, Dict, Set
from Entity.Variables import WdaAppBundleId
class IOSActivator:
"""
- 轻量 iOS 激活器(仅代码调用) - 打包安全版
- 1) 启动 `pymobiledevice3 remote tunneld`(子进程常驻)
- 2) 自动挂载 Developer Disk Image(进程内调用 CLI)
- 3) 设备隧道就绪后启动 WDA(进程内调用 CLI):
- - 优先使用 `--rsd `(支持 IPv6)
- - 失败再用 `PYMOBILEDEVICE3_TUNNEL=127.0.0.1:` 回退(仅 IPv4)
+ 轻量 iOS 激活器(仅代码调用) - 进程/网卡可控版
+ 1) 启动 `pymobiledevice3 remote tunneld`(可常驻/可一次性)
+ 2) 自动挂载 DDI
+ 3) 隧道就绪后启动 WDA
+ 4) 程序退出或 keep_tunnel=False 时,确保 tunneld 进程与虚拟网卡被清理
"""
- def __init__(self, python_executable: Optional[str] = None):
- # 仅用于旧逻辑兼容;本版本已不依赖 self.python 去 -m 调 CLI
- self.python = python_executable or None
+ # ---------- 正则 ----------
+ 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+)")
+ IFACE_RE = re.compile(r"\b(pymobiledevice3-tunnel-[^\s/\\]+)\b", re.IGNORECASE)
- # =========================================================================
- # 内部工具:进程内调用 pymobiledevice3 CLI
- # =========================================================================
- def _pmd3_run(self, args: list[str], udid: str, extra_env: Optional[dict] = None) -> str:
- import subprocess
+ def __init__(self, python_executable: Optional[str] = None):
+ self.python = python_executable or None
+ self._live_procs: Dict[str, subprocess.Popen] = {} # udid -> tunneld proc
+ self._live_ifaces: Dict[str, Set[str]] = {} # udid -> {iface names}
+ 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=False:WDA 启动后关闭隧道(避免虚拟网卡常驻)
+ - 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()
env["PYMOBILEDEVICE3_UDID"] = udid
env["PYTHONUNBUFFERED"] = "1"
@@ -50,92 +325,61 @@ class IOSActivator:
except subprocess.CalledProcessError as exc:
raise RuntimeError(exc.output or f"pymobiledevice3 执行失败,退出码 {exc.returncode}")
- def _pmd3_run_subprocess(self, full_args: list[str], extra_env: Optional[dict] = None) -> str:
- """
- 兜底:通过子进程执行 pymobiledevice3(使用 _resolve_pmd3_argv_and_env())。
- """
- import subprocess
+ def _ensure_str_list(self, seq):
+ return [str(x) for x in seq]
- launcher, env = self._resolve_pmd3_argv_and_env()
- if extra_env:
- for k, v in extra_env.items():
- if v is None:
- env.pop(k, None)
- else:
- env[k] = str(v)
+ def _win_hidden_popen_kwargs(self):
+ if os.name != "nt":
+ return {}
+ si = subprocess.STARTUPINFO()
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ si.wShowWindow = 0 # SW_HIDE
+ 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):
- import os, sys, shutil, subprocess
+ import shutil, subprocess
from pathlib import Path
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env.setdefault("PYTHONIOENCODING", "utf-8")
- # 1) 明确使用 IOSAI_PYTHON(如果上层已设置)
prefer_py = env.get("IOSAI_PYTHON")
-
- # 2) 构造一批候选路径(先根目录,再 Scripts)
base = Path(sys.argv[0]).resolve()
base_dir = base.parent if base.is_file() else base
py_name = "python.exe" if os.name == "nt" else "python"
sidecar_candidates = [
- base_dir / "python-rt" / py_name, # ✅ 嵌入式/你现在的摆放方式
- base_dir / "python-rt" / "Scripts" / py_name, # 兼容虚拟环境式
+ base_dir / "python-rt" / py_name,
+ base_dir / "python-rt" / "Scripts" / py_name,
base_dir.parent / "python-rt" / py_name,
base_dir.parent / "python-rt" / "Scripts" / py_name,
]
-
- # 若外层已通过 IOSAI_PYTHON 指了路径,也放进候选
if prefer_py:
sidecar_candidates.insert(0, Path(prefer_py))
- # 打印探测
for cand in sidecar_candidates:
print(f"[IOSAI] 🔎 probing sidecar at: {cand}")
if cand.is_file():
try:
out = subprocess.check_output(
[str(cand), "-c", "import pymobiledevice3;print('ok')"],
- text=True,
- stderr=subprocess.STDOUT,
- env=env,
- timeout=6,
- **self._win_hidden_popen_kwargs()
+ text=True, stderr=subprocess.STDOUT, env=env, timeout=6, **self._win_hidden_popen_kwargs()
)
if "ok" in out:
print(f"[IOSAI] ✅ sidecar selected: {cand}")
return ([str(cand), "-u", "-m", "pymobiledevice3"], env)
except Exception:
- # 候选解释器存在但没装 pmd3,就继续找下一个
pass
- # 3) 回退:系统 PATH 里直接找 pymobiledevice3 可执行
exe = shutil.which("pymobiledevice3")
if exe:
print(f"[IOSAI] ✅ use PATH executable: {exe}")
return ([exe], env)
- # 4) 兜底:从系统 Python 里找装了 pmd3 的解释器
py_candidates = []
base_exec = getattr(sys, "_base_executable", None)
if base_exec and os.path.isfile(base_exec):
@@ -150,11 +394,7 @@ class IOSActivator:
try:
out = subprocess.check_output(
[py, "-c", "import pymobiledevice3;print('ok')"],
- text=True,
- stderr=subprocess.STDOUT,
- env=env,
- timeout=6,
- **self._win_hidden_popen_kwargs()
+ text=True, stderr=subprocess.STDOUT, env=env, timeout=6, **self._win_hidden_popen_kwargs()
)
if "ok" in out:
print(f"[IOSAI] ✅ system python selected: {py}")
@@ -164,31 +404,10 @@ class IOSActivator:
raise RuntimeError("未检测到可用的 pymobiledevice3(建议携带 python-rt 或安装系统 Python+pmd3)。")
- def _ensure_str_list(self, seq):
- 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),
- }
-
- # =========================================================================
- # 功能函数-
- # =========================================================================
+ # -------- DDI / RSD / 启动 WDA (与你原逻辑一致) --------
def _auto_mount_developer_disk(self, udid: str, retries: int = 3, backoff_seconds: float = 2.0) -> None:
- """
- 使用进程内 CLI:pymobiledevice3 mounter auto-mount(带重试)
- """
- import time
- last_err_text = ""
+ import time as _t
+ last_err = ""
for i in range(max(1, retries)):
try:
out = self._pmd3_run(["mounter", "auto-mount"], udid)
@@ -201,24 +420,17 @@ class IOSActivator:
print("[mounter] Developer disk image mounted.")
return
except Exception as e:
- last_err_text = str(e)
+ last_err = str(e)
if i < retries - 1:
print(f"[mounter] attempt {i+1}/{retries} failed, retrying in {backoff_seconds}s ...")
- try:
- time.sleep(backoff_seconds)
- except Exception:
- pass
+ _t.sleep(backoff_seconds)
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:
- 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:
- """
- 探测 RSD 通道是否就绪:直接尝试 TCP 连接。
- """
port_int = int(rsd_port)
for i in range(1, retries + 1):
print(f"[rsd] Probing RSD {rsd_host}:{rsd_port} (attempt {i}/{retries}) ...")
@@ -234,42 +446,27 @@ class IOSActivator:
return False
def _launch_wda_via_rsd(self, bundle_id: str, rsd_host: str, rsd_port: str, udid: str) -> None:
- """
- 使用进程内 CLI:pymobiledevice3 developer dvt launch --rsd
- """
print(f"[wda] Launch via RSD {rsd_host}:{rsd_port}, bundle: {bundle_id}")
- out = self._pmd3_run(
- ["developer", "dvt", "launch", bundle_id, "--rsd", rsd_host, rsd_port],
- udid=udid,
- )
+ out = self._pmd3_run(["developer", "dvt", "launch", bundle_id, "--rsd", rsd_host, rsd_port], udid=udid)
if out:
for line in out.splitlines():
print(f"[wda] {line}")
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:
- """
- 退路:通过 HTTP 网关端口设置 PYMOBILEDEVICE3_TUNNEL(仅 IPv4)。
- 使用进程内 CLI 启动 WDA。
- """
if not self._is_ipv4_host(http_host):
raise RuntimeError(f"HTTP tunnel host must be IPv4, got {http_host}")
-
tunnel_endpoint = f"{http_host}:{http_port}"
print(f"[wda] Launch via HTTP tunnel {tunnel_endpoint}, bundle: {bundle_id}")
-
- out = self._pmd3_run(
- ["developer", "dvt", "launch", bundle_id],
- udid=udid,
- extra_env={"PYMOBILEDEVICE3_TUNNEL": tunnel_endpoint},
- )
+ out = self._pmd3_run(["developer", "dvt", "launch", bundle_id], udid=udid,
+ extra_env={"PYMOBILEDEVICE3_TUNNEL": tunnel_endpoint})
if out:
for line in out.splitlines():
print(f"[wda] {line}")
print("[wda] Launch via HTTP tunnel completed.")
+ # -------- 端口挑选 --------
def _pick_available_port(self, base=49151, step=10) -> int:
- """挑选一个可用端口(避免重复)"""
for i in range(0, 1000, step):
port = base + i
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -277,197 +474,9 @@ class IOSActivator:
return port
raise RuntimeError("No free port found for tunneld")
- # =========================================================================
- # 对外方法
- # =========================================================================
- 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
-
+ # -------- UDID 过滤 --------
def _line_is_for_udid(self, line: str, udid: str) -> bool:
- """日志行是否属于目标 UDID。"""
try:
return udid.lower() in (line or "").lower()
except Exception:
- return False
+ return False
\ No newline at end of file
diff --git a/Module/Main.py b/Module/Main.py
index 65777ce..eb10e2f 100644
--- a/Module/Main.py
+++ b/Module/Main.py
@@ -49,7 +49,7 @@ def main(arg):
if __name__ == "__main__":
# 获取启动时候传递的参数
- # main(sys.argv)
+ main(sys.argv)
# 添加iOS开发包到电脑上
deployer = DevDiskImageDeployer(verbose=True)
diff --git a/Module/__pycache__/DeviceInfo.cpython-312.pyc b/Module/__pycache__/DeviceInfo.cpython-312.pyc
index fb33a67..1912666 100644
Binary files a/Module/__pycache__/DeviceInfo.cpython-312.pyc and b/Module/__pycache__/DeviceInfo.cpython-312.pyc differ
diff --git a/Module/__pycache__/Main.cpython-312.pyc b/Module/__pycache__/Main.cpython-312.pyc
index 8def77f..24cd69e 100644
Binary files a/Module/__pycache__/Main.cpython-312.pyc and b/Module/__pycache__/Main.cpython-312.pyc differ
diff --git a/Utils/ThreadManager.py b/Utils/ThreadManager.py
index 523fe26..a464052 100644
--- a/Utils/ThreadManager.py
+++ b/Utils/ThreadManager.py
@@ -1,80 +1,141 @@
import ctypes
import threading
+import time
from typing import Dict, Tuple, List
from Utils.LogManager import LogManager
-def _async_raise(tid: int, exc_type=KeyboardInterrupt):
- """向指定线程抛异常,强制跳出"""
- res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), ctypes.py_object(exc_type))
- if res == 0:
- raise ValueError("线程不存在")
- elif res > 1:
- ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
+def _async_raise(tid: int, exc_type=KeyboardInterrupt) -> bool:
+ """向指定线程抛异常(兜底方案)"""
+ try:
+ res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
+ ctypes.c_long(tid), ctypes.py_object(exc_type)
+ )
+ 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:
_tasks: Dict[str, Dict] = {}
- _lock = threading.Lock()
+ _lock = threading.RLock()
@classmethod
- def add(cls, udid: str, thread: threading.Thread, event: threading.Event) -> Tuple[int, str]:
- LogManager.method_info(f"准备创建任务:{udid}", "task")
- LogManager.method_info("创建线程成功", "监控消息")
+ def _cleanup_if_dead(cls, udid: str):
+ """如果任务线程已结束,清理占位"""
+ 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:
- # 判断当前设备是否有任务
- if cls._tasks.get(udid, None) is not None:
- return 1001, "当前设备已存在任务"
- thread.start()
- print(thread.ident)
+ cls._cleanup_if_dead(udid)
- cls._tasks[udid] = {
- "id": thread.ident,
- "thread": thread,
- "event": event
- }
- return 200, "创建成功"
+ # 已存在任务还在运行
+ old = cls._tasks.get(udid)
+ if old and old["thread"].is_alive():
+ if not force:
+ return 1001, "当前设备已存在任务"
+ 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
- def stop(cls, udid: str) -> Tuple[int, str]:
- try:
- print(cls._tasks)
- obj = cls._tasks.get(udid, None)
- obj["event"].set()
- r = cls._kill_thread(obj.get("id"))
- if r:
+ def stop(cls, udid: str, stop_timeout: float = 5.0, kill_timeout: float = 2.0) -> Tuple[int, str]:
+ """安全停止单个任务"""
+ with cls._lock:
+ obj = cls._tasks.get(udid)
+ if not obj:
+ return 200, "任务不存在"
+
+ 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)
- else:
- print("好像有问题")
+ return 200, "已结束"
+
+ # 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:
- print(e)
- return 200, "操作成功"
+ LogManager.method_error(f"[{udid}] 强制停止失败: {e}", "task")
+ finally:
+ cls._tasks.pop(udid, None)
@classmethod
def batch_stop(cls, ids: List[str]) -> Tuple[int, str]:
- try:
- for udid in ids:
- cls.stop(udid)
- except Exception as e:
- print(e)
- return 200, "停止成功."
-
- @classmethod
- 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)
\ No newline at end of file
+ failed = []
+ for udid in ids:
+ code, msg = cls.stop(udid)
+ if code != 200:
+ failed.append(udid)
+ if failed:
+ return 207, f"部分任务未成功停止: {failed}"
+ return 200, "全部停止成功"
\ No newline at end of file
diff --git a/Utils/__pycache__/LogManager.cpython-312.pyc b/Utils/__pycache__/LogManager.cpython-312.pyc
index 4fab7cc..2607057 100644
Binary files a/Utils/__pycache__/LogManager.cpython-312.pyc and b/Utils/__pycache__/LogManager.cpython-312.pyc differ
diff --git a/Utils/__pycache__/ThreadManager.cpython-312.pyc b/Utils/__pycache__/ThreadManager.cpython-312.pyc
index 1e67d5d..c2b09b7 100644
Binary files a/Utils/__pycache__/ThreadManager.cpython-312.pyc and b/Utils/__pycache__/ThreadManager.cpython-312.pyc differ
diff --git a/script/__pycache__/ScriptManager.cpython-312.pyc b/script/__pycache__/ScriptManager.cpython-312.pyc
index f355c04..cc81b14 100644
Binary files a/script/__pycache__/ScriptManager.cpython-312.pyc and b/script/__pycache__/ScriptManager.cpython-312.pyc differ