修复掉画面的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="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.12 (AI-IOS)" />
</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>

View File

@@ -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}slast_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 _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
def _wda_http_status_ok(self, udid: str, timeout_sec: float = 1.2) -> bool:
"""临时 iproxy 转发到 wdaFunctionPortGET /status 成功视为 OK"""
"""临时 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:
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):
# --- 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
conn = http.client.HTTPConnection("127.0.0.1", tmp_port, timeout=timeout_sec)
# /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)
return ok
except Exception as e:
LogManager.method_info(f"WDA探测异常{e}", method, udid=udid)
return False
finally:
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:
if proc:
try:
p = psutil.Process(proc.pid)
p.terminate()
p.wait(timeout=0.6)
except Exception:
try:
p.wait(timeout=1.2)
except psutil.TimeoutExpired:
p.kill()
p.wait(timeout=1.2)
except Exception:
# 兜底强杀
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"
paths = ["/mjpeg", "/mjpegstream", "/"]
for path in paths:
try:
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
conn.request("HEAD", "/")
conn.request("GET", path, headers={"Connection": "close"})
resp = conn.getresponse()
_ = resp.read(128)
code = getattr(resp, "status", 0)
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()
return 200 <= code < 400
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
# 其他情况wda 不健康或两者都不健康):先重启 iproxy
LogManager.method_warning(f"检测到不健康触发重启port={port}", method, udid=udid)
self._restart_iproxy(udid)
# ---------------- 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())
# ---------------- 进程枚举(结构化返回) ----------------
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 <udid> 与后续的两个端口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:
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
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)
# 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_warning(f"孤儿 iproxy 已清理udid={udid}, pid={pid}", method, udid=udid)
except (ValueError, IndexError):
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"进程被强制 killpid={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:

View File

@@ -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)

View File

@@ -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 <host> <port>`(支持 IPv6
- 失败再用 `PYMOBILEDEVICE3_TUNNEL=127.0.0.1:<port>` 回退(仅 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=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()
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:
"""
使用进程内 CLIpymobiledevice3 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:
"""
使用进程内 CLIpymobiledevice3 developer dvt launch <bundle> --rsd <host> <port>
"""
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,196 +474,8 @@ 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:

View File

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

View File

@@ -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))
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:
raise ValueError("线程不存在")
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("创建线程成功", "监控消息")
with cls._lock:
# 判断当前设备是否有任务
if cls._tasks.get(udid, None) is not None:
return 1001, "当前设备已存在任务"
thread.start()
print(thread.ident)
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:
cls._cleanup_if_dead(udid)
# 已存在任务还在运行
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
"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:
print(e)
return 200, "操作成功"
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:
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:
failed = []
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)
code, msg = cls.stop(udid)
if code != 200:
failed.append(udid)
if failed:
return 207, f"部分任务未成功停止: {failed}"
return 200, "全部停止成功"