9 Commits
Author SHA1 Message Date
butubb ef71943a3c Merge branch 'dev' into main——本批:APK 安装进度/静默安装接口/应用列表合并/设备名显示/配置二维码/监控页排序与按钮
dev 侧 6 个提交整体合入 main(对应之前按红线撤回的商店功能,现已在 dev 验收)。
2026-09-13 17:37:56 +08:00
butubb 524ae20c49 Reapply "feat: 设备端应用商店(平台侧)"
This reverts commit bf5071b14d.
2026-09-13 17:37:32 +08:00
butubb 6fd9e6f8f3 chore: 容器依赖守卫补 qrcode——设备配置二维码用了它,不加的话容器重启不会安装(守卫只看固定模块列表) 2026-09-13 17:36:03 +08:00
butubb 3422b4f265 feat: 监控页按名称排序 + 按钮改「看屏 / 打开设备端 Agent」;设备名匹配补 USB 设备
用户反馈的三件事:

1) **监控页设备排序**改成按**名称**(A01/A02/…),没名字的排最后按地址。
   之前是 adb 返回的顺序,看着像按 IP 排,同型号多台时对不上号。
   排序放在 `/api/status`(后端),大屏页一并受益。

2) **操作按钮改名 + 换实现**:
   - 「截图」→「**看屏**」(行为不变,就是查看设备实时画面)
   - 「定位」→「**打开设备端 Agent**」:改成让设备上的 Agent 弹身份大字页
     (名称/IP/平台地址),替代原来"推 HTML 让设备浏览器打开定位页"那套
     (那套要设备装浏览器、页面还依赖设备能访问平台,backlog A1/A2 一直没修)
     * 新接口 `POST /api/agent/show-info {serial, close?}`;没装 Agent 时明确报出来
     * **先点亮屏幕 + 解除锁屏再拉起** —— 设备息屏时 am start 会把大字页开在锁屏后面,
       人看到的还是一片黑(实测踩过)
     * 平台地址取操作者当前访问地址;若是 localhost 则换成本机局域网地址
       (设备上显示 127.0.0.1 毫无意义)

3) **安装应用的选择设备界面仍显示裸序列号**:USB 设备在 adb 里的 serial 就是
   `ro.serialno`,等于池里那条网络记录的**指纹** —— 之前只按 serial 匹配,插着 USB
   时会冒出一个没名字的 `s8o7nrt8pbzhfadq`。现在按 serial 或指纹任一命中都取名称,
   三处(剪贴板注入 / 安装弹窗 / 安装进度表)统一。

顺带修掉几个自己踩出来的坑:
- `device_pool.list_configured()` 返回的是 **serial 字符串列表**,我当成字典用了
  (`.get("serial")` 抛异常被 except 吞掉 → 名字全空)→ 改用 `list_devices()`
- `web/device_agent_api.py` 用了 `PERM_DEVICES` 但没导入 → 服务直接起不来(502)
- Agent 自检里的版本号是写死的常量(升到 1.4 还显示 1.0)→ 改成读包信息
- `doc/API.md` 章节编号(插了一节没把后面整体下移,出现两个 §16)

验证:安装弹窗里 USB 那台现在显示「A07」(指纹匹配);监控页按钮为
「看屏 / 打开设备端 Agent」;对装/未装 Agent 的设备分别得到大字页 / 明确报错;
大字页实测显示 A07 + 192.168.20.250:18050 + Agent v1.4。
2026-09-13 17:18:30 +08:00
butubb 3f041668ec feat: 设备配置二维码——设备池里出码,Agent 扫一下配好
配合设备端 Agent 新增的「扫码配置」:人在机架前、设备还没接进平台时,
不用插线也不用在手机上打字输地址/令牌(还容易漏指纹)。

- `GET /api/devices/qrcode?serial=`:生成配置二维码(PNG data-url),内容为
  {"v":1,"server":…,"token":…,"fingerprint":…,"name":…}
  * 前置:设备端商店已启用(令牌从那儿来),否则 400 并说明去哪儿开
  * `warn` 非空必须显示:①用 localhost 打开平台时,二维码里的地址手机会连不上
    (提示改用局域网地址,_lan_ip() 优先挑 192.168/10.x,**不用** gethostbyname ——
     那会返回 Tailscale 的 100.x 或 docker 的 172.17,手机根本连不上)
    ②设备还没采到指纹 → 扫完平台认不出是哪台
- 设备池每行加「二维码」按钮 → 弹窗显示二维码 + 设备名/地址 + 可展开看原始内容
- requirements 加 qrcode(纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库)
- 文档:API.md §6.2.2 新增、§6.2.1 名称呈现表补齐(剪贴板/安装弹窗/版本管理/
  安装进度四处);DEVICE_AGENT.md §5.3 新增二维码内容格式(设备端契约)

验证:二维码用 cv2.QRCodeDetector 解回来与 payload 完全一致;手机上实测——
扫 A07 的码 → 相机启动 → 解码 → 保存 → 立刻拉清单,平台认出「A07」。
2026-09-13 16:42:53 +08:00
butubb 17b6624a34 docs: 契约补 '请平台静默装' 接口(§3.4)——设备端可选实现
设备端自己调系统安装器在 MIUI 上要过好几步确认,而平台走 adb 装是静默的
(实测升级 2.7s、全新安装 7.9s)。接口已实现,写进两个仓库共享的契约:
受理后平台异步安装、设备端轮询版本变化判断结果;平台装不了时返回 ok=false,
设备端退回本机安装。
2026-09-13 16:30:48 +08:00
butubb 25e4fbb053 feat: 应用列表按包名合并 + 剪贴板/安装/版本管理显示设备名
两件事都是"列表看不清楚"的问题:

1) **同一个应用传多个版本会并排显示多行**(v1.2 / v1.3 各占一行),看着像重复。
   现在按 **package_name 合并成一个应用一行**,显示最新版:
   - `apk_manager.list_latest()`:版本按 version_code 排序取最新,其余版本放进
     `versions` 字段;解析不出包名的 APK 各自独立成行(不硬合并)
   - 管理页版本列显示 `1.3(另有 1 个旧版本)`,多一个「清理旧版本」按钮
   - 「删除」语义改为**删掉这个应用的所有版本**(行=应用),另给
     `?scope=older` 只清理旧版本;老的单条删除行为保留(不带 scope)
   - **设备端商店也走同一个列表** —— 手机上不会再看到同一个 App 的 v1.2/v1.3 两行

2) **设备只显示地址**(`192.168.20.100:5555`),同型号多台时分不清是哪一台。
   三处补齐设备名(平台给设备起的名字,如 A08),统一「名称 · 地址」:
   - 剪贴板注入的设备勾选列表(`/api/adb/devices` 经 `_merged_device_list` 带 name)
   - 应用管理的安装弹窗(`/api/apks/install/devices` 带 name)
   - 应用版本管理表格(`/api/tools/appver` 结果里带 name)
   - 顺带:安装进度表里的「设备」列原来显示**型号**(而且是 STF 时代残留的空 dict),
     改为显示设备名
   没名字的设备(不在池里)自动回退只显示地址,不显示空占位

验证:上传同包名 v1.1/v1.2/v1.3 三个版本 → 列表只出现一行(v1.3,标注共 4 个版本)、
设备端 bootstrap 也只剩 2 个应用;`scope=older` 只留最新、`scope=package` 整包删除;
三个面板在浏览器里渲染无 JS 报错,devText 有名字/没名字两种形态都正确。
2026-09-13 16:29:38 +08:00
butubb fa07ed0804 feat(platform): 设备端商店支持"请平台静默装"—— 设备请求、平台走 adb 落地
背景:设备端自己调系统安装器在 MIUI 上要过「继续 → 勾选未经安全检测 → 继续更新」
甚至 ICP 备案检查好几步;而平台用 adb(推送 + pm install)装是**静默**的,实测
升级 2.7s、全新安装 7.9s、336MB 大包也没弹过一次框。

所以给设备端商店补一个"请求平台装"的出口:用户还是在手机上选应用(应用商店体验),
真正干活的是平台,几秒钟装完、零弹窗。

- web/device_agent_api.py 新增 `POST /api/device/agent/install`:
  设备带令牌调它 → 平台复用应用管理的安装器(`apk_mgr.install`,本身已是
  推送 + pm install、并发 5 台)→ 返回是否受理;失败时设备端自行退回本机安装
- 鉴权与设备识别沿用同一套(令牌 + 指纹),未登记设备拒绝并说明原因

注:设备端 APK 那边的对应按钮改动已暂停(用户决定先不动 Agent),
这个接口属于平台侧能力,先落在本次分支里,等 Agent 恢复时直接可用。
2026-09-13 15:38:14 +08:00
butubb cdf06e1929 fix: 大 APK 推送到设备时显示实时进度(原来是整段黑箱干等)
问题(用户反馈):从网页点「安装」到设备,包大的时候(抖音 336MB)只显示
一句「正在推送 APK...」,传了两分多钟全程没有任何进度,像卡死了。

根因:推送用的是 `adb push`,它**只在结束时**吐一行汇总
("1 file pushed, 3.0 MB/s"),中间过程什么都不给。

改法:分块写 + 实时进度
- core/apk_manager.py 新增 `_push_with_progress()`:改用 `adb exec-in "cat > 文件"`
  自己按 1MB 分块喂数据,每块更新一次状态(限流 0.8s 一次):
    `推送中 62%(211.0/336.1 MB · 2.6 MB/s · 剩约 48s)`
  传完校验设备上文件大小;中途断流/exec-in 不可用时**退回原来的 adb push**(保证兼容)
- 代价:实测速度 2.6 vs 3.1 MB/s(慢 17%),换来看得见的进度,划算
- 「正在设备上安装...」补上预期时间说明 —— `pm install` 在设备端解包
  (336MB 实测 ~65s),系统没给进度接口,只能给预期值
- static/admin/apps.js:安装进度轮询 3s → 1.5s;表格里再加一条细进度条
  (大包要一两分钟,光有文字不够直观)
- doc/API.md:把安装状态机的两段式进度与耗时参考写进接口说明

实测(抖音 v40.4.0 336.1MB → 192.168.20.100,单台):
  推送 60%→100% 每 2 秒一档,速度稳定 2.6 MB/s,134s 传完;
  设备端安装 65s;端到端 ~200s,全程有进度可看。
全量回归 106 项通过。
2026-09-13 15:38:14 +08:00
21 changed files with 1332 additions and 74 deletions
+198 -16
View File
@@ -127,7 +127,7 @@ class ApkManager:
# ================== 列表 ================== # ================== 列表 ==================
def list_all(self): def list_all(self):
"""列出所有 APK。""" """列出所有 APK(**每个上传版本一行**,内部用)。"""
try: try:
with self._db(): with self._db():
rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all() rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all()
@@ -136,6 +136,42 @@ class ApkManager:
_log.error(f"列出 APK 失败: {e}") _log.error(f"列出 APK 失败: {e}")
return [] return []
def list_latest(self):
"""按**包名**合并后的列表:一个应用一行,只显示最新版本。
为什么:同一个应用传过多个版本(v1.2 / v1.3)时,列表里会并排出现两行——
用户看到的是"同一个应用怎么重复了"。这里合并成一行(最新版),
其余版本的信息放在 `versions` 里,界面可以提示"另有 N 个旧版本"并按需清理。
管理页和设备端商店都用这个(设备上选应用时同样不该出现两行同一个 App)。
解析不出包名的 APK 各自独立成行,不做合并(避免把无关文件混在一起)。
"""
rows = self.list_all()
groups = {}
for r in rows:
pkg = (r.get("package_name") or "").strip()
key = pkg if pkg else ("__single__" + str(r.get("id")))
groups.setdefault(key, []).append(r)
def sort_key(x):
return (x.get("version_code") or 0, x.get("upload_time") or "")
out = []
for items in groups.values():
items.sort(key=sort_key, reverse=True)
top = dict(items[0])
top["version_count"] = len(items)
top["versions"] = [{
"id": i.get("id"),
"version_name": i.get("version_name") or "",
"version_code": i.get("version_code") or 0,
"size": i.get("size") or 0,
"upload_time": i.get("upload_time") or "",
} for i in items]
out.append(top)
out.sort(key=lambda x: x.get("upload_time") or "", reverse=True)
return out
# ================== 删除 ================== # ================== 删除 ==================
def delete(self, apk_id): def delete(self, apk_id):
"""删除 APK 文件和数据库记录。""" """删除 APK 文件和数据库记录。"""
@@ -161,6 +197,47 @@ class ApkManager:
_log.error(f"删除 APK 失败: {e}") _log.error(f"删除 APK 失败: {e}")
return False, str(e) return False, str(e)
def delete_package(self, apk_id, keep_latest=False):
"""按**包名**删除:一个应用的所有版本一起处理。
keep_latest=True → 只删旧版本、保留最新那版("清理旧版本")
keep_latest=False → 整个应用删掉(所有版本)
没有包名的 APK 退回单条删除。
"""
try:
with self._db():
row = ApkRow.query.get(apk_id)
if not row:
return False, "APK 不存在"
pkg = (row.package_name or "").strip()
if not pkg:
return self.delete(apk_id)
items = ApkRow.query.filter_by(package_name=pkg).all()
items.sort(key=lambda r: ((r.version_code or 0),
(r.upload_time or "")), reverse=True)
targets = items[1:] if keep_latest else items
if not targets:
return True, "没有需要清理的旧版本"
name = row.display_name or pkg
for r in targets:
p = os.path.join(APK_DIR, r.filename)
if os.path.exists(p):
try:
os.remove(p)
except Exception as e:
_log.warning(f"删除 APK 文件失败: {e}")
db.session.delete(r)
db.session.commit()
_log.info(f"已删除 {name} 的 {len(targets)} 个版本"
f"({'保留最新' if keep_latest else '整个应用'})")
if keep_latest:
return True, f"已清理 {name} 的 {len(targets)} 个旧版本(保留最新)"
return True, f"已删除 {name}(共 {len(targets)} 个版本)"
except Exception as e:
_log.error(f"按包名删除失败: {e}")
return False, str(e)
# ================== 批量安装 ================== # ================== 批量安装 ==================
def install(self, apk_id, serials): def install(self, apk_id, serials):
"""批量安装 APK 到指定设备(后台线程执行)。 """批量安装 APK 到指定设备(后台线程执行)。
@@ -198,13 +275,26 @@ class ApkManager:
# 获取 worker 状态(判断设备是否运行中) # 获取 worker 状态(判断设备是否运行中)
worker_status = {w["serial"]: w for w in get_all_worker_status()} worker_status = {w["serial"]: w for w in get_all_worker_status()}
# 设备名(平台给设备起的名字,如 A08)—— 进度表里显示这个而不是型号:
# 8 台同型号时型号根本区分不了,名字才行
pool_names, fp_names = {}, {}
try:
from core import device_pool
# list_devices() 返回字典(含 name/fingerprint)
for d in device_pool.list_devices():
if d.get("serial"):
pool_names[d["serial"]] = d.get("name") or ""
# USB 设备的 adb serial == ro.serialno == 池里那条记录的指纹
if d.get("fingerprint"):
fp_names[d["fingerprint"]] = d.get("name") or ""
except Exception as e:
_log.warning(f"读取设备名失败(进度表将显示地址): {e}")
# 构造安装任务 # 构造安装任务
items = {} items = {}
for s in serials: for s in serials:
dev = all_devices.get(s, {})
model = dev.get("model") or dev.get("manufacturer") or s
items[s] = { items[s] = {
"name": model, "name": pool_names.get(s) or fp_names.get(s) or s,
"status": "pending", "status": "pending",
"msg": "", "msg": "",
} }
@@ -308,20 +398,17 @@ class ApkManager:
"verifier_verify_adb_installs", "0") "verifier_verify_adb_installs", "0")
except Exception: except Exception:
pass pass
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用) # 2. 推送 APK 到设备(分块 + 实时进度)
self._set_item(serial, "installing", "正在推送 APK...") ok_push, msg_push = self._push_with_progress(serial, apk_path)
r = subprocess.run( if not ok_push:
[ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"], self._set_item(serial, "failed", msg_push)
capture_output=True, timeout=_INSTALL_TIMEOUT _log.warning(f"[{serial}] APK 推送失败: {msg_push}")
)
push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if r.returncode != 0 or "1 file pushed" not in push_out:
msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})"
self._set_item(serial, "failed", msg)
_log.warning(f"[{serial}] APK 推送失败: {msg}")
return return
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本) # 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
self._set_item(serial, "installing", "正在安装...") # 这一步在**设备端**解包/优化,耗时随包大小增长(抖音 336MB 实测 ~65s),
# 但系统没给进度接口,只能给出预期时间,别让用户以为是卡住了。
self._set_item(serial, "installing",
"正在设备上安装...(大包要 1-2 分钟,无进度可读)")
r = subprocess.run( r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d", [ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
"/data/local/tmp/_install.apk"], "/data/local/tmp/_install.apk"],
@@ -385,6 +472,101 @@ class ApkManager:
# adb 连接保持即可,不影响设备和后续操作。 # adb 连接保持即可,不影响设备和后续操作。
pass pass
def _push_with_progress(self, serial, apk_path,
remote="/data/local/tmp/_install.apk"):
"""把 APK 分块写进设备,边传边更新进度。返回 (ok, msg)。
为什么不用 `adb push`:它只在**结束时**吐一行汇总
("1 file pushed, 3.0 MB/s"),大包(抖音 336MB ≈ 1.8 分钟/台)整个传输过程
界面上只有一句"正在推送 APK...",用户只能干等 —— 这就是要优化的问题。
改用 `exec-in` 分块写:每块回调一次进度(百分比/已传/速度/剩余时间)。
实测速度略降(2.6 vs 3.1 MB/s),换来看得见的进度,划算。
传完校验设备上的文件大小;exec-in 不可用时退回原来的 `adb push`。
"""
total = os.path.getsize(apk_path)
chunk = 1 << 20 # 1MB:再大提升有限,再小 adb 调用开销明显
t0 = time.time()
sent = 0
last_report = 0.0
try:
proc = subprocess.Popen(
[ADB_PATH, "-s", serial, "exec-in", "cat > %s" % remote],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
except Exception as e:
_log.warning(f"[{serial}] exec-in 启动失败,退回 adb push: {e}")
return self._push_fallback(serial, apk_path, remote)
try:
with open(apk_path, "rb") as f:
while True:
buf = f.read(chunk)
if not buf:
break
proc.stdin.write(buf)
sent += len(buf)
now = time.time()
if now - last_report >= 0.8 or sent >= total:
last_report = now
spent = max(now - t0, 0.001)
speed = sent / spent / 1048576.0 # MB/s
left = int((total - sent) / max(sent / spent, 1))
self._set_item(
serial, "installing",
"推送中 %d%%(%.1f/%.1f MB · %.1f MB/s · 剩约 %ds)"
% (sent * 100 // max(total, 1), sent / 1048576.0,
total / 1048576.0, speed, left))
proc.stdin.close()
except Exception as e:
try:
proc.kill()
except Exception:
pass
return False, f"推送中断: {e}"
try:
rc = proc.wait(timeout=_INSTALL_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
return False, "推送超时(设备无响应)"
if rc != 0:
err = ""
try:
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[:150]
except Exception:
pass
_log.warning(f"[{serial}] exec-in 推送到一半失败(rc={rc}),退回 adb push")
return self._push_fallback(serial, apk_path, remote)
# 校验设备上文件大小一致(exec-in 中途断流可能"成功"但少字节)
try:
rs = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "stat", "-c", "%s", remote],
capture_output=True, timeout=30)
got = int((rs.stdout or b"").decode("utf-8", "replace").strip().splitlines()[-1])
if got != total:
return False, f"推送不完整(设备 {got} / 本地 {total} 字节)"
except Exception:
pass # stat 参数各机型有差异,取不到就跳过校验
return True, "推送完成 %.1fMB / %.0fs" % (total / 1048576.0, time.time() - t0)
def _push_fallback(self, serial, apk_path, remote):
"""老 adb 或 exec-in 失败时的退路:原来的 `adb push`(无进度)。"""
self._set_item(serial, "installing", "正在推送 APK...(无进度显示)")
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "push", apk_path, remote],
capture_output=True, timeout=_INSTALL_TIMEOUT)
except subprocess.TimeoutExpired:
return False, "推送超时"
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if r.returncode != 0 or "1 file pushed" not in out:
return False, (out.strip().replace("\n", " ")[:150]
or f"推送失败(returncode={r.returncode})")
return True, "推送完成(adb push)"
def _set_item(self, serial, status, msg=""): def _set_item(self, serial, status, msg=""):
"""更新安装任务中某设备的状态。""" """更新安装任务中某设备的状态。"""
if not self._install_task: if not self._install_task:
+20
View File
@@ -349,6 +349,26 @@ class AgentAction(db.Model):
updated_at = db.Column(db.String(20), default="") 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): class AgentConversation(db.Model):
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。""" """AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
__tablename__ = "agent_conversation" __tablename__ = "agent_conversation"
+1 -1
View File
@@ -54,7 +54,7 @@ TABLE_LABELS = {
"custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池", "custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池",
"pending_device": "待连接设备", "agent_conversation": "AI 会话", "pending_device": "待连接设备", "agent_conversation": "AI 会话",
"agent_experience": "经验库", "experience_audit": "经验巡检", "agent_experience": "经验库", "experience_audit": "经验巡检",
"agent_action": "动作库", "agent_action": "动作库", "device_install_log": "设备端安装记录",
} }
_STAGE_TTL = 1800 # 导入暂存有效期(秒) _STAGE_TTL = 1800 # 导入暂存有效期(秒)
+101 -10
View File
@@ -21,9 +21,10 @@
- [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale) - [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale)
- [12. AI 控制台](#12-ai-控制台) - [12. AI 控制台](#12-ai-控制台)
- [13. 系统备份](#13-系统备份) - [13. 系统备份](#13-系统备份)
- [14. 非 JSON 响应汇总](#14-非-json-响应汇总) - [14. 设备端应用商店](#14-设备端应用商店)
- [15. 错误分支速查](#15-错误分支速查) - [15. 非 JSON 响应汇总](#15-非-json-响应汇总)
- [16. 已知问题](#16-已知问题) - [16. 错误分支速查](#16-错误分支速查)
- [17. 已知问题](#17-已知问题)
--- ---
@@ -48,14 +49,14 @@
### 1.3 CSRF ### 1.3 CSRF
- `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。 - `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§16 已知问题](#16-已知问题)。 - ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§17 已知问题](#17-已知问题)。
### 1.4 响应约定 ### 1.4 响应约定
- 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。 - 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。
- **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。 - **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。
- 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。 - 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§14](#14-非-json-响应汇总)。 - 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§15](#15-非-json-响应汇总)。
--- ---
@@ -150,6 +151,7 @@
| POST | `/api/devices/pool/remove` | D | 从池中删除 | | POST | `/api/devices/pool/remove` | D | 从池中删除 |
| POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) | | POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) |
| POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 | | POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 |
| GET | `/api/devices/qrcode?serial=` | D | 生成该设备的**配置二维码**(设备端 Agent 扫码即配好) |
| POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 | | POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 |
| GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 | | GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 |
| POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 | | POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 |
@@ -219,6 +221,27 @@
| POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) | | POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) |
| POST | `/api/system/backup/apply` | 应用恢复(重启生效) | | 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. 认证与页面 ## 3. 认证与页面
@@ -441,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 自动发现
| 接口 | 请求 | 说明 | | 接口 | 请求 | 说明 |
@@ -497,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。
--- ---
@@ -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/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 实时流 |
@@ -670,7 +761,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
--- ---
## 15. 错误分支速查 ## 16. 错误分支速查
| 码 | 典型触发 | | 码 | 典型触发 |
|----|---------| |----|---------|
@@ -686,7 +777,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
--- ---
## 16. 已知问题 ## 17. 已知问题
| 问题 | 影响 | 位置 | | 问题 | 影响 | 位置 |
|------|------|------| |------|------|------|
+4
View File
@@ -43,6 +43,7 @@
| 10 | `agent_experience` | 经验库(任务级配方) | | 10 | `agent_experience` | 经验库(任务级配方) |
| 11 | `experience_audit` | 经验巡检结论 | | 11 | `experience_audit` | 经验巡检结论 |
| 12 | `agent_action` | 动作库(命名动作) | | 12 | `agent_action` | 动作库(命名动作) |
| 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) |
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE` > 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY` > (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
@@ -232,6 +233,9 @@ UTF-8 等价于字节序)。
| `discovery_interval` | 扫描周期秒(10-3600) | 同上 | | `discovery_interval` | 扫描周期秒(10-3600) | 同上 |
| `discovery_port` | adb 探测端口(1-65535) | 同上 | | `discovery_port` | adb 探测端口(1-65535) | 同上 |
| `discovery_auto_claim` | 指纹匹配时自动认领(`"1"`/`"0"`,默认关) | 同上 | | `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_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 |
| `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 | | `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 |
| `deployment_claimed_at` | 标签写入时间 | 同上 | | `deployment_claimed_at` | 标签写入时间 | 同上 |
+258
View File
@@ -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 账号密码
+2
View File
@@ -18,6 +18,7 @@
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 | | [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 | | [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:设计稿与里程碑(P0 未实现,属规划) | 平台开发者 | | [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:设计稿与里程碑(P0 未实现,属规划) | 平台开发者 |
| [DEVICE_AGENT.md](DEVICE_AGENT.md) | **设备端 Agent 接口契约**:应用商店的设备专用接口(清单/下载/上报)、adb 指令协议、版本约定 —— 与设备端 APK 仓库共享的契约 | 设备端开发者、平台开发者 |
| [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 | | [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 |
| [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 | | [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 |
| [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 | | [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 |
@@ -56,6 +57,7 @@
| 任务类型 / 步骤 schema | [TASK_DEV.md](TASK_DEV.md) | | 任务类型 / 步骤 schema | [TASK_DEV.md](TASK_DEV.md) |
| 常驻线程 / 进程装配 | [ARCHITECTURE.md](ARCHITECTURE.md) §线程与并发 | | 常驻线程 / 进程装配 | [ARCHITECTURE.md](ARCHITECTURE.md) §线程与并发 |
| MCP 工具 | [MCP.md](MCP.md) + [MCP_DESIGN.md](MCP_DESIGN.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) | | 对外接入约定 | [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) |
| 暂缓项 / 已知问题 | [backlog/TODO.md](backlog/TODO.md)(完成时移出并同步相关文档) | | 暂缓项 / 已知问题 | [backlog/TODO.md](backlog/TODO.md)(完成时移出并同步相关文档) |
+4
View File
@@ -16,6 +16,10 @@ Flask-SQLAlchemy>=3.0,<4.0
# 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。 # 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。
PyMySQL>=1.1 PyMySQL>=1.1
# 设备端 Agent 的「扫码配置」二维码(设备池面板生成,手机扫一下配好 server/token/指纹)。
# 纯 Python,配合已有的 Pillow 出 PNG,不引前端二维码库。
qrcode>=7.4
# 定时任务调度 # 定时任务调度
APScheduler>=3.10,<4.0 APScheduler>=3.10,<4.0
+1 -1
View File
@@ -16,7 +16,7 @@ python3 - <<'PY'
import importlib.util, sys import importlib.util, sys
mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler", mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler",
"uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL", "uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL",
"fastmcp", "pymysql") "fastmcp", "pymysql", "qrcode")
if all(importlib.util.find_spec(m) for m in mods): if all(importlib.util.find_spec(m) for m in mods):
try: try:
import cv2 import cv2
+105 -10
View File
@@ -29,15 +29,22 @@ async function loadApks(){
await fillAppsDeviceList(); await fillAppsDeviceList();
} }
function renderApkRow(a){ function renderApkRow(a){
// 一行 = 一个应用(列表已按包名合并,显示最新版);旧版本给个数提示 + 清理入口
const n=a.version_count||1;
const verCell=(a.version_name?esc(a.version_name):'-')+
(n>1?' <span style="font-size:11px;color:#94a3b8">(另有 '+(n-1)+' 个旧版本)</span>':'');
const clean=n>1?'<button class="btn btn-xs" onclick="cleanOldVersions(\''+a.id+'\',\''+esc(a.display_name||'')+'\','+(n-1)+')" title="只保留最新版,删掉旧版本">清理旧版本</button> ':'';
const title=n>1?('共 '+n+' 个版本:'+ (a.versions||[]).map(v=>v.version_name||'?').join(' / ')):'';
return '<tr>'+ return '<tr>'+
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+ '<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+ '<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+ '<td title="'+esc(title)+'">'+verCell+'</td>'+
'<td>'+fmtSize(a.size)+'</td>'+ '<td>'+fmtSize(a.size)+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+ '<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
'<td>'+ '<td>'+
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+ '<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+ clean+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\','+n+')">删除</button>'+
'</td>'+ '</td>'+
'</tr>'; '</tr>';
} }
@@ -50,7 +57,7 @@ async function fillAppsDeviceList(){
const cur=sel.value; const cur=sel.value;
const devices=(sr.devices||[]).filter(d=>d.present); const devices=(sr.devices||[]).filter(d=>d.present);
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=> sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>' '<option value="'+esc(d.serial)+'">'+esc(devText(d.device_name||d.name, d.serial))+'</option>'
).join(''); ).join('');
if(cur)sel.value=cur; if(cur)sel.value=cur;
} }
@@ -140,9 +147,20 @@ async function uploadApk(input){
input.value=''; input.value='';
} }
async function deleteApk(id){ async function deleteApk(id, versionCount){
if(!confirm('确认删除此APK文件?'))return; const n=versionCount||1;
const r=await apiDelete('/api/apks/'+id); const msg = n>1
? ('确认删除这个应用?\n\n它一共有 '+n+' 个版本,会**全部**删掉(想只留最新版请用「清理旧版本」)。')
: '确认删除此APK文件?';
if(!confirm(msg))return;
// 一行 = 一个应用 → 删除即删掉该包的所有版本
const r=await apiDelete('/api/apks/'+id+'?scope=package');
handleResult(r,loadApks);
}
async function cleanOldVersions(id, name, oldCount){
if(!confirm('清理「'+name+'」的 '+oldCount+' 个旧版本?\n\n只保留最新版,旧版本文件会被删除。'))return;
const r=await apiDelete('/api/apks/'+id+'?scope=older');
handleResult(r,loadApks); handleResult(r,loadApks);
} }
@@ -168,8 +186,8 @@ async function openInstallModal(apkId){
online.map(d=>{ online.map(d=>{
const isUsb=d.source==='usb'; const isUsb=d.source==='usb';
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>'; const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>';
// serial 唯一,显示 IP + 型号便于区分同型号设备 // 一律「名称 · 地址」——同型号多台时靠名称区分;没有名称的设备(不在池里)只显示地址
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备')); const label=devText(d.name, d.serial);
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>'; return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
}).join('')+ }).join('')+
'</div>'; '</div>';
@@ -210,7 +228,7 @@ function showInstallProgress(){
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>'; '<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
pollInstallStatus(); pollInstallStatus();
if(_installTimer)clearInterval(_installTimer); if(_installTimer)clearInterval(_installTimer);
_installTimer=setInterval(pollInstallStatus,3000); _installTimer=setInterval(pollInstallStatus,1500);
} }
async function pollInstallStatus(){ async function pollInstallStatus(){
@@ -226,10 +244,13 @@ async function pollInstallStatus(){
const items=st.items||{}; const items=st.items||{};
const itemRows=Object.entries(items).map(([serial,info])=>{ const itemRows=Object.entries(items).map(([serial,info])=>{
const s=SS[info.status]||{t:info.status,c:'default'}; const s=SS[info.status]||{t:info.status,c:'default'};
// 推送进度带百分比时补一条细进度条(大包传输要一两分钟,光有文字不够直观)
const m=/推送中 (\d+)%/.exec(info.msg||'');
const bar=m?'<div class="progress" style="height:6px;margin-top:4px;max-width:240px"><div class="progress-bar" style="width:'+m[1]+'%"></div></div>':'';
return '<tr><td>'+esc(info.name||serial)+'</td>'+ return '<tr><td>'+esc(info.name||serial)+'</td>'+
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+ '<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+ '<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>'; '<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+bar+'</td></tr>';
}).join(''); }).join('');
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0; const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
@@ -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;}
+1
View File
@@ -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-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)); tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
if(name==='groups' && typeof loadGroups==='function') loadGroups(); if(name==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
if(name==='devpool' && typeof loadDevPool==='function'){ if(name==='devpool' && typeof loadDevPool==='function'){
loadDevPool(); loadDevPool();
if(typeof loadDiscovery==='function'){ if(typeof loadDiscovery==='function'){
+18 -17
View File
@@ -70,27 +70,28 @@ async function stopSelectedDevices(){
setTimeout(loadMonitor,500); setTimeout(loadMonitor,500);
} }
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标 // 打开设备端 Agent:让设备上的 Agent 弹出「身份大字页」(名称/IP/平台地址),
// 注意:打开定位页会切换设备前台,任务运行中慎用 // 人站在机架前一眼看出这台是平台上的哪一台。
async function locateDevice(serial){ // 替代原来的"推 HTML 让设备浏览器打开定位页"——那条路要设备装浏览器、页面还依赖
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return; // 设备能访问平台,且在 MIUI 上本来就不稳。
const r=await apiPost('/api/device/locate',{serial:serial,show:true}); async function showAgentInfo(serial){
const r=await apiPost('/api/agent/show-info',{serial:serial});
if(!r)return; if(!r)return;
if(r.ok){ if(r.ok){
_locatingSerials.add(serial); _locatingSerials.add(serial);
showToast(r.msg||'已定位','success'); showToast(r.msg||'已让设备显示身份信息','success');
renderDevices(); renderDevices();
}else showToast(r.error||'定位失败','error'); }else showToast(r.error||'打开失败','error');
} }
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位" // 关闭设备端 Agent 的身份页
async function stopLocate(serial){ async function hideAgentInfo(serial){
const r=await apiPost('/api/device/locate/stop',{serial:serial}); const r=await apiPost('/api/agent/show-info',{serial:serial,close:true});
if(!r)return; if(!r)return;
_locatingSerials.delete(serial); _locatingSerials.delete(serial);
renderDevices(); renderDevices();
if(r.ok)showToast(r.msg||'已结束定位','success'); if(r.ok)showToast(r.msg||'已关闭','success');
else showToast(r.error||'结束定位失败','error'); else showToast(r.error||'关闭失败','error');
} }
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认) // 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
@@ -344,13 +345,13 @@ function renderDeviceRow(dev){
// 设备操作按钮需要"设备控制"权限(无权限只保留截图查看) // 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
const canDev=_can('devices'); const canDev=_can('devices');
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':''; const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错) // 看屏按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>'; const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备实时画面(不影响任务)">看屏</button>';
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页) // 设备端 Agent:开着身份页时显示"关闭",否则"打开设备端 Agent"
const locating=_locatingSerials.has(dev.serial); const locating=_locatingSerials.has(dev.serial);
const locateBtn=canDev?(locating const locateBtn=canDev?(locating
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>' ?'<button class="btn btn-xs btn-danger" onclick="hideAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 关掉身份大字页">关闭 Agent</button>'
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):''; :'<button class="btn btn-xs" onclick="showAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 显示身份大字页(名称/IP/平台地址)——人在机架前对号用(会切换设备前台)">打开设备端 Agent</button>'):'';
// 异常/失败设备提供"清除异常"(把状态恢复为未启动) // 异常/失败设备提供"清除异常"(把状态恢复为未启动)
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed') const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':''; ?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
+41 -4
View File
@@ -35,7 +35,7 @@ async function loadToolsDevices(force){
const stCls = d.state==='device' ? 'label-success' : 'label-warning'; const stCls = d.state==='device' ? 'label-success' : 'label-warning';
return '<label data-serial="'+esc(d.serial)+'">' return '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">' +'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>' +'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(devText(d.name,d.serial))+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>'; +'<span class="label '+stCls+'">'+st+'</span></label>';
}).join(''); }).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备'; status.textContent = '共 '+_clipDevices.length+' 台设备';
@@ -252,12 +252,14 @@ async function queryAppVers(){
} }
} }
function renderAppVerRow(x){ function renderAppVerRow(x){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ // 设备列显示「名称 · 地址」(名称来自设备池;没名字就只显示地址)
const dev='<td style="font-family:monospace">'+esc(devText(x.name,x.serial))+'</td>';
if(x.error)return '<tr>'+dev+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>'; '<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ if(x.installed)return '<tr>'+dev+
'<td><span class="label label-success">已安装</span></td>'+ '<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>'; '<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+ return '<tr>'+dev+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>'; '<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
} }
@@ -496,6 +498,7 @@ async function loadDevPool(){
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+ '<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
'<td style="white-space:nowrap">'+ '<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+ '<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+
'<button class="btn btn-xs" title="生成配置二维码:设备端 Agent 扫一下即配好(平台地址/令牌/指纹)" onclick="showDevQrcode(\''+esc(d.serial)+'\')">二维码</button> '+
(d.online ? '' : (d.online ? '' :
'<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') + '<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') +
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+ '<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
@@ -570,3 +573,37 @@ async function refreshPoolModels(){
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);} if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
else showToast(r.error,'error'); else showToast(r.error,'error');
} }
// ================== 设备配置二维码(设备端 Agent 扫码即配好) ==================
// 二维码内容:平台地址 + 设备令牌 + 这台设备的指纹(JSON)。
// 手机上的 Agent 扫一下 → 保存这三项 → 不用在手机上打字、也不用连 adb。
async function showDevQrcode(serial){
document.getElementById('modal-title').textContent='设备配置二维码';
document.getElementById('modal-body').innerHTML='<p class="text-muted">生成中...</p>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">关闭</button>';
showModal();
const r=await apiGet('/api/devices/qrcode?serial='+encodeURIComponent(serial));
const box=document.getElementById('modal-body');
if(!r||!r.ok){
box.innerHTML='<div style="background:#fdecea;border:1px solid #f5c6cb;color:#b42318;'+
'padding:10px 12px;border-radius:6px">'+esc((r&&r.error)||'生成失败')+'</div>';
return;
}
const warn=r.warn?'<div style="background:#fff3cd;border:1px solid #ffda6a;color:#7a5b00;'+
'padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px">⚠️ '+esc(r.warn)+'</div>':'';
box.innerHTML=
warn+
'<div style="text-align:center;padding:8px 0">'+
'<img src="'+r.png+'" style="width:240px;height:240px;image-rendering:pixelated;'+
'background:#fff;padding:8px;border:1px solid #e5e7eb;border-radius:8px" alt="配置二维码">'+
'<div style="margin-top:10px;font-size:15px"><strong>'+esc(r.name||'(未命名)')+
'</strong> <span style="font-family:monospace;color:#6b7280">'+esc(r.serial)+'</span></div>'+
'</div>'+
'<div class="help" style="margin-top:6px">在设备上打开 <strong>设备 Agent</strong> → 点「扫码配置」→ 扫这个二维码,'+
'平台地址、令牌、指纹一次写入,不用在手机上打字。</div>'+
'<details style="margin-top:8px"><summary style="cursor:pointer;color:#6b7280;font-size:12px">'+
'查看二维码内容(排查用)</summary>'+
'<div style="font-family:monospace;font-size:11px;color:#64748b;word-break:break-all;'+
'background:#f8fafc;padding:8px;border-radius:6px;margin-top:6px">'+esc(r.payload)+'</div></details>';
}
+28
View File
@@ -788,6 +788,34 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<tbody id="tb-apks"></tbody> <tbody id="tb-apks"></tbody>
</table> </table>
<div class="list-pager" id="pager-apks"></div> <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> </div>
<!-- 子分栏 5:应用版本管理 --> <!-- 子分栏 5:应用版本管理 -->
+4 -1
View File
@@ -10,6 +10,7 @@
apks — 应用管理 apks — 应用管理
tailscale — Tailscale 管理 tailscale — Tailscale 管理
system — 系统数据备份导出/导入 system — 系统数据备份导出/导入
device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置
""" """
from flask import Blueprint from flask import Blueprint
@@ -28,6 +29,8 @@ def register_blueprints(app):
from .agent_api import bp as agent_bp from .agent_api import bp as agent_bp
_agent_mod.set_app(app) _agent_mod.set_app(app)
from .system_api import bp as system_bp 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, 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) app.register_blueprint(bp)
+38 -11
View File
@@ -17,8 +17,12 @@ bp = Blueprint("apks", __name__)
@bp.route("/api/apks") @bp.route("/api/apks")
@login_required @login_required
def api_apks_list(): def api_apks_list():
"""列出所有已上传的 APK。""" """列出已上传的 APK —— **按包名合并,一个应用一行(最新版本)**。
return jsonify({"ok": True, "apks": context.apk_mgr.list_all()})
同一个应用传过 v1.2 / v1.3 时不再并排显示两行;每行带 versions
(全部版本)与 version_count,界面可提示"另有 N 个旧版本"并按需清理。
"""
return jsonify({"ok": True, "apks": context.apk_mgr.list_latest()})
@bp.route("/api/apks/upload", methods=["POST"]) @bp.route("/api/apks/upload", methods=["POST"])
@perm_required(PERM_APKS) @perm_required(PERM_APKS)
@@ -36,7 +40,18 @@ def api_apks_upload():
@bp.route("/api/apks/<apk_id>", methods=["DELETE"]) @bp.route("/api/apks/<apk_id>", methods=["DELETE"])
@perm_required(PERM_APKS) @perm_required(PERM_APKS)
def api_apks_delete(apk_id): def api_apks_delete(apk_id):
"""删除 APK 文件和记录。""" """删除 APK。
默认只删这一个版本(列表里一行 = 一个应用,所以界面通常传 scope):
?scope=package → 删掉**这个应用的所有版本**
?scope=older → 只清理**旧版本**,保留最新那版
不带 scope 时保持老行为(单条删除),给脚本/外部调用留兼容。
"""
scope = (request.args.get("scope") or "").strip()
if scope in ("package", "older"):
ok, msg = context.apk_mgr.delete_package(
apk_id, keep_latest=(scope == "older"))
else:
ok, msg = context.apk_mgr.delete(apk_id) ok, msg = context.apk_mgr.delete(apk_id)
if ok: if ok:
return jsonify({"ok": True, "msg": msg}) return jsonify({"ok": True, "msg": msg})
@@ -50,21 +65,33 @@ def api_apks_install_devices():
source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。 source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。
""" """
devices, seen = [], set() devices, seen = [], set()
name_by_serial, name_by_fp = {}, {}
try: try:
online = set(device_pool.list_online()) online = set(device_pool.list_online())
for serial in device_pool.list_configured(): # list_devices() 返回字典(含 name/fingerprint);list_configured() 只有 serial 字符串
if serial in online: for d in device_pool.list_devices():
devices.append({"serial": serial, "model": "", "source": "pool"}) serial = d.get("serial")
if serial in online and serial not in seen:
devices.append({"serial": serial, "model": "",
"name": d.get("name") or "", "source": "pool"})
seen.add(serial) seen.add(serial)
except Exception: if serial:
pass name_by_serial[serial] = d.get("name") or ""
# USB 设备的 adb serial == ro.serialno == 池里那条网络记录的指纹,
# 靠指纹才认得出是同一台(否则插着 USB 时会冒出一个没名字的裸序列号)
if d.get("fingerprint"):
name_by_fp[d["fingerprint"]] = d.get("name") or ""
except Exception as e:
_log.warning("读取设备名失败(列表将只显示地址): %s", e)
out = _adb("devices") out = _adb("devices")
for line in (out or "").splitlines()[1:]: for line in (out or "").splitlines()[1:]:
parts = line.split() parts = line.split()
if len(parts) >= 2 and parts[0] and parts[0] not in seen: if len(parts) >= 2 and parts[0] and parts[0] not in seen:
devices.append({"serial": parts[0], "model": "", s = parts[0]
"source": "usb" if ":" not in parts[0] else "adb"}) devices.append({"serial": s, "model": "",
seen.add(parts[0]) "name": name_by_serial.get(s) or name_by_fp.get(s) or "",
"source": "usb" if ":" not in s else "adb"})
seen.add(s)
return jsonify({"ok": True, "devices": devices}) return jsonify({"ok": True, "devices": devices})
@bp.route("/api/apks/install", methods=["POST"]) @bp.route("/api/apks/install", methods=["POST"])
+21 -2
View File
@@ -7,10 +7,12 @@ _log = get_logger("web")
def _merged_device_list(): def _merged_device_list():
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。 """本地 adb(含 USB)+ 设备池的合并列表([{serial, name, state}])。
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。 状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
name 来自设备池(平台给设备起的名字,如 A08)—— 界面上统一按「名称 · 地址」
显示,同型号多台时才分得清是哪一台;池里没有的设备 name 为空,界面回退显示地址。
""" """
states = {} states = {}
out = _adb("devices") out = _adb("devices")
@@ -18,13 +20,30 @@ def _merged_device_list():
parts = line.split() parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
states[parts[0]] = parts[1] states[parts[0]] = parts[1]
# 注意用的是 list_devices()(**字典**,含 name/fingerprint);
# list_configured() 只返回 serial 字符串列表,别拿它当字典用(实测踩过:.get 抛异常
# 被 except 吞掉 → 名字全空)
names, fp_names = {}, {}
try:
for d in device_pool.list_devices():
if d.get("serial"):
names[d["serial"]] = d.get("name") or ""
# USB 设备在 adb 里的 serial 就是 ro.serialno,等于池里那条网络记录的
# **指纹** —— 插着 USB 时会以另一个 serial 出现,靠指纹才认得出是同一台,
# 否则界面上会冒出一个没名字的裸序列号(实测踩过)
if d.get("fingerprint"):
fp_names[d["fingerprint"]] = d.get("name") or ""
except Exception as e:
import logging
logging.getLogger("web").warning(f"读取设备名失败: {e}")
try: try:
for serial in device_pool.list_configured(): for serial in device_pool.list_configured():
if serial not in states: if serial not in states:
states[serial] = "pool" states[serial] = "pool"
except Exception: except Exception:
pass pass
return [{"serial": s, "state": st} for s, st in sorted(states.items())] return [{"serial": s, "name": names.get(s) or fp_names.get(s) or "", "state": st}
for s, st in sorted(states.items())]
def _device_screen_state(serial): def _device_screen_state(serial):
+384
View File
@@ -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]})
+94
View File
@@ -12,6 +12,100 @@ from web.auth import perm_required, PERM_DEVICES
_log = get_logger("web") _log = get_logger("web")
bp = Blueprint("devices", __name__) bp = Blueprint("devices", __name__)
def _lan_ip():
"""挑一个手机连得上的本机地址:优先 192.168.x,其次 10.x。
不能用 gethostbyname(hostname) —— 这台机器上它可能返回 Tailscale 的
100.x(CGNAT 地址,手机不在 tailnet 里根本连不上),或 docker 的 172.17。
"""
import socket
candidates = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip.startswith("127.") or ip.startswith("169.254."):
continue
candidates.append(ip)
except Exception:
pass
for ip in candidates:
if ip.startswith("192.168."):
return ip
for ip in candidates:
if ip.startswith("10."):
return ip
return candidates[0] if candidates else "本机IP"
@bp.route("/api/devices/qrcode")
@perm_required(PERM_DEVICES)
def api_devices_qrcode():
"""给某台设备生成「配置二维码」——设备端 Agent 扫一下就把自己配好。
二维码里是一段 JSON(平台地址 + 设备令牌 + 这台设备的指纹),扫码即完成:
填 server/token/指纹 三件事,不用在手机上打字,也不用连 adb。
⚠️ 两条使用前提(都会在返回里给提示,不静默失败):
1. 平台要先在「应用管理 → 设备端应用商店」启用(令牌是从那儿来的)
2. **要用局域网地址打开本页面**(二维码里的地址取自当前访问地址;
如果你是用 localhost 打开的,手机会连不上,这里会明确告诉你)
"""
import base64
import io
import json as _json
serial = (request.args.get("serial") or "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
dev = None
for d in device_pool.list_devices():
if d.get("serial") == serial:
dev = d
break
if dev is None:
return jsonify({"ok": False, "error": "设备不在池里"}), 404
# 令牌必须先启用设备端商店才有
from core.db_config import meta_get
from web.device_agent_api import store_enabled, ensure_token
if not store_enabled():
return jsonify({"ok": False,
"error": "还没启用设备端商店:先到「应用管理 → 设备端应用商店」勾选启用"}), 400
token = ensure_token()
server = request.host_url.rstrip("/")
warn = ""
if "127.0.0.1" in server or "localhost" in server:
warn = ("你现在是用本机地址打开平台的,二维码里的地址手机会连不上 —— "
"请改用局域网地址(如 http://%s:18050)重新打开本页再生成。"
% _lan_ip())
fp = dev.get("fingerprint") or ""
if not fp:
warn = (warn + " " if warn else "") + \
"这台设备还没采集到指纹,扫码后平台认不出它是哪一台(设备端会显示「未登记」);" \
"建议先让它在线并刷新一次型号/指纹,再生成二维码。"
payload = {"v": 1, "server": server, "token": token,
"fingerprint": fp, "name": dev.get("name") or ""}
text = _json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
try:
import qrcode
img = qrcode.make(text)
buf = io.BytesIO()
img.save(buf, format="PNG")
png = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception as e:
_log.error(f"生成二维码失败: {e}")
return jsonify({"ok": False, "error": f"生成二维码失败: {e}"}), 500
_log.info(f"生成设备配置二维码: {dev.get('name') or serial}")
return jsonify({"ok": True, "serial": serial, "name": dev.get("name") or "",
"fingerprint": fp, "png": png, "payload": text, "warn": warn})
@bp.route("/api/devices/pool", methods=["GET"]) @bp.route("/api/devices/pool", methods=["GET"])
@perm_required(PERM_DEVICES) @perm_required(PERM_DEVICES)
def api_devices_pool_list(): def api_devices_pool_list():
+5
View File
@@ -102,6 +102,11 @@ def api_status():
devices, err = context.mgr.get_status() devices, err = context.mgr.get_status()
if err: if err:
return jsonify({"ok": False, "error": err}), 500 return jsonify({"ok": False, "error": err}), 500
# 按**名称**排(A01/A02/…)——之前是 adb 返回的顺序,看着像按 IP 排,很别扭。
# 没名字的排在最后、彼此按地址排(历史数据里可能有没命名的设备)。
devices.sort(key=lambda d: (
1 if not (d.get("device_name") or "").strip() else 0,
(d.get("device_name") or "").strip() or d.get("serial", "")))
return jsonify({ return jsonify({
"ok": True, "devices": devices, "server_time": time.time(), "ok": True, "devices": devices, "server_time": time.time(),
"fg_scanning": context.mgr._fg_scanner.is_scanning, "fg_scanning": context.mgr._fg_scanner.is_scanning,
+3
View File
@@ -156,6 +156,9 @@ def api_tools_appver():
results[d["serial"]] = fut.result() results[d["serial"]] = fut.result()
except Exception as e: except Exception as e:
results[d["serial"]] = {"installed": False, "error": str(e)} results[d["serial"]] = {"installed": False, "error": str(e)}
# 结果里带上设备名:界面按「名称 · 地址」显示,同型号多台才分得清
if isinstance(results.get(d["serial"]), dict):
results[d["serial"]]["name"] = d.get("name") or ""
fail = sum(1 for v in results.values() if v.get("error")) fail = sum(1 for v in results.values() if v.get("error"))
return jsonify({"ok": True, "results": results, "fail": fail, return jsonify({"ok": True, "results": results, "fail": fail,
"total": len(results)}) "total": len(results)})