dsh-crwu-workbench 0.0.11 → 0.0.13

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/lib/client.js CHANGED
@@ -64,6 +64,34 @@ window.__ModuleLoader__.load({
64
64
  versionDev: "crwu-audit-version-dev",
65
65
  versionInstalled: "crwu-audit-version-installed",
66
66
  versionUnknown: "crwu-audit-version-unknown",
67
+ /**
68
+ * 版本徽标成为**自助更新入口**后的附加类(侧栏入口与面板头部共用同一枚徽标)。
69
+ *
70
+ * 它只是"这枚徽标可点",形状仍是 `.crwu-audit-version`;语气由下面三个修饰类表达。
71
+ */
72
+ updateBadge: "crwu-audit-update-badge",
73
+ /** 有可安装的新版本。 */
74
+ updateBadgeAccent: "crwu-audit-update-badge-accent",
75
+ /** 正在安装 / 已经更新到新版本(正向进行态)。 */
76
+ updateBadgeOk: "crwu-audit-update-badge-ok",
77
+ /** 已经装好、等待重启。 */
78
+ updateBadgeWarn: "crwu-audit-update-badge-warn",
79
+ /** 自助更新面板(`UpdateDialog.tsx`):固定浮层 + 遮罩,盖在面板之上。 */
80
+ updateDialogBackdrop: "crwu-audit-update-dialog-backdrop",
81
+ updateDialog: "crwu-audit-update-dialog",
82
+ updateDialogHead: "crwu-audit-update-dialog-head",
83
+ updateDialogTitle: "crwu-audit-update-dialog-title",
84
+ updateDialogBody: "crwu-audit-update-dialog-body",
85
+ updateDialogNotice: "crwu-audit-update-dialog-notice",
86
+ updateDialogNoticeHint: "crwu-audit-update-dialog-notice-hint",
87
+ updateDialogStages: "crwu-audit-update-dialog-stages",
88
+ updateDialogStage: "crwu-audit-update-dialog-stage",
89
+ updateDialogStageOn: "crwu-audit-update-dialog-stage-on",
90
+ updateDialogReason: "crwu-audit-update-dialog-reason",
91
+ updateDialogError: "crwu-audit-update-dialog-error",
92
+ updateDialogActions: "crwu-audit-update-dialog-actions",
93
+ updateDialogDiag: "crwu-audit-update-dialog-diag",
94
+ updateDialogDiagRow: "crwu-audit-update-dialog-diag-row",
67
95
  /** 头部右侧那句问候(`下午好,某某某`);姓名拿不到时整句不渲染。 */
68
96
  greeting: "crwu-audit-greeting",
69
97
  description: "crwu-audit-description",
@@ -2105,6 +2133,83 @@ body[data-ds-dark-theme] .crwu-audit-root {
2105
2133
  .crwu-audit-mono { font-family: var(--crwu-font-mono); font-size: 12.5px; }
2106
2134
  .crwu-audit-placeholder-tag { background: var(--crwu-surface-subtle); border-color: var(--crwu-border); color: var(--crwu-text-secondary); }
2107
2135
  .crwu-audit-loading, .crwu-audit-loading-inline { color: var(--crwu-text-secondary); }
2136
+
2137
+ /* ────────────────────────────────────────────────────────────────────────────
2138
+ 21. 自助更新:可点的版本徽标 + 更新面板(Task 6)
2139
+
2140
+ 徽标沿用 §… 的 .crwu-audit-version(10px 等宽小标签),这里只加"可点"的语气与三种状态色;
2141
+ 语气一律写成**双类**(.crwu-audit-version.crwu-audit-update-badge-*),理由见 §3.5:
2142
+ 单类会被 §20 的覆盖层吃掉。
2143
+
2144
+ 更新面板是 position: fixed 浮层:遮罩 940 / 面板 941(高过抽屉的 900/901,
2145
+ 低于授权那类真正的阻塞层)。整块都长在 .crwu-audit-root 里,所以 --crwu-* 照常继承。
2146
+ ──────────────────────────────────────────────────────────────────────────── */
2147
+ .crwu-audit-update-badge { cursor: pointer; font-family: inherit; }
2148
+ .crwu-audit-update-badge:hover { background: var(--crwu-hover); border-color: var(--crwu-border-strong); }
2149
+ .crwu-audit-update-badge:focus-visible { outline: 2px solid var(--crwu-brand); outline-offset: 2px; }
2150
+ .crwu-audit-version.crwu-audit-update-badge-accent {
2151
+ color: var(--crwu-brand);
2152
+ border-color: color-mix(in srgb, var(--crwu-brand) 45%, transparent);
2153
+ background: color-mix(in srgb, var(--crwu-brand) 10%, transparent);
2154
+ }
2155
+ .crwu-audit-version.crwu-audit-update-badge-ok {
2156
+ color: var(--dsw-alias-state-success-primary);
2157
+ border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 45%, transparent);
2158
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
2159
+ }
2160
+ .crwu-audit-version.crwu-audit-update-badge-warn {
2161
+ color: var(--dsw-alias-state-warn-primary);
2162
+ border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 45%, transparent);
2163
+ background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);
2164
+ }
2165
+
2166
+ .crwu-audit-update-dialog-backdrop {
2167
+ position: fixed; inset: 0; z-index: 940;
2168
+ background: var(--crwu-scrim);
2169
+ animation: crwu-audit-scrim-in 140ms var(--crwu-ease) both;
2170
+ }
2171
+ .crwu-audit-update-dialog {
2172
+ position: fixed; z-index: 941; top: 50%; left: 50%; transform: translate(-50%, -50%);
2173
+ box-sizing: border-box; width: 460px; max-width: calc(100vw - 32px); max-height: calc(100vh - 64px);
2174
+ display: flex; flex-direction: column;
2175
+ padding: var(--crwu-space-5); border-radius: var(--crwu-radius-lg);
2176
+ border: 1px solid var(--crwu-border-strong); background: var(--crwu-surface);
2177
+ box-shadow: var(--crwu-shadow-dialog);
2178
+ animation: crwu-audit-float-in 160ms var(--crwu-ease) both;
2179
+ }
2180
+ .crwu-audit-update-dialog-head { display: flex; align-items: center; gap: var(--crwu-space-3); }
2181
+ .crwu-audit-update-dialog-title {
2182
+ flex: 1 1 auto; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: var(--crwu-text-primary);
2183
+ }
2184
+ .crwu-audit-update-dialog-body { margin-top: var(--crwu-space-3); overflow: auto; min-height: 0; }
2185
+ .crwu-audit-update-dialog-notice {
2186
+ margin-bottom: var(--crwu-space-3); padding: var(--crwu-space-3);
2187
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 35%, transparent);
2188
+ border-left-width: 3px; border-radius: var(--crwu-radius-md);
2189
+ background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent);
2190
+ color: var(--crwu-text-primary); font-size: 13px; line-height: 20px;
2191
+ }
2192
+ .crwu-audit-update-dialog-notice-hint { margin-top: var(--crwu-space-2); color: var(--crwu-text-secondary); }
2193
+ .crwu-audit-update-dialog-stages { display: flex; align-items: center; gap: var(--crwu-space-2); margin-top: var(--crwu-space-3); }
2194
+ .crwu-audit-update-dialog-stage {
2195
+ display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px;
2196
+ border: 1px solid var(--crwu-border); background: var(--crwu-surface-subtle);
2197
+ color: var(--crwu-text-secondary); font-size: 12px; line-height: 18px;
2198
+ }
2199
+ .crwu-audit-update-dialog-stage-on {
2200
+ border-color: color-mix(in srgb, var(--crwu-brand) 40%, transparent);
2201
+ background: color-mix(in srgb, var(--crwu-brand) 10%, transparent);
2202
+ color: var(--crwu-text-primary); font-weight: 600;
2203
+ }
2204
+ .crwu-audit-update-dialog-reason { margin-top: var(--crwu-space-3); color: var(--crwu-text-secondary); font-size: 12.5px; line-height: 19px; }
2205
+ .crwu-audit-update-dialog-error { margin-top: var(--crwu-space-3); color: var(--dsw-alias-state-error-primary); font-size: 12.5px; line-height: 19px; }
2206
+ .crwu-audit-update-dialog-actions { display: flex; justify-content: flex-end; gap: var(--crwu-space-2); margin-top: var(--crwu-space-4); }
2207
+ .crwu-audit-update-dialog-diag { margin-top: var(--crwu-space-4); border-top: 1px solid var(--crwu-border); padding-top: var(--crwu-space-3); }
2208
+ .crwu-audit-update-dialog-diag-row { display: flex; gap: var(--crwu-space-3); padding: 2px 0; align-items: baseline; }
2209
+
2210
+ @media (prefers-reduced-motion: reduce) {
2211
+ .crwu-audit-update-dialog-backdrop, .crwu-audit-update-dialog { animation: none; }
2212
+ }
2108
2213
  `;
2109
2214
  //#endregion
2110
2215
  //#region src/client/components/primitives.tsx
@@ -2227,9 +2332,9 @@ body[data-ds-dark-theme] .crwu-audit-root {
2227
2332
  versionHint: "当前运行的宿主插件版本",
2228
2333
  versionUnknown: "版本未知",
2229
2334
  loadingHost: "正在读取 Host…",
2230
- hostStaleTitle: "宿主插件是旧构建,请重启 web profile",
2231
- hostStaleHint: "客户端产物刷新页面就会换新,宿主产物只有重启 profile 才会换。现在跑着的宿主代码比你看到的界面旧,审核会按旧逻辑挂到「当前会话」下(而不是建在工作空间里的审核根会话),所以先不要发起审核。",
2232
- hostStaleGate: "宿主插件是旧构建:请先重启 web profile",
2335
+ hostStaleTitle: "宿主插件是旧构建:请完全退出并重新打开 DeepSeek Harness",
2336
+ hostStaleHint: "客户端产物刷新页面就会换新,宿主产物只有完全退出并重新打开 DeepSeek Harness 才会换。现在跑着的宿主代码比你看到的界面旧,审核会按旧逻辑挂到「当前会话」下(而不是建在工作空间里的审核根会话),所以先不要发起审核。退出时要**完全退出**(macOS 上关闭窗口不等于退出:从应用菜单选择「退出」,或按 Command-Q),再重新打开。",
2337
+ hostStaleGate: "宿主插件是旧构建:请先完全退出并重新打开 DeepSeek Harness",
2233
2338
  loadingEnv: "正在自检环境",
2234
2339
  loadingHint: "正在检查工具、登录态、上传配置与外部数据,通过后会自动继续",
2235
2340
  loadingList: "正在加载…",
@@ -2969,6 +3074,59 @@ body[data-ds-dark-theme] .crwu-audit-root {
2969
3074
  cloudSearchEmpty: "OSS 上没有这个流水号的交付件",
2970
3075
  cloudSearchInvalid: "流水号形状不对:示例 2026-301705-LX10170-BG8746(只允许字母、数字与 - _ .,不能含 /)",
2971
3076
  cloudFailed: "云端审核结果清单没拉到(不影响氚云列表):",
3077
+ updateBadgeSeparator: " · ",
3078
+ updateBadgeUpdate: "有更新",
3079
+ updateBadgeChecking: "检查中…",
3080
+ updateBadgeInstalling: "正在更新…",
3081
+ updateBadgeAwaitingRestart: "待重启",
3082
+ updateBadgeUpdated: "已更新",
3083
+ updateReasonDevelopment: "当前是本地开发安装,不参与自助更新。",
3084
+ updateReasonEnterprise: "当前由企业源管理,更新请联系管理员。",
3085
+ updateReasonManagerUnavailable: "当前桌面版没有可用的插件管理服务,更新请联系管理员。",
3086
+ updateReasonAuditActive: "有审核任务正在进行,完成后即可安装更新。",
3087
+ updateReasonInstallActive: "正在安装更新,请稍候。",
3088
+ updateReasonAwaitingRestart: "新版本已安装,请完全退出并重新打开 DeepSeek Harness。",
3089
+ updateReasonCandidateExpired: "候选版本已过期,请重新检查更新。",
3090
+ updateReasonNoCandidate: "当前没有可安装的更新。",
3091
+ updateCheckFailedManual: "检查更新失败,请稍后重试。",
3092
+ updateInstallFailed: "更新失败,当前版本仍可继续使用。",
3093
+ updateCancelFailed: "取消更新失败,更新可能仍在进行,请稍后重试。",
3094
+ updateBadgeTitle: "查看 CRWU 更新(当前 {version})",
3095
+ updateBadgeTitleUpdate: "有新版本可以安装(当前 {version})",
3096
+ updateBadgeTitleUpdated: "CRWU 已经是新版本({version})",
3097
+ updateBadgeTitleRestart: "新版本已安装:需要完全退出并重新打开 DeepSeek Harness",
3098
+ updateBadgeTitleDev: "本地开发安装(dev):不参与自助更新",
3099
+ updateDialogTitle: "CRWU 更新",
3100
+ updateFieldCurrent: "当前版本",
3101
+ updateFieldTarget: "目标版本",
3102
+ updateFieldPublished: "发布时间",
3103
+ updateFieldSource: "公开来源",
3104
+ updateFieldStage: "安装阶段",
3105
+ updateSourceNpmmirror: "国内镜像(npmmirror)",
3106
+ updateSourceNpm: "官方源(npm)",
3107
+ updateStageConnecting: "正在连接更新源",
3108
+ updateStageDownloading: "正在下载安装包",
3109
+ updateStageInstalling: "正在写入插件目录",
3110
+ updateStageCancelling: "正在取消…",
3111
+ updateActionCheck: "检查更新",
3112
+ updateActionInstall: "安装更新",
3113
+ updateActionCancel: "取消安装",
3114
+ updateActionClose: "关闭",
3115
+ updateActionOk: "我知道了",
3116
+ updateNoCandidateLine: "当前没有可安装的新版本。",
3117
+ updateDiagnostics: "诊断信息(不含凭据、私有源地址与完整日志)",
3118
+ updateDiagCheck: "检查结论",
3119
+ updateDiagInstall: "安装结论",
3120
+ updateDiagStage: "安装阶段",
3121
+ updateDiagTarget: "目标版本",
3122
+ updateDiagSource: "来源类型",
3123
+ updateDiagError: "最近错误",
3124
+ updateDiagNone: "无",
3125
+ updateInstalledLine: "CRWU v{version} 已安装。请完全退出并重新打开 DeepSeek Harness,使新版生效。",
3126
+ updateUpdatedLine: "CRWU 已更新到 v{version}。",
3127
+ updateRestartMac: "在 macOS 上,关闭窗口不一定退出应用:请从应用菜单选择「退出」,或按 Command-Q,然后重新打开 DeepSeek Harness。",
3128
+ updateRestartOther: "请完全退出 DeepSeek Harness(不只是关闭窗口),再重新打开。",
3129
+ updateInstallCancelled: "已取消更新,当前版本继续可用。",
2972
3130
  activePrefix: "已有审核进行中:",
2973
3131
  activeSuffix: "(同一时间只允许一条,其它条目已禁用)。",
2974
3132
  stopHint: "停止会取消子会话剩余工作并释放占用,已产出的文件保留。"
@@ -2977,6 +3135,19 @@ body[data-ds-dark-theme] .crwu-audit-root {
2977
3135
  //#region src/shared/consts.ts
2978
3136
  /** Host 与 Client 共用的同源工作台端点。 */
2979
3137
  const WORKBENCH_ROUTE = "/api/crwu-workbench";
3138
+ /**
3139
+ * 自助更新的四个 Host 操作名(协议 16)。
3140
+ *
3141
+ * 与 `WORKBENCH_ROUTE` 同级:它们是**跨进程契约**的一部分(Host 的操作表、`boot.ported.done`、
3142
+ * 冻结清单与 Task 5 的 Client 门面共用这一份名字,避免各写一遍然后漂移)。
3143
+ * 四个操作都返回 `{ ok, check, install }`;安装目标不接受调用方参数(见 `host/update/ops.ts`)。
3144
+ */
3145
+ const UPDATE_OPERATION_NAMES = [
3146
+ "update-status",
3147
+ "update-check",
3148
+ "update-install",
3149
+ "update-cancel"
3150
+ ];
2980
3151
  /** 员工排障入口:用户公开分享的钉钉个人名片链接(来自名片二维码)。 */
2981
3152
  const DEVELOPER_CONTACT_URL = "https://n.dingtalk.com/dingding/h5-profile/outside/index.html?fr_source=13&uidCipher=7O4cP9kgKCT4CvRDZlgrxQ%3D%3D&cardToken=dff1f30a70&profile=%40kgDOFH5CaA";
2982
3153
  /**
@@ -3010,6 +3181,269 @@ body[data-ds-dark-theme] .crwu-audit-root {
3010
3181
  return await response.json();
3011
3182
  }
3012
3183
  //#endregion
3184
+ //#region src/client/features/update/api.ts
3185
+ /**
3186
+ * 更新专用的 Client 门面:四个操作名 + **运行时收窄**。
3187
+ *
3188
+ * 两条边界:
3189
+ * 1. **只发空参数**。四个方法都是零参数方法,每次请求都发 `{}`;调用方就算硬塞
3190
+ * `version` / `registry` / `spec` / `command`,也进不了请求体。安装目标只能由 Host 自己
3191
+ * 授权(Host 侧 `update/ops.ts` 会拒绝任何带参数的 `update-install`)。
3192
+ * 2. **Host 返回的是不可信 JSON**。这里逐字段重建安全对象:未知状态、未知枚举、类型不符、
3193
+ * 多余的 registry metadata / 日志 / requestId 一律不接受。收窄结果喂给 Store,绝不把
3194
+ * 原始 JSON 直接当成 UI 状态。
3195
+ */
3196
+ /**
3197
+ * 方法名 → Host 操作名。
3198
+ *
3199
+ * 操作名来自共享常量(协议 16 的单一事实源),`OPERATION_OF` 从这张局部映射**组合**出来 ——
3200
+ * 不允许在聚合表里再手写一遍字符串。
3201
+ */
3202
+ const UPDATE_METHOD_OPERATION = {
3203
+ updateStatus: UPDATE_OPERATION_NAMES[0],
3204
+ updateCheck: UPDATE_OPERATION_NAMES[1],
3205
+ updateInstall: UPDATE_OPERATION_NAMES[2],
3206
+ updateCancel: UPDATE_OPERATION_NAMES[3]
3207
+ };
3208
+ /**
3209
+ * 建立四个更新方法。
3210
+ *
3211
+ * 返回值刻意是 `Promise<unknown>`:Host 的 JSON 还没经过收窄,谁都不能把它断言成可信状态。
3212
+ * 收窄是 `parseUpdateResponse()` 的职责,由 Store 调用。
3213
+ */
3214
+ function createUpdateApi(transport = (operation, args) => rpc(operation, args)) {
3215
+ const call = (operation) => async () => await transport(operation, {});
3216
+ return {
3217
+ updateStatus: call(UPDATE_METHOD_OPERATION.updateStatus),
3218
+ updateCheck: call(UPDATE_METHOD_OPERATION.updateCheck),
3219
+ updateInstall: call(UPDATE_METHOD_OPERATION.updateInstall),
3220
+ updateCancel: call(UPDATE_METHOD_OPERATION.updateCancel)
3221
+ };
3222
+ }
3223
+ /** 默认门面(组合进 `workbenchApi` 的那一份)。 */
3224
+ const updateApi = createUpdateApi();
3225
+ const CHECK_STATUSES = [
3226
+ "idle",
3227
+ "checking",
3228
+ "up-to-date",
3229
+ "available",
3230
+ "unsupported",
3231
+ "error"
3232
+ ];
3233
+ const UNSUPPORTED_REASONS = [
3234
+ "development-install",
3235
+ "manager-unavailable",
3236
+ "enterprise-registry"
3237
+ ];
3238
+ const CHECK_ERROR_KINDS = [
3239
+ "network",
3240
+ "timeout",
3241
+ "not-found",
3242
+ "invalid-metadata",
3243
+ "too-large",
3244
+ "unknown"
3245
+ ];
3246
+ const INSTALL_STATUSES = [
3247
+ "idle",
3248
+ "installing",
3249
+ "awaiting-restart",
3250
+ "failed",
3251
+ "cancelled",
3252
+ "updated"
3253
+ ];
3254
+ const INSTALL_STAGES = [
3255
+ "connecting",
3256
+ "downloading",
3257
+ "installing",
3258
+ "cancelling"
3259
+ ];
3260
+ const INSTALL_ERROR_KINDS = [
3261
+ "source-unavailable",
3262
+ "not-found",
3263
+ "network",
3264
+ "timeout",
3265
+ "disk-full",
3266
+ "permission",
3267
+ "integrity",
3268
+ "build-blocked",
3269
+ "incompatible-version",
3270
+ "audit-active",
3271
+ "busy",
3272
+ "cancelled",
3273
+ "unknown"
3274
+ ];
3275
+ const SOURCE_KINDS = ["npmmirror", "npm"];
3276
+ function asRecord$1(value) {
3277
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
3278
+ return value;
3279
+ }
3280
+ function asText(value) {
3281
+ return typeof value === "string" ? value : null;
3282
+ }
3283
+ function oneOf(value, allowed) {
3284
+ return typeof value === "string" && allowed.includes(value) ? value : null;
3285
+ }
3286
+ /** 候选:只保留线协议允许的六个字段,其余(registry / 日志 / requestId …)一律丢掉。 */
3287
+ function narrowCandidate(value) {
3288
+ const raw = asRecord$1(value);
3289
+ if (raw === null) return null;
3290
+ const currentVersion = asText(raw.currentVersion);
3291
+ const targetVersion = asText(raw.targetVersion);
3292
+ const sourceKind = oneOf(raw.sourceKind, SOURCE_KINDS);
3293
+ const checkedAt = asText(raw.checkedAt);
3294
+ const expiresAt = asText(raw.expiresAt);
3295
+ if (currentVersion === null || targetVersion === null || sourceKind === null) return null;
3296
+ if (checkedAt === null || expiresAt === null) return null;
3297
+ const candidate = {
3298
+ currentVersion,
3299
+ targetVersion,
3300
+ sourceKind,
3301
+ checkedAt,
3302
+ expiresAt
3303
+ };
3304
+ if (raw.publishedAt !== void 0) {
3305
+ const publishedAt = asText(raw.publishedAt);
3306
+ if (publishedAt === null) return null;
3307
+ candidate.publishedAt = publishedAt;
3308
+ }
3309
+ return candidate;
3310
+ }
3311
+ function narrowCheck(value) {
3312
+ const raw = asRecord$1(value);
3313
+ if (raw === null) return null;
3314
+ const status = oneOf(raw.status, CHECK_STATUSES);
3315
+ if (status === null) return null;
3316
+ switch (status) {
3317
+ case "idle": return { status };
3318
+ case "checking": {
3319
+ if (raw.cached === void 0) return { status };
3320
+ const cached = narrowCandidate(raw.cached);
3321
+ return cached === null ? null : {
3322
+ status,
3323
+ cached
3324
+ };
3325
+ }
3326
+ case "up-to-date": {
3327
+ const checkedAt = asText(raw.checkedAt);
3328
+ return checkedAt === null ? null : {
3329
+ status,
3330
+ checkedAt
3331
+ };
3332
+ }
3333
+ case "available": {
3334
+ const checkedAt = asText(raw.checkedAt);
3335
+ const candidate = narrowCandidate(raw.candidate);
3336
+ return checkedAt === null || candidate === null ? null : {
3337
+ status,
3338
+ checkedAt,
3339
+ candidate
3340
+ };
3341
+ }
3342
+ case "unsupported": {
3343
+ const reason = oneOf(raw.reason, UNSUPPORTED_REASONS);
3344
+ return reason === null ? null : {
3345
+ status,
3346
+ reason
3347
+ };
3348
+ }
3349
+ case "error": {
3350
+ const kind = oneOf(raw.kind, CHECK_ERROR_KINDS);
3351
+ const checkedAt = asText(raw.checkedAt);
3352
+ return kind === null || checkedAt === null ? null : {
3353
+ status,
3354
+ kind,
3355
+ checkedAt
3356
+ };
3357
+ }
3358
+ }
3359
+ }
3360
+ function narrowInstall(value) {
3361
+ const raw = asRecord$1(value);
3362
+ if (raw === null) return null;
3363
+ const status = oneOf(raw.status, INSTALL_STATUSES);
3364
+ if (status === null) return null;
3365
+ switch (status) {
3366
+ case "idle": return { status };
3367
+ case "installing": {
3368
+ const stage = oneOf(raw.stage, INSTALL_STAGES);
3369
+ const targetVersion = asText(raw.targetVersion);
3370
+ const startedAt = asText(raw.startedAt);
3371
+ return stage === null || targetVersion === null || startedAt === null ? null : {
3372
+ status,
3373
+ stage,
3374
+ targetVersion,
3375
+ startedAt
3376
+ };
3377
+ }
3378
+ case "awaiting-restart": {
3379
+ const targetVersion = asText(raw.targetVersion);
3380
+ const installedAt = asText(raw.installedAt);
3381
+ return targetVersion === null || installedAt === null ? null : {
3382
+ status,
3383
+ targetVersion,
3384
+ installedAt
3385
+ };
3386
+ }
3387
+ case "failed": {
3388
+ const kind = oneOf(raw.kind, INSTALL_ERROR_KINDS);
3389
+ if (kind === null) return null;
3390
+ const state = {
3391
+ status,
3392
+ kind
3393
+ };
3394
+ if (raw.targetVersion !== void 0) {
3395
+ const targetVersion = asText(raw.targetVersion);
3396
+ if (targetVersion === null) return null;
3397
+ state.targetVersion = targetVersion;
3398
+ }
3399
+ return state;
3400
+ }
3401
+ case "cancelled": {
3402
+ const state = { status };
3403
+ if (raw.targetVersion !== void 0) {
3404
+ const targetVersion = asText(raw.targetVersion);
3405
+ if (targetVersion === null) return null;
3406
+ state.targetVersion = targetVersion;
3407
+ }
3408
+ return state;
3409
+ }
3410
+ case "updated": {
3411
+ const version = asText(raw.version);
3412
+ return version === null ? null : {
3413
+ status,
3414
+ version
3415
+ };
3416
+ }
3417
+ }
3418
+ }
3419
+ /** 收窄一次更新操作响应。任何不认识的东西都不会进入 UI 状态。 */
3420
+ function parseUpdateResponse(raw) {
3421
+ const envelope = asRecord$1(raw);
3422
+ if (envelope === null) return {
3423
+ kind: "malformed",
3424
+ code: "malformed-envelope"
3425
+ };
3426
+ if (envelope.ok === false) return asText(envelope.error) === null ? {
3427
+ kind: "malformed",
3428
+ code: "malformed-envelope"
3429
+ } : { kind: "rejected" };
3430
+ if (envelope.ok !== true) return {
3431
+ kind: "malformed",
3432
+ code: "malformed-envelope"
3433
+ };
3434
+ const invalid = [];
3435
+ const check = narrowCheck(envelope.check);
3436
+ if (check === null) invalid.push("check");
3437
+ const install = narrowInstall(envelope.install);
3438
+ if (install === null) invalid.push("install");
3439
+ return {
3440
+ kind: "ok",
3441
+ check,
3442
+ install,
3443
+ invalid
3444
+ };
3445
+ }
3446
+ //#endregion
3013
3447
  //#region src/shared/environment/model.ts
3014
3448
  /** 除了 ready,其它状态一律不放行 —— 少一个 `proceed` 判断就会静默放行 `check-failed`。 */
3015
3449
  function statusProceedable(status) {
@@ -3183,6 +3617,7 @@ body[data-ds-dark-theme] .crwu-audit-root {
3183
3617
  return await rpc(operation, args ?? {});
3184
3618
  }
3185
3619
  const workbenchApi = {
3620
+ ...createUpdateApi(),
3186
3621
  boot: () => call("boot"),
3187
3622
  env: (args) => call("env", args),
3188
3623
  pending: (args) => call("pending", args),
@@ -3211,6 +3646,7 @@ body[data-ds-dark-theme] .crwu-audit-root {
3211
3646
  ifindCredentialClear: (args) => call("ifind-credential-clear", args),
3212
3647
  ifindProbe: (args) => call("ifind-probe", args ?? {})
3213
3648
  };
3649
+ ({ ...UPDATE_METHOD_OPERATION });
3214
3650
  /**
3215
3651
  * 报告页需要的门禁。
3216
3652
  *
@@ -9288,7 +9724,7 @@ body[data-ds-dark-theme] .crwu-audit-root {
9288
9724
  function describe(cause) {
9289
9725
  return cause instanceof Error ? cause.message : String(cause);
9290
9726
  }
9291
- const EMPTY = {
9727
+ const EMPTY$1 = {
9292
9728
  ok: false,
9293
9729
  error: "",
9294
9730
  rev: "",
@@ -9299,7 +9735,7 @@ body[data-ds-dark-theme] .crwu-audit-root {
9299
9735
  parentSessionId: ""
9300
9736
  };
9301
9737
  function createBuildStore() {
9302
- let snapshot = EMPTY;
9738
+ let snapshot = EMPTY$1;
9303
9739
  const listeners = /* @__PURE__ */ new Set();
9304
9740
  let inflight = null;
9305
9741
  const emit = () => {
@@ -9363,6 +9799,16 @@ body[data-ds-dark-theme] .crwu-audit-root {
9363
9799
  return snapshot.protocol !== null && snapshot.protocol !== 16;
9364
9800
  }
9365
9801
  /**
9802
+ * 当前运行的版本号(**不带 `v` 前缀**):`0.0.11` / dev 形态回空串。
9803
+ *
9804
+ * 侧栏徽标与更新面板都要把它交给更新 View Model(`v{currentVersion}`),
9805
+ * 口径必须只有一处 —— 以前两边各自 `build.version || rev` 拼过,dev 形态会拼出 `vdev`。
9806
+ */
9807
+ function currentVersionOf(snapshot) {
9808
+ if (snapshot.version !== "") return snapshot.version;
9809
+ return snapshot.rev === "" ? "" : snapshot.rev.replace(/^pkg-/, "");
9810
+ }
9811
+ /**
9366
9812
  * 小标签的取值(纯函数,可单测)。
9367
9813
  *
9368
9814
  * 用户口径(2026-09-22):**这里要写清是 dev 模式,还是他直接安装的具体版本**。
@@ -9393,6 +9839,442 @@ body[data-ds-dark-theme] .crwu-audit-root {
9393
9839
  };
9394
9840
  }
9395
9841
  //#endregion
9842
+ //#region src/client/features/update/view-model.ts
9843
+ /**
9844
+ * 更新徽标 / 面板的**纯** View Model。
9845
+ *
9846
+ * 这里没有任何副作用:不碰 fetch、不读时钟(时间由 `nowMs` 传入)、不碰 window / localStorage /
9847
+ * React。业务规则(能不能装、为什么不能装、徽标说什么、哪句话该显示)只在这一处算清楚,
9848
+ * Task 6 的组件照着画,**不允许**组件再拼一套"候选是否过期 / 是否正在装"的竞态逻辑。
9849
+ *
9850
+ * 用户可见中文全部来自 `zh-CN.ts`;同时给出稳定 code(`installBlockedReason` /
9851
+ * `installErrorKind`),便于 Task 6 映射更细的文案或做诊断。
9852
+ *
9853
+ * 2026-09-28 审查修正:错误按**动作**分派(检查 / 安装 / 取消),且"正在安装"取
9854
+ * "本地在飞 **或** Host 事实说 installing",所以刷新页面后也能正确显示安装中并允许取消。
9855
+ *
9856
+ * 第三轮补充:`snapshot.installCurrent === false` 表示快照里那份安装状态属于**上一事务**
9857
+ * (用户点了重试、新事务已经开始,但 Host 还没回报)。这种"历史状态"不得再当作当前事实
9858
+ * 显示安装失败或"已更新";`awaiting-restart` 例外 —— 它是"磁盘上已经是新版本、进程还没重启"
9859
+ * 这个**持久事实**,重试期间也必须继续提示用户重启。
9860
+ */
9861
+ /** 安装阶段 → 诚实的离散中文(**不写百分比、不写预计剩余时间**)。 */
9862
+ const STAGE_LABELS = {
9863
+ connecting: zhCN.updateStageConnecting,
9864
+ downloading: zhCN.updateStageDownloading,
9865
+ installing: zhCN.updateStageInstalling,
9866
+ cancelling: zhCN.updateStageCancelling
9867
+ };
9868
+ /** 公开来源类型 → 本地中文标签(私有源地址永远不进界面)。 */
9869
+ const SOURCE_LABELS = {
9870
+ npmmirror: zhCN.updateSourceNpmmirror,
9871
+ npm: zhCN.updateSourceNpm
9872
+ };
9873
+ const BLOCK_TEXTS = {
9874
+ "development-install": zhCN.updateReasonDevelopment,
9875
+ "enterprise-registry": zhCN.updateReasonEnterprise,
9876
+ "manager-unavailable": zhCN.updateReasonManagerUnavailable,
9877
+ "candidate-expired": zhCN.updateReasonCandidateExpired,
9878
+ "audit-active": zhCN.updateReasonAuditActive,
9879
+ "install-active": zhCN.updateReasonInstallActive,
9880
+ "awaiting-restart": zhCN.updateReasonAwaitingRestart,
9881
+ "no-candidate": zhCN.updateReasonNoCandidate
9882
+ };
9883
+ /** `expiresAt` 有效且还没到期才算新鲜;非法时间一律按已过期处理(fail closed)。 */
9884
+ function isFresh(expiresAt, nowMs) {
9885
+ const at = Date.parse(expiresAt);
9886
+ return !Number.isNaN(at) && at > nowMs;
9887
+ }
9888
+ function updateViewModelOf(input) {
9889
+ const { snapshot, currentVersion, auditBusy, nowMs } = input;
9890
+ const platform = input.platform ?? "other";
9891
+ const buildKind = input.buildKind ?? "";
9892
+ const check = snapshot.check;
9893
+ const install = snapshot.install;
9894
+ const error = snapshot.error;
9895
+ const candidate = check !== null && check.status === "available" ? check.candidate : null;
9896
+ const installCurrent = snapshot.installCurrent;
9897
+ const awaitingRestart = install !== null && install.status === "awaiting-restart";
9898
+ const showUpdated = installCurrent && install !== null && install.status === "updated";
9899
+ const installing = snapshot.installing || installCurrent && install !== null && install.status === "installing";
9900
+ const cancelling = snapshot.cancelling;
9901
+ const checking = snapshot.checking || check !== null && check.status === "checking";
9902
+ const unsupported = check !== null && check.status === "unsupported" ? check.reason : null;
9903
+ const candidateExpired = candidate !== null && !isFresh(candidate.expiresAt, nowMs);
9904
+ const isDev = buildKind === "dev" || unsupported === "development-install";
9905
+ const blocked = awaitingRestart ? "awaiting-restart" : installing || cancelling ? "install-active" : unsupported === "development-install" ? "development-install" : unsupported === "enterprise-registry" ? "enterprise-registry" : unsupported === "manager-unavailable" ? "manager-unavailable" : auditBusy ? "audit-active" : candidate === null ? "no-candidate" : candidateExpired ? "candidate-expired" : null;
9906
+ const versionLabel = currentVersion === "" ? zhCN.buildTagUnknown : `v${currentVersion}`;
9907
+ let badgeSuffix = "";
9908
+ let badgeTone = "neutral";
9909
+ let badgeText = versionLabel;
9910
+ if (isDev) badgeText = zhCN.buildTagDev;
9911
+ else if (showUpdated) {
9912
+ badgeSuffix = zhCN.updateBadgeUpdated;
9913
+ badgeTone = "success";
9914
+ } else if (awaitingRestart) {
9915
+ badgeSuffix = zhCN.updateBadgeAwaitingRestart;
9916
+ badgeTone = "warn";
9917
+ } else if (installing || cancelling) {
9918
+ badgeSuffix = zhCN.updateBadgeInstalling;
9919
+ badgeTone = "accent";
9920
+ } else if (checking) badgeSuffix = zhCN.updateBadgeChecking;
9921
+ else if (candidate !== null) {
9922
+ badgeSuffix = zhCN.updateBadgeUpdate;
9923
+ badgeTone = "accent";
9924
+ }
9925
+ if (badgeSuffix !== "") badgeText = `${versionLabel}${zhCN.updateBadgeSeparator}${badgeSuffix}`;
9926
+ let badgeTitle = zhCN.updateBadgeTitle.replace("{version}", versionLabel);
9927
+ if (isDev) badgeTitle = zhCN.updateBadgeTitleDev;
9928
+ else if (showUpdated) badgeTitle = zhCN.updateBadgeTitleUpdated.replace("{version}", versionLabel);
9929
+ else if (awaitingRestart) badgeTitle = zhCN.updateBadgeTitleRestart;
9930
+ else if (candidate !== null) badgeTitle = zhCN.updateBadgeTitleUpdate.replace("{version}", versionLabel);
9931
+ const installNotice = installCurrent && install !== null && install.status === "awaiting-restart" ? {
9932
+ line: zhCN.updateInstalledLine.replace("{version}", install.targetVersion),
9933
+ hint: platform === "mac" ? zhCN.updateRestartMac : zhCN.updateRestartOther
9934
+ } : installCurrent && install !== null && install.status === "updated" ? {
9935
+ line: zhCN.updateUpdatedLine.replace("{version}", install.version),
9936
+ hint: ""
9937
+ } : null;
9938
+ const showManualCheckError = error !== null && error.action === "check" && error.origin === "manual" || check !== null && check.status === "error" && snapshot.checkOrigin === "manual";
9939
+ const showInstallError = installCurrent && install !== null && install.status === "failed" || error !== null && error.action === "install";
9940
+ const showCancelError = error !== null && error.action === "cancel";
9941
+ return {
9942
+ badgeText,
9943
+ badgeSuffix,
9944
+ badgeTone,
9945
+ hasCandidate: candidate !== null,
9946
+ currentVersion,
9947
+ currentVersionLabel: isDev ? zhCN.buildTagDev : versionLabel,
9948
+ targetVersion: candidate === null ? "" : candidate.targetVersion,
9949
+ publishedAt: candidate === null || candidate.publishedAt === void 0 ? "" : candidate.publishedAt,
9950
+ sourceKind: candidate === null ? null : candidate.sourceKind,
9951
+ canCheck: !checking && unsupported === null,
9952
+ canInstall: blocked === null,
9953
+ canCancel: installing || cancelling,
9954
+ installBlockedReason: blocked,
9955
+ installBlockedText: blocked === null ? "" : BLOCK_TEXTS[blocked],
9956
+ checking,
9957
+ installing,
9958
+ cancelling,
9959
+ awaitingRestart,
9960
+ showUpdated,
9961
+ installStage: installCurrent && install !== null && install.status === "installing" ? install.stage : null,
9962
+ showManualCheckError,
9963
+ manualCheckErrorText: showManualCheckError ? zhCN.updateCheckFailedManual : "",
9964
+ showInstallError,
9965
+ installErrorText: showInstallError ? zhCN.updateInstallFailed : "",
9966
+ showCancelError,
9967
+ cancelErrorText: showCancelError ? zhCN.updateCancelFailed : "",
9968
+ installErrorKind: installCurrent && install !== null && install.status === "failed" ? install.kind : null,
9969
+ badgeTitle,
9970
+ sourceKindLabel: candidate === null ? "" : SOURCE_LABELS[candidate.sourceKind],
9971
+ installStageLabel: installing ? install !== null && install.status === "installing" ? STAGE_LABELS[install.stage] : zhCN.updateStageCancelling : cancelling ? zhCN.updateStageCancelling : "",
9972
+ installNotice
9973
+ };
9974
+ }
9975
+ //#endregion
9976
+ //#region src/client/features/update/UpdateDialog.tsx
9977
+ /** 弹窗容器的 id:焦点接管与 Tab 循环都要先在文档里找到它(无 ref 也能工作)。 */
9978
+ const DIALOG_ID = "crwu-audit-update-dialog";
9979
+ /** 弹窗里"当前可用"的可聚焦元素(disabled 的不在其中 —— 这是 Tab 循环的判据)。 */
9980
+ const FOCUSABLE_SELECTOR = [
9981
+ "button:not([disabled])",
9982
+ "summary",
9983
+ "a[href]",
9984
+ "input:not([disabled])",
9985
+ "select:not([disabled])",
9986
+ "textarea:not([disabled])",
9987
+ "[tabindex]:not([tabindex=\"-1\"])"
9988
+ ].join(", ");
9989
+ /** 当前文档里那个弹窗容器内"可见可用"的可聚焦元素(选择器见上)。 */
9990
+ function focusablesIn(doc) {
9991
+ const found = (doc?.getElementById?.(DIALOG_ID) ?? null)?.querySelectorAll?.(FOCUSABLE_SELECTOR);
9992
+ return found === void 0 ? [] : Array.from(found);
9993
+ }
9994
+ function join(...all) {
9995
+ return all.filter((item) => typeof item === "string" && item !== "").join(" ");
9996
+ }
9997
+ function UpdateDialog(props) {
9998
+ const { onClose } = props;
9999
+ const view = updateViewModelOf({
10000
+ snapshot: props.snapshot,
10001
+ currentVersion: props.currentVersion,
10002
+ auditBusy: props.auditBusy === true,
10003
+ nowMs: props.nowMs ?? Date.now(),
10004
+ buildKind: props.buildKind ?? "",
10005
+ platform: props.platform ?? "other"
10006
+ });
10007
+ const titleId = "crwu-audit-update-dialog-title";
10008
+ const critical = view.installing || view.cancelling;
10009
+ /**
10010
+ * 键盘处理挂在 dialog 元素上(React 的 `onKeyDown`),**不**用 document 监听 + ref:
10011
+ * ref 要靠普通被动 effect 更新,从 idle 重渲染成 installing 之后、被动 effect 跑之前按下
10012
+ * Escape,读到的还是旧的 critical=false —— 那一刻会错误关掉正在安装的弹窗。
10013
+ * 挂在元素上时处理器随 render 原子更新,没有这个时间窗;焦点本来就被限制在弹窗内,
10014
+ * 键盘事件会冒泡到容器。
10015
+ */
10016
+ const onDialogKeyDown = (event) => {
10017
+ if (event?.key === "Escape") {
10018
+ if (critical) return;
10019
+ onClose();
10020
+ return;
10021
+ }
10022
+ if (event?.key !== "Tab") return;
10023
+ const doc = globalThis.document;
10024
+ const items = focusablesIn(doc);
10025
+ if (items.length === 0) return;
10026
+ const index = items.indexOf(doc?.activeElement ?? null);
10027
+ const next = event.shiftKey === true ? index <= 0 ? items[items.length - 1] : items[index - 1] : index === -1 || index >= items.length - 1 ? items[0] : items[index + 1];
10028
+ event.preventDefault?.();
10029
+ next?.focus?.();
10030
+ };
10031
+ react.useEffect(() => {
10032
+ const doc = globalThis.document;
10033
+ const dialog = doc?.getElementById?.(DIALOG_ID) ?? null;
10034
+ const previouslyFocused = doc?.activeElement ?? null;
10035
+ const first = focusablesIn(doc)[0];
10036
+ if (first !== void 0) first.focus?.();
10037
+ else dialog?.focus?.();
10038
+ return () => {
10039
+ previouslyFocused?.focus?.();
10040
+ };
10041
+ }, []);
10042
+ const notice = view.installNotice;
10043
+ const sourceKind = view.sourceKind === null ? zhCN.updateDiagNone : view.sourceKind;
10044
+ const errorLine = props.snapshot.error === null ? zhCN.updateDiagNone : `${props.snapshot.error.action}/${props.snapshot.error.code}`;
10045
+ const rows = [{
10046
+ key: "current",
10047
+ label: zhCN.updateFieldCurrent,
10048
+ value: view.currentVersionLabel,
10049
+ mono: true
10050
+ }];
10051
+ if (view.targetVersion !== "") rows.push({
10052
+ key: "target",
10053
+ label: zhCN.updateFieldTarget,
10054
+ value: `v${view.targetVersion}`,
10055
+ mono: true
10056
+ });
10057
+ if (view.publishedAt !== "") rows.push({
10058
+ key: "published",
10059
+ label: zhCN.updateFieldPublished,
10060
+ value: formatDateTime(view.publishedAt),
10061
+ mono: true
10062
+ });
10063
+ if (view.sourceKindLabel !== "") rows.push({
10064
+ key: "source",
10065
+ label: zhCN.updateFieldSource,
10066
+ value: view.sourceKindLabel,
10067
+ mono: false
10068
+ });
10069
+ if (view.installStageLabel !== "") rows.push({
10070
+ key: "stage",
10071
+ label: zhCN.updateFieldStage,
10072
+ value: view.installStageLabel,
10073
+ mono: false
10074
+ });
10075
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10076
+ className: WORKBENCH_CLASSES.updateDialogBackdrop,
10077
+ ...critical ? {} : { onClick: onClose }
10078
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10079
+ className: WORKBENCH_CLASSES.updateDialog,
10080
+ id: DIALOG_ID,
10081
+ role: "dialog",
10082
+ "aria-modal": "true",
10083
+ "aria-labelledby": titleId,
10084
+ tabIndex: -1,
10085
+ onKeyDown: onDialogKeyDown,
10086
+ children: [
10087
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10088
+ className: WORKBENCH_CLASSES.updateDialogHead,
10089
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10090
+ className: WORKBENCH_CLASSES.updateDialogTitle,
10091
+ id: titleId,
10092
+ children: zhCN.updateDialogTitle
10093
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
10094
+ label: zhCN.updateActionClose,
10095
+ onClick: onClose
10096
+ })]
10097
+ }),
10098
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10099
+ className: WORKBENCH_CLASSES.updateDialogBody,
10100
+ children: [
10101
+ notice === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10102
+ className: WORKBENCH_CLASSES.updateDialogNotice,
10103
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: notice.line }), notice.hint === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10104
+ className: WORKBENCH_CLASSES.updateDialogNoticeHint,
10105
+ children: notice.hint
10106
+ })]
10107
+ }),
10108
+ view.hasCandidate || view.installStageLabel !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10109
+ className: WORKBENCH_CLASSES.kv,
10110
+ children: rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10111
+ className: WORKBENCH_CLASSES.kvKey,
10112
+ children: row.label
10113
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10114
+ className: WORKBENCH_CLASSES.kvValue,
10115
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10116
+ className: row.mono ? WORKBENCH_CLASSES.mono : void 0,
10117
+ children: row.value
10118
+ })
10119
+ })] }, row.key))
10120
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10121
+ className: WORKBENCH_CLASSES.muted,
10122
+ children: zhCN.updateNoCandidateLine
10123
+ }),
10124
+ view.installStageLabel === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10125
+ className: WORKBENCH_CLASSES.updateDialogStages,
10126
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10127
+ className: join(WORKBENCH_CLASSES.updateDialogStage, view.installStage !== null && WORKBENCH_CLASSES.updateDialogStageOn),
10128
+ children: view.installStageLabel
10129
+ })
10130
+ }),
10131
+ view.installBlockedText === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10132
+ className: WORKBENCH_CLASSES.updateDialogReason,
10133
+ children: view.installBlockedText
10134
+ }),
10135
+ view.showManualCheckError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10136
+ className: WORKBENCH_CLASSES.updateDialogError,
10137
+ children: view.manualCheckErrorText
10138
+ }) : null,
10139
+ view.showInstallError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10140
+ className: WORKBENCH_CLASSES.updateDialogError,
10141
+ children: view.installErrorText
10142
+ }) : null,
10143
+ view.showCancelError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10144
+ className: WORKBENCH_CLASSES.updateDialogError,
10145
+ children: view.cancelErrorText
10146
+ }) : null,
10147
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
10148
+ className: WORKBENCH_CLASSES.updateDialogDiag,
10149
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", {
10150
+ className: WORKBENCH_CLASSES.detailsTitle,
10151
+ children: zhCN.updateDiagnostics
10152
+ }), [
10153
+ {
10154
+ key: "check",
10155
+ label: zhCN.updateDiagCheck,
10156
+ value: props.snapshot.check === null ? zhCN.updateDiagNone : props.snapshot.check.status
10157
+ },
10158
+ {
10159
+ key: "install",
10160
+ label: zhCN.updateDiagInstall,
10161
+ value: props.snapshot.install === null ? zhCN.updateDiagNone : props.snapshot.install.status
10162
+ },
10163
+ {
10164
+ key: "stage",
10165
+ label: zhCN.updateDiagStage,
10166
+ value: view.installStage ?? zhCN.updateDiagNone
10167
+ },
10168
+ {
10169
+ key: "target",
10170
+ label: zhCN.updateDiagTarget,
10171
+ value: view.targetVersion === "" ? zhCN.updateDiagNone : view.targetVersion
10172
+ },
10173
+ {
10174
+ key: "source",
10175
+ label: zhCN.updateDiagSource,
10176
+ value: sourceKind
10177
+ },
10178
+ {
10179
+ key: "error",
10180
+ label: zhCN.updateDiagError,
10181
+ value: errorLine
10182
+ }
10183
+ ].map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10184
+ className: WORKBENCH_CLASSES.updateDialogDiagRow,
10185
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10186
+ className: WORKBENCH_CLASSES.kvKey,
10187
+ children: row.label
10188
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10189
+ className: join(WORKBENCH_CLASSES.kvValue, WORKBENCH_CLASSES.mono),
10190
+ children: row.value
10191
+ })]
10192
+ }, row.key))]
10193
+ })
10194
+ ]
10195
+ }),
10196
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10197
+ className: WORKBENCH_CLASSES.updateDialogActions,
10198
+ children: view.awaitingRestart ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
10199
+ label: zhCN.updateActionOk,
10200
+ tone: "primary",
10201
+ onClick: onClose
10202
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
10203
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
10204
+ label: zhCN.updateActionCheck,
10205
+ disabled: !view.canCheck,
10206
+ onClick: () => {
10207
+ props.onCheck?.();
10208
+ }
10209
+ }),
10210
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
10211
+ label: zhCN.updateActionInstall,
10212
+ tone: "primary",
10213
+ disabled: !view.canInstall,
10214
+ onClick: () => {
10215
+ props.onInstall?.();
10216
+ }
10217
+ }),
10218
+ view.canCancel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
10219
+ label: zhCN.updateActionCancel,
10220
+ onClick: () => {
10221
+ props.onCancel?.();
10222
+ }
10223
+ }) : null
10224
+ ] })
10225
+ })
10226
+ ]
10227
+ })] });
10228
+ }
10229
+ //#endregion
10230
+ //#region src/client/features/update/react.ts
10231
+ /**
10232
+ * 更新 Feature 的 React 订阅适配层。
10233
+ *
10234
+ * 两个纯状态(`update-store.ts` 的更新状态、`dialog-store.ts` 的开关状态)都不认识 React;
10235
+ * 组件只通过这里的 hook 读它们。写法与 `useBuild` / `useEnvStatus` / `useModule` 一致
10236
+ * (本仓不引 react-dom,测试用的是替身 React,所以只允许 useState + useEffect 这一套)。
10237
+ *
10238
+ * 两个 hook 都接受**缺省**:面板在单测里可以不注入任何 store 直接渲染。
10239
+ */
10240
+ /** 订阅更新状态(缺省 = 一份空快照:界面按"还没检查过"显示)。 */
10241
+ function useUpdateStore(store) {
10242
+ const [snapshot, setSnapshot] = react.useState(store?.get() ?? null);
10243
+ react.useEffect(() => {
10244
+ if (store === void 0) return void 0;
10245
+ setSnapshot(store.get());
10246
+ return store.subscribe(() => {
10247
+ setSnapshot(store.get());
10248
+ });
10249
+ }, [store]);
10250
+ return snapshot ?? EMPTY;
10251
+ }
10252
+ /** 订阅更新面板的开关状态(缺省 = 不显示)。 */
10253
+ function useUpdateDialog(store) {
10254
+ const [snapshot, setSnapshot] = react.useState(store?.get() ?? CLOSED);
10255
+ react.useEffect(() => {
10256
+ if (store === void 0) return void 0;
10257
+ setSnapshot(store.get());
10258
+ return store.subscribe(() => {
10259
+ setSnapshot(store.get());
10260
+ });
10261
+ }, [store]);
10262
+ return snapshot;
10263
+ }
10264
+ const CLOSED = { open: false };
10265
+ /** 没有 store 时的空状态:与 `update-store.ts` 的初始快照同形。 */
10266
+ const EMPTY = {
10267
+ initialized: false,
10268
+ check: null,
10269
+ install: null,
10270
+ installCurrent: false,
10271
+ checkOrigin: null,
10272
+ checking: false,
10273
+ installing: false,
10274
+ cancelling: false,
10275
+ error: null
10276
+ };
10277
+ //#endregion
9396
10278
  //#region src/client/features/workbench/greeting.ts
9397
10279
  /**
9398
10280
  * 面板头部右侧那句问候。
@@ -9453,6 +10335,13 @@ body[data-ds-dark-theme] .crwu-audit-root {
9453
10335
  * 轮询本身不是新鲜事,但**必须可释放**:否则切走面板后请求还在跑。
9454
10336
  */
9455
10337
  const POLL_INTERVAL_MS = 1e4;
10338
+ /** 更新徽标的语气 → 附加类(与侧栏入口共用同一套)。 */
10339
+ const UPDATE_TONE_CLASSES$1 = {
10340
+ neutral: "",
10341
+ accent: WORKBENCH_CLASSES.updateBadgeAccent,
10342
+ warn: WORKBENCH_CLASSES.updateBadgeWarn,
10343
+ success: WORKBENCH_CLASSES.updateBadgeOk
10344
+ };
9456
10345
  function WorkbenchPanel(props) {
9457
10346
  const fallback = react.useRef(null);
9458
10347
  if (fallback.current === null) fallback.current = createEnvStatusStore();
@@ -9484,6 +10373,8 @@ body[data-ds-dark-theme] .crwu-audit-root {
9484
10373
  const buildStore = props.build ?? buildFallback.current;
9485
10374
  const build = useBuild(buildStore);
9486
10375
  const tag = buildTagOf(build);
10376
+ const updateSnapshot = useUpdateStore(props.update);
10377
+ const updateDialogState = useUpdateDialog(props.updateDialog);
9487
10378
  /** 被门禁拦住的界面状态:running = 正在重新自检,blocked = 自检没过。 */
9488
10379
  const [gate, setGate] = react.useState("idle");
9489
10380
  const [loginMessage, setLoginMessage] = react.useState("");
@@ -9633,6 +10524,7 @@ body[data-ds-dark-theme] .crwu-audit-root {
9633
10524
  buildStore.refresh();
9634
10525
  const current = envStatus.get();
9635
10526
  if (current.env === null && !current.busy) envStatus.refresh();
10527
+ if (props.update !== void 0 && !props.update.get().initialized) props.update.initialize();
9636
10528
  return () => {
9637
10529
  mounted.current = false;
9638
10530
  };
@@ -9775,6 +10667,15 @@ body[data-ds-dark-theme] .crwu-audit-root {
9775
10667
  const awaitingEnv = env === null && !hostStale && snapshot.error === "";
9776
10668
  const bootError = build.error;
9777
10669
  const boundParentId = build.parentSessionId;
10670
+ const gatingHasAudit = auditBusy !== "" || activeKey !== "";
10671
+ const updateView = updateViewModelOf({
10672
+ snapshot: updateSnapshot,
10673
+ currentVersion: currentVersionOf(build),
10674
+ auditBusy: gatingHasAudit,
10675
+ nowMs: (props.now ?? Date.now)(),
10676
+ buildKind: build.buildKind,
10677
+ platform: props.platform ?? "other"
10678
+ });
9778
10679
  const gating = gatingOf(env, activeKey, {
9779
10680
  busy,
9780
10681
  ...auditBusy === "" ? {} : { auditBusy },
@@ -9893,6 +10794,26 @@ body[data-ds-dark-theme] .crwu-audit-root {
9893
10794
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9894
10795
  className: WORKBENCH_CLASSES.root,
9895
10796
  children: [
10797
+ updateDialogState.open && props.update !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UpdateDialog, {
10798
+ snapshot: updateSnapshot,
10799
+ currentVersion: currentVersionOf(build),
10800
+ buildKind: build.buildKind,
10801
+ auditBusy: gatingHasAudit,
10802
+ nowMs: (props.now ?? Date.now)(),
10803
+ platform: props.platform ?? "other",
10804
+ onClose: () => {
10805
+ props.updateDialog?.close();
10806
+ },
10807
+ onCheck: () => {
10808
+ props.update?.check();
10809
+ },
10810
+ onInstall: () => {
10811
+ props.update?.install();
10812
+ },
10813
+ onCancel: () => {
10814
+ props.update?.cancel();
10815
+ }
10816
+ }) : null,
9896
10817
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9897
10818
  className: WORKBENCH_CLASSES.header,
9898
10819
  children: [
@@ -9901,10 +10822,23 @@ body[data-ds-dark-theme] .crwu-audit-root {
9901
10822
  className: WORKBENCH_CLASSES.title,
9902
10823
  children: zhCN.title
9903
10824
  }),
9904
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10825
+ props.update === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
9905
10826
  className: [WORKBENCH_CLASSES.version, BUILD_TAG_CLASSES[tag.tone]].join(" "),
9906
10827
  title: tag.title,
9907
10828
  children: tag.text
10829
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10830
+ type: "button",
10831
+ className: [
10832
+ WORKBENCH_CLASSES.version,
10833
+ WORKBENCH_CLASSES.updateBadge,
10834
+ UPDATE_TONE_CLASSES$1[updateView.badgeTone]
10835
+ ].filter((item) => item !== "").join(" "),
10836
+ title: updateView.badgeTitle,
10837
+ "aria-label": updateView.badgeTitle,
10838
+ onClick: () => {
10839
+ props.updateDialog?.open();
10840
+ },
10841
+ children: updateView.badgeText
9908
10842
  }),
9909
10843
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: WORKBENCH_CLASSES.grow }),
9910
10844
  greeting === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -10136,6 +11070,13 @@ body[data-ds-dark-theme] .crwu-audit-root {
10136
11070
  busy: WORKBENCH_CLASSES.sideEntryMarkBusy,
10137
11071
  idle: WORKBENCH_CLASSES.sideEntryMarkIdle
10138
11072
  };
11073
+ /** 更新徽标的语气 → 附加类(形状仍是 `.crwu-audit-version`)。 */
11074
+ const UPDATE_TONE_CLASSES = {
11075
+ neutral: "",
11076
+ accent: WORKBENCH_CLASSES.updateBadgeAccent,
11077
+ warn: WORKBENCH_CLASSES.updateBadgeWarn,
11078
+ success: WORKBENCH_CLASSES.updateBadgeOk
11079
+ };
10139
11080
  const GLYPH = {
10140
11081
  eval: EvalIcon,
10141
11082
  audit: AuditIcon,
@@ -10146,8 +11087,17 @@ body[data-ds-dark-theme] .crwu-audit-root {
10146
11087
  const current = useModule(modules);
10147
11088
  const snapshot = useEnvStatus(props.store);
10148
11089
  const build = useBuild(props.build);
11090
+ const updateSnapshot = useUpdateStore(props.update);
10149
11091
  const tone = envLampOf(snapshot);
10150
11092
  const tag = buildTagOf(build);
11093
+ const updateView = props.update === void 0 ? null : updateViewModelOf({
11094
+ snapshot: updateSnapshot,
11095
+ currentVersion: currentVersionOf(build),
11096
+ auditBusy: false,
11097
+ nowMs: (props.now ?? Date.now)(),
11098
+ buildKind: build.buildKind,
11099
+ platform: props.platform ?? "other"
11100
+ });
10151
11101
  const active = props.usePanelInfo === void 0 ? false : props.usePanelInfo((info) => info.activePanelId === WORKBENCH_PANEL_KEY);
10152
11102
  const wide = props.wide !== false;
10153
11103
  react.useEffect(() => {
@@ -10155,7 +11105,12 @@ body[data-ds-dark-theme] .crwu-audit-root {
10155
11105
  if (env.env === null && !env.busy) props.store.refresh();
10156
11106
  const info = props.build.get();
10157
11107
  if (info.rev === "" && info.error === "") props.build.refresh();
10158
- }, [props.store, props.build]);
11108
+ if (props.update !== void 0 && !props.update.get().initialized) props.update.initialize();
11109
+ }, [
11110
+ props.store,
11111
+ props.build,
11112
+ props.update
11113
+ ]);
10159
11114
  const markClass = [WORKBENCH_CLASSES.sideEntryMark, MARK_OF[tone] ?? WORKBENCH_CLASSES.sideEntryMarkIdle].join(" ");
10160
11115
  const mark = tone === "ok" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BadgeCheckIcon, { size: 12 }) : tone === "bad" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BadgeWarnIcon, { size: 12 }) : null;
10161
11116
  const title = `${zhCN.sidebarLabel} · ${tag.title}\n${envMarkTitle(tone)}`;
@@ -10193,10 +11148,21 @@ body[data-ds-dark-theme] .crwu-audit-root {
10193
11148
  className: WORKBENCH_CLASSES.sideCardTitle,
10194
11149
  children: zhCN.sidebarLabel
10195
11150
  }),
10196
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11151
+ updateView === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10197
11152
  className: [WORKBENCH_CLASSES.version, BUILD_TAG_CLASSES[tag.tone]].join(" "),
10198
11153
  title: tag.title,
10199
11154
  children: tag.text
11155
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11156
+ type: "button",
11157
+ className: [
11158
+ WORKBENCH_CLASSES.version,
11159
+ WORKBENCH_CLASSES.updateBadge,
11160
+ UPDATE_TONE_CLASSES[updateView.badgeTone]
11161
+ ].filter((item) => item !== "").join(" "),
11162
+ title: updateView.badgeTitle,
11163
+ "aria-label": updateView.badgeTitle,
11164
+ onClick: props.onOpenUpdate,
11165
+ children: updateView.badgeText
10200
11166
  })
10201
11167
  ]
10202
11168
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -10236,6 +11202,564 @@ body[data-ds-dark-theme] .crwu-audit-root {
10236
11202
  });
10237
11203
  }
10238
11204
  //#endregion
11205
+ //#region src/client/features/update/dialog-store.ts
11206
+ function createUpdateDialogStore() {
11207
+ let snapshot = { open: false };
11208
+ const listeners = /* @__PURE__ */ new Set();
11209
+ const set = (open) => {
11210
+ if (snapshot.open === open) return;
11211
+ snapshot = { open };
11212
+ for (const listener of [...listeners]) listener();
11213
+ };
11214
+ return {
11215
+ get: () => snapshot,
11216
+ open: () => {
11217
+ set(true);
11218
+ },
11219
+ close: () => {
11220
+ set(false);
11221
+ },
11222
+ subscribe(listener) {
11223
+ listeners.add(listener);
11224
+ return () => {
11225
+ listeners.delete(listener);
11226
+ };
11227
+ },
11228
+ dispose() {
11229
+ listeners.clear();
11230
+ }
11231
+ };
11232
+ }
11233
+ //#endregion
11234
+ //#region src/client/features/update/update-store.ts
11235
+ /**
11236
+ * 实例级更新 Store:Client 侧唯一的更新状态事实源。
11237
+ *
11238
+ * 为什么要一个 store 而不是让组件各自请求:侧栏徽标与更新面板是**两个消费者**,
11239
+ * 各拉一次就会出现「徽标说有一更新、面板说已是最新」这种自相矛盾(协议 15 那次环境页
11240
+ * 就是同一个坑)。所以:
11241
+ * - 每个 Client 插件实例**一份**(`createUpdateStore`,没有模块级单例);
11242
+ * - 初始化只有一条链(两个消费者共享同一个 Promise);
11243
+ * - 每个动作各自单飞(检查 / 安装 / 取消 / 状态刷新),重复点击不会多打上游;
11244
+ * - Host 的 JSON 只在 `parseUpdateResponse()` 收窄之后才进快照(见 `api.ts`)。
11245
+ *
11246
+ * ## 2026-09-28 第一轮审查修正
11247
+ *
11248
+ * 1. **操作状态互不覆盖**:`checking` / `installing` / `cancelling` 三个独立布尔。
11249
+ * 2. **轮询由 Host 事实驱动**:`shouldPoll()` 只看 `install.status` 与"本次事务是否在飞"。
11250
+ * 3. **过期响应按世代作废**:`install()` 开新世代,响应带着发出时的世代回来。
11251
+ *
11252
+ * ## 2026-09-28 第二轮审查修正(本节的四条是这一轮的重点)
11253
+ *
11254
+ * A. **新事务一开,旧世代立刻失效**:比较基准是"当前事务世代"(`activeEpoch`),不是
11255
+ * "已经写入过的最高世代"。旧代码用后者,于是"旧 status 挂住 → 点安装 → 旧 status 返回
11256
+ * failed"会把 failed 写进新事务,还会顺手停掉新事务的轮询。
11257
+ * B. **`fetchStatus` 的返回值 = 是否取得当前事务可用的合法 install 事实**,不是"Promise 没
11258
+ * throw":malformed / rejected / install 字段非法 / 不属于当前世代 都不算事实。
11259
+ * C. **status 单飞按世代保存**:旧世代 already-inflight 的 status 不能拿来当当前事实 ——
11260
+ * 先等它落定(结果丢弃),再明确发一次当前世代的 status。
11261
+ * D. **不确定结果统一处理**:`update-install` / `update-cancel` 即使没有 throw,只要信封
11262
+ * 不认识 / 被拒 / install 字段非法 / 不被当前世代接受,都要补一次 status 取事实,
11263
+ * 再决定是"确实在推进"还是"这次动作没生效/取不到事实"。
11264
+ *
11265
+ * ## 2026-09-28 第三轮审查修正
11266
+ *
11267
+ * E. **快照里的安装状态带出"它属于哪个事务"**(`installCurrent`):上一事务的 failed / cancelled
11268
+ * 不能挡住新事务的轮询(`shouldPoll()` 只看当前事务是否落定、当前事务是否在装),
11269
+ * 也不能在重试期间继续当作"当前 Host 事实"显示安装失败。
11270
+ * F. **事务内再加一层 revision**:光靠世代分不出同一事务里"谁更新" —— 两者世代相同。
11271
+ * 每次写入安装状态都递增 `installRevision`,每个请求把**发出时**的 revision 带回来;
11272
+ * 返回时若 `installRevision` 已经更大,就说明期间有更新的状态被接受,这份观察作废
11273
+ * (终态之前发出的旧观察、以及更新的 update-install 之后回来的旧 poll,都走这一条)。
11274
+ *
11275
+ * ## 2026-09-28 第四轮审查修正
11276
+ *
11277
+ * G. **revision 保护所有观察响应,不只终态之后**:`write.revision < installRevision` 的
11278
+ * 非权威响应一律拒绝 —— 否则"update-install 先写 installing、较早发出的 poll 后返回 idle"
11279
+ * 会把状态打回 idle 并把轮询停掉。终态之后只额外多一条"只允许确认完全相同的终态"。
11280
+ * 当前 revision 的观察照常推进(connecting → downloading → installing 不受影响)。
11281
+ *
11282
+ * 这里**不碰 UI**:没有 React、没有 localStorage、没有 Node 模块;时间只用于轮询间隔,
11283
+ * 由注入的 scheduler 决定,单测不做真实等待。
11284
+ */
11285
+ const DEFAULT_POLL_INTERVAL_MS = 2e3;
11286
+ const TERMINAL_INSTALL_STATUSES = [
11287
+ "awaiting-restart",
11288
+ "updated",
11289
+ "cancelled",
11290
+ "failed"
11291
+ ];
11292
+ function defaultScheduler() {
11293
+ return { schedule(callback, delayMs) {
11294
+ const timer = setTimeout(callback, delayMs);
11295
+ return () => {
11296
+ clearTimeout(timer);
11297
+ };
11298
+ } };
11299
+ }
11300
+ /** 值比较:收窄时重建的对象键序稳定,所以序列化比较可靠(用于"没变就不通知")。 */
11301
+ function sameValue(left, right) {
11302
+ if (left === right) return true;
11303
+ if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false;
11304
+ return JSON.stringify(left) === JSON.stringify(right);
11305
+ }
11306
+ function isTerminalInstall(state) {
11307
+ return TERMINAL_INSTALL_STATUSES.includes(state.status);
11308
+ }
11309
+ function createUpdateStore(deps) {
11310
+ const scheduler = deps.scheduler ?? defaultScheduler();
11311
+ const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
11312
+ const listeners = /* @__PURE__ */ new Set();
11313
+ let snapshot = {
11314
+ initialized: false,
11315
+ check: null,
11316
+ install: null,
11317
+ installCurrent: false,
11318
+ checkOrigin: null,
11319
+ checking: false,
11320
+ installing: false,
11321
+ cancelling: false,
11322
+ error: null
11323
+ };
11324
+ let disposed = false;
11325
+ let initializing = null;
11326
+ let checkInflight = null;
11327
+ let installInflight = null;
11328
+ let cancelInflight = null;
11329
+ /** 状态取数与它所属的世代一起保存:旧世代的在飞请求不能拿来当当前事实。 */
11330
+ let statusInflight = null;
11331
+ let pollCancel = null;
11332
+ /**
11333
+ * **当前**安装事务世代。`install()` 一开新事务就 +1 —— 那一刻起,所有带着更早世代回来的
11334
+ * 响应立刻失效(不是"等新事务写入第一条状态之后")。
11335
+ */
11336
+ let activeEpoch = 1;
11337
+ /** `snapshot.install` 属于哪个世代(0 = 还没有过)。不等于 `activeEpoch` 就是"历史状态"。 */
11338
+ let installEpoch = 0;
11339
+ /** 哪个世代已经落定(写过终态);值等于 `activeEpoch` 表示"当前事务已经是终态"。 */
11340
+ let settledEpoch = -1;
11341
+ /**
11342
+ * 安装状态的**写入序号**:每接受一次安装状态 +1(事务内也递增)。
11343
+ *
11344
+ * 请求发出时捕获它,返回时再比一次:`write.revision < installRevision` 就是"这份观察发出之后
11345
+ * 已经有更新的状态被接受了" —— 过期观察,任何结论(含任意终态)都不许覆盖当前状态。
11346
+ */
11347
+ let installRevision = 0;
11348
+ /** 检查 / 取消请求的序号:旧请求的 finally 不许清掉新请求的活动状态。 */
11349
+ let checkSeq = 0;
11350
+ let cancelSeq = 0;
11351
+ function get() {
11352
+ return snapshot;
11353
+ }
11354
+ function subscribe(listener) {
11355
+ listeners.add(listener);
11356
+ return () => {
11357
+ listeners.delete(listener);
11358
+ };
11359
+ }
11360
+ function update(patch) {
11361
+ if (disposed) return snapshot;
11362
+ let changed = false;
11363
+ for (const key of Object.keys(patch)) if (!sameValue(snapshot[key], patch[key])) {
11364
+ changed = true;
11365
+ break;
11366
+ }
11367
+ if (!changed) return snapshot;
11368
+ snapshot = {
11369
+ ...snapshot,
11370
+ ...patch
11371
+ };
11372
+ for (const listener of [...listeners]) try {
11373
+ listener(snapshot);
11374
+ } catch (error) {}
11375
+ return snapshot;
11376
+ }
11377
+ /** 记一条客户端侧失败。 */
11378
+ function recordError(next) {
11379
+ const current = snapshot.error;
11380
+ if (next.origin === "background" && current !== null && current.origin === "manual") return;
11381
+ update({ error: next });
11382
+ }
11383
+ /** 清掉某个手动动作留下的错误(事实证明这次动作确实生效了)。 */
11384
+ function clearManualError(action) {
11385
+ const current = snapshot.error;
11386
+ if (current !== null && current.origin === "manual" && current.action === action) update({ error: null });
11387
+ }
11388
+ /**
11389
+ * 这份安装状态是否还有资格写进快照(过期响应保护)。
11390
+ *
11391
+ * 判定顺序:
11392
+ * 1. `write.epoch !== activeEpoch` → 拒(跨事务的旧响应全部失效);
11393
+ * 2. 非权威且 `write.revision < installRevision` → 拒(**过期观察**:期间已有更新的状态被接受);
11394
+ * 3. 当前事务已落定 → 非权威的只允许确认完全相同的终态,且任何响应都不许退回非终态;
11395
+ * 4. 检查响应顺带带回的快照,安装进行时不得用非终态覆盖。
11396
+ *
11397
+ * @param next - 收窄后的安装状态。
11398
+ * @param write - 这份状态来自哪个世代、哪个 revision、是不是本次事务的权威结论。
11399
+ * @returns 可以写入为 true。
11400
+ */
11401
+ function acceptsInstall(next, write) {
11402
+ if (write.epoch !== activeEpoch) return false;
11403
+ if (!write.authoritative && write.revision < installRevision) return false;
11404
+ if (settledEpoch === activeEpoch) {
11405
+ if (!write.authoritative && !sameValue(next, snapshot.install)) return false;
11406
+ if (!isTerminalInstall(next)) return false;
11407
+ }
11408
+ const installActive = snapshot.installing || snapshot.cancelling || currentTransactionInstalling();
11409
+ if (write.incidental && installActive && !isTerminalInstall(next)) return false;
11410
+ return true;
11411
+ }
11412
+ /** 写入安装状态;返回是否真的被接受。 */
11413
+ function applyInstall(next, write) {
11414
+ if (!acceptsInstall(next, write)) return false;
11415
+ installRevision += 1;
11416
+ installEpoch = activeEpoch;
11417
+ if (isTerminalInstall(next)) settledEpoch = activeEpoch;
11418
+ update({
11419
+ install: next,
11420
+ installCurrent: currentInstallIsVisible()
11421
+ });
11422
+ syncPolling();
11423
+ return true;
11424
+ }
11425
+ function applyEnvelope(raw, options) {
11426
+ const parsed = parseUpdateResponse(raw);
11427
+ const { action, origin } = options;
11428
+ const empty = {
11429
+ envelopeValid: false,
11430
+ rejected: false,
11431
+ checkValid: false,
11432
+ installValid: false,
11433
+ installAccepted: false,
11434
+ isCurrentFact: false
11435
+ };
11436
+ if (parsed.kind === "rejected") {
11437
+ recordError({
11438
+ action,
11439
+ origin,
11440
+ code: "rejected"
11441
+ });
11442
+ return {
11443
+ ...empty,
11444
+ envelopeValid: true,
11445
+ rejected: true
11446
+ };
11447
+ }
11448
+ if (parsed.kind === "malformed") {
11449
+ recordError({
11450
+ action,
11451
+ origin,
11452
+ code: "malformed-envelope"
11453
+ });
11454
+ return empty;
11455
+ }
11456
+ if (parsed.check !== null) {
11457
+ const patch = { check: parsed.check };
11458
+ if (options.setCheckOrigin === true || snapshot.checkOrigin === null) patch.checkOrigin = origin;
11459
+ update(patch);
11460
+ }
11461
+ const installAccepted = parsed.install === null ? false : applyInstall(parsed.install, {
11462
+ epoch: options.epoch,
11463
+ revision: options.revision,
11464
+ authoritative: options.authoritative === true,
11465
+ incidental: options.incidentalInstall === true
11466
+ });
11467
+ const failure = parsed.invalid.includes("check") ? "malformed-check" : parsed.invalid.includes("install") ? "malformed-install" : null;
11468
+ if (failure !== null) recordError({
11469
+ action,
11470
+ origin,
11471
+ code: failure
11472
+ });
11473
+ else if (origin === "manual" && snapshot.error !== null && snapshot.error.origin === "manual" && snapshot.error.action === action) update({ error: null });
11474
+ return {
11475
+ envelopeValid: true,
11476
+ rejected: false,
11477
+ checkValid: parsed.check !== null,
11478
+ installValid: parsed.install !== null,
11479
+ installAccepted,
11480
+ isCurrentFact: parsed.install !== null && installAccepted
11481
+ };
11482
+ }
11483
+ /** 为什么这次 status 响应不能当作当前事务的事实。 */
11484
+ function statusFailureCodeOf(outcome) {
11485
+ if (!outcome.envelopeValid) return "malformed-envelope";
11486
+ if (outcome.rejected) return "rejected";
11487
+ if (!outcome.installValid) return "malformed-install";
11488
+ return "stale";
11489
+ }
11490
+ /**
11491
+ * 取一次状态,并回答"**是否取得了当前事务可用的合法 install 事实**"。
11492
+ *
11493
+ * 与 `updateStatus()` 的成功/失败不是一回事:信封不认识、被拒绝、install 字段非法、
11494
+ * 或者响应属于更早的事务,都不算事实。
11495
+ *
11496
+ * @param action - 这次取数是谁发起的(`status` 主动刷新 / `poll` 安装期间的轮询)。
11497
+ * @returns 事实与"没取到时为什么"。
11498
+ */
11499
+ async function fetchStatus(action) {
11500
+ for (;;) {
11501
+ const inflight = statusInflight;
11502
+ if (inflight !== null && inflight.epoch === activeEpoch) return inflight.promise;
11503
+ if (inflight !== null) {
11504
+ await inflight.promise.catch(() => void 0);
11505
+ continue;
11506
+ }
11507
+ const at = activeEpoch;
11508
+ const revision = installRevision;
11509
+ const run = (async () => {
11510
+ let outcome;
11511
+ try {
11512
+ outcome = applyEnvelope(await deps.api.updateStatus(), {
11513
+ action,
11514
+ origin: "background",
11515
+ epoch: at,
11516
+ revision,
11517
+ setCheckOrigin: snapshot.checkOrigin === null
11518
+ });
11519
+ } catch (error) {
11520
+ recordError({
11521
+ action,
11522
+ origin: "background",
11523
+ code: "transport"
11524
+ });
11525
+ return {
11526
+ fact: false,
11527
+ code: "transport"
11528
+ };
11529
+ }
11530
+ if (outcome.isCurrentFact) return {
11531
+ fact: true,
11532
+ code: null
11533
+ };
11534
+ return {
11535
+ fact: false,
11536
+ code: statusFailureCodeOf(outcome)
11537
+ };
11538
+ })();
11539
+ statusInflight = {
11540
+ epoch: at,
11541
+ promise: run
11542
+ };
11543
+ try {
11544
+ return await run;
11545
+ } finally {
11546
+ if (statusInflight !== null && statusInflight.promise === run) statusInflight = null;
11547
+ }
11548
+ }
11549
+ }
11550
+ /** 一次检查:手动与后台共用单飞闸门(两个消费者同时点也只发一次)。 */
11551
+ function startCheck(origin) {
11552
+ if (checkInflight !== null) return checkInflight;
11553
+ const at = activeEpoch;
11554
+ const revision = installRevision;
11555
+ const seq = checkSeq += 1;
11556
+ const run = (async () => {
11557
+ update({ checking: true });
11558
+ try {
11559
+ applyEnvelope(await deps.api.updateCheck(), {
11560
+ action: "check",
11561
+ origin,
11562
+ epoch: at,
11563
+ revision,
11564
+ setCheckOrigin: true,
11565
+ incidentalInstall: true
11566
+ });
11567
+ } catch (error) {
11568
+ recordError({
11569
+ action: "check",
11570
+ origin,
11571
+ code: "transport"
11572
+ });
11573
+ } finally {
11574
+ if (seq === checkSeq) update({ checking: false });
11575
+ checkInflight = null;
11576
+ syncPolling();
11577
+ }
11578
+ return snapshot;
11579
+ })();
11580
+ checkInflight = run;
11581
+ return run;
11582
+ }
11583
+ /** `snapshot.install` 是不是当前事务的结论。 */
11584
+ function currentInstallIsVisible() {
11585
+ return installEpoch === activeEpoch;
11586
+ }
11587
+ /** 当前事务是否已经落定(终态已经写进快照)。 */
11588
+ function currentTransactionSettled() {
11589
+ return settledEpoch === activeEpoch;
11590
+ }
11591
+ /** 当前事务的 Host 事实说"正在装"。上一事务留下的 installing 不算。 */
11592
+ function currentTransactionInstalling() {
11593
+ return currentInstallIsVisible() && snapshot.install !== null && snapshot.install.status === "installing";
11594
+ }
11595
+ /**
11596
+ * 轮询该不该开着。
11597
+ *
11598
+ * - Host 已落定(awaiting-restart / updated / cancelled / failed)→ 关:没有更多可观察的进展;
11599
+ * - 本地有安装 / 取消请求在飞 → 开:Host 可能还没把 `installing` 报出来,而阶段变化只能靠轮询看;
11600
+ * - 否则只看 Host 是否在装 —— 刷新页面后恢复安装状态走的就是这条;
11601
+ * - Host 说 idle 而本地也没有在飞的事务 → 关。
11602
+ *
11603
+ * `check()` 完全不参与这个判断,所以它清不掉轮询。
11604
+ */
11605
+ function shouldPoll() {
11606
+ if (disposed) return false;
11607
+ if (currentTransactionSettled()) return false;
11608
+ if (snapshot.installing || snapshot.cancelling) return true;
11609
+ return currentTransactionInstalling();
11610
+ }
11611
+ function stopPolling() {
11612
+ const cancel = pollCancel;
11613
+ pollCancel = null;
11614
+ if (cancel !== null) cancel();
11615
+ }
11616
+ function startPolling() {
11617
+ if (disposed || pollCancel !== null) return;
11618
+ pollCancel = scheduler.schedule(() => {
11619
+ pollCancel = null;
11620
+ pollTick();
11621
+ }, pollIntervalMs);
11622
+ }
11623
+ function syncPolling() {
11624
+ if (shouldPoll()) startPolling();
11625
+ else stopPolling();
11626
+ }
11627
+ async function pollTick() {
11628
+ if (!shouldPoll()) {
11629
+ stopPolling();
11630
+ return;
11631
+ }
11632
+ await fetchStatus("poll");
11633
+ syncPolling();
11634
+ }
11635
+ return {
11636
+ get,
11637
+ subscribe,
11638
+ initialize() {
11639
+ if (snapshot.initialized) return Promise.resolve(snapshot);
11640
+ if (initializing !== null) return initializing;
11641
+ const run = (async () => {
11642
+ try {
11643
+ await fetchStatus("status");
11644
+ syncPolling();
11645
+ const check = snapshot.check;
11646
+ if (!(check !== null && check.status !== "idle" && check.status !== "checking") && !disposed) await startCheck("background");
11647
+ } catch (error) {} finally {
11648
+ initializing = null;
11649
+ syncPolling();
11650
+ update({ initialized: true });
11651
+ }
11652
+ return snapshot;
11653
+ })();
11654
+ initializing = run;
11655
+ return run;
11656
+ },
11657
+ check() {
11658
+ return startCheck("manual");
11659
+ },
11660
+ install() {
11661
+ if (installInflight !== null) return installInflight;
11662
+ activeEpoch += 1;
11663
+ clearManualError("install");
11664
+ const at = activeEpoch;
11665
+ const revision = installRevision;
11666
+ const run = (async () => {
11667
+ update({
11668
+ installing: true,
11669
+ installCurrent: currentInstallIsVisible()
11670
+ });
11671
+ syncPolling();
11672
+ try {
11673
+ let outcome = null;
11674
+ try {
11675
+ outcome = applyEnvelope(await deps.api.updateInstall(), {
11676
+ action: "install",
11677
+ origin: "manual",
11678
+ epoch: at,
11679
+ revision,
11680
+ authoritative: true
11681
+ });
11682
+ } catch (error) {
11683
+ outcome = null;
11684
+ }
11685
+ if (outcome === null || !outcome.isCurrentFact) {
11686
+ const status = await fetchStatus("status");
11687
+ const state = snapshot.install;
11688
+ const progressing = state !== null && (state.status === "installing" || isTerminalInstall(state));
11689
+ if (status.fact && progressing) clearManualError("install");
11690
+ else if (status.fact) recordError({
11691
+ action: "install",
11692
+ origin: "manual",
11693
+ code: "no-effect"
11694
+ });
11695
+ else recordError({
11696
+ action: "install",
11697
+ origin: "manual",
11698
+ code: status.code ?? "transport"
11699
+ });
11700
+ }
11701
+ } finally {
11702
+ installInflight = null;
11703
+ update({ installing: false });
11704
+ syncPolling();
11705
+ }
11706
+ return snapshot;
11707
+ })();
11708
+ installInflight = run;
11709
+ return run;
11710
+ },
11711
+ cancel() {
11712
+ if (cancelInflight !== null) return cancelInflight;
11713
+ const at = activeEpoch;
11714
+ const revision = installRevision;
11715
+ const seq = cancelSeq += 1;
11716
+ clearManualError("cancel");
11717
+ const run = (async () => {
11718
+ update({ cancelling: true });
11719
+ syncPolling();
11720
+ try {
11721
+ let outcome = null;
11722
+ try {
11723
+ outcome = applyEnvelope(await deps.api.updateCancel(), {
11724
+ action: "cancel",
11725
+ origin: "manual",
11726
+ epoch: at,
11727
+ revision,
11728
+ authoritative: true
11729
+ });
11730
+ } catch (error) {
11731
+ outcome = null;
11732
+ }
11733
+ if (outcome === null || !outcome.isCurrentFact) {
11734
+ const status = await fetchStatus("status");
11735
+ if (status.fact) clearManualError("cancel");
11736
+ else recordError({
11737
+ action: "cancel",
11738
+ origin: "manual",
11739
+ code: status.code ?? "transport"
11740
+ });
11741
+ }
11742
+ } finally {
11743
+ if (seq === cancelSeq) update({ cancelling: false });
11744
+ cancelInflight = null;
11745
+ syncPolling();
11746
+ }
11747
+ return snapshot;
11748
+ })();
11749
+ cancelInflight = run;
11750
+ return run;
11751
+ },
11752
+ refreshStatus() {
11753
+ return fetchStatus("status").then(() => snapshot);
11754
+ },
11755
+ dispose() {
11756
+ disposed = true;
11757
+ stopPolling();
11758
+ listeners.clear();
11759
+ }
11760
+ };
11761
+ }
11762
+ //#endregion
10239
11763
  //#region src/client/features/workbench/styles.ts
10240
11764
  /** 安装当前插件自己的样式,并在 Cordis effect 释放时清理。 */
10241
11765
  function installWorkbenchStyles() {
@@ -10287,6 +11811,13 @@ body[data-ds-dark-theme] .crwu-audit-root {
10287
11811
  const envStatus = createEnvStatusStore();
10288
11812
  const buildStore = createBuildStore();
10289
11813
  const modules = createModuleStore();
11814
+ const updateStore = createUpdateStore({ api: updateApi });
11815
+ const updateDialog = createUpdateDialogStore();
11816
+ ctx.effect(() => () => {
11817
+ updateStore.dispose();
11818
+ updateDialog.dispose();
11819
+ }, "crwu-workbench: update state");
11820
+ const platform = /Mac|iPhone|iPad/i.test(globalThis.navigator?.userAgent ?? "") ? "mac" : "other";
10290
11821
  const openPanel = () => {
10291
11822
  const target = services.layout;
10292
11823
  if (target?.selectPanel !== void 0) target.selectPanel(WORKBENCH_PANEL_KEY);
@@ -10301,9 +11832,14 @@ body[data-ds-dark-theme] .crwu-audit-root {
10301
11832
  store: envStatus,
10302
11833
  build: buildStore,
10303
11834
  modules,
11835
+ update: updateStore,
10304
11836
  wide: props.wide !== false,
10305
11837
  ...props.usePanelInfo === void 0 ? {} : { usePanelInfo: props.usePanelInfo },
10306
- onOpen: openPanel
11838
+ onOpen: openPanel,
11839
+ onOpenUpdate: () => {
11840
+ openPanel();
11841
+ updateDialog.open();
11842
+ }
10307
11843
  });
10308
11844
  }));
10309
11845
  ctx.slots.inject("main", () => ctx.slots.register({
@@ -10313,7 +11849,10 @@ body[data-ds-dark-theme] .crwu-audit-root {
10313
11849
  services,
10314
11850
  envStatus,
10315
11851
  build: buildStore,
10316
- modules
11852
+ modules,
11853
+ update: updateStore,
11854
+ updateDialog,
11855
+ platform
10317
11856
  })));
10318
11857
  ctx.slots.inject("tool.view.cordis", () => ctx.slots.register({
10319
11858
  name: "tool.view.cordis",