From 3422b4f265b68e4a4d7c076077ff013c6803859e Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 13 Sep 2026 17:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=91=E6=8E=A7=E9=A1=B5=E6=8C=89?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E6=8E=92=E5=BA=8F=20+=20=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=94=B9=E3=80=8C=E7=9C=8B=E5=B1=8F=20/=20=E6=89=93=E5=BC=80?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=AB=AF=20Agent=E3=80=8D=EF=BC=9B=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E5=90=8D=E5=8C=B9=E9=85=8D=E8=A1=A5=20USB=20=E8=AE=BE?= =?UTF-8?q?=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈的三件事: 1) **监控页设备排序**改成按**名称**(A01/A02/…),没名字的排最后按地址。 之前是 adb 返回的顺序,看着像按 IP 排,同型号多台时对不上号。 排序放在 `/api/status`(后端),大屏页一并受益。 2) **操作按钮改名 + 换实现**: - 「截图」→「**看屏**」(行为不变,就是查看设备实时画面) - 「定位」→「**打开设备端 Agent**」:改成让设备上的 Agent 弹身份大字页 (名称/IP/平台地址),替代原来"推 HTML 让设备浏览器打开定位页"那套 (那套要设备装浏览器、页面还依赖设备能访问平台,backlog A1/A2 一直没修) * 新接口 `POST /api/agent/show-info {serial, close?}`;没装 Agent 时明确报出来 * **先点亮屏幕 + 解除锁屏再拉起** —— 设备息屏时 am start 会把大字页开在锁屏后面, 人看到的还是一片黑(实测踩过) * 平台地址取操作者当前访问地址;若是 localhost 则换成本机局域网地址 (设备上显示 127.0.0.1 毫无意义) 3) **安装应用的选择设备界面仍显示裸序列号**:USB 设备在 adb 里的 serial 就是 `ro.serialno`,等于池里那条网络记录的**指纹** —— 之前只按 serial 匹配,插着 USB 时会冒出一个没名字的 `s8o7nrt8pbzhfadq`。现在按 serial 或指纹任一命中都取名称, 三处(剪贴板注入 / 安装弹窗 / 安装进度表)统一。 顺带修掉几个自己踩出来的坑: - `device_pool.list_configured()` 返回的是 **serial 字符串列表**,我当成字典用了 (`.get("serial")` 抛异常被 except 吞掉 → 名字全空)→ 改用 `list_devices()` - `web/device_agent_api.py` 用了 `PERM_DEVICES` 但没导入 → 服务直接起不来(502) - Agent 自检里的版本号是写死的常量(升到 1.4 还显示 1.0)→ 改成读包信息 - `doc/API.md` 章节编号(插了一节没把后面整体下移,出现两个 §16) 验证:安装弹窗里 USB 那台现在显示「A07」(指纹匹配);监控页按钮为 「看屏 / 打开设备端 Agent」;对装/未装 Agent 的设备分别得到大字页 / 明确报错; 大字页实测显示 A07 + 192.168.20.250:18050 + Agent v1.4。 --- core/apk_manager.py | 10 +++-- doc/API.md | 18 +++++---- static/admin/monitor.js | 35 +++++++++--------- web/apks_api.py | 22 ++++++++--- web/common.py | 19 +++++++--- web/device_agent_api.py | 82 ++++++++++++++++++++++++++++++++++++++++- web/monitor.py | 5 +++ 7 files changed, 150 insertions(+), 41 deletions(-) diff --git a/core/apk_manager.py b/core/apk_manager.py index 330cd60..d2b2def 100644 --- a/core/apk_manager.py +++ b/core/apk_manager.py @@ -277,12 +277,16 @@ class ApkManager: # 设备名(平台给设备起的名字,如 A08)—— 进度表里显示这个而不是型号: # 8 台同型号时型号根本区分不了,名字才行 - pool_names = {} + pool_names, fp_names = {}, {} try: from core import device_pool - for d in device_pool.list_configured(enabled_only=False): + # list_devices() 返回字典(含 name/fingerprint) + for d in device_pool.list_devices(): if d.get("serial"): pool_names[d["serial"]] = d.get("name") or "" + # USB 设备的 adb serial == ro.serialno == 池里那条记录的指纹 + if d.get("fingerprint"): + fp_names[d["fingerprint"]] = d.get("name") or "" except Exception as e: _log.warning(f"读取设备名失败(进度表将显示地址): {e}") @@ -290,7 +294,7 @@ class ApkManager: items = {} for s in serials: items[s] = { - "name": pool_names.get(s) or s, + "name": pool_names.get(s) or fp_names.get(s) or s, "status": "pending", "msg": "", } diff --git a/doc/API.md b/doc/API.md index 18010a6..8c86f17 100644 --- a/doc/API.md +++ b/doc/API.md @@ -21,9 +21,10 @@ - [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale) - [12. AI 控制台](#12-ai-控制台) - [13. 系统备份](#13-系统备份) -- [14. 非 JSON 响应汇总](#14-非-json-响应汇总) -- [15. 错误分支速查](#15-错误分支速查) -- [16. 已知问题](#16-已知问题) +- [14. 设备端应用商店](#14-设备端应用商店) +- [15. 非 JSON 响应汇总](#15-非-json-响应汇总) +- [16. 错误分支速查](#16-错误分支速查) +- [17. 已知问题](#17-已知问题) --- @@ -48,14 +49,14 @@ ### 1.3 CSRF - `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。 -- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§16 已知问题](#16-已知问题)。 +- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§17 已知问题](#17-已知问题)。 ### 1.4 响应约定 - 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。 - **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs//run` 的"任务类型不存在")。以本文每节标注为准。 - 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。 -- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§14](#14-非-json-响应汇总)。 +- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§15](#15-非-json-响应汇总)。 --- @@ -237,6 +238,7 @@ | GET | `/api/agent-store/config` | 读配置(含设备令牌) | | POST | `/api/agent-store/config` | 启用/停用、重置令牌 | | GET | `/api/agent-store/logs` | 设备端安装记录 | +| POST | `/api/agent/show-info` | 让设备上的 Agent 显示/关闭**身份大字页**(监控页「打开设备端 Agent」按钮)| > 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。 @@ -749,7 +751,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: | 方法 | 路径 | 响应类型 | 说明 | |------|------|---------|------| -| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录) | +| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录;**已被设备端 Agent 的身份页取代**,仅作历史保留)| | GET | `/api/device/screenshot` | `image/png` | 原始截图(`Cache-Control: no-store`) | | GET | `/api/screen/thumb` | `image/jpeg` | 360px 缩略图 + `X-Screen-State` | | GET | `/api/screen/stream` | `multipart/x-mixed-replace` | MJPEG 实时流 | @@ -759,7 +761,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: --- -## 15. 错误分支速查 +## 16. 错误分支速查 | 码 | 典型触发 | |----|---------| @@ -775,7 +777,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: --- -## 16. 已知问题 +## 17. 已知问题 | 问题 | 影响 | 位置 | |------|------|------| diff --git a/static/admin/monitor.js b/static/admin/monitor.js index d9650ff..03496ff 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -70,27 +70,28 @@ async function stopSelectedDevices(){ setTimeout(loadMonitor,500); } -// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标 -// 注意:打开定位页会切换设备前台,任务运行中慎用 -async function locateDevice(serial){ - if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return; - const r=await apiPost('/api/device/locate',{serial:serial,show:true}); +// 打开设备端 Agent:让设备上的 Agent 弹出「身份大字页」(名称/IP/平台地址), +// 人站在机架前一眼看出这台是平台上的哪一台。 +// 替代原来的"推 HTML 让设备浏览器打开定位页"——那条路要设备装浏览器、页面还依赖 +// 设备能访问平台,且在 MIUI 上本来就不稳。 +async function showAgentInfo(serial){ + const r=await apiPost('/api/agent/show-info',{serial:serial}); if(!r)return; if(r.ok){ _locatingSerials.add(serial); - showToast(r.msg||'已定位','success'); + showToast(r.msg||'已让设备显示身份信息','success'); renderDevices(); - }else showToast(r.error||'定位失败','error'); + }else showToast(r.error||'打开失败','error'); } -// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位" -async function stopLocate(serial){ - const r=await apiPost('/api/device/locate/stop',{serial:serial}); +// 关闭设备端 Agent 的身份页 +async function hideAgentInfo(serial){ + const r=await apiPost('/api/agent/show-info',{serial:serial,close:true}); if(!r)return; _locatingSerials.delete(serial); renderDevices(); - if(r.ok)showToast(r.msg||'已结束定位','success'); - else showToast(r.error||'结束定位失败','error'); + if(r.ok)showToast(r.msg||'已关闭','success'); + else showToast(r.error||'关闭失败','error'); } // 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认) @@ -344,13 +345,13 @@ function renderDeviceRow(dev){ // 设备操作按钮需要"设备控制"权限(无权限只保留截图查看) const canDev=_can('devices'); const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'':''; - // 截图按钮(任何状态都可看,离线时会报错) - const shotBtn=''; - // 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页) + // 看屏按钮(任何状态都可看,离线时会报错) + const shotBtn=''; + // 设备端 Agent:开着身份页时显示"关闭",否则"打开设备端 Agent" const locating=_locatingSerials.has(dev.serial); const locateBtn=canDev?(locating - ?'' - :''):''; + ?'' + :''):''; // 异常/失败设备提供"清除异常"(把状态恢复为未启动) const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed') ?'':''; diff --git a/web/apks_api.py b/web/apks_api.py index e3589d9..d56e96d 100644 --- a/web/apks_api.py +++ b/web/apks_api.py @@ -65,23 +65,33 @@ def api_apks_install_devices(): source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。 """ devices, seen = [], set() + name_by_serial, name_by_fp = {}, {} try: online = set(device_pool.list_online()) - for d in device_pool.list_configured(enabled_only=False): + # 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) - except Exception: - pass + 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: - devices.append({"serial": parts[0], "model": "", - "name": "", "source": "usb" if ":" not in parts[0] else "adb"}) - seen.add(parts[0]) + 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"]) diff --git a/web/common.py b/web/common.py index 25e4abb..53f84bf 100644 --- a/web/common.py +++ b/web/common.py @@ -20,20 +20,29 @@ def _merged_device_list(): parts = line.split() if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): states[parts[0]] = parts[1] - names = {} + # 注意用的是 list_devices()(**字典**,含 name/fingerprint); + # list_configured() 只返回 serial 字符串列表,别拿它当字典用(实测踩过:.get 抛异常 + # 被 except 吞掉 → 名字全空) + names, fp_names = {}, {} try: - for d in device_pool.list_configured(enabled_only=False): + for d in device_pool.list_devices(): if d.get("serial"): names[d["serial"]] = d.get("name") or "" - except Exception: - pass + # USB 设备在 adb 里的 serial 就是 ro.serialno,等于池里那条网络记录的 + # **指纹** —— 插着 USB 时会以另一个 serial 出现,靠指纹才认得出是同一台, + # 否则界面上会冒出一个没名字的裸序列号(实测踩过) + if d.get("fingerprint"): + fp_names[d["fingerprint"]] = d.get("name") or "" + except Exception as e: + import logging + logging.getLogger("web").warning(f"读取设备名失败: {e}") try: for serial in device_pool.list_configured(): if serial not in states: states[serial] = "pool" except Exception: pass - return [{"serial": s, "name": names.get(s, ""), "state": st} + return [{"serial": s, "name": names.get(s) or fp_names.get(s) or "", "state": st} for s, st in sorted(states.items())] diff --git a/web/device_agent_api.py b/web/device_agent_api.py index 10b9132..5ade878 100644 --- a/web/device_agent_api.py +++ b/web/device_agent_api.py @@ -28,7 +28,7 @@ from config import APK_DIR from core import device_pool from core.db_config import meta_get, meta_set from core.logger import get_logger -from web.auth import PERM_APKS, perm_required +from web.auth import PERM_APKS, PERM_DEVICES, perm_required bp = Blueprint("device_agent", __name__) _log = get_logger("web.devagent") @@ -86,7 +86,7 @@ def _identify(): if d: return fp, d.get("serial", serial), d.get("name") or "", True if serial: - for d in device_pool.list_configured(enabled_only=False): + for d in device_pool.list_devices(): # 返回字典(list_configured 只有 serial) if d.get("serial") == serial: return fp, serial, d.get("name") or "", True except Exception as e: @@ -264,6 +264,84 @@ def _prune_logs(keep=_MAX_LOG): # ================== 管理端接口(登录 + 权限)================== +@bp.route("/api/agent/show-info", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_agent_show_info(): + """让设备上的 Agent 打开「身份大字页」(监控页那个按钮调它)。 + + 替代原来那套「推 HTML 让设备浏览器打开定位页」——那套在 MIUI 上要装浏览器、 + 页面还依赖设备能访问平台;Agent 是常驻 App,一条 `am start` 就出画面。 + + 请求:{"serial": "…", "close": false} + 返回:{ok, msg};设备没装 Agent 时明确报出来(不静默失败) + """ + from core.adb_helper import _adb + + data = request.json or {} + serial = (data.get("serial") or "").strip() + close = bool(data.get("close")) + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + + if close: + args = ["--es", "close", "1"] + else: + # 设备信息:名称/IP/平台地址 —— Agent 用它们显示大字页 + name, ip = "", "" + ip = serial.split(":")[0] if ":" in serial else "" + for d in device_pool.list_devices(): # 字典列表(list_configured 只有 serial) + if d.get("serial") == serial: + name = d.get("name") or "" + break + if not name: + # USB 设备的 serial 就是指纹:池里那条记录的 serial 是网络地址, + # 靠指纹才认得出来(否则大字页标题会显示一串裸序列号) + for d in device_pool.list_devices(): + if d.get("fingerprint") and d["fingerprint"] == serial: + name = d.get("name") or "" + break + if not ip: + # USB 设备:平台不知道它的 IP,现问一下设备(只读命令,不影响设备) + out = _adb("-s", serial, "shell", "ip", "-4", "addr", "show", "wlan0") + import re as _re + m = _re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", out or "") + ip = m.group(1) if m else "" + # 平台地址:用操作者当前访问的地址;如果是 localhost,设备根本连不上 → + # 换成局域网地址(设备上显示 127.0.0.1 毫无意义) + server = request.host_url.rstrip("/") + if "127.0.0.1" in server or "localhost" in server: + from web.devices_api import _lan_ip + server = "http://%s:18050" % _lan_ip() + args = ["--es", "name", name or serial.split(":")[0], + "--es", "serial", serial, + "--es", "server", server] + if ip: + args += ["--es", "ip", ip] + + # 先点亮屏幕、解除锁屏:设备息屏时 am start 会把大字页开在锁屏**后面**, + # 人看到的还是一片黑(实测踩过)——这正是原来"定位"按钮里的"点亮屏幕"那一步 + if not close: + try: + _adb("-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP") + _adb("-s", serial, "shell", "wm", "dismiss-keyguard") + except Exception as e: + _log.warning("点亮屏幕失败(不影响显示身份页): %s", e) + + try: + out = _adb("-s", serial, "shell", "am", "start", "-n", + "com.example.deviceagent/.InfoActivity", *args) or "" + except Exception as e: + return jsonify({"ok": False, "error": f"adb 执行失败: {e}"}), 500 + + low = out.lower() + if "unable to resolve intent" in low or "does not exist" in low or "error" in low: + return jsonify({"ok": False, + "error": "这台设备没装设备 Agent(com.example.deviceagent)——" + "可以从「应用管理 → 设备端应用商店」装,或用扫码配置"}), 400 + _log.info("设备端 Agent %s: %s", "关闭身份页" if close else "打开身份页", serial) + return jsonify({"ok": True, "msg": "已关闭" if close else "已让设备显示身份信息"}) + + @bp.route("/api/agent-store/config", methods=["GET", "POST"]) @perm_required(PERM_APKS) def api_agent_store_config(): diff --git a/web/monitor.py b/web/monitor.py index 56d3596..144d548 100644 --- a/web/monitor.py +++ b/web/monitor.py @@ -102,6 +102,11 @@ def api_status(): devices, err = context.mgr.get_status() if err: return jsonify({"ok": False, "error": err}), 500 + # 按**名称**排(A01/A02/…)——之前是 adb 返回的顺序,看着像按 IP 排,很别扭。 + # 没名字的排在最后、彼此按地址排(历史数据里可能有没命名的设备)。 + devices.sort(key=lambda d: ( + 1 if not (d.get("device_name") or "").strip() else 0, + (d.get("device_name") or "").strip() or d.get("serial", ""))) return jsonify({ "ok": True, "devices": devices, "server_time": time.time(), "fg_scanning": context.mgr._fg_scanner.is_scanning,