重建仓库,重新提交。
This commit is contained in:
513
Module/DeviceInfo.py
Normal file
513
Module/DeviceInfo.py
Normal file
@@ -0,0 +1,513 @@
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
from typing import Dict
|
||||
import tidevice
|
||||
import wda
|
||||
from tidevice import Usbmux, ConnectionType
|
||||
from Entity.DeviceModel import DeviceModel
|
||||
from Entity.Variables import WdaAppBundleId, wdaFunctionPort
|
||||
from Module.FlaskSubprocessManager import FlaskSubprocessManager
|
||||
from Module.IOSActivator import IOSActivator
|
||||
from Utils.LogManager import LogManager
|
||||
|
||||
|
||||
class DeviceInfo:
|
||||
_instance = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
# 离线宽限期(保持你原来的数值)
|
||||
REMOVE_GRACE_SEC = 5.0
|
||||
|
||||
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 __init__(self) -> None:
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
|
||||
self._lock = threading.RLock()
|
||||
self._models: Dict[str, DeviceModel] = {}
|
||||
self._manager = FlaskSubprocessManager.get_instance()
|
||||
self.screenPort = 9110
|
||||
|
||||
# 设备心跳时间
|
||||
self._last_seen: Dict[str, float] = {}
|
||||
|
||||
# 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:
|
||||
# type: ignore[attr-defined]
|
||||
self._creationflags = subprocess.CREATE_NO_WINDOW
|
||||
except Exception:
|
||||
self._creationflags = 0
|
||||
|
||||
si = subprocess.STARTUPINFO()
|
||||
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
si.wShowWindow = 0 # SW_HIDE
|
||||
self._startupinfo = si
|
||||
|
||||
LogManager.info("DeviceInfo 初始化完成", udid="system")
|
||||
print("[Init] DeviceInfo 初始化完成")
|
||||
self._initialized = True
|
||||
|
||||
# ==========================
|
||||
# 主循环
|
||||
# ==========================
|
||||
def listen(self):
|
||||
LogManager.method_info("进入主循环", "listen", udid="system")
|
||||
print("[Listen] 开始监听设备上下线...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
usb = Usbmux().device_list()
|
||||
# 只看 USB 连接的设备
|
||||
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")
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
now = time.time()
|
||||
|
||||
# 当前已知的设备(本轮循环开始时)
|
||||
with self._lock:
|
||||
known = set(self._models.keys())
|
||||
current_count = len(self._models)
|
||||
|
||||
# 1. 处理在线设备
|
||||
for udid in online:
|
||||
# 更新心跳时间
|
||||
self._last_seen[udid] = now
|
||||
|
||||
# 新设备但数量已达上限
|
||||
if udid not in known and current_count >= 6:
|
||||
print(f"[Add] 设备数量已达 6 台,忽略新设备: {udid}")
|
||||
LogManager.info(
|
||||
"[Add] 设备数量已达上限(6),忽略新设备",
|
||||
udid=udid,
|
||||
)
|
||||
continue
|
||||
|
||||
# 已经在列表里的设备,跳过添加流程
|
||||
if udid in known:
|
||||
continue
|
||||
|
||||
# 只对新发现的设备做一次信任检查
|
||||
try:
|
||||
if not self._is_trusted(udid):
|
||||
LogManager.info(
|
||||
"[Add] 设备未信任或未就绪,跳过本轮添加",
|
||||
udid=udid,
|
||||
)
|
||||
print(f"[Add] 设备未信任或未就绪,跳过: {udid}")
|
||||
continue
|
||||
except Exception as e:
|
||||
LogManager.warning(
|
||||
f"[Add] 检测设备 {udid} 信任状态异常: {e}",
|
||||
udid=udid,
|
||||
)
|
||||
print(f"[Add] 检测设备 {udid} 信任状态异常: {e}")
|
||||
continue
|
||||
|
||||
# 二次确认数量上限
|
||||
with self._lock:
|
||||
if len(self._models) >= 6:
|
||||
print(f"[Add] 二次检查: 设备数量已达 6 台,忽略新设备: {udid}")
|
||||
LogManager.info(
|
||||
"[Add] 二次检查数量上限,忽略新设备",
|
||||
udid=udid,
|
||||
)
|
||||
continue
|
||||
|
||||
# 真正添加设备
|
||||
try:
|
||||
self._add_device(udid)
|
||||
current_count += 1
|
||||
except Exception as e:
|
||||
LogManager.warning(
|
||||
f"[Add] 处理设备 {udid} 异常: {e}",
|
||||
udid=udid,
|
||||
)
|
||||
print(f"[Add] 处理设备 {udid} 异常: {e}")
|
||||
|
||||
# 2. 处理可能离线的设备(只看本轮开始时 known 里的)
|
||||
for udid in list(known):
|
||||
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}")
|
||||
|
||||
# 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)
|
||||
_ = 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
|
||||
|
||||
# ==========================
|
||||
# 添加设备
|
||||
# ==========================
|
||||
def _add_device(self, udid: str):
|
||||
with self._lock:
|
||||
if udid in self._models:
|
||||
print(f"[Add] 已存在,跳过: {udid}")
|
||||
return
|
||||
print(f"[Add] 新增设备 {udid}")
|
||||
|
||||
# 判断 iOS 版本
|
||||
try:
|
||||
t = tidevice.Device(udid)
|
||||
version_major = float(t.product_version.split(".")[0])
|
||||
except Exception as e:
|
||||
print(f"[Add] 获取系统版本失败 {udid}: {e}")
|
||||
version_major = 0
|
||||
|
||||
# 分配投屏端口 & 写入模型
|
||||
with self._lock:
|
||||
self.screenPort += 1
|
||||
screen_port = self.screenPort
|
||||
|
||||
model = DeviceModel(
|
||||
deviceId=udid,
|
||||
screenPort=screen_port,
|
||||
width=0,
|
||||
height=0,
|
||||
scale=0,
|
||||
type=1,
|
||||
)
|
||||
self._models[udid] = model
|
||||
|
||||
print(f"[Add] 新设备完成 {udid}, screenPort={screen_port}")
|
||||
self._manager_send()
|
||||
|
||||
# 启动 iproxy(投屏转发)
|
||||
try:
|
||||
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:
|
||||
threading.Thread(
|
||||
target=IOSActivator().activate_ios17,
|
||||
args=(udid, self._on_wda_ready),
|
||||
daemon=True,
|
||||
).start()
|
||||
else:
|
||||
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,
|
||||
args=(udid,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
# ==========================
|
||||
# WDA 启动回调(iOS17+)
|
||||
# ==========================
|
||||
def _on_wda_ready(self, udid: str):
|
||||
print(f"[WDA] 回调触发,准备获取屏幕信息 udid={udid}")
|
||||
time.sleep(1)
|
||||
threading.Thread(
|
||||
target=self._fetch_screen_and_notify,
|
||||
args=(udid,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
# ==========================
|
||||
# 通过 WDA 获取屏幕信息
|
||||
# ==========================
|
||||
def _screen_info(self, udid: str):
|
||||
try:
|
||||
c = wda.USBClient(udid, wdaFunctionPort)
|
||||
size = c.window_size()
|
||||
|
||||
w = int(size.width)
|
||||
h = int(size.height)
|
||||
s = float(c.scale)
|
||||
|
||||
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
|
||||
|
||||
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 管理
|
||||
# ==========================
|
||||
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}")
|
||||
return
|
||||
|
||||
args = [
|
||||
iproxy_path,
|
||||
"-u",
|
||||
udid,
|
||||
str(local_port), # 本地端口(投屏)
|
||||
"9567", # 手机端口(go-ios screencast)
|
||||
]
|
||||
|
||||
print(f"[iproxy] 启动进程: {args}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=self._creationflags,
|
||||
startupinfo=self._startupinfo,
|
||||
)
|
||||
|
||||
self._iproxy_process[udid] = proc
|
||||
|
||||
def _stop_iproxy(self, udid: str):
|
||||
p = self._iproxy_process.get(udid)
|
||||
if not p:
|
||||
return
|
||||
try:
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(timeout=2)
|
||||
except Exception:
|
||||
p.kill()
|
||||
except Exception:
|
||||
pass
|
||||
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}")
|
||||
|
||||
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()
|
||||
|
||||
# ==========================
|
||||
# 工具方法
|
||||
# ==========================
|
||||
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"
|
||||
return os.path.join(base, "resources", "iproxy", name)
|
||||
|
||||
# ==========================
|
||||
# 同步数据到 Flask
|
||||
# ==========================
|
||||
def _manager_send(self):
|
||||
try:
|
||||
self._send_snapshot_to_flask()
|
||||
except Exception:
|
||||
try:
|
||||
self._manager.start()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._send_snapshot_to_flask()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _send_snapshot_to_flask(self):
|
||||
with self._lock:
|
||||
devices = [m.toDict() for m in self._models.values()]
|
||||
|
||||
payload = json.dumps({"devices": devices}, ensure_ascii=False)
|
||||
port = int(os.getenv("FLASK_COMM_PORT", "34566"))
|
||||
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1.5) as s:
|
||||
s.sendall(payload.encode() + b"\n")
|
||||
|
||||
print(f"[SNAPSHOT] 已发送 {len(devices)} 台设备")
|
||||
887
Module/FlaskService.py
Normal file
887
Module/FlaskService.py
Normal file
@@ -0,0 +1,887 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import Any, Dict, List
|
||||
import anyio
|
||||
import wda
|
||||
from quart import Quart, request, g
|
||||
from quart_cors import cors
|
||||
import Entity.Variables as ev
|
||||
from Entity import Variables
|
||||
from Entity.ResultData import ResultData
|
||||
from Entity.Variables import addModelToAnchorList, wdaFunctionPort
|
||||
from Utils.AiUtils import AiUtils
|
||||
from Utils.ControlUtils import ControlUtils
|
||||
from Utils.IOSAIStorage import IOSAIStorage
|
||||
from Utils.JsonUtils import JsonUtils
|
||||
from Utils.LogManager import LogManager
|
||||
from Utils.ThreadManager import ThreadManager
|
||||
from script.ScriptManager import ScriptManager
|
||||
|
||||
for name in ('werkzeug', 'werkzeug.serving'):
|
||||
log = logging.getLogger(name)
|
||||
log.disabled = True
|
||||
log.propagate = False
|
||||
log.handlers.clear()
|
||||
|
||||
app = Quart(__name__) # ⭐ 这里用 Quart,而不是 Flask
|
||||
app = cors(app, allow_origin="*") # 允许所有来源跨域
|
||||
|
||||
app.config['JSON_AS_ASCII'] = False # Flask jsonify 不转义中文/emoji
|
||||
app.config['JSONIFY_MIMETYPE'] = "application/json; charset=utf-8"
|
||||
|
||||
# ============ 设备状态内存表 ============
|
||||
listData = []
|
||||
listLock = threading.Lock()
|
||||
|
||||
# 历史遗留:不再消费队列,改为socket线程直接落地
|
||||
dataQueue = Queue()
|
||||
|
||||
# ---- 黏性快照(避免瞬时空) ----
|
||||
_last_nonempty_snapshot = []
|
||||
_last_snapshot_ts = 0.0
|
||||
_STICKY_TTL_SEC = 10.0 # 在瞬时空时,回退到上一份非空快照10秒
|
||||
_empty_logged = False
|
||||
_recovered_logged = False
|
||||
|
||||
# ===== 设备集合变化跟踪 =====
|
||||
change_version = 0
|
||||
_device_ids_snapshot = set()
|
||||
_last_device_count = 0
|
||||
|
||||
def _log_device_changes(action: str):
|
||||
"""记录设备集合增删变化"""
|
||||
global _device_ids_snapshot, change_version
|
||||
curr_ids = {d.get("deviceId") for d in listData if _is_online(d)}
|
||||
added = curr_ids - _device_ids_snapshot
|
||||
removed = _device_ids_snapshot - curr_ids
|
||||
if added or removed:
|
||||
change_version += 1
|
||||
try:
|
||||
LogManager.info(f"[DEVICE][CHANGED][{action}] rev={change_version} count={len(curr_ids)} added={list(added)} removed={list(removed)}")
|
||||
except Exception:
|
||||
print(f"[DEVICE][CHANGED][{action}] rev={change_version} count={len(curr_ids)} added={list(added)} removed={list(removed)}")
|
||||
_device_ids_snapshot = curr_ids
|
||||
|
||||
def _normalize_type(v) -> int:
|
||||
"""把各种表示在线/离线的值,规范成 1/0"""
|
||||
if isinstance(v, bool):
|
||||
return 1 if v else 0
|
||||
if isinstance(v, (int, float)):
|
||||
return 1 if int(v) == 1 else 0
|
||||
if isinstance(v, str):
|
||||
s = v.strip().lower()
|
||||
if s.isdigit():
|
||||
return 1 if int(s) == 1 else 0
|
||||
if s in ("true", "online", "on", "yes"):
|
||||
return 1
|
||||
return 0
|
||||
return 1 if v else 0
|
||||
|
||||
def _is_online(d: Dict[str, Any]) -> bool:
|
||||
return _normalize_type(d.get("type", 1)) == 1
|
||||
|
||||
def _apply_device_snapshot(devices: List[Dict[str, Any]]):
|
||||
"""接收 DeviceInfo 送来的全量设备列表,直接覆盖 listData"""
|
||||
global listData
|
||||
try:
|
||||
normed = []
|
||||
for d in devices:
|
||||
# 拷贝一份,避免引用共享
|
||||
d = dict(d)
|
||||
d["type"] = _normalize_type(d.get("type", 1)) # 规范成 0/1
|
||||
normed.append(d)
|
||||
|
||||
with listLock:
|
||||
before = len(listData)
|
||||
listData[:] = normed # 全量覆盖
|
||||
|
||||
_log_device_changes("SNAPSHOT")
|
||||
try:
|
||||
LogManager.info(f"[DEVICE][SNAPSHOT] size={len(normed)} (was={before})")
|
||||
except Exception:
|
||||
print(f"[DEVICE][SNAPSHOT] size={len(normed)} (was={before})")
|
||||
except Exception as e:
|
||||
LogManager.error(f"[DEVICE][SNAPSHOT][ERROR] {e}")
|
||||
|
||||
def _apply_device_event(obj: Dict[str, Any]):
|
||||
"""把单条设备上线/下线事件落到 listData,并打印关键日志"""
|
||||
try:
|
||||
dev_id = obj.get("deviceId")
|
||||
typ = _normalize_type(obj.get("type", 1))
|
||||
obj["type"] = typ # 写回规范后的值,避免后续被误判
|
||||
if dev_id is None:
|
||||
LogManager.warning(f"[DEVICE][WARN] missing deviceId in obj={obj}")
|
||||
return
|
||||
with listLock:
|
||||
before = len(listData)
|
||||
# 删除同 udid 旧记录
|
||||
listData[:] = [d for d in listData if d.get("deviceId") != dev_id]
|
||||
if typ == 1:
|
||||
listData.append(obj) # 上线
|
||||
LogManager.info(f"[DEVICE][UPSERT] id={dev_id} type={typ} size={len(listData)} (replaced={before - (len(listData)-1)})")
|
||||
_log_device_changes("UPSERT")
|
||||
else:
|
||||
LogManager.warning(f"[DEVICE][REMOVE] id={dev_id} type={typ} size={len(listData)} (removed_prev={before - len(listData)})")
|
||||
_log_device_changes("REMOVE")
|
||||
except Exception as e:
|
||||
LogManager.error(f"[DEVICE][APPLY_EVT][ERROR] {e}")
|
||||
|
||||
# ============ 设备事件 socket 监听 ============
|
||||
def _handle_conn(conn: socket.socket, addr):
|
||||
"""统一的连接处理函数(拆 JSON 行 → 正常化 type → 应用到 listData)"""
|
||||
try:
|
||||
with conn:
|
||||
try:
|
||||
conn.settimeout(3.0) # 避免永久阻塞
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
data = conn.recv(1024)
|
||||
if not data: # 对端关闭
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='ignore')
|
||||
|
||||
# 按行切 JSON;发送端每条以 '\n' 结尾
|
||||
while True:
|
||||
line, sep, buffer = buffer.partition('\n')
|
||||
if not sep:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
LogManager.warning(f"[SOCKET][WARN] 非法 JSON 丢弃: {line[:120]} err={e}")
|
||||
continue
|
||||
|
||||
# === 新增:如果是全量快照(包含 devices 字段) ===
|
||||
if "devices" in obj:
|
||||
devs = obj.get("devices") or []
|
||||
LogManager.info(f"[SOCKET][RECV][SNAPSHOT] size={len(devs)} keys={list(obj.keys())}")
|
||||
try:
|
||||
_apply_device_snapshot(devs)
|
||||
LogManager.info(f"[SOCKET][APPLY][SNAPSHOT] size={len(devs)}")
|
||||
except Exception as e:
|
||||
LogManager.error(f"[DEVICE][APPLY_SNAPSHOT][ERROR] {e}")
|
||||
continue # 处理完这一条,继续下一条 JSON
|
||||
|
||||
# === 否则按原来的单条设备事件处理(兼容旧逻辑) ===
|
||||
dev_id = obj.get("deviceId")
|
||||
typ = _normalize_type(obj.get("type", 1))
|
||||
obj["type"] = typ # 规范 1/0
|
||||
LogManager.info(f"[SOCKET][RECV] deviceId={dev_id} type={typ} keys={list(obj.keys())}")
|
||||
|
||||
try:
|
||||
_apply_device_event(obj) # 保持你原来的增删逻辑
|
||||
LogManager.info(f"[SOCKET][APPLY] deviceId={dev_id} type={typ}")
|
||||
except Exception as e:
|
||||
# 单条业务异常不让线程死
|
||||
LogManager.error(f"[DEVICE][APPLY_EVT][ERROR] {e}")
|
||||
|
||||
except (socket.timeout, ConnectionResetError, BrokenPipeError):
|
||||
# 连接级异常:关闭该连接,回到 accept
|
||||
break
|
||||
except Exception as e:
|
||||
LogManager.warning(f"[SOCKET][WARN] recv error: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
LogManager.error(f"[SOCKET][ERROR] 连接处理异常: {e}")
|
||||
|
||||
def start_socket_listener():
|
||||
"""启动设备事件监听(仅走 FLASK_COMM_PORT;增强健壮性,不改业务)"""
|
||||
# 统一使用 FLASK_COMM_PORT,默认 34566
|
||||
port = int(os.getenv('FLASK_COMM_PORT', 34566))
|
||||
LogManager.info(f"Received port from environment: {port}")
|
||||
print(f"Received port from environment: {port}")
|
||||
|
||||
if port <= 0:
|
||||
LogManager.info("未获取到通信端口,跳过Socket监听")
|
||||
print("未获取到通信端口,跳过Socket监听")
|
||||
return
|
||||
|
||||
backoff = 0.5 # 自愈退避,起于 0.5s,上限 8s
|
||||
while True:
|
||||
s = None
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
except Exception as e:
|
||||
LogManager.warning(f"[SOCKET][WARN] setsockopt SO_REUSEADDR failed: {e}")
|
||||
|
||||
try:
|
||||
s.bind(('127.0.0.1', port))
|
||||
print(f"[INFO] Socket successfully bound to port {port}")
|
||||
LogManager.info(f"[INFO] Socket successfully bound to port {port}")
|
||||
except Exception as bind_error:
|
||||
print(f"[ERROR]端口绑定失败: {bind_error}")
|
||||
LogManager.info(f"[ERROR]端口绑定失败: {bind_error}")
|
||||
# 绑定失败通常是端口未释放/竞争,退避后重试
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 8.0)
|
||||
continue
|
||||
|
||||
s.listen(256)
|
||||
try:
|
||||
s.settimeout(1.5) # accept 超时,便于检查自愈循环
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
LogManager.info(f"[INFO] Socket listener started on port {port}, waiting for connections...")
|
||||
print(f"[INFO] Socket listener started on port {port}, waiting for connections...")
|
||||
# 监听成功 → 退避复位
|
||||
backoff = 0.5
|
||||
|
||||
while True:
|
||||
try:
|
||||
conn, addr = s.accept()
|
||||
except socket.timeout:
|
||||
# 定期“透气”,避免永久卡死;继续等待
|
||||
continue
|
||||
except Exception as e:
|
||||
# 发生 accept 级错误:重建 socket(进入外层 while 自愈)
|
||||
LogManager.error(f"[ERROR] accept 失败: {e}")
|
||||
break
|
||||
|
||||
# 每个连接独立线程处理,保持你原来的做法
|
||||
threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
# 任意未兜住的异常,记录并进入退避自愈
|
||||
LogManager.error(f"[SOCKET][ERROR] 监听主循环异常: {e}")
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 8.0)
|
||||
finally:
|
||||
try:
|
||||
if s:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 独立线程启动 Socket 服务 + 看门狗
|
||||
def bootstrap_server_side_effects():
|
||||
# 仅在真正的 Flask 进程里启动副作用(监听、定时器、MQ 等)
|
||||
listener_thread = threading.Thread(target=start_socket_listener, daemon=True)
|
||||
listener_thread.start()
|
||||
|
||||
# 获取app
|
||||
def get_app():
|
||||
return app
|
||||
|
||||
@app.before_request
|
||||
def _log_request_start():
|
||||
g._start_ts = time.time()
|
||||
LogManager.info(
|
||||
text=f"[HTTP] START {request.method} {request.path}",
|
||||
udid="flask"
|
||||
)
|
||||
|
||||
@app.after_request
|
||||
def _log_request_end(response):
|
||||
cost = time.time() - getattr(g, "_start_ts", time.time())
|
||||
LogManager.info(
|
||||
text=f"[HTTP] END {request.method} {request.path} {response.status_code} in {cost:.3f}s",
|
||||
udid="flask"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# ============ API 路由 ============
|
||||
@app.route('/deviceList', methods=['GET'])
|
||||
async def deviceList():
|
||||
global _last_device_count, change_version
|
||||
global _last_nonempty_snapshot, _last_snapshot_ts, _STICKY_TTL_SEC
|
||||
global _empty_logged, _recovered_logged
|
||||
try:
|
||||
with listLock:
|
||||
# 宽容判定在线(字符串'1'/'true'/True 都算)
|
||||
data = [d for d in listData if _is_online(d)]
|
||||
now = time.time()
|
||||
|
||||
# 记录最近一次非空快照
|
||||
if data:
|
||||
_last_nonempty_snapshot = data.copy()
|
||||
_last_snapshot_ts = now
|
||||
if _recovered_logged:
|
||||
LogManager.info(f"[API][deviceList][RECOVERED] count={len(data)} rev={change_version}")
|
||||
_recovered_logged = False
|
||||
_empty_logged = False
|
||||
else:
|
||||
# 瞬时空:在 TTL 内回退上一份非空快照
|
||||
if _last_nonempty_snapshot and (now - _last_snapshot_ts) <= _STICKY_TTL_SEC:
|
||||
LogManager.warning(f"[API][deviceList][STICKY] serving last non-empty snapshot count={len(_last_nonempty_snapshot)} age={now - _last_snapshot_ts:.1f}s rev={change_version}")
|
||||
return ResultData(data=_last_nonempty_snapshot).toJson()
|
||||
if not _empty_logged:
|
||||
LogManager.error(f"[API][deviceList][DROP_TO_EMPTY] last_count={_last_device_count} rev={change_version}")
|
||||
_empty_logged = True
|
||||
_recovered_logged = True
|
||||
|
||||
_last_device_count = len(data)
|
||||
LogManager.info(f"[API][deviceList] return_count={len(data)} rev={change_version}")
|
||||
return ResultData(data=data).toJson()
|
||||
except Exception as e:
|
||||
LogManager.error(f"[API][deviceList] error={e}")
|
||||
return ResultData(data=[]).toJson()
|
||||
|
||||
@app.route('/passToken', methods=['POST'])
|
||||
async def passToken():
|
||||
data = await request.get_json()
|
||||
print(json.dumps(data))
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 获取设备应用列表
|
||||
@app.route('/deviceAppList', methods=['POST'])
|
||||
async def deviceAppList():
|
||||
param = await request.get_json()
|
||||
udid = param["udid"]
|
||||
apps = ControlUtils.getDeviceAppList(udid)
|
||||
return ResultData(data=apps).toJson()
|
||||
|
||||
# 打开指定app
|
||||
@app.route('/launchApp', methods=['POST'])
|
||||
async def launchApp():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
bundleId = body.get("bundleId")
|
||||
t = wda.USBClient(udid, wdaFunctionPort)
|
||||
t.session().app_start(bundleId)
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 回到首页
|
||||
@app.route('/toHome', methods=['POST'])
|
||||
async def toHome():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
client = wda.USBClient(udid, wdaFunctionPort)
|
||||
client.home()
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 点击事件
|
||||
@app.route('/tapAction', methods=['POST'])
|
||||
async def tapAction():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
client = wda.USBClient(udid, wdaFunctionPort)
|
||||
print("-----------------------")
|
||||
print(client)
|
||||
print("-----------------------")
|
||||
session = client.session()
|
||||
session.appium_settings({"snapshotMaxDepth": 0})
|
||||
x = body.get("x")
|
||||
y = body.get("y")
|
||||
session.tap(x, y)
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 拖拽事件
|
||||
@app.route('/swipeAction', methods=['POST'])
|
||||
async def swipeAction():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
duration = body.get("duration") # 时长
|
||||
sx = body.get("sx") # 起始X点
|
||||
sy = body.get("sy") # 起始Y点
|
||||
ex = body.get("ex") # 结束X点
|
||||
ey = body.get("ey") # 结束Y点
|
||||
|
||||
client = wda.USBClient(udid, wdaFunctionPort)
|
||||
session = client.session()
|
||||
session.appium_settings({"snapshotMaxDepth": 0})
|
||||
|
||||
session.swipe(sx, sy, ex, ey, duration)
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 长按事件
|
||||
@app.route('/longPressAction', methods=['POST'])
|
||||
async def longPressAction():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
x = body.get("x")
|
||||
y = body.get("y")
|
||||
client = wda.USBClient(udid, wdaFunctionPort)
|
||||
session = client.session()
|
||||
session.appium_settings({"snapshotMaxDepth": 5})
|
||||
session.tap_hold(x, y, 1.0)
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 养号
|
||||
@app.route('/growAccount', methods=['POST'])
|
||||
async def growAccount():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
Variables.commentList = body.get("comment")
|
||||
isComment = body.get("isComment")
|
||||
|
||||
manager = ScriptManager()
|
||||
event = threading.Event()
|
||||
# 启动脚本
|
||||
thread = threading.Thread(target=manager.growAccount, args=(udid, isComment, event,))
|
||||
# 添加到线程管理
|
||||
code, msg = ThreadManager.add(udid, thread, event)
|
||||
return ResultData(data="", code=code, message=msg).toJson()
|
||||
|
||||
# 观看直播
|
||||
@app.route("/watchLiveForGrowth", methods=['POST'])
|
||||
async def watchLiveForGrowth():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
manager = ScriptManager()
|
||||
event = threading.Event()
|
||||
thread = threading.Thread(target=manager.watchLiveForGrowth, args=(udid, event))
|
||||
# 添加到线程管理
|
||||
ThreadManager.add(udid, thread, event)
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 停止脚本
|
||||
@app.route("/stopScript", methods=['POST'])
|
||||
async def stopScript():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
LogManager.method_info(f"接口收到 /stopScript udid={udid}", method="task")
|
||||
code, msg = ThreadManager.stop(udid)
|
||||
return ResultData(code=code, data=[], message=msg).toJson()
|
||||
|
||||
# 关注打招呼
|
||||
@app.route('/passAnchorData', methods=['POST'])
|
||||
async def passAnchorData():
|
||||
try:
|
||||
LogManager.method_info("关注打招呼", "关注打招呼")
|
||||
data: Dict[str, Any] = await request.get_json()
|
||||
# 设备列表
|
||||
idList = data.get("deviceList", [])
|
||||
# 主播列表
|
||||
acList = data.get("anchorList", [])
|
||||
Variables.commentList = data.get("comment")
|
||||
isComment = data.get("isComment")
|
||||
LogManager.info(f"[INFO] 获取数据: {idList} {acList}")
|
||||
AiUtils.save_aclist_flat_append(acList)
|
||||
# 是否需要回复
|
||||
needReply = data.get("needReply", False)
|
||||
# 获取打招呼数据
|
||||
ev.prologueList = data.get("prologueList", [])
|
||||
needTranslate = data.get("needTranslate", False)
|
||||
|
||||
# 添加主播数据
|
||||
addModelToAnchorList(acList)
|
||||
|
||||
failed_ids = []
|
||||
# 启动线程,执行脚本(单个设备异常不影响其它设备)
|
||||
for udid in idList:
|
||||
try:
|
||||
manager = ScriptManager()
|
||||
event = threading.Event()
|
||||
thread = threading.Thread(
|
||||
target=manager.safe_greetNewFollowers,
|
||||
args=(udid, needReply, isComment, needTranslate, event,),
|
||||
)
|
||||
ThreadManager.add(udid, thread, event)
|
||||
except Exception as e:
|
||||
failed_ids.append(udid)
|
||||
LogManager.error(f"[passAnchorData] 设备 {udid} 启动脚本失败: {e}")
|
||||
|
||||
# 如果所有设备都失败,可以考虑返回错误码
|
||||
if failed_ids and len(failed_ids) == len(idList):
|
||||
return ResultData(
|
||||
data="",
|
||||
code=1001,
|
||||
message=f"所有设备启动失败: {failed_ids}"
|
||||
).toJson()
|
||||
|
||||
# 部分失败也算整体成功,只是记录一下
|
||||
if failed_ids:
|
||||
LogManager.warning(f"[passAnchorData] 部分设备启动失败: {failed_ids}")
|
||||
|
||||
return ResultData(data="").toJson()
|
||||
except Exception as e:
|
||||
LogManager.error(e)
|
||||
return ResultData(data="", code=1001).toJson()
|
||||
|
||||
@app.route('/followAndGreetUnion', methods=['POST'])
|
||||
async def followAndGreetUnion():
|
||||
try:
|
||||
LogManager.method_info("关注打招呼", "关注打招呼(联盟号)")
|
||||
data: Dict[str, Any] = await request.get_json()
|
||||
|
||||
# 设备列表
|
||||
idList = data.get("deviceList", [])
|
||||
# 主播列表
|
||||
acList = data.get("anchorList", [])
|
||||
LogManager.info(f"[INFO] 获取数据: {idList} {acList}")
|
||||
|
||||
AiUtils.save_aclist_flat_append(acList)
|
||||
|
||||
# 是否需要回复
|
||||
needReply = data.get("needReply", True)
|
||||
needTranslate = data.get("needTranslate", False)
|
||||
# 获取打招呼数据
|
||||
ev.prologueList = data.get("prologueList", [])
|
||||
|
||||
# 添加主播数据
|
||||
addModelToAnchorList(acList)
|
||||
|
||||
failed_ids = []
|
||||
|
||||
# 启动线程,执行脚本(单个设备异常不影响其它设备)
|
||||
for udid in idList:
|
||||
try:
|
||||
manager = ScriptManager()
|
||||
event = threading.Event()
|
||||
thread = threading.Thread(
|
||||
target=manager.safe_followAndGreetUnion,
|
||||
args=(udid, needReply, needTranslate, event),
|
||||
)
|
||||
ThreadManager.add(udid, thread, event)
|
||||
except Exception as e:
|
||||
failed_ids.append(udid)
|
||||
LogManager.error(f"[followAndGreetUnion] 设备 {udid} 启动脚本失败: {e}")
|
||||
|
||||
# 如果所有设备都失败,可以返回错误码
|
||||
if failed_ids and len(failed_ids) == len(idList):
|
||||
return ResultData(
|
||||
data="",
|
||||
code=1001,
|
||||
message=f"所有设备启动失败: {failed_ids}",
|
||||
).toJson()
|
||||
|
||||
# 部分失败也算整体成功,只是记录一下
|
||||
if failed_ids:
|
||||
LogManager.warning(f"[followAndGreetUnion] 部分设备启动失败: {failed_ids}")
|
||||
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
except Exception as e:
|
||||
LogManager.error(f"[followAndGreetUnion] 接口级异常: {e}")
|
||||
return ResultData(data="", code=1001).toJson()
|
||||
|
||||
# 获取私信数据
|
||||
@app.route("/getPrologueList", methods=['GET'])
|
||||
async def getPrologueList():
|
||||
import Entity.Variables as Variables
|
||||
return ResultData(data=Variables.prologueList).toJson()
|
||||
|
||||
# 添加临时数据
|
||||
# 批量追加主播到 JSON 文件
|
||||
@app.route("/addTempAnchorData", methods=['POST'])
|
||||
async def addTempAnchorData():
|
||||
"""
|
||||
请求体支持:
|
||||
- 单个对象:{"anchorId": "xxx", "country": "CN"}
|
||||
- 对象数组:[{"anchorId": "xxx", "country": "CN"}, {"anchorId": "yyy", "country": "US"}]
|
||||
"""
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return ResultData(code=400, message="请求数据为空").toJson()
|
||||
# 追加到 JSON 文件
|
||||
AiUtils.save_aclist_flat_append(data, "data/acList.json")
|
||||
return ResultData(data="ok").toJson()
|
||||
|
||||
# 获取当前屏幕上的聊天信息
|
||||
@app.route("/getChatTextInfo", methods=['POST'])
|
||||
async def getChatTextInfo():
|
||||
data = await request.get_json()
|
||||
udid = data.get("udid")
|
||||
client = wda.USBClient(udid,wdaFunctionPort)
|
||||
session = client.session()
|
||||
xml = session.source()
|
||||
try:
|
||||
result = AiUtils.extract_messages_from_xml(xml)
|
||||
|
||||
last_in = None
|
||||
last_out = None
|
||||
|
||||
for item in reversed(result): # 从后往前找
|
||||
if item.get('type') != 'msg':
|
||||
continue
|
||||
if last_in is None and item['dir'] == 'in':
|
||||
last_in = item['text']
|
||||
if last_out is None and item['dir'] == 'out':
|
||||
last_out = item['text']
|
||||
if last_in is not None and last_out is not None:
|
||||
break
|
||||
|
||||
print(f"检测出对方的最后一条数据:{last_in},{type(last_in)}")
|
||||
print(f"检测出我的最后一条数据:{last_out},{type(last_out)}")
|
||||
|
||||
return ResultData(data=result).toJson()
|
||||
except Exception as e:
|
||||
|
||||
LogManager.error(f"获取屏幕翻译出现错误:{e}", "获取屏幕翻译")
|
||||
|
||||
data = [
|
||||
{
|
||||
'type': 'massage',
|
||||
'dir': 'in',
|
||||
'text': '当前页面无法获取聊天记录,请在tiktok聊天页面进行获取!!!'
|
||||
},
|
||||
{
|
||||
'type': 'massage',
|
||||
'dir': 'in',
|
||||
'text': 'Unable to retrieve chat messages on the current screen. Please navigate to the TikTok chat page and try again!!!'
|
||||
}
|
||||
]
|
||||
return ResultData(data=data, message="解析失败").toJson()
|
||||
|
||||
# 监控消息
|
||||
@app.route("/replyMessages", methods=['POST'])
|
||||
async def monitorMessages():
|
||||
LogManager.method_info("开始监控消息,监控消息脚本启动", "监控消息")
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
# Variables.commentList = body.get("comment")
|
||||
|
||||
manager = ScriptManager()
|
||||
event = threading.Event()
|
||||
thread = threading.Thread(target=manager.replyMessages, args=(udid, event))
|
||||
LogManager.method_info("创建监控消息脚本线程成功", "监控消息")
|
||||
# 添加到线程管理
|
||||
ThreadManager.add(udid, thread, event)
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 上传日志
|
||||
@app.route("/setLoginInfo", methods=['POST'])
|
||||
async def upLoadLogLogs():
|
||||
data = await request.get_json() # 解析 JSON
|
||||
token = data.get("token")
|
||||
userId = data.get("userId")
|
||||
tenantId = data.get("tenantId")
|
||||
ok = LogManager.upload_all_logs("http://47.79.98.113:8101/api/log/upload", token, userId, tenantId)
|
||||
if ok:
|
||||
return ResultData(data="日志上传成功").toJson()
|
||||
else:
|
||||
return ResultData(data="", message="日志上传失败").toJson()
|
||||
|
||||
# 获取当前的主播列表数据
|
||||
@app.route("/anchorList", methods=['POST'])
|
||||
async def queryAnchorList():
|
||||
# 项目根目录(当前文件在 infos 下,回退两层到根目录)
|
||||
root_dir = Path(__file__).resolve().parent.parent
|
||||
file_path = root_dir / "data" / "acList.json"
|
||||
|
||||
data = []
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
LogManager.error(f"[anchorList] 读取失败: {e}")
|
||||
data = []
|
||||
return ResultData(data=data).toJson()
|
||||
|
||||
# 修改当前的主播列表数据
|
||||
@app.route("/updateAnchorList", methods=['POST'])
|
||||
async def updateAnchorList():
|
||||
"""
|
||||
invitationType: 1 普票 2 金票
|
||||
state: 1 通行(True) / 0 不通行(False)
|
||||
"""
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
invitationType = data.get("invitationType")
|
||||
state = bool(data.get("state")) # 转成布尔
|
||||
|
||||
# 要更新成的值
|
||||
new_status = 1 if state else 0
|
||||
|
||||
# 用工具类解析路径,避免 cwd 影响
|
||||
file_path = AiUtils._resolve_path("data/acList.json")
|
||||
|
||||
# 加载 JSON
|
||||
try:
|
||||
doc = json.loads(file_path.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e:
|
||||
LogManager.error(f"[updateAnchorList] 读取失败: {e}")
|
||||
return ResultData(code=1001, message=f"暂无数据").toJson()
|
||||
|
||||
# 定位 anchorList
|
||||
if isinstance(doc, list):
|
||||
acList = doc
|
||||
wrapper = None
|
||||
elif isinstance(doc, dict) and isinstance(doc.get("anchorList"), list):
|
||||
acList = doc["anchorList"]
|
||||
wrapper = doc
|
||||
else:
|
||||
return ResultData(code=500, message="文件格式不合法").toJson()
|
||||
|
||||
# 遍历并更新
|
||||
updated = 0
|
||||
for item in acList:
|
||||
if isinstance(item, dict) and item.get("invitationType") == invitationType:
|
||||
item["state"] = new_status
|
||||
updated += 1
|
||||
|
||||
# 写回(保持原始结构)
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
to_write = wrapper if wrapper is not None else acList
|
||||
file_path.write_text(json.dumps(to_write, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as e:
|
||||
LogManager.error(f"[updateAnchorList] 写入失败: {e}")
|
||||
return ResultData(code=500, message=f"写入失败: {e}").toJson()
|
||||
|
||||
if updated:
|
||||
return ResultData(data=updated, message=f"已更新 {updated} 条记录").toJson()
|
||||
else:
|
||||
return ResultData(data=0, message="未找到符合条件的记录").toJson()
|
||||
|
||||
# 删除主播
|
||||
@app.route("/deleteAnchorWithIds", methods=['POST'])
|
||||
async def deleteAnchorWithIds():
|
||||
ls: list[dict] = await request.get_json() # [{"anchorId": "xxx"}, ...]
|
||||
ids = [d.get("anchorId") for d in ls if d.get("anchorId")]
|
||||
deleted = AiUtils.delete_anchors_by_ids(ids)
|
||||
return ResultData(data={"deleted": deleted}).toJson()
|
||||
|
||||
# 配置ai人设
|
||||
@app.route("/aiConfig", methods=['POST'])
|
||||
async def aiConfig():
|
||||
data = await request.get_json()
|
||||
agentName = data.get("agentName")
|
||||
guildName = data.get("guildName")
|
||||
contactTool = data.get("contactTool")
|
||||
contact = data.get("contact")
|
||||
|
||||
age = data.get("age")
|
||||
sex = data.get("sex")
|
||||
height = data.get("height")
|
||||
weight = data.get("weight")
|
||||
body_features = data.get("body_features")
|
||||
nationality = data.get("nationality")
|
||||
personality = data.get("personality")
|
||||
strengths = data.get("strengths")
|
||||
|
||||
dict = {
|
||||
"agentName": agentName,
|
||||
"guildName": guildName,
|
||||
"contactTool": contactTool,
|
||||
"contact": contact,
|
||||
"age": age,
|
||||
"sex": sex,
|
||||
"height": height,
|
||||
"weight": weight,
|
||||
"body_features": body_features,
|
||||
"nationality": nationality,
|
||||
"personality": personality,
|
||||
"strengths": strengths,
|
||||
"api-key": "app-sdRfZy2by9Kq7uJg7JdOSVr8"
|
||||
}
|
||||
|
||||
# JsonUtils.write_json("aiConfig", dict)
|
||||
IOSAIStorage.overwrite(dict, "aiConfig.json")
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 查询主播聊天发送的最后一条信息
|
||||
@app.route("/select_last_message", methods=['GET'])
|
||||
async def select_last_message():
|
||||
data = JsonUtils.query_all_json_items()
|
||||
return ResultData(data=data).toJson()
|
||||
|
||||
# 修改消息(已读改成未读)
|
||||
@app.route("/update_last_message", methods=['POST'])
|
||||
async def update_last_message():
|
||||
data = await request.get_json() # 解析 JSON
|
||||
sender = data.get("sender")
|
||||
udid = data.get("device")
|
||||
text = data.get("text")
|
||||
|
||||
updated_count = JsonUtils.update_json_items(
|
||||
match={"sender": sender, "text": text}, # 匹配条件
|
||||
patch={"status": 1}, # 修改内容
|
||||
filename="log/last_message.json", # 要修改的文件
|
||||
multi=True # 只改第一条匹配的
|
||||
)
|
||||
if updated_count > 0:
|
||||
return ResultData(data=updated_count, message="修改成功").toJson()
|
||||
return ResultData(data=updated_count, message="修改失败").toJson()
|
||||
|
||||
# 删除已读消息
|
||||
@app.route("/delete_last_message", methods=['POST'])
|
||||
async def delete_last_message():
|
||||
data = await request.get_json() # 解析 JSON
|
||||
sender = data.get("sender")
|
||||
udid = data.get("device")
|
||||
text = data.get("text")
|
||||
|
||||
updated_count = JsonUtils.delete_json_items(
|
||||
match={"sender": sender, "text": text}, # 匹配条件
|
||||
filename="log/last_message.json", # 要修改的文件
|
||||
multi=True # 只改第一条匹配的
|
||||
)
|
||||
if updated_count > 0:
|
||||
return ResultData(data=updated_count, message="修改成功").toJson()
|
||||
|
||||
return ResultData(data=updated_count, message="修改失败").toJson()
|
||||
|
||||
# 停止所有任务
|
||||
@app.route("/stopAllTask", methods=['POST'])
|
||||
async def stopAllTask():
|
||||
idList = await request.get_json()
|
||||
code, msg, data = ThreadManager.batch_stop(idList)
|
||||
return ResultData(code, data, msg).toJson()
|
||||
|
||||
# 切换账号
|
||||
@app.route('/changeAccount', methods=['POST'])
|
||||
async def changeAccount():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
if not udid:
|
||||
return ResultData(data="", code=400, message="缺少 udid").toJson()
|
||||
|
||||
manager = ScriptManager()
|
||||
threading.Event()
|
||||
|
||||
# 启动脚本
|
||||
code, msg = manager.changeAccount(udid)
|
||||
# thread = threading.Thread(target=, args=(udid,))
|
||||
# # 添加到线程管理
|
||||
# thread.start()
|
||||
return ResultData(data="", code=code, message=msg).toJson()
|
||||
|
||||
# 查看设备网络状态
|
||||
@app.route('/getDeviceNetStatus', methods=['POST'])
|
||||
async def getDeviceNetStatus():
|
||||
body = await request.get_json()
|
||||
udid = body.get("udid")
|
||||
# 同步且超级慢的逻辑 → 丢到线程池,不阻塞事件循环
|
||||
def _work():
|
||||
client = wda.USBClient(udid, wdaFunctionPort)
|
||||
r = client.getNetWorkStatus()
|
||||
return r.get("value")
|
||||
value = await anyio.to_thread.run_sync(_work)
|
||||
|
||||
return ResultData(data=value, code=200).toJson()
|
||||
|
||||
# 获取ai配置
|
||||
@app.route("/getAiConfig", methods=['GET'])
|
||||
def getAiConfig():
|
||||
data = IOSAIStorage.load("aiConfig.json")
|
||||
return ResultData(data=data).toJson()
|
||||
|
||||
# 重新开启tiktok
|
||||
@app.route("/restartTikTok", methods=['POST'])
|
||||
async def restartTikTok():
|
||||
json = await request.get_json()
|
||||
udid = json.get("udid")
|
||||
client = wda.USBClient(udid, wdaFunctionPort)
|
||||
session = client.session()
|
||||
ControlUtils.closeTikTok(session, udid)
|
||||
time.sleep(1)
|
||||
ControlUtils.openTikTok(session, udid)
|
||||
return ResultData(data="").toJson()
|
||||
|
||||
# 健康检查
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 只有“明确是 Flask 进程”才跑副作用(通过 APP_ROLE 控制)
|
||||
app.run("0.0.0.0", port=5000, debug=False, use_reloader=False, threaded=True)
|
||||
270
Module/FlaskSubprocessManager.py
Normal file
270
Module/FlaskSubprocessManager.py
Normal file
@@ -0,0 +1,270 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, Dict, List
|
||||
|
||||
from Utils.LogManager import LogManager
|
||||
|
||||
|
||||
class FlaskSubprocessManager:
|
||||
"""
|
||||
超稳定版 Flask 子进程守护
|
||||
- 单线程 watchdog(唯一监控点)
|
||||
- 强制端口检测
|
||||
- 端口不通 / 子进程退出 → 100% 重启
|
||||
- 完整支持 exe + Python 模式
|
||||
- 自动恢复设备列表快照
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.RLock()
|
||||
|
||||
def __new__(cls):
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialize()
|
||||
return cls._instance
|
||||
|
||||
# ========================= 初始化 =========================
|
||||
def _initialize(self):
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.comm_port = 34566
|
||||
self._watchdog_running = False
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self._restart_cooldown = 5 # 每次重启最少间隔
|
||||
self._restart_fail_threshold = 3 # 端口检查连续失败几次才重启
|
||||
self._restart_fail_count = 0
|
||||
|
||||
self._restart_window = 600 # 10 分钟
|
||||
self._restart_limit = 5 # 最多次数
|
||||
self._restart_record: List[float] = []
|
||||
|
||||
if os.name == "nt":
|
||||
si = subprocess.STARTUPINFO()
|
||||
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
si.wShowWindow = 0
|
||||
self._si = si
|
||||
else:
|
||||
self._si = None
|
||||
|
||||
atexit.register(self.stop)
|
||||
self._kill_orphans()
|
||||
|
||||
LogManager.info("FlaskSubprocessManager 初始化完成", udid="flask")
|
||||
|
||||
# ========================= 工具 =========================
|
||||
def _log(self, level, msg):
|
||||
print(msg)
|
||||
if level == "info":
|
||||
LogManager.info(msg, udid="flask")
|
||||
elif level == "warn":
|
||||
LogManager.warning(msg, udid="flask")
|
||||
else:
|
||||
LogManager.error(msg, udid="flask")
|
||||
|
||||
# 杀死残留 python.exe 占用端口
|
||||
def _kill_orphans(self):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
out = subprocess.check_output(["netstat", "-ano"], text=True)
|
||||
for line in out.splitlines():
|
||||
if f"127.0.0.1:{self.comm_port}" in line and "LISTENING" in line:
|
||||
pid = int(line.strip().split()[-1])
|
||||
if pid != os.getpid():
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/PID", str(pid)],
|
||||
capture_output=True
|
||||
)
|
||||
self._log("warn", f"[FlaskMgr] 杀死残留 Flask 实例 PID={pid}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _port_alive(self):
|
||||
"""检测 Flask 与 Quart 的两个端口是否活着"""
|
||||
def _check(p):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", p), timeout=0.4):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return _check(self.comm_port) or _check(self.comm_port + 1)
|
||||
|
||||
# ========================= 启动 =========================
|
||||
# ========================= 启动 =========================
|
||||
def start(self):
|
||||
with self._lock:
|
||||
# 已经有一个在跑了就别重复起
|
||||
if self.process and self.process.poll() is None:
|
||||
self._log("warn", "[FlaskMgr] Flask 已在运行,跳过")
|
||||
return
|
||||
|
||||
# 设定环境变量,给子进程用
|
||||
env = os.environ.copy()
|
||||
env["FLASK_COMM_PORT"] = str(self.comm_port)
|
||||
|
||||
# ✅ 正确判断是否是 Nuitka/打包后的 exe
|
||||
# - 被 Nuitka 打包:sys.frozen 会存在/为 True
|
||||
# - 直接用 python 跑 .py:sys.frozen 不存在
|
||||
is_frozen = bool(getattr(sys, "frozen", False))
|
||||
|
||||
if is_frozen:
|
||||
# 打包后的 exe 模式:直接调用自己
|
||||
exe = Path(sys.executable).resolve()
|
||||
cmd = [str(exe), "--role=flask"]
|
||||
cwd = str(exe.parent)
|
||||
else:
|
||||
# 开发模式:用 python 去跑 Module/Main.py --role=flask
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
main_py = project_root / "Module" / "Main.py"
|
||||
cmd = [sys.executable, "-u", str(main_py), "--role=flask"]
|
||||
cwd = str(project_root)
|
||||
|
||||
self._log("info", f"[FlaskMgr] 启动 Flask: {cmd}")
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
bufsize=1,
|
||||
startupinfo=self._si,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# 异步吃子进程 stdout,顺便打日志
|
||||
threading.Thread(target=self._read_stdout, daemon=True).start()
|
||||
|
||||
# 看门狗只需要起一次
|
||||
if not self._watchdog_running:
|
||||
threading.Thread(target=self._watchdog_loop, daemon=True).start()
|
||||
self._watchdog_running = True
|
||||
|
||||
self._log("info", f"[FlaskMgr] Flask 子进程已启动 PID={self.process.pid}")
|
||||
|
||||
def _read_stdout(self):
|
||||
if not self.process or not self.process.stdout:
|
||||
return
|
||||
for line in iter(self.process.stdout.readline, ""):
|
||||
if line:
|
||||
self._log("info", f"[Flask] {line.rstrip()}")
|
||||
|
||||
# ========================= 停止 =========================
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
if not self.process:
|
||||
return
|
||||
|
||||
try:
|
||||
self.process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.process.wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.process.poll() is None:
|
||||
try:
|
||||
self.process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._log("warn", "[FlaskMgr] 已停止 Flask 子进程")
|
||||
self.process = None
|
||||
|
||||
# ========================= 看门狗 =========================
|
||||
def _watchdog_loop(self):
|
||||
self._log("info", "[FlaskWD] 看门狗已启动")
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
time.sleep(1.2)
|
||||
|
||||
# 1) 子进程退出
|
||||
if not self.process or self.process.poll() is not None:
|
||||
self._log("error", "[FlaskWD] Flask 子进程退出,准备重启")
|
||||
self._restart()
|
||||
continue
|
||||
|
||||
# 2) 端口不通
|
||||
if not self._port_alive():
|
||||
self._restart_fail_count += 1
|
||||
self._log("warn", f"[FlaskWD] 端口检测失败 {self._restart_fail_count}/"
|
||||
f"{self._restart_fail_threshold}")
|
||||
|
||||
if self._restart_fail_count >= self._restart_fail_threshold:
|
||||
self._restart()
|
||||
continue
|
||||
|
||||
# 3) 端口正常
|
||||
self._restart_fail_count = 0
|
||||
|
||||
# ========================= 重启核心逻辑 =========================
|
||||
def _restart(self):
|
||||
now = time.time()
|
||||
|
||||
# 10 分钟限频
|
||||
self._restart_record = [t for t in self._restart_record if now - t < self._restart_window]
|
||||
if len(self._restart_record) >= self._restart_limit:
|
||||
self._log("error", "[FlaskWD] 10 分钟内重启次数太多,暂停监控")
|
||||
return
|
||||
|
||||
# 冷却
|
||||
if self._restart_record and now - self._restart_record[-1] < self._restart_cooldown:
|
||||
self._log("warn", "[FlaskWD] 冷却中,暂不重启")
|
||||
return
|
||||
|
||||
self._log("warn", "[FlaskWD] >>> 重启 Flask 子进程 <<<")
|
||||
|
||||
# 执行重启
|
||||
try:
|
||||
self.stop()
|
||||
time.sleep(1)
|
||||
self.start()
|
||||
self._restart_record.append(now)
|
||||
self._restart_fail_count = 0
|
||||
except Exception as e:
|
||||
self._log("error", f"[FlaskWD] 重启失败: {e}")
|
||||
|
||||
# 重启后推送设备快照
|
||||
self._push_snapshot()
|
||||
|
||||
# ========================= 推送设备快照 =========================
|
||||
def _push_snapshot(self):
|
||||
"""Flask 重启后重新同步 DeviceInfo 内容"""
|
||||
try:
|
||||
from Module.DeviceInfo import DeviceInfo
|
||||
info = DeviceInfo()
|
||||
with info._lock:
|
||||
for m in info._models.values():
|
||||
self.send(m.toDict())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ========================= 发送数据 =========================
|
||||
def send(self, data: Union[str, Dict]):
|
||||
if isinstance(data, dict):
|
||||
data = json.dumps(data, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.comm_port), timeout=2) as s:
|
||||
s.sendall((data + "\n").encode())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
return cls()
|
||||
355
Module/IOSActivator.py
Normal file
355
Module/IOSActivator.py
Normal file
@@ -0,0 +1,355 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import subprocess
|
||||
from typing import Optional, Callable
|
||||
|
||||
from Entity.Variables import WdaAppBundleId
|
||||
|
||||
|
||||
class IOSActivator:
|
||||
"""
|
||||
给 iOS17+ 用的 go-ios 激活器(单例):
|
||||
- 维护一条全局 tunnel 进程
|
||||
- 流程:tunnel start -> pair(可多次重试) -> image auto(非致命) -> runwda(多次重试+日志判定成功)
|
||||
- WDA 启动成功后触发回调 on_wda_ready(udid)
|
||||
"""
|
||||
|
||||
# ===== 单例 & 全局 tunnel =====
|
||||
_instance = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
_tunnel_proc: Optional[subprocess.Popen] = None
|
||||
_tunnel_lock = threading.Lock()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ios_path: Optional[str] = None,
|
||||
pair_timeout: int = 60, # 配对最多等多久
|
||||
pair_retry_interval: int = 3, # 配对重试间隔
|
||||
runwda_max_retry: int = 10, # runwda 最大重试次数
|
||||
runwda_retry_interval: int = 3, # runwda 重试间隔
|
||||
runwda_wait_timeout: int = 25 # 单次 runwda 等待“成功日志”的超时时间
|
||||
):
|
||||
if getattr(self, "_inited", False):
|
||||
return
|
||||
|
||||
# 运行路径处理(源码 / Nuitka EXE)
|
||||
if "__compiled__" in globals():
|
||||
base_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
cur_file = os.path.abspath(__file__)
|
||||
base_dir = os.path.dirname(os.path.dirname(cur_file))
|
||||
|
||||
resource_dir = os.path.join(base_dir, "resources")
|
||||
if not ios_path:
|
||||
ios_path = os.path.join(resource_dir, "ios.exe")
|
||||
|
||||
self.ios_path = ios_path
|
||||
self.pair_timeout = pair_timeout
|
||||
self.pair_retry_interval = pair_retry_interval
|
||||
self.runwda_max_retry = runwda_max_retry
|
||||
self.runwda_retry_interval = runwda_retry_interval
|
||||
self.runwda_wait_timeout = runwda_wait_timeout
|
||||
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ========= 关键:这里改成“真正隐藏窗口”的安全版 =========
|
||||
self._creationflags = 0
|
||||
self._startupinfo = None
|
||||
if os.name == "nt":
|
||||
try:
|
||||
# 只用 CREATE_NO_WINDOW,不搞 DETACHED_PROCESS / NEW_PROCESS_GROUP 之类的骚操作
|
||||
self._creationflags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
self._creationflags = 0
|
||||
|
||||
si = subprocess.STARTUPINFO()
|
||||
try:
|
||||
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
# 某些极端环境下可能没有 STARTF_USESHOWWINDOW,忽略即可
|
||||
pass
|
||||
si.wShowWindow = 0 # SW_HIDE
|
||||
self._startupinfo = si
|
||||
# ========= 关键部分结束 =========
|
||||
|
||||
self._inited = True
|
||||
|
||||
# ===== 通用同步命令执行 =====
|
||||
def _run(
|
||||
self,
|
||||
args,
|
||||
desc: str = "",
|
||||
timeout: Optional[int] = None,
|
||||
check: bool = True,
|
||||
):
|
||||
cmd = [self.ios_path] + list(args)
|
||||
cmd_str = " ".join(cmd)
|
||||
if desc:
|
||||
print(f"[ios] 执行命令({desc}): {cmd_str}")
|
||||
else:
|
||||
print(f"[ios] 执行命令: {cmd_str}")
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
creationflags=self._creationflags,
|
||||
startupinfo=self._startupinfo,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
if check:
|
||||
raise
|
||||
return -1, "", "timeout"
|
||||
|
||||
out = proc.stdout or ""
|
||||
err = proc.stderr or ""
|
||||
if check and proc.returncode != 0:
|
||||
print(f"[ios] 命令失败({desc}), rc={proc.returncode}")
|
||||
raise RuntimeError(f"[ios] 命令失败({desc}), returncode={proc.returncode}")
|
||||
|
||||
return proc.returncode, out, err
|
||||
|
||||
# ===== tunnel 相关 =====
|
||||
def _drain_process_output(self, proc: subprocess.Popen, name: str):
|
||||
"""吃掉后台进程输出,防止缓冲区阻塞"""
|
||||
try:
|
||||
if proc.stdout:
|
||||
for line in proc.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
print(f"[ios][{name}] {line}")
|
||||
except Exception as e:
|
||||
print(f"[ios][{name}] 读取 stdout 异常: {e}")
|
||||
|
||||
try:
|
||||
if proc.stderr:
|
||||
for line in proc.stderr:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
print(f"[ios][{name}][stderr] {line}")
|
||||
except Exception as e:
|
||||
print(f"[ios][{name}] 读取 stderr 异常: {e}")
|
||||
|
||||
def _spawn_tunnel(self):
|
||||
"""启动 / 复用全局 tunnel(不隐藏窗口)"""
|
||||
with IOSActivator._tunnel_lock:
|
||||
# 已有并且还在跑就复用
|
||||
if IOSActivator._tunnel_proc is not None and IOSActivator._tunnel_proc.poll() is None:
|
||||
print("[ios] tunnel 已经在运行,跳过重新启动")
|
||||
return
|
||||
|
||||
cmd = [self.ios_path, "tunnel", "start"]
|
||||
print("[ios] 启动 go-ios tunnel:", " ".join(cmd))
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
creationflags=self._creationflags, # 0:不隐藏
|
||||
startupinfo=self._startupinfo, # None:不隐藏
|
||||
)
|
||||
except Exception as e:
|
||||
print("[ios] 启动 tunnel 失败(忽略):", e)
|
||||
return
|
||||
|
||||
IOSActivator._tunnel_proc = proc
|
||||
print("[ios] tunnel 启动成功, PID=", proc.pid)
|
||||
|
||||
# 后台吃日志
|
||||
threading.Thread(
|
||||
target=self._drain_process_output,
|
||||
args=(proc, "tunnel"),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
# ===== pair & image =====
|
||||
def _pair_until_success(self, udid: str):
|
||||
deadline = time.time() + self.pair_timeout
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
attempt += 1
|
||||
print(f"[ios] 开始配对设备({udid}),第 {attempt} 次尝试")
|
||||
|
||||
rc, out, err = self._run(
|
||||
["--udid", udid, "pair"],
|
||||
desc=f"pair({udid})",
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
|
||||
text = (out or "") + "\n" + (err or "")
|
||||
# 打印一份完整输出,方便调试
|
||||
if text.strip():
|
||||
print("[ios][pair] output:\n", text.strip())
|
||||
|
||||
if "Successfully paired" in text:
|
||||
print(f"[ios] 设备 {udid} 配对成功")
|
||||
return
|
||||
|
||||
if time.time() >= deadline:
|
||||
raise RuntimeError(f"[ios] 设备 {udid} 在超时时间内配对失败(rc={rc})")
|
||||
|
||||
time.sleep(self.pair_retry_interval)
|
||||
|
||||
def _mount_dev_image(self, udid: str):
|
||||
print(f"[ios] 开始为设备 {udid} 挂载开发者镜像 (image auto)")
|
||||
rc, out, err = self._run(
|
||||
["--udid", udid, "image", "auto"],
|
||||
desc=f"image auto({udid})",
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
|
||||
text = (out or "") + "\n" + (err or "")
|
||||
text_lower = text.lower()
|
||||
success_keywords = [
|
||||
"success mounting image",
|
||||
"there is already a developer image mounted",
|
||||
]
|
||||
if any(k in text_lower for k in success_keywords):
|
||||
print(f"[ios] 设备 {udid} 开发者镜像挂载完成")
|
||||
if text.strip():
|
||||
print("[ios][image auto] output:\n", text.strip())
|
||||
return
|
||||
|
||||
print(f"[ios] 设备 {udid} 挂载开发者镜像可能失败(rc={rc}),输出:\n{text.strip()}")
|
||||
|
||||
# ===== runwda(关键逻辑) =====
|
||||
def _run_wda_once_async(self, udid: str, on_wda_ready: Optional[Callable[[str], None]]) -> bool:
|
||||
"""
|
||||
单次 runwda:
|
||||
- 异步启动 ios.exe
|
||||
- 实时读 stdout/stderr
|
||||
- 捕获关键日志(got capabilities / authorized true)视为成功
|
||||
- 超时/进程退出且未成功 -> 失败
|
||||
"""
|
||||
cmd = [
|
||||
self.ios_path,
|
||||
f"--udid={udid}",
|
||||
"runwda",
|
||||
f"--bundleid={WdaAppBundleId}",
|
||||
f"--testrunnerbundleid={WdaAppBundleId}",
|
||||
"--xctestconfig=yolo.xctest",
|
||||
]
|
||||
print("[ios] 异步启动 runwda:", " ".join(cmd))
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
creationflags=self._creationflags, # 0:不隐藏
|
||||
startupinfo=self._startupinfo,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ios] 启动 runwda 进程失败: {e}")
|
||||
return False
|
||||
|
||||
success_evt = threading.Event()
|
||||
|
||||
def _reader(pipe, tag: str):
|
||||
try:
|
||||
for raw in pipe:
|
||||
line = (raw or "").rstrip()
|
||||
if not line:
|
||||
continue
|
||||
print(f"[WDA-LOG] {line}")
|
||||
lower = line.lower()
|
||||
# 你实测的“成功特征”
|
||||
if "got capabilities" in lower or '"authorized":true' in lower:
|
||||
success_evt.set()
|
||||
print(f"[ios] 捕获到 WDA 启动成功日志({tag}),udid={udid}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[ios] 读取 {tag} 日志异常: {e}")
|
||||
|
||||
# 日志线程
|
||||
if proc.stdout:
|
||||
threading.Thread(target=_reader, args=(proc.stdout, "stdout"), daemon=True).start()
|
||||
if proc.stderr:
|
||||
threading.Thread(target=_reader, args=(proc.stderr, "stderr"), daemon=True).start()
|
||||
|
||||
# 等待成功 / 退出 / 超时
|
||||
start = time.time()
|
||||
while True:
|
||||
if success_evt.is_set():
|
||||
print(f"[ios] WDA 日志确认已启动,udid={udid}")
|
||||
if on_wda_ready:
|
||||
try:
|
||||
on_wda_ready(udid)
|
||||
except Exception as e:
|
||||
print(f"[WDA] 回调执行异常: {e}")
|
||||
# 不主动杀进程,让 WDA 挂在那儿
|
||||
return True
|
||||
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
print(f"[ios] runwda 进程退出 rc={rc},未检测到成功日志,udid={udid}")
|
||||
return False
|
||||
|
||||
if time.time() - start > self.runwda_wait_timeout:
|
||||
print(f"[ios] runwda 等待超时({self.runwda_wait_timeout}s),未确认成功,udid={udid}")
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
def _run_wda_with_retry(self, udid: str, on_wda_ready: Optional[Callable[[str], None]]) -> bool:
|
||||
for attempt in range(1, self.runwda_max_retry + 1):
|
||||
print(f"[ios] runwda 尝试 {attempt}/{self.runwda_max_retry},udid={udid}")
|
||||
ok = self._run_wda_once_async(udid, on_wda_ready)
|
||||
if ok:
|
||||
print(f"[ios] runwda 第 {attempt} 次尝试成功,udid={udid}")
|
||||
return True
|
||||
|
||||
print(f"[ios] runwda 第 {attempt} 次尝试失败,udid={udid}")
|
||||
if attempt < self.runwda_max_retry:
|
||||
time.sleep(self.runwda_retry_interval)
|
||||
|
||||
print(f"[ios] runwda 多次失败,放弃,udid={udid}")
|
||||
return False
|
||||
|
||||
# ===== 对外主流程 =====
|
||||
def activate_ios17(self, udid: str, on_wda_ready: Optional[Callable[[str], None]] = None) -> None:
|
||||
print(f"[WDA] iOS17+ 激活开始,udid={udid}, 回调={on_wda_ready}")
|
||||
|
||||
# 1. 先确保 tunnel 在跑
|
||||
self._spawn_tunnel()
|
||||
|
||||
# 2. 配对
|
||||
try:
|
||||
self._pair_until_success(udid)
|
||||
except Exception as e:
|
||||
print(f"[WDA] pair 失败,终止激活流程 udid={udid}, err={e}")
|
||||
return
|
||||
|
||||
# 3. 挂镜像(非致命)
|
||||
try:
|
||||
self._mount_dev_image(udid)
|
||||
except Exception as e:
|
||||
print(f"[WDA] 挂载开发者镜像异常(忽略) udid={udid}, err={e}")
|
||||
|
||||
# 4. runwda + 回调
|
||||
ok = self._run_wda_with_retry(udid, on_wda_ready)
|
||||
if not ok:
|
||||
print(f"[WDA] runwda 多次失败,可能需要手动检查设备,udid={udid}")
|
||||
|
||||
print(f"[WDA] iOS17+ 激活流程结束(不代表一定成功),udid={udid}")
|
||||
176
Module/Main.py
Normal file
176
Module/Main.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import asyncio
|
||||
import ctypes
|
||||
# ===== Main.py 顶部放置(所有 import 之前)=====
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config
|
||||
|
||||
from Module.DeviceInfo import DeviceInfo
|
||||
from Module.FlaskSubprocessManager import FlaskSubprocessManager
|
||||
from Utils.AiUtils import AiUtils
|
||||
from Utils.DevDiskImageDeployer import DevDiskImageDeployer
|
||||
from Utils.LogManager import LogManager
|
||||
|
||||
# 确定 exe 或 py 文件所在目录
|
||||
BASE = Path(getattr(sys, 'frozen', False) and sys.executable or __file__).resolve().parent
|
||||
LOG_DIR = BASE / "log"
|
||||
LOG_DIR.mkdir(exist_ok=True) # 确保 log 目录存在
|
||||
|
||||
print(f"日志目录: {LOG_DIR}")
|
||||
|
||||
def _run_flask_role():
|
||||
from Module.FlaskService import get_app, bootstrap_server_side_effects
|
||||
print("Flask Pid:", os.getpid())
|
||||
port = int(os.getenv("FLASK_COMM_PORT", "34566")) # 固定端口的兜底仍是 34567
|
||||
app = get_app()
|
||||
flaskPort = port + 1
|
||||
AiUtils.flask_port_free(flaskPort)
|
||||
bootstrap_server_side_effects()
|
||||
|
||||
# ==== 关键:统一获取 resources 目录 ====
|
||||
if "__compiled__" in globals():
|
||||
# 被 Nuitka 编译后的 exe 运行时
|
||||
base_dir = os.path.dirname(sys.executable) # exe 所在目录
|
||||
else:
|
||||
# 开发环境,直接跑 .py
|
||||
cur_file = os.path.abspath(__file__) # Module/Main.py 所在目录
|
||||
base_dir = os.path.dirname(os.path.dirname(cur_file)) # 回到项目根 iOSAi
|
||||
|
||||
resource_dir = os.path.join(base_dir, "resources")
|
||||
|
||||
# Hypercorn 配置
|
||||
config = Config()
|
||||
config.bind = [f"0.0.0.0:{flaskPort}"]
|
||||
config.certfile = os.path.join(resource_dir, "server.crt")
|
||||
config.keyfile = os.path.join(resource_dir, "server.key")
|
||||
config.alpn_protocols = ["h2", "http/1.1"]
|
||||
config.workers = 6 # 你机器 4GB → 推荐 3~4 个 worker
|
||||
|
||||
# 直接跑 Quart(ASGI 原生,不再用 WsgiToAsgi)
|
||||
asyncio.run(serve(app, config))
|
||||
|
||||
if "--role=flask" in sys.argv:
|
||||
_run_flask_role()
|
||||
sys.exit(0)
|
||||
|
||||
def _ensure_wintun_installed():
|
||||
"""
|
||||
确保 wintun.dll 已经在系统目录里:
|
||||
- 优先从当前目录的 resources 中找 wintun.dll
|
||||
- 如果 System32 中没有,就复制过去(需要管理员权限)
|
||||
"""
|
||||
try:
|
||||
# ==== 关键:统一获取 resources 目录 ====
|
||||
if "__compiled__" in globals():
|
||||
# Nuitka 编译后的 exe
|
||||
base_dir = os.path.dirname(sys.executable) # exe 所在目录
|
||||
else:
|
||||
# 开发环境运行 .py
|
||||
cur_file = os.path.abspath(__file__) # Module/Main.py 所在目录
|
||||
base_dir = os.path.dirname(os.path.dirname(cur_file)) # 回到 iOSAi 根目录
|
||||
|
||||
resource_dir = os.path.join(base_dir, "resources")
|
||||
src = os.path.join(resource_dir, "wintun.dll")
|
||||
|
||||
# 1. 检查源文件是否存在
|
||||
if not os.path.exists(src):
|
||||
print(f"[wintun] 未找到资源文件: {src}")
|
||||
return
|
||||
|
||||
# 2. 系统 System32 目录
|
||||
windir = os.environ.get("WINDIR", r"C:\Windows")
|
||||
system32 = Path(windir) / "System32"
|
||||
dst = system32 / "wintun.dll"
|
||||
|
||||
# 3. System32 中已经存在则无需复制
|
||||
if dst.exists():
|
||||
print(f"[wintun] System32 中已存在: {dst}")
|
||||
return
|
||||
|
||||
# 4. 执行复制
|
||||
import shutil
|
||||
print(f"[wintun] 复制 {src} -> {dst}")
|
||||
shutil.copy2(src, dst)
|
||||
print("[wintun] 复制完成")
|
||||
|
||||
except PermissionError as e:
|
||||
print(f"[wintun] 权限不足,无法写入 System32:{e}")
|
||||
except Exception as e:
|
||||
print(f"[wintun] 安装 wintun.dll 时异常: {e}")
|
||||
|
||||
# 启动锁
|
||||
def main(arg):
|
||||
if len(arg) != 2 or arg[1] != "iosai":
|
||||
sys.exit(0)
|
||||
|
||||
# 判断是否为管理员身份原型
|
||||
def isAdministrator():
|
||||
"""
|
||||
检测当前进程是否具有管理员权限。
|
||||
- Windows 下调用 Shell32.IsUserAnAdmin()
|
||||
- 如果不是管理员,直接退出程序
|
||||
"""
|
||||
try:
|
||||
is_admin = ctypes.windll.shell32.IsUserAnAdmin()
|
||||
except Exception:
|
||||
# 非 Windows 或无法判断的情况,一律按“非管理员”处理
|
||||
is_admin = False
|
||||
|
||||
if not is_admin:
|
||||
print("[ERROR] 需要以管理员身份运行本程序!")
|
||||
sys.exit(0)
|
||||
|
||||
return True
|
||||
|
||||
# 项目入口
|
||||
if __name__ == "__main__":
|
||||
# 检测是否有管理员身份权限
|
||||
isAdministrator()
|
||||
|
||||
# 检测程序合法性
|
||||
main(sys.argv)
|
||||
|
||||
# 清空日志
|
||||
LogManager.clearLogs()
|
||||
|
||||
# 添加iOS开发包到电脑上
|
||||
deployer = DevDiskImageDeployer(verbose=True)
|
||||
deployer.deploy_all()
|
||||
|
||||
# 复制wintun.dll到system32目录下
|
||||
_ensure_wintun_installed()
|
||||
|
||||
# 启动 Flask 子进程
|
||||
manager = FlaskSubprocessManager.get_instance()
|
||||
manager.start()
|
||||
|
||||
# 设备监听(即使失败/很快返回,也不会导致主进程退出)
|
||||
try:
|
||||
info = DeviceInfo()
|
||||
info.listen()
|
||||
except Exception as e:
|
||||
print("[WARN] Device listener not running:", e)
|
||||
|
||||
# === 保活:阻塞主线程,直到收到 Ctrl+C/关闭 ===
|
||||
import threading, time, signal
|
||||
stop = threading.Event()
|
||||
|
||||
def _handle(_sig, _frm):
|
||||
stop.set()
|
||||
|
||||
# Windows 上 SIGINT/SIGTERM 都可以拦到
|
||||
try:
|
||||
signal.signal(signal.SIGINT, _handle)
|
||||
signal.signal(signal.SIGTERM, _handle)
|
||||
except Exception:
|
||||
pass # 某些环境可能不支持,忽略
|
||||
|
||||
try:
|
||||
while not stop.is_set():
|
||||
time.sleep(1)
|
||||
finally:
|
||||
# 进程退出前记得把子进程关掉
|
||||
manager.stop()
|
||||
|
||||
Reference in New Issue
Block a user