@huaqiu/dsh-tool-plugin-update 0.5.22 → 0.6.2

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.
@@ -21,9 +21,14 @@ import {
21
21
  checkUpdates,
22
22
  ignoreOffer,
23
23
  installSet,
24
+ requestRuntimeUpdate,
25
+ runtimeLatestVersion,
26
+ setPluginUpdateSource,
27
+ setRuntimeUpdateSource,
24
28
  unignoreOffer,
25
29
  type PluginUpdateItem,
26
30
  type PluginUpdateReport,
31
+ type RuntimeUpdateSource,
27
32
  } from './api.js'
28
33
  import type { UpdateCopy } from './copy.js'
29
34
  import {
@@ -262,8 +267,261 @@ function skeleton(copy: UpdateCopy): HTMLElement {
262
267
  return section
263
268
  }
264
269
 
270
+ /** Mutable editor state for one manifest source (lives in the panel closure). */
271
+ interface SourceState {
272
+ editing: boolean
273
+ saving: boolean
274
+ draft: string
275
+ }
276
+
277
+ /**
278
+ * Shared state + callbacks for one update-source editor.
279
+ *
280
+ * hq-edge serves both manifest sources from one shape (plugin release
281
+ * manifest, runtime-update manifest), and both are editable from this panel:
282
+ * an override is persisted per host, so pointing discovery at a staging
283
+ * channel (or a local test rig) needs no env var and no restart. The editor
284
+ * state lives in the panel closure for the same reason `armed` does — a store
285
+ * refresh must not discard a half-typed URL or silently close the editor.
286
+ */
287
+ export interface SourceEditorActions {
288
+ /** The source editor is open (input shown instead of the read-only row). */
289
+ sourceEditing: boolean
290
+ /** A save is in flight. */
291
+ sourceSaving: boolean
292
+ /** The URL being edited (what the input shows). */
293
+ sourceDraft: string
294
+ onSourceEdit(): void
295
+ onSourceDraft(value: string): void
296
+ onSourceSave(): void
297
+ onSourceCancel(): void
298
+ /** Clear the user override, falling back to the configured URL. */
299
+ onSourceReset(): void
300
+ }
301
+
302
+ /**
303
+ * The runtime-update action row's state and callbacks.
304
+ *
305
+ * Installing the runtime is destructive in a way a plugin install is not: hq-edge
306
+ * shuts itself down so the EDA host can swap the runtime directory. So the row
307
+ * is a two-step confirm (arm → confirm), never a single fire-and-forget click.
308
+ */
309
+ export interface RuntimeActions extends SourceEditorActions {
310
+ /** A request is in flight (hq-edge accepted it and is restarting). */
311
+ busy: boolean
312
+ /** The confirm step is armed for the version the row names. */
313
+ armed: boolean
314
+ /**
315
+ * hq-edge answered `202 Accepted`: from here on the outcome belongs to the
316
+ * EDA host, and this process is about to be replaced. Terminal — the row
317
+ * must never re-offer the button once this is true, because hq-edge is
318
+ * already gone and a second request could only fail.
319
+ */
320
+ accepted: boolean
321
+ onArm(): void
322
+ onConfirm(): void
323
+ onCancel(): void
324
+ }
325
+
326
+ /**
327
+ * Normalize the report's `runtime.source` to the full shape.
328
+ *
329
+ * An older hq-edge predates the feature and sends no `source` at all — that
330
+ * must degrade to a read-only panel, not crash the render. A future server
331
+ * may add fields; unknown extras are dropped here rather than trusted.
332
+ */
333
+ function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
334
+ if (raw === null || typeof raw !== 'object') return null
335
+ const candidate = raw as Partial<RuntimeUpdateSource>
336
+ if (typeof candidate.origin !== 'string') return null
337
+ return {
338
+ url: typeof candidate.url === 'string' ? candidate.url : '',
339
+ origin: candidate.origin as RuntimeUpdateSource['origin'],
340
+ configuredUrl: typeof candidate.configuredUrl === 'string' ? candidate.configuredUrl : '',
341
+ override: candidate.override === true,
342
+ }
343
+ }
344
+
345
+ /** The tag naming where the URL in effect came from. */
346
+ function originLabel(origin: RuntimeUpdateSource['origin'], copy: UpdateCopy): string {
347
+ switch (origin) {
348
+ case 'override':
349
+ return copy.originOverride
350
+ case 'config':
351
+ return copy.originConfig
352
+ default:
353
+ return copy.originUnset
354
+ }
355
+ }
356
+
357
+ /** The 更新源 fact row: the URL in effect plus an origin tag. */
358
+ function sourceFactRow(
359
+ facts: HTMLElement,
360
+ source: RuntimeUpdateSource,
361
+ copy: UpdateCopy,
362
+ ): void {
363
+ const wrap = el('div')
364
+ wrap.append(el('dt', '', copy.runtime.source))
365
+ const dd = el('dd')
366
+ const line = el('div', 'hqpu-source-line')
367
+ const code = el(
368
+ 'code',
369
+ 'hqpu-source-url',
370
+ source.url.length > 0 ? source.url : copy.runtime.notConfigured,
371
+ )
372
+ const tag = el('span', 'hqpu-tag')
373
+ tag.dataset.tone = source.override ? 'info' : source.origin === 'config' ? 'success' : 'neutral'
374
+ tag.textContent = originLabel(source.origin, copy)
375
+ line.append(code, tag)
376
+ dd.append(line)
377
+ wrap.append(dd)
378
+ facts.append(wrap)
379
+ }
380
+
381
+ /** Which manifest an editor belongs to — drives data attributes + CSS hook. */
382
+ export type SourceKind = 'runtime' | 'plugin'
383
+
384
+ /** The localized strings one source editor needs (both kinds share the flow). */
385
+ interface SourceEditorLabels {
386
+ hint: string
387
+ edit: string
388
+ save: string
389
+ reset: string
390
+ inputLabel: string
391
+ placeholder: string
392
+ }
393
+
394
+ /** Pick the labels for one kind out of the copy table. */
395
+ function sourceLabels(copy: UpdateCopy, kind: SourceKind): SourceEditorLabels {
396
+ return kind === 'runtime'
397
+ ? {
398
+ hint: copy.runtime.sourceHint,
399
+ edit: copy.runtime.sourceEdit,
400
+ save: copy.runtime.sourceSave,
401
+ reset: copy.runtime.sourceReset,
402
+ inputLabel: copy.runtime.sourceInputLabel,
403
+ placeholder: copy.runtime.sourcePlaceholder,
404
+ }
405
+ : {
406
+ hint: copy.pluginSource.hint,
407
+ edit: copy.pluginSource.edit,
408
+ save: copy.pluginSource.save,
409
+ reset: copy.pluginSource.reset,
410
+ inputLabel: copy.pluginSource.inputLabel,
411
+ placeholder: copy.pluginSource.placeholder,
412
+ }
413
+ }
414
+
415
+ /**
416
+ * One update-source editor, shared by the plugin manifest and the runtime
417
+ * manifest (the only differences are the labels and the data-attribute
418
+ * prefix, so tests and CSS can target each).
419
+ *
420
+ * Read mode is a hint plus a 修改 button; edit mode is the URL field with
421
+ * 保存 / 取消 — plus 恢复默认 while a user override shadows the configured
422
+ * URL. The draft lives in {@link SourceEditorActions} (the panel closure), so
423
+ * a background store refresh re-renders the typed URL instead of discarding it.
424
+ */
425
+ function sourceEditor(
426
+ kind: SourceKind,
427
+ source: RuntimeUpdateSource,
428
+ copy: UpdateCopy,
429
+ actions: SourceEditorActions,
430
+ ): HTMLElement {
431
+ const labels = sourceLabels(copy, kind)
432
+ const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
433
+ if (!actions.sourceEditing) {
434
+ block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
435
+ const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
436
+ edit.type = 'button'
437
+ edit.setAttribute(`data-${kind}-source-edit`, '')
438
+ edit.disabled = actions.sourceSaving
439
+ edit.addEventListener('click', actions.onSourceEdit)
440
+ block.append(edit)
441
+ return block
442
+ }
443
+ const input = el('input', 'hqpu-source-input')
444
+ input.type = 'url'
445
+ input.value = actions.sourceDraft
446
+ input.placeholder = labels.placeholder
447
+ input.setAttribute('aria-label', labels.inputLabel)
448
+ input.setAttribute(`data-${kind}-source-input`, '')
449
+ input.disabled = actions.sourceSaving
450
+ input.addEventListener('input', () => {
451
+ // Draft-only: no re-render, so the caret and IME stay untouched.
452
+ actions.onSourceDraft(input.value)
453
+ })
454
+ // Enter saves, Escape cancels — the field is the whole editor, and Escape
455
+ // must not fall through to the panel-wide close handler.
456
+ input.addEventListener('keydown', (ev) => {
457
+ if (ev.key === 'Enter') {
458
+ ev.preventDefault()
459
+ actions.onSourceSave()
460
+ } else if (ev.key === 'Escape') {
461
+ ev.preventDefault()
462
+ ev.stopPropagation()
463
+ actions.onSourceCancel()
464
+ }
465
+ })
466
+ const save = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', labels.save)
467
+ save.type = 'button'
468
+ save.setAttribute(`data-${kind}-source-save`, '')
469
+ save.disabled = actions.sourceSaving
470
+ save.addEventListener('click', actions.onSourceSave)
471
+ const cancel = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
472
+ cancel.type = 'button'
473
+ cancel.setAttribute(`data-${kind}-source-cancel`, '')
474
+ cancel.disabled = actions.sourceSaving
475
+ cancel.addEventListener('click', actions.onSourceCancel)
476
+ const controls = el('div', 'hqpu-actions hqpu-source-controls')
477
+ controls.append(input, save, cancel)
478
+ if (source.override) {
479
+ const reset = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', labels.reset)
480
+ reset.type = 'button'
481
+ reset.setAttribute(`data-${kind}-source-reset`, '')
482
+ reset.disabled = actions.sourceSaving
483
+ reset.addEventListener('click', actions.onSourceReset)
484
+ controls.append(reset)
485
+ }
486
+ block.append(controls)
487
+ // Focus on open — and on any re-render while editing, which is exactly what
488
+ // a rejected save wants (the user is dropped back into the field to fix it).
489
+ input.focus()
490
+ return block
491
+ }
492
+
493
+ /**
494
+ * The plugin release-manifest source, as its own group above the plugin
495
+ * groups: it says where the whole list comes from, so it belongs above the
496
+ * list rather than inside it. Absent on an older hq-edge (no `pluginSource`
497
+ * in the report) — then nothing is rendered at all.
498
+ */
499
+ function pluginSourceGroup(
500
+ report: PluginUpdateReport,
501
+ copy: UpdateCopy,
502
+ actions: SourceEditorActions,
503
+ ): HTMLElement | null {
504
+ const source = normalizeSource(report.pluginSource)
505
+ if (source === null) return null
506
+ const section = el('section', 'hqpu-group')
507
+ section.dataset.group = 'plugin-source'
508
+ const head = el('div', 'hqpu-group-head')
509
+ head.append(el('h3', 'hqpu-group-title', copy.pluginSource.title))
510
+ section.append(head)
511
+
512
+ const facts = el('dl', 'hqpu-facts')
513
+ sourceFactRow(facts, source, copy)
514
+ section.append(facts)
515
+ section.append(sourceEditor('plugin', source, copy, actions))
516
+ return section
517
+ }
518
+
265
519
  /** The independent HQ Edge runtime report, as ruled fact rows. */
266
- function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement {
520
+ function runtimeGroup(
521
+ report: PluginUpdateReport,
522
+ copy: UpdateCopy,
523
+ actions: RuntimeActions,
524
+ ): HTMLElement {
267
525
  const section = el('section', 'hqpu-group')
268
526
  section.dataset.group = 'runtime'
269
527
  const head = el('div', 'hqpu-group-head')
@@ -272,6 +530,9 @@ function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement
272
530
 
273
531
  const facts = el('dl', 'hqpu-facts')
274
532
  const runtime = report.runtime
533
+ // `runtime.latest` is a release ENTRY; only its version is ever displayed or
534
+ // sent back as `targetVersion`.
535
+ const latestVersion = runtimeLatestVersion(runtime)
275
536
 
276
537
  const row = (label: string, value: string, code = false): void => {
277
538
  const wrap = el('div')
@@ -287,12 +548,20 @@ function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement
287
548
  facts.append(wrap)
288
549
  }
289
550
 
551
+ // The source row: which manifest URL is in effect and where it came from.
552
+ // Absent on an older hq-edge — then only the legacy notConfigured row shows.
553
+ const source = normalizeSource(runtime.source)
554
+
290
555
  if (runtime.configured === false) {
291
- row(copy.runtime.source, copy.runtime.notConfigured)
556
+ if (source !== null) {
557
+ sourceFactRow(facts, source, copy)
558
+ } else {
559
+ row(copy.runtime.source, copy.runtime.notConfigured)
560
+ }
292
561
  } else {
293
562
  row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
294
- if (runtime.updateAvailable && runtime.latest != null) {
295
- row(copy.runtime.latest, runtime.latest)
563
+ if (runtime.updateAvailable && latestVersion !== null) {
564
+ row(copy.runtime.latest, latestVersion)
296
565
  }
297
566
  if (report.manifestVersion !== null) {
298
567
  row(copy.runtime.set, report.manifestVersion, true)
@@ -300,10 +569,62 @@ function runtimeGroup(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement
300
569
  if (typeof runtime.checkedAt === 'string' && runtime.checkedAt.length > 0) {
301
570
  row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString())
302
571
  }
572
+ if (source !== null) {
573
+ sourceFactRow(facts, source, copy)
574
+ }
303
575
  }
304
576
  section.append(facts)
305
577
 
306
- section.append(el('p', 'hqpu-status', copy.runtime.note))
578
+ // The editor: point the runtime at another release channel (a staging
579
+ // manifest, a local rig) without an env var and a restart. hq-edge persists
580
+ // the override per host, so it survives restarts of both processes.
581
+ if (source !== null) {
582
+ section.append(sourceEditor('runtime', source, copy, actions))
583
+ }
584
+
585
+ // ── Runtime update action row ──
586
+ // Only offered when hq-edge reports a newer distribution AND named its
587
+ // version: the request needs `targetVersion`, so an entry without one has
588
+ // nothing to send.
589
+ if (runtime.updateAvailable && latestVersion !== null) {
590
+ const row = el('div', 'hqpu-actions hqpu-runtime-actions')
591
+ if (actions.accepted) {
592
+ // Terminal state: no button. hq-edge has already exited, so re-offering
593
+ // "update" here could only produce a transport error.
594
+ const line = el('span', 'hqpu-status hqpu-status--dot')
595
+ line.setAttribute('role', 'status')
596
+ line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updateAccepted))
597
+ row.append(line)
598
+ } else if (actions.busy) {
599
+ // Downloading + verifying + staging the bundle: tens of seconds to a few
600
+ // minutes. Say what is happening instead of an open-ended spinner.
601
+ const line = el('span', 'hqpu-status hqpu-status--dot')
602
+ line.setAttribute('role', 'status')
603
+ line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updating))
604
+ row.append(line)
605
+ } else if (actions.armed) {
606
+ row.append(el('p', 'hqpu-status hqpu-runtime-confirm', copy.runtime.updateConfirm(latestVersion)))
607
+ const yes = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.confirmInstall)
608
+ yes.type = 'button'
609
+ yes.dataset.runtimeConfirm = ''
610
+ yes.addEventListener('click', actions.onConfirm)
611
+ const no = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
612
+ no.type = 'button'
613
+ no.addEventListener('click', actions.onCancel)
614
+ row.append(yes, no)
615
+ } else {
616
+ const start = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm')
617
+ start.type = 'button'
618
+ start.dataset.runtimeUpdate = ''
619
+ start.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.runtime.update}</span>`
620
+ start.addEventListener('click', actions.onArm)
621
+ row.append(start)
622
+ }
623
+ section.append(row)
624
+ } else {
625
+ section.append(el('p', 'hqpu-status', copy.runtime.note))
626
+ }
627
+
307
628
  if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
308
629
  const line = el('p', 'hqpu-status hqpu-status--dot')
309
630
  line.setAttribute('role', 'status')
@@ -353,10 +674,24 @@ function renderRuntime(
353
674
  section: HTMLElement,
354
675
  report: PluginUpdateReport | null,
355
676
  copy: UpdateCopy,
677
+ actions: RuntimeActions,
356
678
  ): void {
357
679
  section.textContent = ''
358
680
  if (report === null) return
359
- section.append(runtimeGroup(report, copy))
681
+ section.append(runtimeGroup(report, copy, actions))
682
+ }
683
+
684
+ /** The plugin-source group, above the plugin groups. No-op without the field. */
685
+ function renderPluginSource(
686
+ section: HTMLElement,
687
+ report: PluginUpdateReport | null,
688
+ copy: UpdateCopy,
689
+ actions: SourceEditorActions,
690
+ ): void {
691
+ section.textContent = ''
692
+ if (report === null) return
693
+ const node = pluginSourceGroup(report, copy, actions)
694
+ if (node !== null) section.append(node)
360
695
  }
361
696
 
362
697
  /** Run the install transaction, then keep polling until the state settles. */
@@ -548,10 +883,11 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
548
883
  // ── Scrolling body: the centred column ──
549
884
  const scroll = el('div', 'hqpu-scroll')
550
885
  const column = el('div', 'hqpu-column')
886
+ const pluginSourceSection = el('div', 'hqpu-stack')
551
887
  const listSection = el('div', 'hqpu-stack')
552
888
  const runtimeSection = el('div', 'hqpu-stack')
553
889
  const toastSection = el('div', 'hqpu-toast-host')
554
- column.append(listSection, runtimeSection, toastSection)
890
+ column.append(pluginSourceSection, listSection, runtimeSection, toastSection)
555
891
  scroll.append(column)
556
892
  panel.append(scroll)
557
893
 
@@ -568,6 +904,174 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
568
904
 
569
905
  const disposers: Array<() => void> = []
570
906
 
907
+ // ── Runtime update: arm → confirm, then hand off to hq-edge ──
908
+ // Live in the panel's closure (not the report) because they are UI state, and
909
+ // a store refresh must not silently disarm a confirm the user just armed.
910
+ let runtimeArmed = false
911
+ let runtimeBusy = false
912
+ let runtimeAccepted = false
913
+
914
+ // Source-editor state, same reasoning: a background refresh must not discard
915
+ // a half-typed URL or silently close either editor.
916
+ const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
917
+ const pluginSourceState: SourceState = { editing: false, saving: false, draft: '' }
918
+
919
+ // Delegated, not spread: `...sourceActionsFor(...)` would evaluate the
920
+ // getters once and freeze `sourceEditing: false` — the editor would never
921
+ // open. Read every member through the live object instead.
922
+ const runtimeSourceActions = sourceActionsFor('runtime', runtimeSourceState)
923
+ const runtimeActions: RuntimeActions = {
924
+ get busy() {
925
+ return runtimeBusy
926
+ },
927
+ get armed() {
928
+ return runtimeArmed
929
+ },
930
+ get accepted() {
931
+ return runtimeAccepted
932
+ },
933
+ onArm: () => {
934
+ runtimeArmed = true
935
+ render()
936
+ },
937
+ onCancel: () => {
938
+ runtimeArmed = false
939
+ render()
940
+ },
941
+ onConfirm: () => {
942
+ void requestRuntime()
943
+ },
944
+ get sourceEditing() {
945
+ return runtimeSourceActions.sourceEditing
946
+ },
947
+ get sourceSaving() {
948
+ return runtimeSourceActions.sourceSaving
949
+ },
950
+ get sourceDraft() {
951
+ return runtimeSourceActions.sourceDraft
952
+ },
953
+ onSourceEdit: () => runtimeSourceActions.onSourceEdit(),
954
+ onSourceDraft: (value: string) => runtimeSourceActions.onSourceDraft(value),
955
+ onSourceSave: () => runtimeSourceActions.onSourceSave(),
956
+ onSourceCancel: () => runtimeSourceActions.onSourceCancel(),
957
+ onSourceReset: () => runtimeSourceActions.onSourceReset(),
958
+ }
959
+
960
+ const pluginActions: SourceEditorActions = sourceActionsFor('plugin', pluginSourceState)
961
+
962
+ /** Build the {@link SourceEditorActions} half for one manifest source. */
963
+ function sourceActionsFor(
964
+ kind: SourceKind,
965
+ state: SourceState,
966
+ ): SourceEditorActions {
967
+ return {
968
+ get sourceEditing() {
969
+ return state.editing
970
+ },
971
+ get sourceSaving() {
972
+ return state.saving
973
+ },
974
+ get sourceDraft() {
975
+ return state.draft
976
+ },
977
+ onSourceEdit: () => {
978
+ // Prefill with the URL in effect, so 保存 is a no-op unless edited.
979
+ const report = store.report()
980
+ const source =
981
+ kind === 'runtime' ? normalizeSource(report?.runtime.source) : normalizeSource(report?.pluginSource)
982
+ state.draft = source?.url ?? ''
983
+ state.editing = true
984
+ render()
985
+ },
986
+ onSourceDraft: (value: string) => {
987
+ state.draft = value
988
+ },
989
+ onSourceSave: () => {
990
+ void submitSource(kind, state)
991
+ },
992
+ onSourceCancel: () => {
993
+ state.editing = false
994
+ render()
995
+ },
996
+ onSourceReset: () => {
997
+ void submitSource(kind, state, '')
998
+ },
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Save one manifest source — or clear the override with an empty URL —
1004
+ * then re-run discovery.
1005
+ *
1006
+ * The re-check is not optional: the report is cached, so without a fresh
1007
+ * `POST /check` the facts and the update button would keep naming the OLD
1008
+ * manifest's offer after the source changed. hq-edge answers 422 with a
1009
+ * message meant for a person on refusal (bad scheme, credentials, …), which
1010
+ * the toast shows verbatim.
1011
+ */
1012
+ async function submitSource(
1013
+ kind: SourceKind,
1014
+ state: SourceState,
1015
+ url: string = state.draft,
1016
+ ): Promise<void> {
1017
+ state.saving = true
1018
+ render()
1019
+ const labels = kind === 'runtime' ? copy.runtime : copy.pluginSource
1020
+ try {
1021
+ if (kind === 'runtime') await setRuntimeUpdateSource(url)
1022
+ else await setPluginUpdateSource(url)
1023
+ state.editing = false
1024
+ showToast(labels.saved, 'success')
1025
+ try {
1026
+ await checkUpdates()
1027
+ await store.refresh()
1028
+ } catch {
1029
+ // The source IS saved; a failed re-check only leaves the previous
1030
+ // offer on screen until the next refresh — the toolbar re-runs it.
1031
+ }
1032
+ } catch (err: unknown) {
1033
+ showToast(
1034
+ `${labels.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
1035
+ 'error',
1036
+ )
1037
+ } finally {
1038
+ state.saving = false
1039
+ render()
1040
+ }
1041
+ }
1042
+
1043
+ async function requestRuntime(): Promise<void> {
1044
+ const version = runtimeLatestVersion(store.report()?.runtime)
1045
+ if (version === null) {
1046
+ showToast(copy.checkFailed, 'error')
1047
+ return
1048
+ }
1049
+ runtimeArmed = false
1050
+ runtimeBusy = true
1051
+ render()
1052
+ try {
1053
+ const result = await requestRuntimeUpdate(version)
1054
+ if (result?.error !== undefined) {
1055
+ showToast(`${copy.installError}: ${result.error}`, 'error')
1056
+ return
1057
+ }
1058
+ // 202 Accepted: hq-edge exits to let the EDA host swap the runtime and
1059
+ // relaunch. This panel is about to go away with the restart, so say so —
1060
+ // and latch it, so a store refresh cannot re-offer the button for a
1061
+ // server that is already gone.
1062
+ runtimeAccepted = true
1063
+ showToast(copy.runtime.updateAccepted, 'success')
1064
+ } catch (err: unknown) {
1065
+ showToast(
1066
+ `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
1067
+ 'error',
1068
+ )
1069
+ } finally {
1070
+ runtimeBusy = false
1071
+ render()
1072
+ }
1073
+ }
1074
+
571
1075
  const render = (): void => {
572
1076
  const report = store.report()
573
1077
  const installing = store.installing()
@@ -579,7 +1083,8 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
579
1083
  } else {
580
1084
  renderList(listSection, report, copy, installing, store)
581
1085
  }
582
- renderRuntime(runtimeSection, report, copy)
1086
+ renderPluginSource(pluginSourceSection, report, copy, pluginActions)
1087
+ renderRuntime(runtimeSection, report, copy, runtimeActions)
583
1088
  renderActions(actionBar, report, copy, store, installing)
584
1089
  }
585
1090