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():