@bolloon/bolloon-agent 0.5.0 → 0.5.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.
Files changed (106) hide show
  1. package/dist/agents/execution-supervisor.js +87 -5
  2. package/dist/agents/model-policy.js +651 -0
  3. package/dist/agents/pi-sdk-tools.js +84 -33
  4. package/dist/agents/pi-sdk.js +83 -23
  5. package/dist/agents/run-store.js +53 -0
  6. package/dist/agents/task/task-runner.js +8 -1
  7. package/dist/agents/workflow-pivot-loop.js +19 -12
  8. package/dist/cli/clipboard.js +65 -0
  9. package/dist/cli/ink-app.js +145 -17
  10. package/dist/cli/log-gate.js +386 -0
  11. package/dist/cli/mention-data.js +2 -0
  12. package/dist/cli/model-selector.js +538 -0
  13. package/dist/cli/reply-hygiene.js +54 -0
  14. package/dist/cli/setup-wizard.js +686 -69
  15. package/dist/cli/startup-notice.js +160 -0
  16. package/dist/cli/stores.js +22 -1
  17. package/dist/cli/theme.js +49 -0
  18. package/dist/cli/tui-select.js +970 -0
  19. package/dist/cli-entry.js +125 -18
  20. package/dist/electron-build/agents/execution-supervisor.js +86 -4
  21. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  22. package/dist/electron-build/agents/goal-store.js.map +1 -1
  23. package/dist/electron-build/agents/model-policy.js +706 -0
  24. package/dist/electron-build/agents/model-policy.js.map +1 -0
  25. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  26. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  27. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  28. package/dist/electron-build/agents/pi-sdk.js +83 -23
  29. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  30. package/dist/electron-build/agents/run-store.js +55 -1
  31. package/dist/electron-build/agents/run-store.js.map +1 -1
  32. package/dist/electron-build/agents/task/task-runner.js +8 -1
  33. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  34. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  35. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  36. package/dist/electron-build/llm/config-store.js +146 -6
  37. package/dist/electron-build/llm/config-store.js.map +1 -1
  38. package/dist/electron-build/llm/connection-probe.js +764 -0
  39. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  40. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  41. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  42. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  43. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  44. package/dist/electron-build/llm/model-catalog.js +778 -0
  45. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  46. package/dist/electron-build/llm/model-discovery.js +1202 -0
  47. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  48. package/dist/electron-build/llm/model-selection.js +1315 -0
  49. package/dist/electron-build/llm/model-selection.js.map +1 -0
  50. package/dist/electron-build/llm/pi-ai.js +70 -11
  51. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  52. package/dist/electron-build/llm/provider-catalog.js +969 -0
  53. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  54. package/dist/electron-build/llm/provider-registry.js +950 -0
  55. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  56. package/dist/electron-build/llm/tool-name.js +266 -0
  57. package/dist/electron-build/llm/tool-name.js.map +1 -0
  58. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  59. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  60. package/dist/electron-build/utils/dual-source.js +32 -156
  61. package/dist/electron-build/utils/dual-source.js.map +1 -1
  62. package/dist/electron-build/utils/update-manager.js +22 -72
  63. package/dist/electron-build/utils/update-manager.js.map +1 -1
  64. package/dist/electron-build/utils/update-state.js +2 -19
  65. package/dist/electron-build/utils/update-state.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +193 -0
  67. package/dist/electron-build/utils/version-identity.js.map +1 -0
  68. package/dist/electron-build/utils/version-info.js +47 -101
  69. package/dist/electron-build/utils/version-info.js.map +1 -1
  70. package/dist/index.js +282 -96
  71. package/dist/ios/bolloon-web.json +10 -0
  72. package/dist/ios/mobile-contacts.js +44 -11
  73. package/dist/ios/mobile-core.js +1723 -84
  74. package/dist/ios/mobile-tasks.js +339 -0
  75. package/dist/ios/mobile-update.js +1442 -0
  76. package/dist/ios/mobile.js +308 -35
  77. package/dist/ios/routes-llm-config.js +344 -52
  78. package/dist/ios/routes-mobile-tasks.js +353 -0
  79. package/dist/ios/server.js +163 -4
  80. package/dist/ios/sw.js +1 -1
  81. package/dist/llm/config-store.js +146 -6
  82. package/dist/llm/connection-probe.js +744 -0
  83. package/dist/llm/custom-provider-store.js +224 -0
  84. package/dist/llm/data/provider-catalog-baked.js +19 -0
  85. package/dist/llm/model-catalog.js +717 -0
  86. package/dist/llm/model-discovery.js +1137 -0
  87. package/dist/llm/model-selection.js +1241 -0
  88. package/dist/llm/pi-ai.js +70 -11
  89. package/dist/llm/provider-catalog.js +898 -0
  90. package/dist/llm/provider-registry.js +917 -0
  91. package/dist/llm/tool-name.js +220 -0
  92. package/dist/setup/onboard.js +34 -8
  93. package/dist/utils/dual-source-facts.js +223 -0
  94. package/dist/utils/dual-source.js +12 -141
  95. package/dist/utils/update-manager.js +16 -59
  96. package/dist/utils/update-state.js +6 -18
  97. package/dist/utils/version-identity.js +176 -0
  98. package/dist/utils/version-info.js +9 -78
  99. package/dist/web/bolloon-web.json +10 -0
  100. package/dist/web/mobile-core.js +1721 -84
  101. package/dist/web/mobile-update.js +1442 -0
  102. package/dist/web/mobile.js +308 -35
  103. package/dist/web/routes-llm-config.js +344 -52
  104. package/dist/web/sw.js +1 -1
  105. package/package.json +1 -1
  106. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,1442 @@
1
+ /**
2
+ * mobile-update.ts — 手机端 **web 资源层** 的双源 (npm + GitHub) OTA 更新 (2026-09-26, update-protocol §13)
3
+ *
4
+ * ── 为什么只做"web 资源层" ──────────────────────────────────────────────────
5
+ * 手机 App 是 Capacitor 壳 (原生二进制) + web 资源 (界面与逻辑) 两层。两层的**天花板不同**,
6
+ * 这里如实写清, 不承诺做不到的:
7
+ * · **iOS**: 原生壳层**不能自更** (App Store 规则) —— 二进制只能走商店 / TestFlight。
8
+ * web 资源层**可以** OTA 自更。所以"iOS 整体自动更新"这句话是做不到的, 本轮不承诺。
9
+ * · **Android**: 侧载 APK 自更**技术上可行**, 但需要用户允许"未知来源" ⇒ 必须**显式告知 +
10
+ * 用户在环**, 不得静默安装。本轮**不做** APK 自装 (那是一条独立的、需要用户在环的通道)。
11
+ * ⇒ 本轮锁定的自更范围 = **web 资源层** (那四项能力: 入群 / 发任务公告 / 看飞轮进度 / 授权签名
12
+ * 全都活在这一层); 原生层一律如实标注"需商店 / 侧载"。
13
+ *
14
+ * ── 语义复用 (不新造第二套) ────────────────────────────────────────────────
15
+ * 版本 / 身份比较、错误分类、交叉校验、拒绝口径**全部**来自桌面的纯模块:
16
+ * `version-identity.ts` (版本比较 / 结论枚举 / REFUSED_STATUSES / dev 身份)
17
+ * `dual-source-facts.ts` (classifyGithubError / crossCheckStable / compareDevSnapshots)
18
+ * 因此同一件事在电脑上和手机上说的是同一句话:
19
+ * stable = npm `dist-tags.latest` 是权威 + GitHub Tag 同名交叉校验 (semver)
20
+ * dev = GitHub master `git ref` + commit sha 是唯一源, 身份 `<版本>+dev.<sha7>` (不用 semver)
21
+ * 源不可达 / 版本不存在 / 交叉校验不一致 ⇒ **拒绝并说清** (REFUSED), 绝不静默装回旧版、绝不假装成功。
22
+ *
23
+ * ── 浏览器安全 ──────────────────────────────────────────────────────────────
24
+ * 本文件**不许** import 任何 `node:` 模块 (它会被 esbuild 打进 mobile-core.js 在 WebView 里跑),
25
+ * 也不许在顶层读 `process.env`。解压用平台自带的 `DecompressionStream('gzip')` + 手写 tar 解析。
26
+ */
27
+ import { PKG_NAME, NPM_REGISTRY_BASE, GITHUB_API_BASE, UPSTREAM_REPO, GITHUB_UPSTREAM_SLUG, DEV_BRANCH, DEV_REF, DEV_IDENTITY_SEP, DEV_CHANNEL_WARNING, DEV_BACK_TO_STABLE_HINT_MOBILE, CHECK_STATUSES, REFUSED_STATUSES, resolveUpdateChannel, distTagForChannel, channelKindOf, devIdentity, baseVersionOf, isDevIdentity, devShaFromIdentity, compareVersions, isKnownVersion, describeSourceIdentity, envValue, } from '../utils/version-identity.js';
28
+ import { classifyGithubError, classifyRegistryError, crossCheckStable, compareDevSnapshots, toGithubReport, renderGithubReportLine, } from '../utils/dual-source-facts.js';
29
+ // ── 电话端的结论枚举 (只增不改: 桌面的 9 个逐字保留) ────────────────────────
30
+ /**
31
+ * 手机端"检查"的结论。**桌面的 9 个逐字保留** (同名同义), 只增手机特有的几个 ——
32
+ * 每个新增都对应一种"手机上真的会撞上、桌面上不会"的处境, 且都必须**拒绝并说清**:
33
+ *
34
+ * dev_bundle_unavailable dev 通道: GitHub master 的**源码快照里没有 dist/web** (dist 是构建产物,
35
+ * 不在 git 里) ⇒ 手机端无法现场构建。需桌面导出 web 包, 或改走 stable。
36
+ * dev_sha_mismatch dev: 包里的 `.bolloon-dev-snapshot.json` 的 commit sha ≠ GitHub master HEAD
37
+ * ⇒ 拒绝安装一个"身份对不上"的快照。
38
+ * target_unpublished 目标版本在源上**不存在** (桌面把它放在风险项 `target_published` 里;
39
+ * 手机上"拒绝"必须是一个**结论**, 不能只是一条风险提示)。
40
+ * digest_mismatch 下载物的摘要 (npm shasum / 自带 bundleSha256) 与声明不一致 ⇒ 拒绝。
41
+ * web_bundle_malformed 包里找不到 web 资源 (dist/web) ⇒ 拒绝。
42
+ * decompress_unavailable 本机 WebView 没有 `DecompressionStream('gzip')` ⇒ 拒绝, 不装半个。
43
+ * payload_too_large 下载物超过上限 (默认 80 MiB) ⇒ 拒绝, 不拿内存去赌。
44
+ * native_shell_not_wired 资源已备好且验证过, 但**原生壳还没指向可写目录** ⇒ 不切换
45
+ * (切了等于把"能用的旧版本"换成"壳层加载不到的新资源")。见 §13.4。
46
+ */
47
+ export const MOBILE_WEB_STATUSES = [
48
+ ...CHECK_STATUSES,
49
+ 'dev_bundle_unavailable',
50
+ 'dev_sha_mismatch',
51
+ 'target_unpublished',
52
+ 'digest_mismatch',
53
+ 'web_bundle_malformed',
54
+ 'decompress_unavailable',
55
+ 'payload_too_large',
56
+ 'native_shell_not_wired',
57
+ ];
58
+ /** 手机端必须**拒绝并说清**的结论 = 桌面那 5 个 + 手机特有的 8 个 (逐个都在上面写了为什么)。 */
59
+ export const MOBILE_REFUSED_STATUSES = [
60
+ ...REFUSED_STATUSES,
61
+ 'target_unpublished', 'dev_bundle_unavailable', 'dev_sha_mismatch', 'digest_mismatch',
62
+ 'web_bundle_malformed', 'decompress_unavailable', 'payload_too_large', 'native_shell_not_wired',
63
+ ];
64
+ /** web 资源层里必须有、且必须非空的文件 (缺一个就不算一个能启动的 web 层)。 */
65
+ export const MOBILE_WEB_REQUIRED_FILES = ['mobile.html', 'mobile.js', 'mobile-core.js', 'mobile.css'];
66
+ /** 有则一起换、但缺了不算坏的文件。 */
67
+ export const MOBILE_WEB_OPTIONAL_FILES = ['a2ui-client.js', 'sw.js', 'manifest.json', 'index.html'];
68
+ /** 需要语法自检的 JS (浏览器里 `new Function(src)` = 真编译一次)。 */
69
+ export const MOBILE_WEB_JS_FILES = ['mobile.js', 'mobile-core.js', 'a2ui-client.js'];
70
+ /** 默认下载上限 (超过就拒绝, 不赌内存)。 */
71
+ export const MOBILE_MAX_PAYLOAD_BYTES = 80 * 1024 * 1024;
72
+ /** web 资源层的目录布局 (相对 store root)。 */
73
+ export const WEB_LAYOUT = {
74
+ current: 'current',
75
+ staging: 'staging',
76
+ previous: 'previous',
77
+ state: 'state.json',
78
+ };
79
+ function joinRel(...parts) {
80
+ return parts.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean).join('/');
81
+ }
82
+ /** 内存 store (单测 / 验收 / 无原生能力的兜底): 就是一个 Map。 */
83
+ export function createMemoryWebStore(opts = {}) {
84
+ const files = new Map();
85
+ const dirs = new Set(['']);
86
+ const norm = (rel) => joinRel(rel);
87
+ const isDir = (rel) => dirs.has(norm(rel));
88
+ return {
89
+ kind: opts.kind || 'memory',
90
+ rootLabel: opts.rootLabel || 'memory://bolloon-web',
91
+ async list(rel) {
92
+ const d = norm(rel);
93
+ const out = [];
94
+ for (const k of files.keys()) {
95
+ if (d === '') {
96
+ if (!k.includes('/'))
97
+ out.push(k);
98
+ continue;
99
+ }
100
+ if (k.startsWith(d + '/')) {
101
+ const rest = k.slice(d.length + 1);
102
+ if (!rest.includes('/'))
103
+ out.push(rest);
104
+ }
105
+ }
106
+ return out.sort();
107
+ },
108
+ async exists(rel) {
109
+ const k = norm(rel);
110
+ return files.has(k) || isDir(k);
111
+ },
112
+ async readBytes(rel) { const v = files.get(norm(rel)); return v ? v.slice() : null; },
113
+ async readText(rel) { const v = files.get(norm(rel)); return v ? new TextDecoder().decode(v) : null; },
114
+ async writeBytes(rel, data) {
115
+ const k = norm(rel);
116
+ const parts = k.split('/');
117
+ for (let i = 1; i < parts.length; i++)
118
+ dirs.add(parts.slice(0, i).join('/'));
119
+ files.set(k, data.slice ? data.slice() : data);
120
+ },
121
+ async remove(rel) {
122
+ const k = norm(rel);
123
+ for (const key of Array.from(files.keys()))
124
+ if (key === k || key.startsWith(k + '/'))
125
+ files.delete(key);
126
+ for (const d of Array.from(dirs))
127
+ if (d === k || d.startsWith(k + '/'))
128
+ dirs.delete(d);
129
+ },
130
+ async rename(from, to) {
131
+ const f = norm(from);
132
+ const t = norm(to);
133
+ const moves = [];
134
+ for (const key of Array.from(files.keys())) {
135
+ if (key === f)
136
+ moves.push({ from: key, to: t });
137
+ else if (key.startsWith(f + '/'))
138
+ moves.push({ from: key, to: t + key.slice(f.length) });
139
+ }
140
+ // 先删目标 (替换语义), 再搬 — 与真文件系统的 rename 同形状
141
+ await this.remove(t);
142
+ for (const m of moves) {
143
+ const v = files.get(m.from);
144
+ files.delete(m.from);
145
+ const parts = m.to.split('/');
146
+ for (let i = 1; i < parts.length; i++)
147
+ dirs.add(parts.slice(0, i).join('/'));
148
+ files.set(m.to, v);
149
+ }
150
+ dirs.delete(f);
151
+ dirs.add(t);
152
+ },
153
+ baseUrl() { return opts.baseUrl ?? null; },
154
+ };
155
+ }
156
+ /**
157
+ * 真机 store: 把 web 资源放到 App 的**可写数据目录** (Capacitor `Directory.Data`)。
158
+ * 原生壳要能加载它才算"接上" —— 见 `nativeWritable` 与 §13.4。
159
+ */
160
+ export function createCapacitorWebStore(plugin, opts = {}) {
161
+ const directory = opts.directory ?? 'DATA';
162
+ const basePath = (opts.basePath || 'bolloon-web').replace(/^\/+|\/+$/g, '');
163
+ const full = (rel) => joinRel(basePath, rel);
164
+ const b64ToBytes = (b64) => {
165
+ if (typeof atob === 'function') {
166
+ const bin = atob(b64);
167
+ const out = new Uint8Array(bin.length);
168
+ for (let i = 0; i < bin.length; i++)
169
+ out[i] = bin.charCodeAt(i);
170
+ return out;
171
+ }
172
+ return new Uint8Array(Buffer.from(b64, 'base64'));
173
+ };
174
+ const bytesToB64 = (data) => {
175
+ let bin = '';
176
+ for (let i = 0; i < data.length; i++)
177
+ bin += String.fromCharCode(data[i]);
178
+ if (typeof btoa === 'function')
179
+ return btoa(bin);
180
+ return Buffer.from(data).toString('base64');
181
+ };
182
+ return {
183
+ kind: 'capacitor-fs',
184
+ rootLabel: `${String(directory)}:${basePath}`,
185
+ async list(rel) {
186
+ try {
187
+ const r = await plugin.readdir({ path: full(rel), directory });
188
+ return (r?.files || []).filter((f) => f?.type !== 'directory').map((f) => String(f.name)).sort();
189
+ }
190
+ catch {
191
+ return [];
192
+ }
193
+ },
194
+ async exists(rel) {
195
+ try {
196
+ await plugin.stat({ path: full(rel), directory });
197
+ return true;
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ },
203
+ async readBytes(rel) {
204
+ try {
205
+ const r = await plugin.readFile({ path: full(rel), directory });
206
+ const data = String(r?.data || '');
207
+ return data ? b64ToBytes(data) : null;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ },
213
+ async readText(rel) {
214
+ try {
215
+ const r = await plugin.readFile({ path: full(rel), directory, encoding: 'utf8' });
216
+ return r?.data === undefined ? null : String(r.data);
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ },
222
+ async writeBytes(rel, data) {
223
+ const path = full(rel);
224
+ const parts = full(rel).split('/');
225
+ for (let i = 1; i < parts.length; i++) {
226
+ try {
227
+ await plugin.mkdir({ path: parts.slice(0, i).join('/'), directory, recursive: true });
228
+ }
229
+ catch { /* 已存在 */ }
230
+ }
231
+ await plugin.writeFile({ path, data: bytesToB64(data), directory });
232
+ },
233
+ async remove(rel) {
234
+ try {
235
+ await plugin.rmdir({ path: full(rel), directory, recursive: true });
236
+ return;
237
+ }
238
+ catch { /* 不是目录 */ }
239
+ try {
240
+ await plugin.rmdir({ path: full(rel), directory });
241
+ }
242
+ catch { /* 忽略 */ }
243
+ },
244
+ async rename(from, to) {
245
+ try {
246
+ await plugin.rmdir({ path: full(to), directory, recursive: true });
247
+ }
248
+ catch { /* 目标不存在 */ }
249
+ await plugin.rename({ from: full(from), to: full(to), directory, toDirectory: directory });
250
+ },
251
+ baseUrl() { return opts.baseUrl ?? null; },
252
+ };
253
+ }
254
+ /**
255
+ * gzip 解压 —— 用平台自带的 `DecompressionStream` (Chrome 80+ / Safari 16.4+ / WKWebView iOS 16.4+ / Node 18+)。
256
+ * 拿不到就返回 null: 上层据此判 `decompress_unavailable` 并**拒绝**, 不假装成功。
257
+ */
258
+ export async function gunzipBytes(data) {
259
+ const DS = globalThis.DecompressionStream;
260
+ if (typeof DS !== 'function')
261
+ return null;
262
+ try {
263
+ const stream = new globalThis.Blob([data]).stream().pipeThrough(new DS('gzip'));
264
+ const buf = await new Response(stream).arrayBuffer();
265
+ return new Uint8Array(buf);
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ /**
272
+ * 极简 tar 解析 (ustar + GNU 长名 + PAX `path=`)。只做我们真需要的事:
273
+ * 把每个 regular file 的 (路径, 内容) 拿出来。目录 / 符号链接一律跳过 (不假装支持)。
274
+ */
275
+ export function untarBytes(buf) {
276
+ const out = [];
277
+ const td = new TextDecoder();
278
+ const readStr = (off, len) => {
279
+ let end = off;
280
+ while (end < off + len && buf[end] !== 0)
281
+ end++;
282
+ return td.decode(buf.subarray(off, end));
283
+ };
284
+ const readOctal = (off, len) => {
285
+ const s = readStr(off, len).trim().replace(/\0/g, '');
286
+ const v = parseInt(s, 8);
287
+ return Number.isFinite(v) ? v : 0;
288
+ };
289
+ let p = 0;
290
+ let paxPath = null;
291
+ while (p + 512 <= buf.length) {
292
+ const name0 = readStr(p, 100);
293
+ if (!name0)
294
+ break; // 两个连续零块 = 结束
295
+ const size = readOctal(p + 124, 12);
296
+ const typeflag = String.fromCharCode(buf[p + 156] || 0x30);
297
+ const prefix = readStr(p + 345, 155);
298
+ const dataStart = p + 512;
299
+ const dataEnd = dataStart + size;
300
+ if (dataEnd > buf.length)
301
+ break; // 半截文件: 停, 不编造
302
+ let name = (prefix ? `${prefix}/${name0}` : name0);
303
+ if (typeflag === 'x' || typeflag === 'g') {
304
+ // PAX 扩展头: 下一个条目的路径可能在这里 (长路径真会用到)
305
+ const text = td.decode(buf.subarray(dataStart, dataEnd));
306
+ const m = text.match(/\d+ path=([^\n]+)\n/);
307
+ if (m)
308
+ paxPath = m[1];
309
+ }
310
+ else if (typeflag === 'L') {
311
+ paxPath = td.decode(buf.subarray(dataStart, dataEnd)).replace(/\0+$/, '').trim();
312
+ }
313
+ else if (typeflag === '0' || typeflag === '\0' || typeflag === '' || typeflag === '0') {
314
+ if (paxPath) {
315
+ name = paxPath;
316
+ paxPath = null;
317
+ }
318
+ out.push({ path: name.replace(/^\.\//, ''), data: buf.slice(dataStart, dataEnd) });
319
+ }
320
+ else {
321
+ paxPath = null;
322
+ }
323
+ p = dataStart + Math.ceil(size / 512) * 512;
324
+ }
325
+ return out;
326
+ }
327
+ // ── 从 tarball 里取 web 资源 ────────────────────────────────────────────────
328
+ /** 两个源包里的 web 资源前缀 (npm 包外面套着一层 `package/`; 桌面导出的 web 包直接是 `dist/web/`)。 */
329
+ export const WEB_PATH_PREFIXES = ['package/dist/web/', 'dist/web/', './dist/web/'];
330
+ export function isWebResourcePath(path) {
331
+ const p = String(path).replace(/^\.\//, '');
332
+ for (const pre of WEB_PATH_PREFIXES) {
333
+ if (p.startsWith(pre))
334
+ return { hit: true, rel: p.slice(pre.length) };
335
+ }
336
+ return { hit: false, rel: '' };
337
+ }
338
+ /** 从解压后的条目里挑出 web 资源 (相对 web 根), 以及包内的 dev 快照标记 (若有)。 */
339
+ export function pickWebLayer(entries) {
340
+ const files = [];
341
+ let snapshot = null;
342
+ const td = new TextDecoder();
343
+ for (const e of entries) {
344
+ const p = String(e.path).replace(/^\.\//, '');
345
+ if (/(^|\/)\.bolloon-dev-snapshot\.json$/.test(p)) {
346
+ try {
347
+ snapshot = JSON.parse(td.decode(e.data));
348
+ }
349
+ catch {
350
+ snapshot = null;
351
+ }
352
+ continue;
353
+ }
354
+ const w = isWebResourcePath(p);
355
+ if (w.hit && w.rel && !w.rel.endsWith('/'))
356
+ files.push({ rel: w.rel, data: e.data });
357
+ }
358
+ return { files, snapshot };
359
+ }
360
+ // ── 摘要 ────────────────────────────────────────────────────────────────────
361
+ function toHex(buf) {
362
+ const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
363
+ let s = '';
364
+ for (let i = 0; i < b.length; i++)
365
+ s += b[i].toString(16).padStart(2, '0');
366
+ return s;
367
+ }
368
+ /**
369
+ * 摘要算法名归一化 —— **必须**做这一步:
370
+ * npm 给的是 `sha1` / SRI 给的是 `sha512`, 而 WebCrypto 只认 `SHA-1` / `SHA-256` / `SHA-512`。
371
+ * 直接把 `'sha1'.toUpperCase()` 丢进去 → `subtle.digest('SHA1')` 抛错 → 摘要算不出来 →
372
+ * **每一个包都会被判成 digest_mismatch** (真跑抓到过)。
373
+ */
374
+ function normalizeAlgo(a) {
375
+ const k = String(a || '').toLowerCase().replace(/[^a-z0-9]/g, '');
376
+ if (k === 'sha1')
377
+ return 'SHA-1';
378
+ if (k === 'sha256')
379
+ return 'SHA-256';
380
+ if (k === 'sha512')
381
+ return 'SHA-512';
382
+ return null;
383
+ }
384
+ async function digestHex(data, algo) {
385
+ const c = globalThis.crypto?.subtle;
386
+ if (!c)
387
+ return null; // 老 WebView 没有 subtle → 调用方按"算不出摘要"处理
388
+ const name = normalizeAlgo(algo);
389
+ if (!name)
390
+ return null;
391
+ try {
392
+ return toHex(await c.digest(name, data));
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ /** SRI (`sha512-<base64>`) → 归一化成 base64 摘要, 用于和本地算出来的比。 */
399
+ export function parseSri(integrity) {
400
+ const s = String(integrity || '').trim();
401
+ const m = s.match(/^(sha512|sha256|sha1)-([A-Za-z0-9+/=]+)/);
402
+ if (!m)
403
+ return null;
404
+ return { algo: m[1], b64: m[2] };
405
+ }
406
+ function b64Of(bytes) {
407
+ let bin = '';
408
+ for (let i = 0; i < bytes.length; i++)
409
+ bin += String.fromCharCode(bytes[i]);
410
+ if (typeof btoa === 'function')
411
+ return btoa(bin);
412
+ return Buffer.from(bytes).toString('base64');
413
+ }
414
+ // ── 落盘状态 (装在哪个源 / 哪个 sha / 能切回哪) ─────────────────────────────
415
+ export const MOBILE_UPDATE_STATE_SCHEMA = 'bolloon-mobile-web/1';
416
+ export function emptyMobileUpdateState() {
417
+ return {
418
+ schema: MOBILE_UPDATE_STATE_SCHEMA,
419
+ currentVersion: null, installedChannel: null, installedDevSha: null, channelPref: null,
420
+ installedFrom: null, switchableTo: null, lastDevSha: null, devRef: null, devCheckedAt: null,
421
+ lastCheck: null, lastUpdate: null, lastFailure: null, autoPrepared: null,
422
+ history: [], needsReload: false,
423
+ };
424
+ }
425
+ export async function readMobileUpdateState(store) {
426
+ const base = emptyMobileUpdateState();
427
+ const text = await store.readText(WEB_LAYOUT.state);
428
+ if (!text)
429
+ return base;
430
+ try {
431
+ const raw = JSON.parse(text);
432
+ if (!raw || raw.schema !== MOBILE_UPDATE_STATE_SCHEMA)
433
+ return base;
434
+ return { ...base, ...raw, history: Array.isArray(raw.history) ? raw.history.slice(0, 20) : [] };
435
+ }
436
+ catch {
437
+ return base;
438
+ }
439
+ }
440
+ export async function writeMobileUpdateState(patch, store) {
441
+ const cur = await readMobileUpdateState(store);
442
+ const next = { ...cur, ...patch, schema: MOBILE_UPDATE_STATE_SCHEMA };
443
+ await store.writeBytes(WEB_LAYOUT.state, new TextEncoder().encode(JSON.stringify(next, null, 2)));
444
+ return next;
445
+ }
446
+ async function fetchJson(url, fetchImpl, timeoutMs = 15000) {
447
+ try {
448
+ const ac = globalThis.AbortController ? new AbortController() : null;
449
+ const timer = ac ? setTimeout(() => { try {
450
+ ac.abort();
451
+ }
452
+ catch { /* noop */ } }, timeoutMs) : null;
453
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: ac?.signal });
454
+ const status = Number(res?.status || 0);
455
+ const text = await res.text();
456
+ if (timer)
457
+ clearTimeout(timer);
458
+ let json = null;
459
+ try {
460
+ json = JSON.parse(text);
461
+ }
462
+ catch {
463
+ json = null;
464
+ }
465
+ return { ok: status >= 200 && status < 300, status, json, text };
466
+ }
467
+ catch (e) {
468
+ return { ok: false, status: 0, json: null, text: '', err: e };
469
+ }
470
+ }
471
+ /** 查 npm packument (与桌面同一份 registry 语义; 注入则不打网) */
472
+ export async function queryMobileNpmFacts(deps, pkg) {
473
+ if (deps.npmDoc !== undefined && deps.npmDoc !== null)
474
+ return normalizeNpmDoc(deps.npmDoc, null);
475
+ if (deps.npmDoc === null && deps.githubFacts !== undefined) {
476
+ // 显式注入 null = 受控"registry 不可达"
477
+ return { reachable: false, kind: 'offline', detail: '注入的 registry 事实为 null (受控不可达: offline)', latest: null, distTags: {}, versions: [], dist: null };
478
+ }
479
+ const base = (deps.registryBase || NPM_REGISTRY_BASE).replace(/\/+$/, '');
480
+ const url = `${base}/${encodeURIComponent(pkg).replace('%40', '@')}`;
481
+ const fetchImpl = deps.fetchImpl || (globalThis.fetch?.bind(globalThis));
482
+ if (!fetchImpl)
483
+ return { reachable: false, kind: 'offline', detail: '本机没有 fetch (offline)', latest: null, distTags: {}, versions: [], dist: null };
484
+ const r = await fetchJson(url, fetchImpl);
485
+ if (!r.ok) {
486
+ const cls = classifyRegistryError(r.err || null, r.status || undefined);
487
+ return { reachable: false, kind: cls.kind, detail: cls.kind === 'offline' ? `offline: ${cls.detail}` : `registry_unavailable: ${cls.detail}`, latest: null, distTags: {}, versions: [], dist: null };
488
+ }
489
+ if (!r.json)
490
+ return { reachable: false, kind: 'registry_unavailable', detail: 'registry_unavailable: registry 返回了无法解析的内容', latest: null, distTags: {}, versions: [], dist: null };
491
+ return normalizeNpmDoc(r.json, null);
492
+ }
493
+ function normalizeNpmDoc(doc, detail) {
494
+ const distTags = (doc?.['dist-tags'] || {});
495
+ const versions = Object.keys(doc?.versions || {});
496
+ if (!distTags.latest && versions.length === 0) {
497
+ return { reachable: false, detail: detail || 'registry 上没有可用版本', latest: null, distTags: {}, versions: [], dist: null };
498
+ }
499
+ const latest = distTags.latest || versions[versions.length - 1] || null;
500
+ const v = latest ? doc?.versions?.[latest] : null;
501
+ return {
502
+ reachable: true,
503
+ latest,
504
+ distTags, versions,
505
+ dist: v?.dist ? { tarball: String(v.dist.tarball || ''), shasum: v.dist.shasum ? String(v.dist.shasum) : null, integrity: v.dist.integrity ? String(v.dist.integrity) : null } : null,
506
+ };
507
+ }
508
+ /** 查 GitHub 三个事实 (注入则不打网) */
509
+ export async function queryMobileGithubFacts(deps) {
510
+ if (deps.githubFacts !== undefined && deps.githubFacts !== null)
511
+ return deps.githubFacts;
512
+ if (deps.githubFacts === null)
513
+ return null;
514
+ const fetchImpl = deps.fetchImpl || (globalThis.fetch?.bind(globalThis));
515
+ if (!fetchImpl)
516
+ return null;
517
+ const base = (deps.apiBase || GITHUB_API_BASE).replace(/\/+$/, '');
518
+ const missing = [];
519
+ let releases = [];
520
+ let tags = [];
521
+ let headSha = null;
522
+ const failures = [];
523
+ const note = (e, what) => {
524
+ missing.push(what);
525
+ failures.push({ reason: e.reason, detail: `${what}: ${e.detail}`, retryAt: e.retryAt });
526
+ };
527
+ const rel = await fetchJson(`${base}/repos/${GITHUB_UPSTREAM_SLUG}/releases?per_page=100`, fetchImpl);
528
+ if (rel.ok && Array.isArray(rel.json))
529
+ releases = rel.json.map((x) => String(x?.tag_name || '')).filter(Boolean);
530
+ else
531
+ note(classifyGithubError(rel.err || null, rel.status || undefined), 'releases');
532
+ const tg = await fetchJson(`${base}/repos/${GITHUB_UPSTREAM_SLUG}/tags?per_page=100`, fetchImpl);
533
+ if (tg.ok && Array.isArray(tg.json))
534
+ tags = tg.json.map((x) => ({ name: String(x?.name || ''), sha: x?.commit?.sha ? String(x.commit.sha) : null })).filter((t) => t.name);
535
+ else
536
+ note(classifyGithubError(tg.err || null, tg.status || undefined), 'tags');
537
+ const hd = await fetchJson(`${base}/repos/${GITHUB_UPSTREAM_SLUG}/commits/${DEV_BRANCH}`, fetchImpl);
538
+ if (hd.ok && hd.json?.sha)
539
+ headSha = String(hd.json.sha);
540
+ else if (hd.ok)
541
+ note({ reason: 'parse_error', detail: 'commits/master 没有 sha 字段', retryAt: null }, 'head');
542
+ else
543
+ note(classifyGithubError(hd.err || null, hd.status || undefined), 'head');
544
+ const facts = { slug: GITHUB_UPSTREAM_SLUG, ref: DEV_REF, headSha, releases, tags, fetchedAt: new Date().toISOString(), partial: missing.length > 0, missing };
545
+ if (failures.length) {
546
+ const f = failures[0];
547
+ return { ok: false, kind: 'github_unavailable', reason: f.reason, detail: f.detail, retryAt: f.retryAt, facts };
548
+ }
549
+ return { ok: true, facts };
550
+ }
551
+ /**
552
+ * 本地 web 层的身份, 三个来源按可信度排序 (**读不到就说读不到, 绝不猜**):
553
+ * ① 原生壳注入的 `deps.localIdentity`
554
+ * ② store 里上一次 OTA 落下的 `state.json` (含身份与来源)
555
+ * ③ 同目录的构建戳 `bolloon-web.json` (build:web / web 包产出时写)
556
+ */
557
+ export async function resolveLocalWebIdentity(deps) {
558
+ if (deps.localIdentity && isKnownVersion(deps.localIdentity.version)) {
559
+ const devSha = devShaFromIdentity(deps.localIdentity.version);
560
+ return { version: String(deps.localIdentity.version), channel: devSha ? 'dev' : (deps.localIdentity.channel || 'stable'), source: 'shell-injected' };
561
+ }
562
+ const st = await readMobileUpdateState(deps.store);
563
+ if (st.currentVersion && isKnownVersion(st.currentVersion)) {
564
+ const devSha = devShaFromIdentity(st.currentVersion);
565
+ return { version: st.currentVersion, channel: devSha ? 'dev' : (st.installedChannel || 'stable'), source: 'ota-state' };
566
+ }
567
+ const url = deps.localIdentityUrl === null ? null : (deps.localIdentityUrl || './bolloon-web.json');
568
+ const fetchImpl = deps.fetchImpl || (globalThis.fetch?.bind(globalThis));
569
+ if (url && fetchImpl) {
570
+ const r = await fetchJson(url, fetchImpl, 8000).catch(() => null);
571
+ const v = r?.json?.version;
572
+ if (r?.ok && isKnownVersion(v)) {
573
+ const devSha = devShaFromIdentity(v);
574
+ return { version: String(v), channel: devSha ? 'dev' : (r.json.channel === 'dev' ? 'dev' : 'stable'), source: 'build-stamp' };
575
+ }
576
+ }
577
+ return null;
578
+ }
579
+ function refused(s) {
580
+ return MOBILE_REFUSED_STATUSES.includes(s);
581
+ }
582
+ export { refused as isMobileRefusedStatus };
583
+ /**
584
+ * 原生壳接上了吗 —— 决定"装了会不会生效"。
585
+ * 显式 true/false 优先; 没说的时候: 内存 store (单测/验收) 当"接上了", **真机 store (capacitor-fs)
586
+ * 默认当"没接上"** —— 不知道就按不能生效算, 不拿用户的可用版本去赌。
587
+ */
588
+ export function nativeWired(deps) {
589
+ if (deps.nativeWritable === true)
590
+ return true;
591
+ if (deps.nativeWritable === false)
592
+ return false;
593
+ return deps.store.kind === 'memory';
594
+ }
595
+ function warnFor(channel, wired = true) {
596
+ const w = channelKindOf(channel) === 'git-ref' ? [DEV_CHANNEL_WARNING, DEV_BACK_TO_STABLE_HINT_MOBILE] : [];
597
+ if (!wired)
598
+ w.push(NATIVE_CEILING_LINE);
599
+ return w;
600
+ }
601
+ /**
602
+ * 手机端检查更新。**结论优先级与桌面 §12.3 逐条对齐** (手机特有的三条插在对应位置):
603
+ * 1. 读不到本地 web 身份 → local_version_unknown (绝不默认 0.0.0)
604
+ * 2. (原生壳没接可写目录 **不是**检查结论 —— 它是一条事实 `nativeWritable` + 一条警告;
605
+ * 真正拒绝发生在**切换**那一步: `native_shell_not_wired`. 理由: 壳层没接上时用户
606
+ * 仍需要看到"有新版本可用",只是不能装。)
607
+ * 3. npm 不可达 → offline / registry_unavailable
608
+ * 4. dev 通道 GitHub 不可达 → github_unavailable (dev 只有这一个源, 拒绝)
609
+ * 5. dev 通道缺 web 包地址 → dev_bundle_unavailable (源码快照里没有 dist/web)
610
+ * 6. stable 两源指向不同版本 → cross_check_mismatch (拒绝)
611
+ * 7. 目标版本在源上不存在 → target_unpublished (拒绝)
612
+ * 8. 有新版 / 已是最新 → update_available / up_to_date
613
+ */
614
+ export async function checkMobileUpdate(deps) {
615
+ const store = deps.store;
616
+ const now = new Date(deps.now ? deps.now() : Date.now()).toISOString();
617
+ const local = await resolveLocalWebIdentity(deps);
618
+ const channel = resolveUpdateChannel(deps.channel);
619
+ const channelKind = channelKindOf(channel);
620
+ const base = {
621
+ channel, channelKind,
622
+ currentVersion: local?.version || 'unknown',
623
+ installedChannel: local ? (devShaFromIdentity(local.version) ? 'dev' : 'stable') : null,
624
+ installedDevSha: local ? devShaFromIdentity(local.version) : null,
625
+ latestVersion: null, targetIdentity: null, switchableTo: null,
626
+ crossCheck: null, github: null, dev: null, sourceFacts: null, targetPublished: null,
627
+ artifact: null, warnings: warnFor(channel, nativeWired(deps)), checkedAt: now,
628
+ identityLine: describeSourceIdentity({
629
+ packageVersion: local?.version || 'unknown',
630
+ installedDevSha: local ? devShaFromIdentity(local.version) : null,
631
+ switchableTo: null,
632
+ channelKind,
633
+ }),
634
+ localIdentitySource: local?.source || null,
635
+ storeKind: store.kind,
636
+ nativeWritable: nativeWired(deps),
637
+ effectiveAfterReload: nativeWired(deps),
638
+ };
639
+ // 1. 本地身份读不到 —— 不猜
640
+ if (!local || !isKnownVersion(local.version)) {
641
+ return { ...base, status: 'local_version_unknown', reason: `读不到本机 web 层的版本身份 (壳层注入 / OTA state / 构建戳 bolloon-web.json 都没有) — 不判断有没有更新` };
642
+ }
643
+ const npm = await queryMobileNpmFacts(deps, deps.pkgName || PKG_NAME);
644
+ const githubRes = await queryMobileGithubFacts(deps);
645
+ const github = githubRes ? toGithubReport(githubRes) : null;
646
+ const npmLatest = npm.reachable ? (npm.distTags[distTagForChannel(channel)] || npm.latest) : null;
647
+ const sourceFacts = {
648
+ npm: npm.reachable ? { reachable: true, latest: npmLatest } : { reachable: false, latest: null, detail: npm.detail },
649
+ github: github ? {
650
+ reachable: github.reachable, reason: github.reason, detail: github.detail, retryAt: github.retryAt ?? null,
651
+ ref: github.ref, headSha: github.headSha, newestVersion: github.newestVersion,
652
+ releases: github.releases, tags: github.tags,
653
+ } : null,
654
+ crossCheck: null,
655
+ };
656
+ const switchableTo = base.installedDevSha
657
+ ? { channel: 'stable', source: 'npm', target: npmLatest }
658
+ : { channel: 'dev', source: 'github', target: github?.headSha ? github.headSha.slice(0, 7) : null };
659
+ const identityLine = describeSourceIdentity({
660
+ packageVersion: local.version,
661
+ installedDevSha: base.installedDevSha,
662
+ switchableTo,
663
+ channelKind,
664
+ });
665
+ const withSources = { ...base, github, sourceFacts, switchableTo, identityLine };
666
+ // ── dev 通道: GitHub master 是唯一源, sha 是身份 ────────────────────────
667
+ if (channelKind === 'git-ref') {
668
+ if (!github || !github.reachable) {
669
+ const reason = github?.reason || 'offline';
670
+ const detail = github?.detail || (githubRes === null ? '本次未查询 GitHub 源' : 'GitHub 源不可达');
671
+ return {
672
+ ...withSources, status: 'github_unavailable', latestVersion: null,
673
+ reason: `github_unavailable(${reason}): ${detail} — dev 通道只有 GitHub 一个源, 拒绝安装 (不回落到 stable 装一个 npm 版本)`,
674
+ };
675
+ }
676
+ if (!github.headSha) {
677
+ return { ...withSources, status: 'github_unavailable', latestVersion: null, reason: `github_unavailable(not_found): GitHub 上没有 ${DEV_REF} 或读不到 HEAD — 拒绝安装一个身份不明的快照` };
678
+ }
679
+ const dev = compareDevSnapshots(base.installedDevSha, github.headSha, DEV_REF);
680
+ const targetIdentity = devIdentity(baseVersionOf(local.version), github.headSha);
681
+ const devBundleUrl = deps.devBundleUrl !== undefined ? deps.devBundleUrl : envValue('BOLLOON_MOBILE_DEV_BUNDLE_URL');
682
+ const devBundleSha256 = deps.devBundleSha256 !== undefined ? deps.devBundleSha256 : envValue('BOLLOON_MOBILE_DEV_BUNDLE_SHA256');
683
+ const common = { ...withSources, dev, targetIdentity, latestVersion: targetIdentity, sourceFacts: { ...sourceFacts, github: sourceFacts.github } };
684
+ /**
685
+ * dev 的 web 包只能由**有构建能力的一端**产出: GitHub master 的源码快照里**没有** dist/web
686
+ * (dist 是构建产物, 不进 git)。手机端无法现场 tsc 构建 ⇒ 没有配 bundle 地址就**如实拒绝**,
687
+ * 而不是"悄悄退回 stable 装一个 npm 版本"(那正是 §12.5 要禁掉的行为)。
688
+ */
689
+ if (dev.same) {
690
+ return { ...common, status: 'up_to_date', reason: dev.detail };
691
+ }
692
+ if (!devBundleUrl || !/\{sha\}/.test(devBundleUrl)) {
693
+ return {
694
+ ...common, status: 'dev_bundle_unavailable',
695
+ reason: `dev_bundle_unavailable: dev 通道的 web 包要由有构建能力的一端产出 —— GitHub master 的源码快照里没有 dist/web (dist 是构建产物, 不在 git 里), 手机端无法现场构建。`
696
+ + `请用桌面导出: npx tsx scripts/build-mobile-web-bundle.ts --channel dev (产出 dist/web + .bolloon-dev-snapshot.json 的 tar.gz 与同名 .sha256), 并把地址配到手机端 BOLLOON_MOBILE_DEV_BUNDLE_URL (模板需含 {sha}, {sha} 会被替换成 master HEAD 的完整 40 位 sha)。`
697
+ + `${devBundleUrl ? ` 当前配置的模板不含 {sha}: ${devBundleUrl}` : ' 当前没有配置 dev web 包地址。'}`
698
+ + ` stable 通道不受影响 (npm tarball 自带 dist/web)。`,
699
+ };
700
+ }
701
+ const pinned = devBundleSha256 ? String(devBundleSha256).trim().toLowerCase() : null;
702
+ const url = String(devBundleUrl).replace('{sha}', github.headSha);
703
+ return {
704
+ ...common, status: 'update_available', targetPublished: true,
705
+ artifact: {
706
+ channel: 'dev', identity: targetIdentity, source: `github master @ ${github.headSha.slice(0, 7)}`,
707
+ url,
708
+ // pin 了才校验载荷摘要; 没 pin 就如实标"没有载荷锚点"(不给虚假的安全感)
709
+ digest: pinned ? { algo: 'sha256', hex: pinned } : null,
710
+ expectedDevSha: github.headSha, expectedBundleSha256: pinned,
711
+ },
712
+ reason: `${dev.detail} · web 包来自 ${url} (包内 sha 必须等于 master HEAD, 否则拒绝)`
713
+ + (pinned ? ` · 载荷摘要已 pin (sha256:${pinned.slice(0, 12)}…)` : ' · 载荷摘要未 pin (dev 通道固有: 只有身份被 GitHub 锚定)'),
714
+ warnings: common.warnings.concat(pinned ? [] : [
715
+ 'dev 载荷摘要未 pin ⇒ 只保证"身份是 master HEAD 的那个 commit", 不保证"字节就是桌面打的那一份" (要强校验就把 .sha256 清单的值配到 BOLLOON_MOBILE_DEV_BUNDLE_SHA256)',
716
+ ]),
717
+ };
718
+ }
719
+ // ── stable 通道: npm 是权威, GitHub Tag 只做交叉校验 ─────────────────────
720
+ if (!npm.reachable) {
721
+ const detail = npm.detail || 'registry 不可达';
722
+ // 分类来自唯一那份分类器 (dual-source-facts), **不**从 detail 文本里正则猜
723
+ const offline = npm.kind === 'offline';
724
+ return {
725
+ ...withSources, status: offline ? 'offline' : 'registry_unavailable', latestVersion: null,
726
+ reason: `${detail}${github ? ` · ${renderGithubReportLine(github)}` : ''} — 不能判定为最新`,
727
+ };
728
+ }
729
+ const latest = npmLatest;
730
+ const facts = githubRes && githubRes.ok ? githubRes.facts : null;
731
+ const crossCheck = facts ? crossCheckStable(latest, facts) : null;
732
+ const withCross = {
733
+ ...withSources, crossCheck, latestVersion: latest,
734
+ sourceFacts: { ...sourceFacts, crossCheck: crossCheck ? { kind: crossCheck.kind, blocking: crossCheck.blocking, detail: crossCheck.detail } : null },
735
+ };
736
+ // 6. 两个源指向不同版本 → 拒绝 (不许按 GitHub 的记录去装)
737
+ if (crossCheck?.kind === 'mismatch') {
738
+ return {
739
+ ...withCross, status: 'cross_check_mismatch',
740
+ reason: `cross_check_mismatch: ${crossCheck.detail} (npm latest=${latest} / GitHub ${crossCheck.githubVersion || '无记录'})`,
741
+ };
742
+ }
743
+ // 当前装的是 dev 快照 → 显式判"切回 stable" (不拿 semver 硬比)
744
+ if (isDevIdentity(local.version)) {
745
+ const target = latest;
746
+ if (!target || !npm.versions.includes(baseVersionOf(target))) {
747
+ return { ...withCross, status: 'target_unpublished', reason: `target_unpublished: registry 上没有 ${target || '目标版本'} 这个版本 — 拒绝切回 (当前 dev 快照 ${local.version} 原样保留)` };
748
+ }
749
+ if (!npm.dist?.tarball) {
750
+ return { ...withCross, status: 'target_unpublished', reason: `target_unpublished: registry 没给出 ${target} 的下载地址 (dist.tarball 缺失) — 拒绝切回` };
751
+ }
752
+ return {
753
+ ...withCross, status: 'update_available', targetPublished: true,
754
+ artifact: {
755
+ channel: 'stable', identity: String(target), source: 'npm registry (dist-tags.latest)',
756
+ url: npm.dist.tarball,
757
+ digest: npm.dist.shasum ? { algo: 'sha1', hex: npm.dist.shasum } : (npm.dist.integrity ? { algo: 'sha512', b64: parseSri(npm.dist.integrity)?.b64 ?? null } : null),
758
+ },
759
+ reason: `当前装的是 dev 快照 ${local.version} → 切回 stable 的 ${target} (${DEV_BACK_TO_STABLE_HINT_MOBILE})`,
760
+ };
761
+ }
762
+ // 7/8/9. 目标存在性 → 比较
763
+ if (latest && !npm.versions.includes(latest)) {
764
+ return { ...withCross, status: 'target_unpublished', targetPublished: false, reason: `target_unpublished: registry 的 dist-tags 指到 ${latest}, 但 versions 里没有这个版本 — 拒绝更新 (0 字节下载)` };
765
+ }
766
+ if (latest && compareVersions(baseVersionOf(local.version), latest) < 0) {
767
+ if (!npm.dist?.tarball) {
768
+ return { ...withCross, status: 'target_unpublished', targetPublished: true, reason: `target_unpublished: registry 没给出 ${latest} 的下载地址 (dist.tarball 缺失) — 拒绝更新` };
769
+ }
770
+ return {
771
+ ...withCross, status: 'update_available', targetPublished: true,
772
+ artifact: {
773
+ channel: 'stable', identity: latest, source: 'npm registry (dist-tags.latest)',
774
+ url: npm.dist.tarball,
775
+ digest: npm.dist.shasum ? { algo: 'sha1', hex: npm.dist.shasum } : (npm.dist.integrity ? { algo: 'sha512', b64: parseSri(npm.dist.integrity)?.b64 ?? null } : null),
776
+ },
777
+ reason: `当前 ${local.version} → 目标 ${latest}${crossCheck ? ` · 交叉校验: ${crossCheck.kind}` : ''}`,
778
+ };
779
+ }
780
+ return { ...withCross, status: 'up_to_date', targetPublished: latest ? npm.versions.includes(latest) : null };
781
+ }
782
+ /** 下载 + **按声明摘要校验** (对不上 → digest_mismatch, 绝不"先装上再说")。 */
783
+ export async function downloadArtifact(artifact, deps) {
784
+ const fetchImpl = deps.fetchImpl || (globalThis.fetch?.bind(globalThis));
785
+ const maxBytes = deps.maxPayloadBytes ?? MOBILE_MAX_PAYLOAD_BYTES;
786
+ if (!fetchImpl)
787
+ return { ok: false, status: 'offline', detail: '本机没有 fetch, 无法下载', digest: { algo: '', matched: null } };
788
+ let bytes;
789
+ try {
790
+ const res = await fetchImpl(artifact.url, { headers: { Accept: 'application/octet-stream, application/gzip, */*' } });
791
+ const status = Number(res?.status || 0);
792
+ if (!(status >= 200 && status < 300)) {
793
+ const cls = classifyGithubError(null, status || undefined);
794
+ const bucket = cls.reason === 'not_found' ? 'target_unpublished' : (cls.reason === 'offline' ? 'offline' : 'registry_unavailable');
795
+ return { ok: false, status: bucket, detail: `下载 ${artifact.url} 失败: HTTP ${status || '无响应'} (${cls.reason})`, digest: { algo: '', matched: null } };
796
+ }
797
+ const lenHint = Number(res?.headers?.get?.('content-length') || 0);
798
+ if (lenHint && lenHint > maxBytes) {
799
+ return { ok: false, status: 'payload_too_large', detail: `下载物 ${(lenHint / 1048576).toFixed(1)} MiB 超过上限 ${(maxBytes / 1048576).toFixed(0)} MiB — 拒绝`, digest: { algo: '', matched: null } };
800
+ }
801
+ const buf = await res.arrayBuffer();
802
+ bytes = new Uint8Array(buf);
803
+ if (bytes.length > maxBytes) {
804
+ return { ok: false, status: 'payload_too_large', detail: `下载物 ${(bytes.length / 1048576).toFixed(1)} MiB 超过上限 ${(maxBytes / 1048576).toFixed(0)} MiB — 拒绝`, digest: { algo: '', matched: null } };
805
+ }
806
+ }
807
+ catch (e) {
808
+ const cls = classifyGithubError(e);
809
+ return { ok: false, status: cls.reason === 'offline' ? 'offline' : 'registry_unavailable', detail: `下载 ${artifact.url} 失败: ${cls.detail}`, digest: { algo: '', matched: null } };
810
+ }
811
+ // 摘要校验
812
+ if (!artifact.digest) {
813
+ return { ok: true, detail: '该源没有提供摘要 (dev web 包以包内 sha 作为身份校验, 见 dev_sha_mismatch)', bytes, digest: { algo: '', matched: null } };
814
+ }
815
+ const algo = artifact.digest.algo;
816
+ const want = artifact.digest.hex || artifact.digest.b64;
817
+ if (!want)
818
+ return { ok: true, detail: '声明的摘要为空 (跳过)', bytes, digest: { algo, matched: null } };
819
+ let got = null;
820
+ if (artifact.digest.hex)
821
+ got = await digestHex(bytes, algo);
822
+ else {
823
+ const bytesDigest = await digestHex(bytes, algo);
824
+ // 本地 digestHex 给的是 hex; 声明是 base64 → 归一化成 hex 比
825
+ if (bytesDigest)
826
+ got = null;
827
+ const h = bytesDigest;
828
+ if (h) {
829
+ // 把 hex 还原成字节再 base64, 与 SRI 的 base64 逐字比
830
+ const pairs = h.match(/.{2}/g) || [];
831
+ const arr = new Uint8Array(pairs.map((x) => parseInt(x, 16)));
832
+ got = b64Of(arr) === artifact.digest.b64 ? artifact.digest.b64 : b64Of(arr);
833
+ }
834
+ }
835
+ const matched = got !== null && got.toLowerCase() === want.toLowerCase();
836
+ if (!matched) {
837
+ return {
838
+ ok: false, status: 'digest_mismatch',
839
+ detail: `digest_mismatch: 摘要对不上 (${algo}) — 声明 ${String(want).slice(0, 24)}…, 实际 ${String(got).slice(0, 24)}…, 拒绝安装`,
840
+ bytes, digest: { algo, hex: artifact.digest.hex ? got : null, b64: artifact.digest.b64 ? got : null, matched: false },
841
+ };
842
+ }
843
+ return { ok: true, detail: `摘要已核对 (${algo})`, bytes, digest: { algo, hex: artifact.digest.hex ? got : null, b64: artifact.digest.b64 ? got : null, matched: true } };
844
+ }
845
+ /** `mobile.html` 里引用的脚本都要在资源里存在 (否则"能启动"是假的)。 */
846
+ export function scriptRefsOf(html) {
847
+ const out = [];
848
+ const re = /<script[^>]*\ssrc=["']([^"']+)["']/gi;
849
+ let m;
850
+ while ((m = re.exec(html)))
851
+ out.push(m[1]);
852
+ return out;
853
+ }
854
+ /**
855
+ * 验证一个 web 资源层"能不能启动"。两道门, 两道都真跑:
856
+ * ① 结构 + 语法: 必需文件在且非空; `mobile.html` 引用的脚本存在; 每个 JS `new Function(src)` 编译一次
857
+ * ② 真启动探测: 有可加载 URL 时, 真用一个隐藏 iframe 加载 staged 的 mobile.html, 等它
858
+ * readyState=complete 且 `BolloonCore` 就位。拿不到 URL 就**如实降级**为 static-only, 并标注。
859
+ */
860
+ export async function verifyWebLayer(store, prefix, opts = {}) {
861
+ const checks = [];
862
+ const present = {};
863
+ for (const f of MOBILE_WEB_REQUIRED_FILES) {
864
+ const text = await store.readText(joinRel(prefix, f));
865
+ const bytes = text === null ? await store.readBytes(joinRel(prefix, f)) : null;
866
+ const size = text !== null ? text.length : (bytes?.length ?? 0);
867
+ present[f] = String(text ?? '');
868
+ checks.push({ name: `必需文件 ${f}`, ok: size > 0, detail: size > 0 ? `${size} 字节` : '缺失或为空' });
869
+ }
870
+ const html = present['mobile.html'] || '';
871
+ const refs = scriptRefsOf(html);
872
+ const refMiss = [];
873
+ for (const r of refs) {
874
+ if (/^https?:|^\/\//i.test(r))
875
+ continue; // 外链不算 (本项目无外链脚本)
876
+ const rel = r.replace(/^\.\//, '').split('?')[0];
877
+ if (!(await store.exists(joinRel(prefix, rel))))
878
+ refMiss.push(r);
879
+ }
880
+ checks.push({ name: 'mobile.html 引用的脚本都在', ok: refMiss.length === 0, detail: refMiss.length ? `缺失: ${refMiss.join(', ')}` : `${refs.length} 个引用都命中` });
881
+ const syntaxBad = [];
882
+ for (const f of MOBILE_WEB_JS_FILES) {
883
+ const src = await store.readText(joinRel(prefix, f));
884
+ if (src === null || src === '')
885
+ continue; // 可选文件缺失时不判错 (required 那一步已管必需件)
886
+ try {
887
+ // 真编译一次 (不执行): 语法错在这里就暴露, 而不是等用户打开发现白屏
888
+ // eslint-disable-next-line no-new-func
889
+ new Function(src);
890
+ }
891
+ catch (e) {
892
+ syntaxBad.push(`${f}: ${String(e?.message || e).slice(0, 120)}`);
893
+ }
894
+ }
895
+ checks.push({ name: 'JS 语法自检 (真编译一次)', ok: syntaxBad.length === 0, detail: syntaxBad.length ? syntaxBad.join(' | ') : `${MOBILE_WEB_JS_FILES.length} 个文件可编译` });
896
+ const structural = checks.every((c) => c.ok);
897
+ if (!structural) {
898
+ return { ok: false, mode: 'static-only', detail: `结构与语法检查未通过: ${checks.filter((c) => !c.ok).map((c) => `${c.name}(${c.detail})`).join('; ')}`, checks };
899
+ }
900
+ const base = store.baseUrl();
901
+ if (!base) {
902
+ return { ok: true, mode: 'static-only', detail: '结构 + 语法通过; 本 store 没有可加载 URL ⇒ 未做真启动探测 (如实标注, 不当成"已验证能启动")', checks };
903
+ }
904
+ // 有 URL 但**没有 DOM** (Node 验收 / 无窗口环境): 做不了真加载。这时必须**如实降级**成 static-only,
905
+ // 不能把"探测不了"报成"起不来" —— 那会把所有 Node 侧安装误判成验证失败 (真跑抓到过)。
906
+ if (!hasDom()) {
907
+ return {
908
+ ok: true, mode: 'static-only',
909
+ detail: '结构 + 语法通过; 有可加载 URL 但本环境没有 DOM (非浏览器) ⇒ 未做真启动探测 (如实标注; 真探测见 scripts/verify-mobile-update-ui.ts)',
910
+ checks,
911
+ };
912
+ }
913
+ const url = `${String(base).replace(/\/+$/, '')}/${joinRel(prefix, 'mobile.html')}`;
914
+ const probe = await iframeBootProbe(url, opts.bootTimeoutMs ?? 12000);
915
+ checks.push({ name: '真启动探测 (iframe 加载 staged 资源)', ok: probe.ok, detail: probe.detail });
916
+ return { ok: probe.ok, mode: 'iframe', detail: probe.ok ? `真启动探测通过: ${probe.detail}` : `真启动探测失败: ${probe.detail}`, checks };
917
+ }
918
+ /** 有没有可用的 DOM (决定能不能做 iframe 真启动探测) */
919
+ export function hasDom() {
920
+ const d = globalThis.document;
921
+ return !!(d && typeof d.createElement === 'function');
922
+ }
923
+ /** 真加载一次 (隐藏 iframe, 同源)。等 readyState=complete 且 `BolloonCore` 就位。 */
924
+ export async function iframeBootProbe(url, timeoutMs) {
925
+ const doc = globalThis.document;
926
+ if (!doc || typeof doc.createElement !== 'function')
927
+ return { ok: false, detail: '没有 DOM, 无法做启动探测' };
928
+ return await new Promise((resolve) => {
929
+ let done = false;
930
+ const frame = doc.createElement('iframe');
931
+ frame.style.cssText = 'position:absolute;left:-9999px;width:430px;height:932px;border:0;';
932
+ frame.setAttribute('aria-hidden', 'true');
933
+ frame.src = url;
934
+ const finish = (ok, detail) => {
935
+ if (done)
936
+ return;
937
+ done = true;
938
+ try {
939
+ frame.remove();
940
+ }
941
+ catch { /* noop */ }
942
+ resolve({ ok, detail });
943
+ };
944
+ const timer = setTimeout(() => {
945
+ const w = (() => { try {
946
+ return frame.contentWindow;
947
+ }
948
+ catch {
949
+ return null;
950
+ } })();
951
+ finish(false, `等 ${timeoutMs}ms 未见 staged 资源启动 (readyState=${w?.document?.readyState || '未知'}, BolloonCore=${typeof w?.BolloonCore})`);
952
+ }, timeoutMs);
953
+ frame.onload = () => {
954
+ let tries = 0;
955
+ const tick = () => {
956
+ tries++;
957
+ let w = null;
958
+ try {
959
+ w = frame.contentWindow;
960
+ }
961
+ catch { /* 跨域 */ }
962
+ const d = w?.document;
963
+ if (d && d.readyState === 'complete' && d.querySelector && d.querySelector('.tabbar') && w.BolloonCore && typeof w.BolloonCore === 'object') {
964
+ clearTimeout(timer);
965
+ finish(true, `staged 资源真起来了 (tabbar + BolloonCore, ${tries} 次探测)`);
966
+ return;
967
+ }
968
+ if (tries > 60) {
969
+ clearTimeout(timer);
970
+ finish(false, `staged 资源加载完但没起来 (readyState=${d?.readyState || '未知'}, tabbar=${!!d?.querySelector?.('.tabbar')}, BolloonCore=${typeof w?.BolloonCore})`);
971
+ return;
972
+ }
973
+ setTimeout(tick, 150);
974
+ };
975
+ tick();
976
+ };
977
+ frame.onerror = () => { clearTimeout(timer); finish(false, 'iframe 加载失败'); };
978
+ try {
979
+ doc.body.appendChild(frame);
980
+ }
981
+ catch (e) {
982
+ clearTimeout(timer);
983
+ finish(false, `挂载 iframe 失败: ${e?.message || e}`);
984
+ }
985
+ });
986
+ }
987
+ /**
988
+ * 把目标版本**准备到 `staging/`** 并验证 —— **一个字节都不切换 current**。
989
+ * 这是"智能体可以自动跑到的那一步" (见 MOBILE_UPDATE_AGENT_CONTRACT), 也是人工 apply 的前半段。
990
+ */
991
+ export async function prepareMobileUpdate(deps, check) {
992
+ const store = deps.store;
993
+ const now = new Date(deps.now ? deps.now() : Date.now()).toISOString();
994
+ const stage = (s, detail) => { try {
995
+ deps.onStage?.(s, detail);
996
+ }
997
+ catch { /* 回调不许影响流程 */ } };
998
+ const steps = [];
999
+ const mark = (s) => { steps.push(s); };
1000
+ const r = check || await checkMobileUpdate(deps);
1001
+ const from = r.currentVersion;
1002
+ const to = r.artifact?.identity || r.targetIdentity || from;
1003
+ const fail = async (stageName, status, reason, extra = {}) => {
1004
+ mark(`${stageName}: ${reason}`);
1005
+ await store.remove(WEB_LAYOUT.staging);
1006
+ await writeMobileUpdateState({
1007
+ lastCheck: { at: now, status: r.status, reason: r.reason, channel: r.channel, latestVersion: r.latestVersion },
1008
+ lastFailure: { at: now, stage: stageName, reason },
1009
+ autoPrepared: null,
1010
+ }, store);
1011
+ // `stage` 报"最终停在哪"而不是"哪个子步骤红了": 失败一律是 'failed', 具体位置看 failedAt。
1012
+ // (真跑抓到过: 下载失败时 stage='downloading' 会让调用方把"没装成"读成"正在下载"。)
1013
+ return { ok: false, stage: 'failed', failedAt: stageName, status, from, to, reason, steps, fileCount: 0, digest: null, probe: null, skippedDownload: false, ...extra };
1014
+ };
1015
+ if (refused(r.status)) {
1016
+ return fail('blocked', r.status, `拒绝更新 (${r.status}): ${r.reason || '源不可达 / 版本不存在'}`, { stage: 'blocked' });
1017
+ }
1018
+ if (r.status === 'up_to_date') {
1019
+ mark(`up_to_date: ${r.reason || '已是最新'}`);
1020
+ return { ok: false, stage: 'blocked', status: 'up_to_date', from, to, reason: `已是最新 (${from}), 无需更新`, steps, fileCount: 0, digest: null, probe: null, skippedDownload: true };
1021
+ }
1022
+ if (!r.artifact)
1023
+ return fail('planned', 'target_unpublished', '没有可执行的下载计划 (artifact 缺失)');
1024
+ // 已经有同样身份的 staging 且自报验证过 → 复用 (智能体先自动准备好, 人再确认切换)
1025
+ const prevState = await readMobileUpdateState(store);
1026
+ if (prevState.autoPrepared?.identity === to && (await store.exists(joinRel(WEB_LAYOUT.staging, 'mobile.html')))) {
1027
+ mark(`reuse-staging: 复用已准备并验证过的 ${to}`);
1028
+ const probe = await verifyWebLayer(store, WEB_LAYOUT.staging, { bootTimeoutMs: deps.bootTimeoutMs });
1029
+ if (probe.ok) {
1030
+ return { ok: true, stage: 'awaiting_human', status: 'update_available', from, to, reason: `staging 里已有验证过的 ${to} (digest ${prevState.autoPrepared.digest || '无'}) — 等人工确认切换`, steps, fileCount: 0, digest: prevState.autoPrepared.digest, probe, skippedDownload: true };
1031
+ }
1032
+ mark('reuse-staging: 复用的 staging 没通过验证 → 重新下载');
1033
+ }
1034
+ // 1. 下载
1035
+ stage('downloading', `${r.artifact.channel} · ${r.artifact.identity} ← ${r.artifact.url}`);
1036
+ mark(`downloading: ${r.artifact.url}`);
1037
+ const dl = await downloadArtifact(r.artifact, deps);
1038
+ if (!dl.ok || !dl.bytes) {
1039
+ return fail('downloading', dl.status || 'registry_unavailable', dl.detail);
1040
+ }
1041
+ stage('downloaded', `已下载 ${(dl.bytes.length / 1048576).toFixed(2)} MiB · ${dl.detail}`);
1042
+ mark(`downloaded: ${(dl.bytes.length / 1048576).toFixed(2)} MiB · ${dl.detail}`);
1043
+ // 2. 解压 (gzip + tar 都在本文件里, 不依赖 node:)
1044
+ stage('extracting', '解压 tar.gz 并挑出 web 资源 (dist/web)');
1045
+ const tarBytes = await gunzipBytes(dl.bytes);
1046
+ if (!tarBytes) {
1047
+ return fail('extracting', 'decompress_unavailable', 'decompress_unavailable: 本机 WebView 没有 DecompressionStream(\'gzip\') ⇒ 无法解开 tar.gz — 拒绝 (不装半个)。'
1048
+ + ' iOS 16.4+ / Chrome 80+ 才有; 旧 WebView 请升级系统或走商店更新整个 App。');
1049
+ }
1050
+ const entries = untarBytes(tarBytes);
1051
+ const { files, snapshot } = pickWebLayer(entries);
1052
+ if (files.length === 0) {
1053
+ return fail('extracting', 'web_bundle_malformed', `web_bundle_malformed: 包里没有 dist/web/** 资源 (tar 条目 ${entries.length} 个) — 拒绝`);
1054
+ }
1055
+ mark(`extracting: ${files.length} 个 web 文件, 包内 snapshot 标记 = ${snapshot ? `有 (sha ${String(snapshot.sha || '').slice(0, 7)})` : '无'}`);
1056
+ // 3. dev 身份核对 (包必须自报 GitHub master 的那个 commit)
1057
+ if (r.artifact.channel === 'dev') {
1058
+ const want = (r.artifact.expectedDevSha || '').slice(0, 7);
1059
+ const got = snapshot?.sha ? String(snapshot.sha).slice(0, 7) : null;
1060
+ if (!snapshot) {
1061
+ return fail('extracting', 'dev_sha_mismatch', `dev_sha_mismatch: dev web 包里没有 .bolloon-dev-snapshot.json ⇒ 无法证明它是 ${want} 的快照 — 拒绝`);
1062
+ }
1063
+ if (snapshot.channel !== 'dev') {
1064
+ return fail('extracting', 'dev_sha_mismatch', `dev_sha_mismatch: 包内 snapshot 标的是 channel=${snapshot.channel} (不是 dev) — 拒绝`);
1065
+ }
1066
+ if (!got || got !== want) {
1067
+ return fail('extracting', 'dev_sha_mismatch', `dev_sha_mismatch: 包内 sha=${got || '空'} ≠ GitHub master HEAD ${want} — 拒绝安装身份对不上的快照`);
1068
+ }
1069
+ if (snapshot.identity && to && String(snapshot.identity) !== to) {
1070
+ return fail('extracting', 'dev_sha_mismatch', `dev_sha_mismatch: 包内 identity=${snapshot.identity} 与目标身份 ${to} 不一致 — 拒绝`);
1071
+ }
1072
+ }
1073
+ // 4. 落 staging (先清空, 半份 staging 比没有更危险)
1074
+ await store.remove(WEB_LAYOUT.staging);
1075
+ for (const f of files)
1076
+ await store.writeBytes(joinRel(WEB_LAYOUT.staging, f.rel), f.data);
1077
+ stage('staged', `${files.length} 个文件已写入 ${store.rootLabel}/${WEB_LAYOUT.staging}`);
1078
+ mark(`staged: ${files.length} 个文件 → ${WEB_LAYOUT.staging}`);
1079
+ // 5. 验证 (结构 + 语法 + 真启动探测)
1080
+ stage('verifying', '验证 staging 能不能启动 (结构 + 语法 + iframe 真加载)');
1081
+ const probe = await verifyWebLayer(store, WEB_LAYOUT.staging, { bootTimeoutMs: deps.bootTimeoutMs });
1082
+ mark(`verifying: ${probe.mode} — ${probe.detail}`);
1083
+ if (!probe.ok) {
1084
+ return fail('verifying', 'web_bundle_malformed', `切换前验证失败 (staged 资源起不来): ${probe.detail}`, { probe });
1085
+ }
1086
+ const digest = dl.digest.matched === true ? `${dl.digest.algo}:${dl.digest.hex || dl.digest.b64}` : null;
1087
+ await writeMobileUpdateState({
1088
+ lastCheck: { at: now, status: r.status, reason: r.reason, channel: r.channel, latestVersion: r.latestVersion },
1089
+ autoPrepared: { identity: to, digest, verifiedAt: now, steps: steps.slice() },
1090
+ }, store);
1091
+ return { ok: true, stage: 'awaiting_human', status: 'update_available', from, to, reason: `已准备并验证 ${to} (等人工确认切换)`, steps, fileCount: files.length, digest, probe, skippedDownload: false };
1092
+ }
1093
+ /** 切换 (原子替换) + 换完再验一次 + 失败回滚。**只有这一步会动 current**。 */
1094
+ export async function applyMobileUpdate(opts) {
1095
+ const started = Date.now();
1096
+ const store = opts.store;
1097
+ const now = new Date(opts.now ? opts.now() : Date.now()).toISOString();
1098
+ const stage = (s, detail) => { try {
1099
+ opts.onStage?.(s, detail);
1100
+ }
1101
+ catch { /* 回调不许影响流程 */ } };
1102
+ const r = await checkMobileUpdate(opts);
1103
+ const from = r.currentVersion;
1104
+ const to = r.artifact?.identity || r.targetIdentity || from;
1105
+ const steps = [];
1106
+ const finish = async (o) => {
1107
+ const out = { ...o, durationMs: Date.now() - started, steps };
1108
+ const hist = [{ at: now, from: out.from, to: out.to, stage: out.stage, reason: out.reason }];
1109
+ const st = await readMobileUpdateState(store);
1110
+ await writeMobileUpdateState({
1111
+ lastUpdate: hist[0],
1112
+ history: [...hist, ...(st.history || [])].slice(0, 20),
1113
+ lastFailure: out.ok ? null : { at: now, stage: out.failedAt || out.stage, reason: out.reason },
1114
+ needsReload: out.ok ? true : st.needsReload,
1115
+ ...(out.ok ? {
1116
+ currentVersion: out.to,
1117
+ installedChannel: (r.artifact?.channel || 'stable'),
1118
+ installedDevSha: devShaFromIdentity(out.to),
1119
+ installedFrom: {
1120
+ channel: (r.artifact?.channel || 'stable'),
1121
+ source: r.artifact?.source || 'unknown',
1122
+ url: r.artifact?.url || null,
1123
+ digest: out.digest,
1124
+ digestAlgo: out.digest ? out.digest.split(':')[0] : null,
1125
+ at: now,
1126
+ },
1127
+ switchableTo: (r.artifact?.channel === 'dev')
1128
+ ? { channel: 'stable', source: 'npm', target: r.sourceFacts?.npm?.latest ?? null }
1129
+ : { channel: 'dev', source: 'github', target: r.github?.headSha ? r.github.headSha.slice(0, 7) : null },
1130
+ // 从 dev 切回 stable 时, **保留**刚离开的那个 dev 快照 sha: 用户回头想回 dev 时知道上次是哪个
1131
+ ...(r.artifact?.channel === 'dev'
1132
+ ? { lastDevSha: devShaFromIdentity(out.to), devRef: DEV_REF, devCheckedAt: now }
1133
+ : { lastDevSha: r.installedDevSha || null }),
1134
+ autoPrepared: null,
1135
+ } : {}),
1136
+ }, store);
1137
+ return out;
1138
+ };
1139
+ if (refused(r.status)) {
1140
+ stage('blocked', `拒绝更新 (${r.status})`);
1141
+ return finish({ ok: false, stage: 'blocked', status: r.status, from, to, reason: `拒绝更新 (${r.status}): ${r.reason || '源不可达 / 版本不存在'} — 一个字节都没下载, 也没动 current`, digest: null, probe: null, needsReload: false });
1142
+ }
1143
+ if (!r.artifact) {
1144
+ stage('blocked', '没有可执行的更新');
1145
+ return finish({ ok: false, stage: 'blocked', status: r.status, from, to, reason: r.status === 'up_to_date' ? `已是最新 (${from})` : `没有可执行的更新 (${r.status})`, digest: null, probe: null, needsReload: false });
1146
+ }
1147
+ if (!opts.confirm) {
1148
+ stage('blocked', '需要人工确认');
1149
+ return finish({
1150
+ ok: false, stage: 'blocked', status: 'update_available', from, to,
1151
+ reason: 'human_confirm_required: 切换 web 资源层会换掉正在跑的界面与逻辑 — 必须有人在环 (智能体只能自动跑到 prepare/verify)',
1152
+ digest: null, probe: null, needsReload: false,
1153
+ });
1154
+ }
1155
+ // 准备 (复用或重新做一遍)
1156
+ let prep = null;
1157
+ if (opts.reuseStaging) {
1158
+ stage('verifying', '复用 staging 里已准备并验证过的资源');
1159
+ const probe = await verifyWebLayer(store, WEB_LAYOUT.staging, { bootTimeoutMs: opts.bootTimeoutMs });
1160
+ if (probe.ok) {
1161
+ const st = await readMobileUpdateState(store);
1162
+ const identity = st.autoPrepared?.identity || to;
1163
+ if (identity !== to) {
1164
+ stage('blocked', `staging 的身份 ${identity} ≠ 目标 ${to}`);
1165
+ return finish({ ok: false, stage: 'blocked', status: 'web_bundle_malformed', from, to, reason: `staging 里准备的是 ${identity}, 与本次目标 ${to} 不一致 — 拒绝切换 (宁可不装, 不装错的)`, digest: null, probe, needsReload: false });
1166
+ }
1167
+ prep = { ok: true, stage: 'awaiting_human', status: 'update_available', from, to, reason: '复用 staging', steps: st.autoPrepared?.steps || [], fileCount: 0, digest: st.autoPrepared?.digest ?? null, probe, skippedDownload: true };
1168
+ steps.push(...(prep.steps || []).map((s) => `auto: ${s}`));
1169
+ }
1170
+ }
1171
+ if (!prep) {
1172
+ const p = await prepareMobileUpdate(opts, r);
1173
+ if (!p.ok) {
1174
+ return finish({ ok: false, stage: p.stage, failedAt: p.failedAt, status: p.status, from, to, reason: p.reason, digest: p.digest, probe: p.probe, needsReload: false });
1175
+ }
1176
+ prep = p;
1177
+ steps.push(...p.steps);
1178
+ }
1179
+ // ── 原生壳天花板: 接不上就不切 (装了也不会生效 ⇒ 就是假的成功) ──────────────
1180
+ if (!nativeWired(opts)) {
1181
+ stage('blocked', '原生壳还没指向可写目录 → 不切换');
1182
+ steps.push(`native_shell_not_wired: 资源已准备并验证过 (${WEB_LAYOUT.staging}), 但当前原生壳不会加载可写目录里的资源 ⇒ 切换只会把"能用的旧版本"换成"壳层加载不到的新版本" — 拒绝切换`);
1183
+ return finish({
1184
+ ok: false, stage: 'blocked', status: 'native_shell_not_wired', from, to,
1185
+ reason: 'native_shell_not_wired: 手机 App 的原生壳还指向包内资源 (没有指向可写目录), '
1186
+ + `所以这次装到 ${to} 不会生效 — 已在 ${WEB_LAYOUT.staging} 准备好并通过启动验证, 但没有切换 current。`
1187
+ + '修法 (二选一, 见 update-protocol §13.4): ① 把壳层 server.url 指向本机 http 服务的可写目录; '
1188
+ + '② 等原生层改造随商店/侧载发一次。' + NATIVE_CEILING_LINE,
1189
+ digest: prep.digest, probe: prep.probe, needsReload: false,
1190
+ });
1191
+ }
1192
+ // ── 切换: previous 备份 → staging 上位; 任何一步失败都必须回到能启动的旧资源 ──
1193
+ stage('switching', `原子替换: ${WEB_LAYOUT.current} → ${WEB_LAYOUT.previous}, ${WEB_LAYOUT.staging} → ${WEB_LAYOUT.current}`);
1194
+ const hadCurrent = await store.exists(joinRel(WEB_LAYOUT.current, 'mobile.html'));
1195
+ let backupMade = false;
1196
+ try {
1197
+ // `failSwitch` 是一个**注入点** (验收用): 'before_backup' 验"没动 current 就报 failed",
1198
+ // 'after_backup' 才验**真回滚** (previous 已存在 → 必须搬回 current)。`true` 等价于 before_backup。
1199
+ const injectAt = opts.failSwitch === true ? 'before_backup' : opts.failSwitch;
1200
+ if (injectAt === 'before_backup')
1201
+ throw Object.assign(new Error('注入的切换失败 (在动 current 之前)'), { injected: true });
1202
+ await store.remove(WEB_LAYOUT.previous);
1203
+ if (hadCurrent) {
1204
+ await store.rename(WEB_LAYOUT.current, WEB_LAYOUT.previous);
1205
+ backupMade = true;
1206
+ }
1207
+ if (injectAt === 'after_backup')
1208
+ throw Object.assign(new Error('注入的切换失败 (备份已做, staging 未上位)'), { injected: true });
1209
+ await store.rename(WEB_LAYOUT.staging, WEB_LAYOUT.current);
1210
+ steps.push(`switching: ${WEB_LAYOUT.current} 已替换 (旧资源保留在 ${WEB_LAYOUT.previous})`);
1211
+ }
1212
+ catch (e) {
1213
+ const reason = String(e?.message || e);
1214
+ // 回滚: staging 的残留清掉, previous 里那份**能启动的旧资源**搬回 current
1215
+ let rolled = false;
1216
+ try {
1217
+ await store.remove(WEB_LAYOUT.staging);
1218
+ if (backupMade) {
1219
+ await store.remove(WEB_LAYOUT.current);
1220
+ await store.rename(WEB_LAYOUT.previous, WEB_LAYOUT.current);
1221
+ rolled = true;
1222
+ }
1223
+ else if (hadCurrent) {
1224
+ rolled = false; // current 没被动过
1225
+ }
1226
+ const probeAfter = rolled || hadCurrent ? await verifyWebLayer(store, WEB_LAYOUT.current, { bootTimeoutMs: opts.bootTimeoutMs }) : null;
1227
+ steps.push(`rolled_back: ${rolled ? '已把 previous 搬回 current' : 'current 未被改动'} · 启动验证 ${probeAfter ? (probeAfter.ok ? '通过' : '未通过') : '跳过'}`);
1228
+ stage(rolled ? 'rolled_back' : 'failed', `${reason}; ${rolled ? '已回滚到旧资源' : '旧资源未被改动'}`);
1229
+ return finish({
1230
+ ok: false, stage: rolled ? 'rolled_back' : 'failed', failedAt: 'switching', status: 'update_available', from, to,
1231
+ reason: `${reason}; ${rolled ? `已回滚到 ${from} (旧资源回到 current 且验证${probeAfter?.ok ? '通过' : '未通过'})` : 'current 未被改动, 旧资源仍在'}`,
1232
+ digest: prep.digest, probe: probeAfter, needsReload: false,
1233
+ });
1234
+ }
1235
+ catch (e2) {
1236
+ stage('failed', `${reason}; 回滚过程出错: ${e2?.message || e2}`);
1237
+ return finish({
1238
+ ok: false, stage: 'failed', failedAt: 'switching', status: 'update_available', from, to,
1239
+ reason: `${reason}; 回滚过程出错: ${e2?.message || e2}`,
1240
+ digest: prep.digest, probe: null, needsReload: false,
1241
+ });
1242
+ }
1243
+ }
1244
+ // 换完再验一次 (切换本身也可能出错): 不通过就回滚
1245
+ stage('verifying', '切换后再验一次 (能不能启动)');
1246
+ const after = await verifyWebLayer(store, WEB_LAYOUT.current, { bootTimeoutMs: opts.bootTimeoutMs });
1247
+ steps.push(`post-switch verify: ${after.mode} — ${after.detail}`);
1248
+ if (!after.ok) {
1249
+ let rolled = false;
1250
+ try {
1251
+ await store.remove(WEB_LAYOUT.current);
1252
+ if (backupMade) {
1253
+ await store.rename(WEB_LAYOUT.previous, WEB_LAYOUT.current);
1254
+ rolled = true;
1255
+ }
1256
+ stage('rolled_back', `切换后验证失败, ${rolled ? '已回滚' : '没有可回滚的旧资源'}`);
1257
+ }
1258
+ catch { /* 下面按失败记 */ }
1259
+ return finish({
1260
+ ok: false, stage: rolled ? 'rolled_back' : 'failed', failedAt: 'verifying', status: 'update_available', from, to,
1261
+ reason: `切换后验证失败: ${after.detail}; ${rolled ? `已回滚到 ${from}` : '回滚失败 — 需要重装 App'}`,
1262
+ digest: prep.digest, probe: after, needsReload: false,
1263
+ });
1264
+ }
1265
+ stage('succeeded', `已切到 ${to}`);
1266
+ return finish({ ok: true, stage: 'succeeded', status: 'update_available', from, to, reason: `已更新到 ${to} (重启/重载 webview 后生效)`, digest: prep.digest, probe: after, needsReload: true });
1267
+ }
1268
+ /** 一键回滚到上一个能启动的资源 (人手动兜底; 智能体不得自动调, 见契约)。 */
1269
+ export async function rollbackMobileUpdate(opts) {
1270
+ const store = opts.store;
1271
+ const started = Date.now();
1272
+ const now = new Date(opts.now ? opts.now() : Date.now()).toISOString();
1273
+ const st = await readMobileUpdateState(store);
1274
+ const from = st.currentVersion || 'unknown';
1275
+ const steps = [];
1276
+ const finish = async (o) => {
1277
+ const out = { ...o, durationMs: Date.now() - started, steps };
1278
+ await writeMobileUpdateState({
1279
+ lastUpdate: { at: now, from: out.from, to: out.to, stage: out.stage, reason: out.reason },
1280
+ history: [{ at: now, from: out.from, to: out.to, stage: out.stage, reason: out.reason }, ...(st.history || [])].slice(0, 20),
1281
+ }, store);
1282
+ return out;
1283
+ };
1284
+ if (!opts.confirm) {
1285
+ return finish({ ok: false, stage: 'blocked', status: 'update_available', from, to: from, reason: 'human_confirm_required: 回滚会换掉正在跑的 web 资源 — 必须有人在环', digest: null, probe: null, needsReload: false });
1286
+ }
1287
+ if (!(await store.exists(joinRel(WEB_LAYOUT.previous, 'mobile.html')))) {
1288
+ return finish({ ok: false, stage: 'blocked', status: 'web_bundle_malformed', from, to: from, reason: `没有可回滚的旧资源 (${WEB_LAYOUT.previous} 空) — 不假装回滚过`, digest: null, probe: null, needsReload: false });
1289
+ }
1290
+ const probeBefore = await verifyWebLayer(store, WEB_LAYOUT.previous, { bootTimeoutMs: opts.bootTimeoutMs });
1291
+ steps.push(`previous 探测: ${probeBefore.detail}`);
1292
+ if (!probeBefore.ok) {
1293
+ return finish({ ok: false, stage: 'blocked', status: 'web_bundle_malformed', from, to: from, reason: `previous 里的旧资源起不来 (${probeBefore.detail}) — 回滚它只会更糟, 拒绝`, digest: null, probe: probeBefore, needsReload: false });
1294
+ }
1295
+ try {
1296
+ await store.remove(joinRel(WEB_LAYOUT.current, ''));
1297
+ await store.rename(WEB_LAYOUT.previous, WEB_LAYOUT.current);
1298
+ }
1299
+ catch (e) {
1300
+ return finish({ ok: false, stage: 'failed', failedAt: 'switching', status: 'update_available', from, to: from, reason: `回滚失败: ${e?.message || e}`, digest: null, probe: null, needsReload: false });
1301
+ }
1302
+ const after = await verifyWebLayer(store, WEB_LAYOUT.current, { bootTimeoutMs: opts.bootTimeoutMs });
1303
+ steps.push(`回滚后探测: ${after.detail}`);
1304
+ return finish({
1305
+ ok: after.ok, stage: after.ok ? 'rolled_back' : 'failed', status: 'update_available', from, to: from,
1306
+ reason: after.ok ? `已回滚到上一个资源层 (需要重载 webview 生效)` : `回滚后资源起不来: ${after.detail}`,
1307
+ digest: null, probe: after, needsReload: after.ok,
1308
+ });
1309
+ }
1310
+ // ── 给"未来智能体在手机自动更新"用的契约 (前置条件 + 风险边界) ─────────────
1311
+ /**
1312
+ * **智能体自主更新的边界** —— 这一段是代码里的口径, wiki `update-protocol.md` §13.5 是同一份。
1313
+ *
1314
+ * 一句话: 智能体可以自动跑到"**准备并验证**", **不许**自己动 current (切换/重载/回滚),
1315
+ * 也**不许**自己切通道。理由不是"技术做不到", 而是这三件事的**代价不对称**:
1316
+ * 准备失败 = 白费流量; 切换失败 = 用户手里的 App 可能白屏 (而那时用户正需要它)。
1317
+ */
1318
+ export const MOBILE_UPDATE_AGENT_CONTRACT = {
1319
+ schema: 'bolloon-mobile-agent-update/1',
1320
+ /** 可以自动做 (只读 + 只在 staging 里写, 不影响正在跑的资源) */
1321
+ autoAllowedSteps: ['check', 'prepare', 'download', 'digest-verify', 'extract', 'stage', 'verify'],
1322
+ /** 必须人在环 (会换掉正在跑的界面与逻辑 ⇒ 代价不对称) */
1323
+ humanRequiredSteps: ['switch', 'reload', 'rollback', 'channel-switch'],
1324
+ preconditions: [
1325
+ '本机 web 层的身份可读 (壳层注入 / OTA state / 构建戳 bolloon-web.json 三者之一) —— 读不到就 local_version_unknown, 不猜',
1326
+ '原生壳已指向可写目录 (native_writable) —— 否则资源准备好了也不会生效, 一律 native_shell_not_wired 并说清修法',
1327
+ '设备存储余量够 (下载物 ≤ 上限, 默认 80 MiB) —— 超了判 payload_too_large, 不赌内存',
1328
+ '本机 WebView 有 DecompressionStream(\'gzip\') (iOS 16.4+ / Chrome 80+) —— 没有就 decompress_unavailable',
1329
+ '稳定网络: 源不可达一律 offline / registry_unavailable / github_unavailable(reason) 拒绝, 不静默重试到"看起来成功"',
1330
+ '摘要可校验: npm 侧用 dist.shasum/dist.integrity; dev 侧用包内 .bolloon-dev-snapshot.json 的 sha + bundleSha256',
1331
+ ],
1332
+ riskBoundaries: [
1333
+ 'iOS 原生壳层**不能自更** (App Store 规则) ⇒ 不承诺"iOS 整体自动更新"; web 层能更, 二进制走商店/TestFlight',
1334
+ 'Android 侧载 APK 自更**需用户允许未知来源** ⇒ 必须显式告知 + 用户在环, 本轮不做静默装包',
1335
+ '切换前必须真跑一次"能不能启动"探测 (结构+语法+iframe), 探测不通过不许切进 current',
1336
+ '切换必须原子 (rename) 且保留 previous; 任何一步失败都要回到"能启动的旧资源"',
1337
+ 'dev 通道不保证可回滚到上一个 dev 版 (与桌面同一句警告); 一键回 stable 始终可用',
1338
+ '源不可达/版本不存在/交叉校验不一致/摘要不符 ⇒ 拒绝并分类说清, 绝不静默装回旧版、绝不假装成功',
1339
+ ],
1340
+ };
1341
+ /** 智能体入口: **只**跑到"准备好并验证过"就停, 返回 awaiting_human。 */
1342
+ export async function autoPrepareMobileUpdate(deps) {
1343
+ return prepareMobileUpdate(deps);
1344
+ }
1345
+ // ── 报告 (与桌面同措辞的可见性) ─────────────────────────────────────────────
1346
+ /** 一行"当前源身份"(与桌面 `update status` 同措辞) + 手机端特有的两行 (层 / 自更能力)。 */
1347
+ export function renderMobileUpdateReport(r, st) {
1348
+ const L = [];
1349
+ L.push(r.identityLine);
1350
+ L.push(`更新层: web 资源层 (界面与逻辑: 入群 / 发任务公告 / 看飞轮进度 / 授权签名) · store=${r.storeKind}`);
1351
+ L.push(`能切回: ${r.switchableTo ? `${r.switchableTo.channel} (${r.switchableTo.source}${r.switchableTo.target ? ` @ ${r.switchableTo.target}` : ''})` : '无 (未知来源)'}`);
1352
+ L.push(`目标版本: ${r.targetIdentity || r.latestVersion || '未知'}`);
1353
+ L.push(`更新通道: ${r.channel} (比较语义: ${r.channelKind})`);
1354
+ L.push(renderGithubReportLine(r.github));
1355
+ if (r.crossCheck)
1356
+ L.push(`交叉校验: ${r.crossCheck.kind}${r.crossCheck.blocking ? ' (阻塞)' : ''} — ${r.crossCheck.detail}`);
1357
+ if (r.dev)
1358
+ L.push(`dev 判定: ${r.dev.detail}`);
1359
+ if (r.sourceFacts?.npm)
1360
+ L.push(`npm 源: ${r.sourceFacts.npm.reachable ? `可达 (latest=${r.sourceFacts.npm.latest || '未知'})` : `不可达 (${r.sourceFacts.npm.detail || '未知'})`}`);
1361
+ if (st?.installedFrom)
1362
+ L.push(`当前来源: ${st.installedFrom.channel} · ${st.installedFrom.source}${st.installedFrom.digest ? ` · 摘要 ${st.installedFrom.digest}` : ''}${st.installedFrom.at ? ` · ${st.installedFrom.at}` : ''}`);
1363
+ if (st?.lastDevSha && r.installedChannel !== 'dev')
1364
+ L.push(`上次 dev: commit ${st.lastDevSha} (已切回 stable)`);
1365
+ L.push(`结论: ${r.status}${refused(r.status) ? ' — 拒绝 (没有安装任何东西)' : ''}`);
1366
+ if (r.reason)
1367
+ L.push(`说明: ${r.reason}`);
1368
+ for (const w of r.warnings)
1369
+ L.push(w);
1370
+ return L;
1371
+ }
1372
+ /** 手机端(和所有人)必须知道的原生层天花板 —— UI 直接显示这一句。 */
1373
+ export const NATIVE_CEILING_LINE = '原生壳层不能在这里自更: iOS 受 App Store 规则限制 (二进制走商店/TestFlight), Android 侧载 APK 自更需要你允许"未知来源" —— 这里能自动更新的是 web 资源层 (界面与逻辑)。';
1374
+ export { UPSTREAM_REPO, DEV_IDENTITY_SEP };
1375
+ // ── 真机 store 自动选 / 通道偏好 / 重载 ─────────────────────────────────────
1376
+ /** 通道偏好的本机键 (localStorage; 拿不到就退回 state.json) */
1377
+ export const MOBILE_CHANNEL_KEY = 'bolloon.update.channel';
1378
+ /** 真机: 有 Capacitor Filesystem 就用可写数据目录; 没有 (浏览器/验收) 就用内存 store。 */
1379
+ export function createAutoWebStore() {
1380
+ // 壳层可以塞自己的 store (例如用原生文件系统插件实现同一套接口) —— 验收 harness 也走这个口子,
1381
+ // 这样"真浏览器里跑真流水线"用的是同一条代码路径, 不另开一条后门。
1382
+ const injected = globalThis.__bolloonWebStore;
1383
+ if (injected && typeof injected.writeBytes === 'function' && typeof injected.rename === 'function')
1384
+ return injected;
1385
+ const cap = globalThis.Capacitor;
1386
+ const fs = cap?.Plugins?.Filesystem;
1387
+ if (fs && typeof fs.writeFile === 'function') {
1388
+ const base = globalThis.__bolloonWebBaseUrl || null;
1389
+ return createCapacitorWebStore(fs, { basePath: 'bolloon-web', directory: 'DATA', baseUrl: base });
1390
+ }
1391
+ return createMemoryWebStore({ kind: 'memory', rootLabel: 'memory://webview', baseUrl: globalThis.__bolloonWebBaseUrl || null });
1392
+ }
1393
+ export function storedMobileChannel() {
1394
+ try {
1395
+ const v = globalThis.localStorage?.getItem?.(MOBILE_CHANNEL_KEY);
1396
+ return v ? String(v) : null;
1397
+ }
1398
+ catch {
1399
+ return null;
1400
+ }
1401
+ }
1402
+ /**
1403
+ * 切通道 —— **只写偏好, 不装东西** (装是 apply 的事)。非法值 → 不猜, 保持原通道并如实返回。
1404
+ */
1405
+ export async function setMobileUpdateChannel(channel, store) {
1406
+ const want = String(channel || '').toLowerCase();
1407
+ if (want !== 'stable' && want !== 'dev') {
1408
+ const cur = storedMobileChannel() || 'stable';
1409
+ return { ok: false, channel: cur, detail: `未知通道 "${channel}" (只有 stable / dev) — 保持 ${cur}` };
1410
+ }
1411
+ const prev = storedMobileChannel();
1412
+ try {
1413
+ globalThis.localStorage?.setItem?.(MOBILE_CHANNEL_KEY, want);
1414
+ }
1415
+ catch { /* 存不上就只对本次会话有效 */ }
1416
+ if (store)
1417
+ await writeMobileUpdateState({ channelPref: want }, store);
1418
+ return { ok: true, channel: want, detail: `通道 ${prev || 'stable'} → ${want} (只改偏好, 还没有安装任何东西; 点「检查更新」再决定装不装)` };
1419
+ }
1420
+ /**
1421
+ * 让新装的 web 资源生效 —— **人在环**的最后一步。
1422
+ * Capacitor 壳层能重载就重载 WebView; 否则退回 `location.reload()`。
1423
+ * 非浏览器环境 (单测/Node 验收) 如实返回 `none`, **不假装重载过**。
1424
+ */
1425
+ export async function reloadMobileWeb() {
1426
+ const cap = globalThis.Capacitor;
1427
+ try {
1428
+ if (cap?.Plugins?.WebView?.reload) {
1429
+ await cap.Plugins.WebView.reload();
1430
+ return { ok: true, how: 'capacitor-webview', detail: '已请求 WebView 重载 (新 web 资源生效)' };
1431
+ }
1432
+ }
1433
+ catch (e) {
1434
+ return { ok: false, how: 'capacitor-webview', detail: `WebView 重载失败: ${String(e?.message || e)} — 请手动重启 App` };
1435
+ }
1436
+ const loc = globalThis.location;
1437
+ if (loc && typeof loc.reload === 'function') {
1438
+ loc.reload();
1439
+ return { ok: true, how: 'location.reload', detail: '已重载页面' };
1440
+ }
1441
+ return { ok: false, how: 'none', detail: '本环境没有可重载的 WebView (验收/Node) — 未假装重载' };
1442
+ }