242 lines
11 KiB
Python
242 lines
11 KiB
Python
"""工具域 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 config import ADB_PATH
|
||
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__)
|
||
|
||
# adb 终端安全红线:绝不 kill-server / 绝不 disconnect IP:5555——
|
||
# 共享 adb transport,断开会让全部设备连接重建(历史与 STF provider 共用)。
|
||
_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect")
|
||
|
||
@bp.route("/api/adb/devices")
|
||
@admin_required
|
||
def api_adb_devices():
|
||
"""维护终端设备列表(仅管理员):本地 adb 已连接 + 设备池(SQLite)。
|
||
|
||
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
|
||
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
|
||
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
|
||
"""
|
||
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)}
|
||
|