Merge branch 'dev' into main——本批:APK 安装进度/静默安装接口/应用列表合并/设备名显示/配置二维码/监控页排序与按钮

dev 侧 6 个提交整体合入 main(对应之前按红线撤回的商店功能,现已在 dev 验收)。
This commit is contained in:
2026-09-13 17:37:56 +08:00
14 changed files with 670 additions and 77 deletions
+198 -16
View File
@@ -127,7 +127,7 @@ class ApkManager:
# ================== 列表 ================== # ================== 列表 ==================
def list_all(self): def list_all(self):
"""列出所有 APK。""" """列出所有 APK(**每个上传版本一行**,内部用)。"""
try: try:
with self._db(): with self._db():
rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all() rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all()
@@ -136,6 +136,42 @@ class ApkManager:
_log.error(f"列出 APK 失败: {e}") _log.error(f"列出 APK 失败: {e}")
return [] 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): def delete(self, apk_id):
"""删除 APK 文件和数据库记录。""" """删除 APK 文件和数据库记录。"""
@@ -161,6 +197,47 @@ class ApkManager:
_log.error(f"删除 APK 失败: {e}") _log.error(f"删除 APK 失败: {e}")
return False, str(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): def install(self, apk_id, serials):
"""批量安装 APK 到指定设备(后台线程执行)。 """批量安装 APK 到指定设备(后台线程执行)。
@@ -198,13 +275,26 @@ class ApkManager:
# 获取 worker 状态(判断设备是否运行中) # 获取 worker 状态(判断设备是否运行中)
worker_status = {w["serial"]: w for w in get_all_worker_status()} 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 = {} items = {}
for s in serials: for s in serials:
dev = all_devices.get(s, {})
model = dev.get("model") or dev.get("manufacturer") or s
items[s] = { items[s] = {
"name": model, "name": pool_names.get(s) or fp_names.get(s) or s,
"status": "pending", "status": "pending",
"msg": "", "msg": "",
} }
@@ -308,20 +398,17 @@ class ApkManager:
"verifier_verify_adb_installs", "0") "verifier_verify_adb_installs", "0")
except Exception: except Exception:
pass pass
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用) # 2. 推送 APK 到设备(分块 + 实时进度)
self._set_item(serial, "installing", "正在推送 APK...") ok_push, msg_push = self._push_with_progress(serial, apk_path)
r = subprocess.run( if not ok_push:
[ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"], self._set_item(serial, "failed", msg_push)
capture_output=True, timeout=_INSTALL_TIMEOUT _log.warning(f"[{serial}] APK 推送失败: {msg_push}")
)
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}")
return return
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本) # 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
self._set_item(serial, "installing", "正在安装...") # 这一步在**设备端**解包/优化,耗时随包大小增长(抖音 336MB 实测 ~65s),
# 但系统没给进度接口,只能给出预期时间,别让用户以为是卡住了。
self._set_item(serial, "installing",
"正在设备上安装...(大包要 1-2 分钟,无进度可读)")
r = subprocess.run( r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d", [ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
"/data/local/tmp/_install.apk"], "/data/local/tmp/_install.apk"],
@@ -385,6 +472,101 @@ class ApkManager:
# adb 连接保持即可,不影响设备和后续操作。 # adb 连接保持即可,不影响设备和后续操作。
pass 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=""): def _set_item(self, serial, status, msg=""):
"""更新安装任务中某设备的状态。""" """更新安装任务中某设备的状态。"""
if not self._install_task: if not self._install_task:
+46 -9
View File
@@ -21,9 +21,10 @@
- [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale) - [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale)
- [12. AI 控制台](#12-ai-控制台) - [12. AI 控制台](#12-ai-控制台)
- [13. 系统备份](#13-系统备份) - [13. 系统备份](#13-系统备份)
- [14. 非 JSON 响应汇总](#14-非-json-响应汇总) - [14. 设备端应用商店](#14-设备端应用商店)
- [15. 错误分支速查](#15-错误分支速查) - [15. 非 JSON 响应汇总](#15-非-json-响应汇总)
- [16. 已知问题](#16-已知问题) - [16. 错误分支速查](#16-错误分支速查)
- [17. 已知问题](#17-已知问题)
--- ---
@@ -48,14 +49,14 @@
### 1.3 CSRF ### 1.3 CSRF
- `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。 - `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 响应约定 ### 1.4 响应约定
- 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。 - 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。
- **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。 - **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。
- 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。 - 时间统一字符串:`"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/remove` | D | 从池中删除 |
| POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) | | POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) |
| POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 | | POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 |
| GET | `/api/devices/qrcode?serial=` | D | 生成该设备的**配置二维码**(设备端 Agent 扫码即配好) |
| POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 | | POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 |
| GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 | | GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 |
| POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 | | POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 |
@@ -236,6 +238,7 @@
| GET | `/api/agent-store/config` | 读配置(含设备令牌) | | GET | `/api/agent-store/config` | 读配置(含设备令牌) |
| POST | `/api/agent-store/config` | 启用/停用、重置令牌 | | POST | `/api/agent-store/config` | 启用/停用、重置令牌 |
| GET | `/api/agent-store/logs` | 设备端安装记录 | | GET | `/api/agent-store/logs` | 设备端安装记录 |
| POST | `/api/agent/show-info` | 让设备上的 Agent 显示/关闭**身份大字页**(监控页「打开设备端 Agent」按钮)|
> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。 > 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。
@@ -461,8 +464,31 @@
| 任务编辑器「指定设备」下拉 | `名称 · serial` | | 任务编辑器「指定设备」下拉 | `名称 · serial` |
| 分组编辑的设备勾选列表 | `名称 · serial` | | 分组编辑的设备勾选列表 | `名称 · serial` |
| 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 | | 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 |
| 剪贴板注入的设备勾选列表 | `名称 · serial`(无名只显示地址) |
| 应用管理的安装弹窗设备列表 | `名称 · serial` |
| 应用版本管理表格的「设备」列 | `名称 · serial` |
| 应用安装进度表的「设备」列 | 名称(取自设备池;无名退地址) |
| MCP `de_list_devices` | 返回 `name` 字段,提示 AI 汇报时用名称 | | 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 自动发现 ### 6.3 自动发现
| 接口 | 请求 | 说明 | | 接口 | 请求 | 说明 |
@@ -517,7 +543,18 @@
| `DELETE /api/apks/<apk_id>` | — | 删文件 + 记录 | | `DELETE /api/apks/<apk_id>` | — | 删文件 + 记录 |
| `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 | | `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 |
| `GET /api/apks/install/devices` | — | 可安装设备(pool / usb / adb 三个来源) | | `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/device/screenshot` | `image/png` | 原始截图(`Cache-Control: no-store`) |
| GET | `/api/screen/thumb` | `image/jpeg` | 360px 缩略图 + `X-Screen-State` | | GET | `/api/screen/thumb` | `image/jpeg` | 360px 缩略图 + `X-Screen-State` |
| GET | `/api/screen/stream` | `multipart/x-mixed-replace` | MJPEG 实时流 | | 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. 已知问题
| 问题 | 影响 | 位置 | | 问题 | 影响 | 位置 |
|------|------|------| |------|------|------|
+44 -3
View File
@@ -120,7 +120,28 @@
- `404`:id 不存在或平台侧文件已删(**注意与"未启用"的 404 区分:看响应体**, - `404`:id 不存在或平台侧文件已删(**注意与"未启用"的 404 区分:看响应体**,
未启用时是 JSON `{"ok":false,...}`,文件缺失时也是 JSON —— 设备端拿到非 `application/...` 的响应就视为失败) 未启用时是 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 <serial> shell am start -n <pkg>/.ClipActivity --es text_b64 <base64(UTF-
- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist` - 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist`
→ 视为"设备未安装 Agent" → 视为"设备未安装 Agent"
### 5.2 设备身份显示(待设备端实现) ### 5.2 设备身份显示(设备端已实现)
```bash ```bash
adb -s <serial> shell am start -n <pkg>/.InfoActivity \ adb -s <serial> shell am start -n <pkg>/.InfoActivity \
@@ -185,7 +206,27 @@ adb -s <serial> shell am start -n <pkg>/.InfoActivity \
要求:全屏大字、再次调用可覆盖内容、`close` 可关闭、不驻留前台。 要求:全屏大字、再次调用可覆盖内容、`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 ```bash
adb -s <serial> shell am start -n <pkg>/.ConfigActivity \ adb -s <serial> shell am start -n <pkg>/.ConfigActivity \
+4
View File
@@ -16,6 +16,10 @@ Flask-SQLAlchemy>=3.0,<4.0
# 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。 # 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。
PyMySQL>=1.1 PyMySQL>=1.1
# 设备端 Agent 的「扫码配置」二维码(设备池面板生成,手机扫一下配好 server/token/指纹)。
# 纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库。
qrcode>=7.4
# 定时任务调度 # 定时任务调度
APScheduler>=3.10,<4.0 APScheduler>=3.10,<4.0
+1 -1
View File
@@ -16,7 +16,7 @@ python3 - <<'PY'
import importlib.util, sys import importlib.util, sys
mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler", mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler",
"uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL", "uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL",
"fastmcp", "pymysql") "fastmcp", "pymysql", "qrcode")
if all(importlib.util.find_spec(m) for m in mods): if all(importlib.util.find_spec(m) for m in mods):
try: try:
import cv2 import cv2
+31 -10
View File
@@ -29,15 +29,22 @@ async function loadApks(){
await fillAppsDeviceList(); await fillAppsDeviceList();
} }
function renderApkRow(a){ function renderApkRow(a){
// 一行 = 一个应用(列表已按包名合并,显示最新版);旧版本给个数提示 + 清理入口
const n=a.version_count||1;
const verCell=(a.version_name?esc(a.version_name):'-')+
(n>1?' <span style="font-size:11px;color:#94a3b8">(另有 '+(n-1)+' 个旧版本)</span>':'');
const clean=n>1?'<button class="btn btn-xs" onclick="cleanOldVersions(\''+a.id+'\',\''+esc(a.display_name||'')+'\','+(n-1)+')" title="只保留最新版,删掉旧版本">清理旧版本</button> ':'';
const title=n>1?('共 '+n+' 个版本:'+ (a.versions||[]).map(v=>v.version_name||'?').join(' / ')):'';
return '<tr>'+ return '<tr>'+
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+ '<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+ '<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+ '<td title="'+esc(title)+'">'+verCell+'</td>'+
'<td>'+fmtSize(a.size)+'</td>'+ '<td>'+fmtSize(a.size)+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+ '<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
'<td>'+ '<td>'+
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+ '<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+ clean+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\','+n+')">删除</button>'+
'</td>'+ '</td>'+
'</tr>'; '</tr>';
} }
@@ -50,7 +57,7 @@ async function fillAppsDeviceList(){
const cur=sel.value; const cur=sel.value;
const devices=(sr.devices||[]).filter(d=>d.present); const devices=(sr.devices||[]).filter(d=>d.present);
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=> sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>' '<option value="'+esc(d.serial)+'">'+esc(devText(d.device_name||d.name, d.serial))+'</option>'
).join(''); ).join('');
if(cur)sel.value=cur; if(cur)sel.value=cur;
} }
@@ -140,9 +147,20 @@ async function uploadApk(input){
input.value=''; input.value='';
} }
async function deleteApk(id){ async function deleteApk(id, versionCount){
if(!confirm('确认删除此APK文件?'))return; const n=versionCount||1;
const r=await apiDelete('/api/apks/'+id); 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); handleResult(r,loadApks);
} }
@@ -168,8 +186,8 @@ async function openInstallModal(apkId){
online.map(d=>{ online.map(d=>{
const isUsb=d.source==='usb'; const isUsb=d.source==='usb';
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>'; const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>';
// serial 唯一,显示 IP + 型号便于区分同型号设备 // 一律「名称 · 地址」——同型号多台时靠名称区分;没有名称的设备(不在池里)只显示地址
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备')); const label=devText(d.name, d.serial);
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>'; return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
}).join('')+ }).join('')+
'</div>'; '</div>';
@@ -210,7 +228,7 @@ function showInstallProgress(){
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>'; '<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
pollInstallStatus(); pollInstallStatus();
if(_installTimer)clearInterval(_installTimer); if(_installTimer)clearInterval(_installTimer);
_installTimer=setInterval(pollInstallStatus,3000); _installTimer=setInterval(pollInstallStatus,1500);
} }
async function pollInstallStatus(){ async function pollInstallStatus(){
@@ -226,10 +244,13 @@ async function pollInstallStatus(){
const items=st.items||{}; const items=st.items||{};
const itemRows=Object.entries(items).map(([serial,info])=>{ const itemRows=Object.entries(items).map(([serial,info])=>{
const s=SS[info.status]||{t:info.status,c:'default'}; const s=SS[info.status]||{t:info.status,c:'default'};
// 推送进度带百分比时补一条细进度条(大包传输要一两分钟,光有文字不够直观)
const m=/推送中 (\d+)%/.exec(info.msg||'');
const bar=m?'<div class="progress" style="height:6px;margin-top:4px;max-width:240px"><div class="progress-bar" style="width:'+m[1]+'%"></div></div>':'';
return '<tr><td>'+esc(info.name||serial)+'</td>'+ return '<tr><td>'+esc(info.name||serial)+'</td>'+
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+ '<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+ '<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>'; '<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+bar+'</td></tr>';
}).join(''); }).join('');
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0; const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
+18 -17
View File
@@ -70,27 +70,28 @@ async function stopSelectedDevices(){
setTimeout(loadMonitor,500); setTimeout(loadMonitor,500);
} }
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标 // 打开设备端 Agent:让设备上的 Agent 弹出「身份大字页」(名称/IP/平台地址),
// 注意:打开定位页会切换设备前台,任务运行中慎用 // 人站在机架前一眼看出这台是平台上的哪一台。
async function locateDevice(serial){ // 替代原来的"推 HTML 让设备浏览器打开定位页"——那条路要设备装浏览器、页面还依赖
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return; // 设备能访问平台,且在 MIUI 上本来就不稳。
const r=await apiPost('/api/device/locate',{serial:serial,show:true}); async function showAgentInfo(serial){
const r=await apiPost('/api/agent/show-info',{serial:serial});
if(!r)return; if(!r)return;
if(r.ok){ if(r.ok){
_locatingSerials.add(serial); _locatingSerials.add(serial);
showToast(r.msg||'已定位','success'); showToast(r.msg||'已让设备显示身份信息','success');
renderDevices(); renderDevices();
}else showToast(r.error||'定位失败','error'); }else showToast(r.error||'打开失败','error');
} }
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位" // 关闭设备端 Agent 的身份页
async function stopLocate(serial){ async function hideAgentInfo(serial){
const r=await apiPost('/api/device/locate/stop',{serial:serial}); const r=await apiPost('/api/agent/show-info',{serial:serial,close:true});
if(!r)return; if(!r)return;
_locatingSerials.delete(serial); _locatingSerials.delete(serial);
renderDevices(); renderDevices();
if(r.ok)showToast(r.msg||'已结束定位','success'); if(r.ok)showToast(r.msg||'已关闭','success');
else showToast(r.error||'结束定位失败','error'); else showToast(r.error||'关闭失败','error');
} }
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认) // 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
@@ -344,13 +345,13 @@ function renderDeviceRow(dev){
// 设备操作按钮需要"设备控制"权限(无权限只保留截图查看) // 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
const canDev=_can('devices'); const canDev=_can('devices');
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':''; const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错) // 看屏按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>'; const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备实时画面(不影响任务)">看屏</button>';
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页) // 设备端 Agent:开着身份页时显示"关闭",否则"打开设备端 Agent"
const locating=_locatingSerials.has(dev.serial); const locating=_locatingSerials.has(dev.serial);
const locateBtn=canDev?(locating const locateBtn=canDev?(locating
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>' ?'<button class="btn btn-xs btn-danger" onclick="hideAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 关掉身份大字页">关闭 Agent</button>'
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):''; :'<button class="btn btn-xs" onclick="showAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 显示身份大字页(名称/IP/平台地址)——人在机架前对号用(会切换设备前台)">打开设备端 Agent</button>'):'';
// 异常/失败设备提供"清除异常"(把状态恢复为未启动) // 异常/失败设备提供"清除异常"(把状态恢复为未启动)
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed') const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':''; ?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
+41 -4
View File
@@ -35,7 +35,7 @@ async function loadToolsDevices(force){
const stCls = d.state==='device' ? 'label-success' : 'label-warning'; const stCls = d.state==='device' ? 'label-success' : 'label-warning';
return '<label data-serial="'+esc(d.serial)+'">' return '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">' +'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>' +'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(devText(d.name,d.serial))+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>'; +'<span class="label '+stCls+'">'+st+'</span></label>';
}).join(''); }).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备'; status.textContent = '共 '+_clipDevices.length+' 台设备';
@@ -252,12 +252,14 @@ async function queryAppVers(){
} }
} }
function renderAppVerRow(x){ function renderAppVerRow(x){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ // 设备列显示「名称 · 地址」(名称来自设备池;没名字就只显示地址)
const dev='<td style="font-family:monospace">'+esc(devText(x.name,x.serial))+'</td>';
if(x.error)return '<tr>'+dev+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>'; '<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ if(x.installed)return '<tr>'+dev+
'<td><span class="label label-success">已安装</span></td>'+ '<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>'; '<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ return '<tr>'+dev+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>'; '<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
} }
@@ -496,6 +498,7 @@ async function loadDevPool(){
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+ '<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
'<td style="white-space:nowrap">'+ '<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+ '<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+
'<button class="btn btn-xs" title="生成配置二维码:设备端 Agent 扫一下即配好(平台地址/令牌/指纹)" onclick="showDevQrcode(\''+esc(d.serial)+'\')">二维码</button> '+
(d.online ? '' : (d.online ? '' :
'<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') + '<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') +
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+ '<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
@@ -570,3 +573,37 @@ async function refreshPoolModels(){
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);} if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
else showToast(r.error,'error'); else showToast(r.error,'error');
} }
// ================== 设备配置二维码(设备端 Agent 扫码即配好) ==================
// 二维码内容:平台地址 + 设备令牌 + 这台设备的指纹(JSON)。
// 手机上的 Agent 扫一下 → 保存这三项 → 不用在手机上打字、也不用连 adb。
async function showDevQrcode(serial){
document.getElementById('modal-title').textContent='设备配置二维码';
document.getElementById('modal-body').innerHTML='<p class="text-muted">生成中...</p>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">关闭</button>';
showModal();
const r=await apiGet('/api/devices/qrcode?serial='+encodeURIComponent(serial));
const box=document.getElementById('modal-body');
if(!r||!r.ok){
box.innerHTML='<div style="background:#fdecea;border:1px solid #f5c6cb;color:#b42318;'+
'padding:10px 12px;border-radius:6px">'+esc((r&&r.error)||'生成失败')+'</div>';
return;
}
const warn=r.warn?'<div style="background:#fff3cd;border:1px solid #ffda6a;color:#7a5b00;'+
'padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px">⚠️ '+esc(r.warn)+'</div>':'';
box.innerHTML=
warn+
'<div style="text-align:center;padding:8px 0">'+
'<img src="'+r.png+'" style="width:240px;height:240px;image-rendering:pixelated;'+
'background:#fff;padding:8px;border:1px solid #e5e7eb;border-radius:8px" alt="配置二维码">'+
'<div style="margin-top:10px;font-size:15px"><strong>'+esc(r.name||'(未命名)')+
'</strong> <span style="font-family:monospace;color:#6b7280">'+esc(r.serial)+'</span></div>'+
'</div>'+
'<div class="help" style="margin-top:6px">在设备上打开 <strong>设备 Agent</strong> → 点「扫码配置」→ 扫这个二维码,'+
'平台地址、令牌、指纹一次写入,不用在手机上打字。</div>'+
'<details style="margin-top:8px"><summary style="cursor:pointer;color:#6b7280;font-size:12px">'+
'查看二维码内容(排查用)</summary>'+
'<div style="font-family:monospace;font-size:11px;color:#64748b;word-break:break-all;'+
'background:#f8fafc;padding:8px;border-radius:6px;margin-top:6px">'+esc(r.payload)+'</div></details>';
}
+38 -11
View File
@@ -17,8 +17,12 @@ bp = Blueprint("apks", __name__)
@bp.route("/api/apks") @bp.route("/api/apks")
@login_required @login_required
def api_apks_list(): def api_apks_list():
"""列出所有已上传的 APK。""" """列出已上传的 APK —— **按包名合并,一个应用一行(最新版本)**。
return jsonify({"ok": True, "apks": context.apk_mgr.list_all()})
同一个应用传过 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"]) @bp.route("/api/apks/upload", methods=["POST"])
@perm_required(PERM_APKS) @perm_required(PERM_APKS)
@@ -36,7 +40,18 @@ def api_apks_upload():
@bp.route("/api/apks/<apk_id>", methods=["DELETE"]) @bp.route("/api/apks/<apk_id>", methods=["DELETE"])
@perm_required(PERM_APKS) @perm_required(PERM_APKS)
def api_apks_delete(apk_id): def api_apks_delete(apk_id):
"""删除 APK 文件和记录。""" """删除 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) ok, msg = context.apk_mgr.delete(apk_id)
if ok: if ok:
return jsonify({"ok": True, "msg": msg}) return jsonify({"ok": True, "msg": msg})
@@ -50,21 +65,33 @@ def api_apks_install_devices():
source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。 source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。
""" """
devices, seen = [], set() devices, seen = [], set()
name_by_serial, name_by_fp = {}, {}
try: try:
online = set(device_pool.list_online()) online = set(device_pool.list_online())
for serial in device_pool.list_configured(): # list_devices() 返回字典(含 name/fingerprint);list_configured() 只有 serial 字符串
if serial in online: for d in device_pool.list_devices():
devices.append({"serial": serial, "model": "", "source": "pool"}) 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) seen.add(serial)
except Exception: if serial:
pass 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") out = _adb("devices")
for line in (out or "").splitlines()[1:]: for line in (out or "").splitlines()[1:]:
parts = line.split() parts = line.split()
if len(parts) >= 2 and parts[0] and parts[0] not in seen: if len(parts) >= 2 and parts[0] and parts[0] not in seen:
devices.append({"serial": parts[0], "model": "", s = parts[0]
"source": "usb" if ":" not in parts[0] else "adb"}) devices.append({"serial": s, "model": "",
seen.add(parts[0]) "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}) return jsonify({"ok": True, "devices": devices})
@bp.route("/api/apks/install", methods=["POST"]) @bp.route("/api/apks/install", methods=["POST"])
+21 -2
View File
@@ -7,10 +7,12 @@ _log = get_logger("web")
def _merged_device_list(): def _merged_device_list():
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 """本地 adb(含 USB)+ 设备池的合并列表([{serial, name, state}])。
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
name 来自设备池(平台给设备起的名字,如 A08)—— 界面上统一按「名称 · 地址」
显示,同型号多台时才分得清是哪一台;池里没有的设备 name 为空,界面回退显示地址。
""" """
states = {} states = {}
out = _adb("devices") out = _adb("devices")
@@ -18,13 +20,30 @@ def _merged_device_list():
parts = line.split() parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
states[parts[0]] = parts[1] 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: try:
for serial in device_pool.list_configured(): for serial in device_pool.list_configured():
if serial not in states: if serial not in states:
states[serial] = "pool" states[serial] = "pool"
except Exception: except Exception:
pass 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): def _device_screen_state(serial):
+125 -3
View File
@@ -28,7 +28,7 @@ from config import APK_DIR
from core import device_pool from core import device_pool
from core.db_config import meta_get, meta_set from core.db_config import meta_get, meta_set
from core.logger import get_logger 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__) bp = Blueprint("device_agent", __name__)
_log = get_logger("web.devagent") _log = get_logger("web.devagent")
@@ -86,7 +86,7 @@ def _identify():
if d: if d:
return fp, d.get("serial", serial), d.get("name") or "", True return fp, d.get("serial", serial), d.get("name") or "", True
if serial: 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: if d.get("serial") == serial:
return fp, serial, d.get("name") or "", True return fp, serial, d.get("name") or "", True
except Exception as e: except Exception as e:
@@ -110,7 +110,9 @@ def api_dev_agent_bootstrap():
from web import context from web import context
apks = [] apks = []
try: try:
for a in context.apk_mgr.list_all(): # 用「按包名合并」的列表:同一个应用只出现一行(最新版),
# 设备上不会看到同一个 App 的 v1.2 / v1.3 两行
for a in context.apk_mgr.list_latest():
apks.append({ apks.append({
"id": a.get("id", ""), "id": a.get("id", ""),
"display_name": a.get("display_name") or a.get("filename") or "", "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") 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"]) @bp.route("/api/device/agent/report", methods=["POST"])
def api_dev_agent_report(): 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"]) @bp.route("/api/agent-store/config", methods=["GET", "POST"])
@perm_required(PERM_APKS) @perm_required(PERM_APKS)
def api_agent_store_config(): def api_agent_store_config():
+94
View File
@@ -12,6 +12,100 @@ from web.auth import perm_required, PERM_DEVICES
_log = get_logger("web") _log = get_logger("web")
bp = Blueprint("devices", __name__) 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"]) @bp.route("/api/devices/pool", methods=["GET"])
@perm_required(PERM_DEVICES) @perm_required(PERM_DEVICES)
def api_devices_pool_list(): def api_devices_pool_list():
+5
View File
@@ -102,6 +102,11 @@ def api_status():
devices, err = context.mgr.get_status() devices, err = context.mgr.get_status()
if err: if err:
return jsonify({"ok": False, "error": err}), 500 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({ return jsonify({
"ok": True, "devices": devices, "server_time": time.time(), "ok": True, "devices": devices, "server_time": time.time(),
"fg_scanning": context.mgr._fg_scanner.is_scanning, "fg_scanning": context.mgr._fg_scanner.is_scanning,
+3
View File
@@ -156,6 +156,9 @@ def api_tools_appver():
results[d["serial"]] = fut.result() results[d["serial"]] = fut.result()
except Exception as e: except Exception as e:
results[d["serial"]] = {"installed": False, "error": str(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")) fail = sum(1 for v in results.values() if v.get("error"))
return jsonify({"ok": True, "results": results, "fail": fail, return jsonify({"ok": True, "results": results, "fail": fail,
"total": len(results)}) "total": len(results)})