diff --git a/Module/DeviceInfo.py b/Module/DeviceInfo.py index abd722b..02b2f16 100644 --- a/Module/DeviceInfo.py +++ b/Module/DeviceInfo.py @@ -19,8 +19,8 @@ class DeviceInfo: _instance = None _instance_lock = threading.Lock() - # 离线宽限期 - REMOVE_GRACE_SEC = 3.0 + # 离线宽限期(保持你原来的数值) + REMOVE_GRACE_SEC = 5.0 def __new__(cls, *args, **kwargs): if not cls._instance: @@ -44,12 +44,16 @@ class DeviceInfo: # iproxy 子进程:udid -> Popen self._iproxy_process: Dict[str, subprocess.Popen] = {} + # iproxy HTTP 健康检查失败次数:udid -> 连续失败次数 + self._iproxy_fail_count: Dict[str, int] = {} + # Windows 下隐藏子进程窗口(给 iproxy 用) self._creationflags = 0 self._startupinfo = None if os.name == "nt": try: - self._creationflags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined] + # type: ignore[attr-defined] + self._creationflags = subprocess.CREATE_NO_WINDOW except Exception: self._creationflags = 0 @@ -62,9 +66,6 @@ class DeviceInfo: print("[Init] DeviceInfo 初始化完成") self._initialized = True - # ========================== - # 主循环 - # ========================== # ========================== # 主循环 # ========================== @@ -87,52 +88,62 @@ class DeviceInfo: # 当前已知的设备(本轮循环开始时) with self._lock: known = set(self._models.keys()) - # 当前已经维护的设备数量 current_count = len(self._models) # 1. 处理在线设备 for udid in online: - # 更新心跳时间(只要在 online 里,都记一笔,方便离线判断) + # 更新心跳时间 self._last_seen[udid] = now - # 如果是新设备并且数量已经 >= 6,直接忽略 + # 新设备但数量已达上限 if udid not in known and current_count >= 6: print(f"[Add] 设备数量已达 6 台,忽略新设备: {udid}") - LogManager.info(f"[Add] 设备数量已达上限(6),忽略新设备", udid=udid) + LogManager.info( + "[Add] 设备数量已达上限(6),忽略新设备", + udid=udid, + ) continue - # 已经在列表里的设备,直接跳过“是否信任”检查和添加流程 + # 已经在列表里的设备,跳过添加流程 if udid in known: continue - # 只对“新发现”的设备做一次信任检查 + # 只对新发现的设备做一次信任检查 try: if not self._is_trusted(udid): - # 未信任 / 未配对 / 暂时不可用,本轮直接跳过 - LogManager.info(f"[Add] 设备未信任或未就绪,跳过本轮添加: {udid}", udid=udid) + LogManager.info( + "[Add] 设备未信任或未就绪,跳过本轮添加", + udid=udid, + ) print(f"[Add] 设备未信任或未就绪,跳过: {udid}") continue except Exception as e: - # 信任检查本身异常,也当作暂时未就绪处理 - LogManager.warning(f"[Add] 检测设备 {udid} 信任状态异常: {e}", udid=udid) + LogManager.warning( + f"[Add] 检测设备 {udid} 信任状态异常: {e}", + udid=udid, + ) print(f"[Add] 检测设备 {udid} 信任状态异常: {e}") continue - # 再次确认一下数量(极端情况下,多线程同时改 models 的话更稳妥一点) + # 二次确认数量上限 with self._lock: if len(self._models) >= 6: print(f"[Add] 二次检查: 设备数量已达 6 台,忽略新设备: {udid}") - LogManager.info(f"[Add] 二次检查数量上限,忽略新设备", udid=udid) + LogManager.info( + "[Add] 二次检查数量上限,忽略新设备", + udid=udid, + ) continue - # 确认“已信任”的新设备,才真正走 _add_device + # 真正添加设备 try: self._add_device(udid) - # 本轮循环内的计数也 +1,防止这一轮里连续加超过 6 台 current_count += 1 except Exception as e: - # 单设备异常不能干掉整个循环 - LogManager.warning(f"[Add] 处理设备 {udid} 异常: {e}", udid=udid) + LogManager.warning( + f"[Add] 处理设备 {udid} 异常: {e}", + udid=udid, + ) print(f"[Add] 处理设备 {udid} 异常: {e}") # 2. 处理可能离线的设备(只看本轮开始时 known 里的) @@ -143,26 +154,37 @@ class DeviceInfo: try: self._remove_device(udid) except Exception as e: - LogManager.method_error(f"移除失败:{e}", "listen", udid=udid) + LogManager.method_error( + f"移除失败:{e}", + "listen", + udid=udid, + ) print(f"[Remove] 移除失败 {udid}: {e}") + # 3. iproxy 看门狗(进程 + HTTP 探活) + try: + self._check_iproxy_health() + except Exception as e: + LogManager.warning( + f"[iproxy] 看门狗异常: {e}", + udid="system", + ) + print(f"[iproxy] 看门狗异常: {e}") + time.sleep(1) # 判断设备是否信任 def _is_trusted(self, udid: str) -> bool: try: d = tidevice.Device(udid) - # 随便读一个需要 lockdown/配对的字段 - _ = d.product_version # 或 d.info,视你当前 tidevice 版本而定 + _ = d.product_version return True except Exception as e: msg = str(e) - # 这里只是示意,你可以根据你本地真实报错关键字再精细一点 if "NotTrusted" in msg or "Please trust" in msg or "InvalidHostID" in msg: print(f"[Trust] 设备未信任,udid={udid}, err={msg}") return False - # 如果是别的错误(比如瞬时通信异常),我倾向于当作“暂时不信任”,避免把有问题的设备加进去 print(f"[Trust] 检测信任状态出错,当作未信任处理 udid={udid}, err={msg}") return False @@ -184,7 +206,7 @@ class DeviceInfo: print(f"[Add] 获取系统版本失败 {udid}: {e}") version_major = 0 - # 分配投屏端口 & 写入模型(先插入,width/height=0,后面再异步更新) + # 分配投屏端口 & 写入模型 with self._lock: self.screenPort += 1 screen_port = self.screenPort @@ -207,21 +229,24 @@ class DeviceInfo: self._start_iproxy(udid, screen_port) except Exception as e: print(f"[iproxy] 启动失败 {udid}: {e}") + LogManager.warning(f"[iproxy] 启动失败: {e}", udid=udid) # 启动 WDA if version_major >= 17.0: - # iOS17+ 走 go-ios,传入回调:WDA 启动后再拿屏幕尺寸 threading.Thread( target=IOSActivator().activate_ios17, args=(udid, self._on_wda_ready), daemon=True, ).start() else: - # 旧版本直接用 tidevice 启动 WDA,然后异步获取屏幕尺寸 try: tidevice.Device(udid).app_start(WdaAppBundleId) except Exception as e: print(f"[Add] 使用 tidevice 启动 WDA 失败 {udid}: {e}") + LogManager.warning( + f"[Add] 使用 tidevice 启动 WDA 失败: {e}", + udid=udid, + ) else: threading.Thread( target=self._fetch_screen_and_notify, @@ -234,7 +259,6 @@ class DeviceInfo: # ========================== def _on_wda_ready(self, udid: str): print(f"[WDA] 回调触发,准备获取屏幕信息 udid={udid}") - # 稍微等一下再连,避免刚启动时不稳定 time.sleep(1) threading.Thread( target=self._fetch_screen_and_notify, @@ -247,13 +271,12 @@ class DeviceInfo: # ========================== def _screen_info(self, udid: str): try: - # 用 USBClient,通过 WDA 功能端口访问 c = wda.USBClient(udid, wdaFunctionPort) size = c.window_size() w = int(size.width) h = int(size.height) - s = float(c.scale) # facebook-wda 的 scale 挂在 client 上 + s = float(c.scale) print(f"[Screen] 成功获取屏幕 {w}x{h} scale={s} {udid}") return w, h, s @@ -272,10 +295,8 @@ class DeviceInfo: max_retry = 15 interval = 1.0 - # 给 WDA 一点启动缓冲时间 time.sleep(2.0) for _ in range(max_retry): - # 设备已移除就不再尝试 with self._lock: if udid not in self._models: print(f"[Screen] 设备已移除,停止获取屏幕信息 udid={udid}") @@ -283,7 +304,6 @@ class DeviceInfo: w, h, s = self._screen_info(udid) if w > 0 and h > 0: - # 更新模型 with self._lock: m = self._models.get(udid) if not m: @@ -310,7 +330,6 @@ class DeviceInfo: def _start_iproxy(self, udid: str, local_port: int): iproxy_path = self._find_iproxy() - # 已有进程并且还在跑,就不重复起 p = self._iproxy_process.get(udid) if p is not None and p.poll() is None: print(f"[iproxy] 已存在运行中的进程,跳过 {udid}") @@ -318,14 +337,14 @@ class DeviceInfo: args = [ iproxy_path, - "-u", udid, - str(local_port), # 本地端口(投屏) - "9567" # 手机端口(go-ios screencast) + "-u", + udid, + str(local_port), # 本地端口(投屏) + "9567", # 手机端口(go-ios screencast) ] print(f"[iproxy] 启动进程: {args}") - # 不用 PIPE,防止没人读导致缓冲爆掉;窗口用前面配置隐藏 proc = subprocess.Popen( args, stdout=subprocess.DEVNULL, @@ -351,18 +370,109 @@ class DeviceInfo: self._iproxy_process.pop(udid, None) print(f"[iproxy] 已停止 {udid}") + def _is_iproxy_http_healthy(self, local_port: int, timeout: float = 1.0) -> bool: + """ + 通过向本地 iproxy 转发端口发一个最小的 HTTP 请求, + 来判断隧道是否“活着”: + - 正常:能在超时时间内读到一些 HTTP 头 / 任意字节; + - 异常:连接失败、超时、完全收不到字节,都认为不健康。 + """ + try: + with socket.create_connection(("127.0.0.1", local_port), timeout=timeout) as s: + s.settimeout(timeout) + + req = b"GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" + s.sendall(req) + + data = s.recv(128) + if not data: + return False + + if data.startswith(b"HTTP/") or b"\r\n" in data: + return True + + # 即使不是标准 HTTP 头,只要有返回字节,也说明隧道有响应 + return True + + except (socket.timeout, OSError): + return False + except Exception: + return False + + def _check_iproxy_health(self): + """ + iproxy 看门狗: + - 先看进程是否存在 / 已退出; + - 再做一次 HTTP 层探活; + - 连续多次失败才重启,避免抖动时频繁重启。 + """ + with self._lock: + items = list(self._models.items()) + + for udid, model in items: + proc = self._iproxy_process.get(udid) + + # 1) 进程不存在或已退出:直接重启 + if proc is None or proc.poll() is not None: + msg = f"[iproxy] 进程已退出,准备重启 | udid={udid}" + print(msg) + LogManager.warning(msg, "iproxy") + + self._iproxy_fail_count[udid] = 0 + try: + self._start_iproxy(udid, model.screenPort) + except Exception as e: + msg = f"[iproxy] 重启失败 | udid={udid} | err={e}" + print(msg) + LogManager.warning(msg, "iproxy") + continue + + # 2) 进程还在,做一次 HTTP 探活 + is_ok = self._is_iproxy_http_healthy(model.screenPort) + + if is_ok: + if self._iproxy_fail_count.get(udid): + msg = f"[iproxy] HTTP 探活恢复正常 | udid={udid}" + print(msg) + LogManager.info(msg, "iproxy") + self._iproxy_fail_count[udid] = 0 + continue + + # 3) HTTP 探活失败:记录一次失败 + fail = self._iproxy_fail_count.get(udid, 0) + 1 + self._iproxy_fail_count[udid] = fail + + msg = f"[iproxy] HTTP 探活失败 {fail} 次 | udid={udid}" + print(msg) + LogManager.warning(msg, "iproxy") + + FAIL_THRESHOLD = 3 + if fail >= FAIL_THRESHOLD: + msg = f"[iproxy] 连续 {fail} 次 HTTP 探活失败,准备重启 | udid={udid}" + print(msg) + LogManager.warning(msg, "iproxy") + + self._iproxy_fail_count[udid] = 0 + try: + self._stop_iproxy(udid) + self._start_iproxy(udid, model.screenPort) + except Exception as e: + msg = f"[iproxy] HTTP 探活重启失败 | udid={udid} | err={e}" + print(msg) + LogManager.warning(msg, "iproxy") + # ========================== # 移除设备 # ========================== def _remove_device(self, udid: str): print(f"[Remove] 移除设备 {udid}") - # 先停 iproxy self._stop_iproxy(udid) with self._lock: self._models.pop(udid, None) self._last_seen.pop(udid, None) + self._iproxy_fail_count.pop(udid, None) self._manager_send()