"""工具域 API:adb 终端/剪贴板注入/应用版本/大屏「发布」注入素材。""" import os import re import shutil import subprocess import shlex import tempfile import uuid 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, PERM_DEVICES 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 `, 离线/未连接的设备也可选,配合"重连设备"按钮恢复。 """ 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): """向单台设备注入剪贴板文字(ClipInject 通道,见 core.clipboard_helper)。""" from core.clipboard_helper import inject_clipboard return inject_clipboard(serial, text) @bp.route("/api/tools/push_clipboard", methods=["POST"]) @perm_required(PERM_DEVICES) def api_tools_push_clipboard(): """大屏「发布」面板:往指定设备注入剪贴板文字。 与 `/api/tools/clipboard/set` 是**同一个实现**(`_set_device_clipboard`), 只是权限按「设备」位放行 —— 大屏上随手把一段文案抄到手机上,是设备操作, 不该要求管理员位。请求:`{"serial": "..."}` 或 `{"serials": ["..."]}`,外加 `text`。 """ data = request.json or {} text = (data.get("text") or "").strip() serials = data.get("serials") if not isinstance(serials, list) or not serials: one = (data.get("serial") or "").strip() serials = [one] if one else [] if 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"]) return jsonify({"ok": ok_count == len(results), "results": results, "ok_count": ok_count, "fail_count": len(results) - ok_count, "msg": "已注入 %d 台" % ok_count if ok_count else "注入失败", "error": None if ok_count == len(results) else "%d 台注入失败" % (len(results) - ok_count)}) @bp.route("/api/tools/push_material", methods=["POST"]) @perm_required(PERM_DEVICES) def api_tools_push_material(): """大屏「发布」面板:把上传的**视频/图片直接推到指定那台设备**的相册。 form-data:`file`(视频/图片)、`serial`、`album_dir`(可省,默认 /sdcard/DCIM/Camera) 返回 `{ok, msg, remote, verify}` 与「发布计划 → 推送」的区别:那条是**按计划**推(原子占位、去重、回写状态、 按账号名找设备);这条是**临时手动推一个文件** —— 给"手上有一段素材,想马上 在这台机器上发"用,所以**不占位、不写库**,手机相册里就多一个文件 (名字带 `wall` 前缀,和计划推送的素材分得开)。 为什么还要 u2 连接:推完要 `touch` 改时间(相册/抖音按时间倒序,"第一个素材" 才是刚推的这个)+ 校验进没进相册索引 —— 这些都走设备 shell,见 publish_flow。 """ from core import video_plan as vp from tasks.generic.publish_flow import ALBUM_DIR, push_video, verify_pushed from core.account_probe import _connect f = request.files.get("file") serial = (request.form.get("serial") or "").strip() album_dir = (request.form.get("album_dir") or "").strip() or ALBUM_DIR if not serial: return jsonify({"ok": False, "error": "未选择设备"}), 400 if not f or not f.filename: return jsonify({"ok": False, "error": "未选择文件"}), 400 ext = os.path.splitext(f.filename)[1].lower() if ext not in vp.MEDIA_EXTS: return jsonify({"ok": False, "error": "只支持视频/图片(%s),当前 %s" % ("、".join(vp.MEDIA_EXTS), ext or "无扩展名")}), 400 try: online = set(device_pool.list_online()) except Exception: online = set() if online and serial not in online: return jsonify({"ok": False, "error": "这台设备当前不在线"}), 400 tmpdir = tempfile.mkdtemp(prefix="wall_push_") local = os.path.join(tmpdir, vp.safe_name(f.filename)) try: f.save(local) size = os.path.getsize(local) d = _connect(serial, timeout=25) remote, err = push_video(d, serial, local, "wall" + uuid.uuid4().hex[:8], album_dir=album_dir) if err: return jsonify({"ok": False, "error": err}) verify, vmsg = verify_pushed(d, remote) if verify == "nofile": return jsonify({"ok": False, "error": "推送校验失败:%s" % vmsg}) ok = verify == "ok" _log.info("大屏发布:%s ← %s(%d 字节)→ %s [%s]" % (serial, f.filename, size, remote, verify)) return jsonify({"ok": True, "remote": remote, "verify": verify, "size": size, "msg": "已推到 %s" % remote if ok else "已推到 %s(⚠ %s)" % (remote, vmsg)}) except Exception as e: _log.warning("大屏发布失败: %s: %s" % (type(e).__name__, e)) return jsonify({"ok": False, "error": "推送异常: %s: %s" % (type(e).__name__, str(e)[:120])}) finally: shutil.rmtree(tmpdir, ignore_errors=True) 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)} # 结果里带上设备名:界面按「名称 · 地址」显示,同型号多台才分得清 if isinstance(results.get(d["serial"]), dict): results[d["serial"]]["name"] = d.get("name") or "" 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): """向单台设备注入剪贴板文字(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,红线) - 设备未装 ClipInject:am start 返回 unable to resolve Intent,明确报错 - 注入后 u2 读回比对验证(后台读剪贴板实测可用),不一致也明确报错 """ import base64 import time 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", "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: 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]})" 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)}