Compare commits
9
Commits
bf5071b14d
...
ef71943a3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef71943a3c | ||
|
|
524ae20c49 | ||
|
|
6fd9e6f8f3 | ||
|
|
3422b4f265 | ||
|
|
3f041668ec | ||
|
|
17b6624a34 | ||
|
|
25e4fbb053 | ||
|
|
fa07ed0804 | ||
|
|
cdf06e1929 |
+198
-16
@@ -127,7 +127,7 @@ class ApkManager:
|
||||
|
||||
# ================== 列表 ==================
|
||||
def list_all(self):
|
||||
"""列出所有 APK。"""
|
||||
"""列出所有 APK(**每个上传版本一行**,内部用)。"""
|
||||
try:
|
||||
with self._db():
|
||||
rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all()
|
||||
@@ -136,6 +136,42 @@ class ApkManager:
|
||||
_log.error(f"列出 APK 失败: {e}")
|
||||
return []
|
||||
|
||||
def list_latest(self):
|
||||
"""按**包名**合并后的列表:一个应用一行,只显示最新版本。
|
||||
|
||||
为什么:同一个应用传过多个版本(v1.2 / v1.3)时,列表里会并排出现两行——
|
||||
用户看到的是"同一个应用怎么重复了"。这里合并成一行(最新版),
|
||||
其余版本的信息放在 `versions` 里,界面可以提示"另有 N 个旧版本"并按需清理。
|
||||
|
||||
管理页和设备端商店都用这个(设备上选应用时同样不该出现两行同一个 App)。
|
||||
解析不出包名的 APK 各自独立成行,不做合并(避免把无关文件混在一起)。
|
||||
"""
|
||||
rows = self.list_all()
|
||||
groups = {}
|
||||
for r in rows:
|
||||
pkg = (r.get("package_name") or "").strip()
|
||||
key = pkg if pkg else ("__single__" + str(r.get("id")))
|
||||
groups.setdefault(key, []).append(r)
|
||||
|
||||
def sort_key(x):
|
||||
return (x.get("version_code") or 0, x.get("upload_time") or "")
|
||||
|
||||
out = []
|
||||
for items in groups.values():
|
||||
items.sort(key=sort_key, reverse=True)
|
||||
top = dict(items[0])
|
||||
top["version_count"] = len(items)
|
||||
top["versions"] = [{
|
||||
"id": i.get("id"),
|
||||
"version_name": i.get("version_name") or "",
|
||||
"version_code": i.get("version_code") or 0,
|
||||
"size": i.get("size") or 0,
|
||||
"upload_time": i.get("upload_time") or "",
|
||||
} for i in items]
|
||||
out.append(top)
|
||||
out.sort(key=lambda x: x.get("upload_time") or "", reverse=True)
|
||||
return out
|
||||
|
||||
# ================== 删除 ==================
|
||||
def delete(self, apk_id):
|
||||
"""删除 APK 文件和数据库记录。"""
|
||||
@@ -161,6 +197,47 @@ class ApkManager:
|
||||
_log.error(f"删除 APK 失败: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def delete_package(self, apk_id, keep_latest=False):
|
||||
"""按**包名**删除:一个应用的所有版本一起处理。
|
||||
|
||||
keep_latest=True → 只删旧版本、保留最新那版("清理旧版本")
|
||||
keep_latest=False → 整个应用删掉(所有版本)
|
||||
没有包名的 APK 退回单条删除。
|
||||
"""
|
||||
try:
|
||||
with self._db():
|
||||
row = ApkRow.query.get(apk_id)
|
||||
if not row:
|
||||
return False, "APK 不存在"
|
||||
pkg = (row.package_name or "").strip()
|
||||
if not pkg:
|
||||
return self.delete(apk_id)
|
||||
|
||||
items = ApkRow.query.filter_by(package_name=pkg).all()
|
||||
items.sort(key=lambda r: ((r.version_code or 0),
|
||||
(r.upload_time or "")), reverse=True)
|
||||
targets = items[1:] if keep_latest else items
|
||||
if not targets:
|
||||
return True, "没有需要清理的旧版本"
|
||||
name = row.display_name or pkg
|
||||
for r in targets:
|
||||
p = os.path.join(APK_DIR, r.filename)
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
os.remove(p)
|
||||
except Exception as e:
|
||||
_log.warning(f"删除 APK 文件失败: {e}")
|
||||
db.session.delete(r)
|
||||
db.session.commit()
|
||||
_log.info(f"已删除 {name} 的 {len(targets)} 个版本"
|
||||
f"({'保留最新' if keep_latest else '整个应用'})")
|
||||
if keep_latest:
|
||||
return True, f"已清理 {name} 的 {len(targets)} 个旧版本(保留最新)"
|
||||
return True, f"已删除 {name}(共 {len(targets)} 个版本)"
|
||||
except Exception as e:
|
||||
_log.error(f"按包名删除失败: {e}")
|
||||
return False, str(e)
|
||||
|
||||
# ================== 批量安装 ==================
|
||||
def install(self, apk_id, serials):
|
||||
"""批量安装 APK 到指定设备(后台线程执行)。
|
||||
@@ -198,13 +275,26 @@ class ApkManager:
|
||||
# 获取 worker 状态(判断设备是否运行中)
|
||||
worker_status = {w["serial"]: w for w in get_all_worker_status()}
|
||||
|
||||
# 设备名(平台给设备起的名字,如 A08)—— 进度表里显示这个而不是型号:
|
||||
# 8 台同型号时型号根本区分不了,名字才行
|
||||
pool_names, fp_names = {}, {}
|
||||
try:
|
||||
from core import device_pool
|
||||
# list_devices() 返回字典(含 name/fingerprint)
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("serial"):
|
||||
pool_names[d["serial"]] = d.get("name") or ""
|
||||
# USB 设备的 adb serial == ro.serialno == 池里那条记录的指纹
|
||||
if d.get("fingerprint"):
|
||||
fp_names[d["fingerprint"]] = d.get("name") or ""
|
||||
except Exception as e:
|
||||
_log.warning(f"读取设备名失败(进度表将显示地址): {e}")
|
||||
|
||||
# 构造安装任务
|
||||
items = {}
|
||||
for s in serials:
|
||||
dev = all_devices.get(s, {})
|
||||
model = dev.get("model") or dev.get("manufacturer") or s
|
||||
items[s] = {
|
||||
"name": model,
|
||||
"name": pool_names.get(s) or fp_names.get(s) or s,
|
||||
"status": "pending",
|
||||
"msg": "",
|
||||
}
|
||||
@@ -308,20 +398,17 @@ class ApkManager:
|
||||
"verifier_verify_adb_installs", "0")
|
||||
except Exception:
|
||||
pass
|
||||
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用)
|
||||
self._set_item(serial, "installing", "正在推送 APK...")
|
||||
r = subprocess.run(
|
||||
[ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"],
|
||||
capture_output=True, timeout=_INSTALL_TIMEOUT
|
||||
)
|
||||
push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
|
||||
if r.returncode != 0 or "1 file pushed" not in push_out:
|
||||
msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})"
|
||||
self._set_item(serial, "failed", msg)
|
||||
_log.warning(f"[{serial}] APK 推送失败: {msg}")
|
||||
# 2. 推送 APK 到设备(分块 + 实时进度)
|
||||
ok_push, msg_push = self._push_with_progress(serial, apk_path)
|
||||
if not ok_push:
|
||||
self._set_item(serial, "failed", msg_push)
|
||||
_log.warning(f"[{serial}] APK 推送失败: {msg_push}")
|
||||
return
|
||||
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
|
||||
self._set_item(serial, "installing", "正在安装...")
|
||||
# 这一步在**设备端**解包/优化,耗时随包大小增长(抖音 336MB 实测 ~65s),
|
||||
# 但系统没给进度接口,只能给出预期时间,别让用户以为是卡住了。
|
||||
self._set_item(serial, "installing",
|
||||
"正在设备上安装...(大包要 1-2 分钟,无进度可读)")
|
||||
r = subprocess.run(
|
||||
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
|
||||
"/data/local/tmp/_install.apk"],
|
||||
@@ -385,6 +472,101 @@ class ApkManager:
|
||||
# adb 连接保持即可,不影响设备和后续操作。
|
||||
pass
|
||||
|
||||
def _push_with_progress(self, serial, apk_path,
|
||||
remote="/data/local/tmp/_install.apk"):
|
||||
"""把 APK 分块写进设备,边传边更新进度。返回 (ok, msg)。
|
||||
|
||||
为什么不用 `adb push`:它只在**结束时**吐一行汇总
|
||||
("1 file pushed, 3.0 MB/s"),大包(抖音 336MB ≈ 1.8 分钟/台)整个传输过程
|
||||
界面上只有一句"正在推送 APK...",用户只能干等 —— 这就是要优化的问题。
|
||||
|
||||
改用 `exec-in` 分块写:每块回调一次进度(百分比/已传/速度/剩余时间)。
|
||||
实测速度略降(2.6 vs 3.1 MB/s),换来看得见的进度,划算。
|
||||
传完校验设备上的文件大小;exec-in 不可用时退回原来的 `adb push`。
|
||||
"""
|
||||
total = os.path.getsize(apk_path)
|
||||
chunk = 1 << 20 # 1MB:再大提升有限,再小 adb 调用开销明显
|
||||
t0 = time.time()
|
||||
sent = 0
|
||||
last_report = 0.0
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[ADB_PATH, "-s", serial, "exec-in", "cat > %s" % remote],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE)
|
||||
except Exception as e:
|
||||
_log.warning(f"[{serial}] exec-in 启动失败,退回 adb push: {e}")
|
||||
return self._push_fallback(serial, apk_path, remote)
|
||||
|
||||
try:
|
||||
with open(apk_path, "rb") as f:
|
||||
while True:
|
||||
buf = f.read(chunk)
|
||||
if not buf:
|
||||
break
|
||||
proc.stdin.write(buf)
|
||||
sent += len(buf)
|
||||
now = time.time()
|
||||
if now - last_report >= 0.8 or sent >= total:
|
||||
last_report = now
|
||||
spent = max(now - t0, 0.001)
|
||||
speed = sent / spent / 1048576.0 # MB/s
|
||||
left = int((total - sent) / max(sent / spent, 1))
|
||||
self._set_item(
|
||||
serial, "installing",
|
||||
"推送中 %d%%(%.1f/%.1f MB · %.1f MB/s · 剩约 %ds)"
|
||||
% (sent * 100 // max(total, 1), sent / 1048576.0,
|
||||
total / 1048576.0, speed, left))
|
||||
proc.stdin.close()
|
||||
except Exception as e:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return False, f"推送中断: {e}"
|
||||
|
||||
try:
|
||||
rc = proc.wait(timeout=_INSTALL_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return False, "推送超时(设备无响应)"
|
||||
if rc != 0:
|
||||
err = ""
|
||||
try:
|
||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[:150]
|
||||
except Exception:
|
||||
pass
|
||||
_log.warning(f"[{serial}] exec-in 推送到一半失败(rc={rc}),退回 adb push")
|
||||
return self._push_fallback(serial, apk_path, remote)
|
||||
|
||||
# 校验设备上文件大小一致(exec-in 中途断流可能"成功"但少字节)
|
||||
try:
|
||||
rs = subprocess.run(
|
||||
[ADB_PATH, "-s", serial, "shell", "stat", "-c", "%s", remote],
|
||||
capture_output=True, timeout=30)
|
||||
got = int((rs.stdout or b"").decode("utf-8", "replace").strip().splitlines()[-1])
|
||||
if got != total:
|
||||
return False, f"推送不完整(设备 {got} / 本地 {total} 字节)"
|
||||
except Exception:
|
||||
pass # stat 参数各机型有差异,取不到就跳过校验
|
||||
|
||||
return True, "推送完成 %.1fMB / %.0fs" % (total / 1048576.0, time.time() - t0)
|
||||
|
||||
def _push_fallback(self, serial, apk_path, remote):
|
||||
"""老 adb 或 exec-in 失败时的退路:原来的 `adb push`(无进度)。"""
|
||||
self._set_item(serial, "installing", "正在推送 APK...(无进度显示)")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[ADB_PATH, "-s", serial, "push", apk_path, remote],
|
||||
capture_output=True, timeout=_INSTALL_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "推送超时"
|
||||
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
|
||||
if r.returncode != 0 or "1 file pushed" not in out:
|
||||
return False, (out.strip().replace("\n", " ")[:150]
|
||||
or f"推送失败(returncode={r.returncode})")
|
||||
return True, "推送完成(adb push)"
|
||||
|
||||
def _set_item(self, serial, status, msg=""):
|
||||
"""更新安装任务中某设备的状态。"""
|
||||
if not self._install_task:
|
||||
|
||||
@@ -349,6 +349,26 @@ class AgentAction(db.Model):
|
||||
updated_at = db.Column(db.String(20), default="")
|
||||
|
||||
|
||||
class DeviceInstallLog(db.Model):
|
||||
"""设备端应用商店的下载/安装记录(设备上的 Agent 上报,平台侧展示)。
|
||||
|
||||
平台**不主动**发起这条通道的安装:设备自己拉清单、自己下载、自己调
|
||||
PackageInstaller 安装——这样走的是普通应用安装流程,不会触发 MIUI 针对
|
||||
`adb install` 的「USB 安装」拦截(USER_RESTRICTED)。
|
||||
"""
|
||||
__tablename__ = "device_install_log"
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
fingerprint = db.Column(db.String(120), default="") # ro.serialno,识别物理设备
|
||||
serial = db.Column(db.String(120), default="") # 上报时的地址(可能换过)
|
||||
device_name = db.Column(db.String(80), default="") # 平台侧的名称(快照)
|
||||
apk_id = db.Column(db.String(32), default="")
|
||||
package_name = db.Column(db.String(200), default="")
|
||||
version_name = db.Column(db.String(50), default="")
|
||||
action = db.Column(db.String(20), default="") # download / install_ok / install_fail
|
||||
message = db.Column(db.String(500), default="") # 失败原因等
|
||||
created_at = db.Column(db.String(20), default="")
|
||||
|
||||
|
||||
class AgentConversation(db.Model):
|
||||
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
|
||||
__tablename__ = "agent_conversation"
|
||||
|
||||
@@ -54,7 +54,7 @@ TABLE_LABELS = {
|
||||
"custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池",
|
||||
"pending_device": "待连接设备", "agent_conversation": "AI 会话",
|
||||
"agent_experience": "经验库", "experience_audit": "经验巡检",
|
||||
"agent_action": "动作库",
|
||||
"agent_action": "动作库", "device_install_log": "设备端安装记录",
|
||||
}
|
||||
_STAGE_TTL = 1800 # 导入暂存有效期(秒)
|
||||
|
||||
|
||||
+101
-10
@@ -21,9 +21,10 @@
|
||||
- [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale)
|
||||
- [12. AI 控制台](#12-ai-控制台)
|
||||
- [13. 系统备份](#13-系统备份)
|
||||
- [14. 非 JSON 响应汇总](#14-非-json-响应汇总)
|
||||
- [15. 错误分支速查](#15-错误分支速查)
|
||||
- [16. 已知问题](#16-已知问题)
|
||||
- [14. 设备端应用商店](#14-设备端应用商店)
|
||||
- [15. 非 JSON 响应汇总](#15-非-json-响应汇总)
|
||||
- [16. 错误分支速查](#16-错误分支速查)
|
||||
- [17. 已知问题](#17-已知问题)
|
||||
|
||||
---
|
||||
|
||||
@@ -48,14 +49,14 @@
|
||||
### 1.3 CSRF
|
||||
|
||||
- `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。
|
||||
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§16 已知问题](#16-已知问题)。
|
||||
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§17 已知问题](#17-已知问题)。
|
||||
|
||||
### 1.4 响应约定
|
||||
|
||||
- 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。
|
||||
- **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。
|
||||
- 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。
|
||||
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§14](#14-非-json-响应汇总)。
|
||||
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§15](#15-非-json-响应汇总)。
|
||||
|
||||
---
|
||||
|
||||
@@ -150,6 +151,7 @@
|
||||
| POST | `/api/devices/pool/remove` | D | 从池中删除 |
|
||||
| POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) |
|
||||
| POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 |
|
||||
| GET | `/api/devices/qrcode?serial=` | D | 生成该设备的**配置二维码**(设备端 Agent 扫码即配好) |
|
||||
| POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 |
|
||||
| GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 |
|
||||
| POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 |
|
||||
@@ -219,6 +221,27 @@
|
||||
| POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) |
|
||||
| POST | `/api/system/backup/apply` | 应用恢复(重启生效) |
|
||||
|
||||
### 2.11 device_agent(`web/device_agent_api.py`)
|
||||
|
||||
**设备端专用**(无登录会话,靠 `X-Device-Token` 鉴权;平台未启用时统一 404):
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| GET | `/api/device/agent/bootstrap` | 设备拉自己的身份 + 可安装应用清单 |
|
||||
| GET | `/api/device/agent/apk/<apk_id>` | 下载 APK 文件 |
|
||||
| POST | `/api/device/agent/report` | 上报下载/安装结果 |
|
||||
|
||||
**管理端**(登录 + 应用管理权限):
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| GET | `/api/agent-store/config` | 读配置(含设备令牌) |
|
||||
| POST | `/api/agent-store/config` | 启用/停用、重置令牌 |
|
||||
| GET | `/api/agent-store/logs` | 设备端安装记录 |
|
||||
| POST | `/api/agent/show-info` | 让设备上的 Agent 显示/关闭**身份大字页**(监控页「打开设备端 Agent」按钮)|
|
||||
|
||||
> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 认证与页面
|
||||
@@ -441,8 +464,31 @@
|
||||
| 任务编辑器「指定设备」下拉 | `名称 · serial` |
|
||||
| 分组编辑的设备勾选列表 | `名称 · serial` |
|
||||
| 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 |
|
||||
| 剪贴板注入的设备勾选列表 | `名称 · serial`(无名只显示地址) |
|
||||
| 应用管理的安装弹窗设备列表 | `名称 · serial` |
|
||||
| 应用版本管理表格的「设备」列 | `名称 · serial` |
|
||||
| 应用安装进度表的「设备」列 | 名称(取自设备池;无名退地址) |
|
||||
| MCP `de_list_devices` | 返回 `name` 字段,提示 AI 汇报时用名称 |
|
||||
|
||||
> 这几个接口的字段来源:`/api/adb/devices`、`/api/apks/install/devices` 走
|
||||
> `web/common.py:_merged_device_list()`(带 `name`);`/api/tools/appver` 把
|
||||
> `name` 塞进每个设备的结果里。
|
||||
|
||||
### 6.2.2 设备配置二维码(扫码配置)
|
||||
|
||||
| 接口 | 鉴权 | 请求 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `GET /api/devices/qrcode?serial=<地址>` | 设备权限 | — | 返回 `{png(base64 data-url), payload, warn}` |
|
||||
|
||||
给设备端 Agent 扫的配置二维码,一次写入**平台地址 + 设备令牌 + 该设备指纹**:
|
||||
|
||||
- `payload` 是二维码里的原始 JSON:`{"v":1,"server":…,"token":…,"fingerprint":…,"name":…}`
|
||||
- `warn` **非空时必须显示**给操作者,最常见两种:
|
||||
- "你是用 localhost 打开平台的" → 二维码里的地址手机会连不上,要改用局域网地址重开本页
|
||||
- "这台设备还没采集到指纹" → 扫码后平台认不出它是哪一台
|
||||
- 前置条件:先在「应用管理 → 设备端应用商店」**启用**(令牌从那儿来),否则 400
|
||||
- 设备端如何解析见 [DEVICE_AGENT.md](DEVICE_AGENT.md) §5.3
|
||||
|
||||
### 6.3 自动发现
|
||||
|
||||
| 接口 | 请求 | 说明 |
|
||||
@@ -497,7 +543,18 @@
|
||||
| `DELETE /api/apks/<apk_id>` | — | 删文件 + 记录 |
|
||||
| `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 |
|
||||
| `GET /api/apks/install/devices` | — | 可安装设备(pool / usb / adb 三个来源) |
|
||||
| `GET /api/apks/install/status` | — | 安装进度(前端 3s 轮询) |
|
||||
| `GET /api/apks/install/status` | — | 安装进度(前端 1.5s 轮询) |
|
||||
|
||||
> 每台设备的状态机:`pending`(等待)→ `installing`(**带文字的实时进度**)→ `success` / `failed` / `skipped`。
|
||||
> 安装分两段,`items[serial].msg` 会跟着更新:
|
||||
> 1. **推送到设备**:`推送中 62%(211.0/336.1 MB · 2.6 MB/s · 剩约 48s)` —— 用分块写
|
||||
> (`adb exec-in`)拿字节级进度;不用 `adb push` 是因为它只在结束时吐一行汇总,
|
||||
> 大包(抖音 336MB≈2 分钟)整个传输过程界面上只能干等
|
||||
> 2. **设备上安装**:`正在设备上安装...(大包要 1-2 分钟,无进度可读)` —— `pm install`
|
||||
> 在设备端解包,系统没给进度接口
|
||||
>
|
||||
> 参考耗时(336MB 抖音 / 单台):推送 ~134s + 安装 ~65s ≈ 3.3 分钟;多台并发(上限 5)时
|
||||
> 带宽共享,整体时间取决于 WiFi。
|
||||
|
||||
---
|
||||
|
||||
@@ -656,11 +713,45 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
|
||||
|
||||
---
|
||||
|
||||
## 14. 非 JSON 响应汇总
|
||||
## 14. 设备端应用商店
|
||||
|
||||
**设备侧**三个接口(`bootstrap` / `apk` / `report`)的完整协议、鉴权、错误码与版本约定
|
||||
见 [DEVICE_AGENT.md](DEVICE_AGENT.md)——那份文档同时是设备端 APK 仓库的对接契约。
|
||||
|
||||
**管理侧**(登录 + 应用管理权限):
|
||||
|
||||
### GET /api/agent-store/config
|
||||
|
||||
```json
|
||||
{"ok": true, "enabled": false, "token": "…", "token_created_at": "2026-09-13 14:38:11",
|
||||
"api_version": 1, "base_url_hint": "http://192.168.20.220:18050"}
|
||||
```
|
||||
|
||||
`base_url_hint` 由请求的 Host 回填,直接可作为设备端要填的平台地址。
|
||||
|
||||
### POST /api/agent-store/config
|
||||
|
||||
`{"enabled": true}` 启用(并自动生成令牌);`{"regenerate_token": true}` 重置令牌
|
||||
(旧令牌立即失效)。返回 `{"ok": true, "enabled": …, "token": …}`。
|
||||
|
||||
### GET /api/agent-store/logs?limit=50
|
||||
|
||||
```json
|
||||
{"ok": true, "logs": [{"id": 1, "device_name": "A08", "serial": "192.168.20.100:5555",
|
||||
"fingerprint": "…", "apk_id": "56b114aa", "package_name": "com.example.clipinject",
|
||||
"version_name": "1.0", "action": "install_fail", "message": "MIUI 拦截未确认",
|
||||
"created_at": "2026-09-13 14:40:00"}]}
|
||||
```
|
||||
|
||||
`action` ∈ `download` / `install_ok` / `install_fail`;记录表最多保留 500 条(自动裁旧)。
|
||||
|
||||
---
|
||||
|
||||
## 15. 非 JSON 响应汇总
|
||||
|
||||
| 方法 | 路径 | 响应类型 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录) |
|
||||
| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录;**已被设备端 Agent 的身份页取代**,仅作历史保留)|
|
||||
| GET | `/api/device/screenshot` | `image/png` | 原始截图(`Cache-Control: no-store`) |
|
||||
| GET | `/api/screen/thumb` | `image/jpeg` | 360px 缩略图 + `X-Screen-State` |
|
||||
| GET | `/api/screen/stream` | `multipart/x-mixed-replace` | MJPEG 实时流 |
|
||||
@@ -670,7 +761,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
|
||||
|
||||
---
|
||||
|
||||
## 15. 错误分支速查
|
||||
## 16. 错误分支速查
|
||||
|
||||
| 码 | 典型触发 |
|
||||
|----|---------|
|
||||
@@ -686,7 +777,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
|
||||
|
||||
---
|
||||
|
||||
## 16. 已知问题
|
||||
## 17. 已知问题
|
||||
|
||||
| 问题 | 影响 | 位置 |
|
||||
|------|------|------|
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
| 10 | `agent_experience` | 经验库(任务级配方) |
|
||||
| 11 | `experience_audit` | 经验巡检结论 |
|
||||
| 12 | `agent_action` | 动作库(命名动作) |
|
||||
| 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) |
|
||||
|
||||
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
|
||||
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
|
||||
@@ -232,6 +233,9 @@ UTF-8 等价于字节序)。
|
||||
| `discovery_interval` | 扫描周期秒(10-3600) | 同上 |
|
||||
| `discovery_port` | adb 探测端口(1-65535) | 同上 |
|
||||
| `discovery_auto_claim` | 指纹匹配时自动认领(`"1"`/`"0"`,默认关) | 同上 |
|
||||
| `agent_store_enabled` | 设备端应用商店开关(`"1"`/`"0"`,默认关) | 应用管理页 |
|
||||
| `agent_device_token` | **设备端令牌**(Agent 调设备接口用;属凭据) | 同上(启用时自动生成,可重置) |
|
||||
| `agent_device_token_at` | 令牌生成/重置时间 | 同上 |
|
||||
| `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 |
|
||||
| `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 |
|
||||
| `deployment_claimed_at` | 标签写入时间 | 同上 |
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# 设备端 Agent 接口契约(DEVICE_AGENT)
|
||||
|
||||
> 适用读者:写**设备端 Agent APK**(另一个仓库)的人,以及改平台侧这组接口的人。
|
||||
> 相关:[API.md](API.md)(管理端接口)、[DEPLOY.md](DEPLOY.md)(部署)、[ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
**这份文档是两个仓库之间的契约。** 平台侧实现见 `web/device_agent_api.py`;
|
||||
设备端实现见另一个仓库。**改协议必须同时改两边**(见 §6 版本约定)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么有这组接口
|
||||
|
||||
平台通过 **adb** 驱动设备(点击/输入/装应用……),但有三件事 adb 干不好,需要设备上装一个 App:
|
||||
|
||||
| 需求 | adb 的困境 | Agent 的做法 |
|
||||
|------|-----------|-------------|
|
||||
| 写系统剪贴板 | Android 10+ 禁止后台/shell 写剪贴板;u2 的 `set_clipboard` 会"成功但内容被丢弃" | 启动一个**透明 Activity**(有焦点才能写),写完立即退出 |
|
||||
| 安装 APK | MIUI 会拦 `adb install`(`USER_RESTRICTED`),要人工点「继续安装」 | **App 自己调系统安装器** → 走普通应用安装流程,**不触发那道 USB 拦截** |
|
||||
| 显示设备身份 | 现在要把一个 HTML 大字页推给设备浏览器打开,依赖浏览器 | 全屏大字显示:设备名/IP/序列号/平台地址 |
|
||||
|
||||
其中「装应用」是**设备主动来取**(拉清单 → 选 → 下载 → 装),所以平台开了一组
|
||||
**设备专用接口**;「写剪贴板」「显示身份」是**平台推指令**,仍走 adb(§5)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 接入流程(设备端视角)
|
||||
|
||||
```
|
||||
① 一次性:平台在「工具 → 应用管理 → 📱 设备端应用商店」启用,拿到
|
||||
接入地址 base_url(如 http://192.168.20.220:18050)和设备令牌 token
|
||||
—— 这两个值可以通过 adb 一次性推给设备(见 §5.3),不必在每台手机上手输
|
||||
|
||||
② 开机/定时:GET {base_url}/api/device/agent/bootstrap
|
||||
带上 X-Device-Token / X-Device-Fingerprint
|
||||
→ 拿到「我是谁」(平台分配的设备名)+「有哪些应用可以装」
|
||||
|
||||
③ 用户在手机上选一个应用:
|
||||
GET {base_url}/api/device/agent/apk/{id} → 下载 APK 到应用私有目录
|
||||
|
||||
④ 调系统安装器安装(PackageInstaller / ACTION_VIEW + FileProvider)
|
||||
—— 会弹系统安装确认框,用户在屏幕上点一下即可
|
||||
|
||||
⑤ 回报结果:POST {base_url}/api/device/agent/report
|
||||
(download / install_ok / install_fail + 失败原因)
|
||||
平台在「设备端应用商店」面板能看到记录
|
||||
```
|
||||
|
||||
> Agent 需要 `INTERNET` 与 `REQUEST_INSTALL_PACKAGES` 权限;还需要用户在系统设置里
|
||||
> 给"安装未知应用"授权(这是 App 自装的**唯一**前置条件,比 MIUI 的 USB 拦截好过得多)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口规格
|
||||
|
||||
所有接口:
|
||||
- **不需要登录会话**,靠 `X-Device-Token` 鉴权
|
||||
- 请求/响应均为 UTF-8 JSON(下载接口除外)
|
||||
- **功能默认关闭**:平台侧未启用时,全部返回 `404 {"ok":false,"error":"not found"}`
|
||||
(不暴露接口是否存在)
|
||||
|
||||
### 3.1 通用请求头
|
||||
|
||||
| 头 | 必填 | 说明 |
|
||||
|----|------|------|
|
||||
| `X-Device-Token` | ✅ | 平台生成的设备令牌 |
|
||||
| `X-Device-Fingerprint` | 建议 | `ro.serialno`。**平台靠它认出"你是哪台设备"**(换 IP 也能认回原名) |
|
||||
| `X-Device-Serial` | 可选 | 当前 adb 地址(`IP:5555`),指纹认不出时的兜底 |
|
||||
|
||||
错误码:
|
||||
|
||||
| 状态 | 含义 | 设备端该怎么办 |
|
||||
|------|------|---------------|
|
||||
| 404 | 平台未启用设备端商店(或接口不存在) | 提示管理员去平台开启;不要重试轰炸 |
|
||||
| 401 | 令牌不对/缺失 | 提示重新配置令牌(管理员重置后要在设备上更新) |
|
||||
| 500 | 平台内部错误 | 退避重试 |
|
||||
|
||||
### 3.2 `GET /api/device/agent/bootstrap`
|
||||
|
||||
拉设备身份 + 可安装应用清单。建议开机后拉一次 + 每次打开"商店"界面时拉一次。
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"agent_api_version": 1,
|
||||
"server_time": "2026-09-13 14:38:11",
|
||||
"platform": { "url": "http://192.168.20.220:18050" },
|
||||
"device": {
|
||||
"fingerprint": "woijo7v4sgnrhqb6",
|
||||
"serial": "192.168.20.100:5555",
|
||||
"name": "A08",
|
||||
"known": true
|
||||
},
|
||||
"apks": [
|
||||
{
|
||||
"id": "56b114aa",
|
||||
"display_name": "ClipInject",
|
||||
"package_name": "com.example.clipinject",
|
||||
"version_name": "1.0",
|
||||
"version_code": 1,
|
||||
"size": 11615,
|
||||
"upload_time": "2026-09-09 13:49:06",
|
||||
"download_path": "/api/device/agent/apk/56b114aa"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `device.name`:**平台给这台设备起的名字**(如 `A08`)—— 身份显示界面直接用它
|
||||
- `device.known=false`:这台设备还没登记进平台设备池(指纹没匹配上),`name` 为空;
|
||||
设备端应显示"未登记",不要假装自己有名字
|
||||
- `platform.url`:按**设备实际请求到的地址**回填,可直接用于拼下载地址
|
||||
- `apks[].download_path` 是相对路径,拼 `platform.url` 即完整 URL
|
||||
|
||||
### 3.3 `GET /api/device/agent/apk/{apk_id}`
|
||||
|
||||
下载 APK 文件本体(`application/vnd.android.package-archive`)。
|
||||
|
||||
- 成功:`200` + 文件流
|
||||
- `404`:id 不存在或平台侧文件已删(**注意与"未启用"的 404 区分:看响应体**,
|
||||
未启用时是 JSON `{"ok":false,...}`,文件缺失时也是 JSON —— 设备端拿到非 `application/...` 的响应就视为失败)
|
||||
|
||||
### 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`
|
||||
|
||||
回报下载/安装结果,平台侧留档(面板可见)。
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"apk_id": "56b114aa",
|
||||
"action": "download | install_ok | install_fail",
|
||||
"package_name": "com.example.clipinject",
|
||||
"version_name": "1.0",
|
||||
"message": "失败原因(install_fail 时必填,越具体越好)"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:`{"ok": true}`;`action` 非法 → `400`。
|
||||
|
||||
> **失败原因请写人话**:平台面板直接显示 `message`。
|
||||
> 例如「MIUI 拦截:用户未确认」「安装包解析失败」「存储空间不足」。
|
||||
|
||||
---
|
||||
|
||||
## 4. 平台侧配置(管理端)
|
||||
|
||||
「工具 → 应用管理 → 📱 设备端应用商店」:
|
||||
|
||||
| 操作 | 接口 |
|
||||
|------|------|
|
||||
| 读配置(含令牌) | `GET /api/agent-store/config` |
|
||||
| 启用/停用 | `POST /api/agent-store/config` `{"enabled": true}` |
|
||||
| 重置令牌 | `POST /api/agent-store/config` `{"regenerate_token": true}` |
|
||||
| 安装记录 | `GET /api/agent-store/logs?limit=50` |
|
||||
|
||||
- 三个接口都需要**登录 + 应用管理权限**
|
||||
- **停用**不影响已装的 App,只是设备端拿不到清单(返回 404)
|
||||
- **重置令牌**后旧令牌立即失效,所有设备要在 Agent 里更新
|
||||
|
||||
---
|
||||
|
||||
## 5. 平台 → 设备的指令(走 adb,不走网络)
|
||||
|
||||
这些**没有 HTTP 接口**,由平台用 `adb shell am start` 拉起设备上的 Activity。
|
||||
|
||||
### 5.1 剪贴板注入(已在用,务必保持兼容)
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell am start -n <pkg>/.ClipActivity --es text_b64 <base64(UTF-8)>
|
||||
```
|
||||
|
||||
- **必须 base64**:adb shell 直传中文/特殊字符会变形
|
||||
- 必须用**透明 Activity**(有焦点才能写剪贴板),写完**立即退出**
|
||||
- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist`
|
||||
→ 视为"设备未安装 Agent"
|
||||
|
||||
### 5.2 设备身份显示(设备端已实现)
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell am start -n <pkg>/.InfoActivity \
|
||||
--es name "A08" --es ip "192.168.20.100" --es serial "192.168.20.100:5555" \
|
||||
--es server "http://192.168.20.220:18050"
|
||||
# 关闭:--es close 1
|
||||
```
|
||||
|
||||
要求:全屏大字、再次调用可覆盖内容、`close` 可关闭、不驻留前台。
|
||||
|
||||
### 5.3 扫码配置(设备端已实现)
|
||||
|
||||
平台「设备池」里点某台设备的「**二维码**」→ 生成一张二维码 → 手机上的 Agent 点
|
||||
「扫码配置」扫一下,就把 **平台地址 + 设备令牌 + 这台设备的指纹** 一次写入。
|
||||
|
||||
**二维码内容**(JSON 文本,设备端按字段名解析,多余的字段忽略):
|
||||
```json
|
||||
{"v":1,"server":"http://192.168.20.250:18050","token":"<设备令牌>",
|
||||
"fingerprint":"s8o7nrt8pbzhfadq","name":"A07"}
|
||||
```
|
||||
|
||||
- `v` = 格式版本(当前 1);`fingerprint` 可能为空串(平台还没采到指纹)
|
||||
- 设备端扫到**不是本平台的二维码**时应提示并继续扫,不要静默失败
|
||||
- 平台侧生成接口:`GET /api/devices/qrcode?serial=<设备地址>`(需登录 + 设备权限),
|
||||
返回 `{png( base64 data-url), payload, warn}`;`warn` 非空时要显示出来
|
||||
(最常见的是"你现在用 localhost 打开平台,二维码里的地址手机会连不上")
|
||||
|
||||
> 与 §5.4 的 adb 下发**等价、互为补位**:设备已经连上平台时用 adb 批量下发(零操作),
|
||||
> 人在机器旁或设备还没接进来时用扫码(不用线、不用打字)。
|
||||
|
||||
### 5.4 一次性配置下发(adb,建议设备端实现)
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell am start -n <pkg>/.ConfigActivity \
|
||||
--es server "http://192.168.20.220:18050" --es token "<设备令牌>"
|
||||
```
|
||||
|
||||
有了它,8 台设备不用一台台手输地址和令牌。
|
||||
|
||||
---
|
||||
|
||||
## 6. 版本与兼容
|
||||
|
||||
- `agent_api_version`(当前 **1**)在每次 `bootstrap` 响应里返回
|
||||
- **设备端**:启动时对比自己实现的版本,不一致要在日志里明确记下来(不要静默)
|
||||
- **平台端**:
|
||||
- 加字段 / 加接口 = 小版本,设备端忽略未知字段即可
|
||||
- 改已有字段语义 / 删字段 / 改鉴权方式 = **大版本**,两边必须同时改
|
||||
- 改了协议,**必须同时改这份文档的两个仓库版本**(本文件是平台侧那一份)
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全须知
|
||||
|
||||
- **令牌是凭据**:等于"能读平台的应用清单和 APK 文件"。别写进日志、别外传、
|
||||
别提交到 git(平台侧存在 `app_meta.agent_device_token`,会随备份一起走)
|
||||
- 平台侧这组接口**只读**:只能读清单和下载 APK,**不能**触发任务、不能改设备池、
|
||||
不能读配置或密钥
|
||||
- 生产环境建议:令牌泄露时立即在面板点「重置令牌」
|
||||
- 设备端**不需要也不应该**拿 admin 账号密码
|
||||
@@ -18,6 +18,7 @@
|
||||
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
|
||||
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
|
||||
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:设计稿与里程碑(P0 未实现,属规划) | 平台开发者 |
|
||||
| [DEVICE_AGENT.md](DEVICE_AGENT.md) | **设备端 Agent 接口契约**:应用商店的设备专用接口(清单/下载/上报)、adb 指令协议、版本约定 —— 与设备端 APK 仓库共享的契约 | 设备端开发者、平台开发者 |
|
||||
| [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 |
|
||||
| [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 |
|
||||
| [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 |
|
||||
@@ -56,6 +57,7 @@
|
||||
| 任务类型 / 步骤 schema | [TASK_DEV.md](TASK_DEV.md) |
|
||||
| 常驻线程 / 进程装配 | [ARCHITECTURE.md](ARCHITECTURE.md) §线程与并发 |
|
||||
| MCP 工具 | [MCP.md](MCP.md) + [MCP_DESIGN.md](MCP_DESIGN.md) |
|
||||
| 设备端 Agent(APK) | [DEVICE_AGENT.md](DEVICE_AGENT.md) |
|
||||
| 对外接入约定 | [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) |
|
||||
| 暂缓项 / 已知问题 | [backlog/TODO.md](backlog/TODO.md)(完成时移出并同步相关文档) |
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ Flask-SQLAlchemy>=3.0,<4.0
|
||||
# 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。
|
||||
PyMySQL>=1.1
|
||||
|
||||
# 设备端 Agent 的「扫码配置」二维码(设备池面板生成,手机扫一下配好 server/token/指纹)。
|
||||
# 纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库。
|
||||
qrcode>=7.4
|
||||
|
||||
# 定时任务调度
|
||||
APScheduler>=3.10,<4.0
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ python3 - <<'PY'
|
||||
import importlib.util, sys
|
||||
mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler",
|
||||
"uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL",
|
||||
"fastmcp", "pymysql")
|
||||
"fastmcp", "pymysql", "qrcode")
|
||||
if all(importlib.util.find_spec(m) for m in mods):
|
||||
try:
|
||||
import cv2
|
||||
|
||||
+105
-10
@@ -29,15 +29,22 @@ async function loadApks(){
|
||||
await fillAppsDeviceList();
|
||||
}
|
||||
function renderApkRow(a){
|
||||
// 一行 = 一个应用(列表已按包名合并,显示最新版);旧版本给个数提示 + 清理入口
|
||||
const n=a.version_count||1;
|
||||
const verCell=(a.version_name?esc(a.version_name):'-')+
|
||||
(n>1?' <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>'+
|
||||
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></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 style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
|
||||
'<td>'+
|
||||
'<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>'+
|
||||
'</tr>';
|
||||
}
|
||||
@@ -50,7 +57,7 @@ async function fillAppsDeviceList(){
|
||||
const cur=sel.value;
|
||||
const devices=(sr.devices||[]).filter(d=>d.present);
|
||||
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('');
|
||||
if(cur)sel.value=cur;
|
||||
}
|
||||
@@ -140,9 +147,20 @@ async function uploadApk(input){
|
||||
input.value='';
|
||||
}
|
||||
|
||||
async function deleteApk(id){
|
||||
if(!confirm('确认删除此APK文件?'))return;
|
||||
const r=await apiDelete('/api/apks/'+id);
|
||||
async function deleteApk(id, versionCount){
|
||||
const n=versionCount||1;
|
||||
const msg = n>1
|
||||
? ('确认删除这个应用?\n\n它一共有 '+n+' 个版本,会**全部**删掉(想只留最新版请用「清理旧版本」)。')
|
||||
: '确认删除此APK文件?';
|
||||
if(!confirm(msg))return;
|
||||
// 一行 = 一个应用 → 删除即删掉该包的所有版本
|
||||
const r=await apiDelete('/api/apks/'+id+'?scope=package');
|
||||
handleResult(r,loadApks);
|
||||
}
|
||||
|
||||
async function cleanOldVersions(id, name, oldCount){
|
||||
if(!confirm('清理「'+name+'」的 '+oldCount+' 个旧版本?\n\n只保留最新版,旧版本文件会被删除。'))return;
|
||||
const r=await apiDelete('/api/apks/'+id+'?scope=older');
|
||||
handleResult(r,loadApks);
|
||||
}
|
||||
|
||||
@@ -168,8 +186,8 @@ async function openInstallModal(apkId){
|
||||
online.map(d=>{
|
||||
const isUsb=d.source==='usb';
|
||||
const st='<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>';
|
||||
}).join('')+
|
||||
'</div>';
|
||||
@@ -210,7 +228,7 @@ function showInstallProgress(){
|
||||
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
|
||||
pollInstallStatus();
|
||||
if(_installTimer)clearInterval(_installTimer);
|
||||
_installTimer=setInterval(pollInstallStatus,3000);
|
||||
_installTimer=setInterval(pollInstallStatus,1500);
|
||||
}
|
||||
|
||||
async function pollInstallStatus(){
|
||||
@@ -226,10 +244,13 @@ async function pollInstallStatus(){
|
||||
const items=st.items||{};
|
||||
const itemRows=Object.entries(items).map(([serial,info])=>{
|
||||
const s=SS[info.status]||{t:info.status,c:'default'};
|
||||
// 推送进度带百分比时补一条细进度条(大包传输要一两分钟,光有文字不够直观)
|
||||
const m=/推送中 (\d+)%/.exec(info.msg||'');
|
||||
const bar=m?'<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>'+
|
||||
'<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 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('');
|
||||
|
||||
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
|
||||
@@ -262,3 +283,77 @@ function closeInstallModal(){
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ================== 设备端应用商店(Agent 直连安装) ==================
|
||||
// 设备上的 Agent 拉 /api/device/agent/bootstrap 拿清单 → 自己下载 → 自己调系统安装器。
|
||||
// 因为安装由设备上的 App 发起,走普通应用安装流程,不会触发 MIUI 的「USB 安装」拦截。
|
||||
let _storeToken='';
|
||||
|
||||
async function loadAgentStore(){
|
||||
try{
|
||||
const cfg=await (await fetch('/api/agent-store/config',{headers:_csrfHeaders()})).json();
|
||||
if(cfg&&cfg.ok){
|
||||
_storeToken=cfg.token||'';
|
||||
const en=document.getElementById('store-enabled');
|
||||
if(en)en.checked=!!cfg.enabled;
|
||||
const u=document.getElementById('store-url');
|
||||
if(u)u.value=cfg.base_url_hint||'';
|
||||
const t=document.getElementById('store-token');
|
||||
if(t)t.value=_storeToken||'(未生成,勾选启用后自动生成)';
|
||||
}
|
||||
const d=await (await fetch('/api/agent-store/logs?limit=30',{headers:_csrfHeaders()})).json();
|
||||
renderStoreLogs((d&&d.logs)||[]);
|
||||
}catch(e){ /* 面板没打开时静默 */ }
|
||||
}
|
||||
|
||||
function renderStoreLogs(logs){
|
||||
const tb=document.getElementById('tb-store-logs');
|
||||
if(!tb)return;
|
||||
if(!logs.length){
|
||||
tb.innerHTML='<tr><td colspan="5" class="empty">暂无记录(设备还没从商店装过东西)</td></tr>';
|
||||
return;
|
||||
}
|
||||
const act={download:'⬇ 已下载',install_ok:'✅ 安装成功',install_fail:'❌ 安装失败'};
|
||||
tb.innerHTML=logs.map(l=>{
|
||||
const app=[l.display_name||l.package_name||l.apk_id,
|
||||
l.version_name?('v'+l.version_name):''].filter(Boolean).join(' ');
|
||||
const cls=l.action==='install_fail'?'style="color:#b42318"':(l.action==='install_ok'?'style="color:#067647"':'');
|
||||
return '<tr><td>'+esc(l.created_at||'')+'</td><td>'+esc(l.device_name||l.serial||'-')+'</td>'+
|
||||
'<td title="'+esc(l.package_name||'')+'('+esc(l.apk_id||'')+')">'+esc(_cut(app,30))+'</td>'+
|
||||
'<td '+cls+'>'+esc(act[l.action]||l.action||'')+'</td>'+
|
||||
'<td>'+esc(_cut(l.message||'',60))+'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleAgentStore(on){
|
||||
const r=await apiPost('/api/agent-store/config',{enabled:!!on});
|
||||
if(!r)return;
|
||||
if(r.ok){
|
||||
showToast(on?'设备端商店已启用':'设备端商店已停用','success');
|
||||
loadAgentStore();
|
||||
}else{
|
||||
showToast(r.error||'设置失败','error');
|
||||
document.getElementById('store-enabled').checked=!on;
|
||||
}
|
||||
}
|
||||
|
||||
async function regenStoreToken(){
|
||||
if(!confirm('重置设备令牌?\n\n所有已配置该令牌的设备会立即失效,需要在设备端 Agent 里重新填新令牌。'))return;
|
||||
const r=await apiPost('/api/agent-store/config',{regenerate_token:true});
|
||||
if(r&&r.ok){showToast('令牌已重置','success');loadAgentStore();}
|
||||
else if(r)showToast(r.error||'重置失败','error');
|
||||
}
|
||||
|
||||
function copyStoreToken(){
|
||||
if(!_storeToken){showToast('还没有令牌(先启用设备端商店)','error');return;}
|
||||
const done=()=>showToast('令牌已复制','success');
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(_storeToken).then(done).catch(()=>{
|
||||
window.prompt('手动复制令牌:',_storeToken);
|
||||
});
|
||||
}else{
|
||||
window.prompt('手动复制令牌:',_storeToken);
|
||||
}
|
||||
}
|
||||
|
||||
function _cut(s,n){s=String(s==null?'':s);return s.length>n?(s.slice(0,n-1)+'…'):s;}
|
||||
|
||||
@@ -123,6 +123,7 @@ function showSubTab(tabId, name){
|
||||
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
||||
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
||||
if(name==='groups' && typeof loadGroups==='function') loadGroups();
|
||||
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
|
||||
if(name==='devpool' && typeof loadDevPool==='function'){
|
||||
loadDevPool();
|
||||
if(typeof loadDiscovery==='function'){
|
||||
|
||||
+18
-17
@@ -70,27 +70,28 @@ async function stopSelectedDevices(){
|
||||
setTimeout(loadMonitor,500);
|
||||
}
|
||||
|
||||
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标
|
||||
// 注意:打开定位页会切换设备前台,任务运行中慎用
|
||||
async function locateDevice(serial){
|
||||
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return;
|
||||
const r=await apiPost('/api/device/locate',{serial:serial,show:true});
|
||||
// 打开设备端 Agent:让设备上的 Agent 弹出「身份大字页」(名称/IP/平台地址),
|
||||
// 人站在机架前一眼看出这台是平台上的哪一台。
|
||||
// 替代原来的"推 HTML 让设备浏览器打开定位页"——那条路要设备装浏览器、页面还依赖
|
||||
// 设备能访问平台,且在 MIUI 上本来就不稳。
|
||||
async function showAgentInfo(serial){
|
||||
const r=await apiPost('/api/agent/show-info',{serial:serial});
|
||||
if(!r)return;
|
||||
if(r.ok){
|
||||
_locatingSerials.add(serial);
|
||||
showToast(r.msg||'已定位','success');
|
||||
showToast(r.msg||'已让设备显示身份信息','success');
|
||||
renderDevices();
|
||||
}else showToast(r.error||'定位失败','error');
|
||||
}else showToast(r.error||'打开失败','error');
|
||||
}
|
||||
|
||||
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位"
|
||||
async function stopLocate(serial){
|
||||
const r=await apiPost('/api/device/locate/stop',{serial:serial});
|
||||
// 关闭设备端 Agent 的身份页
|
||||
async function hideAgentInfo(serial){
|
||||
const r=await apiPost('/api/agent/show-info',{serial:serial,close:true});
|
||||
if(!r)return;
|
||||
_locatingSerials.delete(serial);
|
||||
renderDevices();
|
||||
if(r.ok)showToast(r.msg||'已结束定位','success');
|
||||
else showToast(r.error||'结束定位失败','error');
|
||||
if(r.ok)showToast(r.msg||'已关闭','success');
|
||||
else showToast(r.error||'关闭失败','error');
|
||||
}
|
||||
|
||||
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
|
||||
@@ -344,13 +345,13 @@ function renderDeviceRow(dev){
|
||||
// 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
|
||||
const canDev=_can('devices');
|
||||
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<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 locateBtn=canDev?(locating
|
||||
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>'
|
||||
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):'';
|
||||
?'<button class="btn btn-xs btn-danger" onclick="hideAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 关掉身份大字页">关闭 Agent</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')
|
||||
?'<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';
|
||||
return '<label data-serial="'+esc(d.serial)+'">'
|
||||
+'<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>';
|
||||
}).join('');
|
||||
status.textContent = '共 '+_clipDevices.length+' 台设备';
|
||||
@@ -252,12 +252,14 @@ async function queryAppVers(){
|
||||
}
|
||||
}
|
||||
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>';
|
||||
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>'+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>';
|
||||
}
|
||||
|
||||
@@ -496,6 +498,7 @@ async function loadDevPool(){
|
||||
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
|
||||
'<td style="white-space:nowrap">'+
|
||||
'<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 ? '' :
|
||||
'<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> '+
|
||||
@@ -570,3 +573,37 @@ async function refreshPoolModels(){
|
||||
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
|
||||
else showToast(r.error,'error');
|
||||
}
|
||||
|
||||
// ================== 设备配置二维码(设备端 Agent 扫码即配好) ==================
|
||||
// 二维码内容:平台地址 + 设备令牌 + 这台设备的指纹(JSON)。
|
||||
// 手机上的 Agent 扫一下 → 保存这三项 → 不用在手机上打字、也不用连 adb。
|
||||
async function showDevQrcode(serial){
|
||||
document.getElementById('modal-title').textContent='设备配置二维码';
|
||||
document.getElementById('modal-body').innerHTML='<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>';
|
||||
}
|
||||
|
||||
@@ -788,6 +788,34 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<tbody id="tb-apks"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-apks"></div>
|
||||
|
||||
<!-- 设备端应用商店:设备上的 Agent 自己拉清单/下载/安装(绕开 MIUI 的 USB 安装拦截) -->
|
||||
<div class="section-title" style="margin-top:16px">📱 设备端应用商店(Agent 直连安装)</div>
|
||||
<div class="help">
|
||||
设备上装的 <b>Agent</b> 自己来拉这份应用清单 → 在手机上选一个 → 自己下载 → 自己调系统安装器安装。
|
||||
因为安装由设备上的 App 发起,走的是<b>普通应用安装流程</b>,
|
||||
<b>不会触发 MIUI 针对 adb 安装的「USB 安装」拦截</b>(那正是 <code>adb install</code> 报 USER_RESTRICTED 的原因)。
|
||||
适合"人就在机器旁边"的场景(本来就要点一次安装确认)。平台批量推送仍用上面的「安装」按钮。
|
||||
<br><span class="text-muted">接口契约见 <code>doc/DEVICE_AGENT.md</code>;令牌属于凭据,别外传。</span>
|
||||
</div>
|
||||
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin:0">
|
||||
<input type="checkbox" id="store-enabled" onchange="toggleAgentStore(this.checked)"> 启用设备端商店
|
||||
</label>
|
||||
<input class="form-control" id="store-url" readonly style="width:290px;font-family:monospace" title="Agent 里要填的平台地址">
|
||||
<input class="form-control" id="store-token" readonly style="width:300px;font-family:monospace" title="Agent 里要填的设备令牌">
|
||||
<button class="btn" onclick="copyStoreToken()">复制令牌</button>
|
||||
<button class="btn" onclick="regenStoreToken()">重置令牌</button>
|
||||
<button class="btn" onclick="loadAgentStore()">刷新记录</button>
|
||||
<span class="text-muted" id="store-status"></span>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th style="width:150px">时间</th><th style="width:90px">设备</th>
|
||||
<th style="width:200px">应用</th><th style="width:110px">动作</th><th>说明</th>
|
||||
</tr></thead>
|
||||
<tbody id="tb-store-logs"><tr><td colspan="5" class="empty">暂无记录(设备还没从商店装过东西)</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 子分栏 5:应用版本管理 -->
|
||||
|
||||
+4
-1
@@ -10,6 +10,7 @@
|
||||
apks — 应用管理
|
||||
tailscale — Tailscale 管理
|
||||
system — 系统数据备份导出/导入
|
||||
device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
@@ -28,6 +29,8 @@ def register_blueprints(app):
|
||||
from .agent_api import bp as agent_bp
|
||||
_agent_mod.set_app(app)
|
||||
from .system_api import bp as system_bp
|
||||
from .device_agent_api import bp as devagent_bp
|
||||
for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp,
|
||||
devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp):
|
||||
devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp,
|
||||
devagent_bp):
|
||||
app.register_blueprint(bp)
|
||||
|
||||
+39
-12
@@ -17,8 +17,12 @@ bp = Blueprint("apks", __name__)
|
||||
@bp.route("/api/apks")
|
||||
@login_required
|
||||
def api_apks_list():
|
||||
"""列出所有已上传的 APK。"""
|
||||
return jsonify({"ok": True, "apks": context.apk_mgr.list_all()})
|
||||
"""列出已上传的 APK —— **按包名合并,一个应用一行(最新版本)**。
|
||||
|
||||
同一个应用传过 v1.2 / v1.3 时不再并排显示两行;每行带 versions
|
||||
(全部版本)与 version_count,界面可提示"另有 N 个旧版本"并按需清理。
|
||||
"""
|
||||
return jsonify({"ok": True, "apks": context.apk_mgr.list_latest()})
|
||||
|
||||
@bp.route("/api/apks/upload", methods=["POST"])
|
||||
@perm_required(PERM_APKS)
|
||||
@@ -36,8 +40,19 @@ def api_apks_upload():
|
||||
@bp.route("/api/apks/<apk_id>", methods=["DELETE"])
|
||||
@perm_required(PERM_APKS)
|
||||
def api_apks_delete(apk_id):
|
||||
"""删除 APK 文件和记录。"""
|
||||
ok, msg = context.apk_mgr.delete(apk_id)
|
||||
"""删除 APK。
|
||||
|
||||
默认只删这一个版本(列表里一行 = 一个应用,所以界面通常传 scope):
|
||||
?scope=package → 删掉**这个应用的所有版本**
|
||||
?scope=older → 只清理**旧版本**,保留最新那版
|
||||
不带 scope 时保持老行为(单条删除),给脚本/外部调用留兼容。
|
||||
"""
|
||||
scope = (request.args.get("scope") or "").strip()
|
||||
if scope in ("package", "older"):
|
||||
ok, msg = context.apk_mgr.delete_package(
|
||||
apk_id, keep_latest=(scope == "older"))
|
||||
else:
|
||||
ok, msg = context.apk_mgr.delete(apk_id)
|
||||
if ok:
|
||||
return jsonify({"ok": True, "msg": msg})
|
||||
return jsonify({"ok": False, "error": msg}), 400
|
||||
@@ -50,21 +65,33 @@ def api_apks_install_devices():
|
||||
source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。
|
||||
"""
|
||||
devices, seen = [], set()
|
||||
name_by_serial, name_by_fp = {}, {}
|
||||
try:
|
||||
online = set(device_pool.list_online())
|
||||
for serial in device_pool.list_configured():
|
||||
if serial in online:
|
||||
devices.append({"serial": serial, "model": "", "source": "pool"})
|
||||
# list_devices() 返回字典(含 name/fingerprint);list_configured() 只有 serial 字符串
|
||||
for d in device_pool.list_devices():
|
||||
serial = d.get("serial")
|
||||
if serial in online and serial not in seen:
|
||||
devices.append({"serial": serial, "model": "",
|
||||
"name": d.get("name") or "", "source": "pool"})
|
||||
seen.add(serial)
|
||||
except Exception:
|
||||
pass
|
||||
if serial:
|
||||
name_by_serial[serial] = d.get("name") or ""
|
||||
# USB 设备的 adb serial == ro.serialno == 池里那条网络记录的指纹,
|
||||
# 靠指纹才认得出是同一台(否则插着 USB 时会冒出一个没名字的裸序列号)
|
||||
if d.get("fingerprint"):
|
||||
name_by_fp[d["fingerprint"]] = d.get("name") or ""
|
||||
except Exception as e:
|
||||
_log.warning("读取设备名失败(列表将只显示地址): %s", e)
|
||||
out = _adb("devices")
|
||||
for line in (out or "").splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] and parts[0] not in seen:
|
||||
devices.append({"serial": parts[0], "model": "",
|
||||
"source": "usb" if ":" not in parts[0] else "adb"})
|
||||
seen.add(parts[0])
|
||||
s = parts[0]
|
||||
devices.append({"serial": s, "model": "",
|
||||
"name": name_by_serial.get(s) or name_by_fp.get(s) or "",
|
||||
"source": "usb" if ":" not in s else "adb"})
|
||||
seen.add(s)
|
||||
return jsonify({"ok": True, "devices": devices})
|
||||
|
||||
@bp.route("/api/apks/install", methods=["POST"])
|
||||
|
||||
+21
-2
@@ -7,10 +7,12 @@ _log = get_logger("web")
|
||||
|
||||
|
||||
def _merged_device_list():
|
||||
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。
|
||||
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, name, state}])。
|
||||
|
||||
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
|
||||
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
|
||||
name 来自设备池(平台给设备起的名字,如 A08)—— 界面上统一按「名称 · 地址」
|
||||
显示,同型号多台时才分得清是哪一台;池里没有的设备 name 为空,界面回退显示地址。
|
||||
"""
|
||||
states = {}
|
||||
out = _adb("devices")
|
||||
@@ -18,13 +20,30 @@ def _merged_device_list():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
|
||||
states[parts[0]] = parts[1]
|
||||
# 注意用的是 list_devices()(**字典**,含 name/fingerprint);
|
||||
# list_configured() 只返回 serial 字符串列表,别拿它当字典用(实测踩过:.get 抛异常
|
||||
# 被 except 吞掉 → 名字全空)
|
||||
names, fp_names = {}, {}
|
||||
try:
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("serial"):
|
||||
names[d["serial"]] = d.get("name") or ""
|
||||
# USB 设备在 adb 里的 serial 就是 ro.serialno,等于池里那条网络记录的
|
||||
# **指纹** —— 插着 USB 时会以另一个 serial 出现,靠指纹才认得出是同一台,
|
||||
# 否则界面上会冒出一个没名字的裸序列号(实测踩过)
|
||||
if d.get("fingerprint"):
|
||||
fp_names[d["fingerprint"]] = d.get("name") or ""
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("web").warning(f"读取设备名失败: {e}")
|
||||
try:
|
||||
for serial in device_pool.list_configured():
|
||||
if serial not in states:
|
||||
states[serial] = "pool"
|
||||
except Exception:
|
||||
pass
|
||||
return [{"serial": s, "state": st} for s, st in sorted(states.items())]
|
||||
return [{"serial": s, "name": names.get(s) or fp_names.get(s) or "", "state": st}
|
||||
for s, st in sorted(states.items())]
|
||||
|
||||
|
||||
def _device_screen_state(serial):
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""设备端 Agent 专用接口(设备主动来取,**不走登录会话**,靠设备令牌鉴权)。
|
||||
|
||||
用途:设备上装的聚合 Agent(剪贴板注入 / 身份显示 / 应用商店)用这组接口
|
||||
自报身份、拉可安装的应用清单、下载 APK、回报安装结果。
|
||||
|
||||
为什么另开一组接口而不是复用管理端 API:
|
||||
* 设备登录不了后台会话,也不该拿 admin 的凭据;
|
||||
* 平台 → 设备的**指令**仍然走 adb(`am start`),这组接口只解决
|
||||
「设备主动来取东西」这一半,边界清楚。
|
||||
|
||||
安全设计(这组接口是平台唯一对设备开放的面):
|
||||
1. **默认关闭**:`app_meta.agent_store_enabled != "1"` 时统一返回 404,
|
||||
不暴露接口是否存在;
|
||||
2. 必须带 `X-Device-Token`,与 `app_meta.agent_device_token` **常量时间**比对;
|
||||
3. 只暴露 APK 元数据与文件本身 —— 不返回任何配置、密钥、设备凭据;
|
||||
4. 令牌可一键重置(重置后旧令牌立即失效)。
|
||||
|
||||
管理端接口在文件后半段(`/api/agent-store/*`,走正常登录 + 权限)。
|
||||
"""
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
|
||||
from config import APK_DIR
|
||||
from core import device_pool
|
||||
from core.db_config import meta_get, meta_set
|
||||
from core.logger import get_logger
|
||||
from web.auth import PERM_APKS, PERM_DEVICES, perm_required
|
||||
|
||||
bp = Blueprint("device_agent", __name__)
|
||||
_log = get_logger("web.devagent")
|
||||
|
||||
# app_meta 键
|
||||
_K_ENABLED = "agent_store_enabled" # "1" 开启;默认关
|
||||
_K_TOKEN = "agent_device_token" # 设备令牌(首次开启时生成)
|
||||
_K_CREATED = "agent_device_token_at" # 令牌生成/重置时间
|
||||
|
||||
_API_VERSION = 1 # 本组接口的版本(契约里写死给 agent 对照)
|
||||
_MAX_LOG = 500 # 记录表最多保留条数(超了删最旧)
|
||||
|
||||
|
||||
# ================== 公共:开关与令牌 ==================
|
||||
def store_enabled():
|
||||
return (meta_get(_K_ENABLED) or "0") == "1"
|
||||
|
||||
|
||||
def get_token():
|
||||
return meta_get(_K_TOKEN) or ""
|
||||
|
||||
|
||||
def ensure_token(regenerate=False):
|
||||
"""取设备令牌,没有(或要求重置)就生成一个。"""
|
||||
tok = get_token()
|
||||
if regenerate or not tok:
|
||||
tok = secrets.token_urlsafe(24)
|
||||
meta_set(_K_TOKEN, tok)
|
||||
meta_set(_K_CREATED, time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
_log.info("设备端商店令牌已%s", "重置" if regenerate else "生成")
|
||||
return tok
|
||||
|
||||
|
||||
def _deny():
|
||||
"""统一拒绝:功能关着就 404(不暴露存在性),令牌不对 401。"""
|
||||
if not store_enabled():
|
||||
return jsonify({"ok": False, "error": "not found"}), 404
|
||||
tok = request.headers.get("X-Device-Token", "") or ""
|
||||
real = get_token()
|
||||
if not real or not tok or not hmac.compare_digest(tok, real):
|
||||
_log.warning("设备端接口鉴权失败(来源 %s)", request.remote_addr)
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
return None
|
||||
|
||||
|
||||
def _identify():
|
||||
"""从请求头识别是哪台设备:指纹优先(换过 IP 也认得出),其次序列号。"""
|
||||
fp = (request.headers.get("X-Device-Fingerprint", "") or "").strip()
|
||||
serial = (request.headers.get("X-Device-Serial", "") or "").strip()
|
||||
name = ""
|
||||
try:
|
||||
if fp:
|
||||
# 注意:find_by_fingerprint / list_configured 返回的都是 dict(不是 ORM 对象)
|
||||
d = device_pool.find_by_fingerprint(fp)
|
||||
if d:
|
||||
return fp, d.get("serial", serial), d.get("name") or "", True
|
||||
if serial:
|
||||
for d in device_pool.list_devices(): # 返回字典(list_configured 只有 serial)
|
||||
if d.get("serial") == serial:
|
||||
return fp, serial, d.get("name") or "", True
|
||||
except Exception as e:
|
||||
_log.warning("识别设备失败: %s", e)
|
||||
return fp, serial, name, False
|
||||
|
||||
|
||||
# ================== 设备侧接口 ==================
|
||||
@bp.route("/api/device/agent/bootstrap")
|
||||
def api_dev_agent_bootstrap():
|
||||
"""设备开机/定时来拉:我是谁 + 有哪些应用可以装。
|
||||
|
||||
请求头:`X-Device-Token`(必带)、`X-Device-Fingerprint`、`X-Device-Serial`
|
||||
响应:设备身份(平台分配的名称)+ 应用清单(含下载地址)
|
||||
"""
|
||||
deny = _deny()
|
||||
if deny:
|
||||
return deny
|
||||
fp, serial, name, known = _identify()
|
||||
|
||||
from web import context
|
||||
apks = []
|
||||
try:
|
||||
# 用「按包名合并」的列表:同一个应用只出现一行(最新版),
|
||||
# 设备上不会看到同一个 App 的 v1.2 / v1.3 两行
|
||||
for a in context.apk_mgr.list_latest():
|
||||
apks.append({
|
||||
"id": a.get("id", ""),
|
||||
"display_name": a.get("display_name") or a.get("filename") or "",
|
||||
"package_name": a.get("package_name") or "",
|
||||
"version_name": a.get("version_name") or "",
|
||||
"version_code": a.get("version_code") or 0,
|
||||
"size": a.get("size") or 0,
|
||||
"upload_time": a.get("upload_time") or "",
|
||||
"download_path": "/api/device/agent/apk/{}".format(a.get("id", "")),
|
||||
})
|
||||
except Exception as e:
|
||||
_log.error("设备端拉取应用清单失败: %s", e)
|
||||
return jsonify({"ok": False, "error": "清单读取失败"}), 500
|
||||
|
||||
_log.info("设备端拉清单: %s(%s) 应用 %d 个", name or "未登记", serial or fp, len(apks))
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"agent_api_version": _API_VERSION,
|
||||
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"platform": {
|
||||
# 用请求里的 Host 回填,设备按自己实际能连通的地址访问
|
||||
"url": request.host_url.rstrip("/"),
|
||||
},
|
||||
"device": {"fingerprint": fp, "serial": serial,
|
||||
"name": name, "known": known},
|
||||
"apks": apks,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/device/agent/apk/<apk_id>")
|
||||
def api_dev_agent_apk(apk_id):
|
||||
"""下载 APK 文件(设备自己拉,比 adb push 快且可并行)。"""
|
||||
deny = _deny()
|
||||
if deny:
|
||||
return deny
|
||||
from core.models import ApkFile
|
||||
|
||||
row = ApkFile.query.get(apk_id)
|
||||
if not row:
|
||||
return jsonify({"ok": False, "error": "apk not found"}), 404
|
||||
path = os.path.join(APK_DIR, row.filename or (apk_id + ".apk"))
|
||||
fname = row.display_name or row.filename or apk_id
|
||||
|
||||
if not os.path.exists(path):
|
||||
return jsonify({"ok": False, "error": "apk file missing"}), 404
|
||||
_log.info("设备端下载 APK: %s", apk_id)
|
||||
return send_file(path, as_attachment=True,
|
||||
download_name="{}.apk".format(fname),
|
||||
mimetype="application/vnd.android.package-archive")
|
||||
|
||||
|
||||
@bp.route("/api/device/agent/install", methods=["POST"])
|
||||
def api_dev_agent_install():
|
||||
"""设备请求**平台**帮它装这个应用(平台走 adb,静默,没有系统安装框)。
|
||||
|
||||
为什么这么设计:普通 App 自己调系统安装器,MIUI 上要过「继续 → 勾选未检测
|
||||
→ 继续更新」甚至 ICP 备案检查好几步;而 `adb install` 在同一台设备上实测是
|
||||
**静默**的(升级 2.7s、全新安装 7.9s,零弹框)。
|
||||
|
||||
所以设备端商店的「安装/更新」按钮走这里:**用户还是在手机上选**(应用商店体验),
|
||||
真正干活的是平台,几秒钟装完。万一 adb 被 MIUI 拦了,返回 ok=false,
|
||||
设备端自己退回本地安装(有弹框,但至少能装)。
|
||||
"""
|
||||
deny = _deny()
|
||||
if deny:
|
||||
return deny
|
||||
data = request.json or {}
|
||||
apk_id = str(data.get("apk_id", ""))
|
||||
if not apk_id:
|
||||
return jsonify({"ok": False, "error": "缺少 apk_id"}), 400
|
||||
|
||||
fp, serial, name, known = _identify()
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "无法识别设备"}), 400
|
||||
if not known:
|
||||
return jsonify({"ok": False, "error": "这台设备还没登记进平台设备池"}), 409
|
||||
|
||||
from core.models import ApkFile
|
||||
if not ApkFile.query.get(apk_id):
|
||||
return jsonify({"ok": False, "error": "apk not found"}), 404
|
||||
|
||||
from web import context
|
||||
try:
|
||||
ok, msg = context.apk_mgr.install(apk_id, [serial])
|
||||
except Exception as e:
|
||||
_log.error("设备端请求安装失败: %s", e)
|
||||
return jsonify({"ok": False, "error": "平台安装任务启动失败"}), 500
|
||||
_log.info("设备端请求安装: %s(%s) apk=%s -> %s", name or "未登记", serial, apk_id, msg)
|
||||
if not ok:
|
||||
return jsonify({"ok": False, "error": msg}), 400
|
||||
return jsonify({"ok": True, "msg": msg, "serial": serial})
|
||||
|
||||
|
||||
@bp.route("/api/device/agent/report", methods=["POST"])
|
||||
def api_dev_agent_report():
|
||||
"""设备上报下载/安装结果,平台侧留下记录(应用管理页可见)。
|
||||
|
||||
请求体:{apk_id, action: download|install_ok|install_fail,
|
||||
package_name?, version_name?, message?, serial?}
|
||||
"""
|
||||
deny = _deny()
|
||||
if deny:
|
||||
return deny
|
||||
data = request.json or {}
|
||||
action = str(data.get("action", ""))[:20]
|
||||
if action not in ("download", "install_ok", "install_fail"):
|
||||
return jsonify({"ok": False, "error": "action 非法"}), 400
|
||||
fp, serial, name, _known = _identify()
|
||||
if data.get("serial"):
|
||||
serial = str(data["serial"])[:120]
|
||||
|
||||
from core.models import DeviceInstallLog, db
|
||||
try:
|
||||
db.session.add(DeviceInstallLog(
|
||||
fingerprint=fp, serial=serial, device_name=name,
|
||||
apk_id=str(data.get("apk_id", ""))[:32],
|
||||
package_name=str(data.get("package_name", ""))[:200],
|
||||
version_name=str(data.get("version_name", ""))[:50],
|
||||
action=action,
|
||||
message=str(data.get("message", ""))[:500],
|
||||
created_at=time.strftime("%Y-%m-%d %H:%M:%S")))
|
||||
db.session.commit()
|
||||
_prune_logs()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.error("设备端安装记录写入失败: %s", e)
|
||||
return jsonify({"ok": False, "error": "记录失败"}), 500
|
||||
_log.info("设备端上报: %s(%s) %s %s %s", name or "未登记", serial or fp,
|
||||
action, data.get("apk_id", ""), data.get("message", ""))
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
def _prune_logs(keep=_MAX_LOG):
|
||||
"""记录表只留最近 N 条(失败忽略,不影响上报)。"""
|
||||
try:
|
||||
from core.models import DeviceInstallLog, db
|
||||
n = db.session.query(DeviceInstallLog).count()
|
||||
if n <= keep:
|
||||
return
|
||||
old = (db.session.query(DeviceInstallLog.id)
|
||||
.order_by(DeviceInstallLog.id).limit(n - keep).all())
|
||||
if old:
|
||||
db.session.query(DeviceInstallLog).filter(
|
||||
DeviceInstallLog.id.in_([r[0] for r in old])).delete(
|
||||
synchronize_session=False)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
_log.warning("清理设备端安装记录失败: %s", e)
|
||||
|
||||
|
||||
# ================== 管理端接口(登录 + 权限)==================
|
||||
@bp.route("/api/agent/show-info", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_agent_show_info():
|
||||
"""让设备上的 Agent 打开「身份大字页」(监控页那个按钮调它)。
|
||||
|
||||
替代原来那套「推 HTML 让设备浏览器打开定位页」——那套在 MIUI 上要装浏览器、
|
||||
页面还依赖设备能访问平台;Agent 是常驻 App,一条 `am start` 就出画面。
|
||||
|
||||
请求:{"serial": "…", "close": false}
|
||||
返回:{ok, msg};设备没装 Agent 时明确报出来(不静默失败)
|
||||
"""
|
||||
from core.adb_helper import _adb
|
||||
|
||||
data = request.json or {}
|
||||
serial = (data.get("serial") or "").strip()
|
||||
close = bool(data.get("close"))
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
|
||||
if close:
|
||||
args = ["--es", "close", "1"]
|
||||
else:
|
||||
# 设备信息:名称/IP/平台地址 —— Agent 用它们显示大字页
|
||||
name, ip = "", ""
|
||||
ip = serial.split(":")[0] if ":" in serial else ""
|
||||
for d in device_pool.list_devices(): # 字典列表(list_configured 只有 serial)
|
||||
if d.get("serial") == serial:
|
||||
name = d.get("name") or ""
|
||||
break
|
||||
if not name:
|
||||
# USB 设备的 serial 就是指纹:池里那条记录的 serial 是网络地址,
|
||||
# 靠指纹才认得出来(否则大字页标题会显示一串裸序列号)
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("fingerprint") and d["fingerprint"] == serial:
|
||||
name = d.get("name") or ""
|
||||
break
|
||||
if not ip:
|
||||
# USB 设备:平台不知道它的 IP,现问一下设备(只读命令,不影响设备)
|
||||
out = _adb("-s", serial, "shell", "ip", "-4", "addr", "show", "wlan0")
|
||||
import re as _re
|
||||
m = _re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", out or "")
|
||||
ip = m.group(1) if m else ""
|
||||
# 平台地址:用操作者当前访问的地址;如果是 localhost,设备根本连不上 →
|
||||
# 换成局域网地址(设备上显示 127.0.0.1 毫无意义)
|
||||
server = request.host_url.rstrip("/")
|
||||
if "127.0.0.1" in server or "localhost" in server:
|
||||
from web.devices_api import _lan_ip
|
||||
server = "http://%s:18050" % _lan_ip()
|
||||
args = ["--es", "name", name or serial.split(":")[0],
|
||||
"--es", "serial", serial,
|
||||
"--es", "server", server]
|
||||
if ip:
|
||||
args += ["--es", "ip", ip]
|
||||
|
||||
# 先点亮屏幕、解除锁屏:设备息屏时 am start 会把大字页开在锁屏**后面**,
|
||||
# 人看到的还是一片黑(实测踩过)——这正是原来"定位"按钮里的"点亮屏幕"那一步
|
||||
if not close:
|
||||
try:
|
||||
_adb("-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP")
|
||||
_adb("-s", serial, "shell", "wm", "dismiss-keyguard")
|
||||
except Exception as e:
|
||||
_log.warning("点亮屏幕失败(不影响显示身份页): %s", e)
|
||||
|
||||
try:
|
||||
out = _adb("-s", serial, "shell", "am", "start", "-n",
|
||||
"com.example.deviceagent/.InfoActivity", *args) or ""
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": f"adb 执行失败: {e}"}), 500
|
||||
|
||||
low = out.lower()
|
||||
if "unable to resolve intent" in low or "does not exist" in low or "error" in low:
|
||||
return jsonify({"ok": False,
|
||||
"error": "这台设备没装设备 Agent(com.example.deviceagent)——"
|
||||
"可以从「应用管理 → 设备端应用商店」装,或用扫码配置"}), 400
|
||||
_log.info("设备端 Agent %s: %s", "关闭身份页" if close else "打开身份页", serial)
|
||||
return jsonify({"ok": True, "msg": "已关闭" if close else "已让设备显示身份信息"})
|
||||
|
||||
|
||||
@bp.route("/api/agent-store/config", methods=["GET", "POST"])
|
||||
@perm_required(PERM_APKS)
|
||||
def api_agent_store_config():
|
||||
"""查看/修改设备端商店配置(应用管理页)。GET 也要权限:令牌属于敏感信息。"""
|
||||
if request.method == "GET":
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"enabled": store_enabled(),
|
||||
"token": get_token(),
|
||||
"token_created_at": meta_get(_K_CREATED) or "",
|
||||
"api_version": _API_VERSION,
|
||||
"base_url_hint": request.host_url.rstrip("/"),
|
||||
})
|
||||
|
||||
data = request.json or {}
|
||||
if "enabled" in data:
|
||||
meta_set(_K_ENABLED, "1" if data["enabled"] else "0")
|
||||
if data["enabled"]:
|
||||
ensure_token()
|
||||
if data.get("regenerate_token"):
|
||||
ensure_token(regenerate=True)
|
||||
_log.info("设备端商店配置更新: enabled=%s", store_enabled())
|
||||
return jsonify({"ok": True, "enabled": store_enabled(), "token": get_token()})
|
||||
|
||||
|
||||
@bp.route("/api/agent-store/logs")
|
||||
@perm_required(PERM_APKS)
|
||||
def api_agent_store_logs():
|
||||
"""设备端安装记录(最近 N 条)。"""
|
||||
limit = min(int(request.args.get("limit", 50) or 50), 200)
|
||||
from core.models import DeviceInstallLog
|
||||
rows = (DeviceInstallLog.query
|
||||
.order_by(DeviceInstallLog.id.desc()).limit(limit).all())
|
||||
return jsonify({"ok": True, "logs": [{
|
||||
"id": r.id, "device_name": r.device_name or "", "serial": r.serial or "",
|
||||
"fingerprint": r.fingerprint or "", "apk_id": r.apk_id or "",
|
||||
"package_name": r.package_name or "", "version_name": r.version_name or "",
|
||||
"action": r.action or "", "message": r.message or "",
|
||||
"created_at": r.created_at or "",
|
||||
} for r in rows]})
|
||||
@@ -12,6 +12,100 @@ from web.auth import perm_required, PERM_DEVICES
|
||||
_log = get_logger("web")
|
||||
bp = Blueprint("devices", __name__)
|
||||
|
||||
|
||||
def _lan_ip():
|
||||
"""挑一个手机连得上的本机地址:优先 192.168.x,其次 10.x。
|
||||
|
||||
不能用 gethostbyname(hostname) —— 这台机器上它可能返回 Tailscale 的
|
||||
100.x(CGNAT 地址,手机不在 tailnet 里根本连不上),或 docker 的 172.17。
|
||||
"""
|
||||
import socket
|
||||
candidates = []
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip.startswith("127.") or ip.startswith("169.254."):
|
||||
continue
|
||||
candidates.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
for ip in candidates:
|
||||
if ip.startswith("192.168."):
|
||||
return ip
|
||||
for ip in candidates:
|
||||
if ip.startswith("10."):
|
||||
return ip
|
||||
return candidates[0] if candidates else "本机IP"
|
||||
|
||||
|
||||
@bp.route("/api/devices/qrcode")
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_qrcode():
|
||||
"""给某台设备生成「配置二维码」——设备端 Agent 扫一下就把自己配好。
|
||||
|
||||
二维码里是一段 JSON(平台地址 + 设备令牌 + 这台设备的指纹),扫码即完成:
|
||||
填 server/token/指纹 三件事,不用在手机上打字,也不用连 adb。
|
||||
|
||||
⚠️ 两条使用前提(都会在返回里给提示,不静默失败):
|
||||
1. 平台要先在「应用管理 → 设备端应用商店」启用(令牌是从那儿来的)
|
||||
2. **要用局域网地址打开本页面**(二维码里的地址取自当前访问地址;
|
||||
如果你是用 localhost 打开的,手机会连不上,这里会明确告诉你)
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json as _json
|
||||
|
||||
serial = (request.args.get("serial") or "").strip()
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
|
||||
dev = None
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("serial") == serial:
|
||||
dev = d
|
||||
break
|
||||
if dev is None:
|
||||
return jsonify({"ok": False, "error": "设备不在池里"}), 404
|
||||
|
||||
# 令牌必须先启用设备端商店才有
|
||||
from core.db_config import meta_get
|
||||
from web.device_agent_api import store_enabled, ensure_token
|
||||
if not store_enabled():
|
||||
return jsonify({"ok": False,
|
||||
"error": "还没启用设备端商店:先到「应用管理 → 设备端应用商店」勾选启用"}), 400
|
||||
token = ensure_token()
|
||||
|
||||
server = request.host_url.rstrip("/")
|
||||
warn = ""
|
||||
if "127.0.0.1" in server or "localhost" in server:
|
||||
warn = ("你现在是用本机地址打开平台的,二维码里的地址手机会连不上 —— "
|
||||
"请改用局域网地址(如 http://%s:18050)重新打开本页再生成。"
|
||||
% _lan_ip())
|
||||
|
||||
fp = dev.get("fingerprint") or ""
|
||||
if not fp:
|
||||
warn = (warn + " " if warn else "") + \
|
||||
"这台设备还没采集到指纹,扫码后平台认不出它是哪一台(设备端会显示「未登记」);" \
|
||||
"建议先让它在线并刷新一次型号/指纹,再生成二维码。"
|
||||
|
||||
payload = {"v": 1, "server": server, "token": token,
|
||||
"fingerprint": fp, "name": dev.get("name") or ""}
|
||||
text = _json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
img = qrcode.make(text)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
png = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception as e:
|
||||
_log.error(f"生成二维码失败: {e}")
|
||||
return jsonify({"ok": False, "error": f"生成二维码失败: {e}"}), 500
|
||||
|
||||
_log.info(f"生成设备配置二维码: {dev.get('name') or serial}")
|
||||
return jsonify({"ok": True, "serial": serial, "name": dev.get("name") or "",
|
||||
"fingerprint": fp, "png": png, "payload": text, "warn": warn})
|
||||
|
||||
@bp.route("/api/devices/pool", methods=["GET"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_devices_pool_list():
|
||||
|
||||
@@ -102,6 +102,11 @@ def api_status():
|
||||
devices, err = context.mgr.get_status()
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
# 按**名称**排(A01/A02/…)——之前是 adb 返回的顺序,看着像按 IP 排,很别扭。
|
||||
# 没名字的排在最后、彼此按地址排(历史数据里可能有没命名的设备)。
|
||||
devices.sort(key=lambda d: (
|
||||
1 if not (d.get("device_name") or "").strip() else 0,
|
||||
(d.get("device_name") or "").strip() or d.get("serial", "")))
|
||||
return jsonify({
|
||||
"ok": True, "devices": devices, "server_time": time.time(),
|
||||
"fg_scanning": context.mgr._fg_scanner.is_scanning,
|
||||
|
||||
@@ -156,6 +156,9 @@ def api_tools_appver():
|
||||
results[d["serial"]] = fut.result()
|
||||
except Exception as e:
|
||||
results[d["serial"]] = {"installed": False, "error": str(e)}
|
||||
# 结果里带上设备名:界面按「名称 · 地址」显示,同型号多台才分得清
|
||||
if isinstance(results.get(d["serial"]), dict):
|
||||
results[d["serial"]]["name"] = d.get("name") or ""
|
||||
fail = sum(1 for v in results.values() if v.get("error"))
|
||||
return jsonify({"ok": True, "results": results, "fail": fail,
|
||||
"total": len(results)})
|
||||
|
||||
Reference in New Issue
Block a user