From bf5071b14d406325c28d5a98a32b6ecd3828e42f Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 13 Sep 2026 14:43:51 +0800 Subject: [PATCH 1/3] =?UTF-8?q?Revert=20"feat:=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=AB=AF=E5=BA=94=E7=94=A8=E5=95=86=E5=BA=97=EF=BC=88=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=BE=A7=EF=BC=89"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 git 红线撤回:该功能未经 dev 验收就被合并进 main(是我提交时没切分支、 又把自己的自测当成了用户验收)—— main 必须保持"已验收可部署"的状态。 功能本身没问题,代码仍在 **dev**(dad1af8)与 feature 分支上,等设备端 Agent 写出来、端到端验收通过后,再从 dev 合并回 main。 main 内容已回到 66632bb(git diff 66632bb HEAD 为空)。 --- core/models.py | 20 --- core/system_backup.py | 2 +- doc/API.md | 56 +------- doc/DATA_MODEL.md | 4 - doc/DEVICE_AGENT.md | 217 ----------------------------- doc/README.md | 2 - static/admin/apps.js | 74 ---------- static/admin/base.js | 1 - templates/admin/monitor.html | 28 ---- web/__init__.py | 5 +- web/device_agent_api.py | 262 ----------------------------------- 11 files changed, 3 insertions(+), 668 deletions(-) delete mode 100644 doc/DEVICE_AGENT.md delete mode 100644 web/device_agent_api.py diff --git a/core/models.py b/core/models.py index b053c2b..9b706c0 100644 --- a/core/models.py +++ b/core/models.py @@ -349,26 +349,6 @@ 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" diff --git a/core/system_backup.py b/core/system_backup.py index 66c01dc..68690d7 100644 --- a/core/system_backup.py +++ b/core/system_backup.py @@ -54,7 +54,7 @@ TABLE_LABELS = { "custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池", "pending_device": "待连接设备", "agent_conversation": "AI 会话", "agent_experience": "经验库", "experience_audit": "经验巡检", - "agent_action": "动作库", "device_install_log": "设备端安装记录", + "agent_action": "动作库", } _STAGE_TTL = 1800 # 导入暂存有效期(秒) diff --git a/doc/API.md b/doc/API.md index bd165d3..d274531 100644 --- a/doc/API.md +++ b/doc/API.md @@ -219,26 +219,6 @@ | 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 文件 | -| POST | `/api/device/agent/report` | 上报下载/安装结果 | - -**管理端**(登录 + 应用管理权限): - -| 方法 | 路径 | 功能 | -|------|------|------| -| GET | `/api/agent-store/config` | 读配置(含设备令牌) | -| POST | `/api/agent-store/config` | 启用/停用、重置令牌 | -| GET | `/api/agent-store/logs` | 设备端安装记录 | - -> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。 - --- ## 3. 认证与页面 @@ -676,41 +656,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: --- -## 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 响应汇总 +## 14. 非 JSON 响应汇总 | 方法 | 路径 | 响应类型 | 说明 | |------|------|---------|------| diff --git a/doc/DATA_MODEL.md b/doc/DATA_MODEL.md index 66ca311..a7513dc 100644 --- a/doc/DATA_MODEL.md +++ b/doc/DATA_MODEL.md @@ -43,7 +43,6 @@ | 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` @@ -233,9 +232,6 @@ 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` | 标签写入时间 | 同上 | diff --git a/doc/DEVICE_AGENT.md b/doc/DEVICE_AGENT.md deleted file mode 100644 index d4471bf..0000000 --- a/doc/DEVICE_AGENT.md +++ /dev/null @@ -1,217 +0,0 @@ -# 设备端 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/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 shell am start -n /.ClipActivity --es text_b64 -``` - -- **必须 base64**:adb shell 直传中文/特殊字符会变形 -- 必须用**透明 Activity**(有焦点才能写剪贴板),写完**立即退出** -- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist` - → 视为"设备未安装 Agent" - -### 5.2 设备身份显示(待设备端实现) - -```bash -adb -s shell am start -n /.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 一次性配置下发(建议设备端实现) - -```bash -adb -s shell am start -n /.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 账号密码 diff --git a/doc/README.md b/doc/README.md index 10e742a..aaf803f 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,7 +18,6 @@ | [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) | 追溯背景时参考 | @@ -57,7 +56,6 @@ | 任务类型 / 步骤 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)(完成时移出并同步相关文档) | diff --git a/static/admin/apps.js b/static/admin/apps.js index c8f5efb..5d496da 100644 --- a/static/admin/apps.js +++ b/static/admin/apps.js @@ -262,77 +262,3 @@ 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='暂无记录(设备还没从商店装过东西)'; - 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 ''+esc(l.created_at||'')+''+esc(l.device_name||l.serial||'-')+''+ - ''+esc(_cut(app,30))+''+ - ''+esc(act[l.action]||l.action||'')+''+ - ''+esc(_cut(l.message||'',60))+''; - }).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;} diff --git a/static/admin/base.js b/static/admin/base.js index 5bb4fd4..481d3de 100644 --- a/static/admin/base.js +++ b/static/admin/base.js @@ -123,7 +123,6 @@ 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'){ diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index a385849..0b6dba9 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -788,34 +788,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
- - -
📱 设备端应用商店(Agent 直连安装)
-
- 设备上装的 Agent 自己来拉这份应用清单 → 在手机上选一个 → 自己下载 → 自己调系统安装器安装。 - 因为安装由设备上的 App 发起,走的是普通应用安装流程, - 不会触发 MIUI 针对 adb 安装的「USB 安装」拦截(那正是 adb install 报 USER_RESTRICTED 的原因)。 - 适合"人就在机器旁边"的场景(本来就要点一次安装确认)。平台批量推送仍用上面的「安装」按钮。 -
接口契约见 doc/DEVICE_AGENT.md;令牌属于凭据,别外传。 -
-
- - - - - - - -
- - - - - - -
时间设备应用动作说明
暂无记录(设备还没从商店装过东西)
diff --git a/web/__init__.py b/web/__init__.py index 58ca3ea..d38fc73 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -10,7 +10,6 @@ apks — 应用管理 tailscale — Tailscale 管理 system — 系统数据备份导出/导入 - device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置 """ from flask import Blueprint @@ -29,8 +28,6 @@ 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, - devagent_bp): + devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp): app.register_blueprint(bp) diff --git a/web/device_agent_api.py b/web/device_agent_api.py deleted file mode 100644 index 357ac93..0000000 --- a/web/device_agent_api.py +++ /dev/null @@ -1,262 +0,0 @@ -"""设备端 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_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_configured(enabled_only=False): - 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: - for a in context.apk_mgr.list_all(): - 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/") -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/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-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]}) From 524ae20c49a4daf6a541d43ed34056e2fb2b981b Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 13 Sep 2026 17:37:32 +0800 Subject: [PATCH 2/3] =?UTF-8?q?Reapply=20"feat:=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=AB=AF=E5=BA=94=E7=94=A8=E5=95=86=E5=BA=97=EF=BC=88=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=BE=A7=EF=BC=89"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bf5071b14d406325c28d5a98a32b6ecd3828e42f. --- core/models.py | 20 +++ core/system_backup.py | 2 +- doc/API.md | 56 +++++++- doc/DATA_MODEL.md | 4 + doc/DEVICE_AGENT.md | 217 +++++++++++++++++++++++++++++ doc/README.md | 2 + static/admin/apps.js | 74 ++++++++++ static/admin/base.js | 1 + templates/admin/monitor.html | 28 ++++ web/__init__.py | 5 +- web/device_agent_api.py | 262 +++++++++++++++++++++++++++++++++++ 11 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 doc/DEVICE_AGENT.md create mode 100644 web/device_agent_api.py diff --git a/core/models.py b/core/models.py index 9b706c0..b053c2b 100644 --- a/core/models.py +++ b/core/models.py @@ -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" diff --git a/core/system_backup.py b/core/system_backup.py index 68690d7..66c01dc 100644 --- a/core/system_backup.py +++ b/core/system_backup.py @@ -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 # 导入暂存有效期(秒) diff --git a/doc/API.md b/doc/API.md index d274531..bd165d3 100644 --- a/doc/API.md +++ b/doc/API.md @@ -219,6 +219,26 @@ | 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 文件 | +| POST | `/api/device/agent/report` | 上报下载/安装结果 | + +**管理端**(登录 + 应用管理权限): + +| 方法 | 路径 | 功能 | +|------|------|------| +| GET | `/api/agent-store/config` | 读配置(含设备令牌) | +| POST | `/api/agent-store/config` | 启用/停用、重置令牌 | +| GET | `/api/agent-store/logs` | 设备端安装记录 | + +> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。 + --- ## 3. 认证与页面 @@ -656,7 +676,41 @@ 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 响应汇总 | 方法 | 路径 | 响应类型 | 说明 | |------|------|---------|------| diff --git a/doc/DATA_MODEL.md b/doc/DATA_MODEL.md index a7513dc..66ca311 100644 --- a/doc/DATA_MODEL.md +++ b/doc/DATA_MODEL.md @@ -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` | 标签写入时间 | 同上 | diff --git a/doc/DEVICE_AGENT.md b/doc/DEVICE_AGENT.md new file mode 100644 index 0000000..d4471bf --- /dev/null +++ b/doc/DEVICE_AGENT.md @@ -0,0 +1,217 @@ +# 设备端 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/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 shell am start -n /.ClipActivity --es text_b64 +``` + +- **必须 base64**:adb shell 直传中文/特殊字符会变形 +- 必须用**透明 Activity**(有焦点才能写剪贴板),写完**立即退出** +- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist` + → 视为"设备未安装 Agent" + +### 5.2 设备身份显示(待设备端实现) + +```bash +adb -s shell am start -n /.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 一次性配置下发(建议设备端实现) + +```bash +adb -s shell am start -n /.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 账号密码 diff --git a/doc/README.md b/doc/README.md index aaf803f..10e742a 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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)(完成时移出并同步相关文档) | diff --git a/static/admin/apps.js b/static/admin/apps.js index 5d496da..c8f5efb 100644 --- a/static/admin/apps.js +++ b/static/admin/apps.js @@ -262,3 +262,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='暂无记录(设备还没从商店装过东西)'; + 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 ''+esc(l.created_at||'')+''+esc(l.device_name||l.serial||'-')+''+ + ''+esc(_cut(app,30))+''+ + ''+esc(act[l.action]||l.action||'')+''+ + ''+esc(_cut(l.message||'',60))+''; + }).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;} diff --git a/static/admin/base.js b/static/admin/base.js index 481d3de..5bb4fd4 100644 --- a/static/admin/base.js +++ b/static/admin/base.js @@ -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'){ diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 0b6dba9..a385849 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -788,6 +788,34 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+ + +
📱 设备端应用商店(Agent 直连安装)
+
+ 设备上装的 Agent 自己来拉这份应用清单 → 在手机上选一个 → 自己下载 → 自己调系统安装器安装。 + 因为安装由设备上的 App 发起,走的是普通应用安装流程, + 不会触发 MIUI 针对 adb 安装的「USB 安装」拦截(那正是 adb install 报 USER_RESTRICTED 的原因)。 + 适合"人就在机器旁边"的场景(本来就要点一次安装确认)。平台批量推送仍用上面的「安装」按钮。 +
接口契约见 doc/DEVICE_AGENT.md;令牌属于凭据,别外传。 +
+
+ + + + + + + +
+ + + + + + +
时间设备应用动作说明
暂无记录(设备还没从商店装过东西)
diff --git a/web/__init__.py b/web/__init__.py index d38fc73..58ca3ea 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -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) diff --git a/web/device_agent_api.py b/web/device_agent_api.py new file mode 100644 index 0000000..357ac93 --- /dev/null +++ b/web/device_agent_api.py @@ -0,0 +1,262 @@ +"""设备端 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_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_configured(enabled_only=False): + 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: + for a in context.apk_mgr.list_all(): + 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/") +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/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-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]}) From cf075ba4e18c0e551f604d8c1d14849efff4f05a Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Wed, 16 Sep 2026 12:39:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(=E4=BB=BB=E5=8A=A1):=20=E6=BB=91?= =?UTF-8?q?=E5=8A=A8=E6=8B=9F=E4=BA=BA=E5=8C=96=20+=20=E6=AF=8F=E5=8F=B0?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E6=9C=89=E8=87=AA=E5=B7=B1=E7=9A=84=E6=89=8B?= =?UTF-8?q?=E6=84=9F=EF=BC=88core/humanize.py=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈"滑动太像机器人""批量执行时每台设备都一样"。 core/humanize.py(新)分两个层次: - **每次不同**:起止点/幅度/时长抖动、弧线方向随机——最容易被识别的不是"慢", 是"每次都一模一样"; - **每台设备不同**:由 crc32(serial) 派生稳定的"性格"(手速 0.82~1.32、 幅度 0.86~1.16、弧度、常用横坐标 ±9% 屏宽、停顿 0.80~1.35)。同设备多次运行 风格一致,不同设备明显不同——13 台批量跑看着像 13 个人各刷各的。 用独立 Random 实例播种,不碰全局 random(多线程 worker 会打乱取值顺序)。 轨迹用二次贝塞尔走 d.swipe_points(曲线),异常时自动退回直线 d.swipe。 **点数固定 4 个**:swipe_points 在慢设备上每多一个点约多 1 秒(实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),设备自己会插值几十步,4 点已足够弯。 顺带修一个真机上的老毛病:滑动原本每次都要读 d.info,而它在部分设备上要 **14 秒**。改用 humanize.screen_size()(走 window_size,同设备 0.6s,缓存 120s)——所以哪怕多了弧线,真机单次滑动反而从 ~15s 降到 ~5s。 - tasks/generic/task.py:swipe / swipe_until 走拟人(新增 distance_ratio、 jitter、humanize 参数,默认开);swipe_until 每轮停顿也抖动;wait 步骤新增 可选 vary_pace(默认关,按设备节奏缩放 0.8~1.35 倍); - static/admin/editor.js:滑动类步骤参数面板加"幅度/抖动/拟人轨迹"+ 说明; - 文档:TASK_DEV.md §8.4(含"点数别调大""别用 d.info"两个坑)、步骤表、 README 能力表与结构。 自测:假设备单测(曲线/抖动/设备间差异/退化路径/不越界/尺寸缓存)+ 真机联调 (4 次滑动全部 ok,标注"弧线"、坐标每次不同、对照的 humanize=false 仍是直线)。 --- README.md | 2 + core/humanize.py | 186 +++++++++++++++++++++++++++++++++++++++++ doc/TASK_DEV.md | 40 ++++++++- static/admin/editor.js | 29 ++++++- tasks/generic/task.py | 60 +++++++------ 5 files changed, 284 insertions(+), 33 deletions(-) create mode 100644 core/humanize.py diff --git a/README.md b/README.md index a0ad241..ed584df 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ | **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 | | **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) | | **步骤编辑器** | 可视化拖拽编排 18 种步骤(含循环/条件/OCR),可打包成"自定义动作"复用 | +| **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) | | **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | | **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) | | **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | @@ -138,6 +139,7 @@ auto_control/ │ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道) │ ├── apk_manager.py # APK 上传/解析/批量安装 │ ├── system_backup.py # 数据备份导出/导入(重启生效) +│ ├── humanize.py # 拟人化:弧线滑动/抖动 + 每台设备的"手感"(按 serial 播种) │ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录 │ ├── step_log.py # 任务步骤明细(异步写线程 + 保留期清理) │ ├── tailscale_client.py # Tailscale API v2 客户端 diff --git a/core/humanize.py b/core/humanize.py new file mode 100644 index 0000000..be73262 --- /dev/null +++ b/core/humanize.py @@ -0,0 +1,186 @@ +"""拟人化操作:滑动不机械,且**每台设备有自己的手感**。 + +分两个层次,别混在一起: + +1. **每次都不一样**(抖动):起点/终点位置、滑动幅度、时长、弧线方向都随机—— + 最容易被识别的特征不是"慢",而是"同一帧重复播放"。 +2. **每台设备稳定不同**(性格):由 `serial` 派生一份固定的偏好(手速、幅度、 + 弧度、停顿、常用触点的横向位置)。同一台设备多次运行风格一致,不同设备之间 + 明显不同——批量跑 13 台时看起来像 13 个人各刷各的,而不是 13 台同步机器人。 + +**性格稳定、抖动随机**,两者都由 serial 播种,不引入全局状态、不做任何 IO。 + +性格怎么来的:`crc32(serial)` 作种子 → 几个围绕 1.0 的比例因子(见 `profile()`)。 +换设备换风格,改名字**不改**风格(按 serial 而不是设备名),换地址会换风格—— +这符合直觉:地址代表"这一台"。 + +想手工看一眼某台设备的性格:`python -c "from core import humanize; print(humanize.profile('192.168.20.55:5555'))"` +""" +import random +import time +import zlib + +from core.logger import get_logger + +_log = get_logger("core.humanize") + +_PROFILE_CACHE = {} +# serial -> (w, h, ts):屏幕尺寸缓存(见 screen_size) +_SIZE_CACHE = {} +_SIZE_TTL = 120 + + +def profile(serial): + """设备性格(按 serial 缓存,恒定)。字段都是围绕 1.0 的比例因子。""" + key = str(serial or "") + p = _PROFILE_CACHE.get(key) + if p is not None: + return p + # 用独立的 Random 实例而不是全局 random:worker 是多线程的,全局 random 的 + # 取值顺序会被其它线程打乱 —— 那样"每台设备的性格"就串味了 + r = random.Random(zlib.crc32(("human:" + key).encode("utf-8"))) + p = { + "speed": r.uniform(0.82, 1.32), # <1 划得快(时长短),>1 划得慢 + "distance": r.uniform(0.86, 1.16), # 有人习惯一划到底,有人小步快跑 + "curve": r.uniform(0.4, 1.6), # 弧线弯度系数 + "x_bias": r.uniform(-0.09, 0.09), # 常用触点的横向偏移(屏幕宽比例) + "pause": r.uniform(0.80, 1.35), # 停顿倍率(wait 步骤可选启用) + } + _PROFILE_CACHE[key] = p + return p + + +def screen_size(d, serial=""): + """屏幕尺寸,带缓存。 + + 为什么不用 `d.info`:它在部分设备上**极慢**(实测 14s,见 doc/backlog),而 + 尺寸在一次运行里根本不会变。`d.window_size()` 快得多(同设备 0.6s)。 + 缓存 120s 过期:够躲开慢调用,又能在屏幕旋转/换分辨率后自愈。 + """ + key = str(serial or "") + now = time.time() + hit = _SIZE_CACHE.get(key) + if hit and now - hit[2] < _SIZE_TTL: + return hit[0], hit[1] + try: + w, h = d.window_size() + except Exception: # 老 agent 没有 window_size 就退回 info + info = d.info + w, h = info["displayWidth"], info["displayHeight"] + w, h = int(w), int(h) + if w > 0 and h > 0: + _SIZE_CACHE[key] = (w, h, now) + return w, h + + +def _bezier(p0, p1, p2, n): + """二次贝塞尔采样——手指划出来是弧线,不是尺子画的直线。""" + pts = [] + for i in range(n + 1): + t = i / n + x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0] + y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1] + pts.append((int(round(x)), int(round(y)))) + # 同样的点会连续出现(曲线很平时)——去掉,免得下发冗余坐标 + out = [pts[0]] + for p in pts[1:]: + if p != out[-1]: + out.append(p) + return out + + +def swipe(d, direction, serial="", duration_min=0.25, duration_max=0.50, + distance_ratio=0.6, jitter=0.15, humanize=True): + """按人类习惯滑一下(上/下/左/右)。 + + 参数: + direction 上滑 up / 下滑 down / 左滑 left / 右滑 right + serial 用来取本设备的性格(不传就是"普通人") + duration_min/max 手指接触屏幕的时长区间(秒),再乘本设备手速系数 + distance_ratio 滑动幅度占屏幕高(竖滑)或宽(横滑)的比例,默认 0.6 + jitter 抖动幅度 0~0.4:位置/幅度/时长各抖这么多;0 = 每次都一样 + humanize False = 退回老的直线滑动(完全规则,用于对照/排障) + + 返回实际参数 dict(时长/起止点),方便日志与排障。异常向上抛,由调用方兜。 + """ + w, h = screen_size(d, serial) + prof = profile(serial) + vertical = direction in ("up", "down") + j = max(0.0, min(float(jitter or 0), 0.4)) + + # ---- 时长:用户给的范围 × 本设备手速 × 本次抖动 ---- + dur = random.uniform(float(duration_min), float(duration_max)) + dur *= prof["speed"] + if j: + dur *= 1 + random.uniform(-j, j) * 0.6 + dur = max(0.08, round(dur, 3)) + + # ---- 幅度与中心点 ---- + span = (h if vertical else w) * float(distance_ratio) * prof["distance"] + if j: + span *= 1 + random.uniform(-j, j) + span = max(0.08, min(span, (h if vertical else w) * 0.92)) + + # 起点不在正中:人不会每次都在屏幕同一条线上划 + if vertical: + cx = w * (0.5 + prof["x_bias"] + (random.uniform(-j, j) * 0.6 if j else 0)) + cx = int(max(w * 0.10, min(cx, w * 0.90))) + cy = h * 0.5 + if j: + cy += h * random.uniform(-j, j) * 0.5 + sign = -1 if direction == "up" else 1 + p0 = (cx, int(max(h * 0.03, min(cy - sign * span / 2, h * 0.97)))) + p2 = (cx, int(max(h * 0.03, min(cy + sign * span / 2, h * 0.97)))) + else: + cy = int(h * (0.5 + prof["x_bias"] + (random.uniform(-j, j) * 0.6 if j else 0))) + cy = int(max(h * 0.10, min(cy, h * 0.90))) + cx = w * 0.5 + if j: + cx += w * random.uniform(-j, j) * 0.5 + sign = -1 if direction == "left" else 1 + p0 = (int(max(w * 0.03, min(cx - sign * span / 2, w * 0.97))), cy) + p2 = (int(max(w * 0.03, min(cx + sign * span / 2, w * 0.97))), cy) + + if not humanize: + # 老行为:正中直线、无抖动(点与点之间由设备等分插值) + if vertical: + s, e = (0.8, 0.2) if direction == "up" else (0.2, 0.8) + base = (int(w * 0.5), int(h * s), int(w * 0.5), int(h * e)) + else: + s, e = (0.8, 0.2) if direction == "left" else (0.2, 0.8) + base = (int(w * s), int(h * 0.5), int(w * e), int(h * 0.5)) + d.swipe(*base, random.uniform(float(duration_min), float(duration_max))) + return {"duration": dur, "from": base[:2], "to": base[2:], "human": False} + + # ---- 弧线:控制点在中点、垂直于滑动方向偏移 ---- + amp = span * 0.05 * prof["curve"] + if j: + amp *= random.uniform(0.3, 1.0) + amp *= random.choice((-1, 1)) # 这次往左弯还是往右弯 + else: + # jitter=0 时不引入任何随机:弯向由设备性格决定,便于对照排障 + amp *= -1 if prof["curve"] < 1.0 else 1 + mx, my = (p0[0] + p2[0]) / 2, (p0[1] + p2[1]) / 2 + if vertical: + ctrl = (mx + amp, my) + else: + ctrl = (mx, my + amp) + # 点数刻意压到 4:`swipe_points` 在慢设备上**每多一个点就多约 1s** + # (实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间 + # 插值 70 步,4 个点的贝塞尔已经足够弯 + pts = _bezier(p0, ctrl, p2, 4) + if len(pts) < 2: # 幅度太小时退化成直线 + pts = [p0, p2] + try: + d.swipe_points([[x, y] for x, y in pts], dur) + except Exception as e: # 老设备/agent 不支持曲线就退回直线 + _log.warning("swipe_points 不可用,退回直线滑动: %s", e) + d.swipe(p0[0], p0[1], p2[0], p2[1], dur) + return {"duration": dur, "from": p0, "to": p2, "curve": amp, "human": True} + + +def pace(serial, seconds, enabled=True): + """按本设备节奏微调一个停顿(wait 步骤可选启用)。enabled=False 原样返回。""" + if not enabled: + return float(seconds) + return float(seconds) * profile(serial)["pause"] diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index 14414f8..a9309b6 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -120,15 +120,15 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` | | 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则**把系统息屏超时顶到最大**(`screen_off_timeout=2147483647`)+ `svc power stayon true` + 唤醒一次。⚠️ 只发 `svc power stayon true` 对**没插充电器**的设备(走 WiFi 的那些)**完全无效**——它管的是"充电时屏幕常亮",2026-09-14 就是因此被反馈"保持亮屏没用" | | 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** | -| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50) | — | 时长取随机值;up = 中轴从 0.8h → 0.2h(down 反向,left/right 同理);**非法 direction 静默不滑** | -| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True) | — | 每轮滑动后 sleep 0.5s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 | +| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | **默认拟人**:弧线轨迹 + 起止点/幅度/时长抖动 + 本设备手速偏好(见 §8.4);`humanize:false` 还原老的「正中直线」;`jitter:0` 时位置/幅度不抖(时长仍随机);**非法 direction 静默不滑** | +| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True)、`duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | 每次滑动都重新生成轨迹(同 §8.4);每轮滑动后随机停 0.35~0.75s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 | | 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警** | | 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** | | 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` | | 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) | | 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 click 输入框) | | 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);`paste=true` 时再触发一次粘贴 | -| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断 | +| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) | | 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 | | 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group | | 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2 | @@ -375,6 +375,40 @@ from .myapp import task # ← 新增 `TaskManager._run_with_retry` 传入;**只用于结构化记录**(如步骤明细 `core/step_log.py`),不参与业务逻辑。不关心就原样透传给 `BaseWorker` 即可。 +### 8.4 拟人化:滑动与设备节奏(`core/humanize.py`) + +自动化最容易被识别的地方不是"慢",是**每次都一模一样**:同一坐标、同一时长、 +13 台设备整齐划一地做同一个动作。`core/humanize.py` 从两个层次解决: + +| 层次 | 机制 | 谁决定 | +|---|---|---| +| **每次不同** | 起点/终点/幅度/时长抖动,弧线方向随机 | 每次调用现算(`jitter` 控制幅度,0~0.4) | +| **每台设备不同** | 手速(0.82~1.32)、幅度(0.86~1.16)、弧度、常用横坐标(±9%屏宽)、停顿(0.80~1.35) | `crc32(serial)` 播种,**同设备恒定** | + +用法(同步步骤里用): + +```python +from core import humanize +humanize.swipe(d, "up", serial=self.serial, duration_min=0.25, duration_max=0.4, + distance_ratio=0.6, jitter=0.15, humanize=True) +humanize.pace(self.serial, 5.0, enabled=True) # wait 步骤的"按设备节奏微调" +``` + +要点与坑: + +- **性格稳定、抖动随机**:稳定才像"不同的人",随机才不像"机器"。性格按 **serial** + 而不是设备名播种——改名不改风格,换地址会换风格(地址代表"这一台")。 +- 内部用**独立的 `random.Random` 实例**,不碰全局 `random`:worker 是多线程的, + 全局 RNG 的取值顺序会被别的线程打乱,性格就串味了。 +- **屏幕尺寸别用 `d.info`**:部分设备上一次要 **14 秒**(实测),改用 + `humanize.screen_size(d, serial)`——它走 `d.window_size()`(同设备 0.6s)并缓存 + 120 秒。这让"加了弧线反而更快"成为可能。 +- **曲线点数固定为 4**(5 个点):`d.swipe_points` 在慢设备上**每多一个点约多 1 秒** + (实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间插值几十步, + 4 个点的贝塞尔已经足够弯。**不要为了"更平滑"把点数调大。** +- `swipe_points` 抛异常时自动退回直线 `d.swipe`(老 agent 兜底),日志会留一行 warning。 +- 想关掉:步骤参数 `humanize: false`(完全还原老行为);想调轻:`jitter: 0.05`。 + --- ## 9. Action 框架(App 专属操作) diff --git a/static/admin/editor.js b/static/admin/editor.js index 257df75..b21f91e 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -5,8 +5,8 @@ var STEP_LIB=[ {type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}}, {type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}}, {type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}}, - {type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50}}, - {type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}}, + {type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}}, + {type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true,duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}}, {type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}}, {type:'clipboard',label:'剪贴板注入',icon:'📋',cat:'interact',params:{text:'',paste:true}}, {type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}}, @@ -69,6 +69,22 @@ function _loadCustomActions(){ }).catch(function(){}); } + +// 「拟人」开关 + 抖动幅度(滑动类步骤共用;见 core/humanize.py) +function _humanizeChkHtml(p){ + return '
' + +'' + +'
' + +'
'; +} +function _humanizeRowHtml(p,extra){ + return extra+_humanizeChkHtml(p) + +'
拟人:滑动走弧线,起止点/幅度/时长每次都抖一点,并按本设备的手速与幅度偏好执行' + +'(每台设备风格稳定不同,批量跑时不像同步机器人)。关掉=还原成正中直线;' + +'抖动填 0=位置/幅度不抖(时长仍在你填的范围内随机)
'; +} + var _stepEditor={ _steps:[], _selected:[], @@ -299,6 +315,7 @@ var _stepEditor={ ['up','down','left','right'].map(function(d){return '';}).join('')+''; h+='
'; h+='
'; + h+=_humanizeRowHtml(p,'
'); }else if(step.type==='click'){ h+=_selRowHtml(p,'
'); }else if(step.type==='long_click'){ @@ -308,9 +325,11 @@ var _stepEditor={ h+=_selRowHtml(p,'
'+ - '
'+ + '
'+ + '
'+ '
'+ - '
上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续
'); + _humanizeChkHtml(p)+ + '
上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续。滑动同样走拟人轨迹
'); }else if(step.type==='wait_el'){ h+=_selRowHtml(p,'
'+ '
等待目标元素出现(替代固定秒数等待),超时则继续
'); @@ -327,6 +346,8 @@ var _stepEditor={ }else if(step.type==='wait'){ h+='
'; h+='
'; + h+='
'; + h+='
勾上后每台设备按自己的节奏缩放这个等待:批量跑时设备之间会逐渐错开,不再整齐划一。代价是实际时长不再严格等于上面填的值
'; }else if(step.type==='clipboard'){ h+='
'; h+='
'; diff --git a/tasks/generic/task.py b/tasks/generic/task.py index b9bbc7d..61f4766 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -37,7 +37,7 @@ from tasks.base import BaseTask, register_task from core.device_worker import BaseWorker, _update_status from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep from core.logger import get_logger -from core import notifier, step_log +from core import notifier, step_log, humanize _log = get_logger("task.generic") @@ -82,10 +82,13 @@ STEP_TYPES = [ {"type": "key_event", "label": "按键", "icon": "⌨️", "params": {"key": "back"}}, {"type": "swipe", "label": "滑动", "icon": "↕", - "params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}}, + "params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50, + "distance_ratio": 0.6, "jitter": 0.15, "humanize": True}}, {"type": "swipe_until", "label": "滑动直到元素", "icon": "🔍", "params": {"selector_type": "xpath", "selector_value": "", "direction": "up", - "max_swipes": 8, "click_when_found": True}}, + "max_swipes": 8, "click_when_found": True, + "duration_min": 0.25, "duration_max": 0.50, + "distance_ratio": 0.6, "jitter": 0.15, "humanize": True}}, {"type": "click", "label": "点击元素", "icon": "✦", "params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}}, {"type": "click_xy", "label": "点击坐标", "icon": "🎯", @@ -379,13 +382,14 @@ class GenericStepsWorker(BaseWorker): if not sel_val: _log.warning("swipe_until 缺少选择器") return None - info = d.info - w, h = info["displayWidth"], info["displayHeight"] - cx = int(w * 0.5) - if direction == "down": - sy, ey = int(h * 0.2), int(h * 0.8) - else: - sy, ey = int(h * 0.8), int(h * 0.2) + # 每次滑动都重新生成(弧线方向/幅度/时长都不一样),不用预算好的固定坐标 + swipe_kw = dict( + serial=self.serial, + duration_min=float(params.get("duration_min", 0.25)), + duration_max=float(params.get("duration_max", 0.50)), + distance_ratio=float(params.get("distance_ratio", 0.6) or 0.6), + jitter=params.get("jitter", 0.15), + humanize=params.get("humanize", True) is not False) for i in range(1, max_swipes + 1): if self.stopped() or self.is_time_up(): return None @@ -411,8 +415,8 @@ class GenericStepsWorker(BaseWorker): _log.info(f"[{self.serial}] 滑动{i}次后找到: {sel_val}") self._track_selector_health(sel_val, True) return True - d.swipe(cx, sy, cx, ey, 0.3) - time.sleep(0.5) + humanize.swipe(d, direction, **swipe_kw) + time.sleep(random.uniform(0.35, 0.75)) # 每次滑动后停顿也抖动一下 self._track_selector_health(sel_val, False) _log.warning(f"[{self.serial}] 滑动{max_swipes}次未找到元素: {sel_val}") return False @@ -531,21 +535,22 @@ class GenericStepsWorker(BaseWorker): return found def _exec_swipe(self, d, params, depth=0): + """滑动一次(默认拟人:弧线 + 抖动 + 本设备手速,见 core/humanize.py)。 + + 老行为(正中直线、每次都一样)可以用 humanize=false 还原; + jitter=0 则保留轨迹但去掉随机抖动(对照排障用)。 + """ direction = params.get("direction", "up") - dur_min = float(params.get("duration_min", 0.25)) - dur_max = float(params.get("duration_max", 0.50)) - dur = random.uniform(dur_min, dur_max) - info = d.info - w, h = info["displayWidth"], info["displayHeight"] - cx = int(w * 0.5) - if direction == "up": - d.swipe(cx, int(h * 0.8), cx, int(h * 0.2), dur) - elif direction == "down": - d.swipe(cx, int(h * 0.2), cx, int(h * 0.8), dur) - elif direction == "left": - d.swipe(int(w * 0.8), int(h * 0.5), int(w * 0.2), int(h * 0.5), dur) - elif direction == "right": - d.swipe(int(w * 0.2), int(h * 0.5), int(w * 0.8), int(h * 0.5), dur) + info = humanize.swipe( + d, direction, serial=self.serial, + duration_min=float(params.get("duration_min", 0.25)), + duration_max=float(params.get("duration_max", 0.50)), + distance_ratio=float(params.get("distance_ratio", 0.6) or 0.6), + jitter=params.get("jitter", 0.15), + humanize=params.get("humanize", True) is not False) + _log.info(f"[{self.serial}] 滑动 {direction} 时长{info['duration']}s " + f"({info['from']}→{info['to']}" + f"{',弧线' if info.get('human') else ',直线'})") def _exec_click(self, d, params, depth=0): sel_type = params.get("selector_type", "xpath") @@ -620,6 +625,9 @@ class GenericStepsWorker(BaseWorker): max_s = float(params.get("max", 3.0)) # 分片 sleep:每 0.5s 检查停止/超时信号,抢占任务能及时接管(纯 sleep 无法中断) total = random.uniform(min_s, max_s) + # 可选:按本设备节奏缩放(勾了"设备节奏差异"才生效,默认关—— + # 时长被放大/缩小是业务语义,不能悄悄改) + total = humanize.pace(self.serial, total, enabled=params.get("vary_pace", False)) end = time.time() + total while time.time() < end: if self.stopped() or self.is_time_up():