diff --git a/core/apk_manager.py b/core/apk_manager.py index f4ebff9..d2b2def 100644 --- a/core/apk_manager.py +++ b/core/apk_manager.py @@ -127,7 +127,7 @@ class ApkManager: # ================== 列表 ================== def list_all(self): - """列出所有 APK。""" + """列出所有 APK(**每个上传版本一行**,内部用)。""" try: with self._db(): rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all() @@ -136,6 +136,42 @@ class ApkManager: _log.error(f"列出 APK 失败: {e}") return [] + def list_latest(self): + """按**包名**合并后的列表:一个应用一行,只显示最新版本。 + + 为什么:同一个应用传过多个版本(v1.2 / v1.3)时,列表里会并排出现两行—— + 用户看到的是"同一个应用怎么重复了"。这里合并成一行(最新版), + 其余版本的信息放在 `versions` 里,界面可以提示"另有 N 个旧版本"并按需清理。 + + 管理页和设备端商店都用这个(设备上选应用时同样不该出现两行同一个 App)。 + 解析不出包名的 APK 各自独立成行,不做合并(避免把无关文件混在一起)。 + """ + rows = self.list_all() + groups = {} + for r in rows: + pkg = (r.get("package_name") or "").strip() + key = pkg if pkg else ("__single__" + str(r.get("id"))) + groups.setdefault(key, []).append(r) + + def sort_key(x): + return (x.get("version_code") or 0, x.get("upload_time") or "") + + out = [] + for items in groups.values(): + items.sort(key=sort_key, reverse=True) + top = dict(items[0]) + top["version_count"] = len(items) + top["versions"] = [{ + "id": i.get("id"), + "version_name": i.get("version_name") or "", + "version_code": i.get("version_code") or 0, + "size": i.get("size") or 0, + "upload_time": i.get("upload_time") or "", + } for i in items] + out.append(top) + out.sort(key=lambda x: x.get("upload_time") or "", reverse=True) + return out + # ================== 删除 ================== def delete(self, apk_id): """删除 APK 文件和数据库记录。""" @@ -161,6 +197,47 @@ class ApkManager: _log.error(f"删除 APK 失败: {e}") return False, str(e) + def delete_package(self, apk_id, keep_latest=False): + """按**包名**删除:一个应用的所有版本一起处理。 + + keep_latest=True → 只删旧版本、保留最新那版("清理旧版本") + keep_latest=False → 整个应用删掉(所有版本) + 没有包名的 APK 退回单条删除。 + """ + try: + with self._db(): + row = ApkRow.query.get(apk_id) + if not row: + return False, "APK 不存在" + pkg = (row.package_name or "").strip() + if not pkg: + return self.delete(apk_id) + + items = ApkRow.query.filter_by(package_name=pkg).all() + items.sort(key=lambda r: ((r.version_code or 0), + (r.upload_time or "")), reverse=True) + targets = items[1:] if keep_latest else items + if not targets: + return True, "没有需要清理的旧版本" + name = row.display_name or pkg + for r in targets: + p = os.path.join(APK_DIR, r.filename) + if os.path.exists(p): + try: + os.remove(p) + except Exception as e: + _log.warning(f"删除 APK 文件失败: {e}") + db.session.delete(r) + db.session.commit() + _log.info(f"已删除 {name} 的 {len(targets)} 个版本" + f"({'保留最新' if keep_latest else '整个应用'})") + if keep_latest: + return True, f"已清理 {name} 的 {len(targets)} 个旧版本(保留最新)" + return True, f"已删除 {name}(共 {len(targets)} 个版本)" + except Exception as e: + _log.error(f"按包名删除失败: {e}") + return False, str(e) + # ================== 批量安装 ================== def install(self, apk_id, serials): """批量安装 APK 到指定设备(后台线程执行)。 @@ -198,13 +275,26 @@ class ApkManager: # 获取 worker 状态(判断设备是否运行中) worker_status = {w["serial"]: w for w in get_all_worker_status()} + # 设备名(平台给设备起的名字,如 A08)—— 进度表里显示这个而不是型号: + # 8 台同型号时型号根本区分不了,名字才行 + pool_names, fp_names = {}, {} + try: + from core import device_pool + # 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}") + # 构造安装任务 items = {} for s in serials: - dev = all_devices.get(s, {}) - model = dev.get("model") or dev.get("manufacturer") or s items[s] = { - "name": model, + "name": pool_names.get(s) or fp_names.get(s) or s, "status": "pending", "msg": "", } @@ -308,20 +398,17 @@ class ApkManager: "verifier_verify_adb_installs", "0") except Exception: pass - # 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用) - self._set_item(serial, "installing", "正在推送 APK...") - r = subprocess.run( - [ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"], - capture_output=True, timeout=_INSTALL_TIMEOUT - ) - push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace") - if r.returncode != 0 or "1 file pushed" not in push_out: - msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})" - self._set_item(serial, "failed", msg) - _log.warning(f"[{serial}] APK 推送失败: {msg}") + # 2. 推送 APK 到设备(分块 + 实时进度) + ok_push, msg_push = self._push_with_progress(serial, apk_path) + if not ok_push: + self._set_item(serial, "failed", msg_push) + _log.warning(f"[{serial}] APK 推送失败: {msg_push}") return # 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本) - self._set_item(serial, "installing", "正在安装...") + # 这一步在**设备端**解包/优化,耗时随包大小增长(抖音 336MB 实测 ~65s), + # 但系统没给进度接口,只能给出预期时间,别让用户以为是卡住了。 + self._set_item(serial, "installing", + "正在设备上安装...(大包要 1-2 分钟,无进度可读)") r = subprocess.run( [ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d", "/data/local/tmp/_install.apk"], @@ -385,6 +472,101 @@ class ApkManager: # adb 连接保持即可,不影响设备和后续操作。 pass + def _push_with_progress(self, serial, apk_path, + remote="/data/local/tmp/_install.apk"): + """把 APK 分块写进设备,边传边更新进度。返回 (ok, msg)。 + + 为什么不用 `adb push`:它只在**结束时**吐一行汇总 + ("1 file pushed, 3.0 MB/s"),大包(抖音 336MB ≈ 1.8 分钟/台)整个传输过程 + 界面上只有一句"正在推送 APK...",用户只能干等 —— 这就是要优化的问题。 + + 改用 `exec-in` 分块写:每块回调一次进度(百分比/已传/速度/剩余时间)。 + 实测速度略降(2.6 vs 3.1 MB/s),换来看得见的进度,划算。 + 传完校验设备上的文件大小;exec-in 不可用时退回原来的 `adb push`。 + """ + total = os.path.getsize(apk_path) + chunk = 1 << 20 # 1MB:再大提升有限,再小 adb 调用开销明显 + t0 = time.time() + sent = 0 + last_report = 0.0 + try: + proc = subprocess.Popen( + [ADB_PATH, "-s", serial, "exec-in", "cat > %s" % remote], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + except Exception as e: + _log.warning(f"[{serial}] exec-in 启动失败,退回 adb push: {e}") + return self._push_fallback(serial, apk_path, remote) + + try: + with open(apk_path, "rb") as f: + while True: + buf = f.read(chunk) + if not buf: + break + proc.stdin.write(buf) + sent += len(buf) + now = time.time() + if now - last_report >= 0.8 or sent >= total: + last_report = now + spent = max(now - t0, 0.001) + speed = sent / spent / 1048576.0 # MB/s + left = int((total - sent) / max(sent / spent, 1)) + self._set_item( + serial, "installing", + "推送中 %d%%(%.1f/%.1f MB · %.1f MB/s · 剩约 %ds)" + % (sent * 100 // max(total, 1), sent / 1048576.0, + total / 1048576.0, speed, left)) + proc.stdin.close() + except Exception as e: + try: + proc.kill() + except Exception: + pass + return False, f"推送中断: {e}" + + try: + rc = proc.wait(timeout=_INSTALL_TIMEOUT) + except subprocess.TimeoutExpired: + proc.kill() + return False, "推送超时(设备无响应)" + if rc != 0: + err = "" + try: + err = (proc.stderr.read() or b"").decode("utf-8", "replace")[:150] + except Exception: + pass + _log.warning(f"[{serial}] exec-in 推送到一半失败(rc={rc}),退回 adb push") + return self._push_fallback(serial, apk_path, remote) + + # 校验设备上文件大小一致(exec-in 中途断流可能"成功"但少字节) + try: + rs = subprocess.run( + [ADB_PATH, "-s", serial, "shell", "stat", "-c", "%s", remote], + capture_output=True, timeout=30) + got = int((rs.stdout or b"").decode("utf-8", "replace").strip().splitlines()[-1]) + if got != total: + return False, f"推送不完整(设备 {got} / 本地 {total} 字节)" + except Exception: + pass # stat 参数各机型有差异,取不到就跳过校验 + + return True, "推送完成 %.1fMB / %.0fs" % (total / 1048576.0, time.time() - t0) + + def _push_fallback(self, serial, apk_path, remote): + """老 adb 或 exec-in 失败时的退路:原来的 `adb push`(无进度)。""" + self._set_item(serial, "installing", "正在推送 APK...(无进度显示)") + try: + r = subprocess.run( + [ADB_PATH, "-s", serial, "push", apk_path, remote], + capture_output=True, timeout=_INSTALL_TIMEOUT) + except subprocess.TimeoutExpired: + return False, "推送超时" + out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace") + if r.returncode != 0 or "1 file pushed" not in out: + return False, (out.strip().replace("\n", " ")[:150] + or f"推送失败(returncode={r.returncode})") + return True, "推送完成(adb push)" + def _set_item(self, serial, status, msg=""): """更新安装任务中某设备的状态。""" if not self._install_task: diff --git a/doc/API.md b/doc/API.md index bd165d3..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-响应汇总)。 --- @@ -150,6 +151,7 @@ | POST | `/api/devices/pool/remove` | D | 从池中删除 | | POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) | | POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 | +| GET | `/api/devices/qrcode?serial=` | D | 生成该设备的**配置二维码**(设备端 Agent 扫码即配好) | | POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 | | GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 | | POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 | @@ -236,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)。 @@ -461,8 +464,31 @@ | 任务编辑器「指定设备」下拉 | `名称 · serial` | | 分组编辑的设备勾选列表 | `名称 · serial` | | 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 | +| 剪贴板注入的设备勾选列表 | `名称 · serial`(无名只显示地址) | +| 应用管理的安装弹窗设备列表 | `名称 · serial` | +| 应用版本管理表格的「设备」列 | `名称 · serial` | +| 应用安装进度表的「设备」列 | 名称(取自设备池;无名退地址) | | MCP `de_list_devices` | 返回 `name` 字段,提示 AI 汇报时用名称 | +> 这几个接口的字段来源:`/api/adb/devices`、`/api/apks/install/devices` 走 +> `web/common.py:_merged_device_list()`(带 `name`);`/api/tools/appver` 把 +> `name` 塞进每个设备的结果里。 + +### 6.2.2 设备配置二维码(扫码配置) + +| 接口 | 鉴权 | 请求 | 说明 | +|------|------|------|------| +| `GET /api/devices/qrcode?serial=<地址>` | 设备权限 | — | 返回 `{png(base64 data-url), payload, warn}` | + +给设备端 Agent 扫的配置二维码,一次写入**平台地址 + 设备令牌 + 该设备指纹**: + +- `payload` 是二维码里的原始 JSON:`{"v":1,"server":…,"token":…,"fingerprint":…,"name":…}` +- `warn` **非空时必须显示**给操作者,最常见两种: + - "你是用 localhost 打开平台的" → 二维码里的地址手机会连不上,要改用局域网地址重开本页 + - "这台设备还没采集到指纹" → 扫码后平台认不出它是哪一台 +- 前置条件:先在「应用管理 → 设备端应用商店」**启用**(令牌从那儿来),否则 400 +- 设备端如何解析见 [DEVICE_AGENT.md](DEVICE_AGENT.md) §5.3 + ### 6.3 自动发现 | 接口 | 请求 | 说明 | @@ -517,7 +543,18 @@ | `DELETE /api/apks/` | — | 删文件 + 记录 | | `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 | | `GET /api/apks/install/devices` | — | 可安装设备(pool / usb / adb 三个来源) | -| `GET /api/apks/install/status` | — | 安装进度(前端 3s 轮询) | +| `GET /api/apks/install/status` | — | 安装进度(前端 1.5s 轮询) | + +> 每台设备的状态机:`pending`(等待)→ `installing`(**带文字的实时进度**)→ `success` / `failed` / `skipped`。 +> 安装分两段,`items[serial].msg` 会跟着更新: +> 1. **推送到设备**:`推送中 62%(211.0/336.1 MB · 2.6 MB/s · 剩约 48s)` —— 用分块写 +> (`adb exec-in`)拿字节级进度;不用 `adb push` 是因为它只在结束时吐一行汇总, +> 大包(抖音 336MB≈2 分钟)整个传输过程界面上只能干等 +> 2. **设备上安装**:`正在设备上安装...(大包要 1-2 分钟,无进度可读)` —— `pm install` +> 在设备端解包,系统没给进度接口 +> +> 参考耗时(336MB 抖音 / 单台):推送 ~134s + 安装 ~65s ≈ 3.3 分钟;多台并发(上限 5)时 +> 带宽共享,整体时间取决于 WiFi。 --- @@ -714,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 实时流 | @@ -724,7 +761,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: --- -## 15. 错误分支速查 +## 16. 错误分支速查 | 码 | 典型触发 | |----|---------| @@ -740,7 +777,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: --- -## 16. 已知问题 +## 17. 已知问题 | 问题 | 影响 | 位置 | |------|------|------| diff --git a/doc/DEVICE_AGENT.md b/doc/DEVICE_AGENT.md index d4471bf..5fff979 100644 --- a/doc/DEVICE_AGENT.md +++ b/doc/DEVICE_AGENT.md @@ -120,7 +120,28 @@ - `404`:id 不存在或平台侧文件已删(**注意与"未启用"的 404 区分:看响应体**, 未启用时是 JSON `{"ok":false,...}`,文件缺失时也是 JSON —— 设备端拿到非 `application/...` 的响应就视为失败) -### 3.4 `POST /api/device/agent/report` +### 3.4 `POST /api/device/agent/install`(可选:让平台帮你静默装) + +**背景**:设备上的 App 自己调系统安装器,MIUI 上要过「继续 → 勾选未经安全检测 → +继续更新」甚至 ICP 备案检查好几步;而**平台用 adb 装是静默的**(实测升级 2.7s、 +全新安装 7.9s、336MB 大包都没弹过一次框)。 + +所以"安装/更新"按钮**建议走这个接口**:用户在手机上选(商店体验),平台用 adb 落地(静默)。 + +**请求体**:`{"apk_id": "56b114aa"}` + +**响应**: +```json +{"ok": true, "msg": "开始安装 X 到 1 台设备", "serial": "192.168.20.100:5555"} +``` + +- 受理后平台**异步**安装,设备端应轮询本机该包的 versionCode 变化来判断结果 + (装上后按 §3.5 上报 `install_ok`) +- 平台装不了(比如 MIUI 拦了 adb 安装、设备不在池里)→ `ok=false` + `error`, + **设备端应退回本机安装**(`ACTION_VIEW` + FileProvider,会弹系统确认框,但至少能装) +- `409` = 这台设备还没登记进平台设备池(平台不认识它,不能替它装) + +### 3.5 `POST /api/device/agent/report` 回报下载/安装结果,平台侧留档(面板可见)。 @@ -174,7 +195,7 @@ adb -s shell am start -n /.ClipActivity --es text_b64 shell am start -n /.InfoActivity \ @@ -185,7 +206,27 @@ adb -s shell am start -n /.InfoActivity \ 要求:全屏大字、再次调用可覆盖内容、`close` 可关闭、不驻留前台。 -### 5.3 一次性配置下发(建议设备端实现) +### 5.3 扫码配置(设备端已实现) + +平台「设备池」里点某台设备的「**二维码**」→ 生成一张二维码 → 手机上的 Agent 点 +「扫码配置」扫一下,就把 **平台地址 + 设备令牌 + 这台设备的指纹** 一次写入。 + +**二维码内容**(JSON 文本,设备端按字段名解析,多余的字段忽略): +```json +{"v":1,"server":"http://192.168.20.250:18050","token":"<设备令牌>", + "fingerprint":"s8o7nrt8pbzhfadq","name":"A07"} +``` + +- `v` = 格式版本(当前 1);`fingerprint` 可能为空串(平台还没采到指纹) +- 设备端扫到**不是本平台的二维码**时应提示并继续扫,不要静默失败 +- 平台侧生成接口:`GET /api/devices/qrcode?serial=<设备地址>`(需登录 + 设备权限), + 返回 `{png( base64 data-url), payload, warn}`;`warn` 非空时要显示出来 + (最常见的是"你现在用 localhost 打开平台,二维码里的地址手机会连不上") + +> 与 §5.4 的 adb 下发**等价、互为补位**:设备已经连上平台时用 adb 批量下发(零操作), +> 人在机器旁或设备还没接进来时用扫码(不用线、不用打字)。 + +### 5.4 一次性配置下发(adb,建议设备端实现) ```bash adb -s shell am start -n /.ConfigActivity \ diff --git a/requirements.txt b/requirements.txt index f263fb8..ea005f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,10 @@ Flask-SQLAlchemy>=3.0,<4.0 # 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。 PyMySQL>=1.1 +# 设备端 Agent 的「扫码配置」二维码(设备池面板生成,手机扫一下配好 server/token/指纹)。 +# 纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库。 +qrcode>=7.4 + # 定时任务调度 APScheduler>=3.10,<4.0 diff --git a/scripts/start.sh b/scripts/start.sh index 155709f..7e4529a 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -16,7 +16,7 @@ python3 - <<'PY' import importlib.util, sys mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler", "uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL", - "fastmcp", "pymysql") + "fastmcp", "pymysql", "qrcode") if all(importlib.util.find_spec(m) for m in mods): try: import cv2 diff --git a/static/admin/apps.js b/static/admin/apps.js index c8f5efb..87396b5 100644 --- a/static/admin/apps.js +++ b/static/admin/apps.js @@ -29,15 +29,22 @@ async function loadApks(){ await fillAppsDeviceList(); } function renderApkRow(a){ + // 一行 = 一个应用(列表已按包名合并,显示最新版);旧版本给个数提示 + 清理入口 + const n=a.version_count||1; + const verCell=(a.version_name?esc(a.version_name):'-')+ + (n>1?' (另有 '+(n-1)+' 个旧版本)':''); + const clean=n>1?' ':''; + const title=n>1?('共 '+n+' 个版本:'+ (a.versions||[]).map(v=>v.version_name||'?').join(' / ')):''; return ''+ ''+esc(a.display_name||'(未命名)')+''+ ''+esc(a.package_name||'-')+''+ - ''+(a.version_name?esc(a.version_name):'-')+''+ + ''+verCell+''+ ''+fmtSize(a.size)+''+ ''+esc(a.upload_time)+''+ ''+ ' '+ - ''+ + clean+ + ''+ ''+ ''; } @@ -50,7 +57,7 @@ async function fillAppsDeviceList(){ const cur=sel.value; const devices=(sr.devices||[]).filter(d=>d.present); sel.innerHTML=''+devices.map(d=> - '' + '' ).join(''); if(cur)sel.value=cur; } @@ -140,9 +147,20 @@ async function uploadApk(input){ input.value=''; } -async function deleteApk(id){ - if(!confirm('确认删除此APK文件?'))return; - const r=await apiDelete('/api/apks/'+id); +async function deleteApk(id, versionCount){ + const n=versionCount||1; + const msg = n>1 + ? ('确认删除这个应用?\n\n它一共有 '+n+' 个版本,会**全部**删掉(想只留最新版请用「清理旧版本」)。') + : '确认删除此APK文件?'; + if(!confirm(msg))return; + // 一行 = 一个应用 → 删除即删掉该包的所有版本 + const r=await apiDelete('/api/apks/'+id+'?scope=package'); + handleResult(r,loadApks); +} + +async function cleanOldVersions(id, name, oldCount){ + if(!confirm('清理「'+name+'」的 '+oldCount+' 个旧版本?\n\n只保留最新版,旧版本文件会被删除。'))return; + const r=await apiDelete('/api/apks/'+id+'?scope=older'); handleResult(r,loadApks); } @@ -168,8 +186,8 @@ async function openInstallModal(apkId){ online.map(d=>{ const isUsb=d.source==='usb'; const st=''+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+''; - // serial 唯一,显示 IP + 型号便于区分同型号设备 - const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备')); + // 一律「名称 · 地址」——同型号多台时靠名称区分;没有名称的设备(不在池里)只显示地址 + const label=devText(d.name, d.serial); return ''; }).join('')+ ''; @@ -210,7 +228,7 @@ function showInstallProgress(){ ''; pollInstallStatus(); if(_installTimer)clearInterval(_installTimer); - _installTimer=setInterval(pollInstallStatus,3000); + _installTimer=setInterval(pollInstallStatus,1500); } async function pollInstallStatus(){ @@ -226,10 +244,13 @@ async function pollInstallStatus(){ const items=st.items||{}; const itemRows=Object.entries(items).map(([serial,info])=>{ const s=SS[info.status]||{t:info.status,c:'default'}; + // 推送进度带百分比时补一条细进度条(大包传输要一两分钟,光有文字不够直观) + const m=/推送中 (\d+)%/.exec(info.msg||''); + const bar=m?'
':''; return ''+esc(info.name||serial)+''+ ''+esc(serial)+''+ ''+s.t+''+ - ''+esc(info.msg||'')+''; + ''+esc(info.msg||'')+bar+''; }).join(''); const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0; 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/static/admin/tools.js b/static/admin/tools.js index f1adb81..c5d9af9 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -35,7 +35,7 @@ async function loadToolsDevices(force){ const stCls = d.state==='device' ? 'label-success' : 'label-warning'; return ''; }).join(''); status.textContent = '共 '+_clipDevices.length+' 台设备'; @@ -252,12 +252,14 @@ async function queryAppVers(){ } } function renderAppVerRow(x){ - if(x.error)return ''+esc(x.serial)+''+ + // 设备列显示「名称 · 地址」(名称来自设备池;没名字就只显示地址) + const dev=''+esc(devText(x.name,x.serial))+''; + if(x.error)return ''+dev+ '查询失败'+esc(x.error)+''; - if(x.installed)return ''+esc(x.serial)+''+ + if(x.installed)return ''+dev+ '已安装'+ ''+esc(x.version_name||'')+''+esc(x.version_code||'')+''; - return ''+esc(x.serial)+''+ + return ''+dev+ '未安装--'; } @@ -496,6 +498,7 @@ async function loadDevPool(){ ''+esc(d.created_at||'')+''+ ''+ ' '+ + ' '+ (d.online ? '' : ' ') + ' '+ @@ -570,3 +573,37 @@ async function refreshPoolModels(){ if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);} else showToast(r.error,'error'); } + +// ================== 设备配置二维码(设备端 Agent 扫码即配好) ================== +// 二维码内容:平台地址 + 设备令牌 + 这台设备的指纹(JSON)。 +// 手机上的 Agent 扫一下 → 保存这三项 → 不用在手机上打字、也不用连 adb。 +async function showDevQrcode(serial){ + document.getElementById('modal-title').textContent='设备配置二维码'; + document.getElementById('modal-body').innerHTML='

生成中...

'; + document.getElementById('modal-footer').innerHTML= + ''; + showModal(); + const r=await apiGet('/api/devices/qrcode?serial='+encodeURIComponent(serial)); + const box=document.getElementById('modal-body'); + if(!r||!r.ok){ + box.innerHTML='
'+esc((r&&r.error)||'生成失败')+'
'; + return; + } + const warn=r.warn?'
⚠️ '+esc(r.warn)+'
':''; + box.innerHTML= + warn+ + '
'+ + '配置二维码'+ + '
'+esc(r.name||'(未命名)')+ + ' '+esc(r.serial)+'
'+ + '
'+ + '
在设备上打开 设备 Agent → 点「扫码配置」→ 扫这个二维码,'+ + '平台地址、令牌、指纹一次写入,不用在手机上打字。
'+ + '
'+ + '查看二维码内容(排查用)'+ + '
'+esc(r.payload)+'
'; +} diff --git a/web/apks_api.py b/web/apks_api.py index a754445..d56e96d 100644 --- a/web/apks_api.py +++ b/web/apks_api.py @@ -17,8 +17,12 @@ bp = Blueprint("apks", __name__) @bp.route("/api/apks") @login_required def api_apks_list(): - """列出所有已上传的 APK。""" - return jsonify({"ok": True, "apks": context.apk_mgr.list_all()}) + """列出已上传的 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) @@ -36,8 +40,19 @@ def api_apks_upload(): @bp.route("/api/apks/", methods=["DELETE"]) @perm_required(PERM_APKS) def api_apks_delete(apk_id): - """删除 APK 文件和记录。""" - ok, msg = context.apk_mgr.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 @@ -50,21 +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 serial in device_pool.list_configured(): - if serial in online: - devices.append({"serial": serial, "model": "", "source": "pool"}) + # 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": "", - "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 241f8b2..53f84bf 100644 --- a/web/common.py +++ b/web/common.py @@ -7,10 +7,12 @@ _log = get_logger("web") def _merged_device_list(): - """本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 + """本地 adb(含 USB)+ 设备池的合并列表([{serial, name, state}])。 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 + name 来自设备池(平台给设备起的名字,如 A08)—— 界面上统一按「名称 · 地址」 + 显示,同型号多台时才分得清是哪一台;池里没有的设备 name 为空,界面回退显示地址。 """ states = {} out = _adb("devices") @@ -18,13 +20,30 @@ def _merged_device_list(): parts = line.split() if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): states[parts[0]] = parts[1] + # 注意用的是 list_devices()(**字典**,含 name/fingerprint); + # list_configured() 只返回 serial 字符串列表,别拿它当字典用(实测踩过:.get 抛异常 + # 被 except 吞掉 → 名字全空) + names, fp_names = {}, {} + try: + for d in device_pool.list_devices(): + if d.get("serial"): + names[d["serial"]] = d.get("name") or "" + # 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, "state": st} for s, st in sorted(states.items())] + return [{"serial": s, "name": names.get(s) or fp_names.get(s) or "", "state": st} + for s, st in sorted(states.items())] def _device_screen_state(serial): diff --git a/web/device_agent_api.py b/web/device_agent_api.py index 357ac93..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: @@ -110,7 +110,9 @@ def api_dev_agent_bootstrap(): from web import context apks = [] try: - for a in context.apk_mgr.list_all(): + # 用「按包名合并」的列表:同一个应用只出现一行(最新版), + # 设备上不会看到同一个 App 的 v1.2 / v1.3 两行 + for a in context.apk_mgr.list_latest(): apks.append({ "id": a.get("id", ""), "display_name": a.get("display_name") or a.get("filename") or "", @@ -162,6 +164,48 @@ def api_dev_agent_apk(apk_id): mimetype="application/vnd.android.package-archive") +@bp.route("/api/device/agent/install", methods=["POST"]) +def api_dev_agent_install(): + """设备请求**平台**帮它装这个应用(平台走 adb,静默,没有系统安装框)。 + + 为什么这么设计:普通 App 自己调系统安装器,MIUI 上要过「继续 → 勾选未检测 + → 继续更新」甚至 ICP 备案检查好几步;而 `adb install` 在同一台设备上实测是 + **静默**的(升级 2.7s、全新安装 7.9s,零弹框)。 + + 所以设备端商店的「安装/更新」按钮走这里:**用户还是在手机上选**(应用商店体验), + 真正干活的是平台,几秒钟装完。万一 adb 被 MIUI 拦了,返回 ok=false, + 设备端自己退回本地安装(有弹框,但至少能装)。 + """ + deny = _deny() + if deny: + return deny + data = request.json or {} + apk_id = str(data.get("apk_id", "")) + if not apk_id: + return jsonify({"ok": False, "error": "缺少 apk_id"}), 400 + + fp, serial, name, known = _identify() + if not serial: + return jsonify({"ok": False, "error": "无法识别设备"}), 400 + if not known: + return jsonify({"ok": False, "error": "这台设备还没登记进平台设备池"}), 409 + + from core.models import ApkFile + if not ApkFile.query.get(apk_id): + return jsonify({"ok": False, "error": "apk not found"}), 404 + + from web import context + try: + ok, msg = context.apk_mgr.install(apk_id, [serial]) + except Exception as e: + _log.error("设备端请求安装失败: %s", e) + return jsonify({"ok": False, "error": "平台安装任务启动失败"}), 500 + _log.info("设备端请求安装: %s(%s) apk=%s -> %s", name or "未登记", serial, apk_id, msg) + if not ok: + return jsonify({"ok": False, "error": msg}), 400 + return jsonify({"ok": True, "msg": msg, "serial": serial}) + + @bp.route("/api/device/agent/report", methods=["POST"]) def api_dev_agent_report(): """设备上报下载/安装结果,平台侧留下记录(应用管理页可见)。 @@ -220,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/devices_api.py b/web/devices_api.py index cf25035..22c55f9 100644 --- a/web/devices_api.py +++ b/web/devices_api.py @@ -12,6 +12,100 @@ from web.auth import perm_required, PERM_DEVICES _log = get_logger("web") bp = Blueprint("devices", __name__) + +def _lan_ip(): + """挑一个手机连得上的本机地址:优先 192.168.x,其次 10.x。 + + 不能用 gethostbyname(hostname) —— 这台机器上它可能返回 Tailscale 的 + 100.x(CGNAT 地址,手机不在 tailnet 里根本连不上),或 docker 的 172.17。 + """ + import socket + candidates = [] + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + ip = info[4][0] + if ip.startswith("127.") or ip.startswith("169.254."): + continue + candidates.append(ip) + except Exception: + pass + for ip in candidates: + if ip.startswith("192.168."): + return ip + for ip in candidates: + if ip.startswith("10."): + return ip + return candidates[0] if candidates else "本机IP" + + +@bp.route("/api/devices/qrcode") +@perm_required(PERM_DEVICES) +def api_devices_qrcode(): + """给某台设备生成「配置二维码」——设备端 Agent 扫一下就把自己配好。 + + 二维码里是一段 JSON(平台地址 + 设备令牌 + 这台设备的指纹),扫码即完成: + 填 server/token/指纹 三件事,不用在手机上打字,也不用连 adb。 + + ⚠️ 两条使用前提(都会在返回里给提示,不静默失败): + 1. 平台要先在「应用管理 → 设备端应用商店」启用(令牌是从那儿来的) + 2. **要用局域网地址打开本页面**(二维码里的地址取自当前访问地址; + 如果你是用 localhost 打开的,手机会连不上,这里会明确告诉你) + """ + import base64 + import io + import json as _json + + serial = (request.args.get("serial") or "").strip() + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + + dev = None + for d in device_pool.list_devices(): + if d.get("serial") == serial: + dev = d + break + if dev is None: + return jsonify({"ok": False, "error": "设备不在池里"}), 404 + + # 令牌必须先启用设备端商店才有 + from core.db_config import meta_get + from web.device_agent_api import store_enabled, ensure_token + if not store_enabled(): + return jsonify({"ok": False, + "error": "还没启用设备端商店:先到「应用管理 → 设备端应用商店」勾选启用"}), 400 + token = ensure_token() + + server = request.host_url.rstrip("/") + warn = "" + if "127.0.0.1" in server or "localhost" in server: + warn = ("你现在是用本机地址打开平台的,二维码里的地址手机会连不上 —— " + "请改用局域网地址(如 http://%s:18050)重新打开本页再生成。" + % _lan_ip()) + + fp = dev.get("fingerprint") or "" + if not fp: + warn = (warn + " " if warn else "") + \ + "这台设备还没采集到指纹,扫码后平台认不出它是哪一台(设备端会显示「未登记」);" \ + "建议先让它在线并刷新一次型号/指纹,再生成二维码。" + + payload = {"v": 1, "server": server, "token": token, + "fingerprint": fp, "name": dev.get("name") or ""} + text = _json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + try: + import qrcode + img = qrcode.make(text) + buf = io.BytesIO() + img.save(buf, format="PNG") + png = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + except Exception as e: + _log.error(f"生成二维码失败: {e}") + return jsonify({"ok": False, "error": f"生成二维码失败: {e}"}), 500 + + _log.info(f"生成设备配置二维码: {dev.get('name') or serial}") + return jsonify({"ok": True, "serial": serial, "name": dev.get("name") or "", + "fingerprint": fp, "png": png, "payload": text, "warn": warn}) + @bp.route("/api/devices/pool", methods=["GET"]) @perm_required(PERM_DEVICES) def api_devices_pool_list(): 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, diff --git a/web/tools_api.py b/web/tools_api.py index e61fb6d..0758f99 100644 --- a/web/tools_api.py +++ b/web/tools_api.py @@ -156,6 +156,9 @@ def api_tools_appver(): 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)})