"""应用管理 API(APK 上传/安装/删除/设备已装应用)。""" import re import shlex import subprocess from flask import Blueprint, jsonify, request from flask_login import login_required from core import device_pool from core.adb_helper import adb_connect_light, _adb from web import context from web.auth import perm_required, PERM_APKS from web.common import _merged_device_list bp = Blueprint("apks", __name__) @bp.route("/api/apks") @login_required def api_apks_list(): """列出已上传的 APK —— **按包名合并,一个应用一行(最新版本)**。 同一个应用传过 v1.2 / v1.3 时不再并排显示两行;每行带 versions (全部版本)与 version_count,界面可提示"另有 N 个旧版本"并按需清理。 """ return jsonify({"ok": True, "apks": context.apk_mgr.list_latest()}) @bp.route("/api/apks/upload", methods=["POST"]) @perm_required(PERM_APKS) def api_apks_upload(): """上传 APK 文件(multipart/form-data,字段名 file)。""" file = request.files.get("file") if not file or not file.filename: return jsonify({"ok": False, "error": "未选择文件"}), 400 info = context.apk_mgr.upload(file) if info: return jsonify({"ok": True, "apk": info, "msg": f"上传成功: {info['display_name']}"}) return jsonify({"ok": False, "error": "上传失败,请检查文件格式"}), 500 @bp.route("/api/apks/", methods=["DELETE"]) @perm_required(PERM_APKS) def api_apks_delete(apk_id): """删除 APK。 默认只删这一个版本(列表里一行 = 一个应用,所以界面通常传 scope): ?scope=package → 删掉**这个应用的所有版本** ?scope=older → 只清理**旧版本**,保留最新那版 不带 scope 时保持老行为(单条删除),给脚本/外部调用留兼容。 """ scope = (request.args.get("scope") or "").strip() if scope in ("package", "older"): ok, msg = context.apk_mgr.delete_package( apk_id, keep_latest=(scope == "older")) else: ok, msg = context.apk_mgr.delete(apk_id) if ok: return jsonify({"ok": True, "msg": msg}) return jsonify({"ok": False, "error": msg}), 400 @bp.route("/api/apks/install/devices") @perm_required(PERM_APKS) def api_apks_install_devices(): """可安装设备列表(应用管理):设备池在线设备 + 本机 adb 设备(含 USB 有线连接)。 source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。 """ devices, seen = [], set() name_by_serial, name_by_fp = {}, {} try: online = set(device_pool.list_online()) # list_devices() 返回字典(含 name/fingerprint);list_configured() 只有 serial 字符串 for d in device_pool.list_devices(): serial = d.get("serial") if serial in online and serial not in seen: devices.append({"serial": serial, "model": "", "name": d.get("name") or "", "source": "pool"}) seen.add(serial) if serial: name_by_serial[serial] = d.get("name") or "" # USB 设备的 adb serial == ro.serialno == 池里那条网络记录的指纹, # 靠指纹才认得出是同一台(否则插着 USB 时会冒出一个没名字的裸序列号) if d.get("fingerprint"): name_by_fp[d["fingerprint"]] = d.get("name") or "" except Exception as e: _log.warning("读取设备名失败(列表将只显示地址): %s", e) out = _adb("devices") for line in (out or "").splitlines()[1:]: parts = line.split() if len(parts) >= 2 and parts[0] and parts[0] not in seen: s = parts[0] devices.append({"serial": s, "model": "", "name": name_by_serial.get(s) or name_by_fp.get(s) or "", "source": "usb" if ":" not in s else "adb"}) seen.add(s) return jsonify({"ok": True, "devices": devices}) @bp.route("/api/apks/install", methods=["POST"]) @perm_required(PERM_APKS) def api_apks_install(): """批量安装 APK 到指定设备。参数: {apk_id, serials:[]}""" data = request.json or {} apk_id = data.get("apk_id", "") serials = data.get("serials", []) ok, msg = context.apk_mgr.install(apk_id, serials) if ok: return jsonify({"ok": True, "msg": msg}) return jsonify({"ok": False, "error": msg}), 400 @bp.route("/api/apks/install/status") @login_required def api_apks_install_status(): """获取安装任务实时状态。""" status = context.apk_mgr.get_install_status() return jsonify({"ok": True, "status": status}) # ================== API:维护(仅管理员) ================== # 维护功能:STF 容器一键重启 + adb 远程终端。两个接口都仅管理员可用。 # adb 终端安全红线(技术约束):绝不 kill-server / 绝不 disconnect IP:5555—— # STF provider 共享该 adb transport,断开会让 STF 误判全部设备离线并触发重连。 _ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect") def _blocked_adb_cmd(cmd): """命中红线的 adb 命令(kill-server / disconnect)直接拒绝。""" low = cmd.lower() return any(p in low for p in _ADB_BLOCKED_PATTERNS) def _merged_device_list(): """本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接; stf_not_ready=STF agent 未就绪(迁移期保留,阶段 3 摘除 STF 后删除)。 """ states = {} out = _adb("devices") for line in (out or "").splitlines()[1:]: parts = line.split() if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): states[parts[0]] = parts[1] try: for serial in device_pool.list_configured(): if serial not in states: states[serial] = "pool" except Exception: pass return [{"serial": s, "state": st} for s, st in sorted(states.items())]