228 lines
8.5 KiB
Python
228 lines
8.5 KiB
Python
"""设备自动化管理后台入口:Flask app 装配 + 启动。
|
||
|
||
路由按功能域拆分到 web/ 蓝图包(auth/monitor/tasks/admin/tools/devices/apks/tailscale),
|
||
本文件只负责:app 创建、数据库初始化、共享对象装配、蓝图注册、uiautodev 生命周期、启动。
|
||
"""
|
||
import os
|
||
import time
|
||
import atexit
|
||
import secrets
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
|
||
from flask import Flask
|
||
from flask_login import LoginManager
|
||
|
||
from core.task_manager import TaskManager
|
||
from core.apk_manager import ApkManager
|
||
from core.logger import get_logger
|
||
from core.models import db, init_db, User
|
||
from core import uiauto_helper
|
||
from config import WEB_HOST, WEB_PORT
|
||
|
||
_log = get_logger("web")
|
||
|
||
|
||
app = Flask(__name__)
|
||
# 会话密钥:优先 .env 的 WEB_SECRET_KEY;未配置则随机生成(重启后登录态失效,生产务必配置固定值)
|
||
_web_secret = os.environ.get("WEB_SECRET_KEY") or secrets.token_hex(32)
|
||
if not os.environ.get("WEB_SECRET_KEY"):
|
||
_log.warning("未配置 WEB_SECRET_KEY,已随机生成会话密钥(重启后登录态失效)")
|
||
app.config["SECRET_KEY"] = _web_secret
|
||
# debug=False 时模板默认不自动重载,开发期改 HTML 需重启才生效;这里显式开启
|
||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
|
||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||
|
||
login_manager = LoginManager(app)
|
||
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
|
||
|
||
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||
init_db(app)
|
||
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
|
||
from core import device_pool
|
||
device_pool.init_app(app)
|
||
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
|
||
from core import device_discovery
|
||
device_discovery.init_app(app)
|
||
mgr = TaskManager(app=app)
|
||
apk_mgr = ApkManager(app=app)
|
||
|
||
# Web 层按模块拆分(web/ 蓝图包):认证/监控/任务/管理/工具/设备池/APK/Tailscale
|
||
from web import context, register_blueprints
|
||
from web.auth import _csrf_protect, _csrf_token
|
||
context.init(mgr, apk_mgr, device_pool)
|
||
register_blueprints(app)
|
||
|
||
|
||
@login_manager.user_loader
|
||
def load_user(user_id):
|
||
return User.query.get(int(user_id))
|
||
|
||
|
||
# ================== API:健康检查(无需登录,供探活) ==================
|
||
|
||
|
||
_UIAUTO_PID_FILE = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "data", "uiauto.pid")
|
||
_uiauto_proc = None
|
||
|
||
|
||
def _kill_stale_uiauto():
|
||
"""清理上次 web_server 残留的 uiautodev 进程(读取 PID 文件)。"""
|
||
if not os.path.exists(_UIAUTO_PID_FILE):
|
||
return
|
||
try:
|
||
with open(_UIAUTO_PID_FILE) as f:
|
||
pid = int(f.read().strip())
|
||
if pid > 0 and pid != os.getpid():
|
||
try:
|
||
os.kill(pid, 0) # 探测进程是否存活
|
||
os.kill(pid, 15) # 终止残留
|
||
_log.warning(f"已清理残留的 uiautodev 进程 (PID={pid})")
|
||
except ProcessLookupError:
|
||
pass
|
||
except PermissionError:
|
||
_log.warning(f"无权限清理残留 uiautodev (PID={pid}),忽略")
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
try:
|
||
os.remove(_UIAUTO_PID_FILE)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _ensure_uiauto_running():
|
||
"""确保 uiautodev 本地服务在运行(元素抓取功能依赖,端口 20242)。
|
||
|
||
已在运行则跳过;未运行则用子进程启动 `uiautodev server --no-browser`。
|
||
启动失败只记日志,不影响主服务。
|
||
"""
|
||
global _uiauto_proc
|
||
_kill_stale_uiauto()
|
||
if uiauto_helper.is_running():
|
||
_log.info("uiauto2 服务已在运行 (端口 20242)")
|
||
return
|
||
try:
|
||
_log.info("正在自动启动 uiautodev 服务 (端口 20242)...")
|
||
kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
_uiauto_proc = subprocess.Popen(
|
||
[sys.executable, "-m", "uiautodev", "server", "--no-browser"],
|
||
**kwargs,
|
||
)
|
||
_log.info(f"uiautodev 服务已启动 (PID={_uiauto_proc.pid})")
|
||
try:
|
||
with open(_UIAUTO_PID_FILE, "w") as f:
|
||
f.write(str(_uiauto_proc.pid))
|
||
except OSError:
|
||
pass
|
||
atexit.register(_stop_uiauto)
|
||
except FileNotFoundError:
|
||
_log.warning("uiautodev 未安装,元素抓取功能不可用。请运行: pip install uiautodev")
|
||
except Exception as e:
|
||
_log.warning(f"自动启动 uiautodev 失败: {e}(元素抓取功能不可用)")
|
||
|
||
|
||
def _stop_uiauto():
|
||
"""停止自动启动的 uiautodev 子进程。"""
|
||
global _uiauto_proc
|
||
try:
|
||
if os.path.exists(_UIAUTO_PID_FILE):
|
||
os.remove(_UIAUTO_PID_FILE)
|
||
except OSError:
|
||
pass
|
||
if not _uiauto_proc:
|
||
return
|
||
try:
|
||
_uiauto_proc.terminate()
|
||
_uiauto_proc.wait(timeout=5)
|
||
_log.info("uiautodev 服务已停止")
|
||
except Exception:
|
||
try:
|
||
_uiauto_proc.kill()
|
||
except Exception:
|
||
pass
|
||
_uiauto_proc = None
|
||
|
||
|
||
def _preconnect_pool_devices():
|
||
"""容器/服务重启后 adb server 重启,设备全部掉线,逐个重连要几分钟;
|
||
启动时后台并发 connect 设备池里的网络设备(IP:5555),把恢复时间降到秒级。
|
||
|
||
USB 设备无需 connect(adb server 自动重枚举),只连网络设备。
|
||
预连接幂等:已连接的设备返回 already connected,无副作用。
|
||
"""
|
||
def _run():
|
||
try:
|
||
from core import device_pool
|
||
from core.adb_helper import _adb
|
||
serials = [s for s in device_pool.list_configured() if ":" in s]
|
||
if not serials:
|
||
return
|
||
_log.info(f"预连接设备池 {len(serials)} 台网络设备(重启后加速恢复)...")
|
||
import concurrent.futures
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
||
list(ex.map(lambda s: _adb("connect", s), serials))
|
||
out = _adb("devices")
|
||
online = sum(1 for ln in (out or "").splitlines()[1:]
|
||
if len(ln.split()) >= 2 and ln.split()[1] == "device")
|
||
_log.info(f"设备池预连接完成: {online} 台在线")
|
||
except Exception as e:
|
||
_log.warning(f"设备池预连接失败: {e}")
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
|
||
def _run_server(host, port):
|
||
"""启动 Flask,自动处理端口冲突和权限问题。
|
||
|
||
常见失败原因:
|
||
WinError 10013 — 端口在 Windows 动态端口范围内被出站连接占用,
|
||
或非管理员绑定 0.0.0.0。前者换端口,后者降级 127.0.0.1。
|
||
WinError 10048 — 端口已被其他进程监听。换端口重试。
|
||
策略:原端口失败 → 试 127.0.0.1:原端口 → 试 127.0.0.1:原端口+1..+5
|
||
"""
|
||
import socket
|
||
# 先探测原端口是否可用(避免 flask 报错打日志难看)
|
||
candidates = [(host, port)]
|
||
if host == "0.0.0.0":
|
||
candidates.append(("127.0.0.1", port))
|
||
for i in range(1, 6):
|
||
candidates.append(("127.0.0.1", port + i))
|
||
|
||
for h, p in candidates:
|
||
try:
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
s.bind((h, p))
|
||
# 探测成功,启动 flask
|
||
if (h, p) != (host, port):
|
||
_log.warning(f"原端口 {host}:{port} 不可用,改用 {h}:{p}")
|
||
_log.info(f"启动服务: http://localhost:{p}/")
|
||
app.run(host=h, port=p, debug=False, threaded=True)
|
||
return
|
||
except OSError as e:
|
||
_log.warning(f"绑定 {h}:{p} 失败: {e}")
|
||
# 全部失败
|
||
raise OSError(f"无法绑定任何候选端口({host}:{port} 及 127.0.0.1:{port}~{port+5})")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
from config import WEB_HOST, WEB_PORT
|
||
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
|
||
_ensure_uiauto_running()
|
||
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
|
||
_preconnect_pool_devices()
|
||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||
try:
|
||
_run_server(WEB_HOST, WEB_PORT)
|
||
finally:
|
||
mgr.shutdown()
|
||
device_discovery.shutdown()
|
||
_stop_uiauto()
|