Files
iOSAI/Module/DeviceInfo.py
2025-11-19 17:23:41 +08:00

910 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
极简稳定版设备监督器DeviceInfo加详细 print 日志
- 每个关键节点都会 print便于人工观察执行到哪一步
- 保留核心逻辑:监听上下线 / 启动 WDA / 起 iproxy / 通知前端
- 并发提速_add_device 异步化(受控并发)
- iproxy 守护:本地端口 + /status 探活,不通则自愈重启;连续失败达阈值才移除
"""
import datetime
import http.client
import json
import os
import socket
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Dict, Optional
import psutil
import tidevice
import wda
from tidevice import Usbmux, ConnectionType
from tidevice._device import BaseDevice
from Entity.DeviceModel import DeviceModel
from Entity.Variables import WdaAppBundleId, wdaScreenPort, wdaFunctionPort
from Module.FlaskSubprocessManager import FlaskSubprocessManager
from Module.IOSActivator import IOSActivator
from Utils.LogManager import LogManager
def _monotonic() -> float:
return time.monotonic()
def _is_port_free(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_free_port(low: int = 20000, high: int = 48000) -> int:
"""全局兜底的端口选择:先随机后顺扫,避免固定起点导致碰撞。支持通过环境变量覆盖范围:
PORT_RANGE_LOW / PORT_RANGE_HIGH
"""
try:
low = int(os.getenv("PORT_RANGE_LOW", str(low)))
high = int(os.getenv("PORT_RANGE_HIGH", str(high)))
except Exception:
pass
if high - low < 100:
high = low + 100
import random
# 随机尝试 64 次
tried = set()
for _ in range(64):
p = random.randint(low, high)
if p in tried:
continue
tried.add(p)
if _is_port_free(p):
return p
# 顺序兜底
for p in range(low, high):
if p in tried:
continue
if _is_port_free(p):
return p
raise RuntimeError("未找到可用端口")
class DeviceInfo:
_instance = None
_instance_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
# 双重检查锁,确保线程安全单例
if not cls._instance:
with cls._instance_lock:
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
# ---- 端口分配:加一个最小的“保留池”,避免并发选到同一个端口 ----
def _alloc_port(self) -> int:
with self._lock:
busy = set(self._port_by_udid.values()) | set(self._reserved_ports)
# 优先随机尝试若干次,减少并发碰撞
import random
low = int(os.getenv("PORT_RANGE_LOW", "20000"))
high = int(os.getenv("PORT_RANGE_HIGH", "48000"))
for _ in range(128):
p = random.randint(low, high)
with self._lock:
if p not in busy and p not in self._reserved_ports and _is_port_free(p):
self._reserved_ports.add(p)
return p
# 兜底顺序扫描
for p in range(low, high):
with self._lock:
if p in self._reserved_ports or p in busy:
continue
if _is_port_free(p):
with self._lock:
self._reserved_ports.add(p)
return p
raise RuntimeError("端口分配失败:没有可用端口")
def _release_port(self, port: int):
with self._lock:
self._reserved_ports.discard(port)
ADD_STABLE_SEC = float(os.getenv("ADD_STABLE_SEC", "2.0"))
REMOVE_GRACE_SEC = float(os.getenv("REMOVE_GRACE_SEC", "6.0"))
WDA_READY_TIMEOUT = float(os.getenv("WDA_READY_TIMEOUT", "35.0"))
def __init__(self) -> None:
# 防止多次初始化(因为 __init__ 每次调用 DeviceInfo() 都会执行)
if getattr(self, "_initialized", False):
return
self._lock = threading.RLock()
self._models: Dict[str, DeviceModel] = {}
self._iproxy: Dict[str, subprocess.Popen] = {}
self._port_by_udid: Dict[str, int] = {}
self._reserved_ports: set[int] = set()
self._first_seen: Dict[str, float] = {}
self._last_seen: Dict[str, float] = {}
self._manager = FlaskSubprocessManager.get_instance()
self._iproxy_path = self._find_iproxy()
# 懒加载线程池属性(供 _add_device 并发使用)
self._add_lock: Optional[threading.RLock] = None
self._adding_udids: Optional[set[str]] = None
self._add_executor: Optional["ThreadPoolExecutor"] = None
# iproxy 连续失败计数(守护用)
self._iproxy_fail_count: Dict[str, int] = {}
LogManager.info("DeviceInfo 初始化完成", udid="system")
print("[Init] DeviceInfo 初始化完成")
# iproxy 守护线程(端口+HTTP探活 → 自愈重启 → 达阈值才移除)
threading.Thread(target=self.check_iproxy_ports, daemon=True).start()
self._initialized = True # 标记已初始化
# =============== 并发添加设备:最小改动(包装 _add_device ===============
def _ensure_add_executor(self):
"""
懒加载:首次调用 _add_device 时初始化线程池与去重集合。
注意:不要只用 hasattr属性可能已在 __init__ 里置为 None。
"""
# 1) 锁
if getattr(self, "_add_lock", None) is None:
self._add_lock = threading.RLock()
# 2) 去重集合
if getattr(self, "_adding_udids", None) is None:
self._adding_udids = set()
# 3) 线程池
if getattr(self, "_add_executor", None) is None:
from concurrent.futures import ThreadPoolExecutor
import os
max_workers = int(os.getenv("DEVICE_ADD_WORKERS", "6"))
self._add_executor = ThreadPoolExecutor(
max_workers=max_workers,
thread_name_prefix="dev-add"
)
try:
LogManager.info(f"[Init] Device add executor started, max_workers={max_workers}", udid="system")
except Exception:
pass
def _safe_add_device(self, udid: str):
"""
后台执行真正的新增实现_add_device_impl
- 任何异常只记日志,不抛出
- 无论成功与否,都在 finally 里清理“正在添加”标记
"""
try:
self._add_device_impl(udid) # ← 这是你原来的重逻辑(见下方)
except Exception as e:
try:
LogManager.method_error(f"_add_device_impl 异常:{e}", "_safe_add_device", udid=udid)
except Exception:
pass
finally:
lock = getattr(self, "_add_lock", None)
if lock is None:
# 极端容错,避免再次抛异常
self._add_lock = lock = threading.RLock()
with lock:
self._adding_udids.discard(udid)
def _add_device(self, udid: str):
"""并发包装器:保持所有调用点不变。"""
self._ensure_add_executor()
# 保险:即使极端情况下属性仍是 None也就地补齐一次
lock = getattr(self, "_add_lock", None)
if lock is None:
self._add_lock = lock = threading.RLock()
adding = getattr(self, "_adding_udids", None)
if adding is None:
self._adding_udids = adding = set()
# 去重:同一 udid 只提交一次
with lock:
if udid in adding:
return
adding.add(udid)
try:
# 注意submit(fn, udid) —— 这里不是 *args=udid直接传第二个位置参数即可
self._add_executor.submit(self._safe_add_device, udid)
except Exception as e:
# 提交失败要把去重标记清掉
with lock:
adding.discard(udid)
try:
LogManager.method_error(text=f"提交新增任务失败:{e}", method="_add_device", udid=udid)
except Exception:
pass
# =============== iproxy 健康检查 / 自愈 ===============
def _iproxy_tcp_probe(self, port: int, timeout: float = 0.6) -> bool:
"""快速 TCP 探测:能建立连接即认为本地监听正常。"""
try:
with socket.create_connection(("127.0.0.1", int(port)), timeout=timeout):
return True
except Exception:
return False
def _iproxy_http_status_ok_quick(self, port: int, timeout: float = 1.2) -> bool:
"""
轻量 HTTP 探测GET /status
- 成功返回 2xx/3xx 视为 OK
- 4xx/5xx 也说明链路畅通(服务可交互),这里统一认为 OK避免误判
"""
try:
conn = http.client.HTTPConnection("127.0.0.1", int(port), timeout=timeout)
conn.request("GET", "/status")
resp = conn.getresponse()
_ = resp.read(128)
code = getattr(resp, "status", 0)
conn.close()
# 任何能返回 HTTP 的,都说明“有服务可交互”
return 100 <= code <= 599
except Exception:
return False
def _iproxy_health_ok(self, udid: str, port: int) -> bool:
# 1) 监听检测:不通直接 False
if not self._iproxy_tcp_probe(port, timeout=0.6):
return False
# 2) 业务探测:/status 慢可能是 WDA 卡顿;失败不等同于“端口坏”
if not self._iproxy_http_status_ok_quick(port, timeout=1.2):
print(f"[iproxy-health] /status 超时,视为轻微异常 {udid}:{port}")
return True
return True
def _restart_iproxy(self, udid: str, port: int) -> bool:
"""干净重启 iproxy先杀旧的再启动新的并等待监听。"""
print(f"[iproxy-guard] 准备重启 iproxy {udid} on {port}")
proc = None
with self._lock:
old = self._iproxy.get(udid)
try:
if old:
self._kill(old)
except Exception as e:
print(f"[iproxy-guard] 杀旧进程异常 {udid}: {e}")
# 重新拉起
try:
proc = self._start_iproxy(udid, local_port=port)
except Exception as e:
print(f"[iproxy-guard] 重启失败 {udid}: {e}")
proc = None
if not proc:
return False
# 写回进程表
with self._lock:
self._iproxy[udid] = proc
print(f"[iproxy-guard] 重启成功 {udid} port={port}")
return True
# =============== 一轮检查:先自愈,仍失败才考虑移除 =================
def check_iproxy_ports(self, connect_timeout: float = 3) -> None:
"""
周期性巡检(默认每 10s 一次):
- 在线设备(type=1)
1) 先做 TCP+HTTP(/status) 探测(封装在 _iproxy_health_ok
2) 失败 → 自愈重启 iproxy仍失败则累计失败计数
3) 连续失败次数 >= 阈值 → 【不删除设备】只标记降级ready=False, streamBroken=True
4) 恢复时清零计数并标记恢复ready=True, streamBroken=False
"""
# 启动延迟,等新增流程跑起来,避免误判
time.sleep(20)
FAIL_THRESHOLD = int(os.getenv("IPROXY_FAIL_THRESHOLD", "3")) # 连续失败阈值(可用环境变量调)
INTERVAL_SEC = int(os.getenv("IPROXY_CHECK_INTERVAL", "10")) # 巡检间隔
try:
while True:
snapshot = list(self._models.items()) # [(deviceId, DeviceModel), ...]
for device_id, model in snapshot:
try:
if model.type != 1:
# 离线设备清零计数
self._iproxy_fail_count.pop(device_id, None)
continue
port = int(model.screenPort)
if port <= 0 or port > 65535:
continue
# 健康探测
ok = self._iproxy_health_ok(device_id, port)
if ok:
# 健康:清零计数
if self._iproxy_fail_count.get(device_id):
self._iproxy_fail_count[device_id] = 0
# CHANGED: 若之前降级过,这里标记恢复并上报
need_report = False
with self._lock:
m = self._models.get(device_id)
if m:
prev_ready = getattr(m, "ready", True)
prev_broken = getattr(m, "streamBroken", False)
if (not prev_ready) or prev_broken:
m.ready = True
if prev_broken:
try:
delattr(m, "streamBroken")
except Exception:
setattr(m, "streamBroken", False)
need_report = True
if need_report and m:
try:
print(f"[iproxy-check] 自愈成功,恢复就绪 deviceId={device_id} port={port}")
self._manager_send()
except Exception as e:
print(f"[iproxy-check] 上报恢复异常 deviceId={device_id}: {e}")
# print(f"[iproxy-check] OK deviceId={device_id} port={port}")
continue
# 第一次失败:尝试自愈重启
print(f"[iproxy-check] 探活失败,准备自愈重启 deviceId={device_id} port={port}")
healed = self._restart_iproxy(device_id, port)
# 重启后再探测一次
ok2 = self._iproxy_health_ok(device_id, port) if healed else False
if ok2:
print(f"[iproxy-check] 自愈成功 deviceId={device_id} port={port}")
self._iproxy_fail_count[device_id] = 0
# CHANGED: 若之前降级过,这里也顺便恢复并上报
need_report = False
with self._lock:
m = self._models.get(device_id)
if m:
prev_ready = getattr(m, "ready", True)
prev_broken = getattr(m, "streamBroken", False)
if (not prev_ready) or prev_broken:
m.ready = True
if prev_broken:
try:
delattr(m, "streamBroken")
except Exception:
setattr(m, "streamBroken", False)
need_report = True
if need_report and m:
try:
self._manager_send()
except Exception as e:
print(f"[iproxy-check] 上报恢复异常 deviceId={device_id}: {e}")
continue
# 自愈失败:累计失败计数
fails = self._iproxy_fail_count.get(device_id, 0) + 1
self._iproxy_fail_count[device_id] = fails
print(f"[iproxy-check] 自愈失败 ×{fails} deviceId={device_id} port={port}")
# 达阈值 → 【不移除设备】,改为降级并上报(避免“删了又加”的抖动)
if fails >= FAIL_THRESHOLD:
with self._lock:
m = self._models.get(device_id)
if m:
m.ready = False
setattr(m, "streamBroken", True)
try:
if m:
print(
f"[iproxy-check] 连续失败 {fails} 次,降级设备(保留在线) deviceId={device_id} port={port}")
self._manager_send()
except Exception as e:
print(f"[iproxy-check] 上报降级异常 deviceId={device_id}: {e}")
except Exception as e:
print(f"[iproxy-check] 单设备检查异常: {e}")
time.sleep(INTERVAL_SEC)
except Exception as e:
print("检查iproxy状态遇到错误",e)
LogManager.error("检查iproxy状态遇到错误",e)
def listen(self):
LogManager.method_info("进入主循环", "listen", udid="system")
print("[Listen] 开始监听设备上下线...")
while True:
try:
usb = Usbmux().device_list()
online = {d.udid for d in usb if d.conn_type == ConnectionType.USB}
except Exception as e:
LogManager.warning(f"[device_list] 异常:{e}", udid="system")
print(f"[Listen] 获取设备列表异常: {e}")
time.sleep(1)
continue
now = _monotonic()
for u in online:
self._first_seen.setdefault(u, now)
self._last_seen[u] = now
with self._lock:
known = set(self._models.keys())
for udid in online - known:
if (now - self._first_seen.get(udid, now)) >= self.ADD_STABLE_SEC:
print(datetime.datetime.now().strftime("%H:%M:%S"))
print(f"[Add] 检测到新设备: {udid}")
try:
self._add_device(udid) # ← 并发包装器
except Exception as e:
LogManager.method_error(f"新增失败:{e}", "listen", udid=udid)
print(f"[Add] 新增失败 {udid}: {e}")
for udid in list(known):
if udid in online:
continue
last = self._last_seen.get(udid, 0.0)
if (now - last) >= self.REMOVE_GRACE_SEC:
print(f"[Remove] 检测到设备离线: {udid}")
try:
self._remove_device(udid)
except Exception as e:
LogManager.method_error(f"移除失败:{e}", "listen", udid=udid)
print(f"[Remove] 移除失败 {udid}: {e}")
time.sleep(1)
def _wait_wda_ready_on_port(self, udid: str, local_port: int, total_timeout_sec: float = None) -> bool:
"""在给定的本地映射端口上等待 /status 就绪。"""
import http.client, time
if total_timeout_sec is None:
total_timeout_sec = self.WDA_READY_TIMEOUT
deadline = _monotonic() + total_timeout_sec
attempt = 0
while _monotonic() < deadline:
attempt += 1
try:
conn = http.client.HTTPConnection("127.0.0.1", local_port, timeout=1.8)
conn.request("GET", "/status")
resp = conn.getresponse()
_ = resp.read(128)
code = getattr(resp, "status", 0)
ok = 200 <= code < 400
print(f"[WDA] /status@{local_port}{attempt}次 code={code}, ok={ok} {udid}")
if ok:
return True
except Exception as e:
print(f"[WDA] /status@{local_port} 异常({attempt}): {e}")
time.sleep(0.5)
print(f"[WDA] /status@{local_port} 等待超时 {udid}")
return False
def _send_snapshot_to_flask(self):
"""把当前 _models 的全量快照发送给 Flask 进程"""
try:
# 1. 把 _models 里的设备转成可 JSON 的 dict 列表
with self._lock:
devices = [m.toDict() for m in self._models.values()]
payload = json.dumps({"devices": devices}, ensure_ascii=False)
# 2. 建立到 Flask 的本地 socket 连接并发送
port = int(os.getenv("FLASK_COMM_PORT", "34566"))
if port <= 0:
LogManager.warning("[SNAPSHOT] 无有效端口,跳过发送")
return
with socket.create_connection(("127.0.0.1", port), timeout=1.5) as s:
s.sendall(payload.encode("utf-8") + b"\n")
print(f"[SNAPSHOT] 已发送 {len(devices)} 台设备快照到 Flask")
LogManager.info(f"[SNAPSHOT] 已发送 {len(devices)} 台设备快照到 Flask")
except Exception as e:
# 不要让异常影响主循环,只打个日志
LogManager.warning(f"[SNAPSHOT] 发送快照失败: {e}")
def _device_online_via_tidevice(self, udid: str) -> bool:
try:
from tidevice import Usbmux, ConnectionType
devs = Usbmux().device_list()
return any(d.udid == udid and d.conn_type == ConnectionType.USB for d in devs)
except Exception:
# 容错tidevice 异常时,假定在线,避免误判;后续命令会再校验
return True
def _pair_if_needed_for_ios17(self, udid: str, timeout_sec: float | None = None) -> bool:
"""
iOS 17+ 配对:已信任直接 True否则触发配对并无限等待设备离线则 False
使用 “python -m pymobiledevice3 lockdown pair -u <udid>” 做配对,规避 API 版本差异。
timeout_sec=None 表示无限等待;若传入数字则为最多等待秒数。
"""
# 已信任直接过
if self._trusted(udid):
return True
print(f"[Pair][CLI] iOS17+ 需要配对,等待手机上点击“信任”… {udid}")
last_log = 0.0
# 轮询直到配对成功/超时/设备离线
while True:
# 1) 设备在线性检查(防止卡等已拔掉的设备)
if not self._device_online_via_tidevice(udid):
print(f"[Pair][CLI] 设备已离线,停止等待 {udid}")
return False
# 2) 触发一次配对尝试
cmd = [sys.executable, "-m", "pymobiledevice3", "lockdown", "pair", "-u", udid]
try:
# 不打印子进程输出,保持你现有日志风格;需要可改为 PIPE 查看
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if res.returncode == 0:
print(f"[Pair][CLI] 配对成功 {udid}")
return True
except Exception as e:
print(f"[Pair][CLI] 调用失败:{e}")
# 3) 日志节流 + 可选超时
now = time.monotonic()
if now - last_log >= 10.0:
print(f"[Pair][CLI] 仍在等待用户在手机上点“信任”… {udid}")
last_log = now
if timeout_sec is not None:
timeout_sec -= 2.0
if timeout_sec <= 0:
print(f"[Pair][CLI] 等待配对超时(达到设定时长){udid}")
return False
time.sleep(2.0)
# ---------------- 原 _add_device 实现:整体改名为 _add_device_impl ----------------
def _add_device_impl(self, udid: str):
print(f"[Add] 开始新增设备 {udid}")
if not self._trusted(udid):
print(f"[Add] 未信任设备 {udid}, 跳过")
return
# 先分配一个“正式使用”的本地端口,并立即起 iproxy只起这一回
port = self._alloc_port()
print(f"[iproxy] 准备启动 iproxy 映射 {port}->{wdaScreenPort} (正式)")
proc = self._start_iproxy(udid, local_port=port)
if not proc:
self._release_port(port)
print(f"[iproxy] 启动失败,放弃新增 {udid}")
return
# 判断 WDA 是否已就绪;如果未就绪,按原逻辑拉起 WDA 并等到就绪
try:
dev = tidevice.Device(udid)
major = int(dev.product_version.split(".")[0])
except Exception:
major = 0
# 直接用“正式端口”探测 /status避免再启一次临时 iproxy
if not self._wait_wda_ready_on_port(udid, local_port=port, total_timeout_sec=3.0):
# 如果还没起来,按你原逻辑拉起 WDA 再等
if major >= 17:
print("进入 iOS17+ 设备的分支")
if not self._pair_if_needed_for_ios17(udid, timeout_sec=None): # 无限等;传秒数则有限时
print(f"[WDA] iOS17+ 配对失败或设备离线,放弃新增 {udid}")
try:
self._kill(proc)
except Exception:
pass
self._release_port(port)
return
try:
IOSActivator().activate(udid)
print("wda启动完成")
except Exception as e:
print(f"[WDA] iOS17 激活异常: {e}")
else:
print(f"[WDA] iOS<=17 启动 WDA app_start (port={wdaScreenPort})")
try:
dev = tidevice.Device(udid)
dev.app_start(WdaAppBundleId)
time.sleep(2)
except Exception as e:
print(f"[WDA] app_start 异常: {e}")
if not self._wait_wda_ready_on_port(udid, local_port=port, total_timeout_sec=self.WDA_READY_TIMEOUT):
print(f"[WDA] WDA 未在超时内就绪, 放弃新增 {udid}")
# 清理已起的正式 iproxy
try:
self._kill(proc)
except Exception:
pass
self._release_port(port)
return
print(f"[WDA] WDA 就绪,准备获取屏幕信息 {udid}")
time.sleep(0.5)
# 带超时的屏幕信息获取(保留你原有容错/重试)
w, h, s = self._screen_info_with_timeout(udid, timeout=3.5)
if not (w and h and s):
for i in range(4):
print(f"[Screen] 第{i + 1}次获取失败, 重试中... {udid}")
time.sleep(0.6)
w, h, s = self._screen_info_with_timeout(udid, timeout=3.5)
if w and h and s:
break
if not (w and h and s):
print(f"[Screen] 屏幕信息仍为空,继续添加 {udid}")
# 写入模型 & 发送前端
with self._lock:
model = DeviceModel(deviceId=udid, screenPort=port, width=w, height=h, scale=s, type=1)
model.ready = True
self._models[udid] = model
self._iproxy[udid] = proc
self._port_by_udid[udid] = port
if hasattr(self, "_iproxy_fail_count"):
self._iproxy_fail_count[udid] = 0
print(f"[Manager] 准备发送设备数据到前端 {udid}")
self._manager_send()
print(datetime.datetime.now().strftime("%H:%M:%S"))
print(f"[Add] 设备添加成功 {udid}, port={port}, {w}x{h}@{s}")
def _remove_device(self, udid: str):
"""
移除设备及其转发,通知上层。
幂等:重复调用不会出错。
"""
print(f"[Remove] 正在移除设备 {udid}")
# --- 1. 锁内执行所有轻量字典操作 ---
with self._lock:
model = self._models.pop(udid, None)
proc = self._iproxy.pop(udid, None)
self._port_by_udid.pop(udid, None)
self._first_seen.pop(udid, None)
self._last_seen.pop(udid, None)
self._iproxy_fail_count.pop(udid, None)
# --- 2. 锁外执行重操作 ---
# 杀进程
try:
self._kill(proc)
except Exception as e:
print(f"[Remove] 杀进程异常 {udid}: {e}")
# 准备下线模型model 可能为 None
if model is None:
model = DeviceModel(
deviceId=udid, screenPort=-1, width=0, height=0, scale=0.0, type=2
)
# 标记状态为离线
model.type = 2
model.ready = False
model.screenPort = -1
# 通知上层
try:
self._manager_send()
except Exception as e:
print(f"[Remove] 通知上层异常 {udid}: {e}")
print(f"[Remove] 设备移除完成 {udid}")
def _trusted(self, udid: str) -> bool:
try:
BaseDevice(udid).get_value("DeviceName")
print(f"[Trust] 设备 {udid} 已信任")
return True
except Exception:
print(f"[Trust] 设备 {udid} 未信任")
return False
def _wda_http_status_ok_once(self, udid: str, timeout_sec: float = 1.8) -> bool:
"""只做一次 /status 探测。任何异常都返回 False不让外层炸掉。"""
tmp_port = None
proc = None
try:
tmp_port = self._alloc_port() # 这里可能抛异常
print(f"[WDA] 启动临时 iproxy 以检测 /status {udid}")
proc = self._spawn_iproxy(udid, local_port=tmp_port, remote_port=wdaScreenPort)
if not proc:
print("[WDA] 启动临时 iproxy 失败")
return False
if not self._wait_until_listening(tmp_port, 3.0):
print(f"[WDA] 临时端口未监听 {tmp_port}")
return False
# 最多两次快速探测
for i in (1, 2):
try:
import http.client
conn = http.client.HTTPConnection("127.0.0.1", tmp_port, timeout=timeout_sec)
conn.request("GET", "/status")
resp = conn.getresponse()
_ = resp.read(128)
code = getattr(resp, "status", 0)
ok = 200 <= code < 400
print(f"[WDA] /status 第{i}次 code={code}, ok={ok}")
if ok:
return True
except Exception as e:
print(f"[WDA] /status 异常({i}): {e}")
time.sleep(0.25)
return False
except Exception as e:
import traceback
print(f"[WDA][probe] 异常:{e}\n{traceback.format_exc()}")
return False
finally:
if proc:
self._kill(proc)
if tmp_port is not None:
self._release_port(tmp_port)
def _wait_wda_ready_http(self, udid: str, total_timeout_sec: float) -> bool:
print(f"[WDA] 等待 WDA Ready (超时 {total_timeout_sec}s) {udid}")
deadline = _monotonic() + total_timeout_sec
while _monotonic() < deadline:
if self._wda_http_status_ok_once(udid):
print(f"[WDA] WDA 就绪 {udid}")
return True
time.sleep(0.6)
print(f"[WDA] WDA 等待超时 {udid}")
return False
def _screen_info(self, udid: str):
try:
# 避免 c.home() 可能触发的阻塞,直接取 window_size
c = wda.USBClient(udid, wdaFunctionPort)
size = c.window_size()
print(f"[Screen] 成功获取屏幕 {int(size.width)}x{int(size.height)} {udid}")
return int(size.width), int(size.height), float(c.scale)
except Exception as e:
print(f"[Screen] 获取屏幕信息异常: {e} {udid}")
return 0, 0, 0.0
def _screen_info_with_timeout(self, udid: str, timeout: float = 3.5):
"""在线程里调用 _screen_info超时返回 0 值,防止卡死。"""
import threading
result = {"val": (0, 0, 0.0)}
done = threading.Event()
def _target():
try:
result["val"] = self._screen_info(udid)
finally:
done.set()
t = threading.Thread(target=_target, daemon=True)
t.start()
if not done.wait(timeout):
print(f"[Screen] 获取屏幕信息超时({timeout}s) {udid}")
return 0, 0, 0.0
return result["val"]
def _wait_until_listening(self, port: int, timeout: float) -> bool:
for to in (1.5, 2.5, 3.5):
deadline = _monotonic() + to
while _monotonic() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.25)
if s.connect_ex(("127.0.0.1", port)) == 0:
print(f"[Port] 端口 {port} 已监听")
return True
time.sleep(0.05)
print(f"[Port] 端口 {port} 未监听")
return False
def _spawn_iproxy(self, udid: str, local_port: int, remote_port: int) -> Optional[subprocess.Popen]:
creationflags = 0
startupinfo = None
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | \
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0
startupinfo = si
cmd = [self._iproxy_path, "-u", udid, str(local_port), str(remote_port)]
try:
print(f"[iproxy] 启动进程 {cmd}")
return subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
startupinfo=startupinfo,
)
except Exception as e:
print(f"[iproxy] 创建进程失败: {e}")
return None
def _start_iproxy(self, udid: str, local_port: int) -> Optional[subprocess.Popen]:
proc = self._spawn_iproxy(udid, local_port=local_port, remote_port=wdaScreenPort)
if not proc:
print(f"[iproxy] 启动失败 {udid}")
return None
if not self._wait_until_listening(local_port, 3.0):
self._kill(proc)
print(f"[iproxy] 未监听, 已杀死 {udid}")
return None
print(f"[iproxy] 启动成功 port={local_port} {udid}")
return proc
def _kill(self, proc: Optional[subprocess.Popen]):
if not proc:
return
try:
p = psutil.Process(proc.pid)
p.terminate()
try:
p.wait(timeout=1.5)
except psutil.TimeoutExpired:
p.kill(); p.wait(timeout=1.5)
print(f"[Proc] 已结束进程 PID={proc.pid}")
except Exception as e:
print(f"[Proc] 结束进程异常: {e}")
def _manager_send(self):
# try:
# if self._manager.send(model.toDict()):
# print(f"[Manager] 已发送前端数据 {model.deviceId}")
# return
# except Exception as e:
# print(f"[Manager] 首次发送异常: {e}")
#
# # 自愈:拉起一次并重试一次(不要用 and 连接)
# try:
# self._manager.start() # 不关心返回值
# if self._manager.send(model.toDict()):
# print(f"[Manager] 重试发送成功 {model.deviceId}")
# return
# except Exception as e:
# print(f"[Manager] 重试发送异常: {e}")
"""对外统一的“通知 Flask 有设备变动”的入口(无参数)。
作用:把当前所有设备的全量快照发给 Flask。
"""
# 第 1 次:直接发快照
try:
self._send_snapshot_to_flask()
return
except Exception as e:
print(f"[Manager] 首次发送快照异常: {e}")
# 自愈:尝试拉起 Flask 子进程
try:
self._manager.start()
except Exception as e:
print(f"[Manager] 拉起 Flask 子进程异常: {e}")
# 第 2 次:再发快照
try:
self._send_snapshot_to_flask()
print(f"[Manager] 重试发送快照成功")
except Exception as e:
print(f"[Manager] 重试发送快照仍失败: {e}")
def _find_iproxy(self) -> str:
env_path = os.getenv("IPROXY_PATH")
if env_path and Path(env_path).is_file():
print(f"[iproxy] 使用环境变量路径 {env_path}")
return env_path
base = Path(__file__).resolve().parent.parent
name = "iproxy.exe" if os.name == "nt" else "iproxy"
path = base / "resources" / "iproxy" / name
if path.is_file():
print(f"[iproxy] 使用默认路径 {path}")
return str(path)
raise FileNotFoundError(f"iproxy 不存在: {path}")