Files
iOSAI/Module/DeviceInfo.py

346 lines
11 KiB
Python
Raw Normal View History

2025-11-19 17:23:41 +08:00
import json
2025-08-15 20:04:59 +08:00
import os
2025-11-19 17:23:41 +08:00
import socket
2025-10-25 00:22:16 +08:00
import threading
2025-11-19 17:23:41 +08:00
import time
2025-11-21 22:03:35 +08:00
import subprocess
2025-11-19 17:23:41 +08:00
from typing import Dict, Optional
2025-11-21 22:03:35 +08:00
2025-09-24 16:32:05 +08:00
import tidevice
2025-11-24 20:38:50 +08:00
import wda
2025-09-04 20:47:14 +08:00
from tidevice import Usbmux, ConnectionType
2025-11-21 22:03:35 +08:00
2025-08-01 13:43:51 +08:00
from Entity.DeviceModel import DeviceModel
2025-11-24 20:38:50 +08:00
from Entity.Variables import WdaAppBundleId, wdaFunctionPort
2025-08-01 13:43:51 +08:00
from Module.FlaskSubprocessManager import FlaskSubprocessManager
2025-10-21 15:43:02 +08:00
from Module.IOSActivator import IOSActivator
from Utils.LogManager import LogManager
2025-08-01 13:43:51 +08:00
2025-09-11 22:46:55 +08:00
2025-10-25 00:22:16 +08:00
class DeviceInfo:
2025-11-03 14:27:31 +08:00
_instance = None
_instance_lock = threading.Lock()
2025-11-24 20:38:50 +08:00
# 离线宽限期
REMOVE_GRACE_SEC = float(os.getenv("REMOVE_GRACE_SEC", "6.0"))
2025-11-03 14:27:31 +08:00
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
2025-10-25 00:22:16 +08:00
def __init__(self) -> None:
2025-11-03 14:27:31 +08:00
if getattr(self, "_initialized", False):
return
2025-10-23 21:38:18 +08:00
self._lock = threading.RLock()
2025-10-25 00:22:16 +08:00
self._models: Dict[str, DeviceModel] = {}
self._manager = FlaskSubprocessManager.get_instance()
2025-11-21 22:03:35 +08:00
self.screenPort = 9110
2025-11-19 20:47:28 +08:00
2025-11-21 22:03:35 +08:00
# 设备心跳时间
self._last_seen: Dict[str, float] = {}
2025-10-31 19:41:44 +08:00
2025-11-21 22:03:35 +08:00
# iproxy 子进程udid -> Popen
self._iproxy_process: Dict[str, subprocess.Popen] = {}
2025-11-05 17:07:51 +08:00
2025-11-21 22:03:35 +08:00
# Windows 下隐藏子进程窗口(给 iproxy 用)
self._creationflags = 0
self._startupinfo = None
if os.name == "nt":
2025-11-19 20:47:28 +08:00
try:
2025-11-21 22:03:35 +08:00
self._creationflags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
2025-11-19 20:47:28 +08:00
except Exception:
2025-11-21 22:03:35 +08:00
self._creationflags = 0
2025-11-21 22:03:35 +08:00
si = subprocess.STARTUPINFO()
2025-11-25 20:33:11 +08:00
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
2025-11-21 22:03:35 +08:00
si.wShowWindow = 0 # SW_HIDE
self._startupinfo = si
2025-11-19 20:47:28 +08:00
2025-11-21 22:03:35 +08:00
LogManager.info("DeviceInfo 初始化完成", udid="system")
print("[Init] DeviceInfo 初始化完成")
self._initialized = True
2025-10-23 21:38:18 +08:00
2025-11-24 20:38:50 +08:00
# ==========================
2025-11-21 22:03:35 +08:00
# 主循环
2025-11-24 20:38:50 +08:00
# ==========================
2025-09-22 14:36:05 +08:00
def listen(self):
2025-10-25 00:22:16 +08:00
LogManager.method_info("进入主循环", "listen", udid="system")
print("[Listen] 开始监听设备上下线...")
2025-11-21 22:03:35 +08:00
2025-09-15 22:40:45 +08:00
while True:
2025-10-23 21:38:18 +08:00
try:
usb = Usbmux().device_list()
2025-10-25 00:22:16 +08:00
online = {d.udid for d in usb if d.conn_type == ConnectionType.USB}
2025-10-23 21:38:18 +08:00
except Exception as e:
2025-10-24 14:36:00 +08:00
LogManager.warning(f"[device_list] 异常:{e}", udid="system")
2025-10-23 21:38:18 +08:00
time.sleep(1)
continue
2025-09-28 14:35:09 +08:00
2025-10-23 21:38:18 +08:00
with self._lock:
known = set(self._models.keys())
2025-10-24 14:36:00 +08:00
2025-11-24 20:38:50 +08:00
# 1. 新设备
2025-11-21 22:03:35 +08:00
for udid in online:
self._last_seen[udid] = time.time()
if udid not in known:
2025-10-25 00:22:16 +08:00
try:
2025-11-21 22:03:35 +08:00
self._add_device(udid)
2025-10-25 00:22:16 +08:00
except Exception as e:
2025-11-24 20:38:50 +08:00
# 单设备异常不能干掉整个循环
2025-11-21 22:03:35 +08:00
LogManager.warning(f"[Add] 处理设备 {udid} 异常: {e}", udid=udid)
print(f"[Add] 处理设备 {udid} 异常: {e}")
2025-10-25 00:22:16 +08:00
2025-11-24 20:38:50 +08:00
# 2. 可能离线的设备
2025-11-21 22:03:35 +08:00
now = time.time()
2025-10-23 21:38:18 +08:00
for udid in list(known):
2025-11-21 22:03:35 +08:00
if udid not in online:
last = self._last_seen.get(udid, 0)
if now - last > self.REMOVE_GRACE_SEC:
try:
self._remove_device(udid)
except Exception as e:
LogManager.method_error(f"移除失败:{e}", "listen", udid=udid)
print(f"[Remove] 移除失败 {udid}: {e}")
2025-09-28 14:35:09 +08:00
2025-09-15 22:40:45 +08:00
time.sleep(1)
2025-11-24 20:38:50 +08:00
# ==========================
2025-11-21 22:03:35 +08:00
# 添加设备
2025-11-24 20:38:50 +08:00
# ==========================
2025-11-21 22:03:35 +08:00
def _add_device(self, udid: str):
with self._lock:
if udid in self._models:
print(f"[Add] 已存在,跳过: {udid}")
2025-11-14 16:58:55 +08:00
return
2025-11-21 22:03:35 +08:00
print(f"[Add] 新增设备 {udid}")
2025-11-14 16:58:55 +08:00
2025-11-21 22:03:35 +08:00
# 判断 iOS 版本
2025-11-13 19:28:57 +08:00
try:
2025-11-21 22:03:35 +08:00
t = tidevice.Device(udid)
version_major = float(t.product_version.split(".")[0])
except Exception as e:
print(f"[Add] 获取系统版本失败 {udid}: {e}")
version_major = 0
2025-11-24 20:38:50 +08:00
# 分配投屏端口 & 写入模型先插入width/height=0后面再异步更新
2025-11-21 22:03:35 +08:00
with self._lock:
self.screenPort += 1
screen_port = self.screenPort
2025-11-13 19:28:57 +08:00
2025-11-21 22:03:35 +08:00
model = DeviceModel(
deviceId=udid,
screenPort=screen_port,
width=0,
height=0,
scale=0,
2025-11-24 20:38:50 +08:00
type=1,
2025-11-21 22:03:35 +08:00
)
self._models[udid] = model
2025-11-13 19:28:57 +08:00
2025-11-21 22:03:35 +08:00
print(f"[Add] 新设备完成 {udid}, screenPort={screen_port}")
self._manager_send()
2025-10-24 14:36:00 +08:00
2025-11-21 22:03:35 +08:00
# 启动 iproxy投屏转发
try:
self._start_iproxy(udid, screen_port)
except Exception as e:
print(f"[iproxy] 启动失败 {udid}: {e}")
2025-11-24 20:38:50 +08:00
# 启动 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}")
else:
threading.Thread(
target=self._fetch_screen_and_notify,
args=(udid,),
daemon=True,
).start()
# ==========================
# WDA 启动回调iOS17+
# ==========================
def _on_wda_ready(self, udid: str):
print(f"[WDA] 回调触发,准备获取屏幕信息 udid={udid}")
# 稍微等一下再连,避免刚启动时不稳定
time.sleep(1)
2025-11-21 22:03:35 +08:00
threading.Thread(
2025-11-24 20:38:50 +08:00
target=self._fetch_screen_and_notify,
2025-11-21 22:03:35 +08:00
args=(udid,),
2025-11-24 20:38:50 +08:00
daemon=True,
2025-11-21 22:03:35 +08:00
).start()
2025-11-24 20:38:50 +08:00
# ==========================
# 通过 WDA 获取屏幕信息
# ==========================
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 上
print(f"[Screen] 成功获取屏幕 {w}x{h} scale={s} {udid}")
return w, h, s
except Exception as e:
print(f"[Screen] 获取屏幕失败: {e} udid={udid}")
return 0, 0, 0.0
# ==========================
# 异步获取屏幕尺寸并通知 Flask
# ==========================
def _fetch_screen_and_notify(self, udid: str):
"""
后台线程里多次尝试通过 WDA 获取屏幕尺寸
成功后更新 model 并发一次 snapshot
"""
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}")
return
w, h, s = self._screen_info(udid)
if w > 0 and h > 0:
# 更新模型
with self._lock:
m = self._models.get(udid)
if not m:
print(f"[Screen] 模型已不存在,无法更新 udid={udid}")
return
m.width = w
m.height = h
m.scale = s
print(f"[Screen] 屏幕信息更新完成,准备推送到 Flask udid={udid}")
try:
self._manager_send()
except Exception as e:
print(f"[Screen] 发送屏幕更新到 Flask 失败 udid={udid}, err={e}")
return
time.sleep(interval)
print(f"[Screen] 多次尝试仍未获取到屏幕信息 udid={udid}")
# ==========================
# iproxy 管理
# ==========================
2025-11-21 22:03:35 +08:00
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}")
2025-09-22 14:36:05 +08:00
return
2025-10-24 14:36:00 +08:00
2025-11-21 22:03:35 +08:00
args = [
iproxy_path,
2025-11-25 20:33:11 +08:00
"-u", udid,
str(local_port), # 本地端口(投屏)
"9567" # 手机端口go-ios screencast
2025-11-21 22:03:35 +08:00
]
print(f"[iproxy] 启动进程: {args}")
2025-11-25 20:33:11 +08:00
# 不用 PIPE防止没人读导致缓冲爆掉窗口用前面配置隐藏
2025-11-21 22:03:35 +08:00
proc = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=self._creationflags,
startupinfo=self._startupinfo,
)
2025-11-25 20:33:11 +08:00
2025-11-21 22:03:35 +08:00
self._iproxy_process[udid] = proc
def _stop_iproxy(self, udid: str):
p = self._iproxy_process.get(udid)
if not p:
2025-11-05 17:07:51 +08:00
return
2025-10-24 16:24:09 +08:00
try:
2025-11-21 22:03:35 +08:00
p.terminate()
try:
p.wait(timeout=2)
except Exception:
p.kill()
2025-10-25 00:22:16 +08:00
except Exception:
2025-11-21 22:03:35 +08:00
pass
self._iproxy_process.pop(udid, None)
print(f"[iproxy] 已停止 {udid}")
2025-11-13 19:28:57 +08:00
2025-11-24 20:38:50 +08:00
# ==========================
2025-11-21 22:03:35 +08:00
# 移除设备
2025-11-24 20:38:50 +08:00
# ==========================
2025-09-22 14:36:05 +08:00
def _remove_device(self, udid: str):
2025-11-21 22:03:35 +08:00
print(f"[Remove] 移除设备 {udid}")
2025-11-24 20:38:50 +08:00
# 先停 iproxy
2025-11-21 22:03:35 +08:00
self._stop_iproxy(udid)
2025-10-31 13:19:55 +08:00
2025-10-23 21:38:18 +08:00
with self._lock:
2025-11-21 22:03:35 +08:00
self._models.pop(udid, None)
2025-10-25 00:22:16 +08:00
self._last_seen.pop(udid, None)
2025-10-31 13:19:55 +08:00
2025-11-21 22:03:35 +08:00
self._manager_send()
2025-10-31 13:19:55 +08:00
2025-11-24 20:38:50 +08:00
# ==========================
# 工具方法
# ==========================
2025-11-21 22:03:35 +08:00
def _find_iproxy(self) -> str:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
name = "iproxy.exe" if os.name == "nt" else "iproxy"
2025-11-24 20:38:50 +08:00
return os.path.join(base, "resources", "iproxy", name)
2025-11-24 20:38:50 +08:00
# ==========================
# 同步数据到 Flask
# ==========================
2025-11-21 22:03:35 +08:00
def _manager_send(self):
try:
2025-11-21 22:03:35 +08:00
self._send_snapshot_to_flask()
2025-09-22 14:36:05 +08:00
except Exception:
2025-10-25 00:22:16 +08:00
try:
2025-11-21 22:03:35 +08:00
self._manager.start()
except Exception:
pass
2025-09-22 14:36:05 +08:00
try:
2025-11-21 22:03:35 +08:00
self._send_snapshot_to_flask()
except Exception:
pass
2025-09-28 14:35:09 +08:00
2025-11-21 22:03:35 +08:00
def _send_snapshot_to_flask(self):
with self._lock:
devices = [m.toDict() for m in self._models.values()]
2025-11-21 22:03:35 +08:00
payload = json.dumps({"devices": devices}, ensure_ascii=False)
port = int(os.getenv("FLASK_COMM_PORT", "34566"))
2025-11-24 20:38:50 +08:00
2025-11-21 22:03:35 +08:00
with socket.create_connection(("127.0.0.1", port), timeout=1.5) as s:
s.sendall(payload.encode() + b"\n")
2025-10-23 21:38:18 +08:00
2025-11-21 22:03:35 +08:00
print(f"[SNAPSHOT] 已发送 {len(devices)} 台设备")