@marktrue/dsh-mobile-remote 3.2.1-rc.1 → 3.2.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -282,6 +282,8 @@ flutter build apk --release
282
282
  > 正式发布必须走 `docs/10-release.md`(含版本 bump、载体打包、预检、演练与发布顺序);本文件不再重复签名/排障细节,避免两处维护。
283
283
  > 更换图标:把 1024×1024 PNG 覆盖到 `assets/icon-1024.png`,运行 `python tools/make_icon.py` 后重新构建。
284
284
  > 多品牌/兼容性注意事项见 docs/09-compatibility.md。
285
+ > 完整构建命令、Google 下载代理配置、签名校验及 Gradle 常见问题见发布操作手册 [`10-release.md`](10-release.md) §4(构建)与[附录 A](10-release.md#附录-aandroid-apk-构建与排障)。
286
+
285
287
  ### 8.3 安装与使用
286
288
  1. 把 APK 传到手机(微信文件传输/网盘/USB),点击安装(需允许"安装未知来源应用")。
287
289
  2. 打开 App →「扫码连接」对准电脑屏幕上的二维码(桌面 DSH 设置 →「连接移动端设备」页);或手动输入电脑地址 + 访问口令。
@@ -292,6 +294,67 @@ flutter build apk --release
292
294
  - 插件/网页端改动 → 按 §3 同步并重启 DSH。
293
295
  - App 改动 → 重新 `flutter build apk --release` 并重装(同签名覆盖安装,保留连接信息)。
294
296
 
297
+ ### 8.5 测试构建与重复自动更新验证
298
+
299
+ 测试构建的目标是:APK 内置版本保持不变,主机源 `manifest.json` 使用更大的测试 build 号(通常为 `+99`),这样每次打开「检查更新」都能再次触发更新流程。
300
+
301
+ 1. 不修改 `dsh-mobile-app/pubspec.yaml`,也不要给 Flutter 构建命令传入新的 `--build-number`:
302
+
303
+ ```bash
304
+ cd dsh-mobile-app
305
+ flutter analyze
306
+ flutter test
307
+ flutter build apk --release
308
+ ```
309
+
310
+ 2. 使用 manifest 覆盖变量打包并部署。`MANIFEST_VERSION` 只写入 manifest,不会改变 APK 内置的 `versionName/versionCode`:
311
+
312
+ ```bash
313
+ ALLOW_TEST_MANIFEST_VERSION=1 \
314
+ MANIFEST_VERSION="3.1.5+99" \
315
+ UPDATE_DIR="$HOME/.dsh/mobile-remote/update" \
316
+ bash tools/package-release.sh
317
+ ```
318
+
319
+ Windows PowerShell:
320
+
321
+ ```powershell
322
+ $env:ALLOW_TEST_MANIFEST_VERSION = '1'
323
+ $env:MANIFEST_VERSION = '3.1.5+99'
324
+ $env:UPDATE_DIR = "$HOME/.dsh/mobile-remote/update"
325
+ .\tools\package-release.ps1
326
+ Remove-Item Env:ALLOW_TEST_MANIFEST_VERSION, Env:MANIFEST_VERSION, Env:UPDATE_DIR
327
+ ```
328
+
329
+ 3. 验证 `manifest.json` 的 `version` 为 `3.1.5+99`,并确认 `sha256`、`size` 与 manifest 指向的 APK 一致;随后在 App 中检查更新并完成覆盖安装。
330
+
331
+ > `+99` 仅用于测试;发布脚本默认拒绝覆盖,必须显式设置 `ALLOW_TEST_MANIFEST_VERSION=1` 才可用。测试完成后清空 `updateDir` 或重新部署正式 manifest,避免用户持续收到测试更新。APK 与已安装版本必须使用同一签名。
332
+
333
+ ### 8.6 App 自动更新(双更新源,v3.0.0+)
334
+
335
+ > 入口:设置 → 关于 → 「更新源」(GitHub Releases / dsh 运行主机,单选持久化,默认 GitHub)+「检查更新」;自动检查在启动连接成功后静默执行一次,命中后首页横幅 + 版本行「● 有新版本」提示。更新流程:确认弹窗(版本/说明/大小/来源)→ 下载(进度可取消)→ 主机源 sha256 校验 → **签名预检**(与已装版本签名不一致即取消并提示)→ 系统安装器。
336
+
337
+ **GitHub 源**:App 直连 `api.github.com/.../releases/latest`,取首个 `DSH-Remote-*.apk` 资产下载。无需配置;GitHub 不可达时明确报错(可切主机源)。
338
+
339
+ **主机源部署(发布者 3 步)**:
340
+ 1. 构建并打包(Linux/WSL:`bash dsh-mobile-app/tools/package-release.sh`;Windows:`package-release.ps1`)——产出 `DSH-Remote-vX.Y.Z.apk`、`dsh-mobile-remote-vX.Y.Z.tgz`、`manifest.json`(共享生成器 `tools/gen-manifest.js`:version 含 build / sha256 / size / notes=CHANGELOG 最新条目全文)。
341
+ 2. 把 APK + `manifest.json` 拷入插件 `updateDir`(可用 `UPDATE_DIR=<路径> bash ...package-release.sh` 直接落位)。
342
+ 3. 插件配置 `updateDir`(缺省空 = 未配置该源):
343
+
344
+ ```yaml
345
+ - insert:
346
+ - id: mobile-remote
347
+ name: dsh-mobile-remote
348
+ config:
349
+ path: /m
350
+ authToken: <口令> # 必须开启(更新通道同样走鉴权)
351
+ updateDir: ~/.dsh/mobile-remote/update/
352
+ ```
353
+
354
+ 重启插件(或触发 patch 热重放)后,手机 App 切「主机」源即可检查/下载。产物可用 `tools/verify-update-manifest.mjs` 校验。
355
+
356
+ > 安全/兼容提醒:`authToken` 必须开启(更新通道暴露 APK 等于暴露分发面);签名变更会导致「签名不一致」被预检拦截——正式分发请用同一 keystore,确实换签需先卸载旧版(§8.3)。
357
+
295
358
  ## 9. 验收清单(已执行 ✅)
296
359
 
297
360
  - [x] `--dump-config` 含 mobile-remote 行;启动日志含 lanBridge 监听行(桌面版)
@@ -304,5 +367,6 @@ flutter build apk --release
304
367
  - [x] Flutter App:analyze 零问题 + 正式签名 release 构建 + 真机全流程测试
305
368
  - [x] 问询弹窗端到端(手机选选项→agent 收到答案 / ✕→取消 / PC 端先答两端同步)
306
369
  - [x] 权限审批弹窗端到端(允许一次→操作继续 / 拒绝→操作被拒)
370
+ - [x] **App 自动更新(v3.0.0+)**:双源检查(GitHub releases/latest + 主机 updateDir 端点)、版本判定单测(更新/不提示/防降级)、下载进度可取消、主机源 sha256 校验、签名预检(不一致取消)、FileProvider 拉起安装器、`gen-manifest.js` 产物经 `verify-update-manifest.mjs` 校验、`flutter analyze` 零问题
307
371
  - [x] 通知删除(单删/批量/清空)+ 诊断页服务探针(respondBridge/frameBridge ✅)
308
372
  - [x] 图像链路实机:发送/渲染/全屏/限额/类型纠正(v3.0.0 全套,见 CHANGELOG)
@@ -24,7 +24,7 @@
24
24
  **切换会话**:抽屉 → 会话 → 点按进入。标题与 PC 端实时同步。
25
25
  **切换工作区**:抽屉 → 工作区(或会话页顶部筛选条)→ 选择目标工作区。会话列表与首页"最近会话"只显示该工作区下的会话;新建会话默认在该工作区目录下创建。
26
26
  **看更早的消息**:对话页向上滑动——滑到顶部会自动加载更早一页,继续上滑继续加载(微信式,无断页);已到会话开头会有提示。往上翻后右下角(输入框正上方)会出现灰白色「回到底部」圆钮,点按直接回到最新消息。
27
- **收通知**:agent 完成任务/需要你回答/失败时,App 通知页出现通知;配合推送桥(企业微信机器人 / Server酱 / ntfy / Bark,配置见 docs/06 §6)可收**手机系统弹窗**——不依赖 App 常驻后台。推送默认只含事件类型与会话短码,会话内容不经过第三方通道。
27
+ **收通知**:agent 完成任务/需要你回答/失败时,App 通知页出现通知;**App 在后台但 SSE 仍在线时,审批/提问会直接用系统本地通知提醒**(Android 13+ 首次启动会请求通知权限,点通知直达该会话的待办卡,issue #46);只有 App 被系统杀掉/离线时才需要推送桥(企业微信机器人 / Server酱 / ntfy / Bark,配置见 docs/06 §6)兜底。推送默认只含事件类型与会话短码,会话内容不经过第三方通道。
28
28
  **删除通知**:通知页**长按**某条可单独删除;右上角垃圾桶 → 批量多选删除或清空全部。仅删除手机 App 里的记录,不影响电脑端。
29
29
  **审批/回答**:agent 思考途中需要你拍板时,对话页弹出卡片——按选项选择(可多选),或输入你自己的答案后「提交」;工具需要更高权限时弹出「权限请求」,选「允许一次 / 拒绝」。与电脑端同一个待办,任一端回答后两端同时消失;也可点卡片右上角 ✕ 取消(agent 按取消继续)。agent 只是等待你普通回复时(状态点橙色),直接输入消息即可。
30
30
  **新建会话**:首页「+ 新建会话」→ 选模式(标准/PTC/极简/创造)→ 可选工作目录(跨盘浏览、可新建文件夹)→ 创建。
@@ -49,6 +49,7 @@
49
49
  - agent 正在处理上一轮时新消息会**排队**(App 有提示);等上一轮完成。
50
50
  - 状态点:绿色=执行中,橙色=等待你回答(直接回复)。
51
51
  **Q4 收不到系统通知**
52
+ - 先看 App 是否只是切到后台:**SSE 仍在线时审批/提问会直接弹本地通知**(v3.2.2 起),只需确认系统给了通知权限;App 被杀或离线才需要 `pushUrls`。
52
53
  - 需部署者配置 `pushUrls`(企业微信机器人 / Server酱 / ntfy / Bark,见 docs/06 §6);检查手机通知权限。
53
54
  - **先验证通道**:设置 → 通知 → **「发送测试通知」**——逐个通道发测试消息,返回"成功几个";失败的通道对照 docs/06 检查 URL/格式/配额(Server酱每日 5 条、ntfy.sh 境内不通等)。
54
55
  **Q5 看不到实时回复**
@@ -1,6 +1,8 @@
1
1
  # 09 兼容性说明(Compatibility)
2
2
 
3
- > 版本:v3.2.0(用量与额度;机制基线 0.1.2-rc.1) · 面向:开源使用者 / 二次开发 / 多设备部署
3
+ > 版本:v3.1.5(当前仓库发布基线;用量与额度功能以当前插件/API 实现为准) · 面向:开源使用者 / 二次开发 / 多设备部署
4
+
5
+ > ⚠ **平台范围(issue #6)**:`/m/api/files*`(文件下载/上传)的 TOCTOU 防护基于 descriptor-relative 语义,**仅 Linux/macOS 可用**;**Windows 返回 `503 files-unavailable`**(安全 fail-closed,理由见 docs/04 §6)。其余功能(会话、消息、审批/问询、目录浏览、通知、推送、用量查询)不受平台限制。
4
6
 
5
7
  本文回答两个问题:**App 在哪些手机上能跑**,以及**插件在什么样的 Harness 上能跑**。
6
8
 
@@ -10,22 +12,24 @@
10
12
 
11
13
  | 组件 | 要求 |
12
14
  |---|---|
13
- | 桌面端 DSH(Harness) | **v3.1.6 起同时适配两代宿主 API:`0.1.x` 与 `0.2.x`**(见 §2.5);**`0.2.1` 起进程内网关调用强制 Peer,由 §2.6 适配(issue #34)**。机制基线:`0.1.2-rc.1` 服务包 = DSH Desktop v2.0.5(approval/request 瀑布 answerer + `$events` 远程事件双端审批 + RPC 网关,见 §2);更早的 `0.1.1-rc.2` 基线 = v2.0.2 起适配。**不按宿主版本号硬拦截**,按能力探测适配(ADR 0017) |
14
- | dsh-mobile-remote 插件 | **v3.0.0 基线;v3.2.0+ 才提供用量与额度**;`/m/api/diagnostics` 可自检 |
15
- | 手机 App(Android) | v3.0.0 基线(用量与额度需 v3.2.0+;与插件同版本 = 完美配对);不同版本可用但"谁旧谁吃亏",详见 README「版本与兼容」;Android 7.0+、64 位机型 |
16
- | 字段级兼容(v3.1.0 候选) | `reasoning`/`title` 为纯增量字段:新插件+旧 App 无影响(忽略新字段);新 App+旧插件自动回退(不渲染折叠块 / 悬浮球标题兜底短码)——任意组合均可使用 |
15
+ | 桌面端 DSH(Harness) | 插件不按宿主版本号硬拦截;可用性取决于宿主提供的 Remote RPC、事件桥等接口。诊断提供 `services` / `checks`,**并自 v3.1.6 起提供宿主能力三态报告**(`checks.hostCapabilities` 的 `ok`/`drift`/`missing` 与 `checks.hostGeneration`);仍**不返回宿主版本号**,因此不要把插件版本推断为宿主能力。见 §2.6;**`0.2.1` 起进程内网关调用强制显式 Peer,由 §2.7 适配(issue #34)** |
16
+ | dsh-mobile-remote 插件 | **v3.1.5**;`/m/api/diagnostics` 提供 `services` / `checks`,并提供 `/m/api/account-usage` |
17
+ | 手机 App(Android) | v3.1.5(与插件同版本便于配对;不同版本按 API 增量兼容);Android 7.0+、64 位机型 |
18
+ | 字段级兼容(v3.1.0) | `reasoning`/`title` 为纯增量字段:新插件+旧 App 无影响(忽略新字段);新 App+旧插件自动回退(不渲染折叠块 / 悬浮球标题兜底短码)——任意组合均可使用 |
17
19
  | 字段级兼容(v3.1.1) | `/m/api/directories` 根视图新增 `sep`(服务端路径分隔符);新插件+旧 App 忽略该字段即可(旧 App 在 WSL 上仍按 `\` 拼接,由服务端`normalizeServerPath` 归一化兜底,浏览/建夹/建会话均可用);新 App+旧插件缺少 `sep` 时按根视图推断分隔符——任意组合均可使用 |
18
- | 用量与额度(v3.2) | 新插件+新 App 通过 `/m/api/account-usage` 显示 DeepSeek/Codex/OpenCode Go;旧 App 忽略新端点。新 App+旧插件进入该入口会显示“电脑端插件版本过旧”,不影响其它功能;升级插件后无需重新配置凭据 |
19
- | 悬浮球面板用量与额度(v3.2) | 新 App+新插件:面板展开时按需展示三来源区块(金额行文字 + 配额行细条/颜色),整块可点进详情页。新 App+旧插件:区块整体降级为原有单行余额(点击仍=去充值),其余面板功能不受影响;旧 App 忽略新端点。无新增服务端契约 |
20
+ | 字段级兼容(v3.1.3) | `/m/api/diagnostics` 增加审批策略与远程事件桥状态(`checks.approvalMode` / `checks.remoteEvents`);旧版 App 忽略新增字段,不受影响 |
21
+ | 用量与额度(v3.1.5) | 新插件+新 App 通过 `/m/api/account-usage` 显示 DeepSeek/Codex/OpenCode Go;旧 App 忽略新端点。新 App+旧插件进入该入口会显示“电脑端插件版本过旧”,不影响其它功能;升级插件后无需重新配置凭据 |
22
+ | 悬浮球面板用量与额度(v3.1.5,ADR 0008) | 新 App+新插件:面板展开时按需展示三来源区块(金额行文字 + 配额行细条/颜色),整块可点进详情页。新 App+旧插件:区块整体降级为原有单行余额(点击仍=去充值),其余面板功能不受影响;旧 App 忽略新端点。无新增服务端契约 |
20
23
  | Flutter 构建环境 | Flutter 3.35+(Dart SDK ^3.13) |
21
24
 
22
25
  **声明式兼容范围(`peerDependencies`)**:`@deepseek-ai/dsh-{credentials,llm,sandbox-policy,scope}` 声明 `>=0.1.0-rc.6 <0.3.0`。宿主 `@deepseek-ai/dsh-app-boot` 的 `evaluatePluginCompatibility()` 用 `semver.satisfies(runtimeVersion, range, { includePrerelease: true })` 判定(**prerelease 参与比较**),因此该范围同时覆盖 `0.1.x`、`0.2.0-rc.*` 与 `0.2.x` 正式版,而 `0.3.0+` 会被判为不兼容。上界**只用于声明可安装边界**,功能路径仍按能力探测(ADR 0017),不按版本号做功能门禁。
23
26
 
24
27
  **快速自检**:手机 App → 设置 → 环境诊断。`services` 一节列出每个内核服务是否存在;**v3.1.3+ 看 `checks.approvalMode`**(生效策略)与 **`checks.remoteEvents`**(`true` = `$events` 双端呈现通道就绪,`false` = both 降级 mobile 或配置即 mobile/desktop);`notes` 说明当前审批策略实际语义与**任何非全绿的宿主能力**。
25
28
 
26
- **v3.1.6+ 新增两项宿主能力自检**(ADR 0017):
29
+ **v3.1.6+ 新增宿主能力自检**(ADR 0017;issue #30 起补原因码):
27
30
 
28
31
  - `checks.hostCapabilities`:逐项给出三态——`ok` / `drift` / `missing`。`drift` 是**能力语义漂移**(服务在、调用不报错,但成员形状不是插件认识的样子),这正是 0.2.0 四处变更与 0.2.1 peer 收紧的形态,旧的两分法看不见它们。**`gateway.eventsPeer`(issue #34)** 专门覆盖"网关入口都在、但进程内 Peer 来源缺失"这一种漂移,原因码为 `gateway-peer-source-missing`。
32
+ - `checks.hostCapabilityReasons`(issue #30 审查修复):**只对 `drift` 项**给出 allow-list 原因码,避免"只报状态值查不出哪一步坏了"。`settings.read` 的取值:`settings-get-threw` / `settings-describe-threw` / `settings-describe-shape-invalid` / `settings-descriptor-without-value` / `settings-has-no-read-method`。该探针与真实读取**共享严格读取器**——对插件真正读取的命名空间逐个探测,"命名空间不存在"仍算 ok,读不到才报 `drift`。
29
33
  - `checks.hostGeneration`:插件**实际走了哪条代际路径**(`jobsCaller` / `settingsRead` / `wireStreamArgs` / `eventsPeer`),排查"为什么同一功能在两台机器上表现不同"时先看这里。`eventsPeer` 取值:`createPeer`(0.2.1+)/ `operatorPeer`(0.2.0 网关兜底)/ `legacy`(旧代 3 参开流不需要 peer)/ `none`(必失败)/ `unknown`(网关服务缺失,无法判定)。
30
34
 
31
35
  `services.apiProxy` / `checks.respondBridge` / `checks.frameBridge` **已随 v3.1.6 删除**:该服务自 `0.1.2-rc.1` 起就不存在,保留它们只会让人以为有一条可用的旧代降级通道。同样地,`/m/api/respond` 在待办不在本地清单时返回 `404 respond-not-pending`(此前返回 `503` 并归咎于"内核过旧",属误导——真实原因通常是"另一端已先答"或"已超时")。
@@ -34,7 +38,7 @@
34
38
 
35
39
  ## 2. 内核耦合点与降级行为
36
40
 
37
- 插件与 Harness 的耦合分三档:**硬依赖**(缺失 = 对应功能不可用)、**软依赖**(缺失 = 功能降级)、**可选**(缺失 = 自动禁用该功能)。插件对每个依赖都做了存在性探测,**任何一项缺失都不会让插件崩溃或影响其他功能**。
41
+ 插件与 Harness 的耦合分三档:**硬依赖**(缺失 = 对应功能不可用)、**软依赖**(缺失 = 功能降级)、**可选**(缺失 = 自动禁用该功能)。插件对每个依赖都做了存在性探测,**任何一项缺失都不会让插件崩溃或影响其他功能**;能力缺失一律**明确报错**,不静默伪装成功([ADR 0001 = Issue #9](https://github.com/marktrue-ahu/dsh-mobile-remote/issues/9))。
38
42
 
39
43
  ### 2.1 服务(`ctx.get` / `ctx.inject`)
40
44
 
@@ -48,31 +52,32 @@
48
52
  | `messageFeedback` | 消息 👍/👎(与 PC 端同一份) | 软依赖 | 反馈菜单隐藏/报错 |
49
53
  | `approval` | 权限策略读取(`setPolicy` 仅当存在时调用) | 可选 | 跳过策略写入 |
50
54
  | `credentials` | DeepSeek 余额查询 | 可选 | 回退环境变量 `DEEPSEEK_API_KEY`;都没有则余额不可用 |
51
- | `userQuestions` | (间接)问询链路 | 可选 | 无弹窗(同上) |
52
- | `approval/request`·`user-questions/request` 瀑布(0.1.2-rc.1+) | Agent 作用域 Cordis 瀑布,插件 answerer 应答(0.1.2 移除了 apiProxy 后的新机制) | 软依赖 | 手机弹窗功能不受影响;该机制是问询/审批的**唯一**现行路径 |
53
- | `typertGateway` `$events` 远程事件(0.1.2-rc.1+,`both` 模式) | 插件进程内 $events 客户端:瀑布经内核转发到网关后与桌面 GUI 同收事件副本、先答生效(issue #9 双端呈现) | 可选(v3.1.3) | `approvalMode: both` 自动降级为 mobile(手机在线独占),日志与诊断 notes 说明;**0.2.1+ 还要求显式进程内 Peer(§2.6)** |
55
+ | `approval/request`·`user-questions/request` 瀑布 | Agent 作用域 Cordis 瀑布,插件 answerer 应答(当前代际的交互入口) | 软依赖 | 无瀑布宿主时问询/审批**无手机弹卡**(旧代 `apiProxy` 帧桥已于 v3.1.6 删除,不存在该降级出口;见 §2.6) |
56
+ | `typertGateway` — Remote 调用入口(`invokeRpc` / `stream`) | 模型目录、会话配置、发送、队列操作、归档、分支、目标等 RPC | 硬依赖 | 调用失败时对应 API 返回错误;**`0.2.1` 起必须显式传活 Peer(§2.7)**,否则恒返回 `gateway/internal: …reading 'signal'` |
57
+ | `typertGateway` — `$events` 远程事件桥(`openWireStream` / 结算 `dispatchRpc`) | 插件进程内 `$events` 客户端:与桌面 GUI 同收事件副本、先答生效 | 可选(v3.1.3) | 桥不可用时 `approvalMode: both` 按 `mobile` 语义降级;`checks.remoteEvents` 和运行日志可辅助诊断。**`0.2.1` 起开流与 `$events/result` 结算都必须带同一个活 Peer(§2.7)** |
58
+ | `sessionController`(`prompt`) | 冷会话恢复:不在 live agent 注册表里的持久会话发送 | 冷会话路径依赖 | 宿主若不支持该调用则发送失败;当前诊断不提供该能力的独立布尔字段 |
59
+ | `apiProxy`(旧代,0.1.1-rc.2 及更早) | 问询/审批帧桥 + 应答回写 | **弃用通道**(ADR 0001) | **两代宿主(0.1.5 / 0.2.0)内核树内均 0 命中**,插件侧的探测与帧桥代码**已于 v3.1.6 整块删除**(issue #19):保留它的唯一效果是让读者以为存在一条可用的旧代降级通道 |
54
60
 
55
- > ⚠ **approvalMode(v3.1.3,issue #9)语义**:`both`(默认)= 桌面 GUI 与手机同时弹卡、任一端先答即生效、另一端自动收卡(对齐 v3.1.1 帧桥体验;仅 0.1.2-rc.1+ 网关可用,旧宿主自动降级 mobile);`mobile` = 手机在线独占应答(v3.1.2 行为),离线交桌面 GUI;`desktop` = 一律交桌面 GUI(手机不弹卡)。手机在场时待办 120s 无应答 fail-close(`unavailable` / 问询跳过),与 v3.1.2 一致。配置于 `cordis.patch.yml` → mobile-remote 行 `config.approvalMode`,重启生效。
56
- >
57
- > ⚠ **apiProxy 帧桥已于 v3.1.6 整块删除**(issue #19):该服务自 `0.1.2-rc.1` 起已从内核移除,`0.1.5` 与 `0.2.0` 内核树内均 0 命中。保留它的唯一效果是让读者以为存在一条可用的旧代降级通道。目前**两代宿主都不存在**该服务,问询/审批一律走上表的两个瀑布 + `$events` 双端呈现。
61
+ > ⚠ **approvalMode(v3.1.3,issue #9)语义**:`both`(默认)= 桌面 GUI 与手机同时弹卡、任一端先答即生效、另一端自动收卡;`mobile` = 手机在线独占应答(v3.1.2 行为),离线交桌面 GUI;`desktop` = 一律交桌面 GUI(手机不弹卡)。手机在场时待办 120s 无应答 fail-close(`unavailable` / 问询跳过)。配置于 `cordis.patch.yml` → mobile-remote 行 `config.approvalMode`,重启生效。
58
62
 
59
63
  ### 2.2 事件与 RPC
60
64
 
61
65
  | 内核接口 | 用途 | 风险与降级 |
62
66
  |---|---|---|
63
- | `ctx.on("session/event")` | 消息流/通知聚合/上下文窗口/Conversation timeline | 事件形态随版本演进;未知类型进入通用事件卡,能力缺失时 App 回退摘要模式 |
67
+ | `ctx.on("session/event")` | 消息流/通知聚合/上下文窗口/队列即时同步/Conversation timeline | 事件形态随版本演进;未知类型进入通用事件卡,能力缺失时 App 回退摘要模式;解析异常被 try/catch 兜底 |
68
+ | `ctx.on("agent/inbox/spliced")`(经 `session/event` 送达) | 队列即时同步:内核任何生效的 inbox 变更都会 append 该会话事件,据此重推 `mobile/queue` 快照 | 事件名与载荷在当前代际与上一代一致;缺失时手机队列退回 REST 轮询兜底(不报错,仅不实时) |
64
69
  | `sessionQuery.readEvent`(可选) | Tool activity 与未知 Visible event 的按需无损详情 | 不存在时回退快照;旧历史详情明确显示不可用,不猜测重建 |
65
70
  | `sessionQuery.observeSession`(可选) | 投影感知的会话观察(轮次大纲,轮次导航) | 不存在时 `/m/api/turn-outline` 显式返回 `capability-missing`;不把「宿主不支持」误报成「该会话没有轮次」 |
66
71
  | `ctx.on("agent/status")` | 状态点(绿/橙) | 同上 |
67
- | `typertGateway`(`invokeRpc` / `stream` / `openWireStream` / `dispatchRpc`) | 全部内核 RPC 与 `$events` 双端审批通道 | **0.2.1 起必须显式传活 Peer(§2.6)**;漏传/传错时返回 `gateway/internal: …reading 'signal'`,由错误映射转成业务错误码(不再静默) |
68
- | `session.models` RPC | 模型目录(App 模型选择器) | 失败 → 目录为空,App 隐藏模型胶囊 |
72
+ | `typertGateway`(`invokeRpc` / `stream` / `openWireStream` / `dispatchRpc`) | 全部内核 RPC 与 `$events` 双端审批通道 | **`0.2.1` 起必须显式传活 Peer(§2.7)**;漏传/传错返回 `gateway/internal: …reading 'signal'`,由错误映射转成业务错误码(不再静默) |
73
+ | `session/modelCatalog` RPC | 模型目录(App 模型选择器) | 失败 → 目录为空,App 隐藏模型胶囊 |
69
74
  | `settings.update`(`agent-presets` / `permission` 命名空间) | 默认预设修改 | 与 PC 端同一写入通道;命名空间变更会导致设置失败(App 报错提示) |
70
- | `session.fork` | 消息分支 | 内核接口变化 → fork 失败提示 |
71
- | `agent.followup` / `session.cancel` | 发消息/停止 | 同上 |
75
+ | `session/prompt` | 发消息(`mode: queue \| steer`,`requestId` **必填**) | `requestId` 由插件无条件铸造;缺失会被网关以 `gateway/input-invalid` 拒绝投递 |
76
+ | `session/fork` / `session/cancel` | 消息分支 / 停止 | 内核接口变化 → 失败提示 |
72
77
 
73
- > ⚠ **子代理通知判定需要 `session.header.origin`(DSH ≥ 0.1.1-rc.2)**:通知聚合对子代理会话(`origin === "subagent"`)抑制完成/失败通知(与内核自身通知一致)。旧内核 header 无 `origin` 字段时,子代理完成/失败通知会被放行(不影响功能正确性,仅通知噪音);fork 出的独立会话(无 origin)照常通知。
78
+ > ⚠ **子代理通知判定需要 `session.header.origin`**:通知聚合对子代理会话(`origin === "subagent"`)抑制完成/失败通知(与内核自身通知一致);fork 出的独立会话(无 origin)照常通知。
74
79
  >
75
- > ⚠ **v3.1.0 候选新增字段(纯增量,无协议破坏)**:`assistant/message` 摘要的 `reasoning`(思维链正文,仅非空时下发,≤20000 字符)与 `/m/api/bootstrap` 的 `agents[*].title` / `sessions[*].title`(会话标题,空则兜底短码)。旧版 App 按 key 取值、忽略未知字段;旧版插件缺少这些字段时新版 App 自动回退(不渲染折叠块 / 悬浮球显示 id 短码)。两端任意组合均可正常使用。
80
+ > ⚠ **两层请求幂等并存(v3.1.3)**:当前代际内核新增了按 `requestId` 的幂等短路(会扫整条持久会话日志找同 `rpcId` 的 `user/message`),与插件自建的发件回执层(`/m/api/send` + `/m/api/send-receipt`)**职责不同、两层都保留**:内核负责「同一请求不重复执行」,插件负责「传输层中断后回答是否已送达」(`Connection reset by peer` 后重试不重复即由此保证)。
76
81
 
77
82
  > ⚠ **Issue #1 时间线能力协商(纯增量):** `/bootstrap` 与 SSE `hello` 的 `capabilities.eventTimeline` 声明 `detail`、`unknownEvents`、`callCorrelation` 等能力。新版 App 只在 `capabilities.eventTimeline.detail` 且事件摘要带 `detail.available` 时请求 `/event-detail`;能力是端点级声明,单事件仍可能因旧日志/离线而不可用。旧插件没有声明时继续使用已有摘要/历史路径,并把详情显示为不可用,不按插件版本号猜测能力。
78
83
  >
@@ -86,10 +91,19 @@
86
91
  - **第三方插件动作**:`ctx.mobileActions.register(...)` 注册后自动出现在 App 动作区(v0.1 契约:仅 text 字段)。
87
92
  - **自定义问答 provider / 自动审批策略**:若用户的 Harness 已经接管人类问询(如自动答题插件、审批策略改为自动放行),内核**不会产生** `question/requested` / `approval/requested` 帧——手机不弹窗是**正确行为**,不是 bug。
88
93
  - **多 profile**:插件按 profile 安装(`<profile>/node_modules/dsh-mobile-remote`),每个 profile 独立配置口令/连接。
94
+ - **内核依赖与宿主 API 需匹配**:`package.json` 声明的 peer dependency 范围是包管理器约束,不等价于对 DSH 宿主版本的运行时兼容保证。profile 若装有与宿主不匹配的依赖副本,可能导致 RPC 失败;升级后按 [安装说明](06-install-run.md) 检查并重装依赖。
95
+
96
+ ### 2.4 旧代际与弃用通道
97
+
98
+ 插件当前没有宿主版本硬拦截,也没有统一的宿主能力清单;服务可用性由具体路由和交互流程决定。宿主 API 不匹配时,相关调用可能失败或按现有降级路径处理,不应假设所有缺失能力都会映射为专用 503 错误。旧代 `apiProxy` 兼容分支**已于 v3.1.6 删除**(该服务自 `0.1.2-rc.1` 起就不存在),当前只保留 `$events` 网关路径;`dispatchRpc` 等宿主接口属于集成边界,升级 DSH 后应运行对应契约测试并在目标宿主验证问询/审批结算。
99
+
100
+ ### 2.5 验收范围(如实标注)
101
+
102
+ 自动化验证由仓库中的 Node 契约测试、Flutter 测试和 Android 构建组成;更新安装器、宿主 RPC 与系统权限流程仍需在目标 DSH 宿主和 Android 设备上人工验收。未执行的设备/宿主组合应标记为未验证,不从其他环境的结果推定兼容。
89
103
 
90
104
  ---
91
105
 
92
- ### 2.5 两代宿主 API 适配(v3.1.6,issue #19)
106
+ ### 2.6 两代宿主 API 适配(v3.1.6,issue #19 / #21)
93
107
 
94
108
  宿主 `0.2.0` 有**四处**接口与 `0.1.x` 不同。四处**全部表现为静默失败**——服务在、调用不报错、行为却已变("能力语义漂移",见 `CONTEXT.md`)。插件按**结构特征**探测代际并各走一条路径,不查版本号(ADR 0017):
95
109
 
@@ -98,34 +112,47 @@
98
112
  | 任务查询/终止的 caller | **Agent 对象**(实现内部取其 `.id`) | **SessionId 字符串** | `jobs.events.subscribe` 是否存在(两处差异同版发生) | 列表恒返回空(像"确实没有任务");终止报"任务属于另一个会话" |
99
113
  | 任务事件订阅 | `onJobsChanged` + `onJobDone` 两个回调 | 统一的 `events.subscribe(filter, listener)` | 同上 | 实时推送静默退化为轮询 |
100
114
  | 设置读取 | `get(ns)` 返回配置节 | `describe()` 返回描述符数组(取 `value`) | `typeof settings.get === "function"` | 抛出的类型错误被 `try/catch` 吞掉,提供商配置页字段凭空消失 |
101
- | 事件流打开的参数位次 | `(端点, 载荷, 取消信号)` | `(端点, 载荷, uplink, peer, 取消信号, 控制)`;**0.2.1 末尾又加 `uplinkWindow`(7 参)** | 形参个数(3 vs ≥5 判 control 位次) | 信号落进 `uplink`、真 signal 为 `undefined`,宿主在 `AbortSignal.any` 处抛错并被重试吞掉 → `approvalMode: both` 的双端呈现**永远不就绪**。peer 从 0.2.1 起必填,见 §2.6 |
115
+ | 事件流打开的参数位次 | `(端点, 载荷, 取消信号)` | `(端点, 载荷, uplink, peer, 取消信号, 控制)`;**`0.2.1` 末尾又加 `uplinkWindow`(7 参)** | 形参个数(3 vs ≥5 判 control 位次) | 信号落进 `uplink`、真 signal 为 `undefined`,宿主在 `AbortSignal.any` 处抛错并被重试吞掉 → `approvalMode: both` 的双端呈现**永远不就绪**。peer 从 `0.2.1` 起必填,见 §2.7 |
102
116
 
103
117
  **另有一类风险单独处理**:插件依赖的若干宿主成员是 **TS-private**(无契约保证)——权限预设的 `names`/`presets`/`apply`,以及工作区注册表的 `enqueueOperation`/`requireState`/`setState`(后者此前**完全无守卫**)。v3.1.6 起这些成员缺失时**显式报错**(503)或显式降级,不再让 `TypeError` 在深处被吞成"按了没反应"。**不迁移**到公开 API——那是行为改变,与本次兼容修复分开评估。
104
118
 
105
119
  **`$events` 流断开改为退避重连**:原实现"就绪后一旦断开即永久放弃",使 `approvalMode: both` 在首次断流后永久退化为手机独占,直到重启宿主——而桌面 GUI 每次开关浏览器都会断流。现语义:**就绪前**失败 = 事件源尚未注册(有界重试 ≈15s);**就绪后**断开 = 瞬时断开(3s 起退避、上限 60s、无限重连,仅插件卸载才停止)。重新就绪后退避复位。
106
120
 
121
+ **`0.2.0` 另外两处破坏(issue #21,与上述四处同类,但位于 #19 未覆盖的位置)**:
122
+
123
+ | 差异 | `0.1.x` | `0.2.x` | 插件如何判别 | 传错的后果 |
124
+ |---|---|---|---|---|
125
+ | 子代理目录 | `subagents/list` Remote 端点 | **该端点已删除**(内核树内 0 命中;只剩 `subagents/prompt` 与 `subagents/interruptByParent`) | 改用 `ctx.subagents.listChildren(parentSessionId)`——**两代都提供**,是持久目录(走 `sessionQuery.observeSession`,**不要求父会话有活跃 Agent**) | 报 `no active Remote method exports this endpoint`,「会话工具 → 子代理」整页打不开 |
126
+ | Codex 代理配置 | `settings.get(ns)` | **该方法已移除** | 跨代读取 `readSettingsSectionStrict` | **读不到配置被误判为"用户没开代理"** → 静默改走直连 `chatgpt.com` → 连接超时 → 手机看不到 Codex 余额(桌面网页端正常,因为它用自己的新 API 读同一份设置) |
127
+
128
+ **"读不到配置"与"未启用"必须分开**:`codexProxyUrl` 返回三态——用户没开代理(走直连是对的)/代理可用/**读不到宿主设置**(`unreadable`,显式失败)。把第三种当成第一种,是 Codex 余额静默消失里最隐蔽的一环。严格读取还会区分「命名空间确实不存在」与「`get()`/`describe()` 抛错、describe 形状漂移、描述符缺 `value`」——后者一律 `unreadable`。
129
+
130
+ **失败原因不外传**:`/m/api/account-usage` 的 `failures[].reason` 只给**稳定错误码 + 固定文案**(`timeout` / `dns` / `connection-refused` / `invalid-request` 等),**绝不回传异常原文**。凭据含内嵌 CR/LF 时,原生 fetch 的 Headers 校验会抛出带完整 Bearer 密钥的消息,而**路径脱敏不等于秘密脱敏**。
131
+
132
+ **子代理列表的完整性**:不能拿会话注册表当子代理目录——`sessions.list()` 只有 **live/驻留**会话,continuable 子代理结束并释放 handle 后会从中移除,而持久目录仍保留其身份。只用注册表会在父会话还活着时"成功却显示暂无子代理"。目录读不到时退回注册表并标注 `catalogDegraded`,**不让不完整列表冒充完整目录**。
133
+
107
134
  **验证范围(如实标注)**:只对 **`0.2.x` 做真实宿主端到端验证**(本机宿主即 0.2.x)。`0.1.x` 那条路径的真实宿主验不到,**靠两代假宿主夹具保护**(`test/host-compat-020.test.mjs`:按真实签名复刻两代差异,让两条分支都被实际执行到)。因此 0.1.x 一侧属于"夹具已验证、真实宿主未验证"。
108
135
 
109
136
  ---
110
137
 
111
- ### 2.6 `0.2.1`:进程内网关调用强制 Peer(issue #34)
138
+ ### 2.7 `0.2.1`:进程内网关调用强制 Peer(issue #34)
112
139
 
113
- 宿主 `0.2.1-rc.2` 把 `0.2.0` 的"peer 可省"收紧为"**每个进程内网关调用都必须显式传一个活着的 Peer**"。与 §2.5 同类:**服务在、成员在、调用不报参数错,但内部先解引用 `peer.signal`**——漏传只会得到 `gateway/internal: Cannot read properties of undefined (reading 'signal')`,或在插件的重试循环里被吞成"通道不可用"。插件按能力探测(不按版本号)选择 peer 来源:
140
+ 宿主 `0.2.1-rc.2` 把 `0.2.0` 的"peer 可省"收紧为"**每个进程内网关调用都必须显式传一个活着的 Peer**"。与 §2.6 同类:**服务在、成员在、调用不报参数错,但宿主内部先解引用 `peer.signal`**——漏传只会得到 `gateway/internal: Cannot read properties of undefined (reading 'signal')`,或在插件的重试循环里被吞成"通道不可用"。插件按能力探测(不按版本号)选择 peer 来源:
114
141
 
115
142
  | 差异 | `0.2.0` | `0.2.1-rc.2` | 插件如何判别 | 传错的后果 |
116
143
  |---|---|---|---|---|
117
144
  | 进程内 Peer 来源 | 网关自带 `operatorPeer()` 兜底(`connection.operator` 单例) | **兜底删除**;改由 `ctx.connection.createPeer({ principal, interactions })` 现造 | `typeof connection.createPeer === "function"` → `createPeer`;否则看 `gateway.operatorPeer` → `operatorPeer`;都没有 → `none` | unary RPC 恒返回 `gateway/internal`;`$events` 永不就绪、`approvalMode: both` 静默降级 mobile |
118
- | 网关四入口的 peer 实参 | 可省 | `invokeRpc(e,p,s,**peer**)` / `stream({…,**peer**})` / `openWireStream(e,p,uplink,**peer**,s,…)` / `dispatchRpc(e,p,s,**peer**)` 均要求必填 | 同上一行(`hostPeerSource`) | 同上 |
145
+ | 网关四入口的 peer 实参 | 可省 | `invokeRpc(e,p,s,**peer**)` / `stream({…,**peer**})` / `openWireStream(e,p,uplink,**peer**,s,…)` / `dispatchRpc(e,p,s,**peer**)` 均必填 | 同上一行(`hostPeerSource`) | 同上 |
119
146
  | `$events/result` 结算身份 | peer 不参与 | `assertPeerLive` + `requireInteractions` + owner + **`client.peer !== peer` 拒绝** | 插件把**开流用的同一个 Peer 实例**用于结算 | 结算被 `gateway/interactions-forbidden` 拒绝,手机答了却"没生效" |
120
147
 
121
148
  **插件做法(issue #34 实现)**:
122
149
 
123
150
  - 插件级**单例** Peer:首次真正调用网关时惰性创建(`owner` + `interactions: "answer"`),跨 RPC 与 `$events` 开流共用;**开流与结算同一个实例**(宿主按 Peer 身份匹配),插件卸载时 `dispose()`。
124
- - `connection` 服务可能晚于插件挂载(0.2.1 实测首次 `ctx.get("connection")` 为 `undefined`)——首次取不到就不缓存,交给既有 `$events` 有界重试/下次调用再建。
151
+ - `connection` 服务可能晚于插件挂载(`0.2.1` 实测首次 `ctx.get("connection")` 为 `undefined`)——首次取不到就不缓存,交给既有 `$events` 有界重试/下次调用再建;缓存的 Peer 若 `signal` 已 abort(作用域销毁)则自动重建。
125
152
  - `0.2.0`/`0.1.x` 不改变行为:没有 `createPeer` 时继续传 `undefined`,由网关兜底 / 旧代 3 参开流不需要 peer。
126
- - 诊断显式化:`checks.hostCapabilities["gateway.eventsPeer"]`(`ok`/`drift`)+ `checks.hostGeneration.eventsPeer`(`createPeer`/`operatorPeer`/`legacy`/`none`/`unknown`),`drift` 时附原因码 `gateway-peer-source-missing`;`approvalMode: both` 的降级文案不再归咎"宿主版本过低"。
153
+ - 诊断显式化:`checks.hostCapabilities["gateway.eventsPeer"]`(`ok`/`drift`,按**真实创建结果**判定而非只看工厂存在)+ `checks.hostGeneration.eventsPeer`(`createPeer`/`operatorPeer`/`legacy`/`none`/`unknown`),`drift` 时附原因码 `gateway-peer-source-missing`;创建失败写一次性告警日志;`approvalMode: both` 的降级文案不再归咎"宿主版本过低"。
127
154
 
128
- **验证范围(如实标注)**:`0.2.1-rc.2` 路径已在**隔离宿主**(`HOME`/`DSH_HOME` 独立、`--profile compat`、端口 3099)完成端到端验证:`checks.remoteEvents=true`、`POST /m/api/defaults {"permissionPreset":…}` 返回 `200 {"ok":true}`;三代的实参位次与 peer 同源/释放由 `test/host-compat-021.test.mjs` 夹具钉住;`0.2.0`/`0.1.x` 回归由既有 `test/host-compat-020.test.mjs` 保护(其 0.2.0 夹具补了真实网关的 `operatorPeer`)。
155
+ **验证范围(如实标注)**:`0.2.1-rc.2` 路径已在**隔离宿主**(`HOME`/`DSH_HOME` 独立、独立 profile、3099 端口)完成端到端验证:`hostCapabilities` 8/8 `ok`(含 `gateway.eventsPeer: ok`)、`hostGeneration.eventsPeer: createPeer`、`checks.remoteEvents: true`、`POST /m/api/defaults {"permissionPreset":"workspace-write"}` 返回 `200 {"ok":true}`。三代实参位次、peer 同源(含 `$events/result` 结算)、单例复用与释放、drift 上报由 `test/host-compat-021.test.mjs` 夹具钉住(变异验证:去掉结算 peer 或改为每次新建都会红);`0.2.0`/`0.1.x` 回归由 `test/host-compat-020.test.mjs` 保护。**生产宿主部署与真机验收不在本分支自动化范围内**,需按发布流程人工执行。
129
156
 
130
157
  ---
131
158
 
@@ -159,7 +186,7 @@ App 为 Flutter 原生 APK(`com.dsh.remote`),渲染后端为 **Impeller(
159
186
 
160
187
  | # | 问题 | 影响 | 状态/缓解 |
161
188
  |---|---|---|---|
162
- | 1 | 宿主 API 存在**静默语义漂移**(服务在、不报错、行为已变) | 升级宿主后功能"没反应",且旧的两分法探测看不见 | 能力三态报告(`checks.hostCapabilities` / `checks.hostGeneration`)+ 两代假宿主夹具;见 §2.5、§2.6 |
189
+ | 1 | 问询/审批的**出站结算**依赖网关未文档化的私有方法 `dispatchRpc` | 上游若变更该通路,问询/审批在手机在线时会失效而非降级 | 启动时探测可调用性 → 缺失则 `checks.remoteEvents=false` 且瀑布按 `mobile` 语义工作(不弹出无法应答的卡片)。见 ADR 0001 |
163
190
  | 2 | 自定义权限预设名在 App 显示「…」 | 纯展示 | 后续拉取预设清单 |
164
191
  | 3 | 国产 ROM 杀后台导致通知延迟(App 内角标) | 通知不及时 | 推送桥不受影响;App 重连后补拉 |
165
192
  | 4 | Impeller 在极老 GPU 的潜在渲染问题(未实测) | 少数旧机可能花屏 | manifest 一行回退 Skia |
@@ -172,7 +199,8 @@ App 为 Flutter 原生 APK(`com.dsh.remote`),渲染后端为 **Impeller(
172
199
  | 11 | 与 dsh-web 移动端远程(`@linxin666/dsh-remote-web-ui`)同装冲突 | 两者抢 `/m` 路由前缀(对方写死不可配),可能异常/崩溃 | 本插件 `path` 改 `/mr` 等非 `/m` 单段即可共存(App 自动适配,无需重装);详见 FAQ |
173
200
  | 12 | 手机在线时桌面端不弹审批/问询框(v3.1.2,issue #9) | 桌面用户无法在 PC 审批/应答(只能手机答或 120s fail-close) | **v3.1.3 修复**:默认 `approvalMode: both` 双端同卡、先答生效(0.1.2-rc.1+);旧宿主/历史版本可用 `approvalMode: mobile \| desktop` 明确策略 |
174
201
  | 13 | `approvalMode: both` 依赖内核 `$events` 通道 | 旧宿主(0.1.1-rc.2 及更早)无法双端同卡 | 自动降级 mobile 并写日志;诊断 `notes` 可查 |
175
- | 14 | `0.2.1-rc.2` 起进程内网关调用强制显式 Peer(§2.6) | 未适配的插件版本上:RPC 功能报 `gateway/internal`,`$events` 双端审批静默降级 | 已按能力探测适配(`connection.createPeer`);诊断 `gateway.eventsPeer` 可归因;旧代行为不变 |
202
+ | 14 | 宿主 API 存在**静默语义漂移**(服务在、调用不报错、行为却已变) | 升级宿主后功能"看起来正常但没有内容",且旧的两分法探测(存在/缺失)看不见它们 | 能力三态报告(`checks.hostCapabilities` 的 `drift` + `checks.hostGeneration`)+ 两代假宿主夹具;见 §2.6、§2.7、ADR 0017 |
203
+ | 15 | `0.2.1-rc.2` 起进程内网关调用强制显式 Peer(§2.7) | 未适配的插件版本上:RPC 功能报 `gateway/internal`,`$events` 双端审批静默降级 | 已按能力探测适配(`connection.createPeer`,开流与结算同源);诊断 `gateway.eventsPeer` 可归因;旧代行为不变 |
176
204
 
177
205
  ## 6. 内置常量与"写死"数据速查
178
206
 
@@ -312,7 +312,9 @@ CLI 契约:
312
312
 
313
313
  目的:验证公共 npm 的**真实接收能力**(体积/请求体上限、认证、dist-tag 机制、镜像可取性)——这些是本地演练覆盖不到的。试发布是**真源补充**,不能替代 §5 预检与 §6 演练。
314
314
 
315
- > **同包名 prerelease**:发布工具与运行时现已支持经授权的 `X.Y.Z-<pre>` 插件版本,载体精确配对为 `<插件完整版本>-app`。本次 alpha 示例为插件 `3.2.1-alpha.1`、App `3.2.1+24`、载体 `3.2.1-alpha.1-app`;主插件显式使用 `--tag alpha`,载体使用 `--tag app`。发布前必须确认 `latest` 已指向稳定插件;新包的首次载体发布可能初始化 `latest` 为载体,遇到这种情况必须先解决 tag 初始化方案,不能直接把它当作安全的 alpha 发布。预发布仍必须通过 §5 预检与 §6 演练,不能绕过门禁。
315
+ > **同包名 prerelease**:发布工具与运行时现已支持经授权的 `X.Y.Z-<pre>` 插件版本,载体精确配对为 `<插件完整版本>-app`。本次 alpha 示例为插件 `3.2.1-alpha.1`、App `3.2.1+24`、载体 `3.2.1-alpha.1-app`;载体固定使用 `app` tag。
316
+ >
317
+ > GitHub Actions [`publish-npm.yml`](../.github/workflows/publish-npm.yml) 通过 `pluginPublishTag` 路由主插件:稳定版使用 `latest`,预发布使用 prerelease 第一段(如 `alpha`)。`npm publish` 与显式 dist-tag 更新都使用该路由;插件发布前会快照 `latest`,并在预发布后验证它未变化。稳定版则验证 `latest` 已指向新稳定版本。此保护不会修复历史上已错误的 tag:发布 alpha 前仍须确认当前 `latest` 符合预期;若它指向载体、预发布或不存在,应暂停并经授权单独处理。预发布仍必须通过 §5 预检与 §6 演练,不能绕过门禁。
316
318
 
317
319
  ### 7.1 一次性包名探针(可选的公共源接收验证)
318
320
 
@@ -0,0 +1,143 @@
1
+ # Spec — App 自动更新检查(更新源:GitHub Releases / dsh 运行主机)
2
+
3
+ > 状态:已与 maintainer 完成 grill 确认(决策树已闭合)。实现目标分支:`feature/app-auto-update`。
4
+
5
+ ## Problem Statement
6
+
7
+ 用户通过手机 App(DSH Remote)远端操作 PC 上的 dsh。App 与插件迭代频繁(版本一致配对,见 README「版本与兼容」),但:
8
+
9
+ 1. 用户不知道何时有新版本 —— 只能去 GitHub Releases 手动翻,而国内网络访问 GitHub 慢且不稳定;
10
+ 2. 即使知道有新版本,也要自己下载 APK、手动安装,72MB 的包在手机浏览器里下载体验差;
11
+ 3. 无法利用「手机本来就连着 dsh 主机(局域网/蒲公英)」这一现成通道 —— 主机上明明已经有构建好的 APK,却没有一个能让手机拿到它的渠道;
12
+ 4. 安装时若签名与已装版本不一致,系统会直接拒绝并给出令人困惑的「应用未安装」,没有任何前置说明。
13
+
14
+ 目标:App 能**检查到更新**,并能通过**可切换的更新源**(GitHub Releases / dsh 运行主机)**下载并安装**,全程有清晰的进度、校验与签名防护。
15
+
16
+ ## Solution
17
+
18
+ 在 App「设置 → 关于」增加更新能力:
19
+
20
+ - **更新源**(单选,持久化,默认 GitHub):
21
+ - **GitHub 源**:查询本项目 GitHub Releases 最新 tag(`v3.0.0` 形式),取 `DSH-Remote-*.apk` 资产,经 HTTPS 直连下载。网络不可达时给出明确失败提示(提示可切主机源),不内置代理/镜像。
22
+ - **主机源(dsh 运行主机)**:插件新增更新目录配置与两个端点 —— `/m/api/update/manifest`(返回版本元数据)+ `/m/api/update/apk`(流式下发 APK),均走现有 `authToken` 鉴权。发布时把 APK 与 `manifest.json` 放入插件配置的目录即可。
23
+ - **检查触发**:设置页「检查更新」手动按钮(明确结果:无更新 / 有新版 / 失败原因)+ 启动时自动检查(默认开,静默:命中才提示,失败静默跳过)。
24
+ - **版本比较**:先比 `major.minor.patch`,相等再比 build 号(主机 manifest 精确到 `+N`;GitHub tag 无 build 视为 0)。同版本不提示;主机 manifest 版本**低于**本机视为异常(防降级)。
25
+ - **下载与安装**:确认弹窗(版本 / 说明 / 大小 / 来源)→ App 内下载(进度可取消)→ 主机源校验 sha256 → **签名预检**(比对自身与下载 APK 证书 SHA-256)→ 一致则拉起系统安装器;不一致或读取异常则**取消更新**并明确提示,由用户自行处理。
26
+ - **发布脚本**:新增 Linux/WSL 等价发布脚本(与现有 Windows `package-release.ps1` 等价),并让两个脚本都能生成 `manifest.json`(version 含 build / sha256 / notes)。
27
+
28
+ ## User Stories
29
+
30
+ 1. As a 手机用户, I want to 在设置里手动检查 App 是否有新版本, so that 我随时能知道是否有更新可装。
31
+ 2. As a 手机用户, I want to 打开 App 时自动静默检查一次更新, so that 我不需要记得手动去查,有新版时自然被提醒。
32
+ 3. As a 手机用户, I want to 自动检查失败时不要打扰我, so that 网络不好或 GitHub 不通时打开 App 不受影响。
33
+ 4. As a 手机用户, I want to 在设置里把更新源切换为 GitHub 或 dsh 主机, so that 我能按所在网络环境选择最可靠的获取渠道。
34
+ 5. As a 手机用户, I want to 首次使用默认走 GitHub Releases 源, so that 无需任何配置即可获得公开版本更新。
35
+ 6. As a 手机用户, I want to 通过主机源获取更新, so that 在局域网/蒲公英里能利用手机本来就连着的主机拿到 APK,速度快且不依赖 GitHub 可达性。
36
+ 7. As a 手机用户, I want to 看到「检查更新」的明确结果(没有新版本 / 有新版本 / 失败原因), so that 我知道操作是否生效,失败时知道该做什么(如切换更新源)。
37
+ 8. As a 手机用户, I want to 在检测到新版本时看到确认弹窗(版本号、更新说明、文件大小、来源), so that 我在下载前能判断是否值得更新。
38
+ 9. As a 手机用户, I want to 下载时看到进度与已下载量,并能随时取消, so that 大 APK(约 72MB)下载期间我有掌控感,误触可撤回。
39
+ 10. As a 手机用户, I want to 主机源下载完成后文件经 sha256 校验, so that 传输损坏或文件被篡改时不会安装坏包。
40
+ 11. As a 手机用户, I want to 下载的 APK 与我已装的版本签名一致才允许安装, so that 不会被「签名不一致」的系统报错搞糊涂,也不会装错来源的包。
41
+ 12. As a 手机用户, I want to 在签名不一致时看到「已取消更新」的明确提示, so that 我明白为什么没装,并能自行决定(如先卸载旧版再装)。
42
+ 13. As a 手机用户, I want to 首次安装更新时系统引导我授权「安装未知应用」, so that Android 8+ 的安装来源限制不会让我卡住。
43
+ 14. As a 手机用户, I want to 主机源 manifest 的版本号精确到 build(如 3.0.0+8), so that 同一版本内的热修(+5/+6/+7…)也能被正确识别为可更新。
44
+ 15. As a 手机用户, I want to 已装版本与最新 release 同版本(如本地 3.0.0+7 vs release v3.0.0)时不提示更新, so that 热修分支不会被误判为「需要更新」。
45
+ 16. As a 手机用户, I want to 主机源版本低于已装版本时不更新并提示异常, so that 部署失误不会导致手机被降级。
46
+ 17. As a 部署者, I want to 把构建好的 APK 和 manifest.json 放进插件配置的更新目录即可发布主机源更新, so that 局域网内用户一条命令都不用配置就能更新。
47
+ 18. As a 部署者, I want to 插件提供带鉴权的 manifest 与 APK 端点, so that 更新通道不会暴露给未授权设备。
48
+ 19. As a 部署者, I want to 在 Linux/WSL 上也能一键打包(APK + 插件 tgz + manifest.json), so that 我不依赖 Windows 的 PowerShell 发布脚本。
49
+ 20. As a 部署者, I want to Windows 发布脚本与 Linux 脚本行为等价(都能生成 manifest.json), so that 团队无论在哪台机器发布,产物一致。
50
+ 21. As a 维护者, I want to manifest.json 的 notes 自动取自 CHANGELOG 最新条目, so that 用户确认弹窗里的更新说明无需手写。
51
+ 22. As a 手机用户, I want to 版本行旁出现常驻的「有新版本」提示, so that 自动检查命中后我一进设置页就能看到。
52
+ 23. As a 手机用户, I want to 更新源切换后立即生效(检查走当前选中的源), so that 从 GitHub 切到主机源后不用重启 App。
53
+ 24. As a 手机用户, I want to GitHub 源在 latest release 里找不到 APK 资产时得到明确提示, so that 不会误报「没有更新」或卡死。
54
+ 25. As a 维护者, I want to 更新决策逻辑(版本比较、manifest 解析、防降级)是纯函数可单测, so that 版本规则的正确性能被自动化测试兜住。
55
+
56
+ ## Implementation Decisions
57
+
58
+ ### 更新源与配置
59
+
60
+ - App 设置「更新源」为**单选持久化偏好**(`github` | `host`),默认 `github`。切换立即影响下一次「检查更新」。
61
+ - 插件配置项 `updateDir`(字符串路径,默认空串 = 未配置主机源;部署者必须显式配置更新目录)。**manifest 是唯一权威**:App 只读 manifest,不枚举目录内 APK;插件不扫描目录里的历史版本。
62
+
63
+ ### 主机源端点契约(插件侧)
64
+
65
+ - `GET {path}/api/update/manifest`(现有鉴权体系):
66
+ - 200 → `{ version: "3.0.0+8", apk: "DSH-Remote-v3.0.0.apk", sha256: "<hex>", size?: <字节>, notes?: "…" }`
67
+ - `updateDir` 未配置 / 无 manifest / 文件缺失 → 对应错误码(App 明确提示「主机源未配置更新」)。
68
+ - `GET {path}/api/update/apk`(现有鉴权体系):按 manifest 的 `apk` 文件名流式下发文件字节,含 `content-length`;读取失败回 404/500。
69
+ - 两个端点都走现有 `authToken` 鉴权,与既有安全边界一致(LAN 桥现成可用)。
70
+
71
+ ### GitHub 源契约
72
+
73
+ - 版本查询:`GET api.github.com/repos/201222-L/dsh-mobile-remote/releases/latest`;解析 `tag_name`(去前缀 `v`)作为版本,build 视为 0。
74
+ - 资产选择:取 `assets[]` 中第一个名形如 `DSH-Remote-*.apk` 的条目;无 APK 资产 → 「无可用更新」明确提示。
75
+ - 下载:该资产的 `browser_download_url`,HTTPS 直连流式下载。无 sha256 校验(依赖 HTTPS + 安装器签名核对)。失败/超时 → 明确错误,建议切主机源。不内置代理/镜像。
76
+
77
+ ### 版本比较规则
78
+
79
+ - 解析 `major.minor.patch` 与可选 `+build`。比较顺序:`major` → `minor` → `patch` → `build`(缺失 build 视为 0)。
80
+ - `needUpdate(local, remote)`:remote 任一主段更大 → 更新;主段相等且 build 更大 → 更新;相等 → 不更新;**remote 低于 local → 异常态(不更新,提示「更新源版本低于当前版本」)**。
81
+ - **实现取舍(buildSet)**:远端版本串未显式带 `+build` 时(GitHub tag 必然如此),主段相等即判定「不提示」——否则本地 `3.0.0+7` vs release tag `v3.0.0` 会因 build 补 0 被误判成降级异常,违背 US15。即:build 比较仅在远端显式带 `+build` 时参与;远端显式 build 更小仍按防降级处理。
82
+
83
+ ### 下载、校验与安装
84
+
85
+ - 确认弹窗:版本 / notes / APK 大小 / 来源名(GitHub / 主机 · 不区分文案风格,均写来源名)。
86
+ - 下载:App 内 http 流式下载到私有缓存目录,弹窗展示进度(百分比 + 已下载量),可取消(取消即清理文件)。
87
+ - sha256 校验:**仅主机源**(manifest 提供 sha256),不一致 → 失败提示,不进入安装。
88
+ - 签名预检(下载后、拉起安装器前):读取本机已装版本证书 SHA-256 与下载 APK 证书 SHA-256 比对:
89
+ - 一致 → 拉起系统安装器(`ACTION_VIEW` + FileProvider,`requestLegacyExternalStorage` 不引入,用应用私有目录);
90
+ - 不一致 → 弹「签名不一致,已取消更新」,不进入安装;
91
+ - 任一签名读取异常(含无法解析下载 APK 签名)→ 保守**取消**并提示。
92
+ - Android 权限与兼容:
93
+ - `AndroidManifest.xml` 增加 `REQUEST_INSTALL_PACKAGES`;
94
+ - 配置 FileProvider(含 `file_paths.xml`,指向缓存/私有文件目录);
95
+ - Android 8+ 首次安装触发系统「安装未知应用」授权引导;
96
+ - `build.gradle.kts` release 显式 `v1SigningEnabled true`(v1+v2 双签名),使 `getPackageArchiveInfo` 能可靠读取 APK 签名做预检(纯本地分发,无平台副作用)。
97
+ - 签名一致性边界:**依赖系统安装器做最终签名核对**(双保险);预检只做前置防呆。
98
+
99
+ ### 交互与 UI
100
+
101
+ - 设置「关于」卡片新增:
102
+ - 「更新源」行:host / GitHub 单选(默认 GitHub);
103
+ - 「检查更新」行:手动触发,含「检查中… / 下载中…」状态;
104
+ - 版本行旁「● 有新版本」常驻提示(自动检查命中后显示,直到更新或版本变更)。
105
+ - 自动检查:应用启动后(连接成功场景)静默执行一次;命中 → 弹确认窗;未命中/失败 → 静默。
106
+ - 手动检查:无论结果都明确反馈(「已是最新版本」/ 确认窗 / 失败原因 + 可切源的引导)。
107
+
108
+ ### 发布脚本(双端等价)
109
+
110
+ - 新增 Linux/WSL 等价脚本(bash),行为与 Windows `package-release.ps1` 对齐:
111
+ 1. 从 `pubspec.yaml` 解析版本(`X.Y.Z+BUILD`,build 取自 `+N`);
112
+ 2. 拷贝已构建 `app-release.apk` → `dist/DSH-Remote-vX.Y.Z.apk`;
113
+ 3. `npm pack` 插件 → `dist/dsh-mobile-remote-vX.Y.Z.tgz`;
114
+ 4. 生成 `manifest.json`:`version`(含 build)、`apk` 文件名、`sha256`(对 APK 计算)、`size`(APK 字节数,确认弹窗展示用)、`notes`(取 CHANGELOG 最新条目全文);
115
+ 5. (可选参数)把 APK + manifest 拷贝到插件 `updateDir`。
116
+ - manifest 生成收敛为**共享生成器** `tools/gen-manifest.js`(Node),两个发布脚本都调用之——JSON 合法性(notes 特殊字符自动转义)与无 BOM 编码由同一份代码保证,双端产出严格等价。
117
+ - 同步扩展 Windows ps1,使两脚本产出等价(含 manifest 生成)。
118
+
119
+ ## Testing Decisions
120
+
121
+ - **主 seam(自动化必达)**:更新决策逻辑做成 **App 端纯 Dart 模块**(无 I/O:manifest / GitHub release JSON 解析、版本比较(semver + build)、需要更新判定、防降级判定、下载 URL 构造、签名校验的输入组装),在**现有 `dsh-mobile-app/test/` 单测 seam** 下用 `flutter test` 覆盖(prior art:`api_logic_test.dart`、`md_link_test.dart` —— 同款「纯逻辑模块 + 单测」形态;本轮已验证 `flutter analyze` 无问题、`flutter test` 全量通过)。
122
+ - 测例方向:`3.0.0+7` vs tag `v3.0.0` → 不更新;`3.0.0+8` vs `3.0.0+7` → 更新;`3.1.0` vs `3.0.0+999` → 更新;remote 低于 local → 异常不更新;manifest 缺 build / 非法版本 → 容错解析;GitHub 资产选择(含无 APK 资产)。
123
+ - **副 seam(已落地)**:发布脚本生成的 `manifest.json` 由同款 verify 脚本校验(`dsh-mobile-app/tools/verify-update-manifest.mjs`:version 含 build、sha256/size 与 APK 实算一致、notes 非空、apk 文件存在);服务端 manifest 读取 helper(`readUpdateManifest`)如需进一步提取式单测沿用 `tools/verify-*.mjs` 形态(prior art:`verify-image-sniff.mjs`)。
124
+ - **明确不自动化(人工验收)**:真实 APK 下载 + 签名预检 + 系统安装器拉起 + Android 8+/11+ 授权引导 —— 需要真机/模拟器,列为发布前人工验收清单(对应 docs/06 §9 风格)。
125
+ - 不考虑引入:widget 测试脚手架、`integration_test` 新 harness(不新增 seam 层)。
126
+
127
+ ## Out of Scope
128
+
129
+ - iOS 端任何形态的更新检查(当前 App 仅 Android)。
130
+ - 周期性/定时推送式更新检查(仅启动时静默 + 手动)。
131
+ - GitHub 镜像 / 代理 / 预置中转域名支持(保持直连 + 明确失败提示)。
132
+ - 自动静默安装(更新必须经用户确认弹窗,不后台装)。
133
+ - 目录内多版本 APK 枚举与历史版本清理(manifest 唯一权威,滞留旧文件由部署者自行清理)。
134
+ - 内测渠道 / 灰度 / Play 商店发布流程。
135
+ - 已装版本与下载 APK 的**包名**核对之外的加固(签名比对已覆盖主要风险)。
136
+ - 对 dsh 内核侧的任何改动(纯插件 + App + 脚本)。
137
+
138
+ ## Further Notes
139
+
140
+ - **签名一致性**:本机 debug 签名构建的 release 与正式 keystore 签名不同;换签名需先卸载(docs/06 §8.3 既有约定)。更新签名预检正是为了把这种「签名不一致」提前到下载后、安装前说清楚,而不是让用户在系统安装器报错时困惑。
141
+ - **宿主源部署形态**:发布机执行发布脚本后,把 `dist/` 产物拷入主机 `updateDir`(或脚本直接带参数写入);插件重启后主机源即可用。局域网/蒲公英场景手机直达主机,无 GitHub 依赖。
142
+ - **manifest 唯一权威的另一面**:`updateDir` 里旧 APK 不会被 App 枚举到;部署者负责保持目录整洁(文档提示)。
143
+ - **与既有验收清单的关系**:本 feature 的人工验收项并入 docs/06 §9 验收清单(新增「App 更新:双源检查 / 下载 / 签名预检 / 安装」一组)。