修复掉画面的bug
This commit is contained in:
3
.idea/iOSAI.iml
generated
3
.idea/iOSAI.iml
generated
@@ -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
2
.idea/misc.xml
generated
@@ -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>
|
||||
@@ -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 <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:
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=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 <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,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
|
||||
@@ -49,7 +49,7 @@ def main(arg):
|
||||
if __name__ == "__main__":
|
||||
|
||||
# 获取启动时候传递的参数
|
||||
# main(sys.argv)
|
||||
main(sys.argv)
|
||||
|
||||
# 添加iOS开发包到电脑上
|
||||
deployer = DevDiskImageDeployer(verbose=True)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
failed = []
|
||||
for udid in ids:
|
||||
code, msg = cls.stop(udid)
|
||||
if code != 200:
|
||||
failed.append(udid)
|
||||
if failed:
|
||||
return 207, f"部分任务未成功停止: {failed}"
|
||||
return 200, "全部停止成功"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user