From ce47a5b83d2985e7527f59f5c02f21aaaad07618 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Wed, 19 Aug 2026 14:55:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20web=5Fserver=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8C=96=E6=8B=86=E5=88=86=EF=BC=88web/=20=E8=93=9D=E5=9B=BE?= =?UTF-8?q?=E5=8C=85=20auth/monitor/tasks/admin/tools/devices/apks/tailsca?= =?UTF-8?q?le=20+=20context/common=EF=BC=8Cweb=5Fserver=201796=E2=86=92193?= =?UTF-8?q?=20=E8=A1=8C=E4=BB=85=E7=95=99=E8=A3=85=E9=85=8D=E4=B8=8E?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=EF=BC=89+=20=E5=A4=A7=E5=B1=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=EF=BC=88=E7=BD=91=E6=A0=BC=205s=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E3=80=81=E5=BC=B9=E7=AA=97=E7=8B=AC=E5=8D=A0=E7=BC=A9=E7=95=A5?= =?UTF-8?q?=E5=9B=BE=E3=80=81=E6=B5=81=E6=94=AF=E6=8C=81=20q/fps=EF=BC=89+?= =?UTF-8?q?=20=E4=B8=89=E4=BF=AE=E5=A4=8D=EF=BC=88MIUI=20=E5=B1=8F?= =?UTF-8?q?=E5=B9=95=E7=8A=B6=E6=80=81=E6=A3=80=E6=B5=8B=E6=94=B9=20dumpsy?= =?UTF-8?q?s=20power=E3=80=81=E8=A1=A5=20ADB=5FPATH=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E9=94=AE=E4=BA=AE=E5=B1=8F=E3=80=81?= =?UTF-8?q?renderGrid=20=E5=A2=9E=E9=87=8F=E6=B8=B2=E6=9F=93=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E8=BD=BD=E7=BC=A9=E7=95=A5=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +- doc/ARCHITECTURE.md | 60 +- templates/admin/wall.html | 107 ++- web/__init__.py | 28 + web/admin_api.py | 101 +++ web/apks_api.py | 123 +++ web/auth.py | 157 ++++ web/common.py | 46 ++ web/context.py | 12 + web/devices_api.py | 119 +++ web/index.html | 94 --- web/monitor.py | 533 ++++++++++++ web/tailscale_api.py | 98 +++ web/tasks_api.py | 336 ++++++++ web/tools_api.py | 236 ++++++ web_server.py | 1639 +------------------------------------ 16 files changed, 1922 insertions(+), 1780 deletions(-) create mode 100644 web/__init__.py create mode 100644 web/admin_api.py create mode 100644 web/apks_api.py create mode 100644 web/auth.py create mode 100644 web/common.py create mode 100644 web/context.py create mode 100644 web/devices_api.py delete mode 100644 web/index.html create mode 100644 web/monitor.py create mode 100644 web/tailscale_api.py create mode 100644 web/tasks_api.py create mode 100644 web/tools_api.py diff --git a/README.md b/README.md index f8049cb..1737ff5 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,18 @@ python web_server.py ``` platform-tools/ ├── config.py # 根配置(部署配置统一从 .env 读,模板见 .env.example) -├── web_server.py # Flask 入口(API + 登录 + 单页应用) +├── web_server.py # Flask 入口(app 装配 + 蓝图注册 + 启动,193 行) +├── web/ # Web 层蓝图包(按功能域拆分,路由都在这里) +│ ├── auth.py # 登录/CSRF/权限装饰器/页面路由(/、/wall) +│ ├── monitor.py # 状态/运行控制/设备操作/远程看屏 +│ ├── tasks_api.py # 任务计划/分组/自定义动作/元素抓取 +│ ├── admin_api.py # 用户管理/日志 +│ ├── tools_api.py # adb 终端/剪贴板注入/应用版本 +│ ├── devices_api.py # 设备池管理 +│ ├── apks_api.py # 应用管理 +│ ├── tailscale_api.py # Tailscale 管理 +│ ├── common.py # 跨模块共享工具(合并设备列表/屏幕状态) +│ └── context.py # 共享对象注入(mgr/apk_mgr/device_pool) ├── requirements.txt # Python 依赖清单 │ ├── core/ # 核心基础设施层 diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index e061233..f6513ff 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -10,8 +10,8 @@ | 层 | 路径 | 职责 | |----|------|------| -| 配置层 | `config.py` | 项目根配置:STF 地址、adb 路径、web 端口等基础设施。**不放任务参数** | -| 核心层 | `core/` | 框架运行时:日志、STF 客户端、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 | +| 配置层 | `config.py` | 项目根配置:adb 路径、web 端口、USB 远程 adb server 等基础设施。**不放任务参数** | +| 核心层 | `core/` | 框架运行时:日志、设备池、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 | | 任务层 | `tasks/` | 每个 App 一个子包,自包含 `task.py` + `actions/`,互不依赖 | | 前端层 | `templates/admin/` | 单页应用(纯 HTML+CSS+JS,无框架) | | 数据层 | `data/` | SQLite 持久化 | @@ -39,7 +39,7 @@ │ JSON API │ ┌─────────────────┐ ▼ ┌──────────────┐ │ 设备 adb │ ┌──────────────┐ │ 看门狗监控 │ │ (IP:5555 直连 │ -│ web_server │ └──────────────┘ │ / USB 远程) │ +│ web/ 蓝图包 │ └──────────────┘ │ / USB 远程) │ │ (Flask API) │ └─────────────────┘ └──────────────┘ │ @@ -137,7 +137,7 @@ run() 主循环(不要重写): **并发控制**:同一 serial 同时只允许一个 worker,避免冲突。 -**状态缓存**:`get_status()` 带 5 秒缓存,STF 请求慢时不阻塞前端。 +**状态缓存**:`get_status()` 带 5 秒缓存,避免每次 /api/status 都查库/adb 阻塞前端。 ### 3.4 前台 App 扫描器(`_ForegroundScanner`) @@ -145,12 +145,11 @@ run() 主循环(不要重写): | 设备状态 | 处理方式 | 是否打扰 | |---------|---------|---------| -| worker 运行中 | 复用已有 ADB 连接查询 | 否 | -| 自己占用无 worker | STF remoteConnect 隧道查询 | 否 | -| 完全空闲 | 返回"空闲"(不 adb connect,避免 STF 误判离线) | 否 | -| 他人占用 | 标记"(他人占用)" | 否 | +| worker 运行中(IP:5555) | 复用已有 ADB 连接查询 | 否 | +| worker 运行中(USB) | 经 220 远程 adb server 查询 | 否 | +| 完全空闲 | 返回"空闲"(不主动 connect) | 否 | -> **为什么不扫描空闲设备的前台 App**:STF provider 内部通过 IP:5555 维持 adb 连接。外部 adb connect/disconnect 会让 adb server 断开该 transport,连带 STF provider 的连接断开,STF 误判设备 offline 并触发重连。 +> **为什么不扫描空闲设备的前台 App**:IP:5555 的 adb transport 是共享的(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接,遵守既有红线。 ### 3.5 ADB 操作(`core/adb_helper.py`) @@ -158,8 +157,8 @@ run() 主循环(不要重写): **铁律:绝不 kill-server**: - `adb kill-server` 会断开所有设备的 adb transport -- 导致 STF provider 对全部设备误判离线并触发重连 -- 影响所有运行中的任务 +- 全部设备连接被重建,影响所有运行中的任务 +- 同理绝不 disconnect IP:5555(共享 transport 红线,见 DEVELOPMENT.md) | 函数 | 说明 | |------|------| @@ -191,9 +190,9 @@ SQLAlchemy 模型,存于 `data/users.db`: ### 权限模型 - `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路) -- 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web_server.py),无权限返回 403; - 查看类 GET 接口只要求登录;用户管理、维护接口(STF 重启 `/api/stf/restart`、adb 终端 `/api/adb/cmd`)强制 `admin_required` -- adb 终端安全红线:拒绝 `kill-server` / `disconnect`(STF provider 共享 adb transport,断开会误判全设备离线) +- 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web/auth.py),无权限返回 403; + 查看类 GET 接口只要求登录;用户管理、adb 终端(`/api/adb/cmd`)强制 `admin_required` +- adb 终端安全红线:拒绝 `kill-server` / `disconnect`(共享 adb transport) - 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断 - 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定 @@ -201,10 +200,10 @@ SQLAlchemy 模型,存于 `data/users.db`: APK 上传/解析/批量安装。 -**设备连接策略(直连,绕过 STF)**: +**设备连接策略(直连)**: - 直接 `adb connect serial`(serial 是 IP:5555) -- 不经过 STF occupy/release,避免 STF release 触发 agent 清理卸载 app -- 安装后不主动 disconnect(STF provider 共享该 adb transport) +- 不经过占用/释放(单实例互斥由调度器内存锁保证) +- 安装后不主动 disconnect(共享 adb transport 红线) **安装流程**: 1. 上传 APK → 保存到 `data/apks/` → pyaxmlparser 解析包名/版本 → 入库 @@ -232,23 +231,24 @@ APK 上传/解析/批量安装。 - 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次) - 返回坐标约定:像素、原点左上 -### 3.10 STF 设备管理(`core/stf_device_mgmt.py`) +### 3.10 Web 层蓝图包(`web/`) -工具页"STF 设备管理":维护 220 上 OpenSTF 设备池。 +路由按功能域拆分(模块化开发底线,便于定位问题): -背景:220 的 adb 跑在 Docker 容器(`STF_ADB_CONTAINER`,默认 `adb`)里,设备池由 -`/mnt/data/openstf/connect_devices.sh`(`STF_SCRIPT_PATH`)的 `DEVICES` 数组维护,cron 每 5 分钟补连。 - -| 函数 | 说明 | +| 模块 | 职责 | |------|------| -| `status()` | 脚本配置 IP + 220 adb 实际连接状态 | -| `add_device(ip)` | 用 220 的 python3 精确改脚本 DEVICES 块 + `docker exec adb adb connect` | -| `remove_device(ip)` | 脚本删条目 + `adb disconnect` | +| `auth.py` | 登录/登出/CSRF/权限装饰器/页面路由(/、/wall) | +| `monitor.py` | 状态/运行控制/设备操作/远程看屏(流+缩略图+触控) | +| `tasks_api.py` | 任务计划/分组/自定义动作/元素抓取/步骤测试 | +| `admin_api.py` | 用户管理/日志 | +| `tools_api.py` | adb 终端/剪贴板注入/应用版本 | +| `devices_api.py` | 设备池管理(增删停用/重连/型号采集) | +| `apks_api.py` | 应用管理 | +| `tailscale_api.py` | Tailscale 管理 | +| `common.py` | 跨模块共享(合并设备列表/屏幕状态) | +| `context.py` | 共享对象注入(mgr/apk_mgr/device_pool) | -关键点: -- 脚本编辑用 python3 行级增删(sed 处理多行数组易误伤) -- `adb connect` 到不可达 IP 会挂 40s+,220 侧用 `timeout 15` 兜底,超时保留在脚本由 cron 重试 -- 断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立 +`web_server.py` 只保留:app 创建、数据库初始化、蓝图注册、uiautodev 生命周期、启动(193 行)。 --- diff --git a/templates/admin/wall.html b/templates/admin/wall.html index f9d37fa..34ec3b7 100644 --- a/templates/admin/wall.html +++ b/templates/admin/wall.html @@ -239,50 +239,89 @@ function fmtProgress(p){ } function shortIp(serial){return String(serial||'').replace(/:5555$/,'');} +function cardHtml(d, i){ + const cls=cardCls(d); + const serial=esc(d.serial); + const name=esc(d.device_name||d.model||'设备'); + const ip=esc(shortIp(d.serial)); + const st=badgeText(d); + const stTag=_screenState[d.serial]==='off'?'
🌙
':''; + const action=d.current_action + ?'
'+(d.task_job?''+esc(d.task_job)+' · ':'')+esc(d.current_action)+'
' + :(d.task_job?'
'+esc(d.task_job)+'
':'
'); + return '
'+ + '
'+ + ''+ + '
📱'+serial+'
'+ + '
'+st+'
'+stTag+ + '
'+ + '
'+ + '
'+name+'
'+ + '
'+ip+'
'+ + '
'+esc(d.model||'')+'
'+ + action+fmtProgress(d.progress)+ + '
点击进入设备操作
'+ + '
'+ + '
'; +} + +// 增量渲染:已有卡片只更新信息区(不重建 img,缩略图不闪不重载);增删设备才动 DOM function renderGrid(devs){ const grid=document.getElementById('wall-grid'); if(!devs.length){ grid.innerHTML='
设备池为空,请到「工具 → 设备池管理」添加设备
'; return; } - grid.innerHTML=devs.map((d,i)=>{ - const cls=cardCls(d); - const serial=esc(d.serial); - const name=esc(d.device_name||d.model||'设备'); - const ip=esc(shortIp(d.serial)); - const st=badgeText(d); - const stTag=_screenState[d.serial]==='off'?'
🌙
':''; - const action=d.current_action - ?'
'+(d.task_job?''+esc(d.task_job)+' · ':'')+esc(d.current_action)+'
' - :(d.task_job?'
'+esc(d.task_job)+'
':'
'); - return '
'+ - '
'+ - ''+ - '
📱'+serial+'
'+ - '
'+st+'
'+stTag+ - '
'+ - '
'+ - '
'+name+'
'+ - '
'+ip+'
'+ - '
'+esc(d.model||'')+'
'+ - action+fmtProgress(d.progress)+ - '
点击进入设备操作
'+ - '
'+ - '
'; - }).join(''); - // 离线卡片显示占位 - grid.querySelectorAll('.card').forEach(c=>{ - const d=devs.find(x=>x.serial===c.dataset.serial); - const img=c.querySelector('img[data-thumb]'); - const ph=c.querySelector('.ph'); - if(d&&d.present&&img){img.style.display='block';ph.style.display='none';} - else if(ph){ph.style.display='flex';} + const existing={}; + grid.querySelectorAll('.card').forEach(c=>{existing[c.dataset.serial]=c;}); + // 1. 移除已不存在的卡片 + Object.keys(existing).forEach(s=>{ + if(!devs.find(d=>d.serial===s))existing[s].remove(); + }); + // 2. 更新/新建 + devs.forEach((d,i)=>{ + const serial=d.serial; + const card=existing[serial]; + if(card){ + // 已有卡片:只更新状态类信息(badge/class/信息区),不动 img + card.className='card '+cardCls(d); + const badge=card.querySelector('.badge'); + const bt=badgeText(d); + if(badge&&badge.textContent!==bt)badge.innerHTML=''+bt; + // 熄屏角标 + const tag=card.querySelector('.screen-tag'); + if(_screenState[serial]==='off'&&!tag)card.querySelector('.thumb').insertAdjacentHTML('beforeend','
🌙
'); + else if(_screenState[serial]!=='off'&&tag)tag.remove(); + // 信息区 + card.querySelector('.name').textContent=d.device_name||d.model||'设备'; + card.querySelector('.ip').textContent=shortIp(serial); + card.querySelector('.model').textContent=d.model||''; + const action=d.current_action + ?(d.task_job?''+esc(d.task_job)+' · ':'')+esc(d.current_action) + :(d.task_job?''+esc(d.task_job)+'':''); + card.querySelector('.action').innerHTML=action; + // 进度 + const progWrap=card.querySelector('.prog-wrap'); + if(progWrap)progWrap.innerHTML=fmtProgress(d.progress); + else if(d.progress&&d.progress.total)card.querySelector('.info').insertAdjacentHTML('beforeend','
'+fmtProgress(d.progress)+'
'); + // 离线占位切换 + const img=card.querySelector('img[data-thumb]'); + const ph=card.querySelector('.ph'); + if(d.present){if(img)img.style.display='block';if(ph)ph.style.display='none';} + else{if(img)img.style.display='none';if(ph)ph.style.display='flex';} + }else{ + grid.insertAdjacentHTML('beforeend',cardHtml(d,i)); + const nc=grid.lastElementChild; + if(!d.present)nc.querySelector('.ph').style.display='flex'; + } }); refreshThumbs(); } // 缩略图轮询(2.5s):fetch 读取 X-Screen-State 头 → blob 喂 img async function refreshThumbs(){ + // 进入设备操作弹窗后:停止全部缩略图获取,资源全部给当前设备的高清流 + if(_ctrlSerial)return; if(document.visibilityState!=='visible')return; const now=Date.now(); const imgs=[...document.querySelectorAll('img[data-thumb]')]; @@ -343,7 +382,7 @@ function closeCtrl(){ function startCtrlStream(){ if(!_ctrlSerial)return; const img=document.getElementById('ctrl-img'); - img.src='/api/screen/stream?serial='+encodeURIComponent(_ctrlSerial)+'&t='+Date.now(); + img.src='/api/screen/stream?serial='+encodeURIComponent(_ctrlSerial)+'&q=85&fps=10&t='+Date.now(); // 弹窗高清高帧 } function updateCtrlHead(){ const d=_devices.find(x=>x.serial===_ctrlSerial); @@ -414,7 +453,7 @@ function ctrlSendText(){ })(); // 定时器 -setInterval(()=>{if(document.visibilityState==='visible')refreshThumbs();},2500); +setInterval(()=>{if(document.visibilityState==='visible')refreshThumbs();},5000); // 网格低帧率(省资源) setInterval(()=>{if(document.visibilityState==='visible')refreshStatus();},5000); // 初始化 diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..dfd8ee3 --- /dev/null +++ b/web/__init__.py @@ -0,0 +1,28 @@ +"""Web 层蓝图包:web_server.py 只保留 app 初始化与装配,路由按功能域拆分。 + +模块划分(按功能定位问题): + auth — 登录/登出/CSRF/权限装饰器/页面路由(/、/wall) + monitor — 状态/运行控制/设备操作/远程看屏 + tasks — 任务计划/分组/自定义动作/元素抓取/步骤测试 + admin — 用户管理/日志 + tools — adb 终端/剪贴板注入/应用版本 + devices — 设备池管理 + apks — 应用管理 + tailscale — Tailscale 管理 +""" +from flask import Blueprint + + +def register_blueprints(app): + """注册全部蓝图(url_prefix 为空,路由路径保持原样)。""" + from .auth import bp as auth_bp + from .monitor import bp as monitor_bp + from .tasks_api import bp as tasks_bp + from .admin_api import bp as admin_bp + from .tools_api import bp as tools_bp + from .devices_api import bp as devices_bp + from .apks_api import bp as apks_bp + from .tailscale_api import bp as tailscale_bp + for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp, + devices_bp, apks_bp, tailscale_bp): + app.register_blueprint(bp) diff --git a/web/admin_api.py b/web/admin_api.py new file mode 100644 index 0000000..be9151c --- /dev/null +++ b/web/admin_api.py @@ -0,0 +1,101 @@ +"""管理域 API:用户管理/日志查看。""" +import os +import time + +from flask import Blueprint, jsonify, request +from flask_login import current_user +from flask import session + +from core.logger import _LOG_DIR, _MODULE_FILES +from core.models import db, User +from web import context +from web.auth import (admin_required, perm_required, _validate_perms, + PERM_LOGS) + +bp = Blueprint("admin", __name__) + +@bp.route("/api/users") +@admin_required +def api_users_list(): + users = [{"id": u.id, "username": u.username, + "is_admin": u.is_admin, "perms": u.get_perms()} for u in User.query.all()] + return jsonify({"ok": True, "users": users}) + +@bp.route("/api/users", methods=["POST"]) +@admin_required +def api_users_create(): + data = request.json or {} + username = (data.get("username") or "").strip() + password = data.get("password", "") + if not username or not password: + return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400 + if User.query.filter_by(username=username).first(): + return jsonify({"ok": False, "error": "用户名已存在"}), 400 + # 默认普通用户(最小权限);管理员由 is_admin 决定,权限位照常保存(降级后生效) + u = User(username=username, is_admin=bool(data.get("is_admin", False))) + u.set_perms(_validate_perms(data.get("perms") or [])) + u.set_password(password) + db.session.add(u) + db.session.commit() + _log.info(f"创建用户 {username} (admin={u.is_admin}, perms={u.get_perms()})") + return jsonify({"ok": True, "msg": "用户已创建"}) + +@bp.route("/api/users/", methods=["PUT"]) +@admin_required +def api_users_update(uid): + u = User.query.get(uid) + if not u: + return jsonify({"ok": False, "error": "用户不存在"}), 404 + data = request.json or {} + if "password" in data and data["password"]: + u.set_password(data["password"]) + if "is_admin" in data: + new_admin = bool(data["is_admin"]) + if u.is_admin and not new_admin and User.query.filter_by(is_admin=True).count() <= 1: + return jsonify({"ok": False, "error": "不能取消最后一个管理员"}), 400 + u.is_admin = new_admin + if "perms" in data: + u.set_perms(_validate_perms(data["perms"])) + db.session.commit() + _log.info(f"更新用户 {u.username} (admin={u.is_admin}, perms={u.get_perms()})") + return jsonify({"ok": True, "msg": "用户已更新"}) + +@bp.route("/api/users/", methods=["DELETE"]) +@admin_required +def api_users_delete(uid): + u = User.query.get(uid) + if not u: + return jsonify({"ok": False, "error": "用户不存在"}), 404 + if u.username == "admin": + return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400 + if u.id == current_user.id: + return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400 + if u.is_admin and User.query.filter_by(is_admin=True).count() <= 1: + return jsonify({"ok": False, "error": "不能删除最后一个管理员"}), 400 + db.session.delete(u) + db.session.commit() + _log.info(f"删除用户 {u.username}") + return jsonify({"ok": True, "msg": "用户已删除"}) + + +# ================== API:日志查看 ================== + +@bp.route("/api/logs") +@perm_required(PERM_LOGS) +def api_logs(): + files = list(_MODULE_FILES.values()) + current = request.args.get("file", "core.log") + lines = int(request.args.get("lines", 300)) + content = "" + path = os.path.join(_LOG_DIR, current) + if os.path.exists(path): + try: + with open(path, encoding="utf-8") as f: + content = "".join(f.readlines()[-lines:]) + except Exception as e: + content = f"读取失败: {e}" + return jsonify({"ok": True, "content": content, "file": current, "files": files}) + + +# ================== API:运行控制 ================== + diff --git a/web/apks_api.py b/web/apks_api.py new file mode 100644 index 0000000..a754445 --- /dev/null +++ b/web/apks_api.py @@ -0,0 +1,123 @@ +"""应用管理 API(APK 上传/安装/删除/设备已装应用)。""" +import re +import shlex +import subprocess + +from flask import Blueprint, jsonify, request +from flask_login import login_required + +from core import device_pool +from core.adb_helper import adb_connect_light, _adb +from web import context +from web.auth import perm_required, PERM_APKS +from web.common import _merged_device_list + +bp = Blueprint("apks", __name__) + +@bp.route("/api/apks") +@login_required +def api_apks_list(): + """列出所有已上传的 APK。""" + return jsonify({"ok": True, "apks": context.apk_mgr.list_all()}) + +@bp.route("/api/apks/upload", methods=["POST"]) +@perm_required(PERM_APKS) +def api_apks_upload(): + """上传 APK 文件(multipart/form-data,字段名 file)。""" + file = request.files.get("file") + if not file or not file.filename: + return jsonify({"ok": False, "error": "未选择文件"}), 400 + info = context.apk_mgr.upload(file) + if info: + return jsonify({"ok": True, "apk": info, + "msg": f"上传成功: {info['display_name']}"}) + return jsonify({"ok": False, "error": "上传失败,请检查文件格式"}), 500 + +@bp.route("/api/apks/", methods=["DELETE"]) +@perm_required(PERM_APKS) +def api_apks_delete(apk_id): + """删除 APK 文件和记录。""" + ok, msg = context.apk_mgr.delete(apk_id) + if ok: + return jsonify({"ok": True, "msg": msg}) + return jsonify({"ok": False, "error": msg}), 400 + +@bp.route("/api/apks/install/devices") +@perm_required(PERM_APKS) +def api_apks_install_devices(): + """可安装设备列表(应用管理):设备池在线设备 + 本机 adb 设备(含 USB 有线连接)。 + + source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。 + """ + devices, seen = [], set() + try: + online = set(device_pool.list_online()) + for serial in device_pool.list_configured(): + if serial in online: + devices.append({"serial": serial, "model": "", "source": "pool"}) + seen.add(serial) + except Exception: + pass + out = _adb("devices") + for line in (out or "").splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] and parts[0] not in seen: + devices.append({"serial": parts[0], "model": "", + "source": "usb" if ":" not in parts[0] else "adb"}) + seen.add(parts[0]) + return jsonify({"ok": True, "devices": devices}) + +@bp.route("/api/apks/install", methods=["POST"]) +@perm_required(PERM_APKS) +def api_apks_install(): + """批量安装 APK 到指定设备。参数: {apk_id, serials:[]}""" + data = request.json or {} + apk_id = data.get("apk_id", "") + serials = data.get("serials", []) + ok, msg = context.apk_mgr.install(apk_id, serials) + if ok: + return jsonify({"ok": True, "msg": msg}) + return jsonify({"ok": False, "error": msg}), 400 + +@bp.route("/api/apks/install/status") +@login_required +def api_apks_install_status(): + """获取安装任务实时状态。""" + status = context.apk_mgr.get_install_status() + return jsonify({"ok": True, "status": status}) + + +# ================== API:维护(仅管理员) ================== +# 维护功能:STF 容器一键重启 + adb 远程终端。两个接口都仅管理员可用。 +# adb 终端安全红线(技术约束):绝不 kill-server / 绝不 disconnect IP:5555—— +# STF provider 共享该 adb transport,断开会让 STF 误判全部设备离线并触发重连。 +_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect") + + +def _blocked_adb_cmd(cmd): + """命中红线的 adb 命令(kill-server / disconnect)直接拒绝。""" + low = cmd.lower() + return any(p in low for p in _ADB_BLOCKED_PATTERNS) + + +def _merged_device_list(): + """本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 + + 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 + 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接; + stf_not_ready=STF agent 未就绪(迁移期保留,阶段 3 摘除 STF 后删除)。 + """ + states = {} + out = _adb("devices") + for line in (out or "").splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): + states[parts[0]] = parts[1] + try: + for serial in device_pool.list_configured(): + if serial not in states: + states[serial] = "pool" + except Exception: + pass + return [{"serial": s, "state": st} for s, st in sorted(states.items())] + diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..62e3663 --- /dev/null +++ b/web/auth.py @@ -0,0 +1,157 @@ +"""认证与页面路由:登录/登出/CSRF/权限装饰器/页面(/、/wall)。 + +CSRF 的 before_request 由 web_server 注册(app 级),本模块提供实现。 +""" +import functools +import secrets + +from flask import (Blueprint, jsonify, request, redirect, url_for, + render_template, make_response, session) +from flask_login import (login_user, logout_user, login_required, current_user) + +from core.logger import get_logger +from core.models import User + +_log = get_logger("web") + +bp = Blueprint("auth", __name__) + + +# ================== CSRF ================== +def _csrf_token(): + """获取或生成当前会话的 CSRF token(非 GET 请求需在 X-CSRF-Token 头携带)。""" + if "csrf_token" not in session: + session["csrf_token"] = secrets.token_hex(16) + return session["csrf_token"] + + +def _csrf_protect(): + """非安全方法(POST/PUT/DELETE/PATCH)校验 X-CSRF-Token 请求头。""" + if request.method not in ("POST", "PUT", "DELETE", "PATCH"): + return + # 登录表单(未登录,尚无 token)和静态文件跳过 + if request.endpoint in ("login", "static"): + return + sess = session.get("csrf_token", "") + header = request.headers.get("X-CSRF-Token", "") + if not sess or header != sess: + return jsonify({"ok": False, "error": "CSRF 校验失败"}), 403 + + +@bp.route("/api/csrf") +@login_required +def api_csrf(): + """获取 CSRF token(前端非 GET 请求需携带 X-CSRF-Token 头)。""" + return jsonify({"ok": True, "token": _csrf_token()}) + + +# ================== 权限控制 ================== +PERM_TASKS = "tasks" +PERM_DEVICES = "devices" +PERM_APKS = "apks" +PERM_LOGS = "logs" +ALL_PERMS = (PERM_TASKS, PERM_DEVICES, PERM_APKS, PERM_LOGS) +_PERM_LABELS = {PERM_TASKS: "任务管理", PERM_DEVICES: "设备控制", + PERM_APKS: "应用管理", PERM_LOGS: "日志查看"} + + +def _has_perm(perm): + """当前用户是否拥有指定权限。管理员恒为 True。""" + u = current_user + return bool(u and (u.is_admin or u.has_perm(perm))) + + +def _validate_perms(raw): + """校验权限位列表:只保留合法值、去重。返回合法列表。""" + valid = set(ALL_PERMS) + out = [] + for p in raw or []: + if p in valid and p not in out: + out.append(p) + return out + + +def perm_required(perm): + """路由装饰器:要求登录且拥有指定业务权限,否则 403。""" + def deco(fn): + @functools.wraps(fn) + @login_required + def wrapper(*args, **kwargs): + if not _has_perm(perm): + return jsonify({"ok": False, + "error": f"无权限执行此操作(需要权限: {_PERM_LABELS.get(perm, perm)})"}), 403 + return fn(*args, **kwargs) + return wrapper + return deco + + +def admin_required(fn): + """路由装饰器:仅管理员可用(用户管理类接口),否则 403。""" + @functools.wraps(fn) + @login_required + def wrapper(*args, **kwargs): + if not current_user.is_admin: + return jsonify({"ok": False, "error": "仅管理员可执行此操作"}), 403 + return fn(*args, **kwargs) + return wrapper + + +@bp.route("/api/me") +@login_required +def api_me(): + """当前登录用户信息(含权限位),前端据此隐藏无权限的功能入口。""" + u = current_user + return jsonify({"ok": True, "user": { + "id": u.id, + "username": u.username, + "is_admin": bool(u.is_admin), + # 管理员返回全部权限位,前端统一用 perms 判断 + "perms": list(ALL_PERMS) if u.is_admin else u.get_perms(), + }}) + + +# ================== 页面路由 ================== +@bp.route("/") +@login_required +def index(): + """单页应用首页。""" + resp = make_response(render_template("admin/monitor.html")) + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +@bp.route("/wall") +@login_required +def wall(): + """监控大屏(独立全屏页面,供挂墙/电视展示)。 + + 深色控制室风格;设备缩略图按需轮询(每设备 ~5s 一帧), + 20 台设备整体开销约 0.1 核 CPU + 50KB/s,普通电脑无压力。 + """ + resp = make_response(render_template("admin/wall.html")) + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + return resp + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + login_user(user) + _csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取 + _log.info(f"用户 {username} 登录") + return redirect(request.args.get("next") or url_for("auth.index")) + return render_template("admin/login.html", error="用户名或密码错误") + return render_template("admin/login.html", error=None) + + +@bp.route("/logout") +@login_required +def logout(): + _log.info(f"用户 {current_user.username} 登出") + logout_user() + return redirect(url_for("auth.login")) diff --git a/web/common.py b/web/common.py new file mode 100644 index 0000000..241f8b2 --- /dev/null +++ b/web/common.py @@ -0,0 +1,46 @@ +"""跨模块共享工具(web 层)。""" +from core import device_pool +from core.adb_helper import _adb +from core.logger import get_logger + +_log = get_logger("web") + + +def _merged_device_list(): + """本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 + + 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 + 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 + """ + states = {} + out = _adb("devices") + for line in (out or "").splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): + states[parts[0]] = parts[1] + try: + for serial in device_pool.list_configured(): + if serial not in states: + states[serial] = "pool" + except Exception: + pass + return [{"serial": s, "state": st} for s, st in sorted(states.items())] + + +def _device_screen_state(serial): + """设备屏幕状态:on/off/unknown(dumpsys power 只读查询)。 + + 用 mWakefulness 判断(Asleep=熄屏):MIUI 的 dumpsys display 没有 + mScreenState 字段,power 字段全机型存在。USB 设备查询失败返回 unknown。 + """ + try: + if ":" not in serial: + return "unknown" + out = _adb("-s", serial, "shell", "dumpsys", "power") or "" + import re + m = re.search(r"mWakefulness=(\w+)", out) + if m: + return "off" if m.group(1).lower() == "asleep" else "on" + except Exception: + pass + return "unknown" diff --git a/web/context.py b/web/context.py new file mode 100644 index 0000000..3b901e2 --- /dev/null +++ b/web/context.py @@ -0,0 +1,12 @@ +"""共享上下文:web_server 启动时注入全局对象,各蓝图模块从这里取。 + +避免蓝图模块与 web_server 循环 import。 +""" +mgr = None # core.task_manager.TaskManager +apk_mgr = None # core.apk_manager.ApkManager +device_pool = None # core.device_pool + + +def init(mgr_, apk_mgr_, device_pool_): + global mgr, apk_mgr, device_pool + mgr, apk_mgr, device_pool = mgr_, apk_mgr_, device_pool_ diff --git a/web/devices_api.py b/web/devices_api.py new file mode 100644 index 0000000..3f24a30 --- /dev/null +++ b/web/devices_api.py @@ -0,0 +1,119 @@ +"""设备池管理 API(本地 SQLite 清单)。""" +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +from flask import Blueprint, jsonify, request + +from core import device_pool +from core.adb_helper import adb_connect +from core.logger import get_logger +from web.auth import perm_required, PERM_DEVICES + +_log = get_logger("web") +bp = Blueprint("devices", __name__) + +@bp.route("/api/devices/pool", methods=["GET"]) +@perm_required(PERM_DEVICES) +def api_devices_pool_list(): + """设备池清单(SQLite,含实时在线状态)。""" + try: + online = set(device_pool.list_online()) + except Exception: + online = set() + rows = device_pool.list_devices() + for r in rows: + r["online"] = r["serial"] in online + return jsonify({"ok": True, "devices": rows}) + +@bp.route("/api/devices/pool/add", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_pool_add(): + """添加/更新设备:{serial, name?, note?}。 + + IP:5555 设备添加后立即尝试 adb connect(不可达显示离线,不影响其他设备)。 + """ + data = request.json or {} + serial = (data.get("serial") or "").strip() + if not serial: + return jsonify({"ok": False, "error": "请输入设备 serial(如 100.100.10.20:5555)"}), 400 + is_new = device_pool.add_device(serial, + name=(data.get("name") or "").strip(), + note=(data.get("note") or "").strip()) + msg = "已添加" if is_new else "已更新" + if ":" in serial: + try: + adb_connect(serial) + except Exception: + pass + # 后台采集型号(不阻塞添加响应) + import threading + threading.Thread(target=device_pool.refresh_model, args=(serial,), + daemon=True).start() + _log.info(f"设备池管理: {msg} {serial}") + return jsonify({"ok": True, "msg": msg, "is_new": is_new}) + +@bp.route("/api/devices/pool/remove", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_pool_remove(): + """从设备池删除(不再参与调度;不影响其他系统)。""" + serial = (request.json or {}).get("serial", "").strip() + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + ok = device_pool.remove_device(serial) + if not ok: + return jsonify({"ok": False, "error": "设备不存在"}), 404 + return jsonify({"ok": True, "msg": "已删除"}) + +@bp.route("/api/devices/pool/toggle", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_pool_toggle(): + """启用/停用设备(停用后不参与调度)。""" + data = request.json or {} + serial = (data.get("serial") or "").strip() + enabled = data.get("enabled") + if not serial or enabled is None: + return jsonify({"ok": False, "error": "缺少参数"}), 400 + ok = device_pool.set_enabled(serial, bool(enabled)) + if not ok: + return jsonify({"ok": False, "error": "设备不存在"}), 404 + return jsonify({"ok": True, "msg": "已" + ("启用" if enabled else "停用")}) + +@bp.route("/api/devices/pool/reconnect", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_pool_reconnect(): + """一键重连:并发 adb connect 池内全部 IP:5555 设备(后台执行,不阻塞)。""" + def _run(): + try: + serials = [s for s in device_pool.list_configured() if ":" in s] + except Exception: + return + if not serials: + return + from concurrent.futures import ThreadPoolExecutor, as_completed + with ThreadPoolExecutor(max_workers=10) as pool: + futures = {pool.submit(adb_connect, s): s for s in serials} + for _ in as_completed(futures): + pass + _log.info(f"设备池一键重连完成({len(serials)} 台)") + # 重连后顺手刷新型号 + try: + device_pool.refresh_all_models() + except Exception: + pass + import threading + threading.Thread(target=_run, daemon=True).start() + return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"}) + +@bp.route("/api/devices/pool/refresh_models", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_pool_refresh_models(): + """批量采集池内在线设备的型号(后台执行,不阻塞)。""" + import threading + threading.Thread(target=device_pool.refresh_all_models, daemon=True).start() + return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"}) + + +# ================== API:Tailscale 管理(仅管理员) ================== +# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。 +# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。 + diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 22d9b16..0000000 --- a/web/index.html +++ /dev/null @@ -1,94 +0,0 @@ - -设备监控大屏 - -
-

设备监控大屏

- - - 管理后台 → -
-
-
-
- - - - - 每 5 秒自动刷新 · 分组/任务管理请到 管理后台 -
- - - -
设备型号STF任务状态抖音进度任务名称当前动作操作计数最近错误操作
-
- - diff --git a/web/monitor.py b/web/monitor.py new file mode 100644 index 0000000..df0aaee --- /dev/null +++ b/web/monitor.py @@ -0,0 +1,533 @@ +"""监控域 API:状态/运行控制/设备操作/远程看屏。""" +import time +import threading +import subprocess +import shlex +from concurrent.futures import ThreadPoolExecutor, as_completed + +from flask import (Blueprint, jsonify, request, Response, render_template) +from flask_login import login_required + +from core import device_pool +from core.adb_helper import (screenshot, adb_connect, adb_connect_light, + _ADB_LOCK, _adb) +from web import context +from web.auth import perm_required, PERM_DEVICES +from web.common import _merged_device_list, _device_screen_state +from config import ADB_PATH +from core.logger import get_logger + +_log = get_logger("web") + +bp = Blueprint("monitor", __name__) + +# 远程看屏参数 +_SCREEN_JPEG_QUALITY = 65 +_SCREEN_FRAME_GAP = 0.05 + +@bp.route("/api/health") +def api_health(): + """轻量健康检查:返回进程/设备/任务摘要,不暴露敏感信息,供运维探活。""" + try: + devices, err = context.mgr.get_status() + devs = devices or [] + return jsonify({ + "ok": True, + "status": "up", + "time": time.time(), + "device_total": len(devs), + "device_online": sum(1 for d in devs if d.get("present")), + "device_running": sum(1 for d in devs + if d.get("worker_status") in ("running", "connecting")), + "device_error": sum(1 for d in devs + if d.get("worker_status") in ("error", "failed")), + "jobs": len(context.mgr.jobs), + }) + except Exception as e: + return jsonify({"ok": False, "status": "down", "error": str(e)}), 500 + + +# ================== API:失败任务汇总 ================== + +@bp.route("/api/summary") +@login_required +def api_summary(): + """失败/异常任务汇总:各状态计数 + 异常设备列表(last_error / 重试次数)。 + + 供监控页"异常汇总"面板使用,便于长期运行观察设备健康度。 + """ + try: + sts = get_all_worker_status() + # 已删除/不在当前 STF 设备池的设备,其陈旧失败记录不应展示。 + # 复用 get_status 缓存(顺带触发上面的状态清理),STF 异常时不隐藏任何记录。 + try: + devices, derr = context.mgr.get_status() + if not derr: + present = {d.get("serial") for d in devices} + sts = [s for s in sts if s.get("serial") in present] + except Exception: + pass + counts = {"total": 0, "running": 0, "done": 0, + "error": 0, "failed": 0, "idle": 0} + errors = [] + for s in sts: + st = s.get("status", "idle") + counts["total"] += 1 + if st in counts: + counts[st] += 1 + if st in ("error", "failed") and s.get("last_error"): + errors.append({ + "serial": s.get("serial"), + "model": s.get("model", ""), + "status": st, + "last_error": s.get("last_error", ""), + "task": s.get("task_job", ""), + "attempt": s.get("attempt", 0), + "updated": s.get("last_heartbeat", 0), + }) + errors.sort(key=lambda x: x.get("updated", 0), reverse=True) + return jsonify({"ok": True, "counts": counts, "errors": errors[:50]}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +# ================== API:状态(监控大屏用)================== + +@bp.route("/api/status") +@login_required +def api_status(): + devices, err = context.mgr.get_status() + if err: + return jsonify({"ok": False, "error": err}), 500 + return jsonify({ + "ok": True, "devices": devices, "server_time": time.time(), + "fg_scanning": context.mgr._fg_scanner.is_scanning, + "fg_last_scan": context.mgr._fg_scanner.last_scan_time, + }) + +@bp.route("/api/scan_foreground", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_scan_foreground(): + """手动触发前台 App 扫描(不打扰设备)。""" + started = context.mgr._fg_scanner.scan_once() + if started: + return jsonify({"ok": True, "msg": "扫描已启动"}) + return jsonify({"ok": False, "error": "已有扫描在进行中"}) + +@bp.route("/api/stop_device", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_stop_device(): + serial = (request.json or {}).get("serial", "") + if context.mgr.stop_device(serial): + return jsonify({"ok": True, "msg": f"已发送停止信号给 {serial}"}) + return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400 + +@bp.route("/locate") +def locate_page(): + """设备端定位页(免登录):点亮后浏览器打开,大字显示设备 IP/序列号。 + + 只展示 serial 文本,无任何平台数据,免登录可接受。 + """ + serial = request.args.get("serial", "") + return render_template_string( + """ + +设备定位 +
{{ serial }}
+
{{ ip }}
+
定位完成 · 按返回键退出
+""", + serial=_esc(serial), ip=_esc(serial.split(":")[0])) + + +# 常见浏览器包名(结束定位时 force-stop 用) +_BROWSER_PKGS = {"com.android.browser", "com.miui.browser", "com.android.chrome", + "com.brave.browser", "com.opera.browser", "org.mozilla.firefox", + "com.UCMobile", "com.tencent.mtt", "com.baidu.browser.apps"} +# 定位时启动的浏览器:serial -> 包名(结束定位时无论前后台都关闭它) +_locate_browsers = {} +_locate_lock = threading.Lock() + + +def _current_focus_pkg(serial): + """取设备当前前台包名(dumpsys window 解析,失败返回空串)。""" + try: + r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"], + capture_output=True, timeout=20) + out = (r.stdout or b"").decode("utf-8", errors="replace") + m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out) + return m.group(1) if m else "" + except Exception: + return "" + +@bp.route("/api/device/locate/stop", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_device_locate_stop(): + """结束定位:关闭定位启动的浏览器(无论当前是否前台)。 + + 优先 force-stop 定位时记录下的浏览器包名(切到后台也能关掉); + 无记录时退回:前台是浏览器 → force-stop;否则按返回键(不误杀任务应用)。 + """ + serial = str((request.json or {}).get("serial", "")).strip() + if not serial: + return jsonify({"ok": False, "error": "缺少设备 serial"}), 400 + try: + if ":" in serial: + adb_connect(serial) + # 1. 优先关闭定位时启动的浏览器(无论前后台) + with _locate_lock: + started = _locate_browsers.pop(serial, None) + if started: + subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", started], + capture_output=True, timeout=20) + msg = f"已关闭定位浏览器 {started}" + else: + # 2. 退回:前台是浏览器则关闭,否则返回键轻量退出 + pkg = _current_focus_pkg(serial) + if pkg in _BROWSER_PKGS: + subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", pkg], + capture_output=True, timeout=20) + msg = f"已关闭浏览器 {pkg}" + else: + subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "4"], + capture_output=True, timeout=15) # BACK 轻量退出 + msg = "已按返回键退出" + (f"(前台 {pkg})" if pkg else "") + _log.info(f"结束定位 {serial}: {msg}") + return jsonify({"ok": True, "msg": msg}) + except Exception as e: + return jsonify({"ok": False, "error": f"结束定位失败: {e}"}), 502 + +@bp.route("/api/device/screen_all", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_device_screen_all(): + """批量亮屏/息屏(并发)。mode: on=亮屏解锁 / off=息屏。 + + 请求可带 serials 指定设备(前端勾选);不带则作用于全部在线设备。 + 息屏会中断运行中的任务(u2 无法操作),前端已有确认提示。 + """ + data = request.json or {} + mode = data.get("mode", "on") + try: + online = set(device_pool.list_online()) + except Exception as e: + return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502 + serials = data.get("serials") or [] + if serials: + # 指定设备:过滤掉不在线的(离线设备操作无意义,直接跳过) + serials = [s for s in serials if s in online] + else: + serials = list(online) + if not serials: + return jsonify({"ok": False, "error": "无在线设备"}), 400 + + def _do(serial): + try: + if ":" in serial: + adb_connect(serial) + if mode == "off": + subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"], + capture_output=True, timeout=15) # KEYCODE_POWER 息屏 + else: + subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"], + capture_output=True, timeout=15) # WAKEUP + subprocess.run([ADB_PATH, "-s", serial, "shell", "wm", "dismiss-keyguard"], + capture_output=True, timeout=15) + return True + except Exception: + return False + + with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool: + results = list(pool.map(_do, serials)) + ok_n = sum(1 for x in results if x) + _log.info(f"批量{'息屏' if mode == 'off' else '亮屏'}: 成功 {ok_n}/{len(serials)}") + return jsonify({"ok": True, "success": ok_n, "total": len(serials)}) + +@bp.route("/api/device/locate", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_device_locate(): + """定位设备:点亮屏幕并解除锁屏,可选打开大字定位页(查找设备用)。 + + 请求 {"serial": "...", "show": true}:show=true 时额外用浏览器打开 + 平台 /locate 定位页,全屏大字显示设备 IP(更醒目,但会切换前台, + 任务运行中慎用)。点亮组合:WAKEUP → dismiss-keyguard → MENU 兜底。 + 绝不 disconnect,红线。 + """ + data = request.json or {} + serial = str(data.get("serial", "")).strip() + if not serial: + return jsonify({"ok": False, "error": "缺少设备 serial"}), 400 + try: + if ":" in serial: + adb_connect(serial) + for cmd in (["shell", "input", "keyevent", "224"], + ["shell", "wm", "dismiss-keyguard"], + ["shell", "input", "keyevent", "82"]): + try: + subprocess.run([ADB_PATH, "-s", serial, *cmd], + capture_output=True, timeout=15) + except Exception: + pass # 单步失败不阻塞,继续下一步 + msgs = ["屏幕已点亮"] + if data.get("show"): + # 用设备浏览器打开定位页(设备走 tailnet 访问本机 100.100.10.2:18050) + locate_url = f"http://100.100.10.2:18050/locate?serial={urllib.parse.quote(serial)}" + r = subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "start", + "-a", "android.intent.action.VIEW", "-d", locate_url], + capture_output=True, timeout=20) + out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace") + if "error" in out.lower() or "exception" in out.lower(): + msgs.append(f"打开定位页失败: {out.strip()[:80]}") + else: + msgs.append("已打开大字定位页(按返回键退出)") + # 记录启动的浏览器包名:结束定位时无论前后台都关闭它。 + # 优先从 am start 输出解析 pkg=(浏览器可能尚未到前台,前台检测会扑空) + pkg = "" + m2 = re.search(r"pkg=([\w.]+)", out) + if m2 and m2.group(1) in _BROWSER_PKGS: + pkg = m2.group(1) + else: + time.sleep(1.5) # 等浏览器切到前台再查 + pkg = _current_focus_pkg(serial) + if pkg in _BROWSER_PKGS: + with _locate_lock: + _locate_browsers[serial] = pkg + _log.info(f"定位设备 {serial}: {'; '.join(msgs)}") + return jsonify({"ok": True, "msg": f"{serial} {';'.join(msgs)}"}) + except Exception as e: + return jsonify({"ok": False, "error": f"定位失败: {e}"}), 502 + +@bp.route("/api/stop_all", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_stop_all(): + stopped = context.mgr.stop_all() + return jsonify({"ok": True, "stopped": stopped}) + +@bp.route("/api/device/clear_error", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_device_clear_error(): + """清除单台设备的异常状态(error/failed → idle),供设备列表"清除异常"按钮使用。""" + serial = (request.json or {}).get("serial", "") + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + if serial in context.mgr.get_running(): + return jsonify({"ok": False, "error": "设备正在运行或等待重试,无法清除"}), 400 + if not clear_worker_error(serial): + return jsonify({"ok": False, "error": "设备正在运行任务,无法清除"}), 400 + _log.info(f"清除设备 {serial} 的异常状态") + return jsonify({"ok": True, "msg": f"已清除 {serial} 的异常状态"}) + +@bp.route("/api/device/clear_all_errors", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_device_clear_all_errors(): + """一键清除所有异常/失败设备(跳过正在运行/等待重试的)。""" + running = set(context.mgr.get_running()) + cleared = clear_all_worker_errors(exclude=running) + _log.info(f"一键清除异常:共清除 {cleared} 台") + return jsonify({"ok": True, "cleared": cleared, + "msg": f"已清除 {cleared} 台设备的异常状态"}) + +@bp.route("/api/device/screenshot") +@login_required +def api_device_screenshot(): + """获取设备当前画面截图(PNG)。 + + 用 adb exec-out screencap -p,只读操作,不抢占 u2 的 atx-agent 通道, + 任务运行中调用安全。直接返回 image/png,前端用 加载。 + ?serial=xxx 设备 serial + ?t=123 时间戳,避免浏览器缓存(前端自动加) + """ + serial = request.args.get("serial", "") + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + ok, data = screenshot(serial) + if ok: + return Response(data, mimetype="image/png", + headers={"Cache-Control": "no-store"}) + return jsonify({"ok": False, "error": data}), 500 + +@bp.route("/api/devices//apps") +@login_required +def api_device_apps(serial): + """获取指定设备上已安装的应用列表(包名 + versionCode + versionName + 路径)。""" + ok, data = list_installed_apps(serial) + if ok: + return jsonify({"ok": True, "apps": data}) + return jsonify({"ok": False, "error": data}), 500 + + +# ================== 自定义动作(步骤打包) ================== + +@bp.route("/api/screen/stream") +@perm_required(PERM_DEVICES) +def api_screen_stream(): + """远程看屏:MJPEG 实时画面流(multipart/x-mixed-replace)。 + + ?serial=xxx 设备 serial + 浏览器 直接渲染;客户端断开(GeneratorExit)自动停止,不占资源。 + 设备离线/atx-agent 无响应时流自然结束,前端提示重新连接。 + """ + serial = request.args.get("serial", "") + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + + # 参数:q=JPEG 质量(大屏弹窗用 85 更清晰);fps=目标帧率(0=默认节流) + try: + quality = max(30, min(95, int(request.args.get("q", _SCREEN_JPEG_QUALITY)))) + fps = float(request.args.get("fps", 0) or 0) + except ValueError: + quality, fps = _SCREEN_JPEG_QUALITY, 0 + gap = (1.0 / fps) if fps > 0 else _SCREEN_FRAME_GAP + + def generate(): + import io + import uiautomator2 as u2 + try: + d = u2.connect(serial) + except Exception as e: + _log.warning(f"远程看屏 {serial} u2 连接失败: {e}") + return + while True: + try: + img = d.screenshot() + if img is None: + break + if img.mode != "RGB": + img = img.convert("RGB") + buf = io.BytesIO() + img.save(buf, "JPEG", quality=quality) + yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + + buf.getvalue() + b"\r\n") + except GeneratorExit: + break + except Exception as e: + _log.debug(f"远程看屏 {serial} 流中断: {e}") + break + time.sleep(gap) + + return Response(generate(), + mimetype="multipart/x-mixed-replace; boundary=frame") + + +def _screen_get_device(serial): + """远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。""" + import uiautomator2 as u2 + return u2.connect(serial) + +@bp.route("/api/screen/thumb") +@perm_required(PERM_DEVICES) +def api_screen_thumb(): + """大屏缩略图:单张 JPEG(360px 宽,质量 55),供监控大屏轮询。 + + 一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。 + 大屏按需轮询:每设备约 2.5s 一帧,20 台 ≈ 0.2 核 CPU + 100KB/s。 + 响应头 X-Screen-State: on/off/unknown —— 大屏据此显示「亮屏中/熄屏中」。 + """ + serial = request.args.get("serial", "") + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + import io + import uiautomator2 as u2 + try: + d = u2.connect(serial) + img = d.screenshot() + if img is None: + return jsonify({"ok": False, "error": "截图失败"}), 503 + if img.width > 360: + ratio = 360 / img.width + img = img.resize((360, int(img.height * ratio))) + buf = io.BytesIO() + img.convert("RGB").save(buf, "JPEG", quality=55) + return Response(buf.getvalue(), mimetype="image/jpeg", + headers={"Cache-Control": "no-store", + "X-Screen-State": _device_screen_state(serial)}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 503 + + +@bp.route("/api/screen/tap", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_screen_tap(): + """点击:{serial, x, y}(设备原生分辨率坐标)。""" + data = request.json or {} + serial, x, y = data.get("serial", ""), data.get("x"), data.get("y") + if not serial or x is None or y is None: + return jsonify({"ok": False, "error": "缺少 serial/x/y"}), 400 + try: + _screen_get_device(serial).click(int(x), int(y)) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503 + +@bp.route("/api/screen/swipe", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_screen_swipe(): + """滑动:{serial, x1, y1, x2, y2, duration?}(设备原生分辨率坐标)。""" + data = request.json or {} + serial = data.get("serial", "") + x1, y1, x2, y2 = (data.get(k) for k in ("x1", "y1", "x2", "y2")) + if not serial or None in (x1, y1, x2, y2): + return jsonify({"ok": False, "error": "缺少 serial/x1/y1/x2/y2"}), 400 + try: + _screen_get_device(serial).swipe(int(x1), int(y1), int(x2), int(y2), + duration=float(data.get("duration", 0.2))) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503 + + +_SCREEN_KEYS = {"back", "home", "recent", "menu", "power", "volume_up", + "volume_down", "enter", "delete", "search", "camera"} + +@bp.route("/api/screen/key", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_screen_key(): + """按键:{serial, key}(back/home/recent/menu/power 等,见 _SCREEN_KEYS)。""" + data = request.json or {} + serial, key = data.get("serial", ""), (data.get("key") or "").strip().lower() + if not serial or key not in _SCREEN_KEYS: + return jsonify({"ok": False, "error": "缺少 serial 或不支持的按键"}), 400 + try: + _screen_get_device(serial).press(key) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503 + +@bp.route("/api/screen/text", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_screen_text(): + """输入文字:{serial, text}(需焦点在输入框,u2 send_keys)。""" + data = request.json or {} + serial, text = data.get("serial", ""), (data.get("text") or "").strip() + if not serial or not text: + return jsonify({"ok": False, "error": "缺少 serial/text"}), 400 + try: + _screen_get_device(serial).send_keys(text) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "error": f"输入失败: {e}"}), 503 + + +# ================== API:应用管理(APK 上传/安装)================== + +def _current_focus_pkg(serial): + """取设备当前前台包名(dumpsys window 解析,失败返回空串)。""" + try: + r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"], + capture_output=True, timeout=20) + out = (r.stdout or b"").decode("utf-8", errors="replace") + m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out) + return m.group(1) if m else "" + except Exception: + return "" + +def _screen_get_device(serial): + """远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。""" + import uiautomator2 as u2 + return u2.connect(serial) + diff --git a/web/tailscale_api.py b/web/tailscale_api.py new file mode 100644 index 0000000..2d68983 --- /dev/null +++ b/web/tailscale_api.py @@ -0,0 +1,98 @@ +"""Tailscale 管理 API(仅管理员)。""" +import re + +from flask import Blueprint, jsonify, request + +from core import tailscale_client +from core.tailscale_client import TailscaleError +from core.logger import get_logger + +_log = get_logger("web") +from web.auth import admin_required + +bp = Blueprint("tailscale", __name__) + +@bp.route("/api/tailscale/status") +@admin_required +def api_tailscale_status(): + """Tailscale 管理配置状态(API key / tailnet 是否已配置)。""" + c = tailscale_client.tailscale + return jsonify({"ok": True, "configured": c.is_configured(), + "hint": c.config_hint() if not c.is_configured() else ""}) + +@bp.route("/api/tailscale/devices") +@admin_required +def api_tailscale_devices(): + """列出 tailnet 全部设备。""" + try: + return jsonify({"ok": True, "devices": tailscale_client.tailscale.list_devices()}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + +@bp.route("/api/tailscale/devices/", methods=["POST"]) +@admin_required +def api_tailscale_device_update(device_id): + """更新设备:按传入字段分发到专属端点(改名 /name、授权 /authorized、密钥 /key)。""" + data = request.json or {} + c = tailscale_client.tailscale + try: + if "name" in data: + c.set_device_name(device_id, data.get("name")) + if "authorized" in data: + c.set_device_authorized(device_id, data.get("authorized")) + if "key_expiry_disabled" in data: + c.set_device_key_expiry(device_id, data.get("key_expiry_disabled")) + _log.info(f"Tailscale 设备更新 {device_id}: {data}") + return jsonify({"ok": True, "msg": "设备已更新"}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + +@bp.route("/api/tailscale/devices//ip", methods=["POST"]) +@admin_required +def api_tailscale_device_ip(device_id): + """设置设备 IPv4 地址(未公开端点,实测可用)。 + + ⚠ 改 IP 会断开设备当前 tailscale 会话,且平台设备池 serial 随之变化, + 改完需同步更新 STF 设备池(分组/任务目标里的旧 IP 会失效)。 + """ + data = request.json or {} + ipv4 = (data.get("ipv4") or "").strip() + if not ipv4: + return jsonify({"ok": False, "error": "缺少 ipv4"}), 400 + try: + tailscale_client.tailscale.set_device_ip(device_id, ipv4) + _log.info(f"Tailscale 设备 {device_id} IP 已设置为 {ipv4}") + return jsonify({"ok": True, "msg": f"设备 IP 已设置为 {ipv4}"}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + +@bp.route("/api/tailscale/devices/", methods=["DELETE"]) +@admin_required +def api_tailscale_device_delete(device_id): + """从 tailnet 移除设备。""" + try: + tailscale_client.tailscale.delete_device(device_id) + _log.info(f"Tailscale 设备已移除: {device_id}") + return jsonify({"ok": True, "msg": "设备已从 tailnet 移除"}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + +@bp.route("/api/tailscale/authkey", methods=["POST"]) +@admin_required +def api_tailscale_authkey(): + """生成设备接入 auth key(key 只显示一次)。""" + data = request.json or {} + try: + # Tailscale 要求 description 仅 ASCII(中文会 400) + desc = re.sub(r"[^\x20-\x7e]", "", (data.get("description") or "").strip()) + key = tailscale_client.tailscale.create_auth_key( + description=desc or "auto_control", + reusable=bool(data.get("reusable", False)), + ephemeral=bool(data.get("ephemeral", False)), + preauthorized=data.get("preauthorized", True), + expiry_seconds=int(data.get("expiry_seconds", 3600))) + _log.info(f"Tailscale auth key 已生成: {key['id']}") + return jsonify({"ok": True, "key": key["key"], "id": key["id"], + "expires": key["expires"]}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 diff --git a/web/tasks_api.py b/web/tasks_api.py new file mode 100644 index 0000000..eaa87c5 --- /dev/null +++ b/web/tasks_api.py @@ -0,0 +1,336 @@ +"""任务域 API:任务计划/分组/自定义动作/元素抓取/步骤测试。""" +import re +import time +import uuid +import threading +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout + +from flask import (Blueprint, jsonify, request, Response) +from flask_login import login_required + +from core import device_pool, uiauto_helper +from core.adb_helper import adb_connect_light +from core.models import db, DeviceGroup, TaskJob, CustomAction +from web import context +from web.auth import perm_required, PERM_DEVICES, PERM_TASKS +from tasks import list_task_types, get_task_class +from web.common import _merged_device_list + +bp = Blueprint("tasks", __name__) + +@bp.route("/api/task_types") +@login_required +def api_task_types(): + return jsonify({"ok": True, "task_types": list_task_types()}) + +@bp.route("/api/actions") +@login_required +def api_actions(): + """返回指定任务类型支持的专属操作。""" + task_type = request.args.get("task_type", "") + task_cls = get_task_class(task_type) + if not task_cls: + return jsonify({"ok": False, "error": "未知任务类型", "actions": []}) + actions = task_cls.list_action_types() + return jsonify({"ok": True, "actions": actions}) + +@bp.route("/api/devices") +@login_required +def api_devices(): + """返回所有在线设备 serial(供分组表单勾选用)。""" + try: + return jsonify({"ok": True, "devices": context.mgr.list_all_serials()}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +# ================== API:任务计划 CRUD ================== +def _job_next_run(job): + """任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。""" + nr = context.mgr.next_run_of(job) + return nr.strftime("%Y-%m-%d %H:%M") if nr else None + +@bp.route("/api/jobs") +@login_required +def api_jobs_list(): + jobs = [] + for j in context.mgr.jobs.values(): + d = j.to_dict() + d["next_run"] = _job_next_run(j) + jobs.append(d) + return jsonify({"ok": True, "jobs": jobs, + "task_types": list_task_types()}) + +@bp.route("/api/jobs", methods=["POST"]) +@perm_required(PERM_TASKS) +def api_jobs_create(): + data = request.json or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"ok": False, "error": "任务名不能为空"}), 400 + task_type = data.get("task_type", "douyin_nurture") + if not get_task_class(task_type): + return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400 + job = context.mgr.add_job( + name=name, task_type=task_type, + target=data.get("target", {"mode": "all"}), + params=data.get("params", {}), + schedule=data.get("schedule", {"mode": "once"}), + retry=data.get("retry", {"max_attempts": 1, "delay": 60}), + enabled=data.get("enabled", True)) + d = job.to_dict() + d["next_run"] = _job_next_run(job) + return jsonify({"ok": True, "msg": "任务已创建", "job": d}) + +@bp.route("/api/jobs/", methods=["PUT"]) +@perm_required(PERM_TASKS) +def api_jobs_update(job_id): + job = context.mgr.jobs.get(job_id) + if not job: + return jsonify({"ok": False, "error": "任务不存在"}), 404 + data = request.json or {} + fields = {} + for k in ("name", "task_type", "target", "params", "schedule", "retry", "enabled"): + if k in data: + fields[k] = data[k] + if "task_type" in fields and not get_task_class(fields["task_type"]): + return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400 + context.mgr.update_job(job_id, **fields) + d = job.to_dict() + d["next_run"] = _job_next_run(job) + return jsonify({"ok": True, "msg": "任务已更新", "job": d}) + +@bp.route("/api/jobs/", methods=["DELETE"]) +@perm_required(PERM_TASKS) +def api_jobs_delete(job_id): + if context.mgr.delete_job(job_id): + return jsonify({"ok": True, "msg": "任务已删除"}) + return jsonify({"ok": False, "error": "任务不存在"}), 404 + +@bp.route("/api/jobs//run", methods=["POST"]) +@perm_required(PERM_TASKS) +def api_jobs_run(job_id): + return jsonify(context.mgr.run_job_now(job_id)) + +@bp.route("/api/jobs//toggle", methods=["POST"]) +@perm_required(PERM_TASKS) +def api_jobs_toggle(job_id): + enabled = (request.json or {}).get("enabled", True) + job = context.mgr.toggle_job(job_id, enabled) + if not job: + return jsonify({"ok": False, "error": "任务不存在"}), 404 + return jsonify({"ok": True, "msg": f"任务已{'启用' if enabled else '停用'}"}) + + +# ================== API:设备分组 CRUD ================== + +@bp.route("/api/groups") +@login_required +def api_groups_list(): + return jsonify({"ok": True, "groups": [g.to_dict() for g in context.mgr.groups.values()]}) + +@bp.route("/api/groups", methods=["POST"]) +@perm_required(PERM_TASKS) +def api_groups_create(): + data = request.json or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"ok": False, "error": "分组名不能为空"}), 400 + if name in context.mgr.groups: + return jsonify({"ok": False, "error": "分组名已存在"}), 400 + context.mgr.add_group(name, data.get("serials", []), data.get("description", "")) + return jsonify({"ok": True, "msg": "分组已创建"}) + +@bp.route("/api/groups/", methods=["PUT"]) +@perm_required(PERM_TASKS) +def api_groups_update(name): + if name not in context.mgr.groups: + return jsonify({"ok": False, "error": "分组不存在"}), 404 + data = request.json or {} + context.mgr.update_group(name, + serials=data.get("serials"), + description=data.get("description")) + return jsonify({"ok": True, "msg": "分组已更新"}) + +@bp.route("/api/groups/", methods=["DELETE"]) +@perm_required(PERM_TASKS) +def api_groups_delete(name): + if context.mgr.delete_group(name): + return jsonify({"ok": True, "msg": "分组已删除"}) + return jsonify({"ok": False, "error": "分组不存在"}), 404 + + +# ================== API:用户管理 CRUD ================== + +@bp.route("/api/custom_actions") +@login_required +def api_custom_actions_list(): + rows = CustomAction.query.order_by(CustomAction.created_at.desc()).all() + return jsonify({"ok": True, "actions": [r.to_dict() for r in rows]}) + +@bp.route("/api/custom_actions", methods=["POST"]) +@perm_required(PERM_TASKS) +def api_custom_actions_create(): + data = request.json or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"ok": False, "error": "动作名不能为空"}), 400 + steps = data.get("steps", []) + if not steps: + return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400 + row = CustomAction(id=uuid.uuid4().hex[:8], name=name, + icon=data.get("icon") or "📦", + created_at=datetime.now().strftime("%Y-%m-%d %H:%M")) + row.set_steps(steps) + db.session.add(row) + db.session.commit() + return jsonify({"ok": True, "msg": "动作已保存", "action": row.to_dict()}) + +@bp.route("/api/custom_actions/", methods=["PUT"]) +@perm_required(PERM_TASKS) +def api_custom_actions_update(action_id): + row = CustomAction.query.get(action_id) + if not row: + return jsonify({"ok": False, "error": "动作不存在"}), 404 + data = request.json or {} + if "name" in data: + name = (data["name"] or "").strip() + if not name: + return jsonify({"ok": False, "error": "动作名不能为空"}), 400 + row.name = name + if "icon" in data: + row.icon = data["icon"] or "📦" + if "steps" in data: + if not data["steps"]: + return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400 + row.set_steps(data["steps"]) + db.session.commit() + return jsonify({"ok": True, "msg": "已更新", "action": row.to_dict()}) + +@bp.route("/api/custom_actions/", methods=["DELETE"]) +@perm_required(PERM_TASKS) +def api_custom_actions_delete(action_id): + row = CustomAction.query.get(action_id) + if not row: + return jsonify({"ok": False, "error": "动作不存在"}), 404 + db.session.delete(row) + db.session.commit() + return jsonify({"ok": True, "msg": "已删除"}) + +@bp.route("/api/uiauto/elements") +@perm_required(PERM_DEVICES) +def api_uiauto_elements(): + """获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。 + + 依赖本地运行的 uiautodev 服务(端口 20242)。 + ?serial=xxx 设备 serial + 返回: + 200 — {ok:true, elements:[...]} + 503 — {ok:false, error:"..."}(uiauto2 未启动) + """ + serial = request.args.get("serial", "") + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + ok, data = uiauto_helper.get_elements(serial) + if ok: + return jsonify({"ok": True, "elements": data}) + return jsonify({"ok": False, "error": data}), 503 + +@bp.route("/api/uiauto/status") +@login_required +def api_uiauto_status(): + """探测 uiauto2 本地服务是否运行(前端按钮禁启用)。""" + return jsonify({"ok": True, "running": uiauto_helper.is_running()}) + +@bp.route("/api/steps/test", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_steps_test(): + """测试单个步骤:在指定设备上试执行,验证选择器是否命中(步骤编辑器"测试此步骤")。 + + 请求: {"serial": "100.100.10.11:5555", "step": {"type": "click", "params": {...}}} + 只读连接(adb connect + u2),与运行中任务互不干扰。 + 返回 result: "命中" / "未找到" / "已执行"。 + """ + data = request.json or {} + serial = (data.get("serial") or "").strip() + step = data.get("step") or {} + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + if not step.get("type"): + return jsonify({"ok": False, "error": "缺少步骤类型"}), 400 + from tasks.generic.task import test_step + ok, msg, result = test_step(serial, step) + res_txt = {True: "命中", False: "未找到"}.get(result, "已执行") + _log.info(f"测试步骤 {step.get('type')} @ {serial}: {res_txt} ({msg})") + return jsonify({"ok": ok, "msg": msg, "result": res_txt}) + +@bp.route("/api/uiauto/devices") +@perm_required(PERM_DEVICES) +def api_uiauto_devices(): + """获取可选设备列表(抓取元素用)。 + + uiautodev 只认识本地 adb 已连接的设备;设备池里在线但未本地连接的设备 + 在这里补全,并先做一次轻量 adb connect(单次尝试,绝不 kill-server), + 让它们可被 uiautodev 抓取。 + """ + ok, data = uiauto_helper.list_devices() + if not ok: + return jsonify({"ok": False, "error": data}), 503 + seen = {d.get("serial") for d in data} + try: + online = set(device_pool.list_online()) + pool = [s for s in device_pool.list_configured() if s in online] + except Exception: + pool = [] + added = 0 + for serial in pool: + if not serial or serial in seen: + continue + # 轻量连接(单次尝试,不重试不 kill-server);连不上也照样列出, + # 前端抓取时会报明确错误,不影响其他设备的连接 + adb_connect_light(serial) + data.append({ + "serial": serial, + "model": "", + "product": "", + "name": serial, + "status": "device", + "enabled": True, + }) + seen.add(serial) + added += 1 + if added: + _log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}") + return jsonify({"ok": True, "devices": data}) + +@bp.route("/api/uiauto/screenshot") +@perm_required(PERM_DEVICES) +def api_uiauto_screenshot(): + """通过 uiauto2 获取设备截图(供抓取元素时显示设备画面)。 + + ?serial=xxx 设备 serial + 直接返回 image/jpeg,前端用 加载。 + """ + serial = request.args.get("serial", "") + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + ok, data = uiauto_helper.get_screenshot(serial) + if ok: + return Response(data, mimetype="image/jpeg", + headers={"Cache-Control": "no-store"}) + return jsonify({"ok": False, "error": data}), 503 + + +# ================== API:远程看屏(阶段 2) ================== +# MJPEG 实时画面流 + u2 触控注入。数据源 u2(atx-agent minicap 截图,单帧 ~200ms), +# 零新依赖(原计划 ws-scrcpy 不在 npm 分发,GitHub 下载在国内不可靠,改自建)。 +# 坐标约定:前端以 显示尺寸归一化后映射到设备原生分辨率(naturalWidth/Height)。 +_SCREEN_JPEG_QUALITY = 65 +_SCREEN_FRAME_GAP = 0.05 # 帧间最小间隔(秒),防止空转烧 CPU + +def _job_next_run(job): + """任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。""" + nr = context.mgr.next_run_of(job) + return nr.strftime("%Y-%m-%d %H:%M") if nr else None + diff --git a/web/tools_api.py b/web/tools_api.py new file mode 100644 index 0000000..982cd4a --- /dev/null +++ b/web/tools_api.py @@ -0,0 +1,236 @@ +"""工具域 API:adb 终端/剪贴板注入/应用版本。""" +import re +import subprocess +import shlex +from concurrent.futures import ThreadPoolExecutor, as_completed + +from flask import Blueprint, jsonify, request + +from core import device_pool +from core.adb_helper import adb_connect, adb_connect_light, _ADB_LOCK, _adb +from core.logger import get_logger +from web import context +from web.auth import admin_required, perm_required +from web.common import _merged_device_list + +_log = get_logger("web") +bp = Blueprint("tools", __name__) + +@bp.route("/api/adb/devices") +@admin_required +def api_adb_devices(): + """维护终端设备列表(仅管理员):本地 adb 已连接 + 设备池(SQLite)。 + + 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 + 供终端设备选择器使用——选中后自动附加 `-s `, + 离线/未连接的设备也可选,配合"重连设备"按钮恢复。 + """ + return jsonify({"ok": True, "devices": _merged_device_list()}) + +@bp.route("/api/adb/cmd", methods=["POST"]) +@admin_required +def api_adb_cmd(): + """维护终端:执行 adb 命令(仅管理员)。 + + 请求: {"cmd": "adb -s 100.100.10.11:5555 shell ls /sdcard"} + 用平台 adb 二进制执行(config.ADB_PATH),带 20s 超时。 + """ + data = request.json or {} + cmd = (data.get("cmd") or "").strip() + if not cmd: + return jsonify({"ok": False, "error": "命令不能为空"}), 400 + if _blocked_adb_cmd(cmd): + return jsonify({"ok": False, + "error": "禁止执行 kill-server / disconnect(会中断 STF 设备监控,影响所有任务)"}), 400 + tokens = shlex.split(cmd) + if tokens and tokens[0] == "adb": + tokens = tokens[1:] + if not tokens: + return jsonify({"ok": False, "error": "命令不能为空"}), 400 + try: + # 与 worker 的 adb 调用共用锁,避免并发操作同一 adb server + with _ADB_LOCK: + r = subprocess.run([ADB_PATH, *tokens], capture_output=True, timeout=20) + out = (r.stdout or b"").decode("utf-8", errors="replace") + err = (r.stderr or b"").decode("utf-8", errors="replace") + _log.info(f"维护终端执行 adb {' '.join(tokens)} -> exit {r.returncode}") + return jsonify({"ok": True, "stdout": out, "stderr": err, "code": r.returncode}) + except subprocess.TimeoutExpired: + return jsonify({"ok": False, "error": "命令执行超时(20s)"}), 408 + except Exception as e: + _log.warning(f"维护终端 adb 执行异常: {e}") + return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500 + +@bp.route("/api/tools/clipboard/set", methods=["POST"]) +@admin_required +def api_tools_clipboard_set(): + """工具-剪贴板注入:把指定文字写入一台或多台设备的剪贴板。 + + 请求: {"serials": ["100.100.10.11:5555", ...], "text": "要注入的文字"} + 设备来源与维护终端一致:本地 adb 已连接设备(含 USB)+ 设备池。 + 实现:u2 jsonrpc.setClipboard(实测 cmd clipboard 在 MIUI 上不存在)。 + """ + data = request.json or {} + serials = data.get("serials") or [] + text = (data.get("text") or "").strip() + if not isinstance(serials, list) or not serials: + return jsonify({"ok": False, "error": "未选择设备"}), 400 + if not text: + return jsonify({"ok": False, "error": "注入内容不能为空"}), 400 + results = {} + for serial in serials: + ok, msg = _set_device_clipboard(serial, text) + results[serial] = {"ok": ok, "msg": msg} + ok_count = sum(1 for v in results.values() if v["ok"]) + fail_count = len(results) - ok_count + return jsonify({"ok": True, "results": results, + "ok_count": ok_count, "fail_count": fail_count, + "error": None if ok_count == len(results) else f"{fail_count} 台设备注入失败"}) + + +def _set_device_clipboard(serial, text): + """向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。 + + - IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线) + - USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent + - 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求 + """ + import uiautomator2 as u2 + if ":" in serial: + try: + adb_connect(serial) + except Exception as e: + return False, f"adb 连接失败: {e}" + d = None + try: + with ThreadPoolExecutor(max_workers=1) as pool: + d = pool.submit(u2.connect, serial).result(timeout=30) + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(d.set_clipboard, text).result(timeout=15) + return True, "已注入" + except FuturesTimeout: + return False, "连接/注入超时(atx-agent 可能无响应)" + except Exception as e: + return False, f"{type(e).__name__}: {str(e)[:120]}" + + +def _app_ver_on_device(serial, pkg): + """单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。 + + IP:port 设备先轻量 adb connect(单次尝试,绝不 disconnect,红线)。 + 查询是只读 shell 命令,无需 adb server 锁(锁只保护 connect/kill-server 类操作)。 + """ + if ":" in serial: + try: + adb_connect_light(serial) + except Exception as e: + return {"installed": False, "error": f"adb 连接失败: {e}"} + try: + r = subprocess.run([ADB_PATH, "-s", serial, "shell", "pm", "path", pkg], + capture_output=True, timeout=15) + if b"package:" not in (r.stdout or b""): + return {"installed": False, "version_name": "", "version_code": ""} + r2 = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "package", pkg], + capture_output=True, timeout=20) + out2 = (r2.stdout or b"").decode("utf-8", errors="replace") + vm = re.search(r"versionName=(\S+)", out2) + vc = re.search(r"versionCode=(\d+)", out2) + return {"installed": True, + "version_name": vm.group(1) if vm else "", + "version_code": vc.group(1) if vc else ""} + except subprocess.TimeoutExpired: + return {"installed": False, "error": "查询超时"} + except Exception as e: + return {"installed": False, "error": str(e)} + +@bp.route("/api/tools/appver", methods=["POST"]) +@admin_required +def api_tools_appver(): + """工具-应用版本管理:查询所有设备上指定包名的安装情况与版本号。 + + 请求: {"pkg": "com.ss.android.ugc.aweme"} + 设备来源:本地 adb(含 USB)+ 设备池,并发查询(最多 10 台同时)。 + """ + pkg = (request.json or {}).get("pkg", "").strip() + if not re.match(r"^[A-Za-z0-9_.]+$", pkg or ""): + return jsonify({"ok": False, "error": "包名格式不正确(仅字母/数字/._)"}), 400 + # 只查在线设备:池内离线条目(如陈旧记录)不连,避免 connect 重试拖慢查询 + try: + online = set(device_pool.list_online()) + devices = [d for d in _merged_device_list() if d["serial"] in online] + except Exception: + devices = [] + if not devices: + return jsonify({"ok": False, "error": "无在线设备"}), 404 + results = {} + with ThreadPoolExecutor(max_workers=min(10, len(devices))) as pool: + futures = {pool.submit(_app_ver_on_device, d["serial"], pkg): d for d in devices} + for fut in as_completed(futures, timeout=90): + d = futures[fut] + try: + results[d["serial"]] = fut.result() + except Exception as e: + results[d["serial"]] = {"installed": False, "error": str(e)} + fail = sum(1 for v in results.values() if v.get("error")) + return jsonify({"ok": True, "results": results, "fail": fail, + "total": len(results)}) + +def _blocked_adb_cmd(cmd): + """命中红线的 adb 命令(kill-server / disconnect)直接拒绝。""" + low = cmd.lower() + return any(p in low for p in _ADB_BLOCKED_PATTERNS) + +def _set_device_clipboard(serial, text): + """向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。 + + - IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线) + - USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent + - 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求 + """ + import uiautomator2 as u2 + if ":" in serial: + try: + adb_connect(serial) + except Exception as e: + return False, f"adb 连接失败: {e}" + d = None + try: + with ThreadPoolExecutor(max_workers=1) as pool: + d = pool.submit(u2.connect, serial).result(timeout=30) + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(d.set_clipboard, text).result(timeout=15) + return True, "已注入" + except FuturesTimeout: + return False, "连接/注入超时(atx-agent 可能无响应)" + except Exception as e: + return False, f"{type(e).__name__}: {str(e)[:120]}" + +def _app_ver_on_device(serial, pkg): + """单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。 + + IP:port 设备先轻量 adb connect(单次尝试,绝不 disconnect,红线)。 + 查询是只读 shell 命令,无需 adb server 锁(锁只保护 connect/kill-server 类操作)。 + """ + if ":" in serial: + try: + adb_connect_light(serial) + except Exception as e: + return {"installed": False, "error": f"adb 连接失败: {e}"} + try: + r = subprocess.run([ADB_PATH, "-s", serial, "shell", "pm", "path", pkg], + capture_output=True, timeout=15) + if b"package:" not in (r.stdout or b""): + return {"installed": False, "version_name": "", "version_code": ""} + r2 = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "package", pkg], + capture_output=True, timeout=20) + out2 = (r2.stdout or b"").decode("utf-8", errors="replace") + vm = re.search(r"versionName=(\S+)", out2) + vc = re.search(r"versionCode=(\d+)", out2) + return {"installed": True, + "version_name": vm.group(1) if vm else "", + "version_code": vc.group(1) if vc else ""} + except subprocess.TimeoutExpired: + return {"installed": False, "error": "查询超时"} + except Exception as e: + return {"installed": False, "error": str(e)} + diff --git a/web_server.py b/web_server.py index 2a62409..3cbdcf4 100644 --- a/web_server.py +++ b/web_server.py @@ -1,52 +1,29 @@ -"""设备自动化管理后台(Flask + Flask-Login,单页应用)。 +"""设备自动化管理后台入口:Flask app 装配 + 启动。 -启动:python web_server.py -访问:http://localhost:5000 - -页面结构: - / — 单页应用(监控/任务/分组/日志/用户 Tab 切换,需登录) - /login /logout — 用户登录/登出 - /api/* — JSON API - -用户系统: - 首次启动自动创建默认管理员 admin/admin123(请及时改密码)。 - 用户数据存 data/users.db(SQLite)。 +路由按功能域拆分到 web/ 蓝图包(auth/monitor/tasks/admin/tools/devices/apks/tailscale), +本文件只负责:app 创建、数据库初始化、共享对象装配、蓝图注册、uiautodev 生命周期、启动。 """ import os -import sys import time -import uuid import atexit import secrets -import functools -import shlex -import re +import socket import subprocess -import urllib.parse import threading -from datetime import datetime -from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout -from flask import (Flask, jsonify, request, redirect, url_for, render_template, - render_template_string, Response, make_response, session) -from markupsafe import escape as _esc -from flask_login import (LoginManager, login_user, logout_user, - login_required, current_user) +from flask import Flask +from flask_login import LoginManager from core.task_manager import TaskManager -from core.device_worker import clear_worker_error, clear_all_worker_errors, get_all_worker_status -from core.logger import get_logger, _LOG_DIR, _MODULE_FILES -from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction from core.apk_manager import ApkManager -from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light, - adb_connect, _ADB_LOCK, _adb) -from core import uiauto_helper, tailscale_client -from core.tailscale_client import TailscaleError -from config import ADB_PATH -from tasks import list_task_types, get_task_class +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) @@ -70,1604 +47,24 @@ device_pool.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)) -# ================== CSRF 防护 ================== -def _csrf_token(): - """获取或生成当前会话的 CSRF token(非 GET 请求需在 X-CSRF-Token 头携带)。""" - if "csrf_token" not in session: - session["csrf_token"] = secrets.token_hex(16) - return session["csrf_token"] - - -@app.before_request -def _csrf_protect(): - """非安全方法(POST/PUT/DELETE/PATCH)校验 X-CSRF-Token 请求头。""" - if request.method not in ("POST", "PUT", "DELETE", "PATCH"): - return - # 登录表单(未登录,尚无 token)和静态文件跳过 - if request.endpoint in ("login", "static"): - return - sess = session.get("csrf_token", "") - header = request.headers.get("X-CSRF-Token", "") - if not sess or header != sess: - return jsonify({"ok": False, "error": "CSRF 校验失败"}), 403 - - -@app.route("/api/csrf") -@login_required -def api_csrf(): - """获取 CSRF token(前端非 GET 请求需携带 X-CSRF-Token 头)。""" - return jsonify({"ok": True, "token": _csrf_token()}) - - -# ================== 权限控制 ================== -# 业务权限位(is_admin 管理员拥有全部权限,不受 perms 限制): -# tasks — 任务管理:任务/自定义动作/分组的创建、修改、删除、启停、执行 -# devices — 设备控制:停止设备、释放占用、清除异常、前台扫描、元素抓取 -# apks — 应用管理:APK 上传、安装、删除 -# logs — 日志查看 -# 查看类接口(GET 状态/列表)所有登录用户可用;用户管理仅管理员可用(admin_required)。 -PERM_TASKS = "tasks" -PERM_DEVICES = "devices" -PERM_APKS = "apks" -PERM_LOGS = "logs" -ALL_PERMS = (PERM_TASKS, PERM_DEVICES, PERM_APKS, PERM_LOGS) -_PERM_LABELS = {PERM_TASKS: "任务管理", PERM_DEVICES: "设备控制", - PERM_APKS: "应用管理", PERM_LOGS: "日志查看"} - - -def _has_perm(perm): - """当前用户是否拥有指定权限。管理员恒为 True。""" - u = current_user - return bool(u and (u.is_admin or u.has_perm(perm))) - - -def _validate_perms(raw): - """校验权限位列表:只保留合法值、去重。返回合法列表。""" - valid = set(ALL_PERMS) - out = [] - for p in raw or []: - if p in valid and p not in out: - out.append(p) - return out - - -def perm_required(perm): - """路由装饰器:要求登录且拥有指定业务权限,否则 403。""" - def deco(fn): - @functools.wraps(fn) - @login_required - def wrapper(*args, **kwargs): - if not _has_perm(perm): - return jsonify({"ok": False, - "error": f"无权限执行此操作(需要权限: {_PERM_LABELS.get(perm, perm)})"}), 403 - return fn(*args, **kwargs) - return wrapper - return deco - - -def admin_required(fn): - """路由装饰器:仅管理员可用(用户管理类接口),否则 403。""" - @functools.wraps(fn) - @login_required - def wrapper(*args, **kwargs): - if not current_user.is_admin: - return jsonify({"ok": False, "error": "仅管理员可执行此操作"}), 403 - return fn(*args, **kwargs) - return wrapper - - -@app.route("/api/me") -@login_required -def api_me(): - """当前登录用户信息(含权限位),前端据此隐藏无权限的功能入口。""" - u = current_user - return jsonify({"ok": True, "user": { - "id": u.id, - "username": u.username, - "is_admin": bool(u.is_admin), - # 管理员返回全部权限位,前端统一用 perms 判断 - "perms": list(ALL_PERMS) if u.is_admin else u.get_perms(), - }}) - - -# ================== 页面路由 ================== -@app.route("/") -@login_required -def index(): - """单页应用首页。""" - resp = make_response(render_template("admin/monitor.html")) - resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" - resp.headers["Pragma"] = "no-cache" - return resp - - -@app.route("/wall") -@login_required -def wall(): - """监控大屏(独立全屏页面,供挂墙/电视展示)。 - - 深色控制室风格;设备缩略图按需轮询(每设备 ~2.5s 一帧), - 20 台设备整体开销约 0.2 核 CPU + 100KB/s,普通电脑无压力。 - """ - resp = make_response(render_template("admin/wall.html")) - resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" - return resp - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - username = request.form.get("username", "") - password = request.form.get("password", "") - user = User.query.filter_by(username=username).first() - if user and user.check_password(password): - login_user(user) - _csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取 - _log.info(f"用户 {username} 登录") - return redirect(request.args.get("next") or url_for("index")) - return render_template("admin/login.html", error="用户名或密码错误") - return render_template("admin/login.html", error=None) - - -@app.route("/logout") -@login_required -def logout(): - _log.info(f"用户 {current_user.username} 登出") - logout_user() - return redirect(url_for("login")) - - # ================== API:健康检查(无需登录,供探活) ================== -@app.route("/api/health") -def api_health(): - """轻量健康检查:返回进程/设备/任务摘要,不暴露敏感信息,供运维探活。""" - try: - devices, err = mgr.get_status() - devs = devices or [] - return jsonify({ - "ok": True, - "status": "up", - "time": time.time(), - "device_total": len(devs), - "device_online": sum(1 for d in devs if d.get("present")), - "device_running": sum(1 for d in devs - if d.get("worker_status") in ("running", "connecting")), - "device_error": sum(1 for d in devs - if d.get("worker_status") in ("error", "failed")), - "jobs": len(mgr.jobs), - }) - except Exception as e: - return jsonify({"ok": False, "status": "down", "error": str(e)}), 500 -# ================== API:失败任务汇总 ================== -@app.route("/api/summary") -@login_required -def api_summary(): - """失败/异常任务汇总:各状态计数 + 异常设备列表(last_error / 重试次数)。 - - 供监控页"异常汇总"面板使用,便于长期运行观察设备健康度。 - """ - try: - sts = get_all_worker_status() - # 已删除/不在当前 STF 设备池的设备,其陈旧失败记录不应展示。 - # 复用 get_status 缓存(顺带触发上面的状态清理),STF 异常时不隐藏任何记录。 - try: - devices, derr = mgr.get_status() - if not derr: - present = {d.get("serial") for d in devices} - sts = [s for s in sts if s.get("serial") in present] - except Exception: - pass - counts = {"total": 0, "running": 0, "done": 0, - "error": 0, "failed": 0, "idle": 0} - errors = [] - for s in sts: - st = s.get("status", "idle") - counts["total"] += 1 - if st in counts: - counts[st] += 1 - if st in ("error", "failed") and s.get("last_error"): - errors.append({ - "serial": s.get("serial"), - "model": s.get("model", ""), - "status": st, - "last_error": s.get("last_error", ""), - "task": s.get("task_job", ""), - "attempt": s.get("attempt", 0), - "updated": s.get("last_heartbeat", 0), - }) - errors.sort(key=lambda x: x.get("updated", 0), reverse=True) - return jsonify({"ok": True, "counts": counts, "errors": errors[:50]}) - except Exception as e: - return jsonify({"ok": False, "error": str(e)}), 500 - - -# ================== API:状态(监控大屏用)================== -@app.route("/api/status") -@login_required -def api_status(): - devices, err = mgr.get_status() - if err: - return jsonify({"ok": False, "error": err}), 500 - return jsonify({ - "ok": True, "devices": devices, "server_time": time.time(), - "fg_scanning": mgr._fg_scanner.is_scanning, - "fg_last_scan": mgr._fg_scanner.last_scan_time, - }) - - -@app.route("/api/scan_foreground", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_scan_foreground(): - """手动触发前台 App 扫描(不打扰设备)。""" - started = mgr._fg_scanner.scan_once() - if started: - return jsonify({"ok": True, "msg": "扫描已启动"}) - return jsonify({"ok": False, "error": "已有扫描在进行中"}) - - -@app.route("/api/task_types") -@login_required -def api_task_types(): - return jsonify({"ok": True, "task_types": list_task_types()}) - - -@app.route("/api/actions") -@login_required -def api_actions(): - """返回指定任务类型支持的专属操作。""" - task_type = request.args.get("task_type", "") - task_cls = get_task_class(task_type) - if not task_cls: - return jsonify({"ok": False, "error": "未知任务类型", "actions": []}) - actions = task_cls.list_action_types() - return jsonify({"ok": True, "actions": actions}) - - -@app.route("/api/devices") -@login_required -def api_devices(): - """返回所有在线设备 serial(供分组表单勾选用)。""" - try: - return jsonify({"ok": True, "devices": mgr.list_all_serials()}) - except Exception as e: - return jsonify({"ok": False, "error": str(e)}), 500 - - -# ================== API:任务计划 CRUD ================== -def _job_next_run(job): - """任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。""" - nr = mgr.next_run_of(job) - return nr.strftime("%Y-%m-%d %H:%M") if nr else None - - -@app.route("/api/jobs") -@login_required -def api_jobs_list(): - jobs = [] - for j in mgr.jobs.values(): - d = j.to_dict() - d["next_run"] = _job_next_run(j) - jobs.append(d) - return jsonify({"ok": True, "jobs": jobs, - "task_types": list_task_types()}) - - -@app.route("/api/jobs", methods=["POST"]) -@perm_required(PERM_TASKS) -def api_jobs_create(): - data = request.json or {} - name = (data.get("name") or "").strip() - if not name: - return jsonify({"ok": False, "error": "任务名不能为空"}), 400 - task_type = data.get("task_type", "douyin_nurture") - if not get_task_class(task_type): - return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400 - job = mgr.add_job( - name=name, task_type=task_type, - target=data.get("target", {"mode": "all"}), - params=data.get("params", {}), - schedule=data.get("schedule", {"mode": "once"}), - retry=data.get("retry", {"max_attempts": 1, "delay": 60}), - enabled=data.get("enabled", True)) - d = job.to_dict() - d["next_run"] = _job_next_run(job) - return jsonify({"ok": True, "msg": "任务已创建", "job": d}) - - -@app.route("/api/jobs/", methods=["PUT"]) -@perm_required(PERM_TASKS) -def api_jobs_update(job_id): - job = mgr.jobs.get(job_id) - if not job: - return jsonify({"ok": False, "error": "任务不存在"}), 404 - data = request.json or {} - fields = {} - for k in ("name", "task_type", "target", "params", "schedule", "retry", "enabled"): - if k in data: - fields[k] = data[k] - if "task_type" in fields and not get_task_class(fields["task_type"]): - return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400 - mgr.update_job(job_id, **fields) - d = job.to_dict() - d["next_run"] = _job_next_run(job) - return jsonify({"ok": True, "msg": "任务已更新", "job": d}) - - -@app.route("/api/jobs/", methods=["DELETE"]) -@perm_required(PERM_TASKS) -def api_jobs_delete(job_id): - if mgr.delete_job(job_id): - return jsonify({"ok": True, "msg": "任务已删除"}) - return jsonify({"ok": False, "error": "任务不存在"}), 404 - - -@app.route("/api/jobs//run", methods=["POST"]) -@perm_required(PERM_TASKS) -def api_jobs_run(job_id): - return jsonify(mgr.run_job_now(job_id)) - - -@app.route("/api/jobs//toggle", methods=["POST"]) -@perm_required(PERM_TASKS) -def api_jobs_toggle(job_id): - enabled = (request.json or {}).get("enabled", True) - job = mgr.toggle_job(job_id, enabled) - if not job: - return jsonify({"ok": False, "error": "任务不存在"}), 404 - return jsonify({"ok": True, "msg": f"任务已{'启用' if enabled else '停用'}"}) - - -# ================== API:设备分组 CRUD ================== -@app.route("/api/groups") -@login_required -def api_groups_list(): - return jsonify({"ok": True, "groups": [g.to_dict() for g in mgr.groups.values()]}) - - -@app.route("/api/groups", methods=["POST"]) -@perm_required(PERM_TASKS) -def api_groups_create(): - data = request.json or {} - name = (data.get("name") or "").strip() - if not name: - return jsonify({"ok": False, "error": "分组名不能为空"}), 400 - if name in mgr.groups: - return jsonify({"ok": False, "error": "分组名已存在"}), 400 - mgr.add_group(name, data.get("serials", []), data.get("description", "")) - return jsonify({"ok": True, "msg": "分组已创建"}) - - -@app.route("/api/groups/", methods=["PUT"]) -@perm_required(PERM_TASKS) -def api_groups_update(name): - if name not in mgr.groups: - return jsonify({"ok": False, "error": "分组不存在"}), 404 - data = request.json or {} - mgr.update_group(name, - serials=data.get("serials"), - description=data.get("description")) - return jsonify({"ok": True, "msg": "分组已更新"}) - - -@app.route("/api/groups/", methods=["DELETE"]) -@perm_required(PERM_TASKS) -def api_groups_delete(name): - if mgr.delete_group(name): - return jsonify({"ok": True, "msg": "分组已删除"}) - return jsonify({"ok": False, "error": "分组不存在"}), 404 - - -# ================== API:用户管理 CRUD ================== -@app.route("/api/users") -@admin_required -def api_users_list(): - users = [{"id": u.id, "username": u.username, - "is_admin": u.is_admin, "perms": u.get_perms()} for u in User.query.all()] - return jsonify({"ok": True, "users": users}) - - -@app.route("/api/users", methods=["POST"]) -@admin_required -def api_users_create(): - data = request.json or {} - username = (data.get("username") or "").strip() - password = data.get("password", "") - if not username or not password: - return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400 - if User.query.filter_by(username=username).first(): - return jsonify({"ok": False, "error": "用户名已存在"}), 400 - # 默认普通用户(最小权限);管理员由 is_admin 决定,权限位照常保存(降级后生效) - u = User(username=username, is_admin=bool(data.get("is_admin", False))) - u.set_perms(_validate_perms(data.get("perms") or [])) - u.set_password(password) - db.session.add(u) - db.session.commit() - _log.info(f"创建用户 {username} (admin={u.is_admin}, perms={u.get_perms()})") - return jsonify({"ok": True, "msg": "用户已创建"}) - - -@app.route("/api/users/", methods=["PUT"]) -@admin_required -def api_users_update(uid): - u = User.query.get(uid) - if not u: - return jsonify({"ok": False, "error": "用户不存在"}), 404 - data = request.json or {} - if "password" in data and data["password"]: - u.set_password(data["password"]) - if "is_admin" in data: - new_admin = bool(data["is_admin"]) - if u.is_admin and not new_admin and User.query.filter_by(is_admin=True).count() <= 1: - return jsonify({"ok": False, "error": "不能取消最后一个管理员"}), 400 - u.is_admin = new_admin - if "perms" in data: - u.set_perms(_validate_perms(data["perms"])) - db.session.commit() - _log.info(f"更新用户 {u.username} (admin={u.is_admin}, perms={u.get_perms()})") - return jsonify({"ok": True, "msg": "用户已更新"}) - - -@app.route("/api/users/", methods=["DELETE"]) -@admin_required -def api_users_delete(uid): - u = User.query.get(uid) - if not u: - return jsonify({"ok": False, "error": "用户不存在"}), 404 - if u.username == "admin": - return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400 - if u.id == current_user.id: - return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400 - if u.is_admin and User.query.filter_by(is_admin=True).count() <= 1: - return jsonify({"ok": False, "error": "不能删除最后一个管理员"}), 400 - db.session.delete(u) - db.session.commit() - _log.info(f"删除用户 {u.username}") - return jsonify({"ok": True, "msg": "用户已删除"}) - - -# ================== API:日志查看 ================== -@app.route("/api/logs") -@perm_required(PERM_LOGS) -def api_logs(): - files = list(_MODULE_FILES.values()) - current = request.args.get("file", "core.log") - lines = int(request.args.get("lines", 300)) - content = "" - path = os.path.join(_LOG_DIR, current) - if os.path.exists(path): - try: - with open(path, encoding="utf-8") as f: - content = "".join(f.readlines()[-lines:]) - except Exception as e: - content = f"读取失败: {e}" - return jsonify({"ok": True, "content": content, "file": current, "files": files}) - - -# ================== API:运行控制 ================== -@app.route("/api/stop_device", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_stop_device(): - serial = (request.json or {}).get("serial", "") - if mgr.stop_device(serial): - return jsonify({"ok": True, "msg": f"已发送停止信号给 {serial}"}) - return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400 - - -@app.route("/locate") -def locate_page(): - """设备端定位页(免登录):点亮后浏览器打开,大字显示设备 IP/序列号。 - - 只展示 serial 文本,无任何平台数据,免登录可接受。 - """ - serial = request.args.get("serial", "") - return render_template_string( - """ - -设备定位 -
{{ serial }}
-
{{ ip }}
-
定位完成 · 按返回键退出
-""", - serial=_esc(serial), ip=_esc(serial.split(":")[0])) - - -# 常见浏览器包名(结束定位时 force-stop 用) -_BROWSER_PKGS = {"com.android.browser", "com.miui.browser", "com.android.chrome", - "com.brave.browser", "com.opera.browser", "org.mozilla.firefox", - "com.UCMobile", "com.tencent.mtt", "com.baidu.browser.apps"} -# 定位时启动的浏览器:serial -> 包名(结束定位时无论前后台都关闭它) -_locate_browsers = {} -_locate_lock = threading.Lock() - - -def _current_focus_pkg(serial): - """取设备当前前台包名(dumpsys window 解析,失败返回空串)。""" - try: - r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"], - capture_output=True, timeout=20) - out = (r.stdout or b"").decode("utf-8", errors="replace") - m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out) - return m.group(1) if m else "" - except Exception: - return "" - - -@app.route("/api/device/locate/stop", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_device_locate_stop(): - """结束定位:关闭定位启动的浏览器(无论当前是否前台)。 - - 优先 force-stop 定位时记录下的浏览器包名(切到后台也能关掉); - 无记录时退回:前台是浏览器 → force-stop;否则按返回键(不误杀任务应用)。 - """ - serial = str((request.json or {}).get("serial", "")).strip() - if not serial: - return jsonify({"ok": False, "error": "缺少设备 serial"}), 400 - try: - if ":" in serial: - adb_connect(serial) - # 1. 优先关闭定位时启动的浏览器(无论前后台) - with _locate_lock: - started = _locate_browsers.pop(serial, None) - if started: - subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", started], - capture_output=True, timeout=20) - msg = f"已关闭定位浏览器 {started}" - else: - # 2. 退回:前台是浏览器则关闭,否则返回键轻量退出 - pkg = _current_focus_pkg(serial) - if pkg in _BROWSER_PKGS: - subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", pkg], - capture_output=True, timeout=20) - msg = f"已关闭浏览器 {pkg}" - else: - subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "4"], - capture_output=True, timeout=15) # BACK 轻量退出 - msg = "已按返回键退出" + (f"(前台 {pkg})" if pkg else "") - _log.info(f"结束定位 {serial}: {msg}") - return jsonify({"ok": True, "msg": msg}) - except Exception as e: - return jsonify({"ok": False, "error": f"结束定位失败: {e}"}), 502 - - -@app.route("/api/device/screen_all", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_device_screen_all(): - """批量亮屏/息屏(并发)。mode: on=亮屏解锁 / off=息屏。 - - 请求可带 serials 指定设备(前端勾选);不带则作用于全部在线设备。 - 息屏会中断运行中的任务(u2 无法操作),前端已有确认提示。 - """ - data = request.json or {} - mode = data.get("mode", "on") - try: - online = set(device_pool.list_online()) - except Exception as e: - return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502 - serials = data.get("serials") or [] - if serials: - # 指定设备:过滤掉不在线的(离线设备操作无意义,直接跳过) - serials = [s for s in serials if s in online] - else: - serials = list(online) - if not serials: - return jsonify({"ok": False, "error": "无在线设备"}), 400 - - def _do(serial): - try: - if ":" in serial: - adb_connect(serial) - if mode == "off": - subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"], - capture_output=True, timeout=15) # KEYCODE_POWER 息屏 - else: - subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"], - capture_output=True, timeout=15) # WAKEUP - subprocess.run([ADB_PATH, "-s", serial, "shell", "wm", "dismiss-keyguard"], - capture_output=True, timeout=15) - return True - except Exception: - return False - - with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool: - results = list(pool.map(_do, serials)) - ok_n = sum(1 for x in results if x) - _log.info(f"批量{'息屏' if mode == 'off' else '亮屏'}: 成功 {ok_n}/{len(serials)}") - return jsonify({"ok": True, "success": ok_n, "total": len(serials)}) - - -@app.route("/api/device/locate", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_device_locate(): - """定位设备:点亮屏幕并解除锁屏,可选打开大字定位页(查找设备用)。 - - 请求 {"serial": "...", "show": true}:show=true 时额外用浏览器打开 - 平台 /locate 定位页,全屏大字显示设备 IP(更醒目,但会切换前台, - 任务运行中慎用)。点亮组合:WAKEUP → dismiss-keyguard → MENU 兜底。 - 绝不 disconnect,红线。 - """ - data = request.json or {} - serial = str(data.get("serial", "")).strip() - if not serial: - return jsonify({"ok": False, "error": "缺少设备 serial"}), 400 - try: - if ":" in serial: - adb_connect(serial) - for cmd in (["shell", "input", "keyevent", "224"], - ["shell", "wm", "dismiss-keyguard"], - ["shell", "input", "keyevent", "82"]): - try: - subprocess.run([ADB_PATH, "-s", serial, *cmd], - capture_output=True, timeout=15) - except Exception: - pass # 单步失败不阻塞,继续下一步 - msgs = ["屏幕已点亮"] - if data.get("show"): - # 用设备浏览器打开定位页(设备走 tailnet 访问本机 100.100.10.2:18050) - locate_url = f"http://100.100.10.2:18050/locate?serial={urllib.parse.quote(serial)}" - r = subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "start", - "-a", "android.intent.action.VIEW", "-d", locate_url], - capture_output=True, timeout=20) - out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace") - if "error" in out.lower() or "exception" in out.lower(): - msgs.append(f"打开定位页失败: {out.strip()[:80]}") - else: - msgs.append("已打开大字定位页(按返回键退出)") - # 记录启动的浏览器包名:结束定位时无论前后台都关闭它。 - # 优先从 am start 输出解析 pkg=(浏览器可能尚未到前台,前台检测会扑空) - pkg = "" - m2 = re.search(r"pkg=([\w.]+)", out) - if m2 and m2.group(1) in _BROWSER_PKGS: - pkg = m2.group(1) - else: - time.sleep(1.5) # 等浏览器切到前台再查 - pkg = _current_focus_pkg(serial) - if pkg in _BROWSER_PKGS: - with _locate_lock: - _locate_browsers[serial] = pkg - _log.info(f"定位设备 {serial}: {'; '.join(msgs)}") - return jsonify({"ok": True, "msg": f"{serial} {';'.join(msgs)}"}) - except Exception as e: - return jsonify({"ok": False, "error": f"定位失败: {e}"}), 502 - - -@app.route("/api/stop_all", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_stop_all(): - stopped = mgr.stop_all() - return jsonify({"ok": True, "stopped": stopped}) - - -@app.route("/api/device/clear_error", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_device_clear_error(): - """清除单台设备的异常状态(error/failed → idle),供设备列表"清除异常"按钮使用。""" - serial = (request.json or {}).get("serial", "") - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - if serial in mgr.get_running(): - return jsonify({"ok": False, "error": "设备正在运行或等待重试,无法清除"}), 400 - if not clear_worker_error(serial): - return jsonify({"ok": False, "error": "设备正在运行任务,无法清除"}), 400 - _log.info(f"清除设备 {serial} 的异常状态") - return jsonify({"ok": True, "msg": f"已清除 {serial} 的异常状态"}) - - -@app.route("/api/device/clear_all_errors", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_device_clear_all_errors(): - """一键清除所有异常/失败设备(跳过正在运行/等待重试的)。""" - running = set(mgr.get_running()) - cleared = clear_all_worker_errors(exclude=running) - _log.info(f"一键清除异常:共清除 {cleared} 台") - return jsonify({"ok": True, "cleared": cleared, - "msg": f"已清除 {cleared} 台设备的异常状态"}) - - -@app.route("/api/device/screenshot") -@login_required -def api_device_screenshot(): - """获取设备当前画面截图(PNG)。 - - 用 adb exec-out screencap -p,只读操作,不抢占 u2 的 atx-agent 通道, - 任务运行中调用安全。直接返回 image/png,前端用 加载。 - ?serial=xxx 设备 serial - ?t=123 时间戳,避免浏览器缓存(前端自动加) - """ - serial = request.args.get("serial", "") - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - ok, data = screenshot(serial) - if ok: - return Response(data, mimetype="image/png", - headers={"Cache-Control": "no-store"}) - return jsonify({"ok": False, "error": data}), 500 - - -@app.route("/api/devices//apps") -@login_required -def api_device_apps(serial): - """获取指定设备上已安装的应用列表(包名 + versionCode + versionName + 路径)。""" - ok, data = list_installed_apps(serial) - if ok: - return jsonify({"ok": True, "apps": data}) - return jsonify({"ok": False, "error": data}), 500 - - -# ================== 自定义动作(步骤打包) ================== -@app.route("/api/custom_actions") -@login_required -def api_custom_actions_list(): - rows = CustomAction.query.order_by(CustomAction.created_at.desc()).all() - return jsonify({"ok": True, "actions": [r.to_dict() for r in rows]}) - - -@app.route("/api/custom_actions", methods=["POST"]) -@perm_required(PERM_TASKS) -def api_custom_actions_create(): - data = request.json or {} - name = (data.get("name") or "").strip() - if not name: - return jsonify({"ok": False, "error": "动作名不能为空"}), 400 - steps = data.get("steps", []) - if not steps: - return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400 - row = CustomAction(id=uuid.uuid4().hex[:8], name=name, - icon=data.get("icon") or "📦", - created_at=datetime.now().strftime("%Y-%m-%d %H:%M")) - row.set_steps(steps) - db.session.add(row) - db.session.commit() - return jsonify({"ok": True, "msg": "动作已保存", "action": row.to_dict()}) - - -@app.route("/api/custom_actions/", methods=["PUT"]) -@perm_required(PERM_TASKS) -def api_custom_actions_update(action_id): - row = CustomAction.query.get(action_id) - if not row: - return jsonify({"ok": False, "error": "动作不存在"}), 404 - data = request.json or {} - if "name" in data: - name = (data["name"] or "").strip() - if not name: - return jsonify({"ok": False, "error": "动作名不能为空"}), 400 - row.name = name - if "icon" in data: - row.icon = data["icon"] or "📦" - if "steps" in data: - if not data["steps"]: - return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400 - row.set_steps(data["steps"]) - db.session.commit() - return jsonify({"ok": True, "msg": "已更新", "action": row.to_dict()}) - - -@app.route("/api/custom_actions/", methods=["DELETE"]) -@perm_required(PERM_TASKS) -def api_custom_actions_delete(action_id): - row = CustomAction.query.get(action_id) - if not row: - return jsonify({"ok": False, "error": "动作不存在"}), 404 - db.session.delete(row) - db.session.commit() - return jsonify({"ok": True, "msg": "已删除"}) - - -@app.route("/api/uiauto/elements") -@perm_required(PERM_DEVICES) -def api_uiauto_elements(): - """获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。 - - 依赖本地运行的 uiautodev 服务(端口 20242)。 - ?serial=xxx 设备 serial - 返回: - 200 — {ok:true, elements:[...]} - 503 — {ok:false, error:"..."}(uiauto2 未启动) - """ - serial = request.args.get("serial", "") - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - ok, data = uiauto_helper.get_elements(serial) - if ok: - return jsonify({"ok": True, "elements": data}) - return jsonify({"ok": False, "error": data}), 503 - - -@app.route("/api/uiauto/status") -@login_required -def api_uiauto_status(): - """探测 uiauto2 本地服务是否运行(前端按钮禁启用)。""" - return jsonify({"ok": True, "running": uiauto_helper.is_running()}) - - -@app.route("/api/steps/test", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_steps_test(): - """测试单个步骤:在指定设备上试执行,验证选择器是否命中(步骤编辑器"测试此步骤")。 - - 请求: {"serial": "100.100.10.11:5555", "step": {"type": "click", "params": {...}}} - 只读连接(adb connect + u2),与运行中任务互不干扰。 - 返回 result: "命中" / "未找到" / "已执行"。 - """ - data = request.json or {} - serial = (data.get("serial") or "").strip() - step = data.get("step") or {} - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - if not step.get("type"): - return jsonify({"ok": False, "error": "缺少步骤类型"}), 400 - from tasks.generic.task import test_step - ok, msg, result = test_step(serial, step) - res_txt = {True: "命中", False: "未找到"}.get(result, "已执行") - _log.info(f"测试步骤 {step.get('type')} @ {serial}: {res_txt} ({msg})") - return jsonify({"ok": ok, "msg": msg, "result": res_txt}) - - -@app.route("/api/uiauto/devices") -@perm_required(PERM_DEVICES) -def api_uiauto_devices(): - """获取可选设备列表(抓取元素用)。 - - uiautodev 只认识本地 adb 已连接的设备;设备池里在线但未本地连接的设备 - 在这里补全,并先做一次轻量 adb connect(单次尝试,绝不 kill-server), - 让它们可被 uiautodev 抓取。 - """ - ok, data = uiauto_helper.list_devices() - if not ok: - return jsonify({"ok": False, "error": data}), 503 - seen = {d.get("serial") for d in data} - try: - online = set(device_pool.list_online()) - pool = [s for s in device_pool.list_configured() if s in online] - except Exception: - pool = [] - added = 0 - for serial in pool: - if not serial or serial in seen: - continue - # 轻量连接(单次尝试,不重试不 kill-server);连不上也照样列出, - # 前端抓取时会报明确错误,不影响其他设备的连接 - adb_connect_light(serial) - data.append({ - "serial": serial, - "model": "", - "product": "", - "name": serial, - "status": "device", - "enabled": True, - }) - seen.add(serial) - added += 1 - if added: - _log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}") - return jsonify({"ok": True, "devices": data}) - - -@app.route("/api/uiauto/screenshot") -@perm_required(PERM_DEVICES) -def api_uiauto_screenshot(): - """通过 uiauto2 获取设备截图(供抓取元素时显示设备画面)。 - - ?serial=xxx 设备 serial - 直接返回 image/jpeg,前端用 加载。 - """ - serial = request.args.get("serial", "") - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - ok, data = uiauto_helper.get_screenshot(serial) - if ok: - return Response(data, mimetype="image/jpeg", - headers={"Cache-Control": "no-store"}) - return jsonify({"ok": False, "error": data}), 503 - - -# ================== API:远程看屏(阶段 2) ================== -# MJPEG 实时画面流 + u2 触控注入。数据源 u2(atx-agent minicap 截图,单帧 ~200ms), -# 零新依赖(原计划 ws-scrcpy 不在 npm 分发,GitHub 下载在国内不可靠,改自建)。 -# 坐标约定:前端以 显示尺寸归一化后映射到设备原生分辨率(naturalWidth/Height)。 -_SCREEN_JPEG_QUALITY = 65 -_SCREEN_FRAME_GAP = 0.05 # 帧间最小间隔(秒),防止空转烧 CPU - - -@app.route("/api/screen/stream") -@perm_required(PERM_DEVICES) -def api_screen_stream(): - """远程看屏:MJPEG 实时画面流(multipart/x-mixed-replace)。 - - ?serial=xxx 设备 serial - 浏览器 直接渲染;客户端断开(GeneratorExit)自动停止,不占资源。 - 设备离线/atx-agent 无响应时流自然结束,前端提示重新连接。 - """ - serial = request.args.get("serial", "") - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - - def generate(): - import io - import uiautomator2 as u2 - try: - d = u2.connect(serial) - except Exception as e: - _log.warning(f"远程看屏 {serial} u2 连接失败: {e}") - return - while True: - try: - img = d.screenshot() - if img is None: - break - if img.mode != "RGB": - img = img.convert("RGB") - buf = io.BytesIO() - img.save(buf, "JPEG", quality=_SCREEN_JPEG_QUALITY) - yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" - + buf.getvalue() + b"\r\n") - except GeneratorExit: - break - except Exception as e: - _log.debug(f"远程看屏 {serial} 流中断: {e}") - break - time.sleep(_SCREEN_FRAME_GAP) - - return Response(generate(), - mimetype="multipart/x-mixed-replace; boundary=frame") - - -def _screen_get_device(serial): - """远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。""" - import uiautomator2 as u2 - return u2.connect(serial) - - - - -@app.route("/api/screen/thumb") -@perm_required(PERM_DEVICES) -def api_screen_thumb(): - """大屏缩略图:单张 JPEG(360px 宽,质量 55),供监控大屏轮询。 - - 一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。 - 大屏按需轮询:每设备约 2.5s 一帧,20 台 ≈ 0.2 核 CPU + 100KB/s。 - 响应头 X-Screen-State: on/off/unknown —— 大屏据此显示「亮屏中/熄屏中」。 - """ - serial = request.args.get("serial", "") - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - import io - import uiautomator2 as u2 - try: - d = u2.connect(serial) - img = d.screenshot() - if img is None: - return jsonify({"ok": False, "error": "截图失败"}), 503 - if img.width > 360: - ratio = 360 / img.width - img = img.resize((360, int(img.height * ratio))) - buf = io.BytesIO() - img.convert("RGB").save(buf, "JPEG", quality=55) - return Response(buf.getvalue(), mimetype="image/jpeg", - headers={"Cache-Control": "no-store", - "X-Screen-State": _device_screen_state(serial)}) - except Exception as e: - return jsonify({"ok": False, "error": str(e)}), 503 - - -def _device_screen_state(serial): - """设备屏幕状态:on/off/unknown(dumpsys display 只读查询)。 - - USB 设备(远程 adb server)查询失败返回 unknown,不影响缩略图。 - """ - try: - if ":" not in serial: - return "unknown" - out = _adb("-s", serial, "shell", "dumpsys", "display") or "" - m = re.search(r"m(?:Global)?ScreenState=(\w+)", out) - if m: - return "on" if m.group(1).upper() == "ON" else "off" - except Exception: - pass - return "unknown" - -@app.route("/api/screen/tap", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_screen_tap(): - """点击:{serial, x, y}(设备原生分辨率坐标)。""" - data = request.json or {} - serial, x, y = data.get("serial", ""), data.get("x"), data.get("y") - if not serial or x is None or y is None: - return jsonify({"ok": False, "error": "缺少 serial/x/y"}), 400 - try: - _screen_get_device(serial).click(int(x), int(y)) - return jsonify({"ok": True}) - except Exception as e: - return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503 - - -@app.route("/api/screen/swipe", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_screen_swipe(): - """滑动:{serial, x1, y1, x2, y2, duration?}(设备原生分辨率坐标)。""" - data = request.json or {} - serial = data.get("serial", "") - x1, y1, x2, y2 = (data.get(k) for k in ("x1", "y1", "x2", "y2")) - if not serial or None in (x1, y1, x2, y2): - return jsonify({"ok": False, "error": "缺少 serial/x1/y1/x2/y2"}), 400 - try: - _screen_get_device(serial).swipe(int(x1), int(y1), int(x2), int(y2), - duration=float(data.get("duration", 0.2))) - return jsonify({"ok": True}) - except Exception as e: - return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503 - - -_SCREEN_KEYS = {"back", "home", "recent", "menu", "power", "volume_up", - "volume_down", "enter", "delete", "search", "camera"} - - -@app.route("/api/screen/key", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_screen_key(): - """按键:{serial, key}(back/home/recent/menu/power 等,见 _SCREEN_KEYS)。""" - data = request.json or {} - serial, key = data.get("serial", ""), (data.get("key") or "").strip().lower() - if not serial or key not in _SCREEN_KEYS: - return jsonify({"ok": False, "error": "缺少 serial 或不支持的按键"}), 400 - try: - _screen_get_device(serial).press(key) - return jsonify({"ok": True}) - except Exception as e: - return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503 - - -@app.route("/api/screen/text", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_screen_text(): - """输入文字:{serial, text}(需焦点在输入框,u2 send_keys)。""" - data = request.json or {} - serial, text = data.get("serial", ""), (data.get("text") or "").strip() - if not serial or not text: - return jsonify({"ok": False, "error": "缺少 serial/text"}), 400 - try: - _screen_get_device(serial).send_keys(text) - return jsonify({"ok": True}) - except Exception as e: - return jsonify({"ok": False, "error": f"输入失败: {e}"}), 503 - - -# ================== API:应用管理(APK 上传/安装)================== -@app.route("/api/apks") -@login_required -def api_apks_list(): - """列出所有已上传的 APK。""" - return jsonify({"ok": True, "apks": apk_mgr.list_all()}) - - -@app.route("/api/apks/upload", methods=["POST"]) -@perm_required(PERM_APKS) -def api_apks_upload(): - """上传 APK 文件(multipart/form-data,字段名 file)。""" - file = request.files.get("file") - if not file or not file.filename: - return jsonify({"ok": False, "error": "未选择文件"}), 400 - info = apk_mgr.upload(file) - if info: - return jsonify({"ok": True, "apk": info, - "msg": f"上传成功: {info['display_name']}"}) - return jsonify({"ok": False, "error": "上传失败,请检查文件格式"}), 500 - - -@app.route("/api/apks/", methods=["DELETE"]) -@perm_required(PERM_APKS) -def api_apks_delete(apk_id): - """删除 APK 文件和记录。""" - ok, msg = apk_mgr.delete(apk_id) - if ok: - return jsonify({"ok": True, "msg": msg}) - return jsonify({"ok": False, "error": msg}), 400 - - -@app.route("/api/apks/install/devices") -@perm_required(PERM_APKS) -def api_apks_install_devices(): - """可安装设备列表(应用管理):设备池在线设备 + 本机 adb 设备(含 USB 有线连接)。 - - source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。 - """ - devices, seen = [], set() - try: - online = set(device_pool.list_online()) - for serial in device_pool.list_configured(): - if serial in online: - devices.append({"serial": serial, "model": "", "source": "pool"}) - seen.add(serial) - except Exception: - pass - out = _adb("devices") - for line in (out or "").splitlines()[1:]: - parts = line.split() - if len(parts) >= 2 and parts[0] and parts[0] not in seen: - devices.append({"serial": parts[0], "model": "", - "source": "usb" if ":" not in parts[0] else "adb"}) - seen.add(parts[0]) - return jsonify({"ok": True, "devices": devices}) - - -@app.route("/api/apks/install", methods=["POST"]) -@perm_required(PERM_APKS) -def api_apks_install(): - """批量安装 APK 到指定设备。参数: {apk_id, serials:[]}""" - data = request.json or {} - apk_id = data.get("apk_id", "") - serials = data.get("serials", []) - ok, msg = apk_mgr.install(apk_id, serials) - if ok: - return jsonify({"ok": True, "msg": msg}) - return jsonify({"ok": False, "error": msg}), 400 - - -@app.route("/api/apks/install/status") -@login_required -def api_apks_install_status(): - """获取安装任务实时状态。""" - status = apk_mgr.get_install_status() - return jsonify({"ok": True, "status": status}) - - -# ================== API:维护(仅管理员) ================== -# 维护功能:STF 容器一键重启 + adb 远程终端。两个接口都仅管理员可用。 -# adb 终端安全红线(技术约束):绝不 kill-server / 绝不 disconnect IP:5555—— -# STF provider 共享该 adb transport,断开会让 STF 误判全部设备离线并触发重连。 -_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect") - - -def _blocked_adb_cmd(cmd): - """命中红线的 adb 命令(kill-server / disconnect)直接拒绝。""" - low = cmd.lower() - return any(p in low for p in _ADB_BLOCKED_PATTERNS) - - -def _merged_device_list(): - """本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 - - 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 - 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接; - stf_not_ready=STF agent 未就绪(迁移期保留,阶段 3 摘除 STF 后删除)。 - """ - states = {} - out = _adb("devices") - for line in (out or "").splitlines()[1:]: - parts = line.split() - if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): - states[parts[0]] = parts[1] - try: - for serial in device_pool.list_configured(): - if serial not in states: - states[serial] = "pool" - except Exception: - pass - return [{"serial": s, "state": st} for s, st in sorted(states.items())] - - -@app.route("/api/adb/devices") -@admin_required -def api_adb_devices(): - """维护终端设备列表(仅管理员):本地 adb 已连接 + 设备池(SQLite)。 - - 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 - 供终端设备选择器使用——选中后自动附加 `-s `, - 离线/未连接的设备也可选,配合"重连设备"按钮恢复。 - """ - return jsonify({"ok": True, "devices": _merged_device_list()}) - - -@app.route("/api/adb/cmd", methods=["POST"]) -@admin_required -def api_adb_cmd(): - """维护终端:执行 adb 命令(仅管理员)。 - - 请求: {"cmd": "adb -s 100.100.10.11:5555 shell ls /sdcard"} - 用平台 adb 二进制执行(config.ADB_PATH),带 20s 超时。 - """ - data = request.json or {} - cmd = (data.get("cmd") or "").strip() - if not cmd: - return jsonify({"ok": False, "error": "命令不能为空"}), 400 - if _blocked_adb_cmd(cmd): - return jsonify({"ok": False, - "error": "禁止执行 kill-server / disconnect(会中断 STF 设备监控,影响所有任务)"}), 400 - tokens = shlex.split(cmd) - if tokens and tokens[0] == "adb": - tokens = tokens[1:] - if not tokens: - return jsonify({"ok": False, "error": "命令不能为空"}), 400 - try: - # 与 worker 的 adb 调用共用锁,避免并发操作同一 adb server - with _ADB_LOCK: - r = subprocess.run([ADB_PATH, *tokens], capture_output=True, timeout=20) - out = (r.stdout or b"").decode("utf-8", errors="replace") - err = (r.stderr or b"").decode("utf-8", errors="replace") - _log.info(f"维护终端执行 adb {' '.join(tokens)} -> exit {r.returncode}") - return jsonify({"ok": True, "stdout": out, "stderr": err, "code": r.returncode}) - except subprocess.TimeoutExpired: - return jsonify({"ok": False, "error": "命令执行超时(20s)"}), 408 - except Exception as e: - _log.warning(f"维护终端 adb 执行异常: {e}") - return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500 - - -@app.route("/api/tools/clipboard/set", methods=["POST"]) -@admin_required -def api_tools_clipboard_set(): - """工具-剪贴板注入:把指定文字写入一台或多台设备的剪贴板。 - - 请求: {"serials": ["100.100.10.11:5555", ...], "text": "要注入的文字"} - 设备来源与维护终端一致:本地 adb 已连接设备(含 USB)+ 设备池。 - 实现:u2 jsonrpc.setClipboard(实测 cmd clipboard 在 MIUI 上不存在)。 - """ - data = request.json or {} - serials = data.get("serials") or [] - text = (data.get("text") or "").strip() - if not isinstance(serials, list) or not serials: - return jsonify({"ok": False, "error": "未选择设备"}), 400 - if not text: - return jsonify({"ok": False, "error": "注入内容不能为空"}), 400 - results = {} - for serial in serials: - ok, msg = _set_device_clipboard(serial, text) - results[serial] = {"ok": ok, "msg": msg} - ok_count = sum(1 for v in results.values() if v["ok"]) - fail_count = len(results) - ok_count - return jsonify({"ok": True, "results": results, - "ok_count": ok_count, "fail_count": fail_count, - "error": None if ok_count == len(results) else f"{fail_count} 台设备注入失败"}) - - -def _set_device_clipboard(serial, text): - """向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。 - - - IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线) - - USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent - - 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求 - """ - import uiautomator2 as u2 - if ":" in serial: - try: - adb_connect(serial) - except Exception as e: - return False, f"adb 连接失败: {e}" - d = None - try: - with ThreadPoolExecutor(max_workers=1) as pool: - d = pool.submit(u2.connect, serial).result(timeout=30) - with ThreadPoolExecutor(max_workers=1) as pool: - pool.submit(d.set_clipboard, text).result(timeout=15) - return True, "已注入" - except FuturesTimeout: - return False, "连接/注入超时(atx-agent 可能无响应)" - except Exception as e: - return False, f"{type(e).__name__}: {str(e)[:120]}" - - -def _app_ver_on_device(serial, pkg): - """单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。 - - IP:port 设备先轻量 adb connect(单次尝试,绝不 disconnect,红线)。 - 查询是只读 shell 命令,无需 adb server 锁(锁只保护 connect/kill-server 类操作)。 - """ - if ":" in serial: - try: - adb_connect_light(serial) - except Exception as e: - return {"installed": False, "error": f"adb 连接失败: {e}"} - try: - r = subprocess.run([ADB_PATH, "-s", serial, "shell", "pm", "path", pkg], - capture_output=True, timeout=15) - if b"package:" not in (r.stdout or b""): - return {"installed": False, "version_name": "", "version_code": ""} - r2 = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "package", pkg], - capture_output=True, timeout=20) - out2 = (r2.stdout or b"").decode("utf-8", errors="replace") - vm = re.search(r"versionName=(\S+)", out2) - vc = re.search(r"versionCode=(\d+)", out2) - return {"installed": True, - "version_name": vm.group(1) if vm else "", - "version_code": vc.group(1) if vc else ""} - except subprocess.TimeoutExpired: - return {"installed": False, "error": "查询超时"} - except Exception as e: - return {"installed": False, "error": str(e)} - - -@app.route("/api/tools/appver", methods=["POST"]) -@admin_required -def api_tools_appver(): - """工具-应用版本管理:查询所有设备上指定包名的安装情况与版本号。 - - 请求: {"pkg": "com.ss.android.ugc.aweme"} - 设备来源:本地 adb(含 USB)+ 设备池,并发查询(最多 10 台同时)。 - """ - pkg = (request.json or {}).get("pkg", "").strip() - if not re.match(r"^[A-Za-z0-9_.]+$", pkg or ""): - return jsonify({"ok": False, "error": "包名格式不正确(仅字母/数字/._)"}), 400 - # 只查在线设备:池内离线条目(如陈旧记录)不连,避免 connect 重试拖慢查询 - try: - online = set(device_pool.list_online()) - devices = [d for d in _merged_device_list() if d["serial"] in online] - except Exception: - devices = [] - if not devices: - return jsonify({"ok": False, "error": "无在线设备"}), 404 - results = {} - with ThreadPoolExecutor(max_workers=min(10, len(devices))) as pool: - futures = {pool.submit(_app_ver_on_device, d["serial"], pkg): d for d in devices} - for fut in as_completed(futures, timeout=90): - d = futures[fut] - try: - results[d["serial"]] = fut.result() - except Exception as e: - results[d["serial"]] = {"installed": False, "error": str(e)} - fail = sum(1 for v in results.values() if v.get("error")) - return jsonify({"ok": True, "results": results, "fail": fail, - "total": len(results)}) - - -@app.route("/api/stf/devmgmt/delete", methods=["POST"]) -@admin_required -def api_stf_devmgmt_delete(): - """STF 设备管理:彻底删除(脚本移除 + disconnect + 清 STF 池记录)。 - - 迁移期桥接:同步从本地设备池删除。阶段 3 此接口退役。 - """ - ip = (request.json or {}).get("ip", "").strip() - if not ip: - return jsonify({"ok": False, "error": "请输入设备 IP"}), 400 - try: - msgs = stf_device_mgmt.delete_device(ip, stf=stf) - except StfDevError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - try: - device_pool.remove_device(f"{ip}:5555" if ":" not in ip else ip) - except Exception: - pass - return jsonify({"ok": True, "msgs": msgs}) - - -@app.route("/api/stf/devmgmt/reconnect", methods=["POST"]) -@admin_required -def api_stf_devmgmt_reconnect(): - """STF 设备管理:一键重连(手动运行 220 的 connect_devices.sh)。""" - try: - ok, summary = stf_device_mgmt.reconnect_all() - return jsonify({"ok": True, "output": summary}) - except StfDevError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - - -# ================== API:设备池管理(本地 SQLite 清单,新增设备的规范化入口) ================== - - -@app.route("/api/devices/pool", methods=["GET"]) -@perm_required(PERM_DEVICES) -def api_devices_pool_list(): - """设备池清单(SQLite,含实时在线状态)。""" - try: - online = set(device_pool.list_online()) - except Exception: - online = set() - rows = device_pool.list_devices() - for r in rows: - r["online"] = r["serial"] in online - return jsonify({"ok": True, "devices": rows}) - - -@app.route("/api/devices/pool/add", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_devices_pool_add(): - """添加/更新设备:{serial, name?, note?}。 - - IP:5555 设备添加后立即尝试 adb connect(不可达显示离线,不影响其他设备)。 - """ - data = request.json or {} - serial = (data.get("serial") or "").strip() - if not serial: - return jsonify({"ok": False, "error": "请输入设备 serial(如 100.100.10.20:5555)"}), 400 - is_new = device_pool.add_device(serial, - name=(data.get("name") or "").strip(), - note=(data.get("note") or "").strip()) - msg = "已添加" if is_new else "已更新" - if ":" in serial: - try: - adb_connect(serial) - except Exception: - pass - # 后台采集型号(不阻塞添加响应) - import threading - threading.Thread(target=device_pool.refresh_model, args=(serial,), - daemon=True).start() - _log.info(f"设备池管理: {msg} {serial}") - return jsonify({"ok": True, "msg": msg, "is_new": is_new}) - - -@app.route("/api/devices/pool/remove", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_devices_pool_remove(): - """从设备池删除(不再参与调度;不影响其他系统)。""" - serial = (request.json or {}).get("serial", "").strip() - if not serial: - return jsonify({"ok": False, "error": "缺少 serial"}), 400 - ok = device_pool.remove_device(serial) - if not ok: - return jsonify({"ok": False, "error": "设备不存在"}), 404 - return jsonify({"ok": True, "msg": "已删除"}) - - -@app.route("/api/devices/pool/toggle", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_devices_pool_toggle(): - """启用/停用设备(停用后不参与调度)。""" - data = request.json or {} - serial = (data.get("serial") or "").strip() - enabled = data.get("enabled") - if not serial or enabled is None: - return jsonify({"ok": False, "error": "缺少参数"}), 400 - ok = device_pool.set_enabled(serial, bool(enabled)) - if not ok: - return jsonify({"ok": False, "error": "设备不存在"}), 404 - return jsonify({"ok": True, "msg": "已" + ("启用" if enabled else "停用")}) - - -@app.route("/api/devices/pool/reconnect", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_devices_pool_reconnect(): - """一键重连:并发 adb connect 池内全部 IP:5555 设备(后台执行,不阻塞)。""" - def _run(): - try: - serials = [s for s in device_pool.list_configured() if ":" in s] - except Exception: - return - if not serials: - return - from concurrent.futures import ThreadPoolExecutor, as_completed - with ThreadPoolExecutor(max_workers=10) as pool: - futures = {pool.submit(adb_connect, s): s for s in serials} - for _ in as_completed(futures): - pass - _log.info(f"设备池一键重连完成({len(serials)} 台)") - # 重连后顺手刷新型号 - try: - device_pool.refresh_all_models() - except Exception: - pass - import threading - threading.Thread(target=_run, daemon=True).start() - return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"}) - - -@app.route("/api/devices/pool/refresh_models", methods=["POST"]) -@perm_required(PERM_DEVICES) -def api_devices_pool_refresh_models(): - """批量采集池内在线设备的型号(后台执行,不阻塞)。""" - import threading - threading.Thread(target=device_pool.refresh_all_models, daemon=True).start() - return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"}) - - -# ================== API:Tailscale 管理(仅管理员) ================== -# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。 -# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。 - - -@app.route("/api/tailscale/status") -@admin_required -def api_tailscale_status(): - """Tailscale 管理配置状态(API key / tailnet 是否已配置)。""" - c = tailscale_client.tailscale - return jsonify({"ok": True, "configured": c.is_configured(), - "hint": c.config_hint() if not c.is_configured() else ""}) - - -@app.route("/api/tailscale/devices") -@admin_required -def api_tailscale_devices(): - """列出 tailnet 全部设备。""" - try: - return jsonify({"ok": True, "devices": tailscale_client.tailscale.list_devices()}) - except TailscaleError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - - -@app.route("/api/tailscale/devices/", methods=["POST"]) -@admin_required -def api_tailscale_device_update(device_id): - """更新设备:按传入字段分发到专属端点(改名 /name、授权 /authorized、密钥 /key)。""" - data = request.json or {} - c = tailscale_client.tailscale - try: - if "name" in data: - c.set_device_name(device_id, data.get("name")) - if "authorized" in data: - c.set_device_authorized(device_id, data.get("authorized")) - if "key_expiry_disabled" in data: - c.set_device_key_expiry(device_id, data.get("key_expiry_disabled")) - _log.info(f"Tailscale 设备更新 {device_id}: {data}") - return jsonify({"ok": True, "msg": "设备已更新"}) - except TailscaleError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - - -@app.route("/api/tailscale/devices//ip", methods=["POST"]) -@admin_required -def api_tailscale_device_ip(device_id): - """设置设备 IPv4 地址(未公开端点,实测可用)。 - - ⚠ 改 IP 会断开设备当前 tailscale 会话,且平台设备池 serial 随之变化, - 改完需同步更新 STF 设备池(分组/任务目标里的旧 IP 会失效)。 - """ - data = request.json or {} - ipv4 = (data.get("ipv4") or "").strip() - if not ipv4: - return jsonify({"ok": False, "error": "缺少 ipv4"}), 400 - try: - tailscale_client.tailscale.set_device_ip(device_id, ipv4) - _log.info(f"Tailscale 设备 {device_id} IP 已设置为 {ipv4}") - return jsonify({"ok": True, "msg": f"设备 IP 已设置为 {ipv4}"}) - except TailscaleError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - - -@app.route("/api/tailscale/devices/", methods=["DELETE"]) -@admin_required -def api_tailscale_device_delete(device_id): - """从 tailnet 移除设备。""" - try: - tailscale_client.tailscale.delete_device(device_id) - _log.info(f"Tailscale 设备已移除: {device_id}") - return jsonify({"ok": True, "msg": "设备已从 tailnet 移除"}) - except TailscaleError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - - -@app.route("/api/tailscale/authkey", methods=["POST"]) -@admin_required -def api_tailscale_authkey(): - """生成设备接入 auth key(key 只显示一次)。""" - data = request.json or {} - try: - # Tailscale 要求 description 仅 ASCII(中文会 400) - desc = re.sub(r"[^\x20-\x7e]", "", (data.get("description") or "").strip()) - key = tailscale_client.tailscale.create_auth_key( - description=desc or "auto_control", - reusable=bool(data.get("reusable", False)), - ephemeral=bool(data.get("ephemeral", False)), - preauthorized=data.get("preauthorized", True), - expiry_seconds=int(data.get("expiry_seconds", 3600))) - _log.info(f"Tailscale auth key 已生成: {key['id']}") - return jsonify({"ok": True, "key": key["key"], "id": key["id"], - "expires": key["expires"]}) - except TailscaleError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - - -# ================== uiautodev 自动启动 ================== -_uiauto_proc = None -# PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留, -# 用 PID 文件在下次启动时识别并清理,避免多次重启后堆积进程。 _UIAUTO_PID_FILE = os.path.join( os.path.dirname(os.path.abspath(__file__)), "data", "uiauto.pid") +_uiauto_proc = None def _kill_stale_uiauto():