dsh-cindy-host 0.1.20
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.
- package/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# DSH Cindy Host
|
|
2
|
+
|
|
3
|
+
A DSH-to-Cindy host adapter. The console demo projects a DSH conversation list and safe lifecycle/status activity to a replaceable sink; the DSH bundle additionally carries the Cindy session, the DeviceLink relay, and the 「Cindy 手机连接」 settings card that lets the Cindy mobile client reach this Host.
|
|
4
|
+
|
|
5
|
+
## Quick Start(从 npm 安装)
|
|
6
|
+
|
|
7
|
+
需要 **Node.js 22 或更高版本**。下面将插件安装到默认 Web profile:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# 从 npm 安装当前发布版本
|
|
11
|
+
bunx @deepseek-ai/dsh plugin --profile web add dsh-cindy-host
|
|
12
|
+
|
|
13
|
+
# pnpm 10 需要显式批准 keytar 的构建脚本
|
|
14
|
+
bunx @deepseek-ai/dsh plugin --profile web approve-builds
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
在批准列表中选择 `keytar`,然后**重启 DSH Web**。重启后可在 **设置 → Plugins** 找到「Cindy 手机连接」:使用手机号和验证码登录,再开启手机连接。
|
|
18
|
+
|
|
19
|
+
有关 `keytar` 的编译环境、失败降级与替代配置,请见[安装与原生模块说明](#安装与原生模块说明)。
|
|
20
|
+
|
|
21
|
+
## Console demo scope
|
|
22
|
+
|
|
23
|
+
- Conversation list: opaque session ID, title, phase, update time.
|
|
24
|
+
- Activity feed: added, removed, and status summaries only.
|
|
25
|
+
- Safe recovery: stream failure emits a generic stale marker and refreshes the list baseline.
|
|
26
|
+
|
|
27
|
+
The projection deliberately excludes message bodies, prompt input, tool calls/results, approval/question payloads, file paths/content, credentials, and command sending. The Cindy transport consumes only what the projection already produced.
|
|
28
|
+
|
|
29
|
+
## Run
|
|
30
|
+
|
|
31
|
+
Requires Node 22 or newer. There are no package dependencies for the fixture demo.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm test
|
|
35
|
+
npm run demo
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Cindy login MVP
|
|
39
|
+
|
|
40
|
+
The Host can request a verification code and exchange it for a Cindy session. It never prints tokens; the host runtime stores a session in the operating-system credential store for refresh. It defaults to the China mainland Cindy auth endpoint (`https://auth.cindy.com.cn`):
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm run login
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`CINDY_AUTH_BASE_URL` remains an optional override for an explicit non-mainland environment.
|
|
47
|
+
|
|
48
|
+
The command displays only the newly generated Host device handle on success, and persists the session so the settings card picks up the same login.
|
|
49
|
+
|
|
50
|
+
## Cindy phone link (Settings → Cindy 手机连接)
|
|
51
|
+
|
|
52
|
+
The bundle adds its own page, **设置 → Cindy 手机连接**, registered into the
|
|
53
|
+
shell's `settings.section` slot — a feature owning its settings page is the
|
|
54
|
+
documented seat for this, so no shell change was needed. Turning **连接手机** on
|
|
55
|
+
signs this Host in to Cindy, joins the DeviceLink relay, and makes the Host show
|
|
56
|
+
up in the Cindy mobile client's device list; turning it off closes the socket,
|
|
57
|
+
stops the projection, and forgets every device and subscriber.
|
|
58
|
+
|
|
59
|
+
The page shows the five states the Host can be in (未连接 / 登录中 / 等待手机连接 /
|
|
60
|
+
已连接 / 连接失败), collects the Cindy verification-code login when no session
|
|
61
|
+
exists yet, and lists the connected devices with their name and `deviceId`.
|
|
62
|
+
|
|
63
|
+
There is **no QR code**: Cindy has no pairing protocol, and its own "connect your
|
|
64
|
+
phone" QR is just the regional app-download page. The four protocol questions and
|
|
65
|
+
their source evidence are recorded in
|
|
66
|
+
[Cindy phone link](doc/cindy-phone-link.md), together with the design.
|
|
67
|
+
|
|
68
|
+
## 手机大文件下载(0.1.10 起)
|
|
69
|
+
|
|
70
|
+
原 Export 入口按 inline → peer → OSS 回退。**0.1.11 起 OSS 兜底也支持最多2 GiB(含边界)**,流式上传,不再受旧512 MiB上限限制;约1.3 GB真实文件已经OSS下载到手机。实际手机P2P连接问题仍待定位,本次不宣称直连已修复。需更新/重启Host后生效,不含手机上传或断点续传。验证于DSH 0.1.5-rc.2 / Node 24.12.0;Node要求≥22,keytar为原生依赖。详见[文件传输说明](doc/file-transfer.md)。
|
|
71
|
+
|
|
72
|
+
## 手机任务图标(0.1.12 起)
|
|
73
|
+
|
|
74
|
+
DSH Host 仍只有一种执行 harness;手机任务行把 Cindy 的三种 `agentKind` 用作模型图标别名:DeepSeek 来源任意模型显示 Pi,GPT 模型显示 Codex,其他来源已知模型显示 Claude;**0.1.13 起**无法确认来源的图标改为 Claude,Pi 只保留给确认是 DeepSeek 的来源(0.1.12 的未知来源仍显示 Pi)。手机新建时选择的 agent 平台不会更换 DSH harness,实际模型和来源仍以 DSH 会话选择为准;手机的 agent 筛选、标签和最近会话默认项也会跟随图标 kind。手机端无需更新,但 Host 安装新版后必须重启才生效。
|
|
75
|
+
|
|
76
|
+
## Safe isolated DSH smoke test
|
|
77
|
+
|
|
78
|
+
Do **not** add this package to the DSH profile you use every day. Use a disposable `DSH_HOME`; it gives the smoke profile its own bundles, settings, and credentials. These PowerShell commands run from this repository:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
# Every following dsh command in this terminal uses only this disposable home.
|
|
82
|
+
$env:DSH_HOME = Join-Path (Get-Location) '.sandbox/dsh-home'
|
|
83
|
+
|
|
84
|
+
# Create an isolated Web profile without starting a server, then add this local bundle.
|
|
85
|
+
dsh --profile cindy-smoke --from-default-profile web --dump-config
|
|
86
|
+
dsh plugin --profile cindy-smoke add .
|
|
87
|
+
|
|
88
|
+
# Gate 1: parse and compose the profile only. Do not continue if this fails.
|
|
89
|
+
dsh --profile cindy-smoke --dump-config
|
|
90
|
+
|
|
91
|
+
# Gate 2: start a separate UI without opening a browser or sharing port 3080.
|
|
92
|
+
# transportEnabled is false by default, so this cannot log in or connect to Cindy.
|
|
93
|
+
dsh --profile cindy-smoke --no-open --port 3081
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Run `npm test` before Gate 1. A clean Gate 2 must show a running DSH host on port 3081 and no Cindy authentication prompt. Stop it with `Ctrl+C`; removing `.sandbox/dsh-home` removes the whole test profile and its credentials. Do not enable `transportEnabled` until the mount, RPC, and lifecycle smoke checks are clean.
|
|
97
|
+
|
|
98
|
+
**Never start port 3081 with `--profile web` and the normal `DSH_HOME`.** A second process then shares the production Host's DeviceLink identity; the relay may route a phone's `maker:input:enqueue` to the wrong process, where resuming a session owned by the 3080 process fails with `SessionAlreadyOwnedError` and appears as `[INTERNAL] DSH Host failed to serve this channel`. It can also make the phone's queue/sync status oscillate. Use only the isolated `cindy-smoke` profile above, and check both `/api/dsh-cindy-host/status` endpoints for duplicate `status.host.deviceId` before testing phone sends.
|
|
99
|
+
|
|
100
|
+
With Gate 2 running, two Host surfaces answer over loopback:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
GET /api/dsh-cindy-host/status # the card's only source of truth
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The card itself is served as the `dsh-cindy-host` entry of
|
|
107
|
+
`window.__DSH_BOOT__`; `npm test` renders it with React so its markup and state
|
|
108
|
+
rules are covered without a browser.
|
|
109
|
+
|
|
110
|
+
## DSH API compatibility boundary
|
|
111
|
+
|
|
112
|
+
`DshHostSource` has contract tests for the older public `InProcessApiClient(toFetchHandler(ctx.apiProxy))` adapter. Current DSH Web (`0.1.5-rc.2`) does not mount `apiProxy`; it exposes Typert remotes instead. Therefore this bundle mounts safely without a live DSH source when `apiProxy` is absent, and the isolated smoke test validates composition/lifecycle only.
|
|
113
|
+
|
|
114
|
+
**DSH's own packages are `peerDependencies`, never `dependencies`** — the host supplies them, and a plugin that installs its own copy drags a whole DSH generation into the profile and stops the profile from booting. That is what `0.1.1` did; see [`doc/publishing.md`](doc/publishing.md) §5.4 and §5.6 for the measured failure and the rule.
|
|
115
|
+
|
|
116
|
+
## 安装与原生模块说明
|
|
117
|
+
|
|
118
|
+
[Quick Start](#quick-start从-npm-安装) 已给出从 npm 安装、批准构建脚本和重启的最短路径。本节说明其中 `keytar` 构建步骤的原因与替代方案。
|
|
119
|
+
|
|
120
|
+
### 为什么要批准 `keytar`?
|
|
121
|
+
|
|
122
|
+
`keytar` 将 Cindy 登录会话保存在系统凭据库(Windows 凭据管理器、macOS Keychain 或 Linux Secret Service),因此需要下载预编译二进制;没有适配的预编译包时,会回退到本机编译。对 pnpm 10 而言,这一步需要显式批准构建脚本。
|
|
123
|
+
|
|
124
|
+
- Windows 通常需要 **VS Build Tools**(含 C++ 工作负载);macOS 需要 Xcode Command Line Tools;Linux 需要 `libsecret-1-dev` 等构建依赖。
|
|
125
|
+
- 如果未批准或编译失败,插件仍会安装并挂载;仅 Cindy 登录和凭据存储不可用,并会给出明确错误。
|
|
126
|
+
- 不要使用 `0.1.1` 或更早版本的 tarball;它们会向 profile 装入不兼容的一代 DSH 依赖。请始终从 npm 安装当前版本。
|
|
127
|
+
|
|
128
|
+
发布、干净 profile 验证和替代的 `onlyBuiltDependencies` 配置见 [`doc/publishing.md`](doc/publishing.md) §5.2。
|
|
129
|
+
|
|
130
|
+
## 发布指南
|
|
131
|
+
|
|
132
|
+
- [doc/publishing.md](doc/publishing.md) —— **插件发布**:把本插件做成别人/别的 profile 能装上的 npm 包(bundle 三要素、三种消费方式、包内容白名单、干净 profile 验证、撤版)。
|
|
133
|
+
- [doc/releasing.md](doc/releasing.md) —— **改动上线**:门禁 → claw 留证 → 脱敏 → 部署(重启才是生效点) → 回滚。
|
|
134
|
+
|
|
135
|
+
## 仓库治理
|
|
136
|
+
|
|
137
|
+
分支 `main`,远端 `origin` = <https://github.com/chanyuenpang/dsh-cindy-host>(公开)。`v0.1.1`
|
|
138
|
+
已打 tag、推远端,并以 GitHub Release 形式附 tarball 分发(**不进 npm registry**,见
|
|
139
|
+
[`doc/publishing.md`](doc/publishing.md) §5.5)。
|
|
140
|
+
|
|
141
|
+
**入库的内容**:`src/`(插件与运行时)、`lib/`(设置页客户端入口)、`test/`(单测)、
|
|
142
|
+
`tools/`(验收脚本与活体探针)、`doc/`、`package.json` / `package-lock.json`、
|
|
143
|
+
`cordis.patch.yml`,以及 claw 的**项目记忆** `.claw/{tasks,adr,truth}/` 与 `.claw/project.json`。
|
|
144
|
+
|
|
145
|
+
**不入库的内容**(见 `.gitignore`):
|
|
146
|
+
|
|
147
|
+
- `node_modules/`;
|
|
148
|
+
- `.sandbox/` —— 沙箱用的 DSH home,带它自己的凭据与会话存储。
|
|
149
|
+
注意:`.sandbox/dsh-home/profiles/<profile>/node_modules/dsh-cindy-host` 是指向本仓库的
|
|
150
|
+
**junction**(不是副本),所以任何**跟随链接递归**的工具(`git status --ignored`、部分索引器)
|
|
151
|
+
会绕圈并报 `Filename too long`。普通 `git add/status` 因为整目录被忽略不会进去;不要据此
|
|
152
|
+
「清理」出一个递归副本,磁盘上并没有重复的仓库。
|
|
153
|
+
- `.claw/runtime/`、`.claw/logs/`、`.claw/memory.sqlite*` —— claw 守护进程的运行时状态;
|
|
154
|
+
- 仓库根目录的一次性探测残留(`.claw-*.txt|log|mjs|cjs|report`)与 agent 生成的图片。
|
|
155
|
+
|
|
156
|
+
**验证入口**:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm test # 单测(463 项)
|
|
160
|
+
npm run audit:channels # 通道判定表(served 52 / declined 140 / unclassified 0)
|
|
161
|
+
node tools/acceptance.mjs --base http://127.0.0.1:3080 --with-prompts # 端到端(105 项)
|
|
162
|
+
npm run verify # 上面三者的串行组合
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`tools/probe-*.mjs` 是活体探针(分页耗时、媒体缩图、todo 投影、会话日志、历史视图形状),
|
|
166
|
+
它们只读本地 DSH 状态,用来把「手机上看到什么」量化成数字。
|
|
167
|
+
|
|
168
|
+
## Documents
|
|
169
|
+
|
|
170
|
+
- [Research](doc/research.md)
|
|
171
|
+
- [Architecture](doc/architecture.md)
|
|
172
|
+
- [Development plan](doc/development-plan.md)
|
|
173
|
+
- [Cindy phone link](doc/cindy-phone-link.md)
|
|
174
|
+
- [Projection read probe](doc/projection-read-probe.md)
|
|
175
|
+
|
|
176
|
+
## Next boundary
|
|
177
|
+
|
|
178
|
+
Cindy device-link integration requires a separate design and security review. It must not be added by swapping the console sink without defining identity, opt-in, topic contracts, and privacy policy.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Architecture: Read-only DSH Projection
|
|
2
|
+
|
|
3
|
+
## Owners
|
|
4
|
+
- **DSH runtime** owns sessions and events.
|
|
5
|
+
- **DshReadClient** owns conversion from the DSH public host API to a narrow read interface.
|
|
6
|
+
- **ActivityProjector** owns allowlisting and conversion to safe activity summaries.
|
|
7
|
+
- **ProjectionSink** owns presentation only; it is not a second session store.
|
|
8
|
+
|
|
9
|
+
## Data flow
|
|
10
|
+
1. The adapter reads a session-list baseline.
|
|
11
|
+
2. It publishes a minimal `ConversationRow` snapshot to the sink.
|
|
12
|
+
3. It opens the host/mux streams.
|
|
13
|
+
4. Only allowlisted lifecycle/status frames become `ActivityItem` records.
|
|
14
|
+
5. An unknown frame or stream failure marks the projection stale, drops incremental state, and performs a fresh baseline read.
|
|
15
|
+
|
|
16
|
+
## DTO boundary
|
|
17
|
+
`ConversationRow = { sessionId, title, phase, updatedAt }`
|
|
18
|
+
|
|
19
|
+
`ActivityItem = { sessionId, sequence, kind, phase, occurredAt }`
|
|
20
|
+
|
|
21
|
+
No DTO contains message bodies, tool arguments/results, approval/question payloads, working-directory paths, file content, credentials, transport identities, or raw errors. Unknown event kinds are discarded.
|
|
22
|
+
|
|
23
|
+
## Mounting seam
|
|
24
|
+
The production mount will construct an `InProcessApiClient` over `ctx.apiProxy` and pass it into `DshReadClient`. The demo uses a fixture source with the same narrow interface. This keeps core projection tests independent of DSH process boot.
|
|
25
|
+
|
|
26
|
+
## Planned modules
|
|
27
|
+
- `src/contracts`: DTO validation and event allowlist.
|
|
28
|
+
- `src/projection`: baseline, incremental updates, stale/rebaseline state machine.
|
|
29
|
+
- `src/dsh-source`: live DSH host seam.
|
|
30
|
+
- `src/fixture-source`: deterministic executable demo input.
|
|
31
|
+
- `src/console-sink`: demo presentation.
|
|
32
|
+
|
|
33
|
+
## Acceptance
|
|
34
|
+
The executable demo displays two conversation rows and safe lifecycle summaries. Tests prove sensitive payload fields never enter the sink and a stream failure triggers rebaseline.
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Cindy 安卓包验证交接:device-link「对端方向静默」修复
|
|
2
|
+
|
|
3
|
+
> 状态(2026-09-18 16:12):**APK 已构建完成并校验,放在下载文件夹**;剩下的只有真机验证。
|
|
4
|
+
> 背景结论见 [`.claw/truth/dsh-cindy-host-mobile-resume-limitation.md`](../.claw/truth/dsh-cindy-host-mobile-resume-limitation.md);
|
|
5
|
+
> 上游 issue:[makecindy/cindy#4634](https://github.com/makecindy/cindy/issues/4634)(含本次补充的证据评论)。
|
|
6
|
+
|
|
7
|
+
## 一、要验证什么
|
|
8
|
+
|
|
9
|
+
**现象**(2026-09-17 实测):安卓版 Cindy 在「挑照片 / 短后台 → 回前台」后,device-link 的**对端→手机方向**整条哑掉,
|
|
10
|
+
手机每秒重试同一个读、**2.5 分钟不恢复**,只有**重启 App**才恢复。
|
|
11
|
+
|
|
12
|
+
**根因**(已定位):手机端所有活性判据量的都是「手机↔relay」——`pong` 由 **relay 自己应答**
|
|
13
|
+
(`scripts/device-link/relayFixture.ts:85-86`,全仓没有任何一端发 `pong`),所以 relay 每 10s 的 pong 让手机那条
|
|
14
|
+
「20s 内任意入站帧就清零」的半开检测(`packages/device-link/src/client.ts:1913-1945`,关键在 `:1918-1919`)
|
|
15
|
+
**永久失明**;唯一会主动探测并重建的 `notifyNetworkChanged` 又被 `:860` 的「任意入站帧即证明可达」早退挡住。
|
|
16
|
+
|
|
17
|
+
**修复**(分支 `fix/mobile-peer-silence-probe`,commit `eadedb8d5`,3 个文件,+292/−2):新增 opt-in
|
|
18
|
+
`peerSilenceProbeMs`——只有带 `src` 的 routed 帧算「对端还在说话」;同一 peer **连续两次**请求超时且对端持续静默
|
|
19
|
+
→ `restartConnection`;relay 已判 `DEVICE_OFFLINE` 时不介入;同窗口限速一次;回前台探针改以对端活性为可达
|
|
20
|
+
证据,并要求已存在超时证据(避免把长执行通道打成失败)。手机端开启 `20_000ms`。
|
|
21
|
+
|
|
22
|
+
**判定标准**
|
|
23
|
+
|
|
24
|
+
| | 修复前 | 修复后(期望) |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| 日志 | 反复 `device-link request timeout`,**没有** `peer silence detected` | 两次 `device-link request timeout` 后出现 **`peer silence detected, forcing reconnect (peer=…, silentForMs=…, pending=…, timeouts=2)`** |
|
|
27
|
+
| 行为 | 界面一直转圈,杀 App 才恢复 | 新 socket + `link-open`/`link-accept`,**~30 秒内自愈**,不需要杀 App |
|
|
28
|
+
|
|
29
|
+
## 二、现在的状态:APK 已打好
|
|
30
|
+
|
|
31
|
+
| 项 | 值 |
|
|
32
|
+
|---|---|
|
|
33
|
+
| 文件 | `C:\Users\chany\Downloads\Cindy-Verify-0.1.0-arm64-v8a-308b4badc.apk`(77.2 MB) |
|
|
34
|
+
| 包名 | `com.xd.cindycn.verify`(与商店版 `com.xd.cindycn` **可同机并存**,不必卸载重登) |
|
|
35
|
+
| 桌面名 | **Cindy Verify**(刻意改的:`android/app/src/main/res/values/strings.xml` 的 `app_name`;避免与商店版同名分不清) |
|
|
36
|
+
| 版本 | versionName 0.1.0 / versionCode 1;compileSdk 36 / targetSdk 36 |
|
|
37
|
+
| ABI | 仅 `arm64-v8a` |
|
|
38
|
+
| 签名 | Android Debug keystore,APK Signature Scheme v2(自签测试包;同签名同包名可直接覆盖安装) |
|
|
39
|
+
| SHA256 | `8F1B73D9E8C1E4F541D5ED9F127F561CCA59CA26F185D26FF9B201E09103CA5A` |
|
|
40
|
+
| JS | 已内联 `assets/index.android.bundle`(17.3 MB)→ **真机不需要 Metro、不需要 adb reverse** |
|
|
41
|
+
|
|
42
|
+
> **版本要点(2026-09-18 16:32,commit `308b4badc`)**:第一版(commit `eadedb8d5`)的触发条件
|
|
43
|
+
> 要求「同一 peer 连续两次请求超时」,而回前台后 App 往往**只发一轮请求**,连续计数永远到不了 2;
|
|
44
|
+
> 回前台探针那条路又额外要求「已存在超时证据」——两条路都不会重建,所以装了第一版也**不会恢复**。
|
|
45
|
+
> 本版改为:**探针超时本身即证据**(提示之后一个对端帧都没来)+「有**短超时**业务请求在等回包」
|
|
46
|
+
> 就重建;长执行通道靠"它的超时是分钟级"排除。已从包内回读确认新逻辑在场。
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
**已核对包内容**(从 APK 里回读,不是推测):
|
|
50
|
+
- 修复代码在包里:`peerSilenceProbeMs` / `peerSilenceProbeEnabled` / `shouldRebuildForPeerSilence` /
|
|
51
|
+
`peer silence detected` / `without peer silence evidence` 各命中 1 次;
|
|
52
|
+
- 区域正确(**cn**):bundle 内含 `https://hotfix.cindy.com.cn/cindy`(`cindy.com.cn` 共 14 处、
|
|
53
|
+
`cindy.app` 17 处),所以它能连到与本机 Host 同一套 relay(`wss://device-link.cindy.com.cn`);
|
|
54
|
+
- `aapt2 dump badging` 与 `apksigner verify` 均通过。
|
|
55
|
+
|
|
56
|
+
**安装**:手机开 USB 调试后
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
adb devices
|
|
60
|
+
adb install -r C:\Users\chany\Downloads\Cindy-Verify-0.1.0-arm64-peer-silence-fix-eadedb8d5.apk
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
或直接把 apk 传到手机点安装。装好后桌面上会出现 **Cindy Verify**(与原来的 Cindy 并存)。
|
|
64
|
+
|
|
65
|
+
## 三、真机验证步骤
|
|
66
|
+
|
|
67
|
+
1. 打开 **Cindy Verify** → 用同一账号登录(微信/Google 登录可能因包名+签名校验失败,改用手机号/邮箱);
|
|
68
|
+
2. 设备列表里应出现本机 Host(DSH 那台),进会话;
|
|
69
|
+
3. **复现**:挑一张照片发出去(系统选择器必然把 App 切到后台)→ 回前台,看界面是否卡住;
|
|
70
|
+
4. 同时抓日志:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
adb logcat -s ReactNativeJS:V | Select-String -Pattern 'device-link|peer silence|request timeout'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**判定**:
|
|
77
|
+
- 出现 `peer silence detected, forcing reconnect (peer=…, silentForMs=…, pending=…, timeouts=2)`
|
|
78
|
+
且随后 ~30 秒内界面恢复 ⇒ **修复生效**;
|
|
79
|
+
- 只有 `device-link request timeout` 反复出现、没有 `peer silence detected` ⇒ 判据没被满足,带回
|
|
80
|
+
`silentForMs` / `pending` / `timeouts` 的实际值;
|
|
81
|
+
- 仍 2.5 分钟不恢复且必须杀 App ⇒ 与修复前一致;若同时看到 `heartbeat lost, forcing reconnect`
|
|
82
|
+
(socket 真被判死重建过),说明缺口在重建后的 link/订阅恢复路径,方向要换。
|
|
83
|
+
|
|
84
|
+
**复现不出来时**:把这段时间的 `device-link` 日志整段带回,再区分「没触发」与「触发了但没自愈」。
|
|
85
|
+
|
|
86
|
+
## 四、环境:本机已装好
|
|
87
|
+
|
|
88
|
+
| 组件 | 位置 | 版本 / 说明 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| JDK | `G:\dev\jdk` | Temurin 17.0.20.1 |
|
|
91
|
+
| Android SDK | `G:\dev\android-sdk` | platform-tools(adb 37.0.1)、platforms/android-36、build-tools **35.0.0 与 36.0.0**(Expo 实际用 36)、cmake/3.22.1、ndk/**27.1.12297006 与 27.0.12077973**(RN core 用前者、`expo-updates` 要后者) |
|
|
92
|
+
| Gradle 分发包 | `G:\dev\android-tools\gradle-9.3.1-bin.zip` | wrapper 指向本地文件(见坑 2) |
|
|
93
|
+
| 用户级 Gradle 配置 | `C:\Users\chany\.gradle\init.gradle` + `gradle.properties` | 镜像重定向 + 超时/重试 + `android.builder.sdkDownload=false`(见坑 1、3) |
|
|
94
|
+
| 下载缓存 | `G:\dev\android-tools\*.zip` | 上面所有包的原始 zip |
|
|
95
|
+
| 区域配置 | `G:\Projects\Cindy\apps\mobile\scripts\self-host-regions.json`(gitignored) | cn 的 `androidPackage = com.xd.cindycn.verify` |
|
|
96
|
+
| dev 端点占位 | `G:\Projects\Cindy\config\endpoint.dev.json`(gitignored) | 从 `.example` 复制;见坑 7 |
|
|
97
|
+
| 构建产物 | `G:\Projects\Cindy\apps\mobile\android\app\build\outputs\apk\release\app-release.apk` | 与 Downloads 里那份同源 |
|
|
98
|
+
|
|
99
|
+
**每次构建前设置**(本机刻意不改系统级 JAVA_HOME / ANDROID_HOME):
|
|
100
|
+
|
|
101
|
+
```powershell
|
|
102
|
+
$env:JAVA_HOME='G:\dev\jdk'
|
|
103
|
+
$env:ANDROID_HOME='G:\dev\android-sdk'
|
|
104
|
+
$env:ANDROID_SDK_ROOT='G:\dev\android-sdk'
|
|
105
|
+
$env:PATH="G:\dev\jdk\bin;G:\dev\android-sdk\platform-tools;$env:PATH"
|
|
106
|
+
$env:EXPO_PUBLIC_CINDY_AUTH_REGION='cn'
|
|
107
|
+
$env:EXPO_PUBLIC_ENDPOINT_MANIFEST_BASE_URL='https://hotfix.cindy.com.cn/cindy'
|
|
108
|
+
$env:EXPO_PUBLIC_ENDPOINT_MANIFEST_PEER_BASE_URL='https://hotfix.cindy.app/cindy'
|
|
109
|
+
$env:CINDY_USE_LOCAL_REGION_CONFIG='1'
|
|
110
|
+
$env:NODE_ENV='production'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## 五、重新出包:实测跑通的完整配方
|
|
114
|
+
|
|
115
|
+
> **一条命令(推荐)**
|
|
116
|
+
>
|
|
117
|
+
> ```powershell
|
|
118
|
+
> pwsh G:\Projects\DSH-cindy-host\tools\build-cindy-verify-apk.ps1 # 含 prebuild(改了原生配置/依赖时用)
|
|
119
|
+
> pwsh G:\Projects\DSH-cindy-host\tools\build-cindy-verify-apk.ps1 -SkipPrebuild # 只改 JS/资源,实测 28 秒
|
|
120
|
+
> ```
|
|
121
|
+
>
|
|
122
|
+
> 脚本做的事:环境变量 → **依赖与锁文件一致性检查**(不一致直接报错并给出修复命令)→ 补齐
|
|
123
|
+
> `config/endpoint.dev.json` 与 `self-host-regions.json` → prebuild(可选)→ **重新打 4 处补丁**
|
|
124
|
+
> (wrapper 指本地 Gradle、内存 8192m/4096m、短 staging + unity build、桌面名 Cindy Verify)→
|
|
125
|
+
> 清掉旧的 autolinking → `gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=2` →
|
|
126
|
+
> `aapt2` / `apksigner` 校验 → 拷成 `Cindy-Verify-<version>-<abi>-<commit>.apk` 到下载文件夹并打印 sha256。
|
|
127
|
+
> 已实测:28 秒,产物 sha256 与已验证那份逐字节一致(`9CA3E555…`)。
|
|
128
|
+
|
|
129
|
+
> **要快,先做这三步(本次的教训:不做这些就要用 2–17 分钟一轮的构建去撞)**
|
|
130
|
+
> 1. `pnpm install --frozen-lockfile --ignore-scripts` —— 这台 checkout 曾经装着 RNGH 3.0.2 而锁文件是
|
|
131
|
+
> 2.32.0,后续所有失败(缺 `Swipeable` → 缺 `endpoint.dev.json` → 旧 autolinking)都是它的连带后果;
|
|
132
|
+
> 2. 先只跑一次 `expo prebuild`,然后**从生成工程里读出全部要求**:`buildToolsVersion`、两个 `ndkVersion`
|
|
133
|
+
> (`node_modules/react-native/gradle/libs.versions.toml` 与 `expo-updates` 的报错)、gradle wrapper 版本 ——
|
|
134
|
+
> 一次性把 SDK 包装齐,再开始构建;
|
|
135
|
+
> 3. 依赖有任何变化,构建前先删 `apps\mobile\android\build\generated\autolinking`(坑 8)。
|
|
136
|
+
>
|
|
137
|
+
> 增量重打(只改 JS / 资源)实测 **28–51 秒**;全量首次约 30–40 分钟。
|
|
138
|
+
>
|
|
139
|
+
> 下面是脚本等价的手工步骤(要看清每一步在干什么时用):
|
|
140
|
+
|
|
141
|
+
```powershell
|
|
142
|
+
# 0) 依赖必须与锁文件一致(这台 checkout 曾经装着 RNGH 3.0.2 而锁文件是 2.32.0,
|
|
143
|
+
# 会一路以「模块解析不了」的形式炸;见坑 6)
|
|
144
|
+
cd G:\Projects\Cindy
|
|
145
|
+
pnpm install --frozen-lockfile --ignore-scripts
|
|
146
|
+
|
|
147
|
+
# 1) 生成原生工程(每次改了 app.json / 原生配置 / 依赖后都要)
|
|
148
|
+
cd apps\mobile
|
|
149
|
+
pnpm exec expo prebuild --platform android --no-install
|
|
150
|
+
|
|
151
|
+
# 2) prebuild 会重置 wrapper 指向,重新指到本地 Gradle(坑 2)
|
|
152
|
+
# apps\mobile\android\gradle\wrapper\gradle-wrapper.properties:
|
|
153
|
+
# distributionUrl=file\:///G:/dev/android-tools/gradle-9.3.1-bin.zip
|
|
154
|
+
# validateDistributionUrl=false
|
|
155
|
+
|
|
156
|
+
# 3) 给 prebuild 产物打三处补丁(本机无管理员权限、开不了长路径,坑 4/5)
|
|
157
|
+
# app/build.gradle:
|
|
158
|
+
# android { externalNativeBuild { cmake { buildStagingDirectory = file("C:/cbx") } } }
|
|
159
|
+
# android { defaultConfig { externalNativeBuild { cmake {
|
|
160
|
+
# arguments "-DCMAKE_UNITY_BUILD=ON", "-DCMAKE_UNITY_BUILD_BATCH_SIZE=1" } } } }
|
|
161
|
+
# app/gradle.properties:
|
|
162
|
+
# org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=4096m
|
|
163
|
+
# app/src/main/res/values/strings.xml:
|
|
164
|
+
# <string name="app_name">Cindy Verify</string> # 仅为与商店版区分
|
|
165
|
+
|
|
166
|
+
# 4) 打包
|
|
167
|
+
cd android
|
|
168
|
+
.\gradlew.bat assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=2
|
|
169
|
+
# 产物:android\app\build\outputs\apk\release\app-release.apk
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **`-PreactNativeArchitectures=arm64-v8a` 很关键**:默认编 4 个 ABI,只编 arm64 大约省 3/4 时间;
|
|
173
|
+
- 首次全量约 30–40 分钟,之后增量(改 JS / 改资源)1–4 分钟;
|
|
174
|
+
- **改了依赖(装/升包)必须先删 `apps\mobile\android\build\generated\autolinking` 再构建**(坑 8)。
|
|
175
|
+
|
|
176
|
+
## 六、踩过的坑(按症状索引,都已绕过)
|
|
177
|
+
|
|
178
|
+
| # | 症状 | 原因 | 处理 |
|
|
179
|
+
|---|---|---|---|
|
|
180
|
+
| 1 | `sdkmanager` 永远卡在 `Fetch remote repository...` | 本机 Java 拉不到 Google 的 `repository2-3.xml` | 全部改为 `curl` 直下 zip 手工铺 SDK 布局;license 文件手写(`G:\dev\android-sdk\licenses\`) |
|
|
181
|
+
| 2 | `SSLHandshakeException: PKIX path building failed`(下 Gradle) | Java 对 `services.gradle.org` 的重定向 CDN 建不出证书链 | curl 预下 `gradle-9.3.1-bin.zip`,`distributionUrl` 指 `file:///…` |
|
|
182
|
+
| 3 | `Could not GET https://dl.google.com/dl/android/maven2/...` 超时 | **dl.google.com 的 maven 路径在本机完全不可达**(curl 25s/Java 31s 超时) | `C:\Users\chany\.gradle\init.gradle` 把 repositories 重定向到阿里云(google/public/gradle-plugin)+ 腾讯/华为兜底;并发拉取偶发被重置,故加 `--max-workers=2` 与重试 |
|
|
183
|
+
| 4 | `ninja: error: Stat(...): Filename longer than 260 characters` | 本机非管理员,`LongPathsEnabled=0`;该对象路径实测 **347 字符**,任何路径压缩都到不了 259 | `buildStagingDirectory=C:/cbx`(省 ~40)+ `CMAKE_UNITY_BUILD=ON`(对象名从「编码后的绝对源码路径」变成 `Unity/unity_N_cxx.cxx.o`,省 ~150);批量大小必须 =1,否则 rnsvg 的多个 TU 合并会重定义报错。RN 0.86 把新架构写死(`ProjectUtils.kt:34 = true`)、WSL 也没装发行版,所以这两条是唯一出路 |
|
|
184
|
+
| 5 | `lintVitalAnalyzeRelease FAILED > Metaspace` | 生成的 `gradle.properties` 只给 `-Xmx2048m -XX:MaxMetaspaceSize=512m` | 提到 `8192m/4096m`(仓库自己的 `build-android.mjs` 也会 patch 这里,值 4096/2048) |
|
|
185
|
+
| 6 | `Unable to resolve module react-native-gesture-handler/Swipeable` / typecheck 报一堆缺失模块 | 这台 checkout 的 node_modules 与锁文件不一致(装着 RNGH **3.0.2**,锁文件要 **2.32.0**;3.x 删了 `Swipeable`) | `pnpm install --frozen-lockfile --ignore-scripts` |
|
|
186
|
+
| 7 | `Unable to resolve module ../../../../config/endpoint.dev.json` | `src/config/env.ts` 里是静态 `require`,即使构建 cn、运行时走不到该分支,Metro 也要能解析 | 从 `config/endpoint.dev.json.example` 复制出该文件(gitignored) |
|
|
187
|
+
| 8 | 依赖变更后 `CMake Error ... does not contain a CMakeLists.txt`(指向 RNGH 的旧布局) | `android/build/generated/autolinking/autolinking.json` 是旧的(`cmakeListsPath` 还写着 RNGH 3.x 的 `android/CMakeLists.txt`) | 删 `apps\mobile\android\build\generated\autolinking`(连带 `app\build\generated\autolinking`、`app\build\intermediates\cxx`、`C:\cbx`)后重建 |
|
|
188
|
+
| 9 | `Preferred NDK version is '27.0.12077973'` | `expo-updates` 与 RN core 偏好不同版本 | 两个 NDK 都装 |
|
|
189
|
+
| 10 | `Failed to find Build Tools revision 36.0.0` / AGP 卡住十几分钟 | Expo 实际用 build-tools 36,而 AGP 自动补装走 Google 仓库清单在本机会挂 | 手工装 `build-tools/36.0.0`;并把 `android.builder.sdkDownload=false` 写进用户级 gradle.properties 让它 fail-fast |
|
|
190
|
+
| 11 | 改了 `packages/device-link` 源码后重打,**新包 sha256 与旧包完全相同**(打进了旧 JS) | `createBundleReleaseJsAndAssets` 不把 `packages/*` 当任务输入 → 判 UP-TO-DATE,连 Metro 缓存都不用清就跳过了 | 构建前删掉 JS bundle 产物(`app/build/generated/assets/react`、`generated/sourcemaps/react`、`intermediates/assets/release`);`tools/build-cindy-verify-apk.ps1` 已内置这一步 |
|
|
191
|
+
| 12 | 装了带修复的包仍然不恢复 | **判据设计过严**:要求「同一 peer 连续两次请求超时」,而回前台后 App 往往只发一轮请求(每个只超时一次),连续计数到不了 2;探针那条路又要求「已存在超时证据」 | 改为「探针超时(= 提示后一个对端帧都没来)即证据 + 有短超时请求在等回包」;长执行通道靠请求自身的分钟级超时排除。commit `308b4badc` |
|
|
192
|
+
| 13 | App 里点下载/导出 **秒失败**(实测 <5 秒),Host 侧却说导出成功 | **阿里云 OSS 拒绝公网端点分发安装包**:key 后缀为 `.apk`/`.ipa`,或对象 Content-Type 恰为 `application/vnd.android.package-archive`,GET 一律 `400 ApkDownloadForbidden` —— 而上传 PUT 与 `presign-get` 都成功,所以两边看着都对 | Host 侧 staging 时把这类对象按**不透明字节**发:`ext → bin`、`Content-Type → application/octet-stream`(`stageableStaging`)。文件名不受影响(手机按浏览到的文件名自己命名)。详见第九节 |
|
|
193
|
+
| 14 | 出包脚本报"构建失败"但**看不到原因** | 脚本把 Gradle 输出过滤成 5 个关键词(`BUILD SUCCESSFUL|FAILED|FAILURE|actionable tasks|What went wrong`),**原因行全被丢掉**;而失败可能是 Windows 文件锁(残留 Gradle 守护进程攥着 `node_modules\expo-modules-core\android\build\...\bundleLibCompileToJarRelease\classes.jar`) | 在 `apps\mobile\android` 手工跑一次 `gradlew.bat assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=2`(记得先设 `JAVA_HOME=G:\dev\jdk`、`ANDROID_HOME`/`ANDROID_SDK_ROOT=G:\dev\android-sdk`),输出落盘看原因;文件锁用 `gradlew --stop` + 删那个 build 目录解决 |
|
|
194
|
+
| 15 | `git commit -m "…"` 报 `pathspec … did not match any file(s)` | 提交信息里写了 **ASCII 双引号**,PowerShell 提前结束字符串,后半段被当成 git 参数 | 提交信息一律走文件:`git commit -F <临时文件>`(本机踩过两次,其中一次连带出包失败,浪费约 20 分钟) |
|
|
195
|
+
|
|
196
|
+
> `curl.exe` 在本机需要 `--ssl-no-revoke`(否则报 `CRYPT_E_NO_REVOCATION_CHECK`)。
|
|
197
|
+
> JDK 走清华镜像 25 MB/s,NDK 走 `https://mirrors.cloud.tencent.com/AndroidSDK/android-ndk-rXX-windows.zip` 13 MB/s,
|
|
198
|
+
> dl.google.com 的普通 zip 只有 1.6–2 MB/s 且时好时坏。
|
|
199
|
+
|
|
200
|
+
## 七、代码位置
|
|
201
|
+
|
|
202
|
+
- 修复分支 `fix/mobile-peer-silence-probe`(commit `eadedb8d5`,**未 push**)在 `G:\Projects\Cindy`;
|
|
203
|
+
补丁见 [`doc/patches/cindy-peer-silence-probe.patch`](patches/cindy-peer-silence-probe.patch);
|
|
204
|
+
换机器/换人时 `git am` 即可(要推分支也可以,需先确认);
|
|
205
|
+
- 本地已验证:`pnpm --filter @cindy/device-link build`(tsc 干净)、`pnpm --filter @cindy/device-link test` **414/414**
|
|
206
|
+
(含本次新增 5 条单测);
|
|
207
|
+
- 本次为出包改的 3 处都在 **prebuild 生成物** `apps/mobile/android/` 内(该目录被 `apps/mobile/.gitignore` 忽略,
|
|
208
|
+
不入仓),不会污染修复提交。
|
|
209
|
+
|
|
210
|
+
## 八、验证完要带回什么
|
|
211
|
+
|
|
212
|
+
1. `adb logcat` 原始片段(带时间戳,含 `device-link` 全量行);
|
|
213
|
+
2. 是否自愈、自愈耗时(从发送到界面出现回复);
|
|
214
|
+
3. 手机型号 / 系统版本 / 包名 / 构建 commit;
|
|
215
|
+
4. 结论写回 `.claw/truth/dsh-cindy-host-mobile-resume-limitation.md`(把「归属」一节从「OS 行为、不再投入」
|
|
216
|
+
改成「客户端判据缺陷、已修 / 已验证或待验证」);
|
|
217
|
+
5. 若验证通过且要提 PR:补丁 `git am` 到干净分支 → 出包验证记录 → 再按上游要求提(他们要求本地打包测试通过)。
|
|
218
|
+
|
|
219
|
+
## 九、导出/下载安装包:根因与修复(2026-09-18,host 0.1.6)
|
|
220
|
+
|
|
221
|
+
**症状**:手机文件浏览器里点「下载/导出」,不到 5 秒弹「下载失败」;Host 侧日志显示导出任务
|
|
222
|
+
`state=done` 并给出了 key —— 两边各自都"成功",失败卡在中间那一跳。
|
|
223
|
+
|
|
224
|
+
**根因(实测,不是推断)**:账号暂存区在阿里云 OSS 的**公网裸域名**
|
|
225
|
+
(`cindy-prod-private.oss-cn-shanghai.aliyuncs.com`),而阿里云禁止该端点分发安装包。命中**任一**
|
|
226
|
+
条件即被拒,且与签名/ACL 无关:
|
|
227
|
+
|
|
228
|
+
| key 后缀 | 对象 Content-Type | GET 结果 |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| `.apk`(任意大小写,server 会小写化) | `application/octet-stream` | `400 ApkDownloadForbidden` |
|
|
231
|
+
| `.bin` | `application/vnd.android.package-archive` | `400 ApkDownloadForbidden` |
|
|
232
|
+
| `.bin` | `application/octet-stream` / `zip` / `x-itunes-ipa` / 该 mime 带 `; charset=binary` | `200` ✅ |
|
|
233
|
+
| `.zip` `.exe` `.dmg` `.apks` `.txt` | `application/octet-stream` | `200` ✅ |
|
|
234
|
+
|
|
235
|
+
响应体给出完整原因(以前被折叠成一个 `download-failed`,白查一下午):
|
|
236
|
+
|
|
237
|
+
```xml
|
|
238
|
+
<Error><Code>ApkDownloadForbidden</Code>
|
|
239
|
+
<Message>The APK file is not allowed to be distributed in a public network using the
|
|
240
|
+
OSS endpoint, please use CNAME instead.</Message></Error>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**修复**(`src/host-media.js`):
|
|
244
|
+
|
|
245
|
+
- `stageableStaging(ext, contentType)`:命中上述任一条件时,staging 声明改为 **`ext=bin` +
|
|
246
|
+
`Content-Type: application/octet-stream`**(两处都要改:PUT 的 `Content-Type` 头才是对象存下来的值);
|
|
247
|
+
- `download-failed` 现在带上对象存储的错误码(`download-failed: ApkDownloadForbidden`),下次一眼判因;
|
|
248
|
+
- 文件**字节不动**,用户看到的文件名也不变:手机按浏览到的文件名自己命名
|
|
249
|
+
(`apps/mobile/app/files/[sessionId].tsx` → `downloadRemoteMediaShareTemp(url, mime, item.name)`),
|
|
250
|
+
分享用的 mime 也取自文件名,所以staged 成不透明字节对安装毫无影响。
|
|
251
|
+
|
|
252
|
+
**证据链**(三个探针都在 `tools/`,都打真的线上暂存区):
|
|
253
|
+
|
|
254
|
+
```powershell
|
|
255
|
+
node tools/probe-oss-ext.mjs # 后缀矩阵:哪些后缀被拒
|
|
256
|
+
node tools/probe-oss-ext.mjs --content-type application/vnd.android.package-archive bin
|
|
257
|
+
node tools/probe-apk-export-roundtrip.mjs # 真实的 77MB 包:上传 → 下载 → sha256 比对
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
修复后实测:`80977303` 字节上传 4.1s → 完整下载 1.87s(41 MB/s)→ sha256 与本地一致 ✅。
|
|
261
|
+
修复前同一探针:`下载失败(179ms): reason=download-failed: ApkDownloadForbidden`。
|
|
262
|
+
|
|
263
|
+
**当前 APK**:`Cindy-Verify-0.1.0-arm64-v8a-94f083331.apk`(77.2 MB,sha256
|
|
264
|
+
`BEC452E890F1E8BCA33597E205B5D4B5B0ADEE36ADF33FBCBB9043AA821CAD90`),含客户端 peer 静默探针。
|
|
265
|
+
**下载链路是 Host 侧的 bug:手机不需要换包就能拿到文件**,换包只是为了验证第三/四节的断线自愈。
|
|
266
|
+
|
|
267
|
+
**仍未覆盖(已知)**:手机→Host 方向若传 `.apk` 附件,key 由手机侧申请,同样会被 OSS 拒;
|
|
268
|
+
要修得在客户端上传处做同样的中性化(本仓 `uploadMedia` 管不到那条路径)。
|
|
269
|
+
|
|
270
|
+
## 十、真机复现:卡死其实有两种,机制完全不同(2026-09-18 18:37 取证)
|
|
271
|
+
|
|
272
|
+
用带对端活性看门狗的包(`94f083331`)复现后,Host 侧的逐 5 秒时间线显示的是**另一种卡死**:
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
10:37:03–06 一串调用(回前台:重新订阅 + sessions:get + messages:view + list-active)
|
|
276
|
+
10:37:06 → 28 22 秒里一次调用都没有 ← 不是"答复没到",是它不再问了
|
|
277
|
+
10:37:28/33/55 只有用户操作触发的 input:enqueue / steer
|
|
278
|
+
Host 同时刻:推送一直在发(watchers=1)、订阅在、reconnect=0、handlerErrors 空、帧预算 0 抑制
|
|
279
|
+
用户侧:「思考时间一直不动」;导航/发消息可用;退到列表再回来不行;**只有重启 App 才行**
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
| | 手机还在问吗 | 机制 | 归属 |
|
|
283
|
+
|---|---|---|---|
|
|
284
|
+
| A(第三节那类,12:18 现场) | **在问,且重复问同一个读** | 答复到不了它(读方向死) | 客户端判据缺陷 |
|
|
285
|
+
| B(本次) | **完全不问** | 计时器整体停摆,不再产生请求 | 客户端生命周期缺陷 |
|
|
286
|
+
|
|
287
|
+
**B 的机制**:后台驻留后 RN(Android)计时器驱动没重新装上,而 App 的实时行为全建立在
|
|
288
|
+
`setInterval`/防抖上 —— 轮询(`messages:view`/`view-intent`,由 push 后的防抖重载驱动)、
|
|
289
|
+
对端探针、以及「思考时间」(`app/sessions/[sessionId].tsx` 的 `setInterval(updateElapsed, 1000)`)
|
|
290
|
+
一起停;AppState 回调、socket 事件、导航、发送这些**事件驱动**的路径照常工作。计时器是 React
|
|
291
|
+
实例级的,所以换屏不恢复,只有整进程重开。
|
|
292
|
+
|
|
293
|
+
**B 的修复**:`apps/mobile/src/device-link/timerLiveness.ts`(commit `04d36977f`)——
|
|
294
|
+
入站帧与回前台这两个**不经过计时器**的事件当检查点,心跳间隙 ≥3 秒判定停摆 → 重建心跳 +
|
|
295
|
+
重连/rehydrate,并上报 `tdiag.stall.s<秒>.r<次数>.<来源>` / `tdiag.recover.after<秒>s.r<次数>.<来源>`。
|
|
296
|
+
|
|
297
|
+
**第二层(即使重建救不回来界面也能恢复)**:取证精确到了那一跳 ——
|
|
298
|
+
push → `historyView.invalidate()` → **`setTimeout(500ms)`** → 重读
|
|
299
|
+
(`packages/maker-shared/src/historyViewController.ts`)。计时器停摆时这一跳永不执行,
|
|
300
|
+
所以「推送一直在到、界面永不刷新、手机一次 `messages:view` 都不再发」。现在:
|
|
301
|
+
`HistoryViewController` 接受注入的 `timersHealthy` 判定,判定为停摆时改用**挂钟节流
|
|
302
|
+
300ms + 立即重读**(不依赖任何定时器);「思考时间」的显示值也改为渲染时按真实时间算。
|
|
303
|
+
|
|
304
|
+
**第三层(用户真机反馈后补的最后一环)**:「进会话页思考时间不动,退到列表再进来会更新一次,
|
|
305
|
+
但不实时更新」—— `remoteSessionStore` 的流式文本增量批量刷新(`scheduleTextDeltaFlush`)把
|
|
306
|
+
`flushPendingTextDeltas` 排在 `setTimeout` 上,而**该函数只在那一个回调里**被调用:
|
|
307
|
+
计时器停摆时增量永远停在缓冲里,退出再进走整页重读所以只更新一次(用户描述与机制一字不差)。
|
|
308
|
+
停摆时改为**直接刷**(正常批量间隔本来只有 32–96ms,同量级;也不会有"最后一批增量滞留"的风险)。
|
|
309
|
+
|
|
310
|
+
**第四层(用户提出的"回前台强制重刷")**:回前台的重新订阅 + 重读其实一直在做
|
|
311
|
+
(`rehydrateWithClient`,日志 10:37:03–06 那一串),但它是**一次性**的。用户补上关键一半:
|
|
312
|
+
「退到列表再进来就恢复」的本质是**数据层整体换代**,而 `connectionEpoch` 只在**客户端重连成功**
|
|
313
|
+
时才换代 —— 停摆时没有重连,所以屏幕各层 effect 都不重跑。现在**判定停摆时也换代一次**
|
|
314
|
+
(只在这个降级态做,不在每次回前台都做)。
|
|
315
|
+
|
|
316
|
+
**判据(不靠肉眼)**:
|
|
317
|
+
|
|
318
|
+
```powershell
|
|
319
|
+
# 手机自报的时间线(带到达时刻;不被普通轮询冲掉,只被更多自报挤掉)
|
|
320
|
+
(Invoke-RestMethod 'http://127.0.0.1:3080/api/dsh-cindy-host/status').diagnostics.phoneDiagnostics
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
- 出现 `tdiag.stall.*` → 证实 B;
|
|
324
|
+
- **紧接着出现 `tdiag.recover.after*s`** → 心跳重建生效,界面应在 10 秒内恢复实时刷新(修复成功);
|
|
325
|
+
- 只有 `stall`、没有 `recover` → 计时器无法从 JS 侧复活,下一步把 App 的实时循环改成
|
|
326
|
+
push 事件直接驱动(不再依赖 `setInterval`)。
|
|
327
|
+
|
|
328
|
+
> **这条判据已被真机实测修正**:2026-09-18 19:34 的实测里 `recover` 一次都没来(间隙单调增长
|
|
329
|
+
> 81→103→109→114→125,`reviveCount` 停在 1),证明 **JS 侧无法复活 RN 的计时器驱动**。
|
|
330
|
+
> 所以 `tdiag.recover.*` 不再是验收条件;能判定的三条见 `.claw/truth/dsh-cindy-host-mobile-resume-limitation.md`
|
|
331
|
+
> 的「验收判据(修正版)」:① 用户看到思考时间自己在走;② 停摆窗口内手机仍在持续读取;
|
|
332
|
+
> ③ 手机自报停摆 + 随后一次重新订阅(层 4 换代生效)。
|
|
333
|
+
|
|
334
|
+
## 十一、交接:装包与验证(我不在场也能做)
|
|
335
|
+
|
|
336
|
+
**装哪一版**:`artifacts` 里取**最新时间戳**的那个(`Cindy-Verify-0.1.0-arm64-v8a-<commit>.apk`),
|
|
337
|
+
覆盖安装即可(同包名同签名,不用卸载)。文件名里的 `<commit>` 就是 Cindy 仓的 HEAD 短哈希,
|
|
338
|
+
可用 `git -C G:\Projects\Cindy log --oneline -1` 对照它包含哪些层。
|
|
339
|
+
|
|
340
|
+
**做什么**:打开 App 进任意会话 → 按 Home 切后台 **1–2 分钟** → 回前台 → **停在会话页盯着**。
|
|
341
|
+
|
|
342
|
+
**看到什么算什么**:
|
|
343
|
+
|
|
344
|
+
| 现象 | 结论 |
|
|
345
|
+
|---|---|
|
|
346
|
+
| 思考时间自己在走、新内容自己进来 | ✅ 修好了(层 2/3/4 生效) |
|
|
347
|
+
| 要退出去再进来才更新 | ❌ 层 3 没兜住,把这一句原话带回来即可 |
|
|
348
|
+
| 界面完全不动 | ❌ 层 2/4 也没兜住,带原话回来 |
|
|
349
|
+
|
|
350
|
+
**取证不用你操作**:`dsh web` 那台机器上跑着守望(`.sandbox/watch-phone.mjs`,每 10 秒把手机
|
|
351
|
+
自报与调用时间线落盘到 `.sandbox/phone-diagnostics*.log`),App 一触发就会自动记下。
|
|
352
|
+
事后用 `node tools/verify-timer-stall.mjs` 一次读完(它会把自报、调用空档、连接层与判读一起打印)。
|
|
353
|
+
|
|
354
|
+
**当前产物(层 1 + 层 2 在同一个包里)**:
|
|
355
|
+
|
|
356
|
+
| 项 | 值 |
|
|
357
|
+
|---|---|
|
|
358
|
+
| 项目内路径 | `artifacts\Cindy-Verify-0.1.0-arm64-v8a-d4969890c.apk` |
|
|
359
|
+
| 大小 / sha256 | 77.2 MB / `A2147D4822A3B06813EEED6D086EC2FA896B3CB5032B0104736108ED496CFDDC` |
|
|
360
|
+
| 包内已核对 | 层 1:`tdiag.stall.s` `tdiag.recover.after` `timer-stall`;层 2:`lastDirectInvalidateAt` `timersHealthy` `setTimerHealthProbe` |
|
|
361
|
+
| 出包踩坑 | 上一次构建失败于 `Unable to delete file …bundleLibCompileToJarRelease\classes.jar`:残留 Gradle 守护进程攥着文件。脚本现在会自动 `gradlew --stop` + 清库产物,并在失败时打出 Gradle 的原因行(见第六节坑 14) |
|
|
362
|
+
|