dsh-plugin-update 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  格式为 Keep-a-Changelog 子集:`Added/Fixed/Changed` 必写,`Deprecated/Removed/Security` 有则透传,`Unreleased` 面板忽略,禁止 git-log 直倒。面板缺日志时中性提示,不挡安装。
4
4
 
5
+ ## [0.9.0] - 2026-10-08
6
+
7
+ ### Added
8
+ - 批量详情独立动作矩阵(#84):详情弹窗按单插件内核只读 + 批量自出动作行,支持单查 / 重试 / 复制诊断 / 复制手工 / 跳过,行单查透 `phoneNames`、诊断进 `diag/env/queue`;死按钮摘净、不嵌套滚动;文案零新增。
9
+ - 批量两段式决策 ADR-0004 proposed(#86):先全查再串行装(生产者 `checkAll` + 消费者复用 `driveLocked/runBatch`)、查并发取 3、消费前重查(不做常驻 reader)、公平性有界与回退线;本版只落决策不改驱动。
10
+ - 主题新增 `newOkText`(#71 修订 Q1):done 行新版走绿 `newOkText`,其余带箭头行走红 `newText`,相等无箭头;README 键表同步,深浅 / 档案 / forced-colors 回退。
11
+
12
+ ### Changed
13
+ - 总账吃知识(#73 口径 = #82 推荐 A)+ 世界账与轮次账分家(#90):无轮次 pending 行按知识四态(有新版进可更新 / 已是最新进已最新 / 无知识与查失败仍待查),有轮次与 checking 行恒走执行态;终态 current / 无重启 done 行被更新鲜知识推翻为可更新(`lastCheckedAt > 会话行 updatedAt` 且快照比对确有新版),陈旧知识与失败检查永不推翻;入口徽标 `batchEntrySummary` 同口径(含 openOn='has-update' 在轮不开面板)。
14
+ - 检查诚实语义(#90 R2/R5 + #73):宿主 `defaultCheck` 回包成功却无版本永不判 current,改判 `check-failed`;知识“查过但无版本”判 never(回“还没查过”),不再坍缩为“已是最新”;知识查失败归待查,不冒充安装失败(顶部不再喊“N 家安装失败”)。
15
+ - 驱动进行中检查回忙(#90 R4)+ 装类在途轮询放行:`checkPhone` 在 `driveActive` 时回 `update-busy`,不用旧表冒充 fresh 结果;批量驱动收尾前可观测中间进度。
16
+ - 判定收敛(#73 code-review):新增 `sessionEntriesOf/entryPhaseOf/isUnfinishedEntry` 与 `knowledgeVerdictOf` 唯一判定,总账与行共用“在轮 / 知识”两处判定,注释正错(首个匹配即返、任一条目非终态即在轮)。
17
+
18
+ ### Fixed
19
+ - 明细行版本与对齐一致性(#83):知识孤版红 + 入口吃知识,会话目标赢知识;三列网格(名 5em / 版 18ch / 态 1fr)与 #71 无回归。
20
+ - 单面板宏忙守卫与批量一致(#87):installing / verifying 快照与 busyAct 在途时查 / 装两键同灰,只加 disabled,不换文案不加键。
21
+ - 详情 row-skip 非法版本与单面板同口径(#88):不记不 toast 静默重绘;entry locale 注释正错。
22
+ - 重启后待重启自动回查清零(#91):任务完成态换算落盘(restart-required→completed), done + 待重启在快照已生效时清零、真未生效时保留;只读失败不展示陈旧待重启。
23
+ - 单面板安装门控稳定(#87 附带):settled 等自动查回包;抖动预留与闪空行留存沿用既有载体(68 预留 + 焦点滚动 + 轮询让路,74 prefs-only)。
24
+
25
+ 门禁:`npm test` 869 例(868 过 / 1 跳过既有 derive-client / 0 失败)+ `gate:bilingual` 四道硬门禁过(文案零新增,312 draft 未动;`--release` 仍如实红,非阻断)。
26
+
27
+ ## [0.8.0] - 2026-10-07
28
+
29
+ ### Added
30
+ - 入口件无新版摆法 `upToDateDisplay`(`note` / `button`(默认) / `tooltip`):默认按钮本身即`已是最新 X.Y.Z`(单按钮不占宽,可点再查,hover 回`检查更新`暗示动作;无新版走中性弱边、hover 才走主色,复用既有 token,不新增文案与 token);`tooltip` 按钮仍是`检查更新`、版本收进悬停;`note` 回右侧小字旧样子(显式传回)。入口 `label()` 与按钮正文同口径,`badge` 形态不受影响;README 2.5 节补自由度与参数说明,单测覆盖三摆法与非法校验。
31
+
32
+ ### Fixed
33
+ - 查新版瞬时抖动补强(面板):忙闲两帧同高(动作区按钮 nowrap + 最小宽预留、转圈槽常驻)+ 横幅/复制区最小高占位,查新版与轮询回包帧不再跳动。
34
+ - 入口件深色默认主题补 `text-muted`(`#9ca3af`),按钮直显弱边在深底可读。
35
+ - 批量 prefs-only 存偏好回归测试补齐(#74):偏好保存不闪空行、失败保留行(测试覆盖,行为零变更)。
36
+
5
37
  ## [0.7.1] - 2026-10-06
6
38
 
7
39
  ### Fixed
package/README.md CHANGED
@@ -144,28 +144,29 @@ mountUpdatePanel(slot, { pluginId: 'my-notes-plugin', prefix: 'notes', call: hos
144
144
 
145
145
  可选档案卷主题(纸面浅色案卷风,不替换默认):挂载时加 `theme: 'archive'` 即换肤(右上大印章「待查/可装/安装中/待重启/受阻/已最新」+ 横幅小印章一字 + profile 牌 + 待重启衬线横幅配手绘 SVG 标 + 窄屏印章固定 + 省略号逐字折叠 + 浅深双主题跟随系统),内核 DOM 顺序不动、复制诊断常在;不传即最小可用默认深色样式。运行时用 `panel.setTheme('archive' | 'default')` 可切。入口件与批量面板是同一个 `theme` 参数,取值同一套。
146
146
 
147
- 主题变量覆盖(第三方换肤):四个 UI 口(单面板、批量面板、入口件、批量入口件)都收 `themeTokens`,键为语义名,一次传入即换肤,不传即零回归。入口件打开的 dialog 面板同步透传;运行时用 `setThemeTokens({...})` 即时换肤,传 `undefined` 即清掉覆盖:
148
-
149
- ```js
150
- mountUpdatePanel(el, { pluginId: 'p', prefix: 'notes', call,
151
- theme: 'archive',
152
- themeTokens: { primary: '#c8402a', bg: '#fffdf6', radiusButton: '3px' },
153
- })
154
- ```
155
-
156
- 键与样式表里的 `--dsh-update-*` 变量一一对应(括号里是变量名),手写 CSS 变量等价生效:
157
-
158
- | 组 | 键(变量) |
159
- |---|---|
160
- | 文字背景边线 | `text`、`textMuted`、`bg`、`bgSoft`、`border`、`borderStrong`、`buttonBg` |
161
- | 主色焦点 | `primary`、`primaryDeep`、`focus` |
162
- | 状态四组 | `okBg / okBorder / okText`、`warnBg / warnBorder / warnText`、`badBg / badBorder / badText`、`busyBg / busyBorder / busyText` |
163
- | 字体阴影 | `fontSans`、`fontSerif`、`fontMono`、`shadow` |
164
- | 圆角 | `radiusPanel`、`radiusButton`、`radiusBadge` |
165
- | 入口件尺寸 | `entryFontSize`、`entryPadding`、`entryBorderRadius`、`entryScale`(数字,大于 0) |
166
-
167
- 三条约束:颜色只收 hex(`#rgb`/`#rrggbb`/`#rrggbbaa`)或英文名单词(含 `transparent`),`rgb()`/`color-mix()` 请自行换算成 hex;其余值走注入检查(分号引号括号等不许过,见 `sizing` 同口径);未知键与非法值挂载即抛错,不静默吞掉。被覆盖的 token 不再跟随深色模式(内联值固定,调用方自己保证深浅可读)。
168
-
147
+ 主题变量覆盖(第三方换肤):四个 UI 口(单面板、批量面板、入口件、批量入口件)都收 `themeTokens`,键为语义名,一次传入即换肤,不传即零回归。入口件打开的 dialog 面板同步透传;运行时用 `setThemeTokens({...})` 即时换肤,传 `undefined` 即清掉覆盖:
148
+
149
+ ```js
150
+ mountUpdatePanel(el, { pluginId: 'p', prefix: 'notes', call,
151
+ theme: 'archive',
152
+ themeTokens: { primary: '#c8402a', bg: '#fffdf6', radiusButton: '3px' },
153
+ })
154
+ ```
155
+
156
+ 键与样式表里的 `--dsh-update-*` 变量一一对应(括号里是变量名),手写 CSS 变量等价生效:
157
+
158
+ | 组 | 键(变量) |
159
+ |---|---|
160
+ | 文字背景边线 | `text`、`textMuted`、`bg`、`bgSoft`、`border`、`borderStrong`、`buttonBg` |
161
+ | 主色焦点 | `primary`、`primaryDeep`、`focus` |
162
+ | 状态四组 | `okBg / okBorder / okText`、`warnBg / warnBorder / warnText`、`badBg / badBorder / badText`、`busyBg / busyBorder / busyText` |
163
+ | 批量新版 | `newText`(未装好红)、`newOkText`(已装好绿) |
164
+ | 字体阴影 | `fontSans`、`fontSerif`、`fontMono`、`shadow` |
165
+ | 圆角 | `radiusPanel`、`radiusButton`、`radiusBadge` |
166
+ | 入口件尺寸 | `entryFontSize`、`entryPadding`、`entryBorderRadius`、`entryScale`(数字,大于 0) |
167
+
168
+ 三条约束:颜色只收 hex(`#rgb`/`#rrggbb`/`#rrggbbaa`)或英文名单词(含 `transparent`),`rgb()`/`color-mix()` 请自行换算成 hex;其余值走注入检查(分号引号括号等不许过,见 `sizing` 同口径);未知键与非法值挂载即抛错,不静默吞掉。被覆盖的 token 不再跟随深色模式(内联值固定,调用方自己保证深浅可读)。
169
+
169
170
  类型定义随包分发(`dsh-plugin-update/panel` 的 `.d.ts`),不用自编译;面板离线可读,与包版本绑定。
170
171
 
171
172
  整组件 HTTP 版(无 `host.call` 环境即跑:浏览器面板经 HTTP POST 直达宿主网关,只传三样):
@@ -204,12 +205,13 @@ const entry = mountUpdateEntry(document.getElementById('upd-entry'), {
204
205
  | 摆什么 | `button` / `badge` / `inline` | `button` | 按钮;只给一个状态点;面板本体直接嵌进来 |
205
206
  | 什么时候查 | `mount` / `never` | `mount` | 进页面静默查一次(**只调 `.updateStatus`,只读**);`never` 则只在点击时查 |
206
207
  | 点了做什么 | `has-update` / `always` / `manual` / `direct` | `has-update` | 有新版才开面板;检查完总是开;交给 `onActivate` 自己跳;点开即弹窗、不预查 |
208
+ | 无新版摆哪 | `button` / `tooltip` / `note` | `button` | 版本即按钮本身;收进悬停;回右侧小字旧样子 |
207
209
 
208
- 按钮文案随状态自己变(按宿主语言单语渲染:中文界面纯中文、英文界面纯英文,一次只出现一种语言):`检查更新` / `有新版 1.1.0` / `正在安装…` / `待重启` / `更新失败,点此查看`(英文见集中字典同 key)。
210
+ 按钮文案随状态自己变(按宿主语言单语渲染:中文界面纯中文、英文界面纯英文,一次只出现一种语言):`检查更新`(还没查到)/ `已是最新 X.Y.Z`(无新版,默认即按钮本身)/ `有新版 1.1.0` / `正在安装…` / `待重启` / `更新失败,点此查看`(英文见集中字典同 key)。
209
211
 
210
212
  语言跟随(单语):入口件默认跟随 `document.documentElement.lang` > `navigator.languages` > 中文兜底;显式覆盖传 `locale: 'zh' | 'en' | { getActive(): string; subscribe?(cb): () => void }`(对象形态可接宿主语言服务,切换即时重绘,`unmount` 后停订;无 DOM 环境不抛,落中文)。
211
213
 
212
- 无新版时原地那句小字(`已是最新 X.Y.Z`)只在 `has-update` 下出现:不想看它就用 `openOn: 'always'`(检查完总是开弹窗,无新版在弹窗里看“已是最新”)或 `openOn: 'direct'`(点开即弹窗,连预查都省了,面板挂载即自查;徽标形态仍走回调口径)。
214
+ 无新版时版本号摆哪(`upToDateDisplay`,缺省 `'button'`):`button` 即按钮本身就是`已是最新 X.Y.Z`(单按钮不占宽,可点再查,hover 回`检查更新`暗示动作;无新版中性弱边,hover 才走主色);`tooltip` 按钮还是`检查更新`、版本收进悬停;`note` 回旧样子(按钮旁右侧小字,只在 `openOn: 'has-update'` 下点击确认无新版后出现)。旧版默认即 `note` 的样子,要回去就显式传 `upToDateDisplay: 'note'`。想连按钮都不留痕,就用 `openOn: 'always'`(检查完总是开弹窗,无新版在弹窗里看“已是最新”)或 `openOn: 'direct'`(点开即弹窗,连预查都省了,面板挂载即自查;徽标形态仍走回调口径)。
213
215
 
214
216
  按钮尺寸(跟头行其他控件同高/整体缩放):传 `sizing` 只改按钮本体,不碰面板;不传即默认外观。`scale` 是整体缩放(默认 `1`,如 `1.2` 放大、`0.9` 缩小),原来写在容器上的 `zoom` 可整体搬进来:
215
217
 
@@ -15,10 +15,19 @@ export type BatchEntryAutoCheck = 'mount' | 'never';
15
15
  export type BatchEntryOpenOn = 'has-update' | 'always' | 'manual' | 'direct';
16
16
  /** 批量入口件的状态档:只用来算文案与 data-state,不另立状态机。 */
17
17
  export type BatchEntryStateKind = 'idle' | 'update' | 'busy' | 'restart' | 'failed';
18
- /** 算文案要的全部输入:批量七行 + 最近一次批量电话的失败稳定码(没查过、没失败为 null)。 */
18
+ /** 算文案要的全部输入:批量七行 + 最近一次批量电话的失败稳定码(没查过、没失败为 null)+ 知识账本 + 会话(后两样只做展示,不参与安装决策;缺省即旧语义)。 */
19
19
  export interface UpdateBatchEntryState {
20
20
  rows: readonly BatchRowView[] | null;
21
21
  error: string | null;
22
+ /** 知识账本 inventory(batchStatus/batchCheck 回包里的同形状透传;#83 与面板总账同口径)。 */
23
+ inventory?: unknown;
24
+ /**
25
+ * 会话(同回包里透传):只用来认「有没做完的一轮」——在轮行走执行态、不吃知识,
26
+ * 徽标才与面板总账同一句话(#73 / #82 §6 细则 3、ADR-0003 §2)。缺省即当无轮次。
27
+ * 连带影响:在轮时聚合档位是 idle(不是 update),openOn='has-update' 因此不会开面板——
28
+ * 徽标说「检查更新」、点一下真去查,与面板同一句话。
29
+ */
30
+ session?: unknown;
22
31
  }
23
32
  /** 聚合后的读数(onActivate 与测试共用同一份,不各算一份)。 */
24
33
  export interface BatchEntrySummary {
@@ -89,7 +98,7 @@ export interface UpdateBatchEntryController {
89
98
  unmount(): void;
90
99
  }
91
100
  /**
92
- * 聚合读数:计数唯一出处 batchLedgerCounts(与面板总账同一份,忙失败占位翻回可更新)。
101
+ * 聚合读数:计数唯一出处 batchLedgerCounts(含 inventory 即与面板总账同一份知识口径,忙失败占位翻回可更新)。
93
102
  * hasUpdate = 忙/失败/待重启/可更新任一非零(openOn has-update 按它开面板;全 settled/待查/跳过即 idle)。
94
103
  * v2 单语:label 与 HTML 出口同语言(lang 显式入参,缺省跟随全局;zh 口径逐字兼容旧中文)。
95
104
  */
@@ -26,7 +26,9 @@ function escapeHtml(value) {
26
26
  }
27
27
  function batchEntrySummary(state, lang) {
28
28
  const rows = state?.rows ?? null;
29
- const counts = batchLedgerCounts(Array.isArray(rows) ? rows : []);
29
+ const inventory = state?.inventory ?? void 0;
30
+ const session = state?.session;
31
+ const counts = batchLedgerCounts(Array.isArray(rows) ? rows : [], inventory, session);
30
32
  const total = Array.isArray(rows) ? rows.length : 0;
31
33
  let kind;
32
34
  if (!Array.isArray(rows) || rows.length === 0) {
@@ -178,6 +180,8 @@ function mountUpdateBatchEntry(container, options) {
178
180
  const ENTRY_SELECTOR = "[data-dsh-upd-entry]";
179
181
  let rows = null;
180
182
  let error = null;
183
+ let inventory = null;
184
+ let session = null;
181
185
  let note = null;
182
186
  let loaded = false;
183
187
  let activating = false;
@@ -205,7 +209,7 @@ function mountUpdateBatchEntry(container, options) {
205
209
  return isObject(reply) ? reply : {};
206
210
  }
207
211
  function stateOf() {
208
- return { rows, error };
212
+ return { rows, error, inventory, session };
209
213
  }
210
214
  function summaryOf() {
211
215
  return batchEntrySummary(stateOf(), currentLang());
@@ -241,6 +245,8 @@ function mountUpdateBatchEntry(container, options) {
241
245
  }
242
246
  const next = asRows(reply["rows"]);
243
247
  if (next) rows = next;
248
+ if (isObject(reply) && "inventory" in reply) inventory = reply["inventory"] ?? null;
249
+ if (isObject(reply) && "session" in reply) session = reply["session"] ?? null;
244
250
  error = null;
245
251
  loaded = true;
246
252
  }
package/dist/entry.d.ts CHANGED
@@ -12,6 +12,8 @@ export type EntryAutoCheck = 'mount' | 'never';
12
12
  * `manual` 交给 onActivate;`direct` 点开即弹窗、不预查(面板挂载即自查;badge 仍走回调口径)。
13
13
  */
14
14
  export type EntryOpenOn = 'has-update' | 'always' | 'manual' | 'direct';
15
+ /** 无新版时版本号摆哪:`note` 右侧小字(旧默认);`button` 按钮本身即版本(默认);`tooltip` 收进悬停。 */
16
+ export type UpToDateDisplay = 'note' | 'button' | 'tooltip';
15
17
  /** 入口件主题(与面板同一套取值,切换即换肤)。 */
16
18
  export type EntryTheme = UpdatePanelTheme;
17
19
  /** 入口件的状态档:只用来算文案与 data-state,不另立状态机。 */
@@ -52,6 +54,8 @@ export interface UpdateEntryOptions {
52
54
  autoCheck?: EntryAutoCheck;
53
55
  /** 缺省 'has-update':有新版才开面板,没有就只在原地给一句提示。 */
54
56
  openOn?: EntryOpenOn;
57
+ /** 无新版时版本号摆哪:缺省 'button'(按钮本身即版本,单按钮不占宽);'note' 回旧样子(右侧小字);'tooltip' 收进悬停。 */
58
+ upToDateDisplay?: UpToDateDisplay;
55
59
  /** 覆盖默认按钮文案(不传就用状态联动文案)。 */
56
60
  label?: string;
57
61
  /** 按钮尺寸覆盖(#69):见 EntrySizing;不传即零回归,badge/inline 不受影响。 */
@@ -89,8 +93,9 @@ export interface UpdateEntryController {
89
93
  unmount(): void;
90
94
  }
91
95
  /**
92
- * 状态档:快照六字段 + 失败码推出,顺序固定(活任务 > 待重启 > 失败 > 有新版 > 待查)。
93
- * 待重启是正常终态(不是失败),所以排在失败前面;正在装的任务比一条陈旧的失败码更可信。
96
+ * 状态档:快照六字段 + 失败码推出,顺序固定(活任务 > 失败 > 待重启 > 有新版 > 待查)。
97
+ * 失败码是最近一次通话的新证据,快照是上一次成功的旧读数:新证据赢(#91)。
98
+ * 待重启仍是正常终态,但只在无新失败时展示;正在装的任务比一条陈旧的失败码更可信。
94
99
  */
95
100
  export declare function entryStateKind(state: UpdateEntryState | null | undefined): EntryStateKind;
96
101
  /**
package/dist/entry.js CHANGED
@@ -8,6 +8,11 @@ import {
8
8
  } from "./panel.js";
9
9
  import { BILINGUAL_CSS, copyHTML, copyText } from "./bilingual.js";
10
10
  import { resolveLang, subscribeLang } from "./lang.js";
11
+ function upToDateVersionOf(snapshot, error) {
12
+ if (error || !snapshot || hasUpdateOf(snapshot)) return null;
13
+ const v = snapshot.runningVersion;
14
+ return typeof v === "string" && v.trim() ? v.trim() : null;
15
+ }
11
16
  function hasUpdateOf(snapshot) {
12
17
  if (!snapshot) return false;
13
18
  const latest = snapshot.latestVersion;
@@ -18,12 +23,12 @@ function entryStateKind(state) {
18
23
  const snapshot = state?.snapshot ?? null;
19
24
  const job = snapshot?.job ?? null;
20
25
  if (job && (job.state === "installing" || job.state === "verifying")) return "busy";
21
- if (snapshot && (snapshot.blockedReason === "pending-restart" || job?.state === "restart-required")) {
22
- return "restart";
23
- }
24
26
  if (state && state.error || job && (job.state === "failed" || job.state === "interrupted")) {
25
27
  return "failed";
26
28
  }
29
+ if (snapshot && (snapshot.blockedReason === "pending-restart" || job?.state === "restart-required")) {
30
+ return "restart";
31
+ }
27
32
  if (hasUpdateOf(snapshot)) return "update";
28
33
  return "idle";
29
34
  }
@@ -123,6 +128,9 @@ const UPDATE_ENTRY_CSS = [
123
128
  '.dsh-upd-entry[data-state="update"] .dsh-upd-entry-dot{background:var(--dsh-update-ok-border,#059669)}',
124
129
  '.dsh-upd-entry[data-state="busy"] .dsh-upd-entry-dot,.dsh-upd-entry[data-state="restart"] .dsh-upd-entry-dot{background:var(--dsh-update-warn-border,#d97706)}',
125
130
  '.dsh-upd-entry[data-state="failed"] .dsh-upd-entry-dot{background:var(--dsh-update-bad-border,#dc2626)}',
131
+ // C 直显(upToDateDisplay='button'):无新版时按钮本身即版本,走中性弱边(不抢有新版的红/绿),hover 才走 primary 暗示可再查;复用既有 token,不加新键。
132
+ '.dsh-upd-entry[data-known="uptodate"] .dsh-upd-entry-btn{border-color:var(--dsh-update-border,#d1d5db);color:var(--dsh-update-text-muted,#6b7280)}',
133
+ '.dsh-upd-entry[data-known="uptodate"] .dsh-upd-entry-btn:hover{border-color:var(--dsh-update-primary,#2563eb);color:var(--dsh-update-primary,#2563eb)}',
126
134
  // 小字自带底(深色宿主 + 浅色变量时也读得出;浅底宿主上只是多一圈细线,不抢戏)。
127
135
  ".dsh-upd-entry-note{font-size:12.5px;opacity:.9;background:var(--dsh-update-bg,#ffffff);border:1px solid var(--dsh-update-border,#e5e7eb);border-radius:var(--dsh-update-radius-badge,4px);padding:1px 8px}",
128
136
  '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-note{background:var(--dsh-update-bg);border-color:var(--dsh-update-border-strong);color:var(--dsh-update-text)}',
@@ -133,7 +141,7 @@ const UPDATE_ENTRY_CSS = [
133
141
  '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-btn{border-color:var(--dsh-update-border-strong);background:var(--dsh-update-bg);color:var(--dsh-update-text);border-radius:var(--dsh-update-entry-border-radius,3px)}',
134
142
  '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-btn:hover{border-color:var(--dsh-update-primary);color:var(--dsh-update-primary)}',
135
143
  '@media (prefers-color-scheme: dark){.dsh-upd-entry[data-theme="archive"]{--dsh-update-text:#ece5d3;--dsh-update-text-muted:#a89c83;--dsh-update-border-strong:#5c4e3b;--dsh-update-primary:#e0684e;--dsh-update-bg:#1e1a15}}',
136
- "@media (prefers-color-scheme: dark){.dsh-upd-entry{color:#e5e7eb}",
144
+ "@media (prefers-color-scheme: dark){.dsh-upd-entry{color:#e5e7eb;--dsh-update-text-muted:#9ca3af}",
137
145
  ".dsh-upd-entry-btn{--dsh-update-button-bg:#1f2937;--dsh-update-border:#374151}}"
138
146
  ].join("\n");
139
147
  const ENTRY_ATTR = "data-dsh-upd-entry";
@@ -179,6 +187,10 @@ function mountUpdateEntry(container, options) {
179
187
  if (openOn !== "has-update" && openOn !== "always" && openOn !== "manual" && openOn !== "direct") {
180
188
  throw new Error(`[dsh-plugin-update] \u70B9\u51FB\u53BB\u5411\u975E\u6CD5\uFF1A\u53EA\u6536 has-update / always / manual / direct\uFF08\u6536\u5230 ${JSON.stringify(options.openOn)}\uFF09`);
181
189
  }
190
+ const upToDateDisplay = options.upToDateDisplay ?? "button";
191
+ if (upToDateDisplay !== "note" && upToDateDisplay !== "button" && upToDateDisplay !== "tooltip") {
192
+ throw new Error(`[dsh-plugin-update] invalid upToDateDisplay: expected note / button / tooltip (got ${JSON.stringify(options.upToDateDisplay)})`);
193
+ }
182
194
  if (options.theme !== void 0 && options.theme !== "default" && options.theme !== "archive") {
183
195
  throw new Error(`[dsh-plugin-update] \u4E3B\u9898\u975E\u6CD5\uFF1A\u53EA\u6536 default \u6216 archive\uFF08\u6536\u5230 ${JSON.stringify(options.theme)}\uFF09`);
184
196
  }
@@ -237,7 +249,13 @@ function mountUpdateEntry(container, options) {
237
249
  return { snapshot, error };
238
250
  }
239
251
  function currentLabel() {
240
- return labelOverride ?? entryLabelFor(stateOf(), currentLang());
252
+ if (labelOverride) return labelOverride;
253
+ const lang = currentLang();
254
+ if (!activating && upToDateDisplay === "button" && entryStateKind(stateOf()) === "idle") {
255
+ const v = upToDateVersionOf(snapshot, error);
256
+ if (v) return copyText("entry.note.up-to-date", lang, { version: v });
257
+ }
258
+ return entryLabelFor(stateOf(), lang);
241
259
  }
242
260
  function hasUpdate() {
243
261
  return hasUpdateOf(snapshot);
@@ -248,18 +266,24 @@ function mountUpdateEntry(container, options) {
248
266
  function entryHTML() {
249
267
  const kind = entryStateKind(stateOf());
250
268
  const lang = currentLang();
251
- const labelHTML = labelOverride ? escapeHtml(labelOverride) : activating ? copyHTML("entry.action.checking", lang) : copyHTML(entryBilingualKeyFor(stateOf()), lang, entryBilingualValuesFor(stateOf()));
252
- const labelText = labelOverride ?? (activating ? copyText("entry.action.checking", lang) : copyText(entryBilingualKeyFor(stateOf()), lang, entryBilingualValuesFor(stateOf())));
269
+ const knownUpToDate = !activating && kind === "idle" ? upToDateVersionOf(snapshot, error) : null;
270
+ const buttonMode = !labelOverride && upToDateDisplay === "button" && knownUpToDate;
271
+ const tooltipMode = !labelOverride && upToDateDisplay === "tooltip" && knownUpToDate;
272
+ const labelHTML = labelOverride ? escapeHtml(labelOverride) : activating ? copyHTML("entry.action.checking", lang) : buttonMode && knownUpToDate ? copyHTML("entry.note.up-to-date", lang, { version: knownUpToDate }) : copyHTML(entryBilingualKeyFor(stateOf()), lang, entryBilingualValuesFor(stateOf()));
273
+ const labelText = labelOverride ?? (activating ? copyText("entry.action.checking", lang) : buttonMode && knownUpToDate ? copyText("entry.note.up-to-date", lang, { version: knownUpToDate }) : copyText(entryBilingualKeyFor(stateOf()), lang, entryBilingualValuesFor(stateOf())));
274
+ const titleText = labelOverride ? labelOverride : activating ? copyText("entry.action.checking", lang) : buttonMode && knownUpToDate ? copyText(entryBilingualKeyFor(stateOf()), lang, entryBilingualValuesFor(stateOf())) : tooltipMode && knownUpToDate ? copyText("entry.note.up-to-date", lang, { version: knownUpToDate }) : labelText;
253
275
  const busyAttr = activating ? ' disabled aria-busy="true"' : "";
254
276
  const themeAttr = theme === "archive" ? ' data-theme="archive"' : "";
255
277
  const tokensStyle = themeTokensStyleFor(themeTokens ?? void 0);
256
278
  const styleBody = [sizingStyle, tokensStyle].filter((part) => part).join(";");
257
279
  const styleAttr = styleBody ? ` style="${styleBody}"` : "";
258
- const control = variant === "badge" ? `<button type="button" class="dsh-upd-entry-dot" ${ENTRY_ATTR}="activate" title="${escapeHtml(labelText)}" aria-label="${escapeHtml(labelText)}"${busyAttr}></button>` : `<button type="button" class="dsh-upd-entry-btn" ${ENTRY_ATTR}="activate"${busyAttr}>${labelHTML}</button>`;
259
- const noteHTML = noteVersion ? `<span class="dsh-upd-entry-note" data-dsh-upd-note="1">${copyHTML("entry.note.up-to-date", lang, { version: noteVersion })}</span>` : "";
280
+ const knownAttr = buttonMode ? ' data-known="uptodate"' : "";
281
+ const displayAttr = ` data-uptodate="${upToDateDisplay}"`;
282
+ const control = variant === "badge" ? `<button type="button" class="dsh-upd-entry-dot" ${ENTRY_ATTR}="activate" title="${escapeHtml(titleText)}" aria-label="${escapeHtml(labelText)}"${busyAttr}></button>` : `<button type="button" class="dsh-upd-entry-btn" ${ENTRY_ATTR}="activate" title="${escapeHtml(titleText)}" aria-label="${escapeHtml(labelText)}"${busyAttr}>${labelHTML}</button>`;
283
+ const noteHTML = upToDateDisplay === "note" && noteVersion ? `<span class="dsh-upd-entry-note" data-dsh-upd-note="1">${copyHTML("entry.note.up-to-date", lang, { version: noteVersion })}</span>` : "";
260
284
  return `<style>${UPDATE_ENTRY_CSS}
261
285
  ${BILINGUAL_CSS}</style>
262
- <span class="dsh-upd-entry" data-variant="${variant}" data-state="${kind}"${themeAttr}${styleAttr}>${control}${noteHTML}</span>`;
286
+ <span class="dsh-upd-entry" data-variant="${variant}" data-state="${kind}"${displayAttr}${knownAttr}${themeAttr}${styleAttr}>${control}${noteHTML}</span>`;
263
287
  }
264
288
  function render() {
265
289
  if (!mounted) return;
@@ -361,7 +385,7 @@ ${BILINGUAL_CSS}</style>
361
385
  openDialog();
362
386
  return;
363
387
  }
364
- noteVersion = snapshot.runningVersion;
388
+ if (upToDateDisplay === "note") noteVersion = snapshot.runningVersion;
365
389
  render();
366
390
  }
367
391
  function setTheme(next) {
@@ -203,6 +203,7 @@ const EMPTY_CACHE = {
203
203
  manual: null,
204
204
  queue: null,
205
205
  profileName: null,
206
+ hostKind: null,
206
207
  error: null,
207
208
  diag: null
208
209
  };
@@ -235,11 +236,21 @@ async function callPhone(rt, name, args) {
235
236
  if ("snapshot" in value) next.snapshot = value["snapshot"] ?? null;
236
237
  if ("manual" in value) next.manual = firstText(value["manual"]);
237
238
  if ("queue" in value) next.queue = value["queue"] ?? null;
238
- if ("env" in value) next.profileName = firstText(asRecord(value["env"])["profileName"]);
239
+ if ("env" in value) {
240
+ next.profileName = firstText(asRecord(value["env"])["profileName"]);
241
+ next.hostKind = firstText(asRecord(value["env"])["environmentKind"]);
242
+ }
239
243
  next.error = null;
240
244
  next.diag = null;
241
245
  } else {
242
246
  next.diag = "diag" in value ? value["diag"] ?? null : null;
247
+ if ("env" in value) {
248
+ const envRec = asRecord(value["env"]);
249
+ const pn = firstText(envRec["profileName"]);
250
+ const hk = firstText(envRec["environmentKind"]);
251
+ if (pn !== null) next.profileName = pn;
252
+ if (hk !== null) next.hostKind = hk;
253
+ }
243
254
  }
244
255
  rt.cache = next;
245
256
  return value;
@@ -249,7 +260,7 @@ async function defaultCheck(rt) {
249
260
  if (reply["ok"] !== true) return { kind: "failed", error: codeOfReply(reply) };
250
261
  const snapshot = snapshotOf(reply);
251
262
  const latest = firstText(snapshot["latestVersion"]);
252
- if (!latest) return { kind: "current" };
263
+ if (!latest) return { kind: "failed", error: "check-failed" };
253
264
  if (isVersionSkipped(await skippedVersionsOf(rt), latest)) return { kind: "skipped", version: latest };
254
265
  const base = firstText(snapshot["installedVersion"], snapshot["runningVersion"]);
255
266
  if (base) {
@@ -490,6 +501,33 @@ function createMultiHostUpdate(deps = {}, options) {
490
501
  return null;
491
502
  }
492
503
  }
504
+ async function reconcileRestart(rt, entry) {
505
+ if (entry.phase !== "done" || entry.restartRequired !== true) return null;
506
+ const snapshot = asRecord(rt.cache.snapshot);
507
+ const running = firstText(snapshot["runningVersion"]);
508
+ const installed = firstText(snapshot["installedVersion"]);
509
+ if (!running || !installed) return null;
510
+ const target = entry.targetVersion ?? installed;
511
+ if (!target) return null;
512
+ if (running !== target) return null;
513
+ if (installed !== running) return null;
514
+ if (firstText(snapshot["blockedReason"]) === "pending-restart") return null;
515
+ const job = snapshot["job"];
516
+ const jobState = job && typeof job === "object" && !Array.isArray(job) ? firstText(job["state"]) : null;
517
+ if (jobState === "restart-required") return null;
518
+ try {
519
+ const latest = await readSession();
520
+ const current = batchEntryOf(latest, entry.key);
521
+ if (!current || current.phase !== "done" || current.restartRequired !== true) return null;
522
+ const marked = markBatchEntry(latest, entry.key, { phase: "done", restartRequired: false, error: null }, now());
523
+ if (!marked.changed) return null;
524
+ await saveSession(marked.session);
525
+ const corrected = batchEntryOf(marked.session, entry.key);
526
+ return corrected ? { session: marked.session, entry: corrected } : null;
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
493
531
  async function buildRows(session, refresh) {
494
532
  let current = session;
495
533
  const rows = [];
@@ -505,6 +543,13 @@ function createMultiHostUpdate(deps = {}, options) {
505
543
  }
506
544
  }
507
545
  }
546
+ if (entry && !driveActive) {
547
+ const correctedRestart = await reconcileRestart(rt, entry);
548
+ if (correctedRestart) {
549
+ current = correctedRestart.session;
550
+ entry = correctedRestart.entry;
551
+ }
552
+ }
508
553
  const phase = entry ? entry.phase : "pending";
509
554
  rows.push({
510
555
  key: rt.spec.key,
@@ -517,6 +562,7 @@ function createMultiHostUpdate(deps = {}, options) {
517
562
  manual: rt.cache.manual,
518
563
  queue: rt.cache.queue,
519
564
  profileName: rt.cache.profileName,
565
+ hostKind: rt.cache.hostKind,
520
566
  phoneNames: rt.host.phoneNames,
521
567
  // 新增两格(#25 解法三,只增不改):该目标的宿主插件标识,与最近一次失败回包的诊断。
522
568
  pluginId: rt.pluginId,
@@ -647,44 +693,43 @@ function createMultiHostUpdate(deps = {}, options) {
647
693
  async function checkPhone() {
648
694
  const refuse = denied();
649
695
  if (refuse) return refuse;
696
+ if (driveActive) return { ok: false, error: "update-busy", errorKind: "update-busy" };
650
697
  const session = await readSession();
651
- if (!driveActive) {
652
- const at = now();
653
- let prev = emptyBatchInventory();
698
+ const at = now();
699
+ let prev = emptyBatchInventory();
700
+ try {
701
+ prev = await readInventory();
702
+ } catch {
703
+ prev = emptyBatchInventory();
704
+ }
705
+ const entries = { ...prev.entries };
706
+ for (const rt of runtimes) {
654
707
  try {
655
- prev = await readInventory();
656
- } catch {
657
- prev = emptyBatchInventory();
658
- }
659
- const entries = { ...prev.entries };
660
- for (const rt of runtimes) {
661
- try {
662
- const outcome = await rt.check(rt.spec.key, rt.spec);
663
- rt.cache = { ...rt.cache, error: outcome.kind === "failed" ? outcome.error : null };
664
- const snapshot = asRecord(rt.cache.snapshot);
665
- const installed = firstText(snapshot["installedVersion"], snapshot["runningVersion"]);
666
- const canInstallRaw = snapshot["canInstall"];
667
- const canInstall = typeof canInstallRaw === "boolean" ? canInstallRaw : null;
668
- if (outcome.kind === "update") {
669
- entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: outcome.version, canInstall, error: null };
670
- } else if (outcome.kind === "current") {
671
- entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: installed, canInstall: false, error: null };
672
- } else if (outcome.kind === "skipped") {
673
- entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: outcome.version, canInstall: false, error: null };
674
- } else {
675
- entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: firstText(snapshot["latestVersion"]), canInstall, error: outcome.error };
676
- }
677
- } catch (error) {
678
- const code = errorPayloadOf(error).error;
679
- rt.cache = { ...rt.cache, error: code };
680
- const snapshot = asRecord(rt.cache.snapshot);
681
- entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: firstText(snapshot["installedVersion"], snapshot["runningVersion"]), latestVersion: firstText(snapshot["latestVersion"]), canInstall: typeof snapshot["canInstall"] === "boolean" ? snapshot["canInstall"] : null, error: code };
708
+ const outcome = await rt.check(rt.spec.key, rt.spec);
709
+ rt.cache = { ...rt.cache, error: outcome.kind === "failed" ? outcome.error : null };
710
+ const snapshot = asRecord(rt.cache.snapshot);
711
+ const installed = firstText(snapshot["installedVersion"], snapshot["runningVersion"]);
712
+ const canInstallRaw = snapshot["canInstall"];
713
+ const canInstall = typeof canInstallRaw === "boolean" ? canInstallRaw : null;
714
+ if (outcome.kind === "update") {
715
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: outcome.version, canInstall, error: null };
716
+ } else if (outcome.kind === "current") {
717
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: installed, canInstall: false, error: null };
718
+ } else if (outcome.kind === "skipped") {
719
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: outcome.version, canInstall: false, error: null };
720
+ } else {
721
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: firstText(snapshot["latestVersion"]), canInstall, error: outcome.error };
682
722
  }
723
+ } catch (error) {
724
+ const code = errorPayloadOf(error).error;
725
+ rt.cache = { ...rt.cache, error: code };
726
+ const snapshot = asRecord(rt.cache.snapshot);
727
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: firstText(snapshot["installedVersion"], snapshot["runningVersion"]), latestVersion: firstText(snapshot["latestVersion"]), canInstall: typeof snapshot["canInstall"] === "boolean" ? snapshot["canInstall"] : null, error: code };
683
728
  }
684
- try {
685
- await writeInventory(normalizeBatchInventory({ version: 1, updatedAt: at, entries }, at));
686
- } catch {
687
- }
729
+ }
730
+ try {
731
+ await writeInventory(normalizeBatchInventory({ version: 1, updatedAt: at, entries }, at));
732
+ } catch {
688
733
  }
689
734
  return await table(session, false);
690
735
  }
@@ -72,7 +72,7 @@ export interface BatchPanelOptions {
72
72
  autoResume?: boolean;
73
73
  }
74
74
  /** 面板可点的动作(HTML 上 data-act 一一对应;测试走同一条路)。 */
75
- export type BatchPanelActionKind = 'check' | 'install' | 'resume' | 'cancel' | 'row-install' | 'row-skip' | 'row-resume-skip' | 'row-cancel-queue' | 'row-copy-manual' | 'row-copy-diag' | 'toggle-details' | 'restart' | 'close' | 'toggle-check-on-open';
75
+ export type BatchPanelActionKind = 'check' | 'install' | 'resume' | 'cancel' | 'row-install' | 'row-check' | 'row-skip' | 'row-resume-skip' | 'row-cancel-queue' | 'row-copy-manual' | 'row-copy-diag' | 'toggle-details' | 'restart' | 'close' | 'toggle-check-on-open';
76
76
  /** 挂载点:只要有 innerHTML 的容器即可(浏览器元素或测试替身都行)。 */
77
77
  export interface BatchPanelContainer {
78
78
  innerHTML: string;
@@ -127,8 +127,14 @@ export interface BatchLedgerCounts {
127
127
  * 失败优先(失败行永远算失败,不算待重启);其余按相位落档;不用重启的终态才算「已最新」。
128
128
  * 例外一处:「忙失败占位」(phase=failed 但 error=update-busy)其实是**排队**不是失败——
129
129
  * 宿主忙时先写占位再回 update-busy,面板把它翻回「可更新」,免得总账把它报成失败。
130
+ * 无轮次 pending 行看知识(#73 口径 = #82 报告 §6 推荐 A),条件与行展示同一处(isInUnfinishedRound):
131
+ * 有新版进可更新、已是最新进已最新、没有结果(无知识 / 查失败)仍待查;
132
+ * checking 行与「有没做完一轮」的行永远走执行态(待查),都不吃知识。
133
+ * 终态 current / 无重启 done 行看世界账(#90 世界账与轮次账分家):知识更新鲜且确有新版即进可更新,
134
+ * 否则沿旧结论(已最新),免得陈旧知识或失败的检查翻转已收尾的轮次。
135
+ * 不传 inventory 即无知识;不传 session 即当无轮次(旧调用零回归)。
130
136
  */
131
- export declare function batchLedgerCounts(rows: readonly BatchRowView[]): BatchLedgerCounts;
137
+ export declare function batchLedgerCounts(rows: readonly BatchRowView[], inventory?: unknown, session?: unknown): BatchLedgerCounts;
132
138
  /** 分类计数 → 一句话总账(只出现非零档,顺序固定:可更新 · 安装中 · 待查 · 待重启 · 失败 · 已跳过 · 已最新;#64 单语:缺省 zh 零回归)。 */
133
139
  export declare function batchLedgerText(counts: BatchLedgerCounts, lang?: unknown): string;
134
140
  /**
@@ -164,7 +170,7 @@ export declare function batchRowKnowledgeText(row: BatchRowView, entry: BatchKno
164
170
  * 一行的版本展示(#71 Q3:永远显示当前版;有新版才 `当前 → 最新`)。
165
171
  * latest 只做展示,不参与安装决策:会话目标版优先(安装语义),无会话目标时才看知识最新版。
166
172
  * - 会话有目标版:current(快照运行版优先)→ 会话目标(current 缺失时占位 `?`,与旧口径一致)。
167
- * - 会话无目标:current(快照优先,缺失退知识 installedVersion)→ 知识 latest(仅当两者皆有且不等;相等即已是最新,只显当前)。
173
+ * - 会话无目标:current(快照优先,缺失退知识 installedVersion)→ 知识 latest(不等即 → 红;相等即已是最新,只显当前;current 缺失而 latest 有即 `? → latest` 红,与会话缺当前同例)。
168
174
  * - 两者皆无:current 为 null、latest 为 null、hasUpdate 为 false(调用方不画版本 span)。
169
175
  */
170
176
  export interface BatchRowVersionParts {
@@ -204,7 +210,7 @@ export interface BatchPanelRenderInput {
204
210
  noticeKey?: string | null;
205
211
  /** 键 -> 该行已取到的日志全文(只传有文本的;取不到与没展开即中性提示,不挡安装)。 */
206
212
  changelogs?: Record<string, string | null>;
207
- /** 轮次会话(有未终态行才画控制区;不传按行相位回退旧口径,兼容旧快照)。 */
213
+ /** 轮次会话(有未终态行才画控制区,并决定在轮的行与总账吃不吃知识;不传按行相位回退旧口径,兼容旧快照)。 */
208
214
  session?: unknown;
209
215
  /** 知识账本(无轮次时行按知识展示;只做展示,不参与安装决策)。 */
210
216
  inventory?: unknown;
@@ -16,6 +16,7 @@ import {
16
16
  failureCopy,
17
17
  isKnownFailureCode,
18
18
  normalizePanelTheme,
19
+ readDiagTolerant,
19
20
  renderUpdatePanelHTML,
20
21
  themeTokensStyleFor
21
22
  } from "./panel.js";
@@ -35,7 +36,7 @@ function buildBatchPhoneNames(prefix) {
35
36
  prefsSave: p + ".batchPrefsSave"
36
37
  };
37
38
  }
38
- function batchLedgerCounts(rows) {
39
+ function batchLedgerCounts(rows, inventory, session) {
39
40
  const counts = {
40
41
  updatable: 0,
41
42
  installing: 0,
@@ -51,13 +52,21 @@ function batchLedgerCounts(rows) {
51
52
  else if (phase === "failed") counts.failed += 1;
52
53
  else if (phase === "installing") counts.installing += 1;
53
54
  else if (phase === "ready") counts.updatable += 1;
54
- else if (phase === "pending" || phase === "checking") counts.pending += 1;
55
+ else if (phase === "pending")
56
+ counts[isInUnfinishedRound(session, row.key) ? "pending" : ledgerKnowledgeBucket(knowledgeEntryOf(inventory, row.key))] += 1;
57
+ else if (phase === "checking") counts.pending += 1;
55
58
  else if (phase === "skipped") counts.skipped += 1;
56
59
  else if (row.restartRequired === true) counts.restart += 1;
57
- else counts.settled += 1;
60
+ else counts[worldUpdateVersion(row, knowledgeEntryOf(inventory, row.key), sessionUpdatedAtOf(session, row.key)) ? "updatable" : "settled"] += 1;
58
61
  }
59
62
  return counts;
60
63
  }
64
+ function ledgerKnowledgeBucket(entry) {
65
+ const verdict = knowledgeVerdictOf(entry);
66
+ if (verdict === "update") return "updatable";
67
+ if (verdict === "current") return "settled";
68
+ return "pending";
69
+ }
61
70
  function batchLedgerText(counts, lang) {
62
71
  const l = langOf(lang);
63
72
  const parts = [];
@@ -91,6 +100,13 @@ function knowledgeEntryOf(inventory, key) {
91
100
  error: typeof e["error"] === "string" ? e["error"] : null
92
101
  };
93
102
  }
103
+ function knowledgeVerdictOf(entry) {
104
+ if (!entry) return "never";
105
+ if (entry.error) return "failed";
106
+ if (entry.latestVersion && entry.installedVersion) return entry.latestVersion !== entry.installedVersion ? "update" : "current";
107
+ if (entry.latestVersion && !entry.installedVersion) return "update";
108
+ return "never";
109
+ }
94
110
  function langOf(lang) {
95
111
  try {
96
112
  return normalizeLangTag(typeof lang === "string" ? lang : resolveLang(lang));
@@ -105,34 +121,52 @@ function batchText(key, lang, values) {
105
121
  return "";
106
122
  }
107
123
  }
108
- function hasUnfinishedRows(rows, session) {
109
- if (isObject(session)) {
110
- const entries = session["entries"];
111
- if (Array.isArray(entries)) {
112
- for (const item of entries) {
113
- if (!isObject(item)) continue;
114
- const phase = item["phase"];
115
- if (typeof phase === "string" && !isTerminalPhase(asBatchPhase(phase))) return true;
116
- }
117
- return false;
118
- }
124
+ function sessionEntriesOf(session) {
125
+ if (!isObject(session)) return null;
126
+ const entries = session["entries"];
127
+ if (!Array.isArray(entries)) return null;
128
+ return entries.filter((item) => isObject(item));
129
+ }
130
+ function entryPhaseOf(entry) {
131
+ const phase = entry["phase"];
132
+ return typeof phase === "string" ? asBatchPhase(phase) : null;
133
+ }
134
+ function isUnfinishedEntry(entry) {
135
+ const phase = entryPhaseOf(entry);
136
+ return phase !== null && !isTerminalPhase(phase);
137
+ }
138
+ function isInUnfinishedRound(session, key) {
139
+ const entries = sessionEntriesOf(session);
140
+ if (entries === null) return false;
141
+ return entries.some((entry) => entry["key"] === key && isUnfinishedEntry(entry));
142
+ }
143
+ function sessionUpdatedAtOf(session, key) {
144
+ const entries = sessionEntriesOf(session);
145
+ if (entries === null) return null;
146
+ for (const entry of entries) {
147
+ if (entry["key"] !== key) continue;
148
+ const at = entry["updatedAt"];
149
+ if (typeof at === "number" && Number.isFinite(at)) return at;
119
150
  }
151
+ return null;
152
+ }
153
+ function worldUpdateVersion(row, knowledge, sessionUpdatedAt) {
154
+ const phase = asBatchPhase(row.phase);
155
+ if (phase !== "current" && !(phase === "done" && row.restartRequired !== true)) return null;
156
+ if (!knowledge || knowledge.error) return null;
157
+ if (sessionUpdatedAt !== null && !(knowledge.lastCheckedAt > sessionUpdatedAt)) return null;
158
+ const parts = batchRowVersionParts(row, knowledge);
159
+ return parts.hasUpdate && parts.latest ? parts.latest : null;
160
+ }
161
+ function hasUnfinishedRows(rows, session) {
162
+ const entries = sessionEntriesOf(session);
163
+ if (entries !== null) return entries.some(isUnfinishedEntry);
120
164
  if (rows.length === 0) return false;
121
165
  return !rows.every((row) => isTerminalPhase(asBatchPhase(row.phase)));
122
166
  }
123
167
  function unfinishedCount(rows, session) {
124
- if (isObject(session)) {
125
- const entries = session["entries"];
126
- if (Array.isArray(entries)) {
127
- let n = 0;
128
- for (const item of entries) {
129
- if (!isObject(item)) continue;
130
- const phase = item["phase"];
131
- if (typeof phase === "string" && !isTerminalPhase(asBatchPhase(phase))) n += 1;
132
- }
133
- return n;
134
- }
135
- }
168
+ const entries = sessionEntriesOf(session);
169
+ if (entries !== null) return entries.filter(isUnfinishedEntry).length;
136
170
  return rows.filter((row) => !isTerminalPhase(asBatchPhase(row.phase))).length;
137
171
  }
138
172
  function batchRowStatus(row, skippedVersion, lang) {
@@ -162,17 +196,10 @@ function batchRowStatus(row, skippedVersion, lang) {
162
196
  }
163
197
  function batchRowKnowledgeText(row, entry, lang) {
164
198
  if (asBatchPhase(row.phase) !== "pending" || !entry) return null;
165
- if (entry.error) return batchText("batch.row.failed", lang);
166
- if (entry.latestVersion && entry.installedVersion && entry.latestVersion !== entry.installedVersion) {
167
- return batchText("batch.row.update", lang, { version: entry.latestVersion });
168
- }
169
- if (entry.latestVersion && entry.installedVersion && entry.latestVersion === entry.installedVersion) {
170
- return batchText("batch.row.current", lang);
171
- }
172
- if (entry.latestVersion && !entry.installedVersion) {
173
- return batchText("batch.row.update", lang, { version: entry.latestVersion });
174
- }
175
- if (entry.lastCheckedAt > 0) return batchText("batch.row.current", lang);
199
+ const verdict = knowledgeVerdictOf(entry);
200
+ if (verdict === "update" && entry.latestVersion) return batchText("batch.row.update", lang, { version: entry.latestVersion });
201
+ if (verdict === "current") return batchText("batch.row.current", lang);
202
+ if (verdict === "failed") return batchText("batch.row.failed", lang);
176
203
  return batchText("batch.row.never", lang);
177
204
  }
178
205
  function batchRowVersionParts(row, knowledge) {
@@ -188,14 +215,15 @@ function batchRowVersionParts(row, knowledge) {
188
215
  const latest = knowledge && typeof knowledge.latestVersion === "string" && knowledge.latestVersion ? knowledge.latestVersion : null;
189
216
  if (current && latest && latest !== current) return { current, latest, hasUpdate: true };
190
217
  if (current) return { current, latest: null, hasUpdate: false };
191
- if (latest) return { current: latest, latest: null, hasUpdate: false };
218
+ if (latest) return { current: null, latest, hasUpdate: true };
192
219
  return { current: null, latest: null, hasUpdate: false };
193
220
  }
194
221
  function batchRowVersionHTML(row, knowledge) {
195
222
  const parts = batchRowVersionParts(row, knowledge);
196
223
  if (parts.hasUpdate && parts.latest) {
197
224
  const cur = parts.current ?? "?";
198
- return '<span class="dsh-upd-bver"><span class="dsh-upd-bver-cur">' + escapeHtml(cur) + '</span><span class="dsh-upd-bver-arrow" aria-hidden="true"> \u2192 </span><span class="dsh-upd-bver-new">' + escapeHtml(parts.latest) + "</span></span>";
225
+ const newCls = asBatchPhase(row.phase) === "done" ? "dsh-upd-bver-new-ok" : "dsh-upd-bver-new";
226
+ return '<span class="dsh-upd-bver"><span class="dsh-upd-bver-cur">' + escapeHtml(cur) + '</span><span class="dsh-upd-bver-arrow" aria-hidden="true"> \u2192 </span><span class="' + newCls + '">' + escapeHtml(parts.latest) + "</span></span>";
199
227
  }
200
228
  if (parts.current) return '<span class="dsh-upd-bver">' + escapeHtml(parts.current) + "</span>";
201
229
  return "";
@@ -216,7 +244,7 @@ function renderBatchPanelHTML(input) {
216
244
  const expandedKey = typeof input.expandedKey === "string" && input.expandedKey ? input.expandedKey : null;
217
245
  const lastError = typeof input.lastError === "string" && input.lastError ? input.lastError : null;
218
246
  const loaded = input.loaded === void 0 ? rows.length > 0 : input.loaded === true;
219
- const counts = batchLedgerCounts(rows);
247
+ const counts = batchLedgerCounts(rows, input.inventory, input.session);
220
248
  const installing = counts.installing > 0;
221
249
  const macroBusy = input.inFlight === true || installing;
222
250
  const total = rows.length;
@@ -238,7 +266,8 @@ function renderBatchPanelHTML(input) {
238
266
  changelogMarkdown: null,
239
267
  lang,
240
268
  knowledge: null,
241
- inRound: false
269
+ inRound: false,
270
+ worldLatest: null
242
271
  };
243
272
  const parts = [];
244
273
  parts.push('<div class="dsh-upd-batch-head">');
@@ -257,24 +286,13 @@ function renderBatchPanelHTML(input) {
257
286
  const showControls = total > 0 && hasUnfinishedRows(rows, session ?? void 0);
258
287
  const prefs = isObject(input.prefs) ? input.prefs : null;
259
288
  const checkOnOpen = prefs === null ? null : prefs["checkOnOpen"] === false ? false : true;
260
- function roundEntryPhase(key) {
261
- if (!session) return null;
262
- const entries = session["entries"];
263
- if (!Array.isArray(entries)) return null;
264
- for (const item of entries) {
265
- if (!isObject(item)) continue;
266
- const e = item;
267
- if (e["key"] === key && typeof e["phase"] === "string") return e["phase"];
268
- }
269
- return null;
270
- }
271
289
  parts.push('<div class="dsh-upd-btable">');
272
290
  for (const row of rows) {
273
291
  const raw = skippedVersions[row.key];
274
292
  const skipped = typeof raw === "string" && raw ? raw : null;
275
293
  const expanded = row.key === expandedKey;
276
- const entryPhase = roundEntryPhase(row.key);
277
- const inRound = entryPhase !== null && !isTerminalPhase(asBatchPhase(entryPhase));
294
+ const inRound = isInUnfinishedRound(input.session, row.key);
295
+ const knowledge = !inRound ? knowledgeEntryOf(input.inventory, row.key) : null;
278
296
  parts.push(
279
297
  rowSetHTML(row, {
280
298
  ...ctx,
@@ -283,8 +301,9 @@ function renderBatchPanelHTML(input) {
283
301
  notice: expanded && row.key === detailNoticeKey ? notice : null,
284
302
  changelogMarkdown: expanded && typeof changelogs[row.key] === "string" ? changelogs[row.key] : null,
285
303
  lang,
286
- knowledge: asBatchPhase(row.phase) === "pending" && !inRound ? knowledgeEntryOf(input.inventory, row.key) : null,
287
- inRound
304
+ knowledge,
305
+ inRound,
306
+ worldLatest: worldUpdateVersion(row, knowledge, sessionUpdatedAtOf(input.session, row.key))
288
307
  })
289
308
  );
290
309
  }
@@ -386,7 +405,7 @@ function rowSetHTML(row, ctx) {
386
405
  const queued = isQueuedRow(row);
387
406
  const position = queued ? queuePositionOf(row) : null;
388
407
  const versionHTML = batchRowVersionHTML(row, ctx.knowledge);
389
- const rowStatusText = queued ? batchQueuedStatus(position, ctx.lang) : ctx.inRound ? batchRowKnowledgeText(row, ctx.knowledge, ctx.lang) ?? batchRowStatus(row, skipped, ctx.lang) : batchRowPendingStatus(row, ctx.knowledge, ctx.lang) ?? batchRowStatus(row, skipped, ctx.lang);
408
+ const rowStatusText = queued ? batchQueuedStatus(position, ctx.lang) : ctx.worldLatest !== null ? batchText("batch.row.update", ctx.lang, { version: ctx.worldLatest }) : ctx.inRound ? batchRowKnowledgeText(row, ctx.knowledge, ctx.lang) ?? batchRowStatus(row, skipped, ctx.lang) : batchRowPendingStatus(row, ctx.knowledge, ctx.lang) ?? batchRowStatus(row, skipped, ctx.lang);
390
409
  const actions = [];
391
410
  const off = ctx.inFlight ? " disabled" : "";
392
411
  if (phase === "installing") {
@@ -427,7 +446,7 @@ function rowSetHTML(row, ctx) {
427
446
  const withCode = escaped.includes(codeEsc) ? escaped.replace(codeEsc, "<code>" + codeEsc + "</code>") : escaped;
428
447
  return '<span class="dsh-upd-bfail">' + withCode + "</span>";
429
448
  })() : "";
430
- const main = '<button type="button" class="dsh-upd-brow-main" data-act="toggle-details" data-key="' + keyAttr + '" aria-expanded="' + (ctx.expanded ? "true" : "false") + '"><span class="dsh-upd-updot" data-tone="' + dotToneOf(row, phase, skipped, queued) + '"></span><span class="dsh-upd-bname">' + escapeHtml(titleOf(row, ctx.titles)) + "</span>" + versionHTML + '<span class="dsh-upd-bstat">' + escapeHtml(rowStatusText) + (ctx.inRound && !queued ? "\uFF08" + escapeHtml(batchText("batch.row.unfinished-tag", ctx.lang)) + "\uFF09" : "") + "</span>" + failLine + "</button>";
449
+ const main = '<button type="button" class="dsh-upd-brow-main" data-act="toggle-details" data-key="' + keyAttr + '" aria-expanded="' + (ctx.expanded ? "true" : "false") + '"><span class="dsh-upd-updot" data-tone="' + dotToneOf(row, phase, skipped, queued, ctx.worldLatest) + '"></span><span class="dsh-upd-bname">' + escapeHtml(titleOf(row, ctx.titles)) + "</span>" + versionHTML + '<span class="dsh-upd-bstat">' + escapeHtml(rowStatusText) + (ctx.inRound && !queued ? "\uFF08" + escapeHtml(batchText("batch.row.unfinished-tag", ctx.lang)) + "\uFF09" : "") + "</span>" + failLine + "</button>";
431
450
  const detail = ctx.expanded ? '<div class="dsh-upd-bdetail" data-key="' + keyAttr + '">' + detailActionsHTML(row, ctx) + (ctx.notice !== null ? '<div class="dsh-upd-bdetail-notice" role="status">' + escapeHtml(ctx.notice) + "</div>" : "") + detailHTML(row, ctx) + "</div>" : "";
432
451
  return '<div class="dsh-upd-brow-set" data-key="' + keyAttr + '"><div class="dsh-upd-brow" data-phase="' + escapeHtml(phase) + '" data-key="' + keyAttr + '"' + (queued ? ' data-queued="1"' : "") + ">" + main + '<span class="dsh-upd-brow-actions">' + actions.join("") + "</span></div>" + detail + "</div>";
433
452
  }
@@ -449,6 +468,7 @@ function detailHTML(row, ctx) {
449
468
  theme: ctx.theme,
450
469
  themeTokens: ctx.themeTokens,
451
470
  profileName: typeof row.profileName === "string" && row.profileName ? row.profileName : null,
471
+ hostKind: typeof row.hostKind === "string" && row.hostKind ? row.hostKind : null,
452
472
  actions: "none"
453
473
  }, ctx.lang);
454
474
  return stripActionButtons(html);
@@ -477,6 +497,11 @@ function detailActionsHTML(row, ctx) {
477
497
  '<button type="button" data-act="row-install" data-key="' + keyAttr + '" data-primary="1"' + off + ">" + escapeHtml(ctx.busy ? batchText("batch.row-action.queue", ctx.lang) : version !== null ? batchText("batch.row-action.install-version", ctx.lang, { version }) : batchText("batch.row-action.install-generic", ctx.lang)) + "</button>"
478
498
  );
479
499
  }
500
+ if (phase !== "installing" && singleCheckPhoneOf(row) !== null) {
501
+ buttons.push(
502
+ '<button type="button" data-act="row-check" data-key="' + keyAttr + '"' + off + ">" + escapeHtml(batchText("batch.action.check", ctx.lang)) + "</button>"
503
+ );
504
+ }
480
505
  if (!queued && phase === "ready" && ctx.skipped === null && version !== null) {
481
506
  buttons.push(
482
507
  '<button type="button" data-act="row-skip" data-key="' + keyAttr + '"' + off + ">" + escapeHtml(batchText("batch.row-action.skip", ctx.lang)) + "</button>"
@@ -498,11 +523,12 @@ function detailActionsHTML(row, ctx) {
498
523
  function stripActionButtons(html) {
499
524
  return html.replace(/<button\b[^>]*data-action=[^>]*>[\s\S]*?<\/button>/g, "");
500
525
  }
501
- function dotToneOf(row, phase, skipped, queued) {
526
+ function dotToneOf(row, phase, skipped, queued, worldLatest = null) {
502
527
  if (phase === "failed" && !queued) return "bad";
503
528
  if (phase === "installing" || phase === "checking") return "busy";
504
529
  if (queued) return "warn";
505
530
  if (row.restartRequired === true) return "warn";
531
+ if (worldLatest !== null) return "todo";
506
532
  if (phase === "ready") return skipped === null ? "todo" : "idle";
507
533
  if (phase === "current" || phase === "done") return "ok";
508
534
  return "idle";
@@ -534,11 +560,15 @@ const UPDATE_BATCH_PANEL_CSS = [
534
560
  ".dsh-upd-bver-cur{opacity:.65}",
535
561
  ".dsh-upd-bver-arrow{opacity:.6}",
536
562
  ".dsh-upd-bver-new{color:var(--dsh-update-new-text,#dc2626);font-weight:700}",
563
+ ".dsh-upd-bver-new-ok{color:var(--dsh-update-new-ok-text,#047857);font-weight:700}",
537
564
  ".dsh-upd-bstat{font-size:12.5px;opacity:.9;min-width:0;overflow-wrap:anywhere}",
538
565
  ".dsh-upd-bfail{grid-column:1/-1;display:block;font-size:12.5px;color:var(--dsh-update-bad-border,#dc2626)}",
539
566
  ".dsh-upd-batch{--dsh-update-new-text:#dc2626}",
567
+ ".dsh-upd-batch{--dsh-update-new-ok-text:#047857}",
540
568
  "@media (prefers-color-scheme: dark){.dsh-upd-batch{--dsh-update-new-text:#f87171}}",
569
+ "@media (prefers-color-scheme: dark){.dsh-upd-batch{--dsh-update-new-ok-text:#34d399}}",
541
570
  "@media (forced-colors: active){.dsh-upd-bver-new{color:CanvasText}}",
571
+ "@media (forced-colors: active){.dsh-upd-bver-new-ok{color:CanvasText}}",
542
572
  ".dsh-upd-blist{font-size:12.5px;margin-top:2px}",
543
573
  ".dsh-upd-batch-ledger{margin:8px 0 0;font-size:13px;opacity:.85}",
544
574
  ".dsh-upd-batch-more{margin-top:6px}",
@@ -576,7 +606,10 @@ const UPDATE_BATCH_PANEL_ARCHIVE_CSS = [
576
606
  '.dsh-upd[data-theme="archive"] .dsh-upd-bver{font-family:var(--dsh-update-font-mono);color:var(--dsh-update-text-muted)}',
577
607
  '.dsh-upd[data-theme="archive"] .dsh-upd-bver-cur{opacity:.75}',
578
608
  '.dsh-upd[data-theme="archive"] .dsh-upd-bver-new{color:var(--dsh-update-new-text,#b3261e)}',
609
+ '.dsh-upd[data-theme="archive"] .dsh-upd-bver-new-ok{color:var(--dsh-update-new-ok-text,#1a7f37)}',
610
+ '@media (prefers-color-scheme: dark){.dsh-upd[data-theme="archive"] .dsh-upd-batch{--dsh-update-new-ok-text:#8fd6a4}}',
579
611
  '.dsh-upd[data-theme="archive"] .dsh-upd-batch{--dsh-update-new-text:#b3261e}',
612
+ '.dsh-upd[data-theme="archive"] .dsh-upd-batch{--dsh-update-new-ok-text:#1a7f37}',
580
613
  '@media (prefers-color-scheme: dark){.dsh-upd[data-theme="archive"] .dsh-upd-batch{--dsh-update-new-text:#ef8a7d}}',
581
614
  '.dsh-upd[data-theme="archive"] .dsh-upd-batch-ledger{color:var(--dsh-update-text-muted)}',
582
615
  '.dsh-upd[data-theme="archive"] .dsh-upd-batch-sum{color:var(--dsh-update-text)}',
@@ -622,6 +655,12 @@ function queuedRequestIdOf(row) {
622
655
  }
623
656
  return null;
624
657
  }
658
+ function singleCheckPhoneOf(row) {
659
+ const phones = isObject(row.phoneNames) ? row.phoneNames : null;
660
+ if (!phones) return null;
661
+ const check = phones["updateCheck"];
662
+ return typeof check === "string" && check ? check : null;
663
+ }
625
664
  function cancelQueuePhoneOf(row) {
626
665
  const phones = isObject(row.phoneNames) ? row.phoneNames : null;
627
666
  if (!phones) return null;
@@ -694,6 +733,21 @@ function jobFailureOf(row) {
694
733
  const detail = message.slice(at + 1).trim();
695
734
  return { code: code || null, detail: detail || null };
696
735
  }
736
+ function diagStructuredSuffix(diagValue) {
737
+ const t = readDiagTolerant(diagValue);
738
+ const parts = [];
739
+ if (t.stage) parts.push("stage=" + t.stage);
740
+ if (t.route) parts.push("route=" + t.route);
741
+ if (t.method) parts.push("method=" + t.method);
742
+ if (typeof t.httpStatus === "number") parts.push("HTTP=" + String(t.httpStatus));
743
+ if (typeof t.exitCode === "number") parts.push("exit=" + String(t.exitCode));
744
+ if (typeof t.latencyMs === "number") parts.push("latency=" + String(t.latencyMs) + "ms");
745
+ if (t.registryHost) parts.push("source=" + t.registryHost);
746
+ if (t.action) parts.push("action=" + t.action);
747
+ if (t.requestId) parts.push("request=" + t.requestId);
748
+ if (t.checkId) parts.push("check=" + t.checkId);
749
+ return parts.length > 0 ? parts.join(" \xB7 ") : null;
750
+ }
697
751
  function diagTextOf(row, lang) {
698
752
  const snapshot = isObject(row.snapshot) ? row.snapshot : null;
699
753
  const queue = isObject(row.queue) ? row.queue : null;
@@ -701,7 +755,9 @@ function diagTextOf(row, lang) {
701
755
  const text = (value) => typeof value === "string" && value ? value : null;
702
756
  const job = jobFailureOf(row);
703
757
  const diagDetail = diag ? text(diag["detail"]) : null;
704
- const detail = diagDetail !== null ? diagDetail : job !== null && job.detail !== null ? job.detail + batchText("batch.diag.source-job", langOf(lang)) : null;
758
+ const baseDetail = diagDetail !== null ? diagDetail : job !== null && job.detail !== null ? job.detail + batchText("batch.diag.source-job", langOf(lang)) : null;
759
+ const structured = diagStructuredSuffix(row.diag);
760
+ const detail = baseDetail !== null && structured !== null ? baseDetail + " [" + structured + "]" : baseDetail ?? structured;
705
761
  const input = {
706
762
  pluginId: pluginIdOf(row),
707
763
  code: job !== null && job.code !== null ? job.code : row.error || "check-failed",
@@ -828,6 +884,7 @@ function mountUpdateBatchPanel(container, options) {
828
884
  let notice = null;
829
885
  let noticeKey = null;
830
886
  let inFlight = false;
887
+ let inFlightIsCheck = false;
831
888
  let loaded = false;
832
889
  let refreshing = false;
833
890
  let confirmCancelArmed = false;
@@ -1072,10 +1129,25 @@ function mountUpdateBatchPanel(container, options) {
1072
1129
  return;
1073
1130
  }
1074
1131
  if (reply["ok"] === true) {
1075
- const session = normalizeBatchSession(reply["session"]);
1076
- const parsed = readRows(reply["rows"]);
1077
- rows = parsed.length > 0 ? parsed : rowsFromSession(session);
1078
- lastSession = session;
1132
+ const hasSession = "session" in reply && reply["session"] !== void 0;
1133
+ const hasRows = "rows" in reply && reply["rows"] !== void 0;
1134
+ if (!hasSession && !hasRows) {
1135
+ if (isObject(reply["prefs"])) lastPrefs = reply["prefs"];
1136
+ return;
1137
+ }
1138
+ if (hasSession) {
1139
+ const session = normalizeBatchSession(reply["session"]);
1140
+ lastSession = session;
1141
+ if (hasRows) {
1142
+ const parsed = readRows(reply["rows"]);
1143
+ rows = parsed.length > 0 ? parsed : rowsFromSession(session);
1144
+ } else {
1145
+ rows = rowsFromSession(session);
1146
+ }
1147
+ } else {
1148
+ const parsed = readRows(reply["rows"]);
1149
+ if (parsed.length > 0) rows = parsed;
1150
+ }
1079
1151
  if (isObject(reply["inventory"])) {
1080
1152
  lastInventory = reply["inventory"];
1081
1153
  const at = reply["inventory"]["updatedAt"];
@@ -1110,6 +1182,7 @@ function mountUpdateBatchPanel(container, options) {
1110
1182
  async function phone(name, args, busyRowKey = null) {
1111
1183
  if (!mounted || inFlight) return null;
1112
1184
  inFlight = true;
1185
+ inFlightIsCheck = name === phones.check;
1113
1186
  clearNotice();
1114
1187
  render();
1115
1188
  let out = null;
@@ -1129,6 +1202,7 @@ function mountUpdateBatchPanel(container, options) {
1129
1202
  out = null;
1130
1203
  } finally {
1131
1204
  inFlight = false;
1205
+ inFlightIsCheck = false;
1132
1206
  if (mounted) render();
1133
1207
  }
1134
1208
  maybeAutoRowChangelog(expandedKey);
@@ -1207,6 +1281,10 @@ function mountUpdateBatchPanel(container, options) {
1207
1281
  const row = typeof key === "string" && key ? rowOfKey(key) : null;
1208
1282
  const version = row ? latestVersionOf(row) : null;
1209
1283
  if (!row || !version) return;
1284
+ if (!validReleaseVersion(version)) {
1285
+ render();
1286
+ return;
1287
+ }
1210
1288
  try {
1211
1289
  skipStoreFor(pluginIdOf(row)).skip(version);
1212
1290
  } catch {
@@ -1227,6 +1305,49 @@ function mountUpdateBatchPanel(container, options) {
1227
1305
  render();
1228
1306
  return;
1229
1307
  }
1308
+ // 「查这一家」(#84):走该家自己的单插件查新版电话(行 phoneNames 透传;老宿主没给即无入口)。
1309
+ // 单插件回包形状与批量回包不同,不进 applyReply:成功靠随后一次 refresh 把新快照读回来;
1310
+ // 失败只在该行留一句失败回执(failureCopy 已有键),行相位不动,不编造。
1311
+ case "row-check": {
1312
+ const row = typeof key === "string" && key ? rowOfKey(key) : null;
1313
+ if (!row) return;
1314
+ const phoneName = singleCheckPhoneOf(row);
1315
+ if (phoneName === null) return;
1316
+ if (inFlight) return;
1317
+ inFlight = true;
1318
+ inFlightIsCheck = true;
1319
+ clearNotice();
1320
+ render();
1321
+ try {
1322
+ const reply = await call(phoneName, { includeEnv: true, includeQueue: true });
1323
+ if (!mounted) return;
1324
+ if (isObject(reply) && reply["ok"] !== true) {
1325
+ const rec = reply;
1326
+ const kind2 = typeof rec["errorKind"] === "string" && rec["errorKind"].trim() ? rec["errorKind"].trim() : "";
1327
+ const err = typeof rec["error"] === "string" && rec["error"].trim() ? rec["error"].trim() : "";
1328
+ const code = kind2 || err || "check-failed";
1329
+ const copy = failureCopy(code, currentLang());
1330
+ say(copy ? copy.zh + "\uFF5C" + copy.act : code, row.key);
1331
+ }
1332
+ } catch {
1333
+ if (mounted) {
1334
+ const copy = failureCopy("check-failed", currentLang());
1335
+ say(copy ? copy.zh + "\uFF5C" + copy.act : "check-failed", row.key);
1336
+ }
1337
+ } finally {
1338
+ inFlight = false;
1339
+ inFlightIsCheck = false;
1340
+ }
1341
+ try {
1342
+ for (const k of [...rowFailedAt.keys()]) {
1343
+ if (k === row.key || k.startsWith(row.key + "\0")) rowFailedAt.delete(k);
1344
+ }
1345
+ } catch {
1346
+ }
1347
+ await refresh();
1348
+ maybeAutoRowChangelog(expandedKey);
1349
+ return;
1350
+ }
1230
1351
  // 「取消排队」:走**该家自己的**单插件安装电话(宿主支持 cancelQueued:只撤自己那条 waiting,
1231
1352
  // 不碰别家、也不碰正在装的拥有者)。批量取消会清掉整轮会话,所以这里绝不用它顶。
1232
1353
  case "row-cancel-queue": {
@@ -1440,7 +1561,7 @@ function mountUpdateBatchPanel(container, options) {
1440
1561
  }
1441
1562
  const timer = getTimer();
1442
1563
  const handle = timer.set(() => {
1443
- if (inFlight) return;
1564
+ if (inFlight && inFlightIsCheck) return;
1444
1565
  void refresh();
1445
1566
  }, pollMs);
1446
1567
  try {
package/dist/panel.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface UpdateThemeTokens {
39
39
  busyText?: string;
40
40
  /** 有新版版本号的醒目红(#71 Q1 专用;缺省跟随坏项红的对比度口径,深浅主题另覆)。 */
41
41
  newText?: string;
42
+ /** 已装好新版版本号的绿色(#71 状态跟随修订:done 行走绿,其余走红;深浅主题另覆)。 */
43
+ newOkText?: string;
42
44
  fontSans?: string;
43
45
  fontSerif?: string;
44
46
  fontMono?: string;
package/dist/panel.js CHANGED
@@ -39,6 +39,7 @@ const THEME_TOKEN_VARS = {
39
39
  busyBorder: "--dsh-update-busy-border",
40
40
  busyText: "--dsh-update-busy-text",
41
41
  newText: "--dsh-update-new-text",
42
+ newOkText: "--dsh-update-new-ok-text",
42
43
  fontSans: "--dsh-update-font-sans",
43
44
  fontSerif: "--dsh-update-font-serif",
44
45
  fontMono: "--dsh-update-font-mono",
@@ -74,7 +75,8 @@ const THEME_TOKEN_COLOR_KEYS = /* @__PURE__ */ new Set([
74
75
  "busyBg",
75
76
  "busyBorder",
76
77
  "busyText",
77
- "newText"
78
+ "newText",
79
+ "newOkText"
78
80
  ]);
79
81
  function themeTokensError(raw) {
80
82
  return new Error(`[dsh-plugin-update] \u4E3B\u9898\u53C2\u6570 themeTokens \u975E\u6CD5\uFF1A\u53EA\u6536\u5DF2\u77E5 token \u952E\uFF08\u989C\u8272\u7528 hex \u6216\u82F1\u6587\u540D\uFF0C\u5B57\u4F53/\u5706\u89D2/\u9634\u5F71/\u5C3A\u5BF8\u4E3A\u5B89\u5168 CSS \u503C\uFF0CentryScale \u4E3A\u5927\u4E8E 0 \u7684\u6709\u9650\u6570\uFF09\uFF08\u6536\u5230 ${JSON.stringify(raw ?? null)})`);
@@ -479,24 +481,6 @@ function panelViewModelCore(input, lang) {
479
481
  queueNote
480
482
  };
481
483
  }
482
- if (snapshot.blockedReason === "pending-restart") {
483
- const latest = snapshot.latestVersion ?? snapshot.installedVersion ?? "";
484
- return {
485
- banner: {
486
- kind: "restart",
487
- // 文案照原型(archive.html:329):不带 emoji——警示由横幅左侧的手绘 SVG 标承担,
488
- // 印章在状态一侧,两者各司其职,不再三重标记。
489
- title: copyText("panel.banner.restart-title", l, { latest }),
490
- action: blockedCopy("pending-restart", l)?.action ?? ""
491
- },
492
- installEnabled: false,
493
- installLabel: copyText("panel.action.install", l),
494
- skippedLatest: false,
495
- showManual: manual ? true : false,
496
- showReset: false,
497
- queueNote
498
- };
499
- }
500
484
  if (jobState === "installing" || jobState === "verifying") {
501
485
  const ver = typeof job?.targetVersion === "string" && job.targetVersion.trim() ? job.targetVersion.trim() : "";
502
486
  let installingTitle = copyText("panel.banner.installing-title", l, { version: ver || " " });
@@ -534,6 +518,24 @@ function panelViewModelCore(input, lang) {
534
518
  queueNote
535
519
  };
536
520
  }
521
+ if (snapshot.blockedReason === "pending-restart") {
522
+ const latest = snapshot.latestVersion ?? snapshot.installedVersion ?? "";
523
+ return {
524
+ banner: {
525
+ kind: "restart",
526
+ // 文案照原型(archive.html:329):不带 emoji——警示由横幅左侧的手绘 SVG 标承担,
527
+ // 印章在状态一侧,两者各司其职,不再三重标记。
528
+ title: copyText("panel.banner.restart-title", l, { latest }),
529
+ action: blockedCopy("pending-restart", l)?.action ?? ""
530
+ },
531
+ installEnabled: false,
532
+ installLabel: copyText("panel.action.install", l),
533
+ skippedLatest: false,
534
+ showManual: manual ? true : false,
535
+ showReset: false,
536
+ queueNote
537
+ };
538
+ }
537
539
  if (snapshot.blockedReason) {
538
540
  const copy = blockedCopy(snapshot.blockedReason, l);
539
541
  const stop = l === "en" ? "." : "\u3002";
@@ -696,6 +698,15 @@ const UPDATE_PANEL_CSS = [
696
698
  '.dsh-upd-actions button:first-child:not([aria-busy="true"])::after{content:"";display:inline-block;width:11px;height:11px;margin-left:8px;visibility:hidden}',
697
699
  ".dsh-upd-banner{min-height:1.2em}",
698
700
  ".dsh-upd-strip{min-height:48px}",
701
+ // —— 查新版瞬时抖动补强:忙闲两帧同高 + 横幅不动(只追加覆盖,不改上面既有串)——
702
+ ".dsh-upd-actions{align-content:flex-start}",
703
+ ".dsh-upd-actions button{white-space:nowrap}",
704
+ ".dsh-upd-actions button:first-child{min-width:10em}",
705
+ '.dsh-upd-actions button[data-primary="1"]{min-width:9em}',
706
+ ".dsh-upd-banner{min-height:3.4em;display:flex;flex-direction:column;justify-content:center}",
707
+ ".dsh-upd-banner{animation:none}",
708
+ ".dsh-upd-copy{min-height:1.75em}",
709
+ ".dsh-upd-copy--empty{visibility:hidden}",
699
710
  "@media (prefers-color-scheme: dark){.dsh-upd{--dsh-update-text:#e5e7eb;--dsh-update-bg:#111827;--dsh-update-border:#374151;",
700
711
  "--dsh-update-button-bg:#1f2937;--dsh-update-bg-soft:#1f2937;--dsh-update-primary:#3b82f6;--dsh-update-focus:#93c5fd;",
701
712
  // 横幅深色覆盖:底色用低透明度同色系(不是浅色原值),边线提亮,保证「深底浅字」可读。
@@ -912,9 +923,12 @@ function renderUpdatePanelKernel(input, view, lang) {
912
923
  const busyAct = input.busyAct;
913
924
  const checkBusy = busyAct === "check";
914
925
  const installBusy = busyAct === "install";
926
+ const jobState = snapshot?.job?.state;
927
+ const snapshotBusy = jobState === "installing" || jobState === "verifying";
928
+ const macroBusy = busyAct === "check" || busyAct === "install" || snapshotBusy;
915
929
  actions.push('<div class="dsh-upd-actions">');
916
930
  actions.push(
917
- (checkBusy ? `<button type="button" data-action="check" disabled aria-busy="true" title="${escapeHtml(copyText("panel.action.checking-busy-title", l))}">${escapeHtml(copyText("panel.action.checking-busy", l))}</button>` : `<button type="button" data-action="check" title="${escapeHtml(copyText("panel.action.check-title", l))}">${escapeHtml(copyText("panel.action.check", l))}</button>`) + (installBusy ? `<button type="button" data-action="install" data-primary="1" disabled aria-busy="true" title="${escapeHtml(copyText("panel.action.installing-busy-title", l))}">${escapeHtml(copyText("panel.action.installing-busy", l))}</button>` : `<button type="button" data-action="install" data-primary="1" title="${escapeHtml(copyText("panel.action.install-title", l))}"${view.installEnabled ? "" : " disabled"}>${escapeHtml(view.installLabel)}</button>`)
931
+ (checkBusy ? `<button type="button" data-action="check" disabled aria-busy="true" title="${escapeHtml(copyText("panel.action.checking-busy-title", l))}">${escapeHtml(copyText("panel.action.checking-busy", l))}</button>` : `<button type="button" data-action="check" title="${escapeHtml(copyText("panel.action.check-title", l))}"${macroBusy ? " disabled" : ""}>${escapeHtml(copyText("panel.action.check", l))}</button>`) + (installBusy ? `<button type="button" data-action="install" data-primary="1" disabled aria-busy="true" title="${escapeHtml(copyText("panel.action.installing-busy-title", l))}">${escapeHtml(copyText("panel.action.installing-busy", l))}</button>` : `<button type="button" data-action="install" data-primary="1" title="${escapeHtml(copyText("panel.action.install-title", l))}"${macroBusy || !view.installEnabled ? " disabled" : ""}>${escapeHtml(view.installLabel)}</button>`)
918
932
  );
919
933
  if (snapshot?.latestVersion && !view.skippedLatest && view.banner.kind === "update") {
920
934
  actions.push(`<button type="button" data-action="skip" title="${escapeHtml(copyText("panel.action.skip-title", l))}">${escapeHtml(copyText("panel.action.skip", l))}</button>`);
@@ -1084,6 +1098,7 @@ ${changelogHTML}
1084
1098
  }
1085
1099
  }
1086
1100
  if (copyNotice) errLines.push(`<div class="dsh-upd-copy" role="status">${escapeHtml(copyNotice)}</div>`);
1101
+ else errLines.push('<div class="dsh-upd-copy dsh-upd-copy--empty" aria-hidden="true"></div>');
1087
1102
  chapters.push(chapterOf(4, errLines.join(""), "", l));
1088
1103
  }
1089
1104
  chapters.push(
package/dist/service.js CHANGED
@@ -364,16 +364,32 @@ function createUpdateCore(ports) {
364
364
  job
365
365
  };
366
366
  }
367
+ function jobsEqual(a, b) {
368
+ if (a === b) return true;
369
+ if (!a || !b) return false;
370
+ return a.id === b.id && a.state === b.state && a.targetVersion === b.targetVersion && a.message === b.message && (a.requestId ?? null) === (b.requestId ?? null);
371
+ }
372
+ async function healedJob(env) {
373
+ const loaded = await loadJob();
374
+ const healed = healJob(loaded, env);
375
+ if (!jobsEqual(loaded, healed)) {
376
+ try {
377
+ await saveJob(healed);
378
+ } catch {
379
+ }
380
+ }
381
+ return healed;
382
+ }
367
383
  async function status() {
368
384
  const env = await ports.readInstalled();
369
- const job = healJob(await loadJob(), env);
385
+ const job = await healedJob(env);
370
386
  return buildSnapshot(env, job);
371
387
  }
372
388
  async function check() {
373
389
  if (checking) return checking;
374
390
  if (checked?.checkId && ports.now() - lastCheckAt < RECHECK_WINDOW_MS) {
375
391
  const env = await ports.readInstalled();
376
- const snapshot = buildSnapshot(env, healJob(await loadJob(), env));
392
+ const snapshot = buildSnapshot(env, await healedJob(env));
377
393
  return { snapshot, receipt: snapshot.canInstall ? toReceipt() : null };
378
394
  }
379
395
  lastCheckAt = ports.now();
@@ -389,7 +405,7 @@ function createUpdateCore(ports) {
389
405
  installationKey: env.installationKey,
390
406
  blockedReason: satisfiesNodeRange(ports.nodeVersion, release2.nodeRange) ? null : "incompatible-node"
391
407
  };
392
- const snapshot = buildSnapshot(env, healJob(await loadJob(), env));
408
+ const snapshot = buildSnapshot(env, await healedJob(env));
393
409
  return { snapshot, receipt: snapshot.canInstall ? toReceipt() : null };
394
410
  } catch (error) {
395
411
  if (checked) checked = { ...checked, checkId: null, expiresAt: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-update",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "可复用的更新系统装成的 npm 包:任何 DSH 插件照文档能集成检查更新等功能(地图 #579)",
5
5
  "license": "MIT",
6
6
  "author": "王辰浩",