用户要求:"大屏按钮右边加一个按钮叫发布,就是选择设备号注入视频或者图片,位置就和发布页面的一样, 然后还有在选择设备号注入剪贴板"。 大屏(`/wall`)顶栏「一键亮屏」右边加 **「📤 发布」**,点开是一块面板: · **设备**下拉(设备名 + 地址,离线会标注;每次打开现取最新列表) · **视频 / 图片**:选文件 → 「注入素材」→ 推到该机相册 `/sdcard/DCIM/Camera` · **剪贴板文字**:填一段 → 「注入剪贴板」→ 写进该机剪贴板(发文案用) · 面板下方是操作日志(✅/❌ + 原因),失败会写清为什么 后端(`web/tools_api.py`,都按 **设备位** 放行 —— 大屏上随手一推是设备操作,不该要管理员位): · `POST /api/tools/push_material`(form-data:file + serial): 保存上传 → `publish_flow.push_video` 推到相册(文件名带 `wall` 前缀、mtime 改成现在, 保证它在相册里排最前)→ `verify_pushed` 校验进没进相册索引 → 返回 `{ok,msg,remote,verify}` · **不占位、不写库**:与「发布计划 → 推送」是两回事(那条按计划走占位/去重/回写状态); 这条是"手上有一段素材,想马上在这台机器上发" · 扩展名按 `video_plan.MEDIA_EXTS` 校验(视频+图片),不在其中 → 400;设备不在线 → 400 · 临时目录用完即删(`finally` 里 rmtree) · `POST /api/tools/push_clipboard`:与 `/api/tools/clipboard/set` **同一个实现** (`_set_device_clipboard`),只是权限位不同 真机验过(A01):剪贴板注入 ✅;1×1 测试图推到相册 ✅(`verify=no_index` 的 ⚠ 是这张图太小、 媒体库没索引它,真视频/图片是 ✅);`.txt` 被 400 拦住 ✅;测试文件已从手机删掉、不留痕。 文档:doc/API.md 加两条接口(含请求/返回/错误口径)、README 实时看屏那行补上发布面板。
352 lines
17 KiB
Python
352 lines
17 KiB
Python
"""工具域 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 <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):
|
||
"""向单台设备注入剪贴板文字(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)}
|
||
|