修复掉画面的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="format" value="PLAIN" />
|
||||||
<option name="myDocStringFormat" value="Plain" />
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="TestRunnerService">
|
||||||
|
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
||||||
|
</component>
|
||||||
</module>
|
</module>
|
||||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -3,5 +3,5 @@
|
|||||||
<component name="Black">
|
<component name="Black">
|
||||||
<option name="sdkName" value="Python 3.12 (AI-IOS)" />
|
<option name="sdkName" value="Python 3.12 (AI-IOS)" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (AI-IOS)" project-jdk-type="Python SDK" />
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
|
||||||
</project>
|
</project>
|
||||||
@@ -6,7 +6,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
|
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List, Any
|
||||||
import random
|
import random
|
||||||
import socket
|
import socket
|
||||||
import http.client
|
import http.client
|
||||||
@@ -55,6 +55,15 @@ class DeviceInfo:
|
|||||||
# WDA Ready 等待(HTTP 轮询方式,不触发 xctest)
|
# WDA Ready 等待(HTTP 轮询方式,不触发 xctest)
|
||||||
WDA_READY_TIMEOUT = float(os.getenv("WDA_READY_TIMEOUT", "35.0"))
|
WDA_READY_TIMEOUT = float(os.getenv("WDA_READY_TIMEOUT", "35.0"))
|
||||||
|
|
||||||
|
# WDA 轻量复位策略
|
||||||
|
MJPEG_BAD_THRESHOLD = int(os.getenv("MJPEG_BAD_THRESHOLD", "3")) # 连续几次 mjpeg 健康失败才重置 WDA
|
||||||
|
WDA_RESET_COOLDOWN = float(os.getenv("WDA_RESET_COOLDOWN", "10")) # WDA 复位冷却,避免风暴
|
||||||
|
|
||||||
|
# 防连坐参数(支持环境变量)
|
||||||
|
GLITCH_SUPPRESS_SEC = float(os.getenv("GLITCH_SUPPRESS_SEC", "6.0")) # 扫描异常后抑制移除的秒数
|
||||||
|
MASS_DROP_RATIO = float(os.getenv("MASS_DROP_RATIO", "0.6")) # 一次性丢失占比阈值
|
||||||
|
ABSENT_TICKS_BEFORE_REMOVE = int(os.getenv("ABSENT_TICKS_BEFORE_REMOVE", "3")) # 连续缺席轮数
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# 自增端口游标仅作兜底扫描使用
|
# 自增端口游标仅作兜底扫描使用
|
||||||
self._port = 9110
|
self._port = 9110
|
||||||
@@ -69,7 +78,7 @@ class DeviceInfo:
|
|||||||
|
|
||||||
# 并发保护 & 状态表
|
# 并发保护 & 状态表
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._port_by_udid: Dict[str, int] = {} # UDID -> 当前使用的本地端口
|
self._port_by_udid: Dict[str, int] = {} # UDID -> 当前使用的本地端口(映射 wdaScreenPort)
|
||||||
self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID
|
self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID
|
||||||
|
|
||||||
# 抗抖
|
# 抗抖
|
||||||
@@ -81,6 +90,17 @@ class DeviceInfo:
|
|||||||
self._trusted_cache: Dict[str, float] = {} # udid -> expire_ts
|
self._trusted_cache: Dict[str, float] = {} # udid -> expire_ts
|
||||||
self._wda_ok_cache: Dict[str, float] = {} # udid -> expire_ts
|
self._wda_ok_cache: Dict[str, float] = {} # udid -> expire_ts
|
||||||
|
|
||||||
|
# 新增:MJPEG 连续坏计数 + 最近一次 WDA 复位时间
|
||||||
|
self._mjpeg_bad_count: Dict[str, int] = {}
|
||||||
|
self._last_wda_reset: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# 新增:按 UDID 的 /status 探测单飞锁,避免临时 iproxy 并发
|
||||||
|
self._probe_locks: Dict[str, threading.Lock] = {}
|
||||||
|
|
||||||
|
# 防连坐
|
||||||
|
self._scan_glitch_until = 0.0 # 截止到该时间前,认为扫描不可靠,跳过移除
|
||||||
|
self._absent_ticks: Dict[str, int] = {} # udid -> 连续缺席次数
|
||||||
|
|
||||||
LogManager.info("DeviceInfo init 完成;日志已启用", udid="system")
|
LogManager.info("DeviceInfo init 完成;日志已启用", udid="system")
|
||||||
|
|
||||||
# ---------------- 主循环 ----------------
|
# ---------------- 主循环 ----------------
|
||||||
@@ -108,13 +128,70 @@ class DeviceInfo:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
known = set(self._models.keys())
|
known = set(self._models.keys())
|
||||||
|
|
||||||
# 真正移除(连续缺席超过宽限期)
|
# -------- 全局扫描异常检测(防连坐)--------
|
||||||
|
missing = [u for u in known if u not in online_now]
|
||||||
|
mass_drop = (len(known) > 0) and (
|
||||||
|
(len(online_now) == 0) or
|
||||||
|
(len(missing) / max(1, len(known)) >= self.MASS_DROP_RATIO)
|
||||||
|
)
|
||||||
|
if mass_drop:
|
||||||
|
self._scan_glitch_until = now + self.GLITCH_SUPPRESS_SEC
|
||||||
|
LogManager.method_warning(
|
||||||
|
f"检测到扫描异常:known={len(known)}, online={len(online_now)}, "
|
||||||
|
f"missing={len(missing)},进入抑制窗口 {self.GLITCH_SUPPRESS_SEC}s",
|
||||||
|
method, udid="system"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 真正移除(仅在非抑制窗口内 + 连续缺席达到阈值 才移除)
|
||||||
for udid in list(known):
|
for udid in list(known):
|
||||||
|
if udid in online_now:
|
||||||
|
# 在线:清空缺席计数
|
||||||
|
self._absent_ticks.pop(udid, None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 离线:记录一次缺席
|
||||||
|
miss = self._absent_ticks.get(udid, 0) + 1
|
||||||
|
self._absent_ticks[udid] = miss
|
||||||
|
|
||||||
last = self._last_seen.get(udid, 0.0)
|
last = self._last_seen.get(udid, 0.0)
|
||||||
if udid not in online_now and (now - last) >= self.REMOVE_GRACE_SEC:
|
exceed_grace = (now - last) >= self.REMOVE_GRACE_SEC
|
||||||
LogManager.info(f"设备判定离线(超过宽限期 {self.REMOVE_GRACE_SEC}s),last_seen={last}", udid=udid)
|
exceed_ticks = miss >= self.ABSENT_TICKS_BEFORE_REMOVE
|
||||||
|
|
||||||
|
# 抑制窗口内:跳过任何移除
|
||||||
|
if now < self._scan_glitch_until:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if exceed_grace and exceed_ticks:
|
||||||
|
# --- 移除前的“可达性”反校验 ---
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
model = self._models.get(udid)
|
||||||
|
port = model.screenPort if model else -1
|
||||||
|
reachable = False
|
||||||
|
# 1) ip:port 的 MJPEG 是否还在
|
||||||
|
if port and port > 0 and self._health_check_mjpeg(port, timeout=0.8):
|
||||||
|
reachable = True
|
||||||
|
# 2) WDA /status 是否仍然正常
|
||||||
|
if not reachable and self._health_check_wda(udid):
|
||||||
|
reachable = True
|
||||||
|
|
||||||
|
if reachable:
|
||||||
|
# 误报:续命
|
||||||
|
self._last_seen[udid] = now
|
||||||
|
self._absent_ticks[udid] = 0
|
||||||
|
LogManager.method_info("离线误报:反校验可达,取消移除并续命", method, udid=udid)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
LogManager.method_warning(f"离线反校验异常:{e}", method, udid=udid)
|
||||||
|
|
||||||
|
LogManager.info(
|
||||||
|
f"设备判定离线(超过宽限期 {self.REMOVE_GRACE_SEC}s 且 连续缺席 {self._absent_ticks[udid]} 次)",
|
||||||
|
udid=udid
|
||||||
|
)
|
||||||
self._remove_device(udid)
|
self._remove_device(udid)
|
||||||
self._last_topology_change_ts = now
|
self._last_topology_change_ts = now
|
||||||
|
# 清理计数
|
||||||
|
self._absent_ticks.pop(udid, None)
|
||||||
|
|
||||||
# 真正新增(连续在线超过稳定期)
|
# 真正新增(连续在线超过稳定期)
|
||||||
new_candidates = [u for u in online_now if u not in known]
|
new_candidates = [u for u in online_now if u not in known]
|
||||||
@@ -164,7 +241,7 @@ class DeviceInfo:
|
|||||||
LogManager.method_warning(f"读取系统版本失败:{e}", method, udid=udid)
|
LogManager.method_warning(f"读取系统版本失败:{e}", method, udid=udid)
|
||||||
system_version_major = 0 # 保底
|
system_version_major = 0 # 保底
|
||||||
|
|
||||||
# === iOS>17:先“被动探测”WDA,未运行则交给 IOSActivator,并通过 HTTP 轮询等待 ===
|
# === iOS>17:被动探测 WDA;未运行则交给 IOSActivator,并通过 HTTP 轮询等待 ===
|
||||||
if system_version_major > 17:
|
if system_version_major > 17:
|
||||||
if self._wda_is_running(udid):
|
if self._wda_is_running(udid):
|
||||||
LogManager.method_info("检测到 WDA 已运行,直接映射", method, udid=udid)
|
LogManager.method_info("检测到 WDA 已运行,直接映射", method, udid=udid)
|
||||||
@@ -209,6 +286,8 @@ class DeviceInfo:
|
|||||||
model.ready = True
|
model.ready = True
|
||||||
self._models[udid] = model
|
self._models[udid] = model
|
||||||
self._procs[udid] = proc
|
self._procs[udid] = proc
|
||||||
|
# 初始化计数
|
||||||
|
self._mjpeg_bad_count[udid] = 0
|
||||||
|
|
||||||
LogManager.method_info(f"设备添加完成,port={port}, {w}x{h}@{s}", method, udid=udid)
|
LogManager.method_info(f"设备添加完成,port={port}, {w}x{h}@{s}", method, udid=udid)
|
||||||
self._manager_send(model)
|
self._manager_send(model)
|
||||||
@@ -227,6 +306,9 @@ class DeviceInfo:
|
|||||||
self._wda_ok_cache.pop(udid, None)
|
self._wda_ok_cache.pop(udid, None)
|
||||||
self._last_seen.pop(udid, None)
|
self._last_seen.pop(udid, None)
|
||||||
self._first_seen.pop(udid, None)
|
self._first_seen.pop(udid, None)
|
||||||
|
self._mjpeg_bad_count.pop(udid, None)
|
||||||
|
self._last_wda_reset.pop(udid, None)
|
||||||
|
self._absent_ticks.pop(udid, None)
|
||||||
|
|
||||||
self._kill(proc)
|
self._kill(proc)
|
||||||
if pid:
|
if pid:
|
||||||
@@ -266,52 +348,96 @@ class DeviceInfo:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# ======= WDA 探测/等待(仅走 iproxy+HTTP,不触发 xctest) =======
|
# ======= WDA 探测/等待(仅走 iproxy+HTTP,不触发 xctest) =======
|
||||||
def _wda_http_status_ok(self, udid: str, timeout_sec: float = 1.2) -> bool:
|
def _get_probe_lock(self, udid: str) -> threading.Lock:
|
||||||
"""临时 iproxy 转发到 wdaFunctionPort,GET /status 成功视为 OK。"""
|
with self._lock:
|
||||||
method = "_wda_http_status_ok"
|
lk = self._probe_locks.get(udid)
|
||||||
tmp_port = self._pick_new_port()
|
if lk is None:
|
||||||
proc = None
|
lk = threading.Lock()
|
||||||
try:
|
self._probe_locks[udid] = lk
|
||||||
cmd = [self._iproxy_path, "-u", udid, str(tmp_port), str(wdaFunctionPort)]
|
return lk
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
if not self._wait_until_listening(tmp_port, initial_timeout=0.8):
|
|
||||||
LogManager.method_info(f"WDA探测:临时端口未监听({tmp_port})", method, udid=udid)
|
|
||||||
return False
|
|
||||||
|
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", tmp_port, timeout=timeout_sec)
|
def _wda_http_status_ok(self, udid: str, timeout_sec: float = 1.2) -> bool:
|
||||||
|
"""起临时 iproxy 到 wdaFunctionPort,探测 /status。增加单飞锁与严格清理。"""
|
||||||
|
method = "_wda_http_status_ok"
|
||||||
|
lock = self._get_probe_lock(udid)
|
||||||
|
if not lock.acquire(timeout=3.0):
|
||||||
|
# 有并发探测在进行,避免同时起多个 iproxy;直接返回“未知→False”
|
||||||
|
LogManager.method_info("状态探测被并发锁抑制", method, udid=udid)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
tmp_port = self._pick_new_port()
|
||||||
|
proc = None
|
||||||
try:
|
try:
|
||||||
conn.request("GET", "/status")
|
cmd = [self._iproxy_path, "-u", udid, str(tmp_port), str(wdaFunctionPort)]
|
||||||
resp = conn.getresponse()
|
|
||||||
_ = resp.read(256)
|
# --- Windows 下隐藏 iproxy 控制台 ---
|
||||||
code = getattr(resp, "status", 0)
|
creationflags = 0
|
||||||
ok = 200 <= code < 400
|
startupinfo = None
|
||||||
LogManager.method_info(f"WDA探测:/status code={code}, ok={ok}", method, udid=udid)
|
if os.name == "nt":
|
||||||
return ok
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | \
|
||||||
except Exception as e:
|
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||||
LogManager.method_info(f"WDA探测异常:{e}", method, udid=udid)
|
si = subprocess.STARTUPINFO()
|
||||||
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
si.wShowWindow = 0
|
||||||
|
startupinfo = si
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
creationflags=creationflags,
|
||||||
|
startupinfo=startupinfo
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._wait_until_listening(tmp_port, initial_timeout=1.0):
|
||||||
|
LogManager.method_info(f"WDA探测:临时端口未监听({tmp_port})", method, udid=udid)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# /status 双重尝试,减少瞬态抖动
|
||||||
|
for _ in (1, 2):
|
||||||
|
try:
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", tmp_port, timeout=timeout_sec)
|
||||||
|
conn.request("GET", "/status")
|
||||||
|
resp = conn.getresponse()
|
||||||
|
_ = resp.read(256)
|
||||||
|
code = getattr(resp, "status", 0)
|
||||||
|
ok = 200 <= code < 400
|
||||||
|
LogManager.method_info(f"WDA探测:/status code={code}, ok={ok}", method, udid=udid)
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception as e:
|
||||||
|
LogManager.method_info(f"WDA探测异常:{e}", method, udid=udid)
|
||||||
|
time.sleep(0.2)
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
try:
|
if proc:
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
if proc:
|
|
||||||
try:
|
|
||||||
p = psutil.Process(proc.pid)
|
|
||||||
p.terminate()
|
|
||||||
p.wait(timeout=0.6)
|
|
||||||
except Exception:
|
|
||||||
try:
|
try:
|
||||||
p.kill()
|
p = psutil.Process(proc.pid)
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(timeout=1.2)
|
||||||
|
except psutil.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
p.wait(timeout=1.2)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
# 兜底强杀
|
||||||
|
try:
|
||||||
|
os.kill(proc.pid, signal.SIGTERM)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
lock.release()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _wait_wda_ready_http(self, udid: str, total_timeout_sec: float = None, interval_sec: float = 0.6) -> bool:
|
def _wait_wda_ready_http(self, udid: str, total_timeout_sec: float = None, interval_sec: float = 0.6) -> bool:
|
||||||
"""
|
"""通过 _wda_http_status_ok 轮询等待 WDA Ready。"""
|
||||||
通过 _wda_http_status_ok 轮询等待 WDA Ready。
|
|
||||||
total_timeout_sec 默认取环境变量 WDA_READY_TIMEOUT(默认 35s)。
|
|
||||||
"""
|
|
||||||
method = "_wait_wda_ready_http"
|
method = "_wait_wda_ready_http"
|
||||||
if total_timeout_sec is None:
|
if total_timeout_sec is None:
|
||||||
total_timeout_sec = self.WDA_READY_TIMEOUT
|
total_timeout_sec = self.WDA_READY_TIMEOUT
|
||||||
@@ -387,11 +513,11 @@ class DeviceInfo:
|
|||||||
deadline = _monotonic() + to
|
deadline = _monotonic() + to
|
||||||
while _monotonic() < deadline:
|
while _monotonic() < deadline:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.settimeout(0.2)
|
s.settimeout(0.25)
|
||||||
if s.connect_ex(("127.0.0.1", port)) == 0:
|
if s.connect_ex(("127.0.0.1", port)) == 0:
|
||||||
LogManager.method_info(f"端口已开始监听:{port}", method, udid="system")
|
LogManager.method_info(f"端口已开始监听:{port}", method, udid="system")
|
||||||
return True
|
return True
|
||||||
time.sleep(0.05)
|
time.sleep(0.06)
|
||||||
LogManager.method_info(f"监听验收阶段超时:{port},扩展等待", method, udid="system")
|
LogManager.method_info(f"监听验收阶段超时:{port},扩展等待", method, udid="system")
|
||||||
LogManager.method_warning(f"监听验收最终超时:{port}", method, udid="system")
|
LogManager.method_warning(f"监听验收最终超时:{port}", method, udid="system")
|
||||||
return False
|
return False
|
||||||
@@ -490,6 +616,7 @@ class DeviceInfo:
|
|||||||
LogManager.method_info(f"自愈被退避抑制,剩余 {delta}s", method, udid=udid)
|
LogManager.method_info(f"自愈被退避抑制,剩余 {delta}s", method, udid=udid)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
old_port = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
proc = self._procs.get(udid)
|
proc = self._procs.get(udid)
|
||||||
if proc:
|
if proc:
|
||||||
@@ -499,6 +626,7 @@ class DeviceInfo:
|
|||||||
if not model:
|
if not model:
|
||||||
LogManager.method_warning("模型不存在,取消自愈", method, udid=udid)
|
LogManager.method_warning("模型不存在,取消自愈", method, udid=udid)
|
||||||
return
|
return
|
||||||
|
old_port = model.screenPort
|
||||||
|
|
||||||
proc2 = self._start_iproxy(udid, port=None)
|
proc2 = self._start_iproxy(udid, port=None)
|
||||||
if not proc2:
|
if not proc2:
|
||||||
@@ -518,26 +646,78 @@ class DeviceInfo:
|
|||||||
model.screenPort = self._port_by_udid.get(udid, model.screenPort)
|
model.screenPort = self._port_by_udid.get(udid, model.screenPort)
|
||||||
self._models[udid] = model
|
self._models[udid] = model
|
||||||
self._manager_send(model)
|
self._manager_send(model)
|
||||||
|
LogManager.method_info(f"[PORT-SWITCH] {udid} {old_port} -> {self._port_by_udid.get(udid)}", method, udid=udid)
|
||||||
LogManager.method_info(f"重启成功,使用新端口 {self._port_by_udid.get(udid)}", method, udid=udid)
|
LogManager.method_info(f"重启成功,使用新端口 {self._port_by_udid.get(udid)}", method, udid=udid)
|
||||||
|
|
||||||
# ---------------- 健康检查 ----------------
|
# ---------------- 健康检查 ----------------
|
||||||
def _health_check_mjpeg(self, port: int, timeout: float = 0.8) -> bool:
|
def _health_check_mjpeg(self, port: int, timeout: float = 1.8) -> bool:
|
||||||
|
"""使用 GET 真实探测 MJPEG:校验 Content-Type 和 boundary。尝试 /mjpeg -> /mjpegstream -> /"""
|
||||||
method = "_health_check_mjpeg"
|
method = "_health_check_mjpeg"
|
||||||
try:
|
paths = ["/mjpeg", "/mjpegstream", "/"]
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
|
for path in paths:
|
||||||
conn.request("HEAD", "/")
|
try:
|
||||||
resp = conn.getresponse()
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
|
||||||
_ = resp.read(128)
|
conn.request("GET", path, headers={"Connection": "close"})
|
||||||
code = getattr(resp, "status", 0)
|
resp = conn.getresponse()
|
||||||
conn.close()
|
ctype = (resp.getheader("Content-Type") or "").lower()
|
||||||
return 200 <= code < 400
|
ok_hdr = (200 <= resp.status < 300) and ("multipart/x-mixed-replace" in ctype)
|
||||||
except Exception:
|
# 仅读少量字节,不阻塞
|
||||||
return False
|
chunk = resp.read(1024)
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if ok_hdr and (b"--" in chunk):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
def _health_check_wda(self, udid: str) -> bool:
|
def _health_check_wda(self, udid: str) -> bool:
|
||||||
# 使用 HTTP 探测(带短缓存),避免触发 xctest
|
"""使用 HTTP 探测(带短缓存),避免触发 xctest。"""
|
||||||
|
# 加一次重试,减少瞬态波动
|
||||||
|
if self._wda_is_running(udid, cache_sec=1.0):
|
||||||
|
return True
|
||||||
|
time.sleep(0.2)
|
||||||
return self._wda_is_running(udid, cache_sec=1.0)
|
return self._wda_is_running(udid, cache_sec=1.0)
|
||||||
|
|
||||||
|
def _maybe_reset_wda_lightweight(self, udid: str) -> bool:
|
||||||
|
"""在 MJPEG 多次异常但 /status 正常时,做 WDA 轻量复位。成功返回 True。"""
|
||||||
|
method = "_maybe_reset_wda_lightweight"
|
||||||
|
now = _monotonic()
|
||||||
|
last = self._last_wda_reset.get(udid, 0.0)
|
||||||
|
if now - last < self.WDA_RESET_COOLDOWN:
|
||||||
|
return False
|
||||||
|
|
||||||
|
LogManager.method_warning("MJPEG 连续异常,尝试 WDA 轻量复位", method, udid=udid)
|
||||||
|
try:
|
||||||
|
dev = tidevice.Device(udid)
|
||||||
|
# 先尝试 stop/start
|
||||||
|
try:
|
||||||
|
dev.app_stop(WdaAppBundleId)
|
||||||
|
time.sleep(1.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
dev.app_start(WdaAppBundleId)
|
||||||
|
# 等待就绪(缩短等待)
|
||||||
|
if self._wait_wda_ready_http(udid, total_timeout_sec=12.0):
|
||||||
|
self._last_wda_reset[udid] = _monotonic()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
LogManager.method_warning(f"WDA stop/start 失败:{e}", method, udid=udid)
|
||||||
|
|
||||||
|
# 兜底:iOS18+ 用 IOSActivator 再尝试
|
||||||
|
try:
|
||||||
|
ios = IOSActivator()
|
||||||
|
ios.activate(udid)
|
||||||
|
if self._wait_wda_ready_http(udid, total_timeout_sec=12.0):
|
||||||
|
self._last_wda_reset[udid] = _monotonic()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
LogManager.method_warning(f"IOSActivator 复位失败:{e}", method, udid=udid)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def _check_and_heal_tunnels(self, interval: float = 5.0):
|
def _check_and_heal_tunnels(self, interval: float = 5.0):
|
||||||
method = "_check_and_heal_tunnels"
|
method = "_check_and_heal_tunnels"
|
||||||
now = _monotonic()
|
now = _monotonic()
|
||||||
@@ -557,21 +737,41 @@ class DeviceInfo:
|
|||||||
if port <= 0:
|
if port <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ok_local = self._health_check_mjpeg(port, timeout=0.8)
|
ok_local = self._health_check_mjpeg(port, timeout=1.8)
|
||||||
ok_wda = self._health_check_wda(udid)
|
ok_wda = self._health_check_wda(udid)
|
||||||
LogManager.method_info(f"健康检查:mjpeg={ok_local}, wda={ok_wda}, port={port}", method, udid=udid)
|
LogManager.method_info(f"健康检查:mjpeg={ok_local}, wda={ok_wda}, port={port}", method, udid=udid)
|
||||||
|
|
||||||
if not (ok_local and ok_wda):
|
if ok_local and ok_wda:
|
||||||
|
self._mjpeg_bad_count[udid] = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 分层自愈:MJPEG 连续异常而 WDA 正常 → 优先复位 WDA
|
||||||
|
if (not ok_local) and ok_wda:
|
||||||
|
cnt = self._mjpeg_bad_count.get(udid, 0) + 1
|
||||||
|
self._mjpeg_bad_count[udid] = cnt
|
||||||
|
if cnt >= self.MJPEG_BAD_THRESHOLD:
|
||||||
|
if self._maybe_reset_wda_lightweight(udid):
|
||||||
|
# 复位成功后重启 iproxy,确保新流映射
|
||||||
|
self._restart_iproxy(udid)
|
||||||
|
self._mjpeg_bad_count[udid] = 0
|
||||||
|
continue # 下一个设备
|
||||||
|
# 若未达门槛或复位失败,仍执行 iproxy 重启
|
||||||
LogManager.method_warning(f"检测到不健康,触发重启;port={port}", method, udid=udid)
|
LogManager.method_warning(f"检测到不健康,触发重启;port={port}", method, udid=udid)
|
||||||
self._restart_iproxy(udid)
|
self._restart_iproxy(udid)
|
||||||
|
continue
|
||||||
|
|
||||||
# ---------------- Windows/*nix:列出所有 iproxy 命令行 ----------------
|
# 其他情况(wda 不健康或两者都不健康):先重启 iproxy
|
||||||
def _get_all_iproxy_cmdlines(self) -> List[str]:
|
LogManager.method_warning(f"检测到不健康,触发重启;port={port}", method, udid=udid)
|
||||||
method = "_get_all_iproxy_cmdlines"
|
self._restart_iproxy(udid)
|
||||||
lines: List[str] = []
|
|
||||||
with self._lock:
|
|
||||||
live_pids = set(self._pid_by_udid.values())
|
|
||||||
|
|
||||||
|
# ---------------- 进程枚举(结构化返回) ----------------
|
||||||
|
def _get_all_iproxy_entries(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
返回结构化 iproxy 进程项:
|
||||||
|
{ 'pid': int, 'name': str, 'cmdline': List[str], 'udid': str|None, 'local_port': int|None, 'remote_port': int|None }
|
||||||
|
"""
|
||||||
|
method = "_get_all_iproxy_entries"
|
||||||
|
entries: List[Dict[str, Any]] = []
|
||||||
is_windows = os.name == "nt"
|
is_windows = os.name == "nt"
|
||||||
target_name = "iproxy.exe" if is_windows else "iproxy"
|
target_name = "iproxy.exe" if is_windows else "iproxy"
|
||||||
|
|
||||||
@@ -580,58 +780,106 @@ class DeviceInfo:
|
|||||||
name = (p.info.get("name") or "").lower()
|
name = (p.info.get("name") or "").lower()
|
||||||
if name != target_name:
|
if name != target_name:
|
||||||
continue
|
continue
|
||||||
if p.info["pid"] in live_pids:
|
|
||||||
continue
|
|
||||||
cmdline = p.info.get("cmdline") or []
|
cmdline = p.info.get("cmdline") or []
|
||||||
if not cmdline:
|
if not cmdline:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
udid = None
|
||||||
|
local_port = None
|
||||||
|
remote_port = None
|
||||||
|
|
||||||
|
# 解析 -u <udid> 与后续的两个端口(LOCAL_PORT, REMOTE_PORT)
|
||||||
if "-u" in cmdline:
|
if "-u" in cmdline:
|
||||||
cmd = " ".join(cmdline)
|
try:
|
||||||
lines.append(f"{cmd} {p.info['pid']}")
|
i = cmdline.index("-u")
|
||||||
|
if i + 1 < len(cmdline):
|
||||||
|
udid = cmdline[i + 1]
|
||||||
|
# 在 -u udid 之后扫描数字端口
|
||||||
|
ints = []
|
||||||
|
for token in cmdline[i + 2:]:
|
||||||
|
if token.isdigit():
|
||||||
|
ints.append(int(token))
|
||||||
|
# 停止条件:拿到两个
|
||||||
|
if len(ints) >= 2:
|
||||||
|
break
|
||||||
|
if len(ints) >= 2:
|
||||||
|
local_port, remote_port = ints[0], ints[1]
|
||||||
|
else:
|
||||||
|
# 兜底:全局找两个数字
|
||||||
|
ints2 = [int(t) for t in cmdline if t.isdigit()]
|
||||||
|
if len(ints2) >= 2:
|
||||||
|
local_port, remote_port = ints2[-2], ints2[-1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
"pid": p.info["pid"],
|
||||||
|
"name": name,
|
||||||
|
"cmdline": cmdline,
|
||||||
|
"udid": udid,
|
||||||
|
"local_port": local_port,
|
||||||
|
"remote_port": remote_port
|
||||||
|
})
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
continue
|
continue
|
||||||
LogManager.method_info(f"扫描到候选 iproxy 进程数={len(lines)}", method, udid="system")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
# ---------------- 杀孤儿 ----------------
|
LogManager.method_info(f"扫描到候选 iproxy 进程数={len(entries)}", method, udid="system")
|
||||||
|
return entries
|
||||||
|
|
||||||
|
# ---------------- 杀孤儿(含“同 UDID 的非当前实例”清理) ----------------
|
||||||
def _cleanup_orphan_iproxy(self):
|
def _cleanup_orphan_iproxy(self):
|
||||||
method = "_cleanup_orphan_iproxy"
|
method = "_cleanup_orphan_iproxy"
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
live_udids = set(self._models.keys())
|
live_udids = set(self._models.keys())
|
||||||
live_pids = set(self._pid_by_udid.values())
|
live_pid_by_udid = dict(self._pid_by_udid)
|
||||||
|
live_port_by_udid = dict(self._port_by_udid)
|
||||||
|
|
||||||
cleaned = 0
|
cleaned = 0
|
||||||
for ln in self._get_all_iproxy_cmdlines():
|
for ent in self._get_all_iproxy_entries():
|
||||||
parts = ln.split()
|
pid = ent["pid"]
|
||||||
try:
|
udid = ent.get("udid")
|
||||||
if "-u" not in parts:
|
local_port = ent.get("local_port")
|
||||||
continue
|
# 完全不认识的进程(无法解析 udid),跳过
|
||||||
udid = parts[parts.index('-u') + 1]
|
if not udid:
|
||||||
pid = int(parts[-1])
|
|
||||||
if pid not in live_pids and udid not in live_udids:
|
|
||||||
self._kill_pid_gracefully(pid)
|
|
||||||
cleaned += 1
|
|
||||||
LogManager.method_warning(f"孤儿 iproxy 已清理:udid={udid}, pid={pid}", method, udid=udid)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 1) 完全孤儿:udid 不在活跃设备集,且 pid 不是任何已跟踪 pid → 杀
|
||||||
|
if udid not in live_udids and pid not in live_pid_by_udid.values():
|
||||||
|
self._kill_pid_gracefully(pid, silent=True)
|
||||||
|
cleaned += 1
|
||||||
|
LogManager.method_info(f"孤儿 iproxy 已清理:udid={udid}, pid={pid}", method)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2) 同 UDID 的非当前实例:udid 活跃,但 pid != 当前 pid,且本地端口也不是当前端口 → 杀
|
||||||
|
live_pid = live_pid_by_udid.get(udid)
|
||||||
|
live_port = live_port_by_udid.get(udid)
|
||||||
|
if udid in live_udids and pid != live_pid:
|
||||||
|
if (local_port is None) or (live_port is None) or (local_port != live_port):
|
||||||
|
self._kill_pid_gracefully(pid, silent=True)
|
||||||
|
cleaned += 1
|
||||||
|
LogManager.method_info(f"清理同UDID旧实例:udid={udid}, pid={pid}, local_port={local_port}", method)
|
||||||
|
|
||||||
if cleaned:
|
if cleaned:
|
||||||
LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method, udid="system")
|
LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method)
|
||||||
|
|
||||||
# ---------------- 按 PID 强杀 ----------------
|
# ---------------- 按 PID 强杀 ----------------
|
||||||
def _kill_pid_gracefully(self, pid: int):
|
def _kill_pid_gracefully(self, pid: int, silent: bool = False):
|
||||||
method = "_kill_pid_gracefully"
|
"""优雅地结束进程,不弹出cmd窗口"""
|
||||||
try:
|
try:
|
||||||
p = psutil.Process(pid)
|
if platform.system() == "Windows":
|
||||||
p.terminate()
|
# 不弹窗方式
|
||||||
try:
|
subprocess.run(
|
||||||
p.wait(timeout=1.0)
|
["taskkill", "/PID", str(pid), "/F", "/T"],
|
||||||
LogManager.method_info(f"进程已终止:pid={pid}", method, udid="system")
|
stdout=subprocess.DEVNULL if silent else None,
|
||||||
except psutil.TimeoutExpired:
|
stderr=subprocess.DEVNULL if silent else None,
|
||||||
p.kill()
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
|
||||||
LogManager.method_warning(f"进程被强制 kill:pid={pid}", method, udid="system")
|
)
|
||||||
|
else:
|
||||||
|
# Linux / macOS
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LogManager.method_warning(f"kill 进程异常:pid={pid}, err={e}", method, udid="system")
|
LogManager.method_error(f"结束进程 {pid} 失败: {e}", "_kill_pid_gracefully")
|
||||||
|
|
||||||
# ---------------- 端口工具(兜底) ----------------
|
# ---------------- 端口工具(兜底) ----------------
|
||||||
def _pick_free_port(self, start: int = None, limit: int = 2000) -> int:
|
def _pick_free_port(self, start: int = None, limit: int = 2000) -> int:
|
||||||
|
|||||||
@@ -716,6 +716,11 @@ def test():
|
|||||||
# 关闭会话
|
# 关闭会话
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
# 获取ai配置
|
||||||
|
@app.route("/getAiConfig", methods=['GET'])
|
||||||
|
def getAiConfig():
|
||||||
|
data = IOSAIStorage.load("aiConfig.json")
|
||||||
|
return ResultData(data=data).toJson()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run("0.0.0.0", port=5000, debug=True, use_reloader=False)
|
app.run("0.0.0.0", port=5000, debug=True, use_reloader=False)
|
||||||
|
|||||||
@@ -1,32 +1,307 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import atexit
|
||||||
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional
|
from typing import Optional, List, Tuple, Dict, Set
|
||||||
|
|
||||||
from Entity.Variables import WdaAppBundleId
|
from Entity.Variables import WdaAppBundleId
|
||||||
|
|
||||||
|
|
||||||
class IOSActivator:
|
class IOSActivator:
|
||||||
"""
|
"""
|
||||||
轻量 iOS 激活器(仅代码调用) - 打包安全版
|
轻量 iOS 激活器(仅代码调用) - 进程/网卡可控版
|
||||||
1) 启动 `pymobiledevice3 remote tunneld`(子进程常驻)
|
1) 启动 `pymobiledevice3 remote tunneld`(可常驻/可一次性)
|
||||||
2) 自动挂载 Developer Disk Image(进程内调用 CLI)
|
2) 自动挂载 DDI
|
||||||
3) 设备隧道就绪后启动 WDA(进程内调用 CLI):
|
3) 隧道就绪后启动 WDA
|
||||||
- 优先使用 `--rsd <host> <port>`(支持 IPv6)
|
4) 程序退出或 keep_tunnel=False 时,确保 tunneld 进程与虚拟网卡被清理
|
||||||
- 失败再用 `PYMOBILEDEVICE3_TUNNEL=127.0.0.1:<port>` 回退(仅 IPv4)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, python_executable: Optional[str] = None):
|
# ---------- 正则 ----------
|
||||||
# 仅用于旧逻辑兼容;本版本已不依赖 self.python 去 -m 调 CLI
|
HTTP_RE = re.compile(r"http://([0-9.]+):(\d+)")
|
||||||
self.python = python_executable or None
|
RSD_CREATED_RE = re.compile(r"Created tunnel\s+--rsd\s+([^\s]+)\s+(\d+)")
|
||||||
|
RSD_FALLBACK_RE = re.compile(r"--rsd\s+(\S+?)[\s:](\d+)")
|
||||||
|
IFACE_RE = re.compile(r"\b(pymobiledevice3-tunnel-[^\s/\\]+)\b", re.IGNORECASE)
|
||||||
|
|
||||||
# =========================================================================
|
def __init__(self, python_executable: Optional[str] = None):
|
||||||
# 内部工具:进程内调用 pymobiledevice3 CLI
|
self.python = python_executable or None
|
||||||
# =========================================================================
|
self._live_procs: Dict[str, subprocess.Popen] = {} # udid -> tunneld proc
|
||||||
def _pmd3_run(self, args: list[str], udid: str, extra_env: Optional[dict] = None) -> str:
|
self._live_ifaces: Dict[str, Set[str]] = {} # udid -> {iface names}
|
||||||
import subprocess
|
self._registered = False
|
||||||
|
|
||||||
|
# =============== 公共入口 ===============
|
||||||
|
def activate(
|
||||||
|
self,
|
||||||
|
udid: str,
|
||||||
|
wda_bundle_id: Optional[str] = WdaAppBundleId,
|
||||||
|
ready_timeout_sec: float = 120.0,
|
||||||
|
mount_retries: int = 3,
|
||||||
|
backoff_seconds: float = 2.0,
|
||||||
|
rsd_probe_retries: int = 5,
|
||||||
|
rsd_probe_delay_sec: float = 3.0,
|
||||||
|
pre_mount_first: bool = True,
|
||||||
|
keep_tunnel: bool = False, # << 新增:默认 False,一次性用完就关
|
||||||
|
broad_cleanup_on_exit: bool = True, # << 退出时顺带清理所有 pmd3 残留网卡
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
执行:挂镜像(可选) -> 开隧道 -> (等待 RSD 就绪)-> 启动 WDA
|
||||||
|
- 默认 keep_tunnel=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()
|
launcher, env = self._resolve_pmd3_argv_and_env()
|
||||||
env["PYMOBILEDEVICE3_UDID"] = udid
|
env["PYMOBILEDEVICE3_UDID"] = udid
|
||||||
env["PYTHONUNBUFFERED"] = "1"
|
env["PYTHONUNBUFFERED"] = "1"
|
||||||
@@ -50,92 +325,61 @@ class IOSActivator:
|
|||||||
except subprocess.CalledProcessError as exc:
|
except subprocess.CalledProcessError as exc:
|
||||||
raise RuntimeError(exc.output or f"pymobiledevice3 执行失败,退出码 {exc.returncode}")
|
raise RuntimeError(exc.output or f"pymobiledevice3 执行失败,退出码 {exc.returncode}")
|
||||||
|
|
||||||
def _pmd3_run_subprocess(self, full_args: list[str], extra_env: Optional[dict] = None) -> str:
|
def _ensure_str_list(self, seq):
|
||||||
"""
|
return [str(x) for x in seq]
|
||||||
兜底:通过子进程执行 pymobiledevice3(使用 _resolve_pmd3_argv_and_env())。
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
launcher, env = self._resolve_pmd3_argv_and_env()
|
def _win_hidden_popen_kwargs(self):
|
||||||
if extra_env:
|
if os.name != "nt":
|
||||||
for k, v in extra_env.items():
|
return {}
|
||||||
if v is None:
|
si = subprocess.STARTUPINFO()
|
||||||
env.pop(k, None)
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
else:
|
si.wShowWindow = 0 # SW_HIDE
|
||||||
env[k] = str(v)
|
return {
|
||||||
|
"startupinfo": si,
|
||||||
|
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
|
||||||
|
}
|
||||||
|
|
||||||
cmd = [*launcher, *full_args]
|
|
||||||
cmd = self._ensure_str_list(cmd)
|
|
||||||
print("[pmd3-subproc]", " ".join(cmd))
|
|
||||||
try:
|
|
||||||
out = subprocess.check_output(
|
|
||||||
cmd,
|
|
||||||
text=True,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
env=env,
|
|
||||||
**self._win_hidden_popen_kwargs()
|
|
||||||
)
|
|
||||||
return out or ""
|
|
||||||
except subprocess.CalledProcessError as exc:
|
|
||||||
raise RuntimeError(exc.output or f"pymobiledevice3 子进程执行失败,代码 {exc.returncode}")
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# 旧版环境依赖的替代方案:仅用于启动 tunneld 的子进程(需要常驻)
|
|
||||||
# =========================================================================
|
|
||||||
def _resolve_pmd3_argv_and_env(self):
|
def _resolve_pmd3_argv_and_env(self):
|
||||||
import os, sys, shutil, subprocess
|
import shutil, subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["PYTHONUNBUFFERED"] = "1"
|
env["PYTHONUNBUFFERED"] = "1"
|
||||||
env.setdefault("PYTHONIOENCODING", "utf-8")
|
env.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
|
|
||||||
# 1) 明确使用 IOSAI_PYTHON(如果上层已设置)
|
|
||||||
prefer_py = env.get("IOSAI_PYTHON")
|
prefer_py = env.get("IOSAI_PYTHON")
|
||||||
|
|
||||||
# 2) 构造一批候选路径(先根目录,再 Scripts)
|
|
||||||
base = Path(sys.argv[0]).resolve()
|
base = Path(sys.argv[0]).resolve()
|
||||||
base_dir = base.parent if base.is_file() else base
|
base_dir = base.parent if base.is_file() else base
|
||||||
py_name = "python.exe" if os.name == "nt" else "python"
|
py_name = "python.exe" if os.name == "nt" else "python"
|
||||||
|
|
||||||
sidecar_candidates = [
|
sidecar_candidates = [
|
||||||
base_dir / "python-rt" / py_name, # ✅ 嵌入式/你现在的摆放方式
|
base_dir / "python-rt" / py_name,
|
||||||
base_dir / "python-rt" / "Scripts" / py_name, # 兼容虚拟环境式
|
base_dir / "python-rt" / "Scripts" / py_name,
|
||||||
base_dir.parent / "python-rt" / py_name,
|
base_dir.parent / "python-rt" / py_name,
|
||||||
base_dir.parent / "python-rt" / "Scripts" / py_name,
|
base_dir.parent / "python-rt" / "Scripts" / py_name,
|
||||||
]
|
]
|
||||||
|
|
||||||
# 若外层已通过 IOSAI_PYTHON 指了路径,也放进候选
|
|
||||||
if prefer_py:
|
if prefer_py:
|
||||||
sidecar_candidates.insert(0, Path(prefer_py))
|
sidecar_candidates.insert(0, Path(prefer_py))
|
||||||
|
|
||||||
# 打印探测
|
|
||||||
for cand in sidecar_candidates:
|
for cand in sidecar_candidates:
|
||||||
print(f"[IOSAI] 🔎 probing sidecar at: {cand}")
|
print(f"[IOSAI] 🔎 probing sidecar at: {cand}")
|
||||||
if cand.is_file():
|
if cand.is_file():
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
[str(cand), "-c", "import pymobiledevice3;print('ok')"],
|
[str(cand), "-c", "import pymobiledevice3;print('ok')"],
|
||||||
text=True,
|
text=True, stderr=subprocess.STDOUT, env=env, timeout=6, **self._win_hidden_popen_kwargs()
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
env=env,
|
|
||||||
timeout=6,
|
|
||||||
**self._win_hidden_popen_kwargs()
|
|
||||||
)
|
)
|
||||||
if "ok" in out:
|
if "ok" in out:
|
||||||
print(f"[IOSAI] ✅ sidecar selected: {cand}")
|
print(f"[IOSAI] ✅ sidecar selected: {cand}")
|
||||||
return ([str(cand), "-u", "-m", "pymobiledevice3"], env)
|
return ([str(cand), "-u", "-m", "pymobiledevice3"], env)
|
||||||
except Exception:
|
except Exception:
|
||||||
# 候选解释器存在但没装 pmd3,就继续找下一个
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3) 回退:系统 PATH 里直接找 pymobiledevice3 可执行
|
|
||||||
exe = shutil.which("pymobiledevice3")
|
exe = shutil.which("pymobiledevice3")
|
||||||
if exe:
|
if exe:
|
||||||
print(f"[IOSAI] ✅ use PATH executable: {exe}")
|
print(f"[IOSAI] ✅ use PATH executable: {exe}")
|
||||||
return ([exe], env)
|
return ([exe], env)
|
||||||
|
|
||||||
# 4) 兜底:从系统 Python 里找装了 pmd3 的解释器
|
|
||||||
py_candidates = []
|
py_candidates = []
|
||||||
base_exec = getattr(sys, "_base_executable", None)
|
base_exec = getattr(sys, "_base_executable", None)
|
||||||
if base_exec and os.path.isfile(base_exec):
|
if base_exec and os.path.isfile(base_exec):
|
||||||
@@ -150,11 +394,7 @@ class IOSActivator:
|
|||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
[py, "-c", "import pymobiledevice3;print('ok')"],
|
[py, "-c", "import pymobiledevice3;print('ok')"],
|
||||||
text=True,
|
text=True, stderr=subprocess.STDOUT, env=env, timeout=6, **self._win_hidden_popen_kwargs()
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
env=env,
|
|
||||||
timeout=6,
|
|
||||||
**self._win_hidden_popen_kwargs()
|
|
||||||
)
|
)
|
||||||
if "ok" in out:
|
if "ok" in out:
|
||||||
print(f"[IOSAI] ✅ system python selected: {py}")
|
print(f"[IOSAI] ✅ system python selected: {py}")
|
||||||
@@ -164,31 +404,10 @@ class IOSActivator:
|
|||||||
|
|
||||||
raise RuntimeError("未检测到可用的 pymobiledevice3(建议携带 python-rt 或安装系统 Python+pmd3)。")
|
raise RuntimeError("未检测到可用的 pymobiledevice3(建议携带 python-rt 或安装系统 Python+pmd3)。")
|
||||||
|
|
||||||
def _ensure_str_list(self, seq):
|
# -------- DDI / RSD / 启动 WDA (与你原逻辑一致) --------
|
||||||
return [str(x) for x in seq]
|
|
||||||
|
|
||||||
def _win_hidden_popen_kwargs(self):
|
|
||||||
"""在 Windows 上隐藏子进程窗口;非 Windows 返回空参数。"""
|
|
||||||
if os.name != "nt":
|
|
||||||
return {}
|
|
||||||
import subprocess as _sp
|
|
||||||
si = _sp.STARTUPINFO()
|
|
||||||
si.dwFlags |= _sp.STARTF_USESHOWWINDOW
|
|
||||||
si.wShowWindow = 0 # SW_HIDE
|
|
||||||
return {
|
|
||||||
"startupinfo": si,
|
|
||||||
"creationflags": getattr(_sp, "CREATE_NO_WINDOW", 0x08000000),
|
|
||||||
}
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# 功能函数-
|
|
||||||
# =========================================================================
|
|
||||||
def _auto_mount_developer_disk(self, udid: str, retries: int = 3, backoff_seconds: float = 2.0) -> None:
|
def _auto_mount_developer_disk(self, udid: str, retries: int = 3, backoff_seconds: float = 2.0) -> None:
|
||||||
"""
|
import time as _t
|
||||||
使用进程内 CLI:pymobiledevice3 mounter auto-mount(带重试)
|
last_err = ""
|
||||||
"""
|
|
||||||
import time
|
|
||||||
last_err_text = ""
|
|
||||||
for i in range(max(1, retries)):
|
for i in range(max(1, retries)):
|
||||||
try:
|
try:
|
||||||
out = self._pmd3_run(["mounter", "auto-mount"], udid)
|
out = self._pmd3_run(["mounter", "auto-mount"], udid)
|
||||||
@@ -201,24 +420,17 @@ class IOSActivator:
|
|||||||
print("[mounter] Developer disk image mounted.")
|
print("[mounter] Developer disk image mounted.")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err_text = str(e)
|
last_err = str(e)
|
||||||
if i < retries - 1:
|
if i < retries - 1:
|
||||||
print(f"[mounter] attempt {i+1}/{retries} failed, retrying in {backoff_seconds}s ...")
|
print(f"[mounter] attempt {i+1}/{retries} failed, retrying in {backoff_seconds}s ...")
|
||||||
try:
|
_t.sleep(backoff_seconds)
|
||||||
time.sleep(backoff_seconds)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Auto-mount failed after {retries} attempts.\n{last_err_text}")
|
raise RuntimeError(f"Auto-mount failed after {retries} attempts.\n{last_err}")
|
||||||
|
|
||||||
def _is_ipv4_host(self, host: str) -> bool:
|
def _is_ipv4_host(self, host: str) -> bool:
|
||||||
import re as _re
|
return bool(re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host))
|
||||||
return bool(_re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host))
|
|
||||||
|
|
||||||
def _wait_for_rsd_ready(self, rsd_host: str, rsd_port: str, retries: int = 5, delay: float = 3.0) -> bool:
|
def _wait_for_rsd_ready(self, rsd_host: str, rsd_port: str, retries: int = 5, delay: float = 3.0) -> bool:
|
||||||
"""
|
|
||||||
探测 RSD 通道是否就绪:直接尝试 TCP 连接。
|
|
||||||
"""
|
|
||||||
port_int = int(rsd_port)
|
port_int = int(rsd_port)
|
||||||
for i in range(1, retries + 1):
|
for i in range(1, retries + 1):
|
||||||
print(f"[rsd] Probing RSD {rsd_host}:{rsd_port} (attempt {i}/{retries}) ...")
|
print(f"[rsd] Probing RSD {rsd_host}:{rsd_port} (attempt {i}/{retries}) ...")
|
||||||
@@ -234,42 +446,27 @@ class IOSActivator:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _launch_wda_via_rsd(self, bundle_id: str, rsd_host: str, rsd_port: str, udid: str) -> None:
|
def _launch_wda_via_rsd(self, bundle_id: str, rsd_host: str, rsd_port: str, udid: str) -> None:
|
||||||
"""
|
|
||||||
使用进程内 CLI:pymobiledevice3 developer dvt launch <bundle> --rsd <host> <port>
|
|
||||||
"""
|
|
||||||
print(f"[wda] Launch via RSD {rsd_host}:{rsd_port}, bundle: {bundle_id}")
|
print(f"[wda] Launch via RSD {rsd_host}:{rsd_port}, bundle: {bundle_id}")
|
||||||
out = self._pmd3_run(
|
out = self._pmd3_run(["developer", "dvt", "launch", bundle_id, "--rsd", rsd_host, rsd_port], udid=udid)
|
||||||
["developer", "dvt", "launch", bundle_id, "--rsd", rsd_host, rsd_port],
|
|
||||||
udid=udid,
|
|
||||||
)
|
|
||||||
if out:
|
if out:
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
print(f"[wda] {line}")
|
print(f"[wda] {line}")
|
||||||
print("[wda] Launch via RSD completed.")
|
print("[wda] Launch via RSD completed.")
|
||||||
|
|
||||||
def _launch_wda_via_http_tunnel(self, bundle_id: str, http_host: str, http_port: str, udid: str) -> None:
|
def _launch_wda_via_http_tunnel(self, bundle_id: str, http_host: str, http_port: str, udid: str) -> None:
|
||||||
"""
|
|
||||||
退路:通过 HTTP 网关端口设置 PYMOBILEDEVICE3_TUNNEL(仅 IPv4)。
|
|
||||||
使用进程内 CLI 启动 WDA。
|
|
||||||
"""
|
|
||||||
if not self._is_ipv4_host(http_host):
|
if not self._is_ipv4_host(http_host):
|
||||||
raise RuntimeError(f"HTTP tunnel host must be IPv4, got {http_host}")
|
raise RuntimeError(f"HTTP tunnel host must be IPv4, got {http_host}")
|
||||||
|
|
||||||
tunnel_endpoint = f"{http_host}:{http_port}"
|
tunnel_endpoint = f"{http_host}:{http_port}"
|
||||||
print(f"[wda] Launch via HTTP tunnel {tunnel_endpoint}, bundle: {bundle_id}")
|
print(f"[wda] Launch via HTTP tunnel {tunnel_endpoint}, bundle: {bundle_id}")
|
||||||
|
out = self._pmd3_run(["developer", "dvt", "launch", bundle_id], udid=udid,
|
||||||
out = self._pmd3_run(
|
extra_env={"PYMOBILEDEVICE3_TUNNEL": tunnel_endpoint})
|
||||||
["developer", "dvt", "launch", bundle_id],
|
|
||||||
udid=udid,
|
|
||||||
extra_env={"PYMOBILEDEVICE3_TUNNEL": tunnel_endpoint},
|
|
||||||
)
|
|
||||||
if out:
|
if out:
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
print(f"[wda] {line}")
|
print(f"[wda] {line}")
|
||||||
print("[wda] Launch via HTTP tunnel completed.")
|
print("[wda] Launch via HTTP tunnel completed.")
|
||||||
|
|
||||||
|
# -------- 端口挑选 --------
|
||||||
def _pick_available_port(self, base=49151, step=10) -> int:
|
def _pick_available_port(self, base=49151, step=10) -> int:
|
||||||
"""挑选一个可用端口(避免重复)"""
|
|
||||||
for i in range(0, 1000, step):
|
for i in range(0, 1000, step):
|
||||||
port = base + i
|
port = base + i
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
@@ -277,196 +474,8 @@ class IOSActivator:
|
|||||||
return port
|
return port
|
||||||
raise RuntimeError("No free port found for tunneld")
|
raise RuntimeError("No free port found for tunneld")
|
||||||
|
|
||||||
# =========================================================================
|
# -------- UDID 过滤 --------
|
||||||
# 对外方法
|
|
||||||
# =========================================================================
|
|
||||||
def activate(
|
|
||||||
self,
|
|
||||||
udid: str,
|
|
||||||
wda_bundle_id: Optional[str] = WdaAppBundleId,
|
|
||||||
ready_timeout_sec: float = 120.0,
|
|
||||||
mount_retries: int = 3,
|
|
||||||
backoff_seconds: float = 2.0,
|
|
||||||
rsd_probe_retries: int = 5,
|
|
||||||
rsd_probe_delay_sec: float = 3.0,
|
|
||||||
pre_mount_first: bool = True,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
执行:挂镜像(可选) -> 开隧道 -> (等待 RSD 就绪)-> 启动 WDA
|
|
||||||
- 优先用 `--rsd` 启动
|
|
||||||
- 失败再用 HTTP 网关作为退路
|
|
||||||
"""
|
|
||||||
if not udid or not isinstance(udid, str):
|
|
||||||
raise ValueError("udid is required and must be a non-empty string")
|
|
||||||
|
|
||||||
print(f"[activate] UDID = {udid}")
|
|
||||||
|
|
||||||
# ⚠️ 检查管理员权限
|
|
||||||
if os.name == "nt":
|
|
||||||
import ctypes
|
|
||||||
try:
|
|
||||||
is_admin = ctypes.windll.shell32.IsUserAnAdmin() != 0
|
|
||||||
except Exception:
|
|
||||||
is_admin = False
|
|
||||||
if not is_admin:
|
|
||||||
print("[⚠️] 当前进程未以管理员身份运行,tunneld 可能无法创建 USB 隧道。")
|
|
||||||
|
|
||||||
import time as _t
|
|
||||||
start_ts = _t.time()
|
|
||||||
|
|
||||||
# 1) (可选) 先挂载,避免 tidevice 并发 DDI 竞态
|
|
||||||
if pre_mount_first:
|
|
||||||
try:
|
|
||||||
self._auto_mount_developer_disk(udid, retries=mount_retries, backoff_seconds=backoff_seconds)
|
|
||||||
_t.sleep(2) # 稳定 DDI
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[activate] 预挂载失败(继续尝试开隧道后再挂载一次):{e}")
|
|
||||||
|
|
||||||
# 2) 启动 tunneld 子进程
|
|
||||||
port = self._pick_available_port()
|
|
||||||
launcher, env2 = self._resolve_pmd3_argv_and_env()
|
|
||||||
env2["PYTHONUNBUFFERED"] = "1"
|
|
||||||
env2.setdefault("PYTHONIOENCODING", "utf-8")
|
|
||||||
env2["PYMOBILEDEVICE3_UDID"] = udid
|
|
||||||
|
|
||||||
cmd = self._ensure_str_list([*launcher, "remote", "tunneld", "--port", str(port)])
|
|
||||||
print("[activate] 使用命令启动隧道:", " ".join(cmd))
|
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
universal_newlines=True,
|
|
||||||
env=env2,
|
|
||||||
**self._win_hidden_popen_kwargs()
|
|
||||||
)
|
|
||||||
|
|
||||||
captured: list[str] = []
|
|
||||||
http_host: Optional[str] = None
|
|
||||||
http_port: Optional[str] = None
|
|
||||||
rsd_host: Optional[str] = None
|
|
||||||
rsd_port: Optional[str] = None
|
|
||||||
device_tunnel_ready = False
|
|
||||||
wda_started = False
|
|
||||||
mount_done = pre_mount_first
|
|
||||||
|
|
||||||
HTTP_RE = re.compile(r"http://([0-9.]+):(\d+)")
|
|
||||||
RSD_CREATED_RE = re.compile(r"Created tunnel\s+--rsd\s+([^\s]+)\s+(\d+)")
|
|
||||||
RSD_FALLBACK_RE = re.compile(r"--rsd\s+(\S+?)[\s:](\d+)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
assert proc.stdout is not None
|
|
||||||
for line in proc.stdout:
|
|
||||||
captured.append(line)
|
|
||||||
print(f"[tunneld] {line}", end="")
|
|
||||||
|
|
||||||
if proc.poll() is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 捕获 HTTP 网关端口
|
|
||||||
if http_port is None:
|
|
||||||
m = HTTP_RE.search(line)
|
|
||||||
if m:
|
|
||||||
http_host, http_port = m.group(1), m.group(2)
|
|
||||||
print(f"[tunneld] Tunnel API: {http_host}:{http_port}")
|
|
||||||
|
|
||||||
# ✅ 捕获 RSD(仅识别当前 UDID 的行)
|
|
||||||
if self._line_is_for_udid(line, udid):
|
|
||||||
m = RSD_CREATED_RE.search(line) or RSD_FALLBACK_RE.search(line)
|
|
||||||
if m and not device_tunnel_ready:
|
|
||||||
rsd_host, rsd_port = m.group(1), m.group(2)
|
|
||||||
device_tunnel_ready = True
|
|
||||||
print(f"[tunneld] Device-level tunnel ready (RSD {rsd_host}:{rsd_port}).")
|
|
||||||
else:
|
|
||||||
continue # 其它设备的隧道日志忽略
|
|
||||||
|
|
||||||
# 当设备隧道准备好后启动 WDA
|
|
||||||
if (not wda_started) and wda_bundle_id and device_tunnel_ready:
|
|
||||||
try:
|
|
||||||
if not mount_done:
|
|
||||||
self._auto_mount_developer_disk(
|
|
||||||
udid, retries=mount_retries, backoff_seconds=backoff_seconds
|
|
||||||
)
|
|
||||||
_t.sleep(2)
|
|
||||||
mount_done = True
|
|
||||||
|
|
||||||
rsd_ok = False
|
|
||||||
if rsd_host and rsd_port:
|
|
||||||
rsd_ok = self._wait_for_rsd_ready(
|
|
||||||
rsd_host, rsd_port,
|
|
||||||
retries=rsd_probe_retries,
|
|
||||||
delay=rsd_probe_delay_sec,
|
|
||||||
)
|
|
||||||
|
|
||||||
if rsd_ok:
|
|
||||||
self._launch_wda_via_rsd(
|
|
||||||
bundle_id=wda_bundle_id,
|
|
||||||
rsd_host=rsd_host,
|
|
||||||
rsd_port=rsd_port,
|
|
||||||
udid=udid,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if http_host and http_port:
|
|
||||||
self._launch_wda_via_http_tunnel(
|
|
||||||
bundle_id=wda_bundle_id,
|
|
||||||
http_host=http_host,
|
|
||||||
http_port=http_port,
|
|
||||||
udid=udid,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise RuntimeError("No valid tunnel endpoint for fallback.")
|
|
||||||
|
|
||||||
wda_started = True
|
|
||||||
|
|
||||||
except RuntimeError as exc:
|
|
||||||
print(str(exc), file=sys.stderr)
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
raise
|
|
||||||
|
|
||||||
# 超时保护
|
|
||||||
if (not wda_started) and ready_timeout_sec > 0 and (_t.time() - start_ts > ready_timeout_sec):
|
|
||||||
print(f"[tunneld] Timeout waiting for device tunnel ({ready_timeout_sec}s). Aborting.")
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
break
|
|
||||||
|
|
||||||
# 结束清理
|
|
||||||
try:
|
|
||||||
return_code = proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
return_code = proc.returncode or -9
|
|
||||||
|
|
||||||
output = "".join(captured)
|
|
||||||
if return_code != 0 and not wda_started:
|
|
||||||
raise RuntimeError(f"tunneld exited with code {return_code}.\n{output}")
|
|
||||||
|
|
||||||
print("[activate] Done.")
|
|
||||||
return output
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n[activate] Interrupted, cleaning up ...", file=sys.stderr)
|
|
||||||
try:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
proc.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _line_is_for_udid(self, line: str, udid: str) -> bool:
|
def _line_is_for_udid(self, line: str, udid: str) -> bool:
|
||||||
"""日志行是否属于目标 UDID。"""
|
|
||||||
try:
|
try:
|
||||||
return udid.lower() in (line or "").lower()
|
return udid.lower() in (line or "").lower()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def main(arg):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
# 获取启动时候传递的参数
|
# 获取启动时候传递的参数
|
||||||
# main(sys.argv)
|
main(sys.argv)
|
||||||
|
|
||||||
# 添加iOS开发包到电脑上
|
# 添加iOS开发包到电脑上
|
||||||
deployer = DevDiskImageDeployer(verbose=True)
|
deployer = DevDiskImageDeployer(verbose=True)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,80 +1,141 @@
|
|||||||
import ctypes
|
import ctypes
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from typing import Dict, Tuple, List
|
from typing import Dict, Tuple, List
|
||||||
|
|
||||||
from Utils.LogManager import LogManager
|
from Utils.LogManager import LogManager
|
||||||
|
|
||||||
|
|
||||||
def _async_raise(tid: int, exc_type=KeyboardInterrupt):
|
def _async_raise(tid: int, exc_type=KeyboardInterrupt) -> bool:
|
||||||
"""向指定线程抛异常,强制跳出"""
|
"""向指定线程抛异常(兜底方案)"""
|
||||||
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), ctypes.py_object(exc_type))
|
try:
|
||||||
if res == 0:
|
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
||||||
raise ValueError("线程不存在")
|
ctypes.c_long(tid), ctypes.py_object(exc_type)
|
||||||
elif res > 1:
|
)
|
||||||
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
|
if res == 0:
|
||||||
|
LogManager.method_info(f"线程 {tid} 不存在", "task")
|
||||||
|
return False
|
||||||
|
elif res > 1:
|
||||||
|
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
|
||||||
|
LogManager.method_info(f"线程 {tid} 命中多个线程,已回滚", "task")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
LogManager.method_error(f"强杀线程失败: {e}", "task")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ThreadManager:
|
class ThreadManager:
|
||||||
_tasks: Dict[str, Dict] = {}
|
_tasks: Dict[str, Dict] = {}
|
||||||
_lock = threading.Lock()
|
_lock = threading.RLock()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add(cls, udid: str, thread: threading.Thread, event: threading.Event) -> Tuple[int, str]:
|
def _cleanup_if_dead(cls, udid: str):
|
||||||
LogManager.method_info(f"准备创建任务:{udid}", "task")
|
"""如果任务线程已结束,清理占位"""
|
||||||
LogManager.method_info("创建线程成功", "监控消息")
|
obj = cls._tasks.get(udid)
|
||||||
|
if obj and not obj["thread"].is_alive():
|
||||||
|
cls._tasks.pop(udid, None)
|
||||||
|
LogManager.method_info(f"检测到 [{udid}] 线程已结束,自动清理。", "task")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add(cls, udid: str, thread: threading.Thread, event: threading.Event, force: bool = False) -> Tuple[int, str]:
|
||||||
with cls._lock:
|
with cls._lock:
|
||||||
# 判断当前设备是否有任务
|
cls._cleanup_if_dead(udid)
|
||||||
if cls._tasks.get(udid, None) is not None:
|
|
||||||
return 1001, "当前设备已存在任务"
|
|
||||||
thread.start()
|
|
||||||
print(thread.ident)
|
|
||||||
|
|
||||||
cls._tasks[udid] = {
|
# 已存在任务还在运行
|
||||||
"id": thread.ident,
|
old = cls._tasks.get(udid)
|
||||||
"thread": thread,
|
if old and old["thread"].is_alive():
|
||||||
"event": event
|
if not force:
|
||||||
}
|
return 1001, "当前设备已存在任务"
|
||||||
return 200, "创建成功"
|
LogManager.method_info(f"[{udid}] 检测到旧任务,尝试强制停止", "task")
|
||||||
|
cls._force_stop_locked(udid)
|
||||||
|
|
||||||
|
# 启动新任务
|
||||||
|
try:
|
||||||
|
thread.start()
|
||||||
|
cls._tasks[udid] = {
|
||||||
|
"id": thread.ident,
|
||||||
|
"thread": thread,
|
||||||
|
"event": event,
|
||||||
|
"start_time": time.time(),
|
||||||
|
}
|
||||||
|
LogManager.method_info(f"创建任务成功 [{udid}],线程ID={thread.ident}", "task")
|
||||||
|
return 200, "创建成功"
|
||||||
|
except Exception as e:
|
||||||
|
LogManager.method_error(f"线程启动失败: {e}", "task")
|
||||||
|
return 1002, f"线程启动失败: {e}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def stop(cls, udid: str) -> Tuple[int, str]:
|
def stop(cls, udid: str, stop_timeout: float = 5.0, kill_timeout: float = 2.0) -> Tuple[int, str]:
|
||||||
try:
|
"""安全停止单个任务"""
|
||||||
print(cls._tasks)
|
with cls._lock:
|
||||||
obj = cls._tasks.get(udid, None)
|
obj = cls._tasks.get(udid)
|
||||||
obj["event"].set()
|
if not obj:
|
||||||
r = cls._kill_thread(obj.get("id"))
|
return 200, "任务不存在"
|
||||||
if r:
|
|
||||||
|
thread = obj["thread"]
|
||||||
|
event = obj["event"]
|
||||||
|
tid = obj["id"]
|
||||||
|
|
||||||
|
LogManager.method_info(f"请求停止 [{udid}] 线程ID={tid}", "task")
|
||||||
|
|
||||||
|
# 已经结束
|
||||||
|
if not thread.is_alive():
|
||||||
cls._tasks.pop(udid, None)
|
cls._tasks.pop(udid, None)
|
||||||
else:
|
return 200, "已结束"
|
||||||
print("好像有问题")
|
|
||||||
|
# 1. 协作式停止
|
||||||
|
try:
|
||||||
|
event.set()
|
||||||
|
except Exception as e:
|
||||||
|
LogManager.method_error(f"[{udid}] 设置停止事件失败: {e}", "task")
|
||||||
|
|
||||||
|
thread.join(timeout=stop_timeout)
|
||||||
|
if not thread.is_alive():
|
||||||
|
cls._tasks.pop(udid, None)
|
||||||
|
LogManager.method_info(f"[{udid}] 协作式停止成功", "task")
|
||||||
|
return 200, "已停止"
|
||||||
|
|
||||||
|
# 2. 强杀兜底
|
||||||
|
LogManager.method_info(f"[{udid}] 协作式超时,尝试强杀", "task")
|
||||||
|
if _async_raise(tid):
|
||||||
|
thread.join(timeout=kill_timeout)
|
||||||
|
|
||||||
|
if not thread.is_alive():
|
||||||
|
cls._tasks.pop(udid, None)
|
||||||
|
LogManager.method_info(f"[{udid}] 强杀成功", "task")
|
||||||
|
return 200, "已停止"
|
||||||
|
|
||||||
|
# 3. 最终兜底:标记释放占位
|
||||||
|
LogManager.method_error(f"[{udid}] 无法停止(线程可能卡死),已释放占位", "task")
|
||||||
|
cls._tasks.pop(udid, None)
|
||||||
|
return 206, "停止超时,线程可能仍在后台运行"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _force_stop_locked(cls, udid: str):
|
||||||
|
"""内部用,带锁强制停止旧任务"""
|
||||||
|
obj = cls._tasks.get(udid)
|
||||||
|
if not obj:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
event = obj["event"]
|
||||||
|
event.set()
|
||||||
|
obj["thread"].join(timeout=2)
|
||||||
|
if obj["thread"].is_alive():
|
||||||
|
_async_raise(obj["id"])
|
||||||
|
obj["thread"].join(timeout=1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
LogManager.method_error(f"[{udid}] 强制停止失败: {e}", "task")
|
||||||
return 200, "操作成功"
|
finally:
|
||||||
|
cls._tasks.pop(udid, None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def batch_stop(cls, ids: List[str]) -> Tuple[int, str]:
|
def batch_stop(cls, ids: List[str]) -> Tuple[int, str]:
|
||||||
try:
|
failed = []
|
||||||
for udid in ids:
|
for udid in ids:
|
||||||
cls.stop(udid)
|
code, msg = cls.stop(udid)
|
||||||
except Exception as e:
|
if code != 200:
|
||||||
print(e)
|
failed.append(udid)
|
||||||
return 200, "停止成功."
|
if failed:
|
||||||
|
return 207, f"部分任务未成功停止: {failed}"
|
||||||
@classmethod
|
return 200, "全部停止成功"
|
||||||
def _kill_thread(cls, tid: int) -> bool:
|
|
||||||
"""向原生线程 ID 抛 KeyboardInterrupt,强制跳出"""
|
|
||||||
try:
|
|
||||||
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid),
|
|
||||||
ctypes.py_object(KeyboardInterrupt))
|
|
||||||
# LogManager.method_info(f"向原生线程 {tid} 抛 KeyboardInterrupt,强制跳出", "task")
|
|
||||||
if res == 0: # 线程已不存在
|
|
||||||
print("线程不存在")
|
|
||||||
return False
|
|
||||||
if res > 1: # 命中多个线程,重置
|
|
||||||
print("命中了多个线程")
|
|
||||||
ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
|
|
||||||
LogManager.method_info("杀死线程创建成功", "监控消息")
|
|
||||||
|
|
||||||
print("杀死线程成功")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print("杀死线程出现问题 错误的原因:",e)
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user