Merge branch 'dev' into main——本批:APK 安装进度/静默安装接口/应用列表合并/设备名显示/配置二维码/监控页排序与按钮
dev 侧 6 个提交整体合入 main(对应之前按红线撤回的商店功能,现已在 dev 验收)。
This commit is contained in:
+198
-16
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
Reference in New Issue
Block a user