"""工具域 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)}