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/CHANGELOG.md +64 -0
- package/README.en.md +45 -0
- package/README.md +51 -1
- package/lib/client.js +1549 -10
- package/lib/index.js +110 -39
- package/package.json +1 -1
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: "
|
|
2231
|
-
hostStaleHint: "
|
|
2232
|
-
hostStaleGate: "
|
|
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
|
-
|
|
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",
|