@huaqiu/dsh-tool-plugin-update 0.6.6 → 0.6.7

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/index.mjs CHANGED
@@ -65,6 +65,18 @@ function readBody(req) {
65
65
  * forwarder answers 405 rather than forwarding a GET to a POST-only upstream
66
66
  * and surfacing hq-edge's 404 as a plugin bug.
67
67
  */
68
+ /**
69
+ * Match a dynamic `/status/<jobId>` sub-path. Returns the job id for a single
70
+ * non-empty segment (no nested slashes), else null. The id is opaque — this
71
+ * route forwards it verbatim; hq-edge owns validation and the 404 for unknown
72
+ * ids.
73
+ */
74
+ function matchJobStatusPath(sub) {
75
+ if (!sub.startsWith("/status/")) return null;
76
+ const id = sub.slice(8);
77
+ if (id.length === 0 || id.includes("/") || id.includes("?")) return null;
78
+ return id;
79
+ }
68
80
  function routeFor(sub, method) {
69
81
  if (sub === "/runtime-source") {
70
82
  if (method === "GET") return {
@@ -92,6 +104,15 @@ function routeFor(sub, method) {
92
104
  method: "GET",
93
105
  path: "/api/v1/plugin-updates"
94
106
  } : null;
107
+ if (sub === "/status") return method === "GET" ? {
108
+ method: "GET",
109
+ path: "/api/v1/plugin-updates/status"
110
+ } : null;
111
+ const jobId = matchJobStatusPath(sub);
112
+ if (jobId !== null) return method === "GET" ? {
113
+ method: "GET",
114
+ path: `/api/v1/plugin-updates/status/${encodeURIComponent(jobId)}`
115
+ } : null;
95
116
  if (sub === "/runtime") return method === "GET" ? {
96
117
  method: "GET",
97
118
  path: "/api/v1/runtime-update"
@@ -119,8 +140,9 @@ function routeFor(sub, method) {
119
140
  } : null;
120
141
  return null;
121
142
  }
122
- /** Every sub-path this forwarder serves — used to tell a 404 from a 405. */
123
- const ROUTE_SUB_PATHS = /* @__PURE__ */ new Set([
143
+ /** Every static sub-path this forwarder serves — used to tell a 404 from a
144
+ * 405. The dynamic `/status/<id>` path is matched separately. */
145
+ const STATIC_SUB_PATHS = /* @__PURE__ */ new Set([
124
146
  "",
125
147
  "/report",
126
148
  "/check",
@@ -130,8 +152,17 @@ const ROUTE_SUB_PATHS = /* @__PURE__ */ new Set([
130
152
  "/runtime",
131
153
  "/runtime-request",
132
154
  "/runtime-source",
133
- "/plugin-source"
155
+ "/plugin-source",
156
+ "/status"
134
157
  ]);
158
+ /**
159
+ * Whether a sub-path names a route this forwarder recognizes at all. A
160
+ * recognized sub-path hit with an unsupported method is a 405; an unrecognized
161
+ * one is a 404. `/status/<id>` is recognized for any single-segment id.
162
+ */
163
+ function isKnownSubPath(sub) {
164
+ return STATIC_SUB_PATHS.has(sub) || matchJobStatusPath(sub) !== null;
165
+ }
135
166
  /** Create the prefix-route handler forwarding to hq-edge. */
136
167
  function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIMEOUT_MS) {
137
168
  return async (req, res) => {
@@ -142,7 +173,8 @@ function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIME
142
173
  }
143
174
  const target = routeFor(sub, req.method === "HEAD" ? "GET" : req.method ?? "");
144
175
  if (target === null) {
145
- sendJson(res, sub === "" || ROUTE_SUB_PATHS.has(sub) ? 405 : 404, { error: sub === "" || ROUTE_SUB_PATHS.has(sub) ? `method ${req.method} not allowed on ${sub || "/"}` : "not found" });
176
+ const known = isKnownSubPath(sub);
177
+ sendJson(res, known ? 405 : 404, { error: known ? `method ${req.method} not allowed on ${sub || "/"}` : "not found" });
146
178
  return;
147
179
  }
148
180
  if (req.method !== "GET" && req.method !== "POST" && req.method !== "HEAD") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-tool-plugin-update",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -31,8 +31,8 @@
31
31
  "@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.2"
32
32
  },
33
33
  "dependencies": {
34
- "@huaqiu/dsh-plugin-log": "0.6.6",
35
- "@huaqiu/dsh-sidebar-entry": "0.6.6"
34
+ "@huaqiu/dsh-sidebar-entry": "0.6.7",
35
+ "@huaqiu/dsh-plugin-log": "0.6.7"
36
36
  },
37
37
  "files": [
38
38
  "lib",
package/src/client/api.ts CHANGED
@@ -29,6 +29,15 @@ export interface PluginUpdateItem {
29
29
  ignored?: boolean
30
30
  reasons?: string[]
31
31
  notes?: string[]
32
+ /**
33
+ * User-facing release metadata (schemaVersion-2 manifests). ALL optional and
34
+ * absent for v1 manifests — the view falls back to the package short name
35
+ * and version line when they are missing.
36
+ */
37
+ displayName?: string
38
+ description?: string
39
+ sizeBytes?: number
40
+ changes?: string[]
32
41
  }
33
42
 
34
43
  /**
@@ -49,6 +58,33 @@ export interface RuntimeUpdateEntry {
49
58
  arch?: string
50
59
  /** Exact absolute HTTPS URL of the release archive. */
51
60
  url: string
61
+ /**
62
+ * Optional user-facing metadata (schemaVersion-2 runtime manifest). All
63
+ * absent for v1 manifests; the panel degrades gracefully without them.
64
+ */
65
+ publishedAt?: string
66
+ sizeBytes?: number
67
+ summary?: string
68
+ notesUrl?: string
69
+ notes?: string[]
70
+ breaking?: string[]
71
+ channel?: string
72
+ }
73
+
74
+ /**
75
+ * The EDA host's own report of the last runtime replacement, read from
76
+ * `runtime-update-result.json` by hq-edge. This is the authoritative outcome
77
+ * after HQ Edge exits and the host swaps the runtime — it must be rendered,
78
+ * not discarded. `state` is `succeeded`, `rolled_back` (swap failed but the
79
+ * previous runtime was restored) or `failed` (no confirmed rollback).
80
+ */
81
+ export interface RuntimeLastResult {
82
+ state: 'succeeded' | 'rolled_back' | 'failed'
83
+ targetVersion?: string | null
84
+ installedVersion?: string | null
85
+ at?: string
86
+ errorCode?: string
87
+ message?: string
52
88
  }
53
89
 
54
90
  export interface RuntimeUpdateInfo {
@@ -59,6 +95,8 @@ export interface RuntimeUpdateInfo {
59
95
  updateAvailable: boolean
60
96
  checkedAt?: string | null
61
97
  lastError?: string | null
98
+ /** The host-reported outcome of the last runtime swap; null when none. */
99
+ lastResult?: RuntimeLastResult | null
62
100
  /**
63
101
  * Where the runtime-update manifest is read from. Optional: an older hq-edge
64
102
  * does not send it, and the panel must degrade to a read-only source row.
@@ -110,6 +148,21 @@ export function runtimeLatestVersion(
110
148
  return null
111
149
  }
112
150
 
151
+ /**
152
+ * Set-level release metadata from a schemaVersion-2 plugin manifest. ALL
153
+ * fields optional: v1 manifests send none, and the panel must fall back to
154
+ * showing just the version then.
155
+ */
156
+ export interface ReleaseInfo {
157
+ publishedAt?: string
158
+ channel?: string
159
+ requiresRestart?: boolean
160
+ summary?: string
161
+ notesUrl?: string
162
+ notes?: string[]
163
+ breaking?: string[]
164
+ }
165
+
113
166
  export interface PluginUpdateReport {
114
167
  /** The evaluated release set; null while no manifest has been read. */
115
168
  manifestVersion: string | null
@@ -118,7 +171,13 @@ export interface PluginUpdateReport {
118
171
  actionableCount: number
119
172
  ignoredSet: boolean
120
173
  fetchedAt?: string
174
+ /** When the last plugin discovery ran (ISO); drives the "上次检查" row. */
175
+ checkedAt?: string | null
176
+ /** Non-empty when the last plugin discovery FAILED — the panel must show
177
+ * an error row with a Retry, never the silent "no updates" empty state. */
121
178
  lastError?: string | null
179
+ /** Set-level release metadata; absent for v1 manifests. */
180
+ release?: ReleaseInfo | null
122
181
  runtime: RuntimeUpdateInfo
123
182
  /**
124
183
  * Where the plugin release manifest is read from. Optional: an older
@@ -127,6 +186,198 @@ export interface PluginUpdateReport {
127
186
  pluginSource?: PluginUpdateSource
128
187
  }
129
188
 
189
+ // ── Durable update-job lifecycle (GET /status, GET /status/:id) ────────────
190
+
191
+ /** Lifecycle phases of one durable update job. Terminal phases end the poll. */
192
+ export type UpdatePhase =
193
+ | 'queued'
194
+ | 'resolving'
195
+ | 'downloading'
196
+ | 'verifying'
197
+ | 'staging'
198
+ | 'activating'
199
+ | 'awaiting-host'
200
+ | 'restarting'
201
+ | 'verifying-after-restart'
202
+ | 'done'
203
+ | 'failed'
204
+ | 'rolled-back'
205
+ | 'abandoned'
206
+
207
+ /** Phases after which a job never runs again — polling stops and a result view
208
+ * replaces the active-job view. */
209
+ export const TERMINAL_PHASES: ReadonlySet<UpdatePhase> = new Set<UpdatePhase>([
210
+ 'done',
211
+ 'failed',
212
+ 'rolled-back',
213
+ 'abandoned',
214
+ ])
215
+
216
+ export type UpdateKind = 'plugin-set' | 'runtime'
217
+
218
+ export interface UpdateJobProgress {
219
+ /** 0..100 when the phase has a meaningful percentage; null otherwise. */
220
+ percent: number | null
221
+ bytesDone?: number
222
+ bytesTotal?: number
223
+ bytesPerSecond?: number
224
+ etaSeconds?: number
225
+ }
226
+
227
+ export interface UpdateStep {
228
+ name: string
229
+ state: 'pending' | 'running' | 'installed' | 'skipped' | 'failed'
230
+ percent?: number
231
+ message?: string
232
+ }
233
+
234
+ export interface UpdateJobResult {
235
+ ok: boolean
236
+ installedVersion: string | null
237
+ errorCode?: string
238
+ message?: string
239
+ rolledBack: boolean
240
+ finishedAt: string
241
+ }
242
+
243
+ /**
244
+ * One durable update job, as hq-edge reports it (`GET /status` and
245
+ * `/status/:id`). The UI is a projection of this job: opening the panel
246
+ * resumes whatever the server says is active, rather than owning the
247
+ * lifecycle in a DOM closure.
248
+ */
249
+ export interface UpdateJob {
250
+ id: string
251
+ kind: UpdateKind
252
+ targetVersion: string
253
+ previousVersion: string | null
254
+ startedAt: string
255
+ updatedAt: string
256
+ phase: UpdatePhase
257
+ message?: string
258
+ progress: UpdateJobProgress
259
+ steps?: UpdateStep[]
260
+ result?: UpdateJobResult
261
+ logHint?: string
262
+ }
263
+
264
+ /** `GET /status`: the current active job plus a bounded list of recent ones. */
265
+ export interface UpdateJobStatus {
266
+ activeJob: UpdateJob | null
267
+ recentJobs: UpdateJob[]
268
+ }
269
+
270
+ const PHASE_VALUES: ReadonlySet<string> = new Set<string>([
271
+ 'queued',
272
+ 'resolving',
273
+ 'downloading',
274
+ 'verifying',
275
+ 'staging',
276
+ 'activating',
277
+ 'awaiting-host',
278
+ 'restarting',
279
+ 'verifying-after-restart',
280
+ 'done',
281
+ 'failed',
282
+ 'rolled-back',
283
+ 'abandoned',
284
+ ])
285
+
286
+ function asStringArray(value: unknown): string[] | undefined {
287
+ if (!Array.isArray(value)) return undefined
288
+ const out = value.filter((entry): entry is string => typeof entry === 'string')
289
+ return out.length > 0 ? out : undefined
290
+ }
291
+
292
+ function asFiniteNumber(value: unknown): number | undefined {
293
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
294
+ }
295
+
296
+ /** Defensively normalize one job payload from the wire into {@link UpdateJob}.
297
+ * Unknown/older servers may omit fields; a malformed `phase` falls back to
298
+ * `queued` rather than crashing the view. */
299
+ export function normalizeJob(raw: unknown): UpdateJob | null {
300
+ if (raw === null || typeof raw !== 'object') return null
301
+ const r = raw as Record<string, unknown>
302
+ if (typeof r.id !== 'string' || r.id.length === 0) return null
303
+ const phase = typeof r.phase === 'string' && PHASE_VALUES.has(r.phase)
304
+ ? (r.phase as UpdatePhase)
305
+ : 'queued'
306
+ const progressRaw = (r.progress ?? {}) as Record<string, unknown>
307
+ const percentRaw = asFiniteNumber(progressRaw.percent)
308
+ const progress: UpdateJobProgress = {
309
+ percent: percentRaw === undefined ? null : Math.min(100, Math.max(0, percentRaw)),
310
+ }
311
+ const bytesDone = asFiniteNumber(progressRaw.bytesDone)
312
+ const bytesTotal = asFiniteNumber(progressRaw.bytesTotal)
313
+ const bytesPerSecond = asFiniteNumber(progressRaw.bytesPerSecond)
314
+ const etaSeconds = asFiniteNumber(progressRaw.etaSeconds)
315
+ if (bytesDone !== undefined) progress.bytesDone = bytesDone
316
+ if (bytesTotal !== undefined) progress.bytesTotal = bytesTotal
317
+ if (bytesPerSecond !== undefined) progress.bytesPerSecond = bytesPerSecond
318
+ if (etaSeconds !== undefined) progress.etaSeconds = etaSeconds
319
+
320
+ const job: UpdateJob = {
321
+ id: r.id,
322
+ kind: r.kind === 'runtime' ? 'runtime' : 'plugin-set',
323
+ targetVersion: typeof r.targetVersion === 'string' ? r.targetVersion : '',
324
+ previousVersion:
325
+ typeof r.previousVersion === 'string' ? r.previousVersion : null,
326
+ startedAt: typeof r.startedAt === 'string' ? r.startedAt : '',
327
+ updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : '',
328
+ phase,
329
+ progress,
330
+ }
331
+ if (typeof r.message === 'string' && r.message.length > 0) job.message = r.message
332
+ if (typeof r.logHint === 'string' && r.logHint.length > 0) job.logHint = r.logHint
333
+
334
+ if (Array.isArray(r.steps)) {
335
+ const steps: UpdateStep[] = []
336
+ for (const entry of r.steps) {
337
+ if (entry === null || typeof entry !== 'object') continue
338
+ const s = entry as Record<string, unknown>
339
+ if (typeof s.name !== 'string') continue
340
+ const state =
341
+ s.state === 'running' || s.state === 'installed' || s.state === 'skipped' || s.state === 'failed'
342
+ ? s.state
343
+ : 'pending'
344
+ const step: UpdateStep = { name: s.name, state }
345
+ const pct = asFiniteNumber(s.percent)
346
+ if (pct !== undefined) step.percent = pct
347
+ if (typeof s.message === 'string' && s.message.length > 0) step.message = s.message
348
+ steps.push(step)
349
+ }
350
+ if (steps.length > 0) job.steps = steps
351
+ }
352
+
353
+ if (r.result !== null && typeof r.result === 'object') {
354
+ const res = r.result as Record<string, unknown>
355
+ const result: UpdateJobResult = {
356
+ ok: res.ok === true,
357
+ installedVersion:
358
+ typeof res.installedVersion === 'string' ? res.installedVersion : null,
359
+ rolledBack: res.rolledBack === true,
360
+ finishedAt: typeof res.finishedAt === 'string' ? res.finishedAt : '',
361
+ }
362
+ if (typeof res.errorCode === 'string' && res.errorCode.length > 0) result.errorCode = res.errorCode
363
+ if (typeof res.message === 'string' && res.message.length > 0) result.message = res.message
364
+ job.result = result
365
+ }
366
+ return job
367
+ }
368
+
369
+ /** Normalize a `GET /status` payload; an empty/absent body degrades to "no
370
+ * active job" rather than throwing. */
371
+ export function normalizeJobStatus(raw: unknown): UpdateJobStatus {
372
+ if (raw === null || typeof raw !== 'object') return { activeJob: null, recentJobs: [] }
373
+ const r = raw as Record<string, unknown>
374
+ const activeJob = normalizeJob(r.activeJob)
375
+ const recentJobs = Array.isArray(r.recentJobs)
376
+ ? r.recentJobs.map(normalizeJob).filter((j): j is UpdateJob => j !== null)
377
+ : []
378
+ return { activeJob, recentJobs }
379
+ }
380
+
130
381
  async function request<T>(path: string, init?: RequestInit): Promise<T> {
131
382
  const res = await fetch(`${UPDATE_BASE}${path}`, {
132
383
  cache: 'no-store',
@@ -153,7 +404,14 @@ export function checkUpdates(): Promise<PluginUpdateReport> {
153
404
  }
154
405
 
155
406
  export interface InstallResult {
156
- ok: boolean
407
+ /**
408
+ * New durable contract: `202 { jobId, state: 'accepted' }`. The install is
409
+ * now an async job — poll `/status/:jobId` for progress, don't read success
410
+ * off this response. Legacy boolean fields are kept for older hq-edge.
411
+ */
412
+ jobId?: string
413
+ state?: string
414
+ ok?: boolean
157
415
  installedSetVersion?: string
158
416
  installed?: number
159
417
  skipped?: number
@@ -183,6 +441,8 @@ export function ignoreOffer(setVersion: string): Promise<unknown> {
183
441
  * updating → `busy`), 422 (preflight/manifest/download refused) or 503.
184
442
  */
185
443
  export interface RuntimeUpdateRequestResult {
444
+ /** Durable job id — poll `/status/:jobId` for the accepted job's progress. */
445
+ jobId?: string
186
446
  requestId?: string
187
447
  targetVersion?: string
188
448
  state?: string
@@ -258,3 +518,23 @@ export function setPluginUpdateSource(url: string): Promise<PluginUpdateSource>
258
518
  body: JSON.stringify({ url }),
259
519
  })
260
520
  }
521
+
522
+ /**
523
+ * GET the durable job status: the currently active job (if any) plus a bounded
524
+ * list of recent jobs. This is what the panel reads BEFORE running discovery
525
+ * on open, so an in-flight install/runtime update is resumed instead of
526
+ * forgotten. An older hq-edge without the status route 404s — callers catch
527
+ * that and degrade to "no active job".
528
+ */
529
+ export function fetchJobStatus(): Promise<UpdateJobStatus> {
530
+ return request<unknown>('/status').then(normalizeJobStatus)
531
+ }
532
+
533
+ /** GET one job by id; rejects (upstream 404 `unknown job`) for an unknown id. */
534
+ export function fetchJob(id: string): Promise<UpdateJob> {
535
+ return request<unknown>(`/status/${encodeURIComponent(id)}`).then((raw) => {
536
+ const job = normalizeJob(raw)
537
+ if (job === null) throw new Error('malformed job payload')
538
+ return job
539
+ })
540
+ }
@@ -20,9 +20,16 @@ const zh = {
20
20
  loading: '正在读取插件更新…',
21
21
  empty: '没有可用的插件更新',
22
22
  checkFailed: '检查更新失败,请重试',
23
- installAll: (count: number) => `安装 ${count} 项更新`,
24
- installOne: '安装',
23
+ // The button installs the ENTIRE offered set at once (not one card) — label
24
+ // it honestly rather than per-plugin.
25
+ installAll: (count: number) => `安装整套更新(${count} 项)`,
26
+ installOne: '安装整套',
27
+ installOneTip: '将安装整套 N 项更新并自动重启 DSH,而不仅是此插件',
25
28
  installing: '正在安装…(DSH 将自动重启)',
29
+ retry: '重试',
30
+ checkedAt: (when: string) => `上次检查:${when}`,
31
+ checkFailedBanner: (message: string) => `检查更新失败:${message}`,
32
+ staleNote: '以下为上次成功检查的结果,可能已过期',
26
33
  nothingToInstall: '暂无可安装的更新',
27
34
  ignore: '忽略此版本',
28
35
  unignore: '恢复提醒',
@@ -56,11 +63,23 @@ const zh = {
56
63
  unknown: '版本未知',
57
64
  notConfigured: '未配置更新源',
58
65
  note: '运行时更新由 EDA 安装器处理',
66
+ sizeLabel: '包大小',
59
67
  update: '安装运行时更新',
60
68
  updateConfirm: (version: string) =>
61
69
  `安装 HQ Edge 运行时 ${version}?HQ Edge 会先关闭并完成替换,然后重新启动。`,
62
- updating: '正在下载并校验运行时包,可能需要一分钟…',
70
+ // No fixed "takes one minute" claim: duration depends on download speed
71
+ // and package size (the bundle is hundreds of MB).
72
+ updating: '正在下载并校验运行时包…',
73
+ durationNote: '耗时取决于下载速度与包体大小;运行时包较大,替换后 HQ Edge 会自动重启。',
63
74
  updateAccepted: '运行时更新已受理,HQ Edge 正在重启…',
75
+ size: (bytes: number) => `约 ${formatMb(bytes)}`,
76
+ lastResult: {
77
+ title: '上次运行时更新结果',
78
+ succeeded: '运行时更新成功',
79
+ failed: '运行时更新失败',
80
+ rolledBack: '运行时更新已回滚(已恢复旧版本)',
81
+ elevation: '系统级安装可能弹出 UAC 提权窗口;拒绝提权会导致替换失败并自动回滚到旧版本。',
82
+ },
64
83
  // ── Update-source editor ──
65
84
  sourceHint: '运行时更新清单的下载地址,保存后立即生效',
66
85
  sourceEdit: '修改',
@@ -92,6 +111,59 @@ const zh = {
92
111
  installSuccess: '更新已安装,正在重启 DSH…',
93
112
  confirmInstall: '确认安装',
94
113
  cancel: '取消',
114
+ // ── Durable update job (active / result / recent) ──
115
+ job: {
116
+ kindPluginSet: '插件集更新',
117
+ kindRuntime: 'HQ Edge 运行时更新',
118
+ phase: {
119
+ queued: '排队中',
120
+ resolving: '解析版本',
121
+ downloading: '下载中',
122
+ verifying: '校验完整性',
123
+ staging: '暂存文件',
124
+ activating: '启用插件集',
125
+ 'awaiting-host': '等待主机替换运行时',
126
+ restarting: '正在重启',
127
+ 'verifying-after-restart': '重启后校验',
128
+ done: '已完成',
129
+ failed: '失败',
130
+ 'rolled-back': '已回滚',
131
+ abandoned: '已中断',
132
+ } as Record<string, string>,
133
+ willRestartDsh: '完成后 DSH 将自动重启,更新本身会在后台继续。',
134
+ willSwapRuntime: 'HQ Edge 会下载完整运行时包并退出,由 EDA 主机替换后重启;系统级安装可能弹出 UAC 提权窗口。',
135
+ closeNote: '关闭此面板不会取消更新。',
136
+ bytes: (done: string, total: string) => `${done} / ${total}`,
137
+ speed: (v: string) => `${v}/s`,
138
+ eta: (s: string) => `剩余约 ${s}`,
139
+ nextDone: '正在完成,请稍候…',
140
+ // Terminal result view
141
+ resultDone: '更新已完成',
142
+ resultFailed: '更新失败',
143
+ resultRolledBack: '更新已回滚',
144
+ resultAbandoned: '更新未完成',
145
+ retry: '重新尝试(发起新的更新)',
146
+ recentTitle: '最近更新',
147
+ stepsTitle: '各组件进度',
148
+ logHint: (hint: string) => `日志标识:${hint}`,
149
+ },
150
+ // ── Release metadata ──
151
+ release: {
152
+ summaryTitle: '版本说明',
153
+ notesLink: '查看发布说明',
154
+ breakingTitle: '破坏性变更',
155
+ changesTitle: '更新内容',
156
+ advanced: '高级:更新源',
157
+ publishedAt: (when: string) => `发布于 ${when}`,
158
+ },
159
+ }
160
+
161
+ /** Compact a byte count for display: "约 12.3 MB" / "约 480 MB". */
162
+ export function formatMb(bytes: number): string {
163
+ if (!Number.isFinite(bytes) || bytes <= 0) return ''
164
+ const mb = bytes / (1024 * 1024)
165
+ if (mb >= 100) return `${Math.round(mb)} MB`
166
+ return `${mb.toFixed(1)} MB`
95
167
  }
96
168
 
97
169
  export type UpdateCopy = typeof zh
@@ -107,9 +179,14 @@ const en: UpdateCopy = {
107
179
  loading: 'Reading plugin updates…',
108
180
  empty: 'No plugin updates available',
109
181
  checkFailed: 'Failed to check for updates — please retry',
110
- installAll: (count: number) => `Install ${count} update${count === 1 ? '' : 's'}`,
111
- installOne: 'Install',
182
+ installAll: (count: number) => `Install whole set (${count})`,
183
+ installOne: 'Install set',
184
+ installOneTip: 'Installs the whole set of N updates and restarts DSH — not just this plugin',
112
185
  installing: 'Installing… (DSH will restart)',
186
+ retry: 'Retry',
187
+ checkedAt: (when: string) => `Last checked: ${when}`,
188
+ checkFailedBanner: (message: string) => `Update check failed: ${message}`,
189
+ staleNote: 'Showing the last successful check — it may be stale',
113
190
  nothingToInstall: 'Nothing to install right now',
114
191
  ignore: 'Ignore this release',
115
192
  unignore: 'Show again',
@@ -143,11 +220,21 @@ const en: UpdateCopy = {
143
220
  unknown: 'Version unknown',
144
221
  notConfigured: 'No update source configured',
145
222
  note: 'Runtime updates are handled by the EDA installer',
223
+ sizeLabel: 'Package size',
146
224
  update: 'Install runtime update',
147
225
  updateConfirm: (version: string) =>
148
226
  `Install HQ Edge runtime ${version}? HQ Edge shuts down to swap the runtime, then restarts.`,
149
- updating: 'Downloading and verifying the runtime bundle — this can take a minute…',
227
+ updating: 'Downloading and verifying the runtime bundle…',
228
+ durationNote: 'Duration depends on download speed and package size; the runtime bundle is large, and HQ Edge restarts after the swap.',
150
229
  updateAccepted: 'Runtime update accepted — HQ Edge is restarting…',
230
+ size: (bytes: number) => `~ ${formatMb(bytes)}`,
231
+ lastResult: {
232
+ title: 'Last runtime update result',
233
+ succeeded: 'Runtime update succeeded',
234
+ failed: 'Runtime update failed',
235
+ rolledBack: 'Runtime update rolled back (previous version restored)',
236
+ elevation: 'A system-wide install may show a UAC elevation prompt; declining it fails the swap and rolls back to the previous version.',
237
+ },
151
238
  // ── Update-source editor ──
152
239
  sourceHint: 'Where the runtime update manifest is fetched from; saved changes take effect immediately',
153
240
  sourceEdit: 'Edit',
@@ -180,6 +267,50 @@ const en: UpdateCopy = {
180
267
  installSuccess: 'Update installed — restarting DSH…',
181
268
  confirmInstall: 'Confirm install',
182
269
  cancel: 'Cancel',
270
+ // ── Durable update job (active / result / recent) ──
271
+ job: {
272
+ kindPluginSet: 'Plugin-set update',
273
+ kindRuntime: 'HQ Edge runtime update',
274
+ phase: {
275
+ queued: 'Queued',
276
+ resolving: 'Resolving version',
277
+ downloading: 'Downloading',
278
+ verifying: 'Verifying integrity',
279
+ staging: 'Staging files',
280
+ activating: 'Activating plugin set',
281
+ 'awaiting-host': 'Waiting for host to swap runtime',
282
+ restarting: 'Restarting',
283
+ 'verifying-after-restart': 'Verifying after restart',
284
+ done: 'Done',
285
+ failed: 'Failed',
286
+ 'rolled-back': 'Rolled back',
287
+ abandoned: 'Abandoned',
288
+ } as Record<string, string>,
289
+ willRestartDsh: 'DSH restarts automatically once this finishes; the update continues in the background.',
290
+ willSwapRuntime: 'HQ Edge downloads the full runtime bundle and exits; the EDA host swaps and relaunches it. A system-wide install may show a UAC prompt.',
291
+ closeNote: 'Closing this panel does not cancel the update.',
292
+ bytes: (done: string, total: string) => `${done} / ${total}`,
293
+ speed: (v: string) => `${v}/s`,
294
+ eta: (s: string) => `~${s} left`,
295
+ nextDone: 'Finishing — please wait…',
296
+ resultDone: 'Update completed',
297
+ resultFailed: 'Update failed',
298
+ resultRolledBack: 'Update rolled back',
299
+ resultAbandoned: 'Update did not complete',
300
+ retry: 'Retry (start a new update)',
301
+ recentTitle: 'Recent updates',
302
+ stepsTitle: 'Per-component progress',
303
+ logHint: (hint: string) => `Log id: ${hint}`,
304
+ },
305
+ // ── Release metadata ──
306
+ release: {
307
+ summaryTitle: 'Release notes',
308
+ notesLink: 'View release notes',
309
+ breakingTitle: 'Breaking changes',
310
+ changesTitle: 'What changed',
311
+ advanced: 'Advanced: update sources',
312
+ publishedAt: (when: string) => `Published ${when}`,
313
+ },
183
314
  }
184
315
 
185
316
  export const COPY = { zh, en } as const