dsh-life-pack 0.2.7 → 0.3.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 (42) hide show
  1. package/dist/client.js +351 -102
  2. package/dist/client.js.map +1 -1
  3. package/dist/dsh-ctx.d.ts +9 -5
  4. package/dist/dsh-ctx.d.ts.map +1 -1
  5. package/dist/health-fetch.d.ts +14 -14
  6. package/dist/health-fetch.d.ts.map +1 -1
  7. package/dist/health-fetch.js +21 -5
  8. package/dist/health-fetch.js.map +1 -1
  9. package/dist/health-panel.d.ts +6 -0
  10. package/dist/health-panel.d.ts.map +1 -1
  11. package/dist/health-panel.js +13 -0
  12. package/dist/health-panel.js.map +1 -1
  13. package/dist/health-view.d.ts.map +1 -1
  14. package/dist/health-view.js +20 -9
  15. package/dist/health-view.js.map +1 -1
  16. package/dist/manager-version.d.ts +7 -0
  17. package/dist/manager-version.d.ts.map +1 -0
  18. package/dist/manager-version.js +62 -0
  19. package/dist/manager-version.js.map +1 -0
  20. package/dist/nav.d.ts +18 -2
  21. package/dist/nav.d.ts.map +1 -1
  22. package/dist/nav.js +10 -8
  23. package/dist/nav.js.map +1 -1
  24. package/dist/update-client.d.ts +5 -0
  25. package/dist/update-client.d.ts.map +1 -1
  26. package/dist/update-client.js +16 -4
  27. package/dist/update-client.js.map +1 -1
  28. package/dist/update-contract.d.ts +18 -0
  29. package/dist/update-contract.d.ts.map +1 -1
  30. package/dist/update-contract.js +48 -19
  31. package/dist/update-contract.js.map +1 -1
  32. package/dist/update-env.d.ts.map +1 -1
  33. package/dist/update-env.js +7 -11
  34. package/dist/update-env.js.map +1 -1
  35. package/dist/update-host.d.ts.map +1 -1
  36. package/dist/update-host.js +4 -0
  37. package/dist/update-host.js.map +1 -1
  38. package/dist/update-view.d.ts +43 -6
  39. package/dist/update-view.d.ts.map +1 -1
  40. package/dist/update-view.js +93 -21
  41. package/dist/update-view.js.map +1 -1
  42. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -38,42 +38,48 @@ window.__ModuleLoader__.load({
38
38
  slotId: "ilife:memo",
39
39
  order: 70,
40
40
  title: "备忘录",
41
- plugin: "dsh-memo-ilife"
41
+ plugin: "dsh-memo-ilife",
42
+ channel: "/ilife-memo"
42
43
  },
43
44
  {
44
45
  skill: "calorie",
45
46
  slotId: "ilife:calorie",
46
47
  order: 75,
47
48
  title: "卡路里",
48
- plugin: "dsh-calorie"
49
+ plugin: "dsh-calorie",
50
+ channel: "/ilife-calorie"
49
51
  },
50
52
  {
51
53
  skill: "schedule",
52
54
  slotId: "ilife:schedule",
53
55
  order: 80,
54
- title: "作息",
55
- plugin: "dsh-schedule-ilife"
56
+ title: "作息管家",
57
+ plugin: "dsh-schedule-ilife",
58
+ channel: "/ilife-schedule-ilife"
56
59
  },
57
60
  {
58
61
  skill: "home",
59
62
  slotId: "ilife:home",
60
63
  order: 85,
61
- title: "居家",
62
- plugin: "dsh-home-ilife"
64
+ title: "居家管家",
65
+ plugin: "dsh-home-ilife",
66
+ channel: "/ilife-home-ilife"
63
67
  },
64
68
  {
65
69
  skill: "chef",
66
70
  slotId: "ilife:chef",
67
71
  order: 90,
68
- title: "大厨",
69
- plugin: "dsh-chef"
72
+ title: "私家大厨",
73
+ plugin: "dsh-chef",
74
+ channel: "/ilife-chef"
70
75
  },
71
76
  {
72
77
  skill: "bill",
73
78
  slotId: "ilife:cookie",
74
79
  order: 95,
75
- title: "记账",
76
- plugin: "dsh-bill-ilife"
80
+ title: "饼干记账",
81
+ plugin: "dsh-bill-ilife",
82
+ channel: "/ilife-bill-ilife"
77
83
  }
78
84
  ];
79
85
  function recoFor(tab) {
@@ -139,10 +145,17 @@ window.__ModuleLoader__.load({
139
145
  * 一处定义、两边引用(结构纪律「概念唯一」):名字改了只有这一行要动。
140
146
  * 宿主判据为什么要读这个字面量、以及它的边界,见 `readPanelRegistered` 的头注。 */
141
147
  const CONFIG_TAB_SLOT = "ilife.config-tab";
148
+ /** 载体基段:`connection.rpc.call` 的**第一段**参数,宿主注册路径与它同源(`'/api' + 通道名`)。
149
+ *
150
+ * 一处定义(#735):总管自己的电话、以及面板到各家的每一通电话,第一段都必须是它。
151
+ * 真机实现(`dsh-client-connection/lib/client.js`):URL = `${第一段}/${第二段}`、信封 `method` = 第二段。
152
+ * 传错第一段就是 404:总管自己的电话在 #678 栽过一次,各家的配置体检在 #735 又栽过一次
153
+ * (那次传的是通道名 `/ilife-<技能>`,拼出来的地址上没有注册过那条路由,六家全 404)。 */
154
+ const CARRIER_BASE = "/api";
142
155
  /** 载体:宿主注册 `connection.fetch.register` 的路径 / 面板 `connection.rpc.call` 的两段参数。 */
143
156
  const MANAGER_RPC = {
144
157
  /** 载体基段(`connection.rpc.call` 的**第一段**参数)。传错第一段就是 404:真机上踩过一次。 */
145
- base: "/api",
158
+ base: CARRIER_BASE,
146
159
  /** 单段通道名,须 match `/^\/[A-Za-z0-9._~-]+$/`(cookbook §6)。 */
147
160
  channel: "/ilife-manager",
148
161
  /** 面板侧第二段参数(等于通道名去掉前导斜杠那一段)。 */
@@ -160,41 +173,62 @@ window.__ModuleLoader__.load({
160
173
  * (`update.phoneNames`,`host.ts:335`);面板侧既不能 import 更新包(浏览器产物纯度门)
161
174
  * 也不该把名字再写一份(写两份必然走散)。宿主把名字表连同版本行一起交出去,
162
175
  * 面板一行字面量都不留。 */
163
- targets: "ilife-manager.targets"
176
+ targets: "ilife-manager.targets",
177
+ /** 总管自述版本(票 #737):入参 `{}`,回包 `{version}`。
178
+ *
179
+ * 宿主读**自己这份已安装包**的 `package.json`(`manager-version.ts`),面板只渲染读到的值。
180
+ * 为什么走电话:面板是浏览器产物,禁 node 内建(`test/client-bundle-48.test.mjs` 看门),
181
+ * 读盘只许在宿主半;与卡路里 #130 同一条路(host 读 → RPC → client 纯渲染)。 */
182
+ version: "ilife-manager.version"
164
183
  };
165
- /** 装不了 / 装不成的人话原因表:八种 `blockedReason` + 过程错误码(更新包 README 第 8、12 节)。 */
184
+ /** 版本读不到时两侧共用的降级字面量(面板照原样显示,不假装知道版本)。
185
+ *
186
+ * 一处定义、两侧引用:宿主半 `manager-version.ts` 读失败时回它,面板半 `update-client.ts`
187
+ * 归一化时也认它——面板侧不许 import 宿主半(会把 node 内建带进浏览器束)。 */
188
+ const VERSION_UNKNOWN = "unknown";
189
+ /** 装不了 / 装不成的人话原因表:八种 `blockedReason` + 过程错误码(更新包 README 第 8、12 节)。
190
+ *
191
+ * 每条都要三段齐全(票 #740 的文案判据):**事实**(哪里对不上)→ **后果**(这家现在能不能装)
192
+ * → **动作**(用户做得到、且面板上真有的那一步;面板代劳不了的走「下面这条命令」)。
193
+ * 禁用口语与自造词:「半截任务」「电话没接上」「回执异常」「还出现就重装」「查宿主日志」一律不写。
194
+ *
195
+ * 点名的按钮必须是**这一行上真够得着**的那颗(票 #740 第三轮):结果浮层一开就盖住题头的
196
+ * 「检查更新」,所以「改完再读一次」这类下一步一律写「重新检查」(画在那一行上);
197
+ * 只有发生在**重启之后**的下一步才写「检查更新」。反过来,凡是写着「下面这条命令」的那几句,
198
+ * 那一行的命令块必须真出来(判据 `Verdict.manualHint`)——两处都由门禁守着。 */
166
199
  const REASON_TEXT = {
167
- "unknown-profile": "使用范围认不出(名字非法或目录不存在):先检查使用范围名与目录,这种情形不给手工命令。",
168
- "source-install": "当前是从源码装的,不是按版本号装的:想走更新,先按版本号重装一次。",
169
- "invalid-installation": "已装的包不完整(名字对不上、版本非法、入口文件缺失):重装当前版本,修好已装目录再查。",
170
- "installation-changed": "安装位置在使用中途变了:重新打开宿主再查一次;还出现就重装。",
171
- "pending-restart": "新版已装到磁盘,正在跑的还是旧版:重启宿主后生效。",
172
- "registry-conflict": "本地声明的版本与磁盘实际版本互相矛盾:打开使用范围的清单,把目标包名那行改成版本号再试。",
173
- "incompatible-node": "新版要求的 Node 与当前运行的 Node 对不上:先把 Node 升到 22 或更高,再查。",
174
- "recovery-required": "上次安装被打断,留下一个半截任务:重新点一次安装;一直出现就查宿主日志。",
175
- "check-failed": "查新版失败(联网不通或官方源不可达):检查网络与官方源地址,再查一次。",
176
- "invalid-release": "远端发行信息不合规(名字对不上、版本非法、包地址或完整性校验不过):换个版本再查。",
177
- "check-expired": "上次查新版的凭证过期了:重新查一次再点安装(不要重试旧编号)。",
178
- "update-busy": "同一个使用范围同时只装一个:等当前任务结束再点。",
179
- "install-failed": "安装没跑成:按下面那条手工命令在终端执行一次。",
180
- "manager-unreachable": "总管的电话没接上(宿主半没注册或版本对不上):重启宿主后再试;仍不行看宿主日志。",
181
- "bad-request": "回执异常(请求形状不对):把宿主日志里的事件名报给作者。",
182
- internal: "回执异常(总管内部错误):把宿主日志里的事件名报给作者。"
200
+ "unknown-profile": "认不出这台机器上的安装位置(使用范围的目录不存在,或名字不合法)。把使用范围目录建好,再点「重新检查」。",
201
+ "source-install": "这一份不是从 npm 发行包装的(源码装),面板不能更新它。要更新,照下面这条命令重装,装完再点「重新检查」。",
202
+ "invalid-installation": "磁盘上这一份装得不完整(包名或版本对不上、入口文件缺失)。照下面这条命令重装,装完再点「重新检查」。",
203
+ "installation-changed": "这台机器的安装清单变了(在别处装过、或清单被手工改过),这一家现在不能装。点「重新检查」重新读一次,再点「装上更新」。",
204
+ "pending-restart": "新版已装到磁盘,正在运行的是旧版本。重启 DSH(退出后重新打开)后生效。",
205
+ "registry-conflict": "使用范围清单里写的版本与磁盘上的实际版本对不上。打开使用范围的 `package.json`,把这一行改成磁盘上的版本号,再点「重新检查」。",
206
+ "incompatible-node": "新版本要求 Node 22 或更高,当前运行的是更低版本。升级 Node、重启 DSH 后,再点「检查更新」。",
207
+ "recovery-required": "上次安装没有收尾。点「重试安装」再跑一次;还出现这一条,就用下面这条命令重装。",
208
+ "check-failed": "查不到最新版本:联网失败,或官方源(registry.npmjs.org)不可达。确认网络与源地址后,再点「重新检查」。",
209
+ "invalid-release": "官方源上这个版本的元数据不完整(包名或版本不合法、包地址或校验值缺失)。换一个版本,或稍后重试。",
210
+ "check-expired": "上次检查的结果已过期。点「重新检查」重查一次,再点「装上更新」。",
211
+ "update-busy": "同一时间只能安装一个插件。等这次装完,再点「装上更新」。",
212
+ "install-failed": "安装没有完成。在终端里执行下面这条命令把它装完,装完再点「重新检查」。",
213
+ "manager-unreachable": "面板连不上总管插件(宿主里没注册,或版本对不上)。重启 DSH 后重试;仍不行就在 DSH 日志里找 `[dsh-life-pack]` 的报错。",
214
+ "bad-request": "面板与总管之间的请求出错了(属程序缺陷)。请在 DSH 日志里找 `[dsh-life-pack]` 的报错,连同这家插件的名字报到本仓 issue。",
215
+ internal: "面板与总管之间的请求出错了(属程序缺陷)。请在 DSH 日志里找 `[dsh-life-pack]` 的报错,连同这家插件的名字报到本仓 issue。"
183
216
  };
184
217
  /** 原因码 → 人话(认不出的码不猜、原样回码并说明这是未知原因)。 */
185
218
  function reasonText(code) {
186
- return REASON_TEXT[code] ?? "未知原因(" + code + "):把宿主日志里的事件名报给作者。";
219
+ return REASON_TEXT[code] ?? "面板不认识这个原因码(" + code + ")。请在 DSH 日志里找同一处报错,报到本仓 issue。";
187
220
  }
188
221
  //#endregion
189
222
  //#region src/update-client.ts
190
- /** 面板到宿主的三条流程(纯异步函数:无 DOM、无 react;传输口由调用方注入,便于单测)。
223
+ /** 面板到宿主的四条流程(纯异步函数:无 DOM、无 react;传输口由调用方注入,便于单测)。
191
224
  *
192
- * 三条流程与更新包 README 的对应关系:
225
+ * 四条流程与更新包 README 的对应关系:
193
226
  * - 检查更新:`updateCheck`(第 3 节第 3 步的 `host.call(UPD_CHECK, {})`);
194
227
  * - 装上更新:`updateCheck` 拿凭证 → `updateInstall` 提交 → **按间隔轮询 `updateStatus`**
195
228
  * 直到任务离开 `installing`/`verifying`(第 3 节第 3 步与第 9 节:安装是后台跑,
196
229
  * 调用方不干等;凭证过期 `check-expired` 是正常错误码,重查一次再提交,见第 11 节);
197
- * - 装上缺席的包:走总管自有的 `ilife-manager.install`(更新包的三个电话只管已装的包)。
230
+ * - 装上缺席的包:走总管自有的 `ilife-manager.install`(更新包的三个电话只管已装的包);
231
+ * - 总管自述版本:走总管自有的 `ilife-manager.version`(宿主读自己那份包描述文件,见 `manager-version.ts`)。
198
232
  */
199
233
  /** 轮询上限:1 秒一次、最多 900 次(覆盖更新包 15 分钟的安装时限)。 */
200
234
  const MAX_POLL_TRIES = 900;
@@ -253,6 +287,16 @@ window.__ModuleLoader__.load({
253
287
  }
254
288
  };
255
289
  }
290
+ /** 取总管自述版本(票 #737):宿主读**自己这份已安装包**的 `package.json`,面板只渲染读到的值。
291
+ *
292
+ * 单次、无轮询;宿主读不到会回 `unknown`,传输层失败也回同一个字面量——面板照原样显示,
293
+ * 不假装知道版本(与卡路里 #130 的降级口径一致)。 */
294
+ async function loadManagerVersion(call) {
295
+ const out = await callManager(call, MANAGER_ACTIONS.version, {});
296
+ if (!out.ok) return VERSION_UNKNOWN;
297
+ const raw = out.value?.version;
298
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : VERSION_UNKNOWN;
299
+ }
256
300
  /** 查一家:默认查新版(`updateCheck`,顺带回最新版本与凭证);给 `phoneName` 时查那个电话。 */
257
301
  async function checkTarget(call, target, phoneName) {
258
302
  const name = phoneName ?? target.phones?.check;
@@ -322,57 +366,136 @@ window.__ModuleLoader__.load({
322
366
  if (target.panelRegistered === false) return "unregistered-product";
323
367
  return "not-connected";
324
368
  }
325
- /** 把快照翻成一行结论:还没查 / 已是最新 / 有新版 / 装不了(附人话原因)。 */
369
+ /** 这一家是不是「装好了、只差重启」——判据是**事实**(磁盘版本 ≠ 正在跑的版本),不是原因码。
370
+ *
371
+ * 为什么不许按原因码判(票 #740 真机):同一种事实在更新核心里可能报成两种码——
372
+ * `pending-restart`,或者被上一次中断的任务盖成 `recovery-required`。按码判会把「该重启」
373
+ * 漏成一句「装不上」,用户看到的正是那种读不懂的屏。 */
374
+ function restartPendingOf(snapshot) {
375
+ const installed = snapshot.installedVersion;
376
+ return installed !== null && installed !== snapshot.runningVersion;
377
+ }
378
+ /** 待重启那一句(三段齐全:事实 → 后果 → 动作)。
379
+ *
380
+ * 措辞不写「新版」:磁盘上那份也可能比正在跑的**低**(手工降级),「新版」就不实了;
381
+ * 只照事实说「磁盘上装的是 X、正在运行的是 Y」。
382
+ * 动作只给「重启 DSH」:这一态下面板**做不了别的**——更新包的可装判据要求最新版严格高于
383
+ * **正在运行**的版本(`service.js:219`),而凭证只发在可装态(`service.js:256`:`receipt` 只在
384
+ * `canInstall` 时给),所以这一态里「重试安装」点了也是白点。 */
385
+ function restartLine(snapshot) {
386
+ const installed = snapshot.installedVersion ?? snapshot.job?.targetVersion ?? "新版本";
387
+ return "磁盘上装的是 " + String(installed) + ";正在运行的是 " + snapshot.runningVersion + "。重启 DSH(退出后重新打开)后生效。";
388
+ }
389
+ /** 「条件在别处(目录/清单/别处装的),改完回来重读一次」的原因码:面板能给的下一步就是「重新检查」。
390
+ *
391
+ * 为什么要逐码列(票 #740 第三轮的朗读表):`unknown-profile`/`registry-conflict`/`invalid-installation`/
392
+ * `source-install` 这四态原先一个动作都不给,屏上那句却写着「再点「检查更新」」——那颗按钮住在面板题头,
393
+ * 结果浮层一开(点「检查更新」就开)就被浮层盖住,够不着。跟两张「还没查过」的卡是同一类病。 */
394
+ const RECHECK_REASONS = /* @__PURE__ */ new Set([
395
+ "installation-changed",
396
+ "unknown-profile",
397
+ "registry-conflict",
398
+ "invalid-installation",
399
+ "source-install"
400
+ ]);
401
+ /** 「这一步面板代劳不了、得照下面那条命令做」的原因码:这些话下面必须真有命令块。 */
402
+ const NEEDS_MANUAL = /* @__PURE__ */ new Set([
403
+ "source-install",
404
+ "invalid-installation",
405
+ "recovery-required"
406
+ ]);
407
+ /** 拦截态各自给得出的下一步:面板能代劳的给按钮,代劳不了的给 null(那种走手工命令块)。 */
408
+ function actionForBlocked(reason) {
409
+ if (RECHECK_REASONS.has(reason)) return "recheck";
410
+ if (reason === "recovery-required") return "retry";
411
+ return null;
412
+ }
413
+ /** 这一行此刻该画哪颗按钮:结论自带的动作优先,**失败的行一律给「重新检查」**(票 #740 第三轮)。
414
+ *
415
+ * 失败的行(查不到最新版/检查过期/装失败)没有快照、也就没有结论,原先一颗按钮都不画:
416
+ * 屏上写着「再点「检查更新」」,那颗按钮却在浮层底下 ⇒ 无路可走。重新查一次对每一种失败都成立。 */
417
+ function cardActionOf(verdict, phase) {
418
+ if (verdict?.action) return verdict.action;
419
+ return phase === "failed" ? "recheck" : null;
420
+ }
421
+ /** 装成一家之后,其余各家的读数一律作废(票 #740):七家共用同一份使用范围清单,
422
+ * 刚那次安装已经改写了它,旧快照再拿去装必然被守卫拦下。这句话就是那条守卫的人话。 */
423
+ function staleVerdict() {
424
+ return {
425
+ kind: "blocked",
426
+ text: "刚在本面板装过别的插件,共用的安装清单变了,这一家要先重读一次。点「重新检查」,再点「装上更新」。",
427
+ action: "recheck"
428
+ };
429
+ }
430
+ /** 把除 `keepKey` 之外的行全标成过期(票 #740):装成一家 ⇒ 七家共用的安装清单被改写,
431
+ * 其余各家的旧快照再拿去装必然被守卫拦下。纯函数:状态源与单测共用同一份判据,
432
+ * 不关心行里还有什么(泛型只要求有 `stale` 这一格)。 */
433
+ function markStaleOthers(rows, keepKey) {
434
+ const next = {};
435
+ for (const [key, row] of Object.entries(rows)) next[key] = key === keepKey ? row : {
436
+ ...row,
437
+ stale: true
438
+ };
439
+ return next;
440
+ }
441
+ /** 把快照翻成一行结论:还没查 / 已是最新 / 有新版 / 待重启 / 装不了(附人话原因与下一步)。 */
326
442
  function verdictOf(target, snapshot) {
327
443
  const absent = isAbsent(target);
328
444
  const latest = snapshot.latestVersion;
329
445
  if (absent && latest === null) return {
330
446
  kind: "unknown",
331
- text: "未安装;点「检查更新」查最新版本后装上。",
332
- action: null
447
+ text: "未安装。点「重新检查」查最新版本,再点「装上」。",
448
+ action: "recheck"
333
449
  };
334
450
  if (absent) return {
335
451
  kind: "update-available",
336
- text: "未安装;最新版本 " + String(latest) + "。",
452
+ text: "未安装;最新版本 " + String(latest) + "。点「装上」安装。",
337
453
  action: "install"
338
454
  };
339
- if (snapshot.blockedReason !== null) {
455
+ if (restartPendingOf(snapshot)) return {
456
+ kind: "blocked",
457
+ text: restartLine(snapshot),
458
+ action: null
459
+ };
460
+ const installed = snapshot.installedVersion;
461
+ const hasNewer = latest !== null && latest !== installed;
462
+ if (snapshot.blockedReason !== null && hasNewer) {
340
463
  const blocked = snapshot.blockedReason;
341
- if (blocked === "pending-restart") return {
342
- kind: "blocked",
343
- text: reasonText(blocked),
344
- action: null
345
- };
346
464
  return {
347
465
  kind: "blocked",
348
466
  text: reasonText(blocked),
349
- action: null
467
+ action: actionForBlocked(blocked),
468
+ manualHint: NEEDS_MANUAL.has(blocked)
350
469
  };
351
470
  }
352
471
  if (latest === null) return {
353
472
  kind: "unknown",
354
- text: "还没查过最新版本;点「检查更新」。",
355
- action: null
473
+ text: "还没查过最新版本。点「重新检查」。",
474
+ action: "recheck"
356
475
  };
357
476
  if (snapshot.canInstall) return {
358
477
  kind: "update-available",
359
- text: "有新版:" + String(latest) + ",点「装上更新」。",
478
+ text: "有新版本 " + String(latest) + "(正在运行 " + snapshot.runningVersion + ")。点「装上更新」。",
360
479
  action: "update"
361
480
  };
481
+ if (hasNewer) return {
482
+ kind: "update-available",
483
+ text: "有新版本 " + String(latest) + "(正在运行 " + snapshot.runningVersion + ")。点「重新检查」,再点「装上更新」。",
484
+ action: "recheck"
485
+ };
362
486
  return {
363
487
  kind: "up-to-date",
364
- text: "已是最新(" + String(latest) + ")。",
488
+ text: "已是最新版本 " + String(latest) + "。",
365
489
  action: null
366
490
  };
367
491
  }
368
- /** 待重启横幅文案(更新包 README 第 10 节:说清新版号与「重启后才生效」两件事)。 */
369
- function pendingRestartText(target, snapshot) {
370
- if (snapshot.blockedReason !== "pending-restart") return null;
371
- const installed = snapshot.installedVersion ?? snapshot.job?.targetVersion ?? null;
372
- const running = snapshot.runningVersion;
373
- if (installed === null) return "⚠️ 新版已装好,重启宿主后生效。";
374
- if (installed === running) return "⚠️ 新版 " + installed + " 已装好,重启宿主后生效。";
375
- return "⚠️ 新版 " + installed + " 已装好,正在跑的还是 " + running + ",重启宿主后生效。";
492
+ /** 待重启横幅:**一行总账**,只点名哪几家要做「重启 DSH」这件事。
493
+ *
494
+ * 为什么不再逐家重复那句话(票 #740 对抗式审查第三轮的朗读表逮到):同一句「重启 DSH…后生效」
495
+ * 会同时出现在横幅和那家卡片上,一屏两遍。分工定死:**横幅答「谁要重启」,卡片答「为什么、怎么做」**。 */
496
+ function restartBannerText(titles) {
497
+ if (titles.length === 0) return null;
498
+ return "⚠️ 待重启 DSH(退出后重新打开):" + titles.join("、");
376
499
  }
377
500
  /** 面板展示的那条可复制命令(用户会照着敲的那一条)。
378
501
  *
@@ -657,6 +780,13 @@ window.__ModuleLoader__.load({
657
780
  default: return "idle";
658
781
  }
659
782
  }
783
+ /** 四个动作在屏上的名字:按钮文案与判据同源(票 #740)。 */
784
+ const ACTION_LABEL = {
785
+ install: "装上",
786
+ update: "装上更新",
787
+ retry: "重试安装",
788
+ recheck: "重新检查"
789
+ };
660
790
  const IDLE$1 = {
661
791
  phase: "idle",
662
792
  outcome: null,
@@ -693,6 +823,10 @@ window.__ModuleLoader__.load({
693
823
  [key]: next
694
824
  }));
695
825
  }, []);
826
+ /** 装成一家 ⇒ 其余各家的旧快照一律作废(票 #740):判据见 `markStaleOthers`。 */
827
+ const staleOthers = react.useCallback((keepKey) => {
828
+ setRows((previous) => markStaleOthers(previous, keepKey));
829
+ }, []);
696
830
  return {
697
831
  targets,
698
832
  rows,
@@ -708,18 +842,21 @@ window.__ModuleLoader__.load({
708
842
  for (const target of list) patch(target.key, {
709
843
  phase: "checking",
710
844
  outcome: rows[target.key]?.outcome ?? null,
711
- failure: null
845
+ failure: null,
846
+ stale: false
712
847
  });
713
848
  Promise.all(list.map(async (target) => {
714
849
  const result = await checkTarget(getCall(), target);
715
850
  patch(target.key, result.ok ? {
716
851
  phase: "ready",
717
852
  outcome: result.value,
718
- failure: null
853
+ failure: null,
854
+ stale: false
719
855
  } : {
720
856
  phase: "failed",
721
857
  outcome: null,
722
- failure: result
858
+ failure: result,
859
+ stale: false
723
860
  });
724
861
  })).finally(() => setChecking(false));
725
862
  }, [
@@ -728,12 +865,36 @@ window.__ModuleLoader__.load({
728
865
  rows,
729
866
  targets
730
867
  ]),
731
- act: react.useCallback((target) => {
868
+ act: react.useCallback((target, action) => {
732
869
  const current = rows[target.key] ?? IDLE$1;
870
+ if (action === "recheck") {
871
+ patch(target.key, {
872
+ phase: "checking",
873
+ outcome: current.outcome,
874
+ failure: null,
875
+ stale: false
876
+ });
877
+ (async () => {
878
+ const checked = await checkTarget(getCall(), target);
879
+ patch(target.key, checked.ok ? {
880
+ phase: "ready",
881
+ outcome: checked.value,
882
+ failure: null,
883
+ stale: false
884
+ } : {
885
+ phase: "failed",
886
+ outcome: null,
887
+ failure: checked,
888
+ stale: false
889
+ });
890
+ })();
891
+ return;
892
+ }
733
893
  patch(target.key, {
734
894
  ...current,
735
895
  phase: "installing",
736
- failure: null
896
+ failure: null,
897
+ stale: false
737
898
  });
738
899
  (async () => {
739
900
  const absent = isAbsent(target);
@@ -744,29 +905,36 @@ window.__ModuleLoader__.load({
744
905
  patch(target.key, {
745
906
  phase: "failed",
746
907
  outcome: null,
747
- failure: checked
908
+ failure: checked,
909
+ stale: false
748
910
  });
749
911
  return;
750
912
  }
751
913
  patch(target.key, {
752
914
  phase: "installing",
753
915
  outcome: checked.value,
754
- failure: null
916
+ failure: null,
917
+ stale: false
755
918
  });
756
919
  version = checked.value.snapshot.latestVersion;
757
920
  }
758
921
  const result = absent ? await installAbsent(getCall(), target, version ?? "") : await updateInstalled(getCall(), target, pollMs);
759
922
  if (result.ok) {
923
+ const before = current.outcome?.snapshot.installedVersion ?? null;
760
924
  const refreshed = await checkTarget(getCall(), target, target.phones?.status);
925
+ const after = refreshed.ok ? refreshed.value.snapshot.installedVersion ?? null : null;
761
926
  patch(target.key, refreshed.ok ? {
762
927
  phase: "ready",
763
928
  outcome: refreshed.value,
764
- failure: null
929
+ failure: null,
930
+ stale: false
765
931
  } : {
766
932
  phase: "failed",
767
933
  outcome: null,
768
- failure: refreshed
934
+ failure: refreshed,
935
+ stale: false
769
936
  });
937
+ if (after !== null && after !== before) staleOthers(target.key);
770
938
  await reload();
771
939
  return;
772
940
  }
@@ -776,7 +944,8 @@ window.__ModuleLoader__.load({
776
944
  failure: {
777
945
  ...result,
778
946
  manual: result.manual ?? current.outcome?.manual ?? null
779
- }
947
+ },
948
+ stale: false
780
949
  });
781
950
  })();
782
951
  }, [
@@ -784,7 +953,8 @@ window.__ModuleLoader__.load({
784
953
  patch,
785
954
  pollMs,
786
955
  reload,
787
- rows
956
+ rows,
957
+ staleOthers
788
958
  ])
789
959
  };
790
960
  }
@@ -801,20 +971,20 @@ window.__ModuleLoader__.load({
801
971
  } : PANEL_STYLE.btn,
802
972
  disabled,
803
973
  onClick: checkAll,
804
- title: "一次查七家(总管自己 + 六个单品)的插件包版本"
974
+ title: "检查总管与六家插件的版本"
805
975
  }, checking ? "检查中…" : "检查更新");
806
976
  }
807
- /** 待重启横幅:任何一家成 `pending-restart` 就常驻显示,重启宿主后自动消失(更新包 README 第 10 节)。 */
977
+ /** 待重启横幅:一行总账,只点名哪几家要重启(理由与做法写在各家卡片上,见 `restartBannerText`)。 */
808
978
  function RestartBanners(props) {
809
- const lines = [];
979
+ const titles = [];
810
980
  for (const target of props.face.targets) {
811
981
  const outcome = props.face.rows[target.key]?.outcome;
812
982
  if (!outcome) continue;
813
- const text = pendingRestartText(target, outcome.snapshot);
814
- if (text) lines.push(text);
983
+ if (restartPendingOf(outcome.snapshot)) titles.push(target.title);
815
984
  }
816
- if (lines.length === 0) return null;
817
- return react.createElement("div", { style: PANEL_STYLE.banner }, lines.map((line, index) => react.createElement("div", { key: String(index) }, line)));
985
+ const text = restartBannerText(titles);
986
+ if (text === null) return null;
987
+ return react.createElement("div", { style: PANEL_STYLE.banner }, text);
818
988
  }
819
989
  /** 七家结果:装不了的与待重启的常驻(横幅),详细结果放**居中浮层**(点「检查更新」开)。
820
990
  *
@@ -837,10 +1007,11 @@ window.__ModuleLoader__.load({
837
1007
  const body = shown.map((target) => {
838
1008
  const row = rows[target.key];
839
1009
  const snapshot = row.outcome?.snapshot ?? null;
840
- const verdict = snapshot ? verdictOf(target, snapshot) : null;
1010
+ const verdict = row.stale === true ? staleVerdict() : snapshot ? verdictOf(target, snapshot) : null;
841
1011
  const manual = manualForDisplay(target, row.failure?.manual ?? row.outcome?.manual ?? null);
842
1012
  const busy = row.phase === "installing";
843
- const showManual = manual !== null && (row.phase === "failed" || verdict?.kind === "blocked");
1013
+ const showManual = manual !== null && (row.phase === "failed" || verdict?.manualHint === true || verdict?.kind === "blocked" && verdict.action === null);
1014
+ const buttonAction = cardActionOf(verdict, row.phase);
844
1015
  const tone = rowToneOf(row, verdict);
845
1016
  const paint = ROW_TONE[tone];
846
1017
  /** 这一行「当前/最新」两个版本号:不等才算有新版,把「最新」那格上色(用户扫一眼先看有没有箭头)。 */
@@ -871,7 +1042,7 @@ window.__ModuleLoader__.load({
871
1042
  } : {
872
1043
  ...PANEL_STYLE.verdictLine,
873
1044
  color: "var(--dsw-alias-label-secondary, #9a9a9a)"
874
- } }, busy ? "正在装,请稍候…" : verdict?.text ?? (checking ? "检查中…" : "")), verdict?.action ? react.createElement("button", {
1045
+ } }, busy ? "正在装,请稍候…" : verdict?.text ?? (checking ? "检查中…" : "")), buttonAction ? react.createElement("button", {
875
1046
  type: "button",
876
1047
  style: busy ? {
877
1048
  ...PANEL_STYLE.btnPrimary,
@@ -879,11 +1050,13 @@ window.__ModuleLoader__.load({
879
1050
  cursor: "default"
880
1051
  } : PANEL_STYLE.btnPrimary,
881
1052
  disabled: busy,
882
- onClick: () => props.face.act(target)
883
- }, verdict.action === "install" ? "装上" : "装上更新") : null, row.failure ? react.createElement("div", { style: PANEL_STYLE.reason }, "装不上:" + row.failure.message) : null, versionLines(target, snapshot).map((line, index) => react.createElement("div", {
1053
+ onClick: () => {
1054
+ props.face.act(target, buttonAction);
1055
+ }
1056
+ }, ACTION_LABEL[buttonAction]) : null, row.failure ? react.createElement("div", { style: PANEL_STYLE.reason }, "装不上:" + row.failure.message) : null, versionLines(target, snapshot).map((line, index) => react.createElement("div", {
884
1057
  key: String(index),
885
1058
  style: PANEL_STYLE.skill
886
- }, line)), showManual ? react.createElement("code", { style: PANEL_STYLE.cmd }, manual) : null);
1059
+ }, line)), showManual ? react.createElement("div", { style: PANEL_STYLE.skill }, "在终端里执行这条命令可以手动完成这一步:") : null, showManual ? react.createElement("code", { style: PANEL_STYLE.cmd }, manual) : null);
887
1060
  });
888
1061
  return react.createElement("div", null, react.createElement(RestartBanners, { face: props.face }), open ? react.createElement("div", {
889
1062
  style: PANEL_STYLE.overlay,
@@ -905,7 +1078,7 @@ window.__ModuleLoader__.load({
905
1078
  onClick: () => close(),
906
1079
  title: "关闭",
907
1080
  "aria-label": "关闭"
908
- }, "×")), react.createElement("div", { style: PANEL_STYLE.dialogBody }, body, react.createElement("div", { style: PANEL_STYLE.dialogNote }, "装与更新都在宿主后台跑:关掉这块不影响正在进行的安装,再点「检查更新」可以看到最新状态。")))) : null);
1081
+ }, "×")), react.createElement("div", { style: PANEL_STYLE.dialogBody }, body, react.createElement("div", { style: PANEL_STYLE.dialogNote }, "安装在宿主后台继续:关掉这块不会中断它。再点「检查更新」看最新进度。")))) : null);
909
1082
  }
910
1083
  /** 缺席卡:**按两处独立事实分三态**,每态只给能改变状态的动作。
911
1084
  *
@@ -926,17 +1099,19 @@ window.__ModuleLoader__.load({
926
1099
  const manual = (target ? manualForDisplay(target, row?.failure?.manual ?? row?.outcome?.manual ?? null) : null) ?? props.fallbackCommand;
927
1100
  const installed = target ? target.installedVersion ?? target.runningVersion : null;
928
1101
  const state = slotStateOf(target, props.inLedger);
1102
+ const blockedVerdict = target && row?.outcome && row.outcome.snapshot.blockedReason !== null ? verdictOf(target, row.outcome.snapshot) : null;
929
1103
  let reason = row?.failure?.message ?? null;
930
- if (target && reason === null && row?.outcome && row.outcome.snapshot.blockedReason !== null) reason = verdictOf(target, row.outcome.snapshot).text;
1104
+ if (reason === null && blockedVerdict) reason = blockedVerdict.text;
1105
+ const retryAction = row ? cardActionOf(blockedVerdict, row.phase) : null;
931
1106
  let headline;
932
1107
  switch (state) {
933
1108
  case "absent":
934
1109
  headline = target ? "未安装[" + target.packageName + "],装上后这个页签就能用了。" : "这个页签对应的插件还没装。";
935
1110
  break;
936
1111
  case "unregistered-product":
937
- headline = target.packageName + " " + String(installed) + " 装着,但它这一版的产物里没有爱生活页签的注册代码:得等它发一个带注册代码的新版才对得上。重启与重装都不会变(重装只是把同一版再装一次)。";
1112
+ headline = target.packageName + " " + String(installed) + " 已安装,但这一版的插件包里没有「爱生活页签」的注册代码:要等它发布带注册代码的新版本。重启和重装都不会改变这一点。";
938
1113
  break;
939
- default: headline = target.packageName + " " + String(installed) + " 装着、产物里也有注册代码,但它的设置页没送到浏览器:重启宿主后再看这个页签;仍然这样就去宿主日志里找它的报错。";
1114
+ default: headline = target.packageName + " " + String(installed) + " 已安装,插件包里也有注册代码,但它的设置页没有加载到面板:重启 DSH 后再看这个页签;仍然这样,就在 DSH 日志里找它的报错。";
940
1115
  }
941
1116
  return react.createElement("div", { style: PANEL_STYLE.reco }, react.createElement("div", null, headline), state === "absent" ? react.createElement("button", {
942
1117
  type: "button",
@@ -950,9 +1125,23 @@ window.__ModuleLoader__.load({
950
1125
  },
951
1126
  disabled: busy,
952
1127
  onClick: () => {
953
- if (target) props.face.act(target);
1128
+ if (target) props.face.act(target, "install");
954
1129
  }
955
- }, busy ? "正在装,请稍候…" : "装上") : null, reason ? react.createElement("div", { style: PANEL_STYLE.reason }, reason) : null, react.createElement("code", { style: PANEL_STYLE.cmd }, manual));
1130
+ }, busy ? "正在装,请稍候…" : "装上") : null, retryAction === "recheck" && target ? react.createElement("button", {
1131
+ type: "button",
1132
+ style: busy ? {
1133
+ ...PANEL_STYLE.btn,
1134
+ marginTop: 8,
1135
+ marginLeft: 8,
1136
+ opacity: .55
1137
+ } : {
1138
+ ...PANEL_STYLE.btn,
1139
+ marginTop: 8,
1140
+ marginLeft: 8
1141
+ },
1142
+ disabled: busy,
1143
+ onClick: () => props.face.act(target, "recheck")
1144
+ }, ACTION_LABEL.recheck) : null, reason ? react.createElement("div", { style: PANEL_STYLE.reason }, reason) : null, react.createElement("code", { style: PANEL_STYLE.cmd }, manual));
956
1145
  }
957
1146
  //#endregion
958
1147
  //#region src/health-contract.ts
@@ -998,20 +1187,38 @@ window.__ModuleLoader__.load({
998
1187
  /** 面板到各家的配置体检:一次取数、六份报告(纯异步函数:无 DOM、无 react;传输口注入,便于单测)。
999
1188
  *
1000
1189
  * 三条纪律:
1001
- * · **只走各家自己的通道**(`rpc.call(channel, HEALTH_ENDPOINT, …)`)——总管不认任何一家、也不加
1002
- * 任何单品的依赖;通道名从「爱生活页签槽」的账本条目带出来(各家注册时写进 options)。
1190
+ * · **只走各家自己的电话**(`rpc.call(CARRIER_BASE, <电话名>, {method, payload}, …)`)——总管不认任何
1191
+ * 一家的端点、也不加任何单品的依赖;电话名与端点名都是各家契约件公开的事实。
1192
+ * ⚠ **载体形状**(#735 真机踩到):第一段必须是**载体基段** `/api`,第二段是那家的电话名
1193
+ * (=它注册在 `/api<通道>` 上的那一段),端点名写进载荷的 `method`。写错第一段就是 404,
1194
+ * 而 404 只落成「那一家取不到数」,屏上看起来像按钮坏了(先例:总管自己的电话在 #678 栽过同一处,
1195
+ * 见 `update-contract.ts` 的 `CARRIER_BASE`)。
1003
1196
  * · **一次取数,六份报告**:总览那行与各家那张表读的是同一份结果(见 `health-panel.ts` 的单一状态源),
1004
1197
  * 不许两处各算一遍。
1005
1198
  * · 有界超时:一家不回不该拖死整屏(技能侧 spawn 上限 20 s,这里给 25 s)。
1006
1199
  */
1007
- /** 一次取数的时限(毫秒):技能侧 spawn 上限 20 s,这里留一点余量。 */
1008
- const HEALTH_TIMEOUT_MS = 25e3;
1200
+ /** 一家的电话名:通道名去掉前导斜杠那一段(各家宿主半注册在 `/api` + 通道名,信封 `method` 就是它)。 */
1201
+ function phoneOf(channel) {
1202
+ return channel.startsWith("/") ? channel.slice(1) : channel;
1203
+ }
1204
+ /** 整批取数的时限(毫秒),**不是**一家一份:六家的活是在**同一个宿主线程**上排队干的。
1205
+ *
1206
+ * 为什么不是「技能侧 spawn 上限 20 s + 一点余量」(票 #740 对抗式审查第二轮逮到):
1207
+ * 各家的宿主半用的是 `spawnSync`(各单品的 `src/bridge.ts` 里 `SPAWN_TIMEOUT_MS = 20_000`),
1208
+ * 它**阻塞**宿主的事件循环——六通电话同时打进来,真正干活的是一个接一个串起来的。
1209
+ * 于是「六家之和」才是这一批的真实上界:按一家 20 s 算最坏 120 s,常见情形(一家 1~3 s)约 10 s。
1210
+ * 60 s 取两者之间:给常见情形留足余量,又不至于让一次卡死把按钮按成两分钟不动。
1211
+ * 单家的错照样逐家报(那家页签里的体检表会写「体检超时(60s)…」)。 */
1212
+ const HEALTH_TIMEOUT_MS = 6e4;
1009
1213
  function timeoutText() {
1010
1214
  return "体检超时(" + String(Math.round(HEALTH_TIMEOUT_MS / 1e3)) + "s):技能没在时限内回话。";
1011
1215
  }
1012
1216
  async function fetchOne(call, row) {
1013
1217
  try {
1014
- const result = await call(row.channel, HEALTH_ENDPOINT, {}, AbortSignal.timeout(HEALTH_TIMEOUT_MS));
1218
+ const result = await call(CARRIER_BASE, phoneOf(row.channel), {
1219
+ method: HEALTH_ENDPOINT,
1220
+ payload: {}
1221
+ }, AbortSignal.timeout(HEALTH_TIMEOUT_MS));
1015
1222
  if (!result || typeof result !== "object" || typeof result.ok !== "boolean") return {
1016
1223
  report: null,
1017
1224
  error: "回执信封异常(非 ok 信封)。"
@@ -1118,6 +1325,17 @@ window.__ModuleLoader__.load({
1118
1325
  } catch {}
1119
1326
  return face;
1120
1327
  }
1328
+ /** 汇总行该显示哪一句错(票 #735):取数过程本身的错优先;**一家报告都没回来**时,把第一条家错误顶上来。
1329
+ *
1330
+ * 为什么要有这一格:各家的错只画在那家页签里的体检表上,一行摘要看不见 ⇒ 用户点完只见「体检中…」闪一下、
1331
+ * 接着仍旧「还没体检」,会以为按钮坏了(真机上就是这么被误判的;那次六家全 404,汇总行一声不吭)。
1332
+ * 有报告回来时不顶:那时各家那张表里已经把错说清楚了,摘要行只管读数。 */
1333
+ function summaryErrorOf(face) {
1334
+ if (face.error !== null) return face.error;
1335
+ const rows = Object.values(face.rows);
1336
+ if (rows.some((row) => row.report !== null)) return null;
1337
+ return rows.find((row) => row.error !== null)?.error ?? null;
1338
+ }
1121
1339
  //#endregion
1122
1340
  //#region src/health-view.ts
1123
1341
  /** 配置体检的两个画法:面板顶部那行总览(六家一盏灯)+ 各家那张表。
@@ -1339,15 +1557,26 @@ window.__ModuleLoader__.load({
1339
1557
  }
1340
1558
  /** 几截读数之间那个分隔符:不许染档位色(它不属于任何一档)。 */
1341
1559
  const SEG_SEP = " · ";
1342
- /** 汇总行那一句:绿要显眼(它是「正常」这件事本身),没通道的那几家不冒充绿。 */
1560
+ /** 汇总行那一句:绿要显眼(它是「正常」这件事本身),没通道的那几家不冒充绿。
1561
+ *
1562
+ * 「没有体检出口」与「还没跑过体检」是**两件事**(票 #735):前者那颗「体检一次」按下去不会有任何反应,
1563
+ * 必须自己说出来——两态共用一句「还没体检」时,用户与诊断都会被引到「点了没跑」上去。 */
1343
1564
  function overallText(lights) {
1344
1565
  const pending = lights.filter((light) => !light.hasChannel).length;
1345
- const withReport = lights.filter((light) => light.hasChannel);
1346
- const tail = pending > 0 ? " · " + String(pending) + " 家还没体检" : "";
1347
- if (withReport.length > 0 && withReport.every((light) => light.status === "green")) return react.createElement("span", { style: {
1566
+ const withChannel = lights.filter((light) => light.hasChannel);
1567
+ const tail = pending > 0 ? " · " + String(pending) + " 家没有体检出口" : "";
1568
+ if (withChannel.length === 0) {
1569
+ const what = lights.length === 0 ? "还没有任何一家登记体检出口" : String(lights.length) + " 家都没有体检出口(通道名没登记上)";
1570
+ return react.createElement("span", { style: {
1571
+ color: STATUS_TEXT.red,
1572
+ fontWeight: 700
1573
+ } }, "没有可体检的家:" + what);
1574
+ }
1575
+ const allGreen = withChannel.every((light) => light.status === "green");
1576
+ if (allGreen && pending === 0) return react.createElement("span", { style: {
1348
1577
  color: STATUS_COLOR.green,
1349
1578
  fontWeight: 700
1350
- } }, "六家正常" + tail);
1579
+ } }, "六家正常");
1351
1580
  const parts = [];
1352
1581
  for (const status of ["red", "yellow"]) {
1353
1582
  let count = 0;
@@ -1365,7 +1594,7 @@ window.__ModuleLoader__.load({
1365
1594
  }
1366
1595
  }, STATUS_LABEL[status] + " " + String(count)));
1367
1596
  }
1368
- if (parts.length === 0) return react.createElement("span", { style: { color: INK_DIM } }, "还没体检" + tail);
1597
+ if (parts.length === 0) return react.createElement("span", { style: { color: INK_DIM } }, (allGreen ? "有出口的都正常" : "还没体检") + tail);
1369
1598
  parts.push(react.createElement("span", {
1370
1599
  key: "todo",
1371
1600
  style: { color: INK }
@@ -1409,11 +1638,10 @@ window.__ModuleLoader__.load({
1409
1638
  * 与页签的分工:这行只答「一共几条要处理」;「哪几家、各几条」由页签的圆点与小数字答;
1410
1639
  * 「具体哪一条坏了、去哪修」由那家页签里的 `HealthTable` 答。 */
1411
1640
  function HealthSummaryLine(props) {
1412
- const hasLights = props.lights.length > 0;
1413
1641
  return react.createElement("div", {
1414
1642
  style: HEALTH_STYLE.summaryRow,
1415
1643
  "data-ilife-health": "summary"
1416
- }, react.createElement("div", { style: HEALTH_STYLE.head }, "配置体检"), react.createElement("div", { style: HEALTH_STYLE.summaryText }, props.error !== null ? react.createElement("span", { style: { color: STATUS_TEXT.red } }, props.error) : hasLights ? overallText(props.lights) : react.createElement("span", { style: { color: INK_DIM } }, "还没体检")), react.createElement("button", {
1644
+ }, react.createElement("div", { style: HEALTH_STYLE.head }, "配置体检"), react.createElement("div", { style: HEALTH_STYLE.summaryText }, props.error !== null ? react.createElement("span", { style: { color: STATUS_TEXT.red } }, props.error) : overallText(props.lights)), react.createElement("button", {
1417
1645
  type: "button",
1418
1646
  style: props.running ? {
1419
1647
  ...HEALTH_STYLE.btn,
@@ -1533,6 +1761,9 @@ window.__ModuleLoader__.load({
1533
1761
  * 文案与网址都取自 nav.ts 的两张静态表,本文件只画不算。
1534
1762
  */
1535
1763
  const inject = ["slots", "connection"];
1764
+ /** 包名 → 本家那条客户端通道(票 #735)。取值面是导航表那份镜像,不再从页签槽账本的自定义选项里读:
1765
+ * 装机槽位面不透传自定义键,读了恒是空串(详见 `nav.ts` 上 `ManagerTab.channel` 的注释)。 */
1766
+ const CHANNEL_BY_PLUGIN = new Map(MANAGER_TABS.map((tab) => [tab.plugin, tab.channel]));
1536
1767
  /** 总管视觉(内联 style;颜色走 DSH 主题别名,深浅主题自适应,写死值只做回退;与技能面板同语言)。 */
1537
1768
  const S = {
1538
1769
  headRow: {
@@ -1710,6 +1941,23 @@ window.__ModuleLoader__.load({
1710
1941
  style: S.moreRow
1711
1942
  }, react.createElement("span", { style: S.morePkg }, row.pkg), react.createElement("span", { style: S.moreDesc }, row.desc), react.createElement("span", { style: S.moreIcon }, react.createElement(ExternalIcon, null)))));
1712
1943
  }
1944
+ /** 总管自述版本(票 #737):**不手写**,问宿主——宿主读自己这份已安装包的 `package.json`。
1945
+ *
1946
+ * 单次取数(`loadManagerVersion`),无轮询;初值 `unknown`,读不到也停在 `unknown`。
1947
+ * 与卡路里 #130 同一条路:面板是浏览器产物、禁 node 内建,读盘只许在宿主半。 */
1948
+ function useManagerVersion(getCall) {
1949
+ const [version, setVersion] = react.useState(VERSION_UNKNOWN);
1950
+ react.useEffect(() => {
1951
+ let alive = true;
1952
+ loadManagerVersion(getCall()).then((next) => {
1953
+ if (alive) setVersion(next);
1954
+ });
1955
+ return () => {
1956
+ alive = false;
1957
+ };
1958
+ }, [getCall]);
1959
+ return version;
1960
+ }
1713
1961
  /** 爱生活面板:总设置区 + 检查更新(七家)+ 爱生活页签条(slot 驱动)+ 技能设置页投影/缺席卡。 */
1714
1962
  function LifePackSection(props) {
1715
1963
  const tabsId = react.useId();
@@ -1717,6 +1965,7 @@ window.__ModuleLoader__.load({
1717
1965
  const rows = props.useTabs((value) => value);
1718
1966
  const present = new Set(rows.map((r) => r.id));
1719
1967
  const face = useUpdateRows(props.getCall);
1968
+ const managerVersion = useManagerVersion(props.getCall);
1720
1969
  const health = useHealthPanel(() => props.getCall(), react.useMemo(() => rows.filter((row) => row.channel.length > 0).map((row) => ({
1721
1970
  id: row.id,
1722
1971
  channel: row.channel
@@ -1766,10 +2015,10 @@ window.__ModuleLoader__.load({
1766
2015
  setActiveId(next.plugin);
1767
2016
  tabRefs.current[nextIndex]?.focus();
1768
2017
  }
1769
- return react.createElement("div", null, react.createElement("div", { style: S.headRow }, react.createElement("div", { style: S.head }, "爱生活"), react.createElement("div", { style: S.headActions }, react.createElement(CheckUpdateButton, { face }), react.createElement(PanelActions, null))), react.createElement("div", { style: S.meta }, react.createElement("div", null, "总开关 · 开关(缺省启用,只读)"), react.createElement("div", null, "总管 dsh-life-pack · 0.2.6")), react.createElement(UpdateResults, { face }), react.createElement(HealthSummaryLine, {
2018
+ return react.createElement("div", null, react.createElement("div", { style: S.headRow }, react.createElement("div", { style: S.head }, "爱生活"), react.createElement("div", { style: S.headActions }, react.createElement(CheckUpdateButton, { face }), react.createElement(PanelActions, null))), react.createElement("div", { style: S.meta }, react.createElement("div", null, "总管 dsh-life-pack · " + managerVersion)), react.createElement(UpdateResults, { face }), react.createElement(HealthSummaryLine, {
1770
2019
  lights,
1771
2020
  running: health.running,
1772
- error: health.error,
2021
+ error: summaryErrorOf(health),
1773
2022
  onRun: health.run
1774
2023
  }), react.createElement("div", {
1775
2024
  role: "tablist",
@@ -1853,7 +2102,7 @@ window.__ModuleLoader__.load({
1853
2102
  id: entry.options.id ?? "",
1854
2103
  order: entry.options.order ?? 0,
1855
2104
  label: resolveLabel(entry.options.label),
1856
- channel: entry.options.channel ?? ""
2105
+ channel: CHANNEL_BY_PLUGIN.get(entry.options.id ?? "") ?? ""
1857
2106
  })).sort((a, b) => a.order - b.order);
1858
2107
  }
1859
2108
  return tabs;