修复一点点小bug
This commit is contained in:
2
.idea/workspace.xml
generated
2
.idea/workspace.xml
generated
@@ -6,7 +6,7 @@
|
|||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="eceeff5e-51c1-459c-a911-d21ec090a423" name="Changes" comment="20250904-初步功能已完成">
|
<list default="true" id="eceeff5e-51c1-459c-a911-d21ec090a423" name="Changes" comment="20250904-初步功能已完成">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/Module/FlaskService.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/FlaskService.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/Module/DeviceInfo.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/DeviceInfo.py" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -6,30 +7,33 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import http.client
|
||||||
|
import psutil
|
||||||
|
import hashlib # 仍保留,如需后续扩展
|
||||||
import tidevice
|
import tidevice
|
||||||
import wda
|
import wda
|
||||||
from tidevice import Usbmux, ConnectionType
|
from tidevice import Usbmux, ConnectionType
|
||||||
from tidevice._device import BaseDevice
|
from tidevice._device import BaseDevice
|
||||||
|
|
||||||
from Entity.DeviceModel import DeviceModel
|
from Entity.DeviceModel import DeviceModel
|
||||||
from Entity.Variables import WdaAppBundleId, wdaFunctionPort, wdaScreenPort
|
from Entity.Variables import WdaAppBundleId, wdaFunctionPort, wdaScreenPort
|
||||||
from Module.FlaskSubprocessManager import FlaskSubprocessManager
|
from Module.FlaskSubprocessManager import FlaskSubprocessManager
|
||||||
from Module.IOSActivator import IOSActivator
|
from Module.IOSActivator import IOSActivator
|
||||||
from Utils.LogManager import LogManager
|
from Utils.LogManager import LogManager
|
||||||
|
|
||||||
import socket
|
|
||||||
import http.client
|
|
||||||
from collections import defaultdict
|
|
||||||
import psutil
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceInfo:
|
class DeviceInfo:
|
||||||
|
|
||||||
REMOVE_GRACE_SEC = 5.0 # 设备离线宽限期(秒)
|
# --- 时序参数(更稳) ---
|
||||||
ADD_STABLE_SEC = 1.5 # 设备上线稳定期(秒)
|
REMOVE_GRACE_SEC = 8.0 # 设备离线宽限期(秒)
|
||||||
ORPHAN_COOLDOWN = 3.0 # 拓扑变更后暂停孤儿清理(秒)
|
ADD_STABLE_SEC = 2.5 # 设备上线稳定期(秒)
|
||||||
|
ORPHAN_COOLDOWN = 8.0 # 拓扑变更后暂停孤儿清理(秒)
|
||||||
|
HEAL_INTERVAL = 5.0 # 健康巡检间隔(秒)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
# 自增端口游标仅作兜底扫描使用
|
||||||
self._port = 9110
|
self._port = 9110
|
||||||
self._models: Dict[str, DeviceModel] = {}
|
self._models: Dict[str, DeviceModel] = {}
|
||||||
self._procs: Dict[str, subprocess.Popen] = {}
|
self._procs: Dict[str, subprocess.Popen] = {}
|
||||||
@@ -38,20 +42,24 @@ class DeviceInfo:
|
|||||||
self._pool = ThreadPoolExecutor(max_workers=6)
|
self._pool = ThreadPoolExecutor(max_workers=6)
|
||||||
|
|
||||||
self._last_heal_check_ts = 0.0
|
self._last_heal_check_ts = 0.0
|
||||||
self._heal_backoff: Dict[str, float] = defaultdict(float) # udid -> next_allowed_ts
|
self._heal_backoff: Dict[str, float] = {} # udid -> next_allowed_ts
|
||||||
|
|
||||||
# 并发保护 & 状态表
|
# 并发保护 & 状态表
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._port_by_udid: Dict[str, int] = {} # UDID -> local_port
|
self._port_by_udid: Dict[str, int] = {} # UDID -> 当前使用的本地端口
|
||||||
self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID
|
self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID
|
||||||
|
|
||||||
# 抗抖:最近一次看到在线的时间 / 首次看到在线的时间
|
# 抗抖
|
||||||
self._last_seen: Dict[str, float] = {} # udid -> ts
|
self._last_seen: Dict[str, float] = {} # udid -> ts
|
||||||
self._first_seen: Dict[str, float] = {} # udid -> ts(首次在线)
|
self._first_seen: Dict[str, float] = {} # udid -> ts(首次在线)
|
||||||
self._last_topology_change_ts = 0.0 # 最近一次“新增或真正移除”的时间
|
self._last_topology_change_ts = 0.0
|
||||||
|
|
||||||
|
LogManager.info("DeviceInfo init 完成;日志已启用", udid="system")
|
||||||
|
|
||||||
# ---------------- 主循环 ----------------
|
# ---------------- 主循环 ----------------
|
||||||
def listen(self):
|
def listen(self):
|
||||||
|
method = "listen"
|
||||||
|
LogManager.method_info("进入主循环", method, udid="system")
|
||||||
orphan_gc_tick = 0
|
orphan_gc_tick = 0
|
||||||
while True:
|
while True:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -59,8 +67,7 @@ class DeviceInfo:
|
|||||||
usb = Usbmux().device_list()
|
usb = Usbmux().device_list()
|
||||||
online_now = {d.udid for d in usb if d.conn_type == ConnectionType.USB}
|
online_now = {d.udid for d in usb if d.conn_type == ConnectionType.USB}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 如果拉设备列表失败,本轮不做增删(避免误杀)
|
LogManager.warning(f"[device_list] 异常:{e}", udid="system")
|
||||||
LogManager.warning(f"device_list() 异常:{e}")
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -68,33 +75,37 @@ class DeviceInfo:
|
|||||||
for u in online_now:
|
for u in online_now:
|
||||||
if u not in self._first_seen:
|
if u not in self._first_seen:
|
||||||
self._first_seen[u] = now
|
self._first_seen[u] = now
|
||||||
|
LogManager.method_info("first seen", method, udid=u)
|
||||||
self._last_seen[u] = now
|
self._last_seen[u] = now
|
||||||
|
|
||||||
# 处理真正移除(连续缺席超过宽限期)
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
known = set(self._models.keys())
|
known = set(self._models.keys())
|
||||||
|
|
||||||
|
# 真正移除(连续缺席超过宽限期)
|
||||||
for udid in list(known):
|
for udid in list(known):
|
||||||
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:
|
if udid not in online_now and (now - last) >= self.REMOVE_GRACE_SEC:
|
||||||
self._remove_device(udid) # 真正下线
|
LogManager.info(f"设备判定离线(超过宽限期 {self.REMOVE_GRACE_SEC}s),last_seen={last}", udid=udid)
|
||||||
|
self._remove_device(udid)
|
||||||
self._last_topology_change_ts = now
|
self._last_topology_change_ts = now
|
||||||
|
|
||||||
# 处理真正新增(连续在线超过稳定期)
|
# 真正新增(连续在线超过稳定期)
|
||||||
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]
|
||||||
to_add = [u for u in new_candidates if (now - self._first_seen.get(u, now)) >= self.ADD_STABLE_SEC]
|
to_add = [u for u in new_candidates if (now - self._first_seen.get(u, now)) >= self.ADD_STABLE_SEC]
|
||||||
if to_add:
|
if to_add:
|
||||||
|
LogManager.info(f"新增设备稳定上线:{to_add}", udid="system")
|
||||||
futures = {self._pool.submit(self._add_device, u): u for u in to_add}
|
futures = {self._pool.submit(self._add_device, u): u for u in to_add}
|
||||||
for f in as_completed(futures, timeout=30):
|
for f in as_completed(futures, timeout=45):
|
||||||
try:
|
try:
|
||||||
f.result()
|
f.result()
|
||||||
self._last_topology_change_ts = time.time()
|
self._last_topology_change_ts = time.time()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LogManager.error(f"异步连接失败:{e}")
|
LogManager.error(f"异步连接失败:{e}", udid="system")
|
||||||
|
|
||||||
# 定期健康检查 + 自愈
|
# 定期健康检查 + 自愈
|
||||||
self._check_and_heal_tunnels(interval=2.0)
|
self._check_and_heal_tunnels(interval=self.HEAL_INTERVAL)
|
||||||
|
|
||||||
# 每 10 次清理一次孤儿 iproxy(但在拓扑变更后 N 秒暂停执行,避免插拔风暴期误杀)
|
# 周期性孤儿清理(拓扑变更冷却之后)
|
||||||
orphan_gc_tick += 1
|
orphan_gc_tick += 1
|
||||||
if orphan_gc_tick >= 10:
|
if orphan_gc_tick >= 10:
|
||||||
orphan_gc_tick = 0
|
orphan_gc_tick = 0
|
||||||
@@ -103,53 +114,64 @@ class DeviceInfo:
|
|||||||
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 新增设备 ----------------
|
# ---------------- 新增设备 ----------------
|
||||||
def _add_device(self, udid: str):
|
def _add_device(self, udid: str):
|
||||||
|
method = "_add_device"
|
||||||
|
LogManager.method_info("开始新增设备", method, udid=udid)
|
||||||
|
|
||||||
if not self._trusted(udid):
|
if not self._trusted(udid):
|
||||||
|
LogManager.method_warning("未信任设备,跳过", method, udid=udid)
|
||||||
return
|
return
|
||||||
|
|
||||||
r = self.startWda(udid)
|
r = self.startWda(udid)
|
||||||
if r is False:
|
if r is False:
|
||||||
LogManager.info("启动wda失败")
|
LogManager.method_error("启动 WDA 失败,放弃新增", method, udid=udid)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# iOS 17+ 激活/信任阶段更抖,稍等更稳
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
w, h, s = self._screen_info(udid)
|
w, h, s = self._screen_info(udid)
|
||||||
if w == 0 or h == 0 or s == 0:
|
if w == 0 or h == 0 or s == 0:
|
||||||
print("未获取到设备屏幕信息")
|
LogManager.method_warning("未获取到屏幕信息,放弃新增", method, udid=udid)
|
||||||
return
|
return
|
||||||
print("获取设备信息成功")
|
|
||||||
|
|
||||||
# 固定端口分配(加锁,避免竞态)
|
# 不复用端口:直接起一个新端口
|
||||||
with self._lock:
|
proc = self._start_iproxy(udid, port=None)
|
||||||
port = self._alloc_port(udid)
|
|
||||||
|
|
||||||
proc = self._start_iproxy(udid, port)
|
|
||||||
if not proc:
|
if not proc:
|
||||||
print("启动iproxy失败")
|
LogManager.method_error("启动 iproxy 失败,放弃新增", method, udid=udid)
|
||||||
return
|
return
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
model = DeviceModel(deviceId=udid, screenPort=port,
|
port = self._port_by_udid[udid]
|
||||||
width=w, height=h, scale=s, type=1)
|
model = DeviceModel(deviceId=udid, screenPort=port, width=w, height=h, scale=s, type=1)
|
||||||
model.ready = True
|
model.ready = True
|
||||||
self._models[udid] = model
|
self._models[udid] = model
|
||||||
self._procs[udid] = proc
|
self._procs[udid] = proc
|
||||||
self._pid_by_udid[udid] = proc.pid
|
|
||||||
|
|
||||||
print("准备添加设备")
|
LogManager.method_info(f"设备添加完成,port={port}, {w}x{h}@{s}", method, udid=udid)
|
||||||
self._manager_send(model)
|
self._manager_send(model)
|
||||||
|
|
||||||
# ---------------- 移除设备(仅在宽限期后调用) ----------------
|
# ---------------- 移除设备 ----------------
|
||||||
def _remove_device(self, udid: str):
|
def _remove_device(self, udid: str):
|
||||||
|
method = "_remove_device"
|
||||||
|
LogManager.method_info("开始移除设备", method, udid=udid)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
model = self._models.pop(udid, None)
|
model = self._models.pop(udid, None)
|
||||||
proc = self._procs.pop(udid, None)
|
proc = self._procs.pop(udid, None)
|
||||||
self._pid_by_udid.pop(udid, None)
|
pid = self._pid_by_udid.pop(udid, None)
|
||||||
# 不清 _port_by_udid,端口下次仍复用,前端更稳定
|
self._port_by_udid.pop(udid, None)
|
||||||
|
|
||||||
if not model:
|
if not model:
|
||||||
|
LogManager.method_warning("未找到设备模型,可能重复移除", method, udid=udid)
|
||||||
return
|
return
|
||||||
|
|
||||||
model.type = 2
|
model.type = 2
|
||||||
self._kill(proc)
|
self._kill(proc)
|
||||||
|
if pid:
|
||||||
|
self._kill_pid_gracefully(pid)
|
||||||
self._manager_send(model)
|
self._manager_send(model)
|
||||||
|
LogManager.method_info("设备移除完毕", method, udid=udid)
|
||||||
|
|
||||||
# ---------------- 工具函数 ----------------
|
# ---------------- 工具函数 ----------------
|
||||||
def _trusted(self, udid: str) -> bool:
|
def _trusted(self, udid: str) -> bool:
|
||||||
@@ -160,129 +182,257 @@ class DeviceInfo:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def startWda(self, udid):
|
def startWda(self, udid):
|
||||||
print("进入启动wda方法")
|
method = "startWda"
|
||||||
|
LogManager.method_info("进入启动流程", method, udid=udid)
|
||||||
try:
|
try:
|
||||||
dev = tidevice.Device(udid)
|
dev = tidevice.Device(udid)
|
||||||
systemVersion = int(dev.product_version.split(".")[0])
|
systemVersion = int(dev.product_version.split(".")[0])
|
||||||
# 判断运行wda的逻辑
|
|
||||||
if systemVersion > 17:
|
if systemVersion > 17:
|
||||||
|
LogManager.method_info(f"iOS 主版本 {systemVersion},使用 IOSActivator", method, udid=udid)
|
||||||
ios = IOSActivator()
|
ios = IOSActivator()
|
||||||
threading.Thread(
|
threading.Thread(target=ios.activate, args=(udid,), daemon=True).start()
|
||||||
target=ios.activate,
|
|
||||||
args=(udid,)
|
|
||||||
).start()
|
|
||||||
else:
|
else:
|
||||||
|
LogManager.method_info(f"app_start WDA: {WdaAppBundleId}", method, udid=udid)
|
||||||
dev.app_start(WdaAppBundleId)
|
dev.app_start(WdaAppBundleId)
|
||||||
print("启动wda成功")
|
LogManager.method_info("WDA 启动完成,等待稳定...", method, udid=udid)
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("启动wda遇到错误:", e)
|
LogManager.method_error(f"WDA 启动异常:{e}", method, udid=udid)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _screen_info(self, udid: str):
|
def _screen_info(self, udid: str):
|
||||||
|
method = "_screen_info"
|
||||||
try:
|
try:
|
||||||
c = wda.USBClient(udid, wdaFunctionPort)
|
c = wda.USBClient(udid, wdaFunctionPort)
|
||||||
c.home()
|
c.home()
|
||||||
size = c.window_size()
|
size = c.window_size()
|
||||||
scale = c.scale
|
scale = c.scale
|
||||||
|
LogManager.method_info(f"屏幕信息:{int(size.width)}x{int(size.height)}@{float(scale)}", method, udid=udid)
|
||||||
return int(size.width), int(size.height), float(scale)
|
return int(size.width), int(size.height), float(scale)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("获取设备信息遇到错误:", e)
|
LogManager.method_warning(f"获取屏幕信息异常:{e}", method, udid=udid)
|
||||||
return 0, 0, 0
|
return 0, 0, 0
|
||||||
|
|
||||||
# ---------------- 端口映射(保留你之前的“先杀后启”“隐藏黑窗”修复) ----------------
|
# ---------------- 端口/进程:不复用端口 ----------------
|
||||||
def _start_iproxy(self, udid: str, port: int) -> Optional[subprocess.Popen]:
|
def _is_port_bindable(self, port: int, host: str = "127.0.0.1") -> bool:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind((host, port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
def _pick_new_port(self, tries: int = 40) -> int:
|
||||||
|
method = "_pick_new_port"
|
||||||
|
# 先在 9111~9499 随机尝试
|
||||||
|
for _ in range(tries // 2):
|
||||||
|
p = random.randint(9111, 9499)
|
||||||
|
if self._is_port_bindable(p):
|
||||||
|
LogManager.method_info(f"端口候选可用(9k段):{p}", method, udid="system")
|
||||||
|
return p
|
||||||
|
else:
|
||||||
|
LogManager.method_info(f"端口候选占用(9k段):{p}", method, udid="system")
|
||||||
|
# 再在 20000~48000 随机尝试
|
||||||
|
for _ in range(tries):
|
||||||
|
p = random.randint(20000, 48000)
|
||||||
|
if self._is_port_bindable(p):
|
||||||
|
LogManager.method_info(f"端口候选可用(20k-48k):{p}", method, udid="system")
|
||||||
|
return p
|
||||||
|
else:
|
||||||
|
LogManager.method_info(f"端口候选占用(20k-48k):{p}", method, udid="system")
|
||||||
|
LogManager.method_warning("随机端口尝试耗尽,改顺序扫描", method, udid="system")
|
||||||
|
return self._pick_free_port(start=49152, limit=10000)
|
||||||
|
|
||||||
|
def _wait_until_listening(self, port: int, timeout: float = 2.0) -> bool:
|
||||||
|
method = "_wait_until_listening"
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(0.2)
|
||||||
|
if s.connect_ex(("127.0.0.1", port)) == 0:
|
||||||
|
LogManager.method_info(f"端口已开始监听:{port}", method, udid="system")
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
LogManager.method_warning(f"监听验收超时:{port}", method, udid="system")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _start_iproxy(self, udid: str, port: Optional[int] = None) -> Optional[subprocess.Popen]:
|
||||||
|
method = "_start_iproxy"
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
old_pid = self._pid_by_udid.get(udid)
|
old_pid = self._pid_by_udid.get(udid)
|
||||||
if old_pid:
|
if old_pid:
|
||||||
|
LogManager.method_info(f"发现旧 iproxy,准备结束:pid={old_pid}", method, udid=udid)
|
||||||
self._kill_pid_gracefully(old_pid)
|
self._kill_pid_gracefully(old_pid)
|
||||||
self._pid_by_udid.pop(udid, None)
|
self._pid_by_udid.pop(udid, None)
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
if not self._is_port_free(port):
|
attempts = 0
|
||||||
port = self._pick_free_port(start=max(self._port, port))
|
while attempts < 3:
|
||||||
|
attempts += 1
|
||||||
|
local_port = port if (attempts == 1 and port is not None) else self._pick_new_port()
|
||||||
|
if not self._is_port_bindable(local_port):
|
||||||
|
LogManager.method_info(f"[attempt {attempts}] 端口竞争,换候选:{local_port}", method, udid=udid)
|
||||||
|
continue
|
||||||
|
|
||||||
|
LogManager.method_info(f"[attempt {attempts}] 启动 iproxy,port={local_port}", method, udid=udid)
|
||||||
|
|
||||||
creationflags = 0
|
creationflags = 0
|
||||||
startupinfo = None
|
startupinfo = None
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | getattr(subprocess,
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | \
|
||||||
"CREATE_NEW_PROCESS_GROUP",
|
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||||
0x00000200)
|
|
||||||
si = subprocess.STARTUPINFO()
|
si = subprocess.STARTUPINFO()
|
||||||
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
si.wShowWindow = 0
|
si.wShowWindow = 0
|
||||||
startupinfo = si
|
startupinfo = si
|
||||||
|
|
||||||
cmd = [self._iproxy_path, "-u", udid, str(port), str(wdaScreenPort)]
|
cmd = [self._iproxy_path, "-u", udid, str(local_port), str(wdaScreenPort)]
|
||||||
proc = subprocess.Popen(
|
try:
|
||||||
cmd,
|
proc = subprocess.Popen(
|
||||||
stdout=subprocess.DEVNULL,
|
cmd,
|
||||||
stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
creationflags=creationflags,
|
stderr=subprocess.DEVNULL,
|
||||||
startupinfo=startupinfo
|
creationflags=creationflags,
|
||||||
)
|
startupinfo=startupinfo
|
||||||
self._procs[udid] = proc
|
)
|
||||||
self._pid_by_udid[udid] = proc.pid
|
except Exception as e:
|
||||||
self._port_by_udid[udid] = port
|
LogManager.method_warning(f"创建进程失败:{e}", method, udid=udid)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not self._wait_until_listening(local_port, timeout=2.0):
|
||||||
|
LogManager.method_warning(f"[attempt {attempts}] iproxy 未监听,重试换端口", method, udid=udid)
|
||||||
|
self._kill(proc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._procs[udid] = proc
|
||||||
|
self._pid_by_udid[udid] = proc.pid
|
||||||
|
self._port_by_udid[udid] = local_port
|
||||||
|
|
||||||
|
LogManager.method_info(f"iproxy 启动成功并监听,pid={proc.pid}, port={local_port}", method, udid=udid)
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
LogManager.method_error("iproxy 启动多次失败", method, udid=udid)
|
||||||
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
LogManager.method_error(f"_start_iproxy 异常:{e}", method, udid=udid)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _kill(self, proc: Optional[subprocess.Popen]):
|
def _kill(self, proc: Optional[subprocess.Popen]):
|
||||||
|
method = "_kill"
|
||||||
if not proc:
|
if not proc:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
|
LogManager.method_info("进程已正常终止", method, udid="system")
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
os.kill(proc.pid, signal.SIGKILL)
|
os.kill(proc.pid, signal.SIGKILL)
|
||||||
except Exception:
|
LogManager.method_warning("进程被强制杀死", method, udid="system")
|
||||||
pass
|
except Exception as e:
|
||||||
|
LogManager.method_warning(f"强杀失败:{e}", method, udid="system")
|
||||||
|
|
||||||
# ---------------- 端口分配(加锁 + 稳定端口) ----------------
|
# ---------------- 自愈:直接换新端口重启 + 指数退避 ----------------
|
||||||
def _alloc_port(self, udid: str) -> int:
|
def _restart_iproxy(self, udid: str):
|
||||||
"""
|
method = "_restart_iproxy"
|
||||||
为 UDID 分配一个**稳定**的本地端口:
|
now = time.time()
|
||||||
- 同一 UDID 优先复用上次端口(减少前端切换)
|
next_allowed = self._heal_backoff.get(udid, 0.0)
|
||||||
- 初次分配使用 “20000-45000” 的稳定哈希起点向上探测空闲
|
if now < next_allowed:
|
||||||
"""
|
delta = round(next_allowed - now, 2)
|
||||||
# 已有则直接复用
|
LogManager.method_info(f"自愈被退避抑制,剩余 {delta}s", method, udid=udid)
|
||||||
if udid in self._port_by_udid:
|
return
|
||||||
p = self._port_by_udid[udid]
|
|
||||||
if self._is_port_free(p):
|
|
||||||
return p
|
|
||||||
|
|
||||||
# 基于 UDID 计算稳定起点
|
with self._lock:
|
||||||
h = int(hashlib.sha1(udid.encode("utf-8")).hexdigest(), 16)
|
proc = self._procs.get(udid)
|
||||||
start = 20000 + (h % 25000) # 20000~44999
|
if proc:
|
||||||
# 避免和你类里默认的 9110 等端口冲突,向上找空闲
|
LogManager.method_info(f"为重启准备清理旧 iproxy,pid={proc.pid}", method, udid=udid)
|
||||||
p = self._pick_free_port(start=start, limit=4000)
|
self._kill(proc)
|
||||||
self._port_by_udid[udid] = p
|
time.sleep(0.2)
|
||||||
return p
|
model = self._models.get(udid)
|
||||||
|
if not model:
|
||||||
|
LogManager.method_warning("模型不存在,取消自愈", method, udid=udid)
|
||||||
|
return
|
||||||
|
|
||||||
def _manager_send(self, model: DeviceModel):
|
proc2 = self._start_iproxy(udid, port=None)
|
||||||
|
if not proc2:
|
||||||
|
backoff_old = max(1.5, next_allowed - now + 1.0) if next_allowed > now else 1.5
|
||||||
|
backoff = min(backoff_old * 1.7, 15.0)
|
||||||
|
self._heal_backoff[udid] = now + backoff
|
||||||
|
LogManager.method_warning(f"重启失败,扩展退避 {round(backoff,2)}s", method, udid=udid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 成功后短退避
|
||||||
|
self._heal_backoff[udid] = now + 1.2
|
||||||
|
|
||||||
|
# 通知前端新端口
|
||||||
|
with self._lock:
|
||||||
|
model = self._models.get(udid)
|
||||||
|
if model:
|
||||||
|
model.screenPort = self._port_by_udid.get(udid, model.screenPort)
|
||||||
|
self._models[udid] = model
|
||||||
|
self._manager_send(model)
|
||||||
|
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:
|
||||||
|
method = "_health_check_mjpeg"
|
||||||
try:
|
try:
|
||||||
self._manager.send(model.toDict())
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
|
||||||
|
conn.request("HEAD", "/")
|
||||||
|
resp = conn.getresponse()
|
||||||
|
_ = resp.read(128)
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return False
|
||||||
|
|
||||||
def _find_iproxy(self) -> str:
|
def _health_check_wda(self, udid: str) -> bool:
|
||||||
base = Path(__file__).resolve().parent.parent
|
method = "_health_check_wda"
|
||||||
name = "iproxy.exe"
|
try:
|
||||||
path = base / "resources" / "iproxy" / name
|
c = wda.USBClient(udid, wdaFunctionPort)
|
||||||
print(str(path))
|
st = c.status()
|
||||||
if path.is_file():
|
return bool(st)
|
||||||
return str(path)
|
except Exception:
|
||||||
raise FileNotFoundError(f"iproxy 不存在: {path}")
|
return False
|
||||||
|
|
||||||
# ------------ Windows 专用:列出所有 iproxy 命令行(更安全) ------------
|
def _check_and_heal_tunnels(self, interval: float = 5.0):
|
||||||
|
method = "_check_and_heal_tunnels"
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_heal_check_ts < interval:
|
||||||
|
return
|
||||||
|
self._last_heal_check_ts = now
|
||||||
|
|
||||||
|
if (now - self._last_topology_change_ts) < max(self.ORPHAN_COOLDOWN, 6.0):
|
||||||
|
LogManager.method_info("拓扑变更冷却中,本轮跳过自愈", method, udid="system")
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
items = list(self._models.items())
|
||||||
|
|
||||||
|
for udid, model in items:
|
||||||
|
port = model.screenPort
|
||||||
|
if port <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok_local = self._health_check_mjpeg(port, timeout=0.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):
|
||||||
|
LogManager.method_warning(f"检测到不健康,触发重启;port={port}", method, udid=udid)
|
||||||
|
self._restart_iproxy(udid)
|
||||||
|
|
||||||
|
# ---------------- Windows 专用:列出所有 iproxy 命令行 ----------------
|
||||||
def _get_all_iproxy_cmdlines(self) -> List[str]:
|
def _get_all_iproxy_cmdlines(self) -> List[str]:
|
||||||
|
method = "_get_all_iproxy_cmdlines"
|
||||||
lines: List[str] = []
|
lines: List[str] = []
|
||||||
live_pids = set()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
live_pids = set(self._pid_by_udid.values())
|
live_pids = set(self._pid_by_udid.values())
|
||||||
for p in psutil.process_iter(attrs=["name", "cmdline", "pid"]):
|
for p in psutil.process_iter(attrs=["name", "cmdline", "pid"]):
|
||||||
@@ -290,7 +440,6 @@ class DeviceInfo:
|
|||||||
name = (p.info.get("name") or "").lower()
|
name = (p.info.get("name") or "").lower()
|
||||||
if name != "iproxy.exe":
|
if name != "iproxy.exe":
|
||||||
continue
|
continue
|
||||||
# 跳过我们自己登记在册的 iproxy,避免误杀
|
|
||||||
if p.info["pid"] in live_pids:
|
if p.info["pid"] in live_pids:
|
||||||
continue
|
continue
|
||||||
cmdline = p.info.get("cmdline") or []
|
cmdline = p.info.get("cmdline") or []
|
||||||
@@ -301,41 +450,48 @@ class DeviceInfo:
|
|||||||
lines.append(f"{cmd} {p.info['pid']}")
|
lines.append(f"{cmd} {p.info['pid']}")
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
continue
|
continue
|
||||||
|
LogManager.method_info(f"扫描到候选 iproxy 进程数={len(lines)}", method, udid="system")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
# ------------ 杀孤儿 ------------
|
# ---------------- 杀孤儿 ----------------
|
||||||
def _cleanup_orphan_iproxy(self):
|
def _cleanup_orphan_iproxy(self):
|
||||||
live_udids = set()
|
method = "_cleanup_orphan_iproxy"
|
||||||
live_pids = set()
|
|
||||||
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_pids = set(self._pid_by_udid.values())
|
||||||
|
|
||||||
|
cleaned = 0
|
||||||
for ln in self._get_all_iproxy_cmdlines():
|
for ln in self._get_all_iproxy_cmdlines():
|
||||||
parts = ln.split()
|
parts = ln.split()
|
||||||
try:
|
try:
|
||||||
udid = parts[parts.index('-u') + 1]
|
udid = parts[parts.index('-u') + 1]
|
||||||
pid = int(parts[-1])
|
pid = int(parts[-1])
|
||||||
# 既不在我们的 PID 表里,且 UDID 不在线,才算孤儿
|
|
||||||
if pid not in live_pids and udid not in live_udids:
|
if pid not in live_pids and udid not in live_udids:
|
||||||
self._kill_pid_gracefully(pid)
|
self._kill_pid_gracefully(pid)
|
||||||
LogManager.warning(f'扫到孤儿 iproxy,已清理 {udid} PID={pid}')
|
cleaned += 1
|
||||||
|
LogManager.method_warning(f"孤儿 iproxy 已清理:udid={udid}, pid={pid}", method, udid="system")
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ------------ 按 PID 强杀 ------------
|
if cleaned:
|
||||||
|
LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method, udid="system")
|
||||||
|
|
||||||
|
# ---------------- 按 PID 强杀 ----------------
|
||||||
def _kill_pid_gracefully(self, pid: int):
|
def _kill_pid_gracefully(self, pid: int):
|
||||||
|
method = "_kill_pid_gracefully"
|
||||||
try:
|
try:
|
||||||
p = psutil.Process(pid)
|
p = psutil.Process(pid)
|
||||||
p.terminate()
|
p.terminate()
|
||||||
try:
|
try:
|
||||||
p.wait(timeout=1.0)
|
p.wait(timeout=1.0)
|
||||||
|
LogManager.method_info(f"进程已终止:pid={pid}", method, udid="system")
|
||||||
except psutil.TimeoutExpired:
|
except psutil.TimeoutExpired:
|
||||||
p.kill()
|
p.kill()
|
||||||
except Exception:
|
LogManager.method_warning(f"进程被强制 kill:pid={pid}", method, udid="system")
|
||||||
pass
|
except Exception as e:
|
||||||
|
LogManager.method_warning(f"kill 进程异常:pid={pid}, err={e}", method, udid="system")
|
||||||
|
|
||||||
# ------------ 端口工具 ------------
|
# ---------------- 端口工具(兜底) ----------------
|
||||||
def _is_port_free(self, port: int) -> bool:
|
def _is_port_free(self, port: int) -> bool:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
@@ -347,91 +503,35 @@ class DeviceInfo:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _pick_free_port(self, start: int = None, limit: int = 2000) -> int:
|
def _pick_free_port(self, start: int = None, limit: int = 2000) -> int:
|
||||||
"""从 start 起向上找一个空闲端口。(注意:调用方务必在 self._lock 下)"""
|
method = "_pick_free_port"
|
||||||
p = self._port if start is None else start
|
p = self._port if start is None else start
|
||||||
tried = 0
|
tried = 0
|
||||||
while tried < limit:
|
while tried < limit:
|
||||||
p += 1
|
p += 1
|
||||||
tried += 1
|
tried += 1
|
||||||
if self._is_port_free(p):
|
if self._is_port_free(p):
|
||||||
self._port = p # 更新游标
|
LogManager.method_info(f"顺序扫描找到端口:{p}", method, udid="system")
|
||||||
return p
|
return p
|
||||||
|
LogManager.method_error("顺序扫描未找到可用端口(范围内)", method, udid="system")
|
||||||
raise RuntimeError("未找到可用端口(扫描范围内)")
|
raise RuntimeError("未找到可用端口(扫描范围内)")
|
||||||
|
|
||||||
def _health_check_mjpeg(self, port: int, timeout: float = 1.0) -> bool:
|
# ---------------- 其他 ----------------
|
||||||
"""
|
def _manager_send(self, model: DeviceModel):
|
||||||
对 http://127.0.0.1:<port>/ 做非常轻量的探活。
|
method = "_manager_send"
|
||||||
WDA mjpegServer(默认9100)通常根路径就会有 multipart/x-mixed-replace。
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
|
self._manager.send(model.toDict())
|
||||||
conn.request("GET", "/")
|
LogManager.method_info("已通知管理器(前端)", method, udid=model.deviceId)
|
||||||
resp = conn.getresponse()
|
except Exception as e:
|
||||||
alive = 200 <= resp.status < 400
|
LogManager.method_warning(f"通知管理器异常:{e}", method, udid=model.deviceId)
|
||||||
try:
|
|
||||||
resp.read(256)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
conn.close()
|
|
||||||
return alive
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _restart_iproxy(self, udid: str):
|
def _find_iproxy(self) -> str:
|
||||||
"""重启某个 udid 的 iproxy(带退避)。"""
|
method = "_find_iproxy"
|
||||||
now = time.time()
|
base = Path(__file__).resolve().parent.parent
|
||||||
next_allowed = self._heal_backoff[udid]
|
name = "iproxy.exe"
|
||||||
if now < next_allowed:
|
path = base / "resources" / "iproxy" / name
|
||||||
return # 处于退避窗口内,先不重启
|
LogManager.method_info(f"查找 iproxy 路径:{path}", method, udid="system")
|
||||||
|
if path.is_file():
|
||||||
with self._lock:
|
return str(path)
|
||||||
proc = self._procs.get(udid)
|
err = f"iproxy 不存在: {path}"
|
||||||
if proc:
|
LogManager.method_error(err, method, udid="system")
|
||||||
self._kill(proc)
|
raise FileNotFoundError(err)
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
model = self._models.get(udid)
|
|
||||||
if not model:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 如果端口被别的进程占用了,换一个新端口并通知管理器
|
|
||||||
if not self._is_port_free(model.screenPort):
|
|
||||||
new_port = self._pick_free_port(start=max(self._port, model.screenPort))
|
|
||||||
model.screenPort = new_port
|
|
||||||
self._models[udid] = model
|
|
||||||
self._port_by_udid[udid] = new_port
|
|
||||||
self._manager_send(model) # 通知前端/上位机端口变化
|
|
||||||
|
|
||||||
proc2 = self._start_iproxy(udid, model.screenPort)
|
|
||||||
if not proc2:
|
|
||||||
# 启动失败,设置退避(逐步增加上限)
|
|
||||||
self._heal_backoff[udid] = now + 2.0
|
|
||||||
return
|
|
||||||
|
|
||||||
self._procs[udid] = proc2
|
|
||||||
self._pid_by_udid[udid] = proc2.pid
|
|
||||||
|
|
||||||
# 成功后缩短退避
|
|
||||||
self._heal_backoff[udid] = now + 0.5
|
|
||||||
|
|
||||||
def _check_and_heal_tunnels(self, interval: float = 2.0):
|
|
||||||
"""
|
|
||||||
定期巡检所有在线设备的本地映射端口是否“活着”,不活就重启 iproxy。
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
if now - self._last_heal_check_ts < interval:
|
|
||||||
return
|
|
||||||
self._last_heal_check_ts = now
|
|
||||||
|
|
||||||
# 读取时也加锁,避免与增删设备并发冲突
|
|
||||||
with self._lock:
|
|
||||||
items = list(self._models.items())
|
|
||||||
|
|
||||||
for udid, model in items:
|
|
||||||
port = model.screenPort
|
|
||||||
if port <= 0:
|
|
||||||
continue
|
|
||||||
ok = self._health_check_mjpeg(port, timeout=0.8)
|
|
||||||
if not ok:
|
|
||||||
LogManager.warning(f"端口失活,准备自愈:udid={udid} port={port}")
|
|
||||||
self._restart_iproxy(udid)
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
resources/00008110-000120603C13801E/bgv.png
Normal file
BIN
resources/00008110-000120603C13801E/bgv.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
resources/00008110-000120603C13801E/bgv_comment.png
Normal file
BIN
resources/00008110-000120603C13801E/bgv_comment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
Reference in New Issue
Block a user