29 Commits
Author SHA1 Message Date
butubb 441ff7c7da feat: 设备分组移入工具页(原顶级 tab 删除)——工具页新增「设备分组」子分栏,分组管理入口与其他设备工具聚合 2026-09-04 10:15:44 +08:00
butubb 0b6b000b01 feat: 剪贴板注入面板只显示在线设备(pool/离线条目无注入意义);adb 终端面板保留全部供重连 2026-09-04 10:12:17 +08:00
butubb d3ccc4a7a8 chore: users.db 为运行时数据库(容器实时写入导致 git pull 一直冲突),移除 git 跟踪并加入 .gitignore 2026-09-04 00:11:14 +00:00
butubb 88f4d09d4c chore: 清理 DataEase 误解压残留;users.db 移除跟踪(运行时数据),.gitignore 忽略 data/ logs/ 2026-09-04 08:11:05 +08:00
butubb 7276cfe585 fix: 待连接池后端过滤离线设备——list_pending 只返回当前在线设备(离线候选不可确认),不依赖前端 JS 缓存版本,任何客户端都拿不到离线条目 2026-09-04 08:08:52 +08:00
butubb 1a74372ad9 fix: 任务调度限定设备池——preempt 抢占模式的 all 目标原用 list_online()(含待连接/未入池设备,自动发现验证连上的设备会被任务误跑);改为设备池内在线设备;group 模式同样过滤未入池设备(serial 模式保留显式指定) 2026-09-01 09:07:12 +08:00
butubb 22ba9272bd feat: 全站默认只看在线——监控页/大屏/待连接池同步过滤离线设备(可切换查看全部);18051 main 旧实例已停止(STF 已删,旧代码无保留意义) 2026-09-01 08:58:21 +08:00
butubb ac7997176d feat: 设备池默认只看在线——离线旧条目(IP 已变的幽灵设备)默认隐藏,在线优先排序;「只看在线」开关可切换查看全部(含离线/停用) 2026-09-01 08:54:06 +08:00
butubb b9fab1999d fix: 自动发现按钮引号转义语法错误——onclick 内嵌单引号拼接在生成时丢一层转义(Unexpected string 导致整个 tools.js 不执行,设备池/发现全不渲染);改为 dataset 传参彻底绕开嵌套引号 2026-08-31 15:38:33 +08:00
butubb 4efe17c1c9 feat: 设备自动发现——定时扫描局域网+Tailscale 网段开放 adb 5555 的设备进入待连接池,用户确认后才加入正式设备池(不自动连接);socket 并发探测+adb 短超时验证(state=device 过滤 unauthorized),配置存 app_meta 可前端调整;配套:adb_connect_light 验证短超时、DISABLE_SCHEDULER 回归开关 2026-08-30 10:57:07 +08:00
butubb 1e39f5490a fix: 大屏触控优化——①点击错位:ctrl-img object-fit:contain 时图片绘制区小于盒子(居中黑边),坐标换算改为按实际绘制区域,黑边点击忽略;②滑动卡顿:tap/swipe/key/text 每次 u2.connect 重建连接(1-3s 握手),加 60s 连接缓存,失败自动清缓存重建(实测点击 2.97s→1.13s) 2026-08-20 13:22:21 +08:00
butubb 31a2ae911e fix: uiautodev 自动启动失败 name sys is not defined——_ensure_uiauto_running 用 sys.platform 但 web_server.py 未 import sys;补 import,元素抓取/动作编辑器设备列表恢复 2026-08-20 11:09:25 +08:00
butubb 90ca2c2100 fix: 任务动作编辑器剪贴板注入改用 ClipInject 通道——u2 set_clipboard 在 Android 10+ 静默失效(粘贴模式必抛 UiAutomationError);提取 core/clipboard_helper 公共模块供工具页与任务执行端共用,粘贴模式 atx-agent pasteClipboard + set_text 兜底 2026-08-20 10:51:54 +08:00
butubb 2a3c748964 fix: 工具页剪贴板注入无反应——injectClipboard 函数在阶段3清理时被误删,点击按钮 JS 报错;补全函数(校验→ClipInject 通道 API→逐台结果表+toast 汇总),帮助文案同步更新 2026-08-20 10:40:49 +08:00
butubb 5d637bd4f5 fix: 剪贴板注入改用 ClipInject 通道——u2.set_clipboard 在 Android 10+ 被系统静默丢弃(atx-agent 后台身份写剪贴板受限),改为 am start 透明 Activity(shell 身份启动不受后台限制),注入后 u2 读回比对验证,未装 APK 明确报错 2026-08-20 10:34:53 +08:00
butubb 81a99e007b feat: 启动时后台并发预连接设备池——adb server 重启后设备全掉线,adb 命令排队卡几分钟;预连接(并发 connect IP:5555)把恢复时间降到秒级 2026-08-20 09:33:24 +08:00
butubb f78955f119 fix: start.sh 增加 cv2 空壳兜底——pip 24.0 解压 abi3 wheel 偶发丢根文件(cv2.abi3.so/__init__.py 缺失致 OCR 全挂),检测后 pip download + zipfile 手动解压(幂等) 2026-08-20 08:51:02 +08:00
butubb 27530ff5ac fix: 手动执行不再受运行窗口限制(窗口只约束定时触发)——立即执行按钮在窗口外也可触发任务 2026-08-20 08:36:29 +08:00
butubb 0fab6f4de8 fix: opencv-python-headless 锁 4.x——清华源 5.0.0.93 wheel 打包异常(site-packages 无 cv2 模块),OCR 仍不可用 2026-08-19 16:23:36 +08:00
butubb 5de87465bc fix: start.sh 修复顺序——rapidocr 依赖声明 opencv-python 会被 pip 强制装回 GUI 版,uninstall 必须在 pip install 之后最后执行 2026-08-19 16:17:28 +08:00
butubb b33ff17c26 feat: 容器启动脚本 scripts/start.sh——幂等卸载 opencv-python(GUI 版缺 X11 库致 OCR 崩)并装 headless,compose 指向本脚本后环境修复全走 git 2026-08-19 16:14:15 +08:00
butubb c0667c5959 fix: 容器环境 OCR 不可用——opencv-python 依赖 X11(libxcb)在 slim 容器 import 崩溃致条件判断 OCR 步骤任务退出,改为 opencv-python-headless(本地 Mac 不受影响) 2026-08-19 16:11:20 +08:00
butubb d0cb531c20 fix: 未登录访问 500——login_view 蓝图化后未更新为 auth.login,flask_login 重定向 url_for('login') BuildError 2026-08-19 16:02:47 +08:00
butubb 42c2820180 docs: requirements 注释清理(STF 时代描述 → 当前架构) 2026-08-19 15:51:57 +08:00
butubb a9720e8b81 fix: 抢占归还丢失——preempted_job 在重试循环内被重置为 None,关键字任务一旦重试(attempt≥2)被抢占的养号任务永不恢复;提升到循环外+已有值不覆盖+归还时被抢占任务停用/删除也打日志 2026-08-19 15:46:18 +08:00
butubb e0c3373dd1 feat: 全量路由回归测试脚本 scripts/regression_test.py(GET 全量 + 写操作空 body 探测抓 import 遗漏 + 关键业务 POST,500=缺陷 502/503=业务提示)+ fix: clear_all_worker_errors 缺导入(回归脚本首跑即抓到) 2026-08-19 15:24:11 +08:00
butubb 2adc54b512 fix: adb 相关功能全挂——tools_api 缺 ADB_PATH/_ADB_BLOCKED_PATTERNS 导入(模块拆分遗漏),应用版本/adb终端/剪贴板恢复,全量 28 个 GET 路由回归通过 2026-08-19 15:12:42 +08:00
butubb a0916ebda2 fix: 抓取元素回填垃圾选择器——无属性元素生成 //*[N](匹配任意节点点不到),改为 invalid 标记前端提示不可选;结构路径跳过无 class 层消除 //*[N] 前缀污染;模块拆分遗漏修复(get_all_worker_status/list_installed_apps 致 summary/设备已装应用 500) 2026-08-19 15:10:26 +08:00
butubb 265e47a1f4 fix: 测试此步骤弹窗全链路修复——closeTestModal 恢复(阶段3误删导致关不掉)、test-overlay 层级 1200(高于编辑器/元素抓取弹窗)、tasks_api 补 _log(后端500)、GenericStepsWorker 旧签名调用修复(测试执行失败) 2026-08-19 15:00:27 +08:00
25 changed files with 1189 additions and 109 deletions
+3
View File
@@ -45,3 +45,6 @@ bin/adb/*
# 数据备份与损坏库(运维临时文件,不入库) # 数据备份与损坏库(运维临时文件,不入库)
data/db_backup_*/ data/db_backup_*/
data/*.corrupt_* data/*.corrupt_*
data/users.db*
data/users.db-wal
data/users.db-shm
+7
View File
@@ -82,3 +82,10 @@ USB_ADB_PORT = int(_env("USB_ADB_PORT", "5037"))
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。 # 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "") TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
# ================== 设备自动发现 ==================
# 定时扫描网段中开放 adb 5555 的设备 → 待连接池(用户确认后才加入正式池)。
# 网段默认:局域网 + Tailscale;可在工具页设备池面板修改(存 app_meta)。
DISCOVERY_PORT = int(_env("DISCOVERY_PORT", "5555"))
DISCOVERY_SUBNETS = ["192.168.20.0/24", "100.100.10.0/24"]
DISCOVERY_INTERVAL = int(_env("DISCOVERY_INTERVAL", "60"))
+9 -6
View File
@@ -19,17 +19,18 @@ _ADB_LOCK = threading.Lock()
_ADB_TIMEOUT = 30 _ADB_TIMEOUT = 30
def _adb(*args): def _adb(*args, timeout=_ADB_TIMEOUT):
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。 """执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出, 用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
遇到非 GBK 字节会崩溃)。 遇到非 GBK 字节会崩溃)。
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。 带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
timeout 可覆盖(扫描验证等非关键路径用短超时快速失败)。
""" """
try: try:
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=_ADB_TIMEOUT) r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s") _log.warning(f"adb {' '.join(args)} 超时 {timeout}s")
return "" return ""
return _decode(r) return _decode(r)
@@ -157,16 +158,18 @@ def _try_connect(remote_adb_url, retries):
return False return False
def adb_connect_light(remote_adb_url): def adb_connect_light(remote_adb_url, timeout=5):
"""轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。 """轻量 adb connect(扫描/验证专用):单次尝试,不重试,绝不 kill-server。
与 adb_connect 的区别: 与 adb_connect 的区别:
- 不重试:失败立即返回 False - 不重试:失败立即返回 False
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接 - 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
- 短超时(默认 5s):验证场景(设备发现)连不上的主机快速放弃,
避免每台卡 30s 拖慢整轮发现并长时间占用全局 adb 锁
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。 用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
""" """
with _ADB_LOCK: with _ADB_LOCK:
out = _adb("connect", remote_adb_url) out = _adb("connect", remote_adb_url, timeout=timeout)
if "connected" in out.lower() and "failed" not in out.lower(): if "connected" in out.lower() and "failed" not in out.lower():
return True return True
return False return False
+69
View File
@@ -0,0 +1,69 @@
"""剪贴板注入公共模块(ClipInject APK 通道)。
背景:Android 10+ 禁止后台(atx-agent/shell)写剪贴板——u2.set_clipboard
调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。ClipInject
(com.example.clipinject,用户自研 APK)通过 am start 启动透明 Activity,
窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受后台启动限制
(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。
工具页(web/tools_api.py)与任务执行器(tasks/generic/task.py)共用本模块。
"""
import base64
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from config import ADB_PATH
from core.adb_helper import adb_connect
from core.logger import get_logger
_log = get_logger("core.clip")
_CLIP_PACKAGE = "com.example.clipinject"
_CLIP_ACTIVITY = "com.example.clipinject/.ClipActivity"
def inject_clipboard(serial, text, d=None):
"""向设备剪贴板注入文字,返回 (ok, msg)。
- d:可选,任务执行器已持有 u2 Device 时传入复用(跳过重复连接),
读回验证直接用它;不传则临时 u2.connect(工具页场景,带超时保护)
- IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线)
- 未装 ClipInject / 读回不一致都会明确报错(不再静默失败)
"""
import uiautomator2 as u2
if ":" in serial:
try:
adb_connect(serial)
except Exception as e:
return False, f"adb 连接失败: {e}"
b64 = base64.b64encode(text.encode("utf-8")).decode()
# 1. am start 透明 Activity 写剪贴板(shell 身份,不受后台启动限制)
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "am", "start",
"-n", _CLIP_ACTIVITY, "--es", "text_b64", b64],
capture_output=True, timeout=30)
out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace")
if "does not exist" in out or "unable to resolve Intent" in out:
return False, f"设备未安装 ClipInject({_CLIP_PACKAGE}),请先安装"
if "Error" in out or ("Starting" not in out and r.returncode != 0):
return False, f"am start 失败: {out.strip()[:120]}"
except subprocess.TimeoutExpired:
return False, "am start 超时(adb 无响应)"
except Exception as e:
return False, f"{type(e).__name__}: {str(e)[:120]}"
# 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用)
time.sleep(0.5)
try:
if d is None:
with ThreadPoolExecutor(max_workers=1) as pool:
d = pool.submit(u2.connect, serial).result(timeout=30)
with ThreadPoolExecutor(max_workers=1) as pool:
got = pool.submit(lambda: d.clipboard).result(timeout=15)
if got == text:
return True, "已注入"
return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"
except Exception as e:
# 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
+340
View File
@@ -0,0 +1,340 @@
"""设备自动发现:扫描网段中开放 adb 5555 的设备 → 待连接池(pending)。
流程:socket 并发探测 5555(标准库,不碰 adb)→ 剔除已在设备池的 serial →
串行 adb 验证(adb_connect_light,内部全局锁)→ 用 `adb devices` 的
state=="device" 过滤(排除 unauthorized/offline,connect 输出不可信)→
写入 pending_device 表。**扫描只验证、不自动连接入池**——用户在前端确认后
才调 device_pool.add_device + adb_connect(见 web/devices_api.py)。
安全红线:绝不 kill-server / 绝不 disconnect(与全项目一致)。
配置存 app_meta KV 表(discovery_enabled/subnets/interval/port),定时线程
每轮重读,开关/周期/网段改动即时生效无需重启。
"""
import ipaddress
import json
import socket
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from config import (USB_ADB_HOST,
DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL)
from core.adb_helper import _adb, adb_connect_light
from core.logger import get_logger
from core.models import db, PendingDevice
_log = get_logger("core.disc")
# 扫描配置键(app_meta)
_K_ENABLED = "discovery_enabled"
_K_SUBNETS = "discovery_subnets"
_K_INTERVAL = "discovery_interval"
_K_PORT = "discovery_port"
# 单网段主机数上限(防误配 /8 之类),超过截断并告警
_MAX_HOSTS_PER_SUBNET = 1024
# socket 探测并发数与单 IP 超时
_PROBE_WORKERS = 64
_PROBE_TIMEOUT = 0.4
_app = None
_scan_lock = threading.Lock() # 定时/手动扫描互斥
_stop_event = threading.Event() # shutdown 用
_scanning = False # 状态快照(API/前端)
_last_scan = None # (时间串, 开放数, 可连数, 新增数)
_last_error = ""
def _ctx():
"""后台线程访问 db 时自行推 app context(仿 device_pool._ctx)。"""
return _app.app_context() if _app else None
def _fmt(ts=None):
return (ts or datetime.now()).strftime("%Y-%m-%d %H:%M")
# ================== 配置读写 ==================
def get_settings():
"""读发现配置(app_meta,缺键用 config 默认值补齐)。"""
with _ctx():
def _get(key, default):
v = db.session.execute(
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar()
return v if v is not None else default
try:
subnets = json.loads(_get(_K_SUBNETS, "[]")) or DISCOVERY_SUBNETS
except Exception:
subnets = DISCOVERY_SUBNETS
return {
"enabled": _get(_K_ENABLED, "1") == "1", # 默认开启
"subnets": subnets,
"interval": int(_get(_K_INTERVAL, str(DISCOVERY_INTERVAL)) or DISCOVERY_INTERVAL),
"port": int(_get(_K_PORT, str(DISCOVERY_PORT)) or DISCOVERY_PORT),
}
def save_settings(enabled=None, subnets=None, interval=None, port=None):
"""写发现配置(部分字段更新)。subnets 逐项校验 CIDR,非法返回 (False, 原因)。"""
if subnets is not None:
clean = []
for s in subnets:
s = (s or "").strip()
if not s:
continue
try:
ipaddress.ip_network(s, strict=False)
except ValueError:
return False, f"网段格式错误: {s}"
clean.append(s)
subnets = clean
try:
if interval is not None and (int(interval) < 10 or int(interval) > 3600):
return False, "扫描周期需在 10-3600 秒之间"
if port is not None and (int(port) < 1 or int(port) > 65535):
return False, "端口不合法"
except (TypeError, ValueError):
return False, "参数不合法"
with _ctx():
def _put(key, value):
db.session.execute(
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
{"k": key, "v": str(value)})
if enabled is not None:
_put(_K_ENABLED, "1" if enabled else "0")
if subnets is not None:
_put(_K_SUBNETS, json.dumps(subnets))
if interval is not None:
_put(_K_INTERVAL, str(int(interval)))
if port is not None:
_put(_K_PORT, str(int(port)))
db.session.commit()
return True, "已保存"
# ================== 网段展开与端口探测 ==================
def _expand_subnets(subnets, max_hosts=_MAX_HOSTS_PER_SUBNET):
"""CIDR 列表 → IP 列表。排除 220 自身(USB_ADB_HOST)与本机 IP;
非法网段跳过记日志;单网段超过 max_hosts 截断并告警。"""
self_ips = {USB_ADB_HOST}
try: # 本机自身 IP(接口枚举,尽力而为;无 hostname -I 的平台跳过)
import subprocess
out = subprocess.run(["hostname", "-I"], capture_output=True, text=True,
timeout=3).stdout or ""
self_ips.update(p for p in out.split() if p)
except Exception:
pass
ips = []
for cidr in subnets:
try:
net = ipaddress.ip_network((cidr or "").strip(), strict=False)
except ValueError:
_log.warning(f"发现: 跳过非法网段 {cidr}")
continue
hosts = [str(h) for h in net.hosts()
if str(h) not in self_ips]
if len(hosts) > max_hosts:
_log.warning(f"发现: 网段 {cidr} 主机数 {len(hosts)} 超过上限,截断前 {max_hosts}")
hosts = hosts[:max_hosts]
ips.extend(hosts)
return ips
def _probe_port(ip, port, timeout=_PROBE_TIMEOUT):
"""单 IP 端口探测(标准库 socket,超时/拒绝/不可达一律 False)。"""
try:
with socket.create_connection((ip, port), timeout=timeout):
return True
except Exception:
return False
def _probe_open(ips, port, workers=_PROBE_WORKERS):
"""并发探测,返回开放端口的主机列表。"""
if not ips:
return []
with ThreadPoolExecutor(max_workers=workers) as ex:
results = list(ex.map(lambda ip: (ip, _probe_port(ip, port)), ips))
return [ip for ip, ok in results if ok]
# ================== adb devices 解析 ==================
def _parse_adb_devices(out):
"""解析 `adb devices` 输出 → state=="device" 的 serial 集合。
只看 connect 输出不可靠(未授权设备也返回 connected),必须用 state 过滤。
"""
devices = set()
for line in (out or "").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
if parts[1] == "device":
devices.add(parts[0])
return devices
# ================== 扫描 ==================
def scan_once(manual=False):
"""执行一轮扫描。返回 (ok, result_dict);后台线程/API 调用。"""
global _scanning, _last_scan, _last_error
if not _scan_lock.acquire(blocking=False):
return False, {"error": "扫描进行中"}
_scanning = True
try:
settings = get_settings()
port = settings["port"]
subnets = settings["subnets"]
if not subnets:
_last_error = "未配置扫描网段"
return True, {"found": 0, "verified": 0, "new": 0, "error": _last_error}
# 1. socket 并发探测(不碰 adb,把几百台缩小到开放端口量级)
open_ips = _probe_open(_expand_subnets(subnets), port)
candidates = {f"{ip}:{port}" for ip in open_ips}
with _ctx():
# 2. 剔除已在正式设备池的 serial
configured = set(device_pool_list_configured())
candidates -= configured
# 3. 串行 adb 验证:已连接的直接跳过(零成本),其余 adb_connect_light
already = _parse_adb_devices(_adb("devices"))
for serial in candidates - already:
adb_connect_light(serial) # 内部全局锁串行;失败无妨,下面按 state 过滤
# 4. state==device 过滤(排除 unauthorized/offline)
verified = _parse_adb_devices(_adb("devices")) & candidates
# 5. 写 pending:新 → 插入;已有 → 更新 last_seen
now = _fmt()
existing = {p.serial for p in PendingDevice.query.all()}
added = 0
for serial in verified:
source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan"
if serial in existing:
PendingDevice.query.filter_by(serial=serial).update(
{"last_seen": now})
else:
db.session.add(PendingDevice(serial=serial, source=source,
first_seen=now, last_seen=now))
added += 1
db.session.commit()
_log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台,"
f"新增待连接 {added} 台")
_last_scan = (now, len(open_ips), len(verified), added)
_last_error = ""
return True, {"found": len(open_ips), "verified": len(verified), "new": added}
except Exception as e:
_log.warning(f"发现扫描异常: {e}")
_last_error = str(e)[:200]
return False, {"error": _last_error}
finally:
_scanning = False
_scan_lock.release()
def device_pool_list_configured():
"""设备池已配置 serial(延迟 import 避免循环依赖)。"""
from core import device_pool
return device_pool.list_configured()
# ================== 定时扫描线程 ==================
def _discovery_loop():
"""定时扫描 daemon 线程。每轮重读配置(开关/周期即时生效)。
启动先 sleep 15s 避让 web_server 预连接线程(两者都抢 adb 全局锁)。
等待用 30s 切片(_stop_event.wait),关停/改周期 ≤30s 生效。
"""
try:
_stop_event.wait(15)
while not _stop_event.is_set():
try:
settings = get_settings()
if settings["enabled"]:
scan_once()
except Exception as e:
_log.warning(f"发现定时扫描异常: {e}")
wait_s = max(10, settings.get("interval", 60))
waited = 0
while waited < wait_s and not _stop_event.is_set():
_stop_event.wait(min(30, wait_s - waited))
waited += min(30, wait_s - waited)
except Exception:
pass
def init_app(app):
"""web_server 启动时调用:绑 app + 起定时扫描 daemon 线程。"""
global _app
_app = app
_stop_event.clear()
t = threading.Thread(target=_discovery_loop, name="device-discovery", daemon=True)
t.start()
_log.info("设备自动发现线程已启动(默认 60s 扫描一次)")
def shutdown():
"""优雅退出:置 stop_event,等待中的循环在切片边界退出。"""
_stop_event.set()
# ================== 对外查询与确认 ==================
def get_status():
"""API 状态快照:配置 + 扫描状态 + 最近一轮结果 + 待连接数量。"""
settings = get_settings()
with _ctx():
pending = PendingDevice.query.count()
return {
"enabled": settings["enabled"],
"subnets": settings["subnets"],
"interval": settings["interval"],
"port": settings["port"],
"scanning": _scanning,
"last_scan": _last_scan[0] if _last_scan else "",
"last_result": ({"found": _last_scan[1], "verified": _last_scan[2],
"new": _last_scan[3]} if _last_scan else None),
"last_error": _last_error,
"pending_count": pending,
}
def list_pending():
"""待连接列表:只返回当前在线的设备(离线候选不可确认,不展示)。
后端直接过滤(2026-09-04):不依赖前端 JS 版本,任何客户端都拿不到
离线条目;设备恢复在线后扫描自动更新 last_seen 并重新出现在列表。
"""
online = _parse_adb_devices(_adb("devices"))
with _ctx():
rows = [p.to_dict() for p in PendingDevice.query.order_by(
PendingDevice.first_seen.desc()).all()]
return [dict(r, online=True) for r in rows if r["serial"] in online]
def confirm_pending(serial, name=""):
"""确认连接:pending 行 → 正式设备池(add_device upsert)→ 删 pending。
返回 (ok, msg, is_new)。adb_connect + 采型号由 API 层后台线程做。
"""
with _ctx():
row = PendingDevice.query.get(serial)
if not row:
return False, "设备不在待连接列表", False
from core import device_pool
is_new = device_pool.add_device(serial, name=name or "")
PendingDevice.query.filter_by(serial=serial).delete()
db.session.commit()
return True, "已加入设备池" + ("" if is_new else "(已存在,信息已更新)"), is_new
def ignore_pending(serial):
"""忽略:删除 pending 行(下轮扫描可能再次发现)。"""
with _ctx():
row = PendingDevice.query.get(serial)
if not row:
return False, "设备不在待连接列表"
PendingDevice.query.filter_by(serial=serial).delete()
db.session.commit()
return True, "已忽略"
+21
View File
@@ -238,6 +238,21 @@ class Device(db.Model):
return f"<Device {self.serial}>" return f"<Device {self.serial}>"
class PendingDevice(db.Model):
"""自动发现待连接池:扫描验证通过的设备,等待用户确认后才入正式池。
与 Device 表的区别:只代表"被扫描到、可连接",不参与任务调度。
"""
serial = db.Column(db.String(120), primary_key=True) # 如 192.168.20.5:5555
source = db.Column(db.String(20), default="") # lan / tailscale
first_seen = db.Column(db.String(20), default="") # 首次发现时间
last_seen = db.Column(db.String(20), default="") # 最近一次扫描仍可见的时间
def to_dict(self):
return {"serial": self.serial, "source": self.source or "",
"first_seen": self.first_seen or "", "last_seen": self.last_seen or ""}
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL) # 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
# 版本号单调递增,只执行比当前 schema_version 新的迁移。 # 版本号单调递增,只执行比当前 schema_version 新的迁移。
SCHEMA_MIGRATIONS = [ SCHEMA_MIGRATIONS = [
@@ -252,6 +267,12 @@ SCHEMA_MIGRATIONS = [
"created_at VARCHAR(20) DEFAULT '')"), "created_at VARCHAR(20) DEFAULT '')"),
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)", (3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"), "ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)",
"CREATE TABLE IF NOT EXISTS pending_device ("
"serial VARCHAR(120) PRIMARY KEY,"
"source VARCHAR(20) DEFAULT '',"
"first_seen VARCHAR(20) DEFAULT '',"
"last_seen VARCHAR(20) DEFAULT '')"),
] ]
+33 -10
View File
@@ -154,12 +154,24 @@ class TaskJob:
elif mode == "group": elif mode == "group":
g = manager.groups.get(self.target.get("group_name")) g = manager.groups.get(self.target.get("group_name"))
serials = list(g.serials) if g else [] serials = list(g.serials) if g else []
# 分组应引用设备池内设备:不在池内的(如手动填的旧 IP)不参与调度,
# 与 all/preempt 模式口径一致(2026-09-01)
try:
configured = set(device_pool.list_configured())
serials = [s for s in serials if s in configured]
except Exception:
pass
else: else:
# all:默认返回池内在线设备(单实例互斥由 _run_with_retry 的 _running 保证); # all:默认返回池内在线设备(单实例互斥由 _run_with_retry 的 _running 保证);
# 抢占模式返回全部在线设备(含运行中,执行时抢占) # 抢占模式返回全部在线设备(含运行中,执行时抢占)
if self.params.get("preempt"): if self.params.get("preempt"):
try: try:
return device_pool.list_online() # 抢占模式:只抢设备池内的在线设备——
# list_online() 会包含待连接/未入池设备(如自动发现验证时连上的),
# 它们不在设备池就不该被任务调度(2026-09-01 修复)
online = set(device_pool.list_online())
configured = set(device_pool.list_configured())
return [s for s in device_pool.list_online() if s in configured]
except Exception: except Exception:
return [] return []
try: try:
@@ -337,7 +349,12 @@ class TaskManager:
def __init__(self, app=None): def __init__(self, app=None):
self.app = app # Flask app,用于 db context self.app = app # Flask app,用于 db context
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai") self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
self.scheduler.start() if os.environ.get("DISABLE_SCHEDULER"):
# 测试/回归模式:不启动 cron 调度器,避免 test_client 加载任务后
# 按 cron 真的跑任务(干扰回归、占用设备)。add_job 仍可调用不报错。
_log.info("DISABLE_SCHEDULER=1:跳过 cron 调度器(测试/回归模式)")
else:
self.scheduler.start()
start_watchdog() # 启动心跳看门狗 start_watchdog() # 启动心跳看门狗
self.groups = {} # name -> DeviceGroup(内存业务对象) self.groups = {} # name -> DeviceGroup(内存业务对象)
self.jobs = {} # id -> TaskJob(内存业务对象) self.jobs = {} # id -> TaskJob(内存业务对象)
@@ -587,14 +604,10 @@ class TaskManager:
return _next_run_time(job.schedule) return _next_run_time(job.schedule)
def run_job_now(self, job_id): def run_job_now(self, job_id):
"""立即执行任务(手动触发)。运行窗口外拒绝启动。""" """立即执行任务(手动触发)。运行窗口只约束定时触发,手动执行不受限制。"""
job = self.jobs.get(job_id) job = self.jobs.get(job_id)
if not job: if not job:
return {"ok": False, "error": "任务不存在"} return {"ok": False, "error": "任务不存在"}
if not _in_run_window(job.schedule):
win = job.schedule.get("window") or {}
return {"ok": False,
"error": f"当前不在运行窗口内({win.get('start', '')}-{win.get('end', '')}),任务未启动"}
# 在独立线程跑,不阻塞调用方 # 在独立线程跑,不阻塞调用方
t = threading.Thread(target=self._run_job, args=(job,), daemon=True) t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
t.start() t.start()
@@ -636,6 +649,9 @@ class TaskManager:
# 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力 # 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力
if start_delay > 0: if start_delay > 0:
time.sleep(start_delay) time.sleep(start_delay)
# 被抢占的原任务 id(抢占结束后归还)——必须在循环外:
# 重试时若重置为 None,finally 归还逻辑会丢失信息,被抢占任务永不恢复
preempted_job = None
try: try:
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
# 用户已请求停止 → 不再启动新 attempt # 用户已请求停止 → 不再启动新 attempt
@@ -645,7 +661,6 @@ class TaskManager:
return return
# 同一 serial 同时只能一个 worker;不同任务可配置抢占 # 同一 serial 同时只能一个 worker;不同任务可配置抢占
preempt = False preempt = False
preempted_job = None # 被抢占的原任务 id(抢占结束后归还)
with self._lock: with self._lock:
if serial in self._running: if serial in self._running:
cur = self._running[serial] cur = self._running[serial]
@@ -658,7 +673,8 @@ class TaskManager:
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})") _log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
return return
preempt = True preempt = True
preempted_job = cur.get("job_id") if not preempted_job:
preempted_job = cur.get("job_id")
if preempt: if preempt:
# 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁, # 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁,
# 在锁内调用会死锁!),等其释放后接管 # 在锁内调用会死锁!),等其释放后接管
@@ -749,6 +765,10 @@ class TaskManager:
if j and j.enabled: if j and j.enabled:
_log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}") _log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}")
threading.Thread(target=self._run_job, args=(j,), daemon=True).start() threading.Thread(target=self._run_job, args=(j,), daemon=True).start()
else:
# 打日志便于排查:被抢占任务已删除/停用时不会归还,但要知道原因
_log.info(f"{serial} 抢占任务 {job.name} 结束,"
f"被抢占任务 {preempted_job} {'已停用,不归还' if j else '已不存在,不归还'}")
# ---- 运行控制 ---- # ---- 运行控制 ----
def stop_device(self, serial): def stop_device(self, serial):
@@ -878,4 +898,7 @@ class TaskManager:
self.stop_all() self.stop_all()
self._fg_scanner.stop() self._fg_scanner.stop()
stop_watchdog() stop_watchdog()
self.scheduler.shutdown(wait=False) try:
self.scheduler.shutdown(wait=False)
except Exception:
pass # DISABLE_SCHEDULER 模式下 scheduler 未 start,shutdown 会抛异常
+17 -5
View File
@@ -190,9 +190,14 @@ def _extract(root, out):
desc = props.get("content-desc", "") desc = props.get("content-desc", "")
cls = props.get("class", "") cls = props.get("class", "")
tag = cls.split(".")[-1] if cls else "*" tag = cls.split(".")[-1] if cls else "*"
# 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义 # 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义;
seg = f"{tag}[{tag_index}]" # class 为空的层(tag=*)不参与结构路径,避免 //*[N] 前缀污染导致选择器定位到任意节点
own_tag_path = f"{tag_path}/{seg}" if tag_path else seg if tag != "*":
seg = f"{tag}[{tag_index}]"
own_tag_path = f"{tag_path}/{seg}" if tag_path else seg
else:
seg = ""
own_tag_path = tag_path
# ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ---- # ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ----
if rid: if rid:
@@ -206,8 +211,15 @@ def _extract(root, out):
suggested = {"type": "xpath", "value": val} suggested = {"type": "xpath", "value": val}
child_ctx = val child_ctx = val
else: else:
# 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示) if tag == "*":
suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True} # class 也为空的元素:结构路径 //*[N] 无意义(匹配到文档任意节点),
# 标记 invalid,前端提示不可选,避免回填垃圾选择器导致"点击不到"
suggested = {"type": "xpath", "value": "",
"invalid": True,
"reason": "该元素无可用属性(id/文本/class),无法生成可靠选择器"}
else:
# 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示)
suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True}
child_ctx = None child_ctx = None
out.append({ out.append({
BIN
View File
Binary file not shown.
+5 -3
View File
@@ -15,10 +15,10 @@ Flask-SQLAlchemy>=3.0,<4.0
# 定时任务调度 # 定时任务调度
APScheduler>=3.10,<4.0 APScheduler>=3.10,<4.0
# HTTP 客户端(STF API 调用) # HTTP 客户端
requests>=2.28 requests>=2.28
# SSH 远程执行(STF 重启 / STF 设备管理;.env 配 STF_SSH_PASSWORD 时走密码认证, # SSH 远程执行(预留运维用;.env 配 STF_SSH_PASSWORD 时走密码认证,
# 跨平台无需 sshpass;未配置则退回系统 ssh 免密密钥) # 跨平台无需 sshpass;未配置则退回系统 ssh 免密密钥)
paramiko>=3.0 paramiko>=3.0
@@ -29,8 +29,10 @@ uiautomator2>=3.0
uiautodev>=0.14 uiautodev>=0.14
# 屏幕 OCR(条件判断的 OCR识别 选择器,ONNX 推理,跨平台:Windows/Linux/macOS, # 屏幕 OCR(条件判断的 OCR识别 选择器,ONNX 推理,跨平台:Windows/Linux/macOS,
# 中英文模型随包内置;服务器无显示器环境建议把 opencv-python 换成 opencv-python-headless) # 中英文模型随包内置;服务器/容器环境必须用 headless 版 opencv——
# opencv-python 依赖 X11 库(libxcb),slim 容器里 import cv2 直接崩,OCR 步骤任务失败退出)
rapidocr_onnxruntime>=1.4 rapidocr_onnxruntime>=1.4
opencv-python-headless>=4.8,<5 # 锁定 4.x:5.x wheel 打包异常(无 cv2 模块)
# APK 元信息解析(应用管理功能:自动读取包名/版本/应用名) # APK 元信息解析(应用管理功能:自动读取包名/版本/应用名)
pyaxmlparser>=0.3.27 pyaxmlparser>=0.3.27
+167
View File
@@ -0,0 +1,167 @@
"""全量路由回归测试:启动后遍历所有 API 路由发请求,抓 500/异常。
用法(项目根目录):
.venv/bin/python scripts/regression_test.py
设计:
- 用 Flask test_client(不占用 18050,不影响运行中的服务)
- GET 路由全量请求(路径参数自动替换);流式/二进制接口跳过
- 写操作(POST/PUT/DELETE)发空 body 探测:期望 400/403/404(参数校验),
返回 500 = 代码缺陷(import 遗漏/引用错误),立即报出
- 关键业务 POST 用真实参数验证(亮屏/剪贴板/测试步骤/设备池增删)
- 退出码:有失败返回 1(可接入检查流程)
背景:web/ 蓝图拆分后多次出现 import 遗漏导致接口 500(ADB_PATH/_log/常量等),
每次都是线上报错才暴露。此脚本让这类问题在改动后 30 秒内现形。
"""
import os
import sys
import signal
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 测试模式:禁用 cron 调度器(否则 test_client 会按 DB 里的任务 cron 真跑任务,
# 干扰回归/占用设备——实测 ocr关键字任务每 30 分钟触发导致回归超时)
os.environ["DISABLE_SCHEDULER"] = "1"
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间
signal.alarm(300)
from web_server import app # noqa: E402 (导入即完成 app 装配,与生产一致)
# 流式/二进制接口:无法用普通 GET 断言,跳过(已有单独验证路径)
_SKIP_PREFIXES = (
"/api/screen/stream", "/api/screen/thumb",
"/api/uiauto/screenshot", "/api/device/screenshot",
)
# 关键业务 POST:用真实参数验证(这些覆盖了核心链路)
_KEY_POSTS = [
("一键亮屏", "/api/device/screen_all", {"mode": "on"}),
("一键息屏", "/api/device/screen_all", {"mode": "off"}),
("剪贴板注入", "/api/tools/clipboard/set",
{"serials": ["100.100.10.11:5555"], "text": "回归测试"}),
("测试步骤(wait)", "/api/steps/test",
{"serial": "100.100.10.11:5555",
"step": {"type": "wait", "label": "等待", "params": {"max": 1, "min": 1, "probability": 100}}}),
("adb 终端执行", "/api/adb/cmd", {"cmd": "adb devices"}),
("adb 红线拦截", "/api/adb/cmd", {"cmd": "adb kill-server"}),
("应用版本查询", "/api/tools/appver", {"pkg": "jp.co.cyberagent.stf"}),
]
# 红线接口:期望被拒绝(不是 ok)
_EXPECT_REJECT = {"/api/adb/cmd"}
def _fill_path_params(rule):
"""把路由路径参数 <xxx> 替换为测试值(serial 用真实设备)。"""
url = rule
url = url.replace("<serial>", "100.100.10.11:5555")
url = url.replace("<apk_id>", "x")
url = url.replace("<action_id>", "x")
url = url.replace("<device_id>", "x")
url = url.replace("<uid>", "1")
return url
def main():
c = app.test_client()
# 登录 + CSRF
r = c.post("/login", data={"username": "admin", "password": "admin123"})
if r.status_code != 302:
print("❌ 登录失败(请检查 admin 账号)")
sys.exit(1)
token = c.get("/api/csrf").get_json()["token"]
H = {"X-CSRF-Token": token}
fails = []
warns = []
passed = 0
# ========== 1. GET 全量 ==========
rules = sorted({r.rule for r in app.url_map.iter_rules()
if r.rule.startswith("/api") and "GET" in r.methods})
for rule in rules:
if rule.startswith(_SKIP_PREFIXES):
continue
url = _fill_path_params(rule)
try:
r = c.get(url)
if r.status_code == 500:
fails.append(f"GET {rule} -> HTTP 500(代码缺陷)")
elif r.status_code >= 502:
warns.append(f"GET {rule} -> HTTP {r.status_code}(业务错误,如未配置/设备离线)")
else:
passed += 1
except Exception as e:
fails.append(f"GET {rule} -> {type(e).__name__}: {e}")
print(f"GET 路由: {len(rules)} 个(跳过 {len([r for r in rules if r.startswith(_SKIP_PREFIXES)])} 个流式/二进制)")
# ========== 1.5 未登录访问(登录重定向链路) ==========
try:
r = c.get("/")
if r.status_code == 302 and "login" in (r.headers.get("Location") or ""):
passed += 1
elif r.status_code == 200:
passed += 1 # 已登录态也可能 200(测试客户端无 cookie 时走 302)
else:
fails.append(f"未登录访问 / -> HTTP {r.status_code}(应 302 到登录页)")
except Exception as e:
fails.append(f"未登录访问 / -> {type(e).__name__}: {e}")
# ========== 2. 写操作空 body 探测(抓 import 遗漏/引用错误) ==========
write_rules = sorted({r.rule for r in app.url_map.iter_rules()
if r.rule.startswith("/api")
and (r.methods & {"POST", "PUT", "DELETE"})})
for rule in write_rules:
url = _fill_path_params(rule)
try:
methods = {m for r2 in app.url_map.iter_rules() if r2.rule == rule for m in r2.methods}
if "DELETE" in methods:
r = c.delete(url, headers=H)
elif "PUT" in methods:
r = c.put(url, json={}, headers=H)
else:
r = c.post(url, json={}, headers=H)
# 空 body 期望 400(参数校验);403=权限/404=路径参数、500=代码缺陷
if r.status_code == 500:
fails.append(f"写操作 {rule} -> HTTP 500(空 body 应 400,500=代码缺陷)")
elif r.status_code >= 502:
warns.append(f"写操作 {rule} -> HTTP {r.status_code}(业务错误,如未配置/设备离线)")
else:
passed += 1
except Exception as e:
fails.append(f"写操作 {rule} -> {type(e).__name__}: {e}")
print(f"写操作路由: {len(write_rules)} 个(空 body 探测)")
# ========== 3. 关键业务 POST(真实参数) ==========
for name, url, body in _KEY_POSTS:
try:
r = c.post(url, json=body, headers=H)
j = r.get_json() if r.is_json else None
is_reject = url in _EXPECT_REJECT and "kill" in (body.get("cmd") or "")
good = (not j.get("ok")) if is_reject else bool(j and j.get("ok") is not False)
if good:
passed += 1
print(f" ✅ {name}: {r.status_code}")
else:
fails.append(f"关键 POST {name} -> HTTP {r.status_code} {str(j)[:80]}")
except Exception as e:
fails.append(f"关键 POST {name} -> {type(e).__name__}: {e}")
# ========== 汇总 ==========
print("-" * 50)
if warns:
print(f"⚠ 业务性提示 {len(warns)} 个(非代码缺陷):")
for w in warns:
print(" ", w)
if fails:
print(f"❌ 发现 {len(fails)} 个代码缺陷:")
for f in fails:
print(" ", f)
sys.exit(1)
print(f"✅ 全部通过({passed} 项检查,含 {len(rules)} GET + {len(write_rules)} 写操作探测 + {len(_KEY_POSTS)} 关键业务)")
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# 容器启动脚本:安装依赖 + 环境修复 + 启动 web_server。
# compose 的 python-app 服务 command 指向本脚本。
#
# 为什么需要环境修复(必须在 pip install 之后):
# rapidocr_onnxruntime 的依赖声明是 opencv-python(GUI 版),pip 解析依赖时
# 会强制装回它;而 GUI 版依赖 X11 库(libxcb),python:slim 容器里 import cv2
# 直接崩溃 → RapidOCR 不可用 → 条件判断的 OCR 步骤抛异常 → 任务失败退出。
# 因此在安装完 requirements 后,最后强制卸载 GUI 版并装 headless(幂等)。
# 本地 Mac 有 GUI 环境不受影响。
if [ -f requirements.txt ]; then
pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
fi
# 环境修复:最后强制 headless(覆盖 rapidocr 拉回的 GUI 版)
pip uninstall -y -q opencv-python 2>/dev/null || true
pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "opencv-python-headless>=4.8,<5" # 5.x wheel 无 cv2 模块,锁定 4.x
# pip 24.0 解压 abi3 wheel 偶发丢根文件(4.14.0.94 实测 site-packages/cv2 只剩子目录,
# cv2.abi3.so / __init__.py 缺失 → import cv2 成空壳,OCR 全挂)→ 空壳检测 + wheel 手动解压兜底(幂等)
python3 - <<'PY'
import glob, os, shutil, site, subprocess, sys, zipfile
def cv2_ok():
try:
import cv2
return hasattr(cv2, "__version__") and cv2.__file__ is not None
except Exception:
return False
if cv2_ok():
sys.exit(0)
print("cv2 空壳,wheel 手动解压修复...")
r = subprocess.run(["pip", "download", "--no-deps", "-q",
"-i", "https://pypi.tuna.tsinghua.edu.cn/simple",
"-d", "/tmp/ocv", "opencv-python-headless>=4.8,<5"], capture_output=True)
if r.returncode != 0:
sys.exit(1)
w = sorted(glob.glob("/tmp/ocv/opencv_python_headless*.whl"))[-1]
with zipfile.ZipFile(w) as z:
z.extractall("/tmp/ocv")
dst = os.path.join(site.getsitepackages()[0], "cv2")
shutil.rmtree(dst, ignore_errors=True)
shutil.copytree("/tmp/ocv/cv2", dst)
import cv2
print(f"cv2 {cv2.__version__} 手动解压修复完成")
PY
exec python -u web_server.py
+17 -2
View File
@@ -94,11 +94,11 @@ function showTab(name){
// 停止所有定时器 // 停止所有定时器
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;} if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
if(_logTimer){clearInterval(_logTimer);_logTimer=null;} if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
// 按需加载数据 // 按需加载数据
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();} if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
if(name==='groups')loadGroups();
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
if(name==='users')loadUsers(); if(name==='users')loadUsers();
} }
@@ -106,13 +106,22 @@ function showTab(name){
// ================== 页内子分栏(任务/工具 通用) ================== // ================== 页内子分栏(任务/工具 通用) ==================
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位 // 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', tools: 'clipboard'}; let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
function showSubTab(tabId, name){ function showSubTab(tabId, name){
_activeSubs[tabId] = name; _activeSubs[tabId] = name;
const tab = document.getElementById('tab-'+tabId); const tab = document.getElementById('tab-'+tabId);
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name)); tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name)); tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
if(name==='devpool' && typeof loadDevPool==='function') loadDevPool(); if(name==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='devpool' && typeof loadDevPool==='function'){
loadDevPool();
if(typeof loadDiscovery==='function'){
loadDiscovery();
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
_discoveryTimer=setInterval(loadDiscovery,10000); // 10s 轮询:扫完自动出新结果
}
} else if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
} }
// ================== 模态框 ================== // ================== 模态框 ==================
@@ -132,6 +141,12 @@ function closeModal(){
}catch(e){console.warn('closeModal cleanup error:',e);} }catch(e){console.warn('closeModal cleanup error:',e);}
} }
// 测试此步骤弹窗关闭(editor.js 的测试按钮用;曾随 STF 清理误删)
function closeTestModal(){
const ov=document.getElementById('test-overlay');
if(ov)ov.classList.remove('show');
}
// 关闭元素抓取弹窗(不影响任务编辑窗口) // 关闭元素抓取弹窗(不影响任务编辑窗口)
function closeElPicker(){ function closeElPicker(){
var ov=document.getElementById('el-picker-overlay'); var ov=document.getElementById('el-picker-overlay');
+6 -1
View File
@@ -998,6 +998,7 @@ var _stepEditor={
if(el.description)attrs.push('<span>desc:'+esc(el.description)+'</span>'); if(el.description)attrs.push('<span>desc:'+esc(el.description)+'</span>');
var indent=' '.repeat(Math.min(el.depth||0,6)); var indent=' '.repeat(Math.min(el.depth||0,6));
var sg=el.suggested||{}; var sg=el.suggested||{};
if(sg.invalid)attrs.push('<span style="color:#dc2626">⚠ '+esc(sg.reason||'无可用属性')+'</span>');
return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+ return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
'<span class="ep-depth">'+(el.depth||0)+'</span>'+ '<span class="ep-depth">'+(el.depth||0)+'</span>'+
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+ '<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
@@ -1022,6 +1023,10 @@ var _stepEditor={
_pickEl:function(i){ _pickEl:function(i){
var el=this._elData[i]; var el=this._elData[i];
if(!el||!this._elTargetInput)return; if(!el||!this._elTargetInput)return;
if(el.suggested&&el.suggested.invalid){
showToast(el.suggested.reason||'该元素无法生成选择器','error');
return;
}
// 填入推荐选择器值,并切换选择器类型 // 填入推荐选择器值,并切换选择器类型
this._elTargetInput.value=el.suggested.value; this._elTargetInput.value=el.suggested.value;
var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]'); var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]');
@@ -1077,7 +1082,7 @@ async function saveTask(jobId){
cron:buildCronFromFreq(), cron:buildCronFromFreq(),
stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'}; stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
} }
// 运行窗口:窗口外(定时触发 + 手动执行)不启动 // 运行窗口:只约束定时触发(窗口外定时不启动),手动执行不受限制
if(document.getElementById('f-window_enable').checked){ if(document.getElementById('f-window_enable').checked){
schedule.window={ schedule.window={
start:document.getElementById('f-window_start').value||'09:00', start:document.getElementById('f-window_start').value||'09:00',
+7 -1
View File
@@ -307,8 +307,14 @@ function renderDevices(){
if(_sig===_devicesSig)return; if(_sig===_devicesSig)return;
_devicesSig=_sig; _devicesSig=_sig;
// 排序由 applyListPager 统一处理(sortValue + _lpSort) // 排序由 applyListPager 统一处理(sortValue + _lpSort)
// 「只看在线」勾选时过滤离线设备(默认勾选)
const onlyOnline=document.getElementById('monitor-filter-online')
? document.getElementById('monitor-filter-online').checked : true;
setListPager('devices',{ setListPager('devices',{
data:()=>_statusData.devices||[], data:()=>{
const all=_statusData.devices||[];
return onlyOnline? all.filter(d=>d.present) : all;
},
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)), filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
sortValue:_sortValue, sortValue:_sortValue,
render:renderDeviceRow, render:renderDeviceRow,
+1 -1
View File
@@ -141,7 +141,7 @@ function openTaskModal(jobId){
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+ '<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+ '<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
'</div>'+ '</div>'+
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+ '<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口只约束定时触发:窗口外定时不会启动,手动执行不受限制。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
'<div class="form-row">'+ '<div class="form-row">'+
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+ '<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+ '<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
+120 -3
View File
@@ -21,7 +21,9 @@ async function loadToolsDevices(force){
const list = await _fetchAdbDevices(force); const list = await _fetchAdbDevices(force);
const status = document.getElementById('clip-status'); const status = document.getElementById('clip-status');
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;} if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
_clipDevices = list; // 剪贴板注入只对在线设备有意义:过滤掉设备池未连接(pool)/离线条目;
// adb 终端面板保留全部(重连设备需要),它用自己的渲染逻辑不受影响
_clipDevices = list.filter(d=>d.state==='device');
const wrap = document.getElementById('clip-devices'); const wrap = document.getElementById('clip-devices');
if(!_clipDevices.length){ if(!_clipDevices.length){
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>'; wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>';
@@ -39,11 +41,119 @@ async function loadToolsDevices(force){
status.textContent = '共 '+_clipDevices.length+' 台设备'; status.textContent = '共 '+_clipDevices.length+' 台设备';
} }
// ================== 设备自动发现(扫描 → 待连接池 → 确认) ==================
function loadDiscovery(){
apiGet('/api/devices/discovery').then(r=>{
if(!r||!r.ok)return;
const status=document.getElementById('discovery-status');
const enabled=document.getElementById('discovery-enabled');
const interval=document.getElementById('discovery-interval');
const subnets=document.getElementById('discovery-subnets');
if(!enabled)return; // 面板未渲染(离开子分栏)
enabled.checked=!!r.enabled;
interval.value=r.interval||60;
subnets.value=(r.subnets||[]).join(', ');
// 状态行
if(r.scanning){status.textContent='扫描中...';}
else if(r.last_scan){
let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 '
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0);
if(r.last_error)txt+=' · ⚠ '+esc(r.last_error);
status.textContent=txt;
}else{status.textContent='尚未扫描';}
// 待连接表格:只显示在线设备(离线候选不可确认连接,隐藏;恢复在线后自动出现)
const rows=(r.pending||[]).filter(x=>x.online);
const tb=document.getElementById('tb-discovery');
if(!tb)return;
if(!rows.length){
tb.innerHTML='<tr><td colspan="6" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
}else{
tb.innerHTML=rows.map(x=>{
const src=x.source==='tailscale'
?'<span class="label label-info">Tailscale</span>'
:'<span class="label label-warning">局域网</span>';
const onl=x.online
?'<span class="label label-success">在线</span>'
:'<span class="label label-warning">离线</span>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">确认添加</button> '
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
}).join('');
}
});
}
function toggleDiscovery(checked){
apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{
if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success');
else showToast('设置失败: '+((r&&r.error)||''),'error');
});
}
function saveDiscoverySettings(){
const subnets=(document.getElementById('discovery-subnets').value||'')
.split(',').map(s=>s.trim()).filter(Boolean);
const interval=parseInt(document.getElementById('discovery-interval').value)||60;
apiPost('/api/devices/discovery/settings',{subnets,interval}).then(r=>{
if(r&&r.ok)showToast('设置已保存','success');
else showToast('保存失败: '+((r&&r.error)||''),'error');
});
}
function runDiscoveryScan(){
apiPost('/api/devices/discovery/scan',{}).then(r=>{
if(r&&r.ok){showToast(r.msg||'扫描已启动','success');setTimeout(loadDiscovery,3000);}
else showToast('扫描失败: '+((r&&r.error)||''),'error');
});
}
function confirmDiscoveryDev(serial){
const name=prompt('备注名(可选,留空直接添加)','');
if(name===null)return;
apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{
if(r&&r.ok){
showToast('已加入设备池'+(r.is_new?'':'(已存在,已更新)'),'success');
loadDevPool();loadDiscovery();
}else showToast('确认失败: '+((r&&r.error)||''),'error');
});
}
function ignoreDiscoveryDev(serial){
if(!confirm('忽略 '+serial+'?下轮扫描可能再次发现'))return;
apiPost('/api/devices/discovery/ignore',{serial}).then(r=>{
if(r&&r.ok){showToast('已忽略','success');loadDiscovery();}
else showToast('操作失败: '+((r&&r.error)||''),'error');
});
}
function toggleClipDevice(cb){ function toggleClipDevice(cb){
if(cb.checked)_clipSelected.add(cb.value); if(cb.checked)_clipSelected.add(cb.value);
else _clipSelected.delete(cb.value); else _clipSelected.delete(cb.value);
} }
// 注入剪贴板(ClipInject APK 通道,Android 10+ 可用):
// am start 透明 Activity 写入 + u2 读回比对验证;结果逐台渲染 + toast 汇总
function injectClipboard(){
const text = document.getElementById('clip-text').value;
const serials = [..._clipSelected];
if(!serials.length){showToast('请先勾选目标设备','error');return;}
if(!text.trim()){showToast('请输入注入内容','error');return;}
const btn = Array.from(document.querySelectorAll('#tools-sub-clipboard .btn-primary'))
.find(b=>b.textContent.includes('注入剪贴板'));
if(btn){btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; btn._old = old;}
apiPost('/api/tools/clipboard/set', {serials, text}).then(r=>{
if(!r){showToast('请求失败','error');return;}
const rows = Object.entries(r.results||{}).map(([s,v])=>{
const ok = !!v.ok;
return '<tr><td style="font-family:monospace">'+esc(s)+'</td>'
+'<td><span class="label '+(ok?'label-success':'label-danger')+'">'
+(ok?'成功':'失败')+'</span> '+esc(v.msg||'')+'</td></tr>';
}).join('');
document.getElementById('clip-result').innerHTML = rows;
document.getElementById('clip-result-wrap').style.display='block';
const okc = r.ok_count||0, fail = r.fail_count||0;
if(fail>0) showToast(okc+' 台成功,'+fail+' 台失败','error');
else showToast(okc+' 台设备注入成功','success');
}).catch(e=>{showToast('注入异常: '+e,'error');})
.finally(()=>{ if(btn){btn.disabled=false; btn.textContent=btn._old;} });
}
// ================== 工具:应用版本管理 ================== // ================== 工具:应用版本管理 ==================
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}] let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
@@ -285,9 +395,16 @@ async function loadDevPool(){
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>'; tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/devices/pool'); const r=await apiGet('/api/devices/pool');
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;} if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;}
const rows=r.devices||[]; let rows=r.devices||[];
// 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼)
const onlyOnline=document.getElementById('devpool-filter-online')
? document.getElementById('devpool-filter-online').checked : false;
rows.sort((a,b)=>(b.online?1:0)-(a.online?1:0));
if(onlyOnline)rows=rows.filter(x=>x.online);
const onlineCnt=rows.filter(x=>x.online).length; const onlineCnt=rows.filter(x=>x.online).length;
status.textContent='共 '+rows.length+' 台,在线 '+onlineCnt+' 台'; status.textContent=onlyOnline
? '在线 '+rows.length+' 台(共 '+(r.devices||[]).length+' 台配置)'
: '共 '+rows.length+' 台,在线 '+onlineCnt+' 台';
tb.innerHTML=rows.map(d=>{ tb.innerHTML=rows.map(d=>{
const st= d.enabled const st= d.enabled
? '<span class="label label-success">启用</span>' ? '<span class="label label-success">启用</span>'
+22 -11
View File
@@ -478,26 +478,37 @@ class GenericStepsWorker(BaseWorker):
return found # 命中结果,"测试此步骤"用 return found # 命中结果,"测试此步骤"用
def _exec_clipboard(self, d, params, depth=0): def _exec_clipboard(self, d, params, depth=0):
"""剪贴板注入:写入指定内容到剪贴板,可选立即粘贴到当前聚焦的输入框。 """剪贴板注入(ClipInject 通道,Android 10+ 可用),可选立即粘贴。
批量发链接场景:先聚焦评论区输入框 → 注入+粘贴链接1 → 换链接 → 注入+粘贴链接2…… 批量发链接场景:先聚焦评论区输入框 → 注入+粘贴链接1 → 换链接 → 注入+粘贴链接2……
粘贴模式复用 u2 send_keys(内部 setClipboard + pasteClipboard,与"输入文字"同机制)。 粘贴模式:ClipInject 写入剪贴板后调 atx-agent pasteClipboard 粘贴到当前
焦点输入框;动作失败时兜底 u2 set_text 直接设置聚焦输入框文本(不依赖
剪贴板,与"输入文字"兜底同机制)。
""" """
text = str(params.get("text", "") or "") text = str(params.get("text", "") or "")
if not text: if not text:
_log.warning(f"[{self.serial}] clipboard 内容为空,跳过") _log.warning(f"[{self.serial}] clipboard 内容为空,跳过")
return False return False
from core.clipboard_helper import inject_clipboard
ok, msg = inject_clipboard(self.serial, text, d=d)
if not ok:
_log.warning(f"[{self.serial}] 剪贴板注入失败: {msg}")
return False
_log.info(f"[{self.serial}] 剪贴板注入: {text[:60]}({msg})")
if not params.get("paste"):
return True
try: try:
if params.get("paste"): d.jsonrpc.pasteClipboard()
d.send_keys(text) # setClipboard + pasteClipboard _log.info(f"[{self.serial}] 已粘贴: {text[:60]}")
_log.info(f"[{self.serial}] 剪贴板注入并粘贴: {text[:60]}")
else:
d.set_clipboard(text)
_log.info(f"[{self.serial}] 剪贴板注入(不粘贴): {text[:60]}")
return True return True
except Exception as e: except Exception as e:
_log.warning(f"[{self.serial}] 剪贴板注入异常: {e}") try:
return False d(focused=True).set_text(text)
_log.info(f"[{self.serial}] 粘贴兜底 set_text: {text[:60]}")
return True
except Exception as e2:
_log.warning(f"[{self.serial}] 粘贴失败: {e} / {e2}")
return True # 剪贴板已写入,粘贴失败不致命
def _exec_wait(self, d, params, depth=0): def _exec_wait(self, d, params, depth=0):
min_s = float(params.get("min", 1.0)) min_s = float(params.get("min", 1.0))
@@ -645,7 +656,7 @@ def test_step(serial, step):
if d is None: if d is None:
return False, "u2 连接超时", None return False, "u2 连接超时", None
try: try:
w = GenericStepsWorker(None, serial, {}) w = GenericStepsWorker(serial, {})
result = w._exec_one(d, step) result = w._exec_one(d, step)
return True, f"步骤已执行({step.get('label') or step.get('type')})", result return True, f"步骤已执行({step.get('label') or step.get('type')})", result
except Exception as e: except Exception as e:
+49 -20
View File
@@ -313,7 +313,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button> <button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button>
<button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button> <button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button>
<button class="tab" data-tab="groups" onclick="showTab('groups')">分组</button>
<button class="tab" data-tab="logs" onclick="showTab('logs')" data-perm="logs">日志</button> <button class="tab" data-tab="logs" onclick="showTab('logs')" data-perm="logs">日志</button>
<button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button> <button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button>
<button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button> <button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button>
@@ -343,6 +342,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button> <button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button> <button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
<button class="btn" onclick="loadMonitor()">刷新</button> <button class="btn" onclick="loadMonitor()">刷新</button>
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
<input type="checkbox" id="monitor-filter-online" checked onchange="loadMonitor()"> 只看在线
</label>
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务..."> <input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span> <span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
</div> </div>
@@ -420,23 +422,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div> </div>
<!-- ========== Tab 3: 分组 ========== --> <!-- ========== Tab 3: 分组 ========== -->
<div id="tab-groups" class="tab-panel">
<div class="page-title">设备分组管理</div>
<div class="page-sub">管理设备分组,用于任务按分组执行</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button>
<button class="btn" onclick="loadGroups()">刷新</button>
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
</div>
<table class="table">
<thead><tr>
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
</tr></thead>
<tbody id="tb-groups"></tbody>
</table>
<div class="list-pager" id="pager-groups"></div>
</div>
<!-- ========== Tab 4: 日志 ========== --> <!-- ========== Tab 4: 日志 ========== -->
<div id="tab-logs" class="tab-panel"> <div id="tab-logs" class="tab-panel">
<div class="page-title">日志查看</div> <div class="page-title">日志查看</div>
@@ -494,6 +479,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button> <button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button> <button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
<button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button> <button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button>
<button class="sub-tab" data-sub="groups" onclick="showSubTab('tools','groups')">设备分组</button>
</div> </div>
<!-- 子分栏 1:剪贴板注入 --> <!-- 子分栏 1:剪贴板注入 -->
@@ -513,7 +499,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button> <button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button>
<span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span> <span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span>
</div> </div>
<div class="help">实现:u2 setClipboard(jsonrpc 直连 atx-agent),实测支持中文、引号、换行;USB 设备首次连接会自动推送 atx-agent。</div> <div class="help">实现:ClipInject APK 透明 Activity 写入剪贴板(Android 10+ 可用,u2 setClipboard 在 Android 12/13 会被系统丢弃);设备需先安装 com.example.clipinject,注入后自动读回比对验证。</div>
</div> </div>
<div style="flex:1;min-width:280px"> <div style="flex:1;min-width:280px">
<div class="form-group"> <div class="form-group">
@@ -649,6 +635,28 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) --> <!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) -->
<div class="sub-panel" id="tools-sub-devpool"> <div class="sub-panel" id="tools-sub-devpool">
<!-- 自动发现:扫描网段 → 待连接池,确认后才加入正式池 -->
<div style="border-bottom:1px solid var(--border);margin-bottom:10px;padding-bottom:10px">
<div class="toolbar" style="flex-wrap:wrap">
<label style="display:inline-flex;align-items:center;gap:4px">
<input type="checkbox" id="discovery-enabled" onchange="toggleDiscovery(this.checked)"> 定时扫描
</label>
<span class="text-muted" style="margin-left:8px">周期</span>
<input type="number" id="discovery-interval" style="width:70px" min="10">
<span class="text-muted">秒 · 网段</span>
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
<span id="discovery-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr><th>serial</th><th>来源</th><th>首次发现</th><th>最近发现</th><th>在线</th><th>操作</th></tr></thead>
<tbody id="tb-discovery"><tr><td colspan="6" class="empty">尚未扫描</td></tr></tbody>
</table>
<div class="help">扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入<b>待连接池</b>;
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。</div>
</div>
<div class="toolbar"> <div class="toolbar">
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace" <input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()"> placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
@@ -658,6 +666,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button> <button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button> <button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
<button class="btn" onclick="loadDevPool()">刷新</button> <button class="btn" onclick="loadDevPool()">刷新</button>
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
<input type="checkbox" id="devpool-filter-online" checked onchange="loadDevPool()"> 只看在线
</label>
<span id="devpool-status" class="text-muted"></span> <span id="devpool-status" class="text-muted"></span>
</div> </div>
<table class="table table-hover table-sm" style="margin-top:8px"> <table class="table table-hover table-sm" style="margin-top:8px">
@@ -673,12 +684,30 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div> </div>
</div> </div>
<!-- 子分栏:设备分组(原顶级 tab,移到工具页) -->
<div class="sub-panel" id="tools-sub-groups">
<div class="toolbar">
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button>
<button class="btn" onclick="loadGroups()">刷新</button>
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
<span id="groups-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr>
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
</tr></thead>
<tbody id="tb-groups"></tbody>
</table>
<div class="list-pager" id="pager-groups"></div>
<div class="help">管理设备分组,用于任务按分组执行。分组内的设备需先在<b>设备池管理</b>中确认(不在池内的设备不参与调度)。</div>
</div>
</div> </div>
</div><!-- /.content --> </div><!-- /.content -->
<!-- ========== 测试此步骤弹窗 ========== --> <!-- ========== 测试此步骤弹窗 ========== -->
<div id="test-overlay" class="modal-overlay" onclick="if(event.target===this)closeTestModal()"> <div id="test-overlay" class="modal-overlay" style="z-index:1200" onclick="if(event.target===this)closeTestModal()">
<div class="modal" id="test-modal-box"> <div class="modal" id="test-modal-box">
<div class="modal-header"> <div class="modal-header">
<h3 id="test-modal-title">测试步骤</h3> <h3 id="test-modal-title">测试步骤</h3>
+20 -6
View File
@@ -116,6 +116,9 @@ main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;paddi
<header> <header>
<div class="title">设备监控大屏</div> <div class="title">设备监控大屏</div>
<div class="stats" id="wall-stats"></div> <div class="stats" id="wall-stats"></div>
<label class="wall-btn" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer">
<input type="checkbox" id="wall-filter-online" checked onchange="refreshStatus()"> 只看在线
</label>
<button class="wall-btn" id="btn-wake-all" onclick="wakeAll()">💡 一键亮屏</button> <button class="wall-btn" id="btn-wake-all" onclick="wakeAll()">💡 一键亮屏</button>
<div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div> <div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div>
</header> </header>
@@ -203,11 +206,18 @@ async function refreshStatus(){
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model])); JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model]));
_devices=devs; _devices=devs;
renderStats(devs); renderStats(devs);
if(changed)renderGrid(devs); if(changed)renderGrid(visibleDevs(devs));
// 操作弹窗打开时同步刷新其标题状态 // 操作弹窗打开时同步刷新其标题状态
if(_ctrlSerial)updateCtrlHead(); if(_ctrlSerial)updateCtrlHead();
} }
// 「只看在线」勾选时过滤离线设备(默认勾选)
function visibleDevs(devs){
const onlyOnline=document.getElementById('wall-filter-online')
? document.getElementById('wall-filter-online').checked : true;
return onlyOnline? devs.filter(d=>d.present) : devs;
}
function renderStats(devs){ function renderStats(devs){
const online=devs.filter(d=>d.present).length; const online=devs.filter(d=>d.present).length;
const running=devs.filter(d=>['running','connecting'].includes(d.worker_status)).length; const running=devs.filter(d=>['running','connecting'].includes(d.worker_status)).length;
@@ -391,14 +401,18 @@ function updateCtrlHead(){
document.getElementById('ctrl-sub').textContent=_ctrlSerial+(d.present?'':'(离线)'); document.getElementById('ctrl-sub').textContent=_ctrlSerial+(d.present?'':'(离线)');
} }
// 坐标映射:显示尺寸 -> 设备原生分辨率 // 坐标映射:显示尺寸 -> 设备原生分辨率
// 图片 object-fit:contain 时实际绘制区域小于盒子(居中留黑边),
// 必须按绘制区域换算,否则黑边/偏移导致点击错位
function ctrlCoords(ev){ function ctrlCoords(ev){
const img=document.getElementById('ctrl-img'); const img=document.getElementById('ctrl-img');
if(!img.naturalWidth)return[null,null]; if(!img.naturalWidth||!img.naturalHeight)return[null,null];
const r=img.getBoundingClientRect(); const r=img.getBoundingClientRect();
return[ const scale=Math.min(r.width/img.naturalWidth,r.height/img.naturalHeight);
Math.round((ev.clientX-r.left)/r.width*img.naturalWidth), const drawW=img.naturalWidth*scale, drawH=img.naturalHeight*scale;
Math.round((ev.clientY-r.top)/r.height*img.naturalHeight), const ox=(r.width-drawW)/2, oy=(r.height-drawH)/2; // contain 偏移
]; const px=ev.clientX-r.left, py=ev.clientY-r.top;
if(px<ox||px>ox+drawW||py<oy||py>oy+drawH)return[null,null]; // 黑边点击忽略
return[Math.round((px-ox)/scale),Math.round((py-oy)/scale)];
} }
function ctrlTap(x,y){ function ctrlTap(x,y){
if(x==null||!x)return; if(x==null||!x)return;
+85 -1
View File
@@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from core import device_pool from core import device_pool, device_discovery
from core.adb_helper import adb_connect from core.adb_helper import adb_connect
from core.logger import get_logger from core.logger import get_logger
from web.auth import perm_required, PERM_DEVICES from web.auth import perm_required, PERM_DEVICES
@@ -113,6 +113,90 @@ def api_devices_pool_refresh_models():
return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"}) return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"})
# ================== API:设备自动发现(扫描 → 待连接池 → 确认) ==================
# 扫描只把设备放进待连接池(pending),用户确认后才加入正式设备池并连接。
@bp.route("/api/devices/discovery", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_discovery():
"""发现状态 + 待连接列表(前端 10s 轮询一次请求)。"""
try:
status = device_discovery.get_status()
pending = device_discovery.list_pending()
except Exception as e:
_log.warning(f"discovery 状态获取失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
status["pending"] = pending
return jsonify({"ok": True, **status})
@bp.route("/api/devices/discovery/scan", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_scan():
"""手动触发一轮扫描(后台执行,约 5-30 秒)。"""
ok, result = device_discovery.scan_once(manual=True)
if not ok:
return jsonify({"ok": False, "error": result.get("error", "扫描失败")}), 409
if "error" in result:
return jsonify({"ok": False, "error": result["error"]}), 400
return jsonify({"ok": True, "msg": "扫描已启动(后台执行,约 5-30 秒)",
"result": result})
@bp.route("/api/devices/discovery/confirm", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_confirm():
"""确认连接:待连接 → 正式设备池 + 后台 adb connect + 采型号。"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, msg, is_new = device_discovery.confirm_pending(
serial, name=(data.get("name") or "").strip())
if not ok:
return jsonify({"ok": False, "error": msg}), 404
if ":" in serial:
def _connect():
try:
adb_connect(serial)
except Exception:
pass
try:
device_pool.refresh_model(serial)
except Exception:
pass
threading.Thread(target=_connect, daemon=True).start()
_log.info(f"设备池管理: 发现确认 {serial}")
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
@bp.route("/api/devices/discovery/ignore", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_ignore():
"""忽略:从待连接池删除(下轮扫描可能再次发现)。"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, msg = device_discovery.ignore_pending(serial)
if not ok:
return jsonify({"ok": False, "error": msg}), 404
return jsonify({"ok": True, "msg": msg})
@bp.route("/api/devices/discovery/settings", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_settings():
"""保存发现配置(部分字段更新):{enabled?, subnets?, interval?, port?}。"""
data = request.json or {}
ok, msg = device_discovery.save_settings(
enabled=data.get("enabled"),
subnets=data.get("subnets"),
interval=data.get("interval"),
port=data.get("port"))
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "msg": msg})
# ================== API:Tailscale 管理(仅管理员) ================== # ================== API:Tailscale 管理(仅管理员) ==================
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。 # 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。 # 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
+59 -6
View File
@@ -9,8 +9,10 @@ from flask import (Blueprint, jsonify, request, Response, render_template)
from flask_login import login_required from flask_login import login_required
from core import device_pool from core import device_pool
from core.adb_helper import (screenshot, adb_connect, adb_connect_light, from core.adb_helper import (screenshot, list_installed_apps, adb_connect,
_ADB_LOCK, _adb) adb_connect_light, _ADB_LOCK, _adb)
from core.device_worker import (get_all_worker_status, clear_worker_error,
clear_all_worker_errors)
from web import context from web import context
from web.auth import perm_required, PERM_DEVICES from web.auth import perm_required, PERM_DEVICES
from web.common import _merged_device_list, _device_screen_state from web.common import _merged_device_list, _device_screen_state
@@ -414,10 +416,34 @@ def api_screen_stream():
mimetype="multipart/x-mixed-replace; boundary=frame") mimetype="multipart/x-mixed-replace; boundary=frame")
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
def _screen_get_device(serial): def _screen_get_device(serial):
"""远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。""" """远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
由调用方把异常转 503。
"""
import time
import uiautomator2 as u2 import uiautomator2 as u2
return u2.connect(serial) now = time.time()
cached = _screen_u2_cache.get(serial)
if cached and now - cached[0] < 60:
return cached[1]
d = u2.connect(serial)
_screen_u2_cache[serial] = (now, d)
if len(_screen_u2_cache) > 30: # 惰性清理过期项
for k in list(_screen_u2_cache):
if now - _screen_u2_cache[k][0] >= 60:
del _screen_u2_cache[k]
return d
def _screen_invalidate(serial):
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
_screen_u2_cache.pop(serial, None)
@bp.route("/api/screen/thumb") @bp.route("/api/screen/thumb")
@perm_required(PERM_DEVICES) @perm_required(PERM_DEVICES)
@@ -462,6 +488,7 @@ def api_screen_tap():
_screen_get_device(serial).click(int(x), int(y)) _screen_get_device(serial).click(int(x), int(y))
return jsonify({"ok": True}) return jsonify({"ok": True})
except Exception as e: except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503 return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503
@bp.route("/api/screen/swipe", methods=["POST"]) @bp.route("/api/screen/swipe", methods=["POST"])
@@ -478,6 +505,7 @@ def api_screen_swipe():
duration=float(data.get("duration", 0.2))) duration=float(data.get("duration", 0.2)))
return jsonify({"ok": True}) return jsonify({"ok": True})
except Exception as e: except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503 return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503
@@ -496,6 +524,7 @@ def api_screen_key():
_screen_get_device(serial).press(key) _screen_get_device(serial).press(key)
return jsonify({"ok": True}) return jsonify({"ok": True})
except Exception as e: except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503 return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503
@bp.route("/api/screen/text", methods=["POST"]) @bp.route("/api/screen/text", methods=["POST"])
@@ -526,8 +555,32 @@ def _current_focus_pkg(serial):
except Exception: except Exception:
return "" return ""
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
def _screen_get_device(serial): def _screen_get_device(serial):
"""远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。""" """远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
由调用方把异常转 503。
"""
import time
import uiautomator2 as u2 import uiautomator2 as u2
return u2.connect(serial) now = time.time()
cached = _screen_u2_cache.get(serial)
if cached and now - cached[0] < 60:
return cached[1]
d = u2.connect(serial)
_screen_u2_cache[serial] = (now, d)
if len(_screen_u2_cache) > 30: # 惰性清理过期项
for k in list(_screen_u2_cache):
if now - _screen_u2_cache[k][0] >= 60:
del _screen_u2_cache[k]
return d
def _screen_invalidate(serial):
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
_screen_u2_cache.pop(serial, None)
+3
View File
@@ -12,6 +12,9 @@ from flask_login import login_required
from core import device_pool, uiauto_helper from core import device_pool, uiauto_helper
from core.adb_helper import adb_connect_light from core.adb_helper import adb_connect_light
from core.models import db, DeviceGroup, TaskJob, CustomAction from core.models import db, DeviceGroup, TaskJob, CustomAction
from core.logger import get_logger
_log = get_logger("web")
from web import context from web import context
from web.auth import perm_required, PERM_DEVICES, PERM_TASKS from web.auth import perm_required, PERM_DEVICES, PERM_TASKS
from tasks import list_task_types, get_task_class from tasks import list_task_types, get_task_class
+45 -32
View File
@@ -8,6 +8,7 @@ from flask import Blueprint, jsonify, request
from core import device_pool from core import device_pool
from core.adb_helper import adb_connect, adb_connect_light, _ADB_LOCK, _adb from core.adb_helper import adb_connect, adb_connect_light, _ADB_LOCK, _adb
from config import ADB_PATH
from core.logger import get_logger from core.logger import get_logger
from web import context from web import context
from web.auth import admin_required, perm_required from web.auth import admin_required, perm_required
@@ -16,6 +17,10 @@ from web.common import _merged_device_list
_log = get_logger("web") _log = get_logger("web")
bp = Blueprint("tools", __name__) bp = Blueprint("tools", __name__)
# adb 终端安全红线:绝不 kill-server / 绝不 disconnect IP:5555——
# 共享 adb transport,断开会让全部设备连接重建(历史与 STF provider 共用)。
_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect")
@bp.route("/api/adb/devices") @bp.route("/api/adb/devices")
@admin_required @admin_required
def api_adb_devices(): def api_adb_devices():
@@ -89,29 +94,9 @@ def api_tools_clipboard_set():
def _set_device_clipboard(serial, text): def _set_device_clipboard(serial, text):
"""向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。 """向单台设备注入剪贴板文字(ClipInject 通道,见 core.clipboard_helper)。"""
from core.clipboard_helper import inject_clipboard
- IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线) return inject_clipboard(serial, text)
- 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): def _app_ver_on_device(serial, pkg):
@@ -181,29 +166,57 @@ def _blocked_adb_cmd(cmd):
return any(p in low for p in _ADB_BLOCKED_PATTERNS) return any(p in low for p in _ADB_BLOCKED_PATTERNS)
def _set_device_clipboard(serial, text): def _set_device_clipboard(serial, text):
"""向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。 """向单台设备注入剪贴板文字(ClipInject APK:透明 Activity 聚焦后写入)。
Android 10+ 禁止后台(atx-agent/shell)写剪贴板,u2.set_clipboard 在
Android 12/13 上调用"成功"但内容被系统静默丢弃。ClipInject
(com.example.clipinject)通过 am start 启动透明 Activity,窗口聚焦后
setPrimaryClip,shell 启动 Activity 不受后台启动限制(广播方式在 MIUI
会被 Background activity start 拦截,必须用 am start)。
- IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线) - IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线)
- USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent - 设备未装 ClipInject:am start 返回 unable to resolve Intent,明确报错
- 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求 - 注入后 u2 读回比对验证(后台读剪贴板实测可用),不一致也明确报错
""" """
import base64
import time
import uiautomator2 as u2 import uiautomator2 as u2
if ":" in serial: if ":" in serial:
try: try:
adb_connect(serial) adb_connect(serial)
except Exception as e: except Exception as e:
return False, f"adb 连接失败: {e}" return False, f"adb 连接失败: {e}"
d = None b64 = base64.b64encode(text.encode("utf-8")).decode()
# 1. am start 透明 Activity 写剪贴板(shell 身份启动,不受后台启动限制)
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "am", "start",
"-n", "com.example.clipinject/.ClipActivity",
"--es", "text_b64", b64],
capture_output=True, timeout=30)
out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace")
if "does not exist" in out or "unable to resolve Intent" in out:
return False, ("设备未安装 ClipInject(com.example.clipinject),"
f"请先安装: {out.strip()[:100]}")
if "Error" in out or ("Starting" not in out and r.returncode != 0):
return False, f"am start 失败: {out.strip()[:120]}"
except subprocess.TimeoutExpired:
return False, "am start 超时(adb 无响应)"
except Exception as e:
return False, f"{type(e).__name__}: {str(e)[:120]}"
# 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用)
time.sleep(0.5)
try: try:
with ThreadPoolExecutor(max_workers=1) as pool: with ThreadPoolExecutor(max_workers=1) as pool:
d = pool.submit(u2.connect, serial).result(timeout=30) d = pool.submit(u2.connect, serial).result(timeout=30)
with ThreadPoolExecutor(max_workers=1) as pool: with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(d.set_clipboard, text).result(timeout=15) got = pool.submit(lambda: d.clipboard).result(timeout=15)
return True, "已注入" if got == text:
except FuturesTimeout: return True, "已注入"
return False, "连接/注入超时(atx-agent 可能无响应)" return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"
except Exception as e: except Exception as e:
return False, f"{type(e).__name__}: {str(e)[:120]}" # 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
def _app_ver_on_device(serial, pkg): def _app_ver_on_device(serial, pkg):
"""单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。 """单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。
+35 -1
View File
@@ -9,6 +9,7 @@ import atexit
import secrets import secrets
import socket import socket
import subprocess import subprocess
import sys
import threading import threading
from flask import Flask from flask import Flask
@@ -37,13 +38,16 @@ app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
login_manager = LoginManager(app) login_manager = LoginManager(app)
login_manager.login_view = "login" login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
init_db(app) init_db(app)
# 设备池(本地清单 + adb 在线状态):任务调度的数据源 # 设备池(本地清单 + adb 在线状态):任务调度的数据源
from core import device_pool from core import device_pool
device_pool.init_app(app) device_pool.init_app(app)
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
from core import device_discovery
device_discovery.init_app(app)
mgr = TaskManager(app=app) mgr = TaskManager(app=app)
apk_mgr = ApkManager(app=app) apk_mgr = ApkManager(app=app)
@@ -147,6 +151,33 @@ def _stop_uiauto():
_uiauto_proc = None _uiauto_proc = None
def _preconnect_pool_devices():
"""容器/服务重启后 adb server 重启,设备全部掉线,逐个重连要几分钟;
启动时后台并发 connect 设备池里的网络设备(IP:5555),把恢复时间降到秒级。
USB 设备无需 connect(adb server 自动重枚举),只连网络设备。
预连接幂等:已连接的设备返回 already connected,无副作用。
"""
def _run():
try:
from core import device_pool
from core.adb_helper import _adb
serials = [s for s in device_pool.list_configured() if ":" in s]
if not serials:
return
_log.info(f"预连接设备池 {len(serials)} 台网络设备(重启后加速恢复)...")
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
list(ex.map(lambda s: _adb("connect", s), serials))
out = _adb("devices")
online = sum(1 for ln in (out or "").splitlines()[1:]
if len(ln.split()) >= 2 and ln.split()[1] == "device")
_log.info(f"设备池预连接完成: {online} 台在线")
except Exception as e:
_log.warning(f"设备池预连接失败: {e}")
threading.Thread(target=_run, daemon=True).start()
def _run_server(host, port): def _run_server(host, port):
"""启动 Flask,自动处理端口冲突和权限问题。 """启动 Flask,自动处理端口冲突和权限问题。
@@ -185,9 +216,12 @@ if __name__ == "__main__":
from config import WEB_HOST, WEB_PORT from config import WEB_HOST, WEB_PORT
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242) # 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
_ensure_uiauto_running() _ensure_uiauto_running()
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
_preconnect_pool_devices()
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)") _log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
try: try:
_run_server(WEB_HOST, WEB_PORT) _run_server(WEB_HOST, WEB_PORT)
finally: finally:
mgr.shutdown() mgr.shutdown()
device_discovery.shutdown()
_stop_uiauto() _stop_uiauto()