@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.
package/lib/client.js CHANGED
@@ -64,15 +64,40 @@ window.__ModuleLoader__.load({
64
64
  applyBadge
65
65
  };
66
66
  }
67
- /** Re-insert the entry after the New Session row (before the browser region). */
67
+ /**
68
+ * Re-insert the entry after the New Session row, in the family's intended
69
+ * order.
70
+ *
71
+ * The `familySelectors` array order is the single source of truth for visual
72
+ * order, so the rendered order is stable regardless of which plugin mounts
73
+ * first or of observer callback order — a re-render can never swap two rows.
74
+ * (Earlier builds used the `position: 'before' | 'after'` convention, which
75
+ * clustered rows by mount order and let a late-mounting `before` row steal the
76
+ * lead. The array order supersedes it; `position` remains only as a fallback
77
+ * for a row whose selector is absent from the family list.)
78
+ */
68
79
  function placeEntry(root, entry, options) {
69
80
  const button = newSessionButton(root);
70
81
  if (button === void 0) return false;
71
82
  if (entry.parentElement !== root) {
72
83
  const row = button.closest("[class*=\"logoRow\"]");
73
84
  const base = row !== null && row.parentElement === root ? row : button;
74
- const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(options.familySelectors.join(", ")));
75
- const anchor = options.position === "before" ? family.length > 0 ? family[0] : base.nextElementSibling : family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
85
+ const order = options.familySelectors;
86
+ const present = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(order.join(", "))).map((el) => ({
87
+ el,
88
+ idx: order.findIndex((sel) => el.matches(sel))
89
+ }));
90
+ const selfIndex = order.indexOf(options.rowSelector);
91
+ let anchor;
92
+ if (selfIndex === -1) anchor = options.position === "before" ? present.length > 0 ? present[0].el : base.nextElementSibling : present.length > 0 ? present[present.length - 1].el.nextElementSibling : base.nextElementSibling;
93
+ else {
94
+ const later = present.find((member) => member.idx > selfIndex);
95
+ if (later !== void 0) anchor = later.el;
96
+ else {
97
+ const earlier = [...present].reverse().find((member) => member.idx < selfIndex);
98
+ anchor = earlier !== void 0 ? earlier.el.nextElementSibling : base.nextElementSibling;
99
+ }
100
+ }
76
101
  root.insertBefore(entry, anchor ?? null);
77
102
  }
78
103
  return true;
@@ -158,10 +183,11 @@ window.__ModuleLoader__.load({
158
183
  * family row means adding its selector here (and to no plugin-local list).
159
184
  */
160
185
  const HUAQIU_SIDEBAR_ENTRY_FAMILY = [
186
+ "[data-hq-plugin-update-entry]",
161
187
  "[data-hqcg-footprint-entry]",
162
188
  "[data-hqcg-symbol-entry]",
163
189
  "[data-hqcg-schematic-entry]",
164
- "[data-hq-plugin-update-entry]"
190
+ "[data-hq-dummy-entry]"
165
191
  ];
166
192
  /**
167
193
  * Read the host UI language from `<html lang>`.
@@ -229,6 +255,22 @@ window.__ModuleLoader__.load({
229
255
  * @module
230
256
  */
231
257
  const UPDATE_BASE = "/api/v1/huaqiu/plugin-update";
258
+ /**
259
+ * The latest runtime version to display, or null when there is none.
260
+ *
261
+ * hq-edge sends the full release entry; an older or future server could send
262
+ * a bare string. Both are accepted here so the facts row can never again fall
263
+ * back to `String(object)` — i.e. "[object Object]" — in the DOM.
264
+ */
265
+ function runtimeLatestVersion(runtime) {
266
+ const latest = runtime?.latest;
267
+ if (typeof latest === "string") return latest.length > 0 ? latest : null;
268
+ if (latest !== null && typeof latest === "object" && "version" in latest) {
269
+ const version = latest.version;
270
+ if (typeof version === "string" && version.length > 0) return version;
271
+ }
272
+ return null;
273
+ }
232
274
  async function request(path, init) {
233
275
  const res = await fetch(`${UPDATE_BASE}${path}`, {
234
276
  cache: "no-store",
@@ -268,6 +310,13 @@ window.__ModuleLoader__.load({
268
310
  body: JSON.stringify({ setVersion })
269
311
  });
270
312
  }
313
+ /** POST the user-confirmed HQ Edge runtime update for one target version. */
314
+ function requestRuntimeUpdate(targetVersion) {
315
+ return request("/runtime-request", {
316
+ method: "POST",
317
+ body: JSON.stringify({ targetVersion })
318
+ });
319
+ }
271
320
  /** POST unignore for the set version (reverses an ignore). */
272
321
  function unignoreOffer(setVersion) {
273
322
  return request("/unignore", {
@@ -275,6 +324,37 @@ window.__ModuleLoader__.load({
275
324
  body: JSON.stringify({ setVersion })
276
325
  });
277
326
  }
327
+ /**
328
+ * POST a new runtime-update manifest URL, or `""` to clear a user override
329
+ * and fall back to the configured URL.
330
+ *
331
+ * hq-edge validates the URL itself (`https` required except for loopback, no
332
+ * embedded credentials) and answers 422 with a message meant for a person —
333
+ * this client surfaces it verbatim rather than pre-validating, so the rule
334
+ * lives in exactly one place. A successful answer is the NEW source; the
335
+ * caller must then re-run discovery, because the cached report still names
336
+ * the old manifest's offer.
337
+ */
338
+ function setRuntimeUpdateSource(url) {
339
+ return request("/runtime-source", {
340
+ method: "POST",
341
+ body: JSON.stringify({ url })
342
+ });
343
+ }
344
+ /**
345
+ * POST a new plugin release-manifest URL, or `""` to clear a user override.
346
+ *
347
+ * hq-edge persists the override per host AND re-runs discovery in the
348
+ * background, so the next report (and the sidebar badge) already reflect the
349
+ * new manifest. This client still re-checks explicitly after a save — the
350
+ * background pass is a safety net for other consumers, not a substitute.
351
+ */
352
+ function setPluginUpdateSource(url) {
353
+ return request("/plugin-source", {
354
+ method: "POST",
355
+ body: JSON.stringify({ url })
356
+ });
357
+ }
278
358
  const COPY = {
279
359
  zh: {
280
360
  entryLabel: "插件更新",
@@ -322,11 +402,39 @@ window.__ModuleLoader__.load({
322
402
  source: "更新源",
323
403
  unknown: "版本未知",
324
404
  notConfigured: "未配置更新源",
325
- note: "运行时更新由 EDA 安装器处理"
405
+ note: "运行时更新由 EDA 安装器处理",
406
+ update: "安装运行时更新",
407
+ updateConfirm: (version) => `安装 HQ Edge 运行时 ${version}?HQ Edge 会先关闭并完成替换,然后重新启动。`,
408
+ updating: "正在下载并校验运行时包,可能需要一分钟…",
409
+ updateAccepted: "运行时更新已受理,HQ Edge 正在重启…",
410
+ sourceHint: "运行时更新清单的下载地址,保存后立即生效",
411
+ sourceEdit: "修改",
412
+ sourceSave: "保存",
413
+ sourceReset: "恢复默认",
414
+ saved: "更新源已保存,正在重新检查更新…",
415
+ saveFailed: "保存更新源失败",
416
+ sourceInputLabel: "运行时更新清单地址",
417
+ sourcePlaceholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/edge-headless-release-manifest.json"
326
418
  },
419
+ pluginSource: {
420
+ title: "插件更新源",
421
+ hint: "插件更新清单的下载地址,保存后立即生效",
422
+ edit: "修改",
423
+ save: "保存",
424
+ reset: "恢复默认",
425
+ saved: "更新源已保存,正在重新检查更新…",
426
+ saveFailed: "保存更新源失败",
427
+ inputLabel: "插件更新清单地址",
428
+ placeholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/plugins-release-manifest.json"
429
+ },
430
+ originOverride: "自定义",
431
+ originConfig: "默认配置",
432
+ originUnset: "未配置",
327
433
  lastError: "上次检查失败",
328
434
  installError: "安装失败",
329
- installSuccess: "更新已安装,正在重启 DSH…"
435
+ installSuccess: "更新已安装,正在重启 DSH…",
436
+ confirmInstall: "确认安装",
437
+ cancel: "取消"
330
438
  },
331
439
  en: {
332
440
  entryLabel: "Plugin Updates",
@@ -374,11 +482,39 @@ window.__ModuleLoader__.load({
374
482
  source: "Update source",
375
483
  unknown: "Version unknown",
376
484
  notConfigured: "No update source configured",
377
- note: "Runtime updates are handled by the EDA installer"
485
+ note: "Runtime updates are handled by the EDA installer",
486
+ update: "Install runtime update",
487
+ updateConfirm: (version) => `Install HQ Edge runtime ${version}? HQ Edge shuts down to swap the runtime, then restarts.`,
488
+ updating: "Downloading and verifying the runtime bundle — this can take a minute…",
489
+ updateAccepted: "Runtime update accepted — HQ Edge is restarting…",
490
+ sourceHint: "Where the runtime update manifest is fetched from; saved changes take effect immediately",
491
+ sourceEdit: "Edit",
492
+ sourceSave: "Save",
493
+ sourceReset: "Reset to default",
494
+ saved: "Update source saved — re-checking for updates…",
495
+ saveFailed: "Failed to save the update source",
496
+ sourceInputLabel: "Runtime update manifest URL",
497
+ sourcePlaceholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/edge-headless-release-manifest.json"
498
+ },
499
+ pluginSource: {
500
+ title: "Plugin update source",
501
+ hint: "Where the plugin update manifest is fetched from; saved changes take effect immediately",
502
+ edit: "Edit",
503
+ save: "Save",
504
+ reset: "Reset to default",
505
+ saved: "Update source saved — re-checking for updates…",
506
+ saveFailed: "Failed to save the update source",
507
+ inputLabel: "Plugin update manifest URL",
508
+ placeholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/plugins-release-manifest.json"
378
509
  },
510
+ originOverride: "Custom",
511
+ originConfig: "Configured",
512
+ originUnset: "Not configured",
379
513
  lastError: "Last check failed",
380
514
  installError: "Install failed",
381
- installSuccess: "Update installed — restarting DSH…"
515
+ installSuccess: "Update installed — restarting DSH…",
516
+ confirmInstall: "Confirm install",
517
+ cancel: "Cancel"
382
518
  }
383
519
  };
384
520
  //#endregion
@@ -619,8 +755,154 @@ window.__ModuleLoader__.load({
619
755
  section.setAttribute("aria-label", copy.loading);
620
756
  return section;
621
757
  }
758
+ /**
759
+ * Normalize the report's `runtime.source` to the full shape.
760
+ *
761
+ * An older hq-edge predates the feature and sends no `source` at all — that
762
+ * must degrade to a read-only panel, not crash the render. A future server
763
+ * may add fields; unknown extras are dropped here rather than trusted.
764
+ */
765
+ function normalizeSource(raw) {
766
+ if (raw === null || typeof raw !== "object") return null;
767
+ const candidate = raw;
768
+ if (typeof candidate.origin !== "string") return null;
769
+ return {
770
+ url: typeof candidate.url === "string" ? candidate.url : "",
771
+ origin: candidate.origin,
772
+ configuredUrl: typeof candidate.configuredUrl === "string" ? candidate.configuredUrl : "",
773
+ override: candidate.override === true
774
+ };
775
+ }
776
+ /** The tag naming where the URL in effect came from. */
777
+ function originLabel(origin, copy) {
778
+ switch (origin) {
779
+ case "override": return copy.originOverride;
780
+ case "config": return copy.originConfig;
781
+ default: return copy.originUnset;
782
+ }
783
+ }
784
+ /** The 更新源 fact row: the URL in effect plus an origin tag. */
785
+ function sourceFactRow(facts, source, copy) {
786
+ const wrap = el("div");
787
+ wrap.append(el("dt", "", copy.runtime.source));
788
+ const dd = el("dd");
789
+ const line = el("div", "hqpu-source-line");
790
+ const code = el("code", "hqpu-source-url", source.url.length > 0 ? source.url : copy.runtime.notConfigured);
791
+ const tag = el("span", "hqpu-tag");
792
+ tag.dataset.tone = source.override ? "info" : source.origin === "config" ? "success" : "neutral";
793
+ tag.textContent = originLabel(source.origin, copy);
794
+ line.append(code, tag);
795
+ dd.append(line);
796
+ wrap.append(dd);
797
+ facts.append(wrap);
798
+ }
799
+ /** Pick the labels for one kind out of the copy table. */
800
+ function sourceLabels(copy, kind) {
801
+ return kind === "runtime" ? {
802
+ hint: copy.runtime.sourceHint,
803
+ edit: copy.runtime.sourceEdit,
804
+ save: copy.runtime.sourceSave,
805
+ reset: copy.runtime.sourceReset,
806
+ inputLabel: copy.runtime.sourceInputLabel,
807
+ placeholder: copy.runtime.sourcePlaceholder
808
+ } : {
809
+ hint: copy.pluginSource.hint,
810
+ edit: copy.pluginSource.edit,
811
+ save: copy.pluginSource.save,
812
+ reset: copy.pluginSource.reset,
813
+ inputLabel: copy.pluginSource.inputLabel,
814
+ placeholder: copy.pluginSource.placeholder
815
+ };
816
+ }
817
+ /**
818
+ * One update-source editor, shared by the plugin manifest and the runtime
819
+ * manifest (the only differences are the labels and the data-attribute
820
+ * prefix, so tests and CSS can target each).
821
+ *
822
+ * Read mode is a hint plus a 修改 button; edit mode is the URL field with
823
+ * 保存 / 取消 — plus 恢复默认 while a user override shadows the configured
824
+ * URL. The draft lives in {@link SourceEditorActions} (the panel closure), so
825
+ * a background store refresh re-renders the typed URL instead of discarding it.
826
+ */
827
+ function sourceEditor(kind, source, copy, actions) {
828
+ const labels = sourceLabels(copy, kind);
829
+ const block = el("div", `hqpu-source-editor hqpu-source-editor--${kind}`);
830
+ if (!actions.sourceEditing) {
831
+ block.append(el("p", "hqpu-status hqpu-source-hint", labels.hint));
832
+ const edit = el("button", "hqpu-btn hqpu-btn--outline hqpu-btn--sm", labels.edit);
833
+ edit.type = "button";
834
+ edit.setAttribute(`data-${kind}-source-edit`, "");
835
+ edit.disabled = actions.sourceSaving;
836
+ edit.addEventListener("click", actions.onSourceEdit);
837
+ block.append(edit);
838
+ return block;
839
+ }
840
+ const input = el("input", "hqpu-source-input");
841
+ input.type = "url";
842
+ input.value = actions.sourceDraft;
843
+ input.placeholder = labels.placeholder;
844
+ input.setAttribute("aria-label", labels.inputLabel);
845
+ input.setAttribute(`data-${kind}-source-input`, "");
846
+ input.disabled = actions.sourceSaving;
847
+ input.addEventListener("input", () => {
848
+ actions.onSourceDraft(input.value);
849
+ });
850
+ input.addEventListener("keydown", (ev) => {
851
+ if (ev.key === "Enter") {
852
+ ev.preventDefault();
853
+ actions.onSourceSave();
854
+ } else if (ev.key === "Escape") {
855
+ ev.preventDefault();
856
+ ev.stopPropagation();
857
+ actions.onSourceCancel();
858
+ }
859
+ });
860
+ const save = el("button", "hqpu-btn hqpu-btn--primary hqpu-btn--sm", labels.save);
861
+ save.type = "button";
862
+ save.setAttribute(`data-${kind}-source-save`, "");
863
+ save.disabled = actions.sourceSaving;
864
+ save.addEventListener("click", actions.onSourceSave);
865
+ const cancel = el("button", "hqpu-btn hqpu-btn--outline hqpu-btn--sm", copy.cancel);
866
+ cancel.type = "button";
867
+ cancel.setAttribute(`data-${kind}-source-cancel`, "");
868
+ cancel.disabled = actions.sourceSaving;
869
+ cancel.addEventListener("click", actions.onSourceCancel);
870
+ const controls = el("div", "hqpu-actions hqpu-source-controls");
871
+ controls.append(input, save, cancel);
872
+ if (source.override) {
873
+ const reset = el("button", "hqpu-btn hqpu-btn--ghost hqpu-btn--sm", labels.reset);
874
+ reset.type = "button";
875
+ reset.setAttribute(`data-${kind}-source-reset`, "");
876
+ reset.disabled = actions.sourceSaving;
877
+ reset.addEventListener("click", actions.onSourceReset);
878
+ controls.append(reset);
879
+ }
880
+ block.append(controls);
881
+ input.focus();
882
+ return block;
883
+ }
884
+ /**
885
+ * The plugin release-manifest source, as its own group above the plugin
886
+ * groups: it says where the whole list comes from, so it belongs above the
887
+ * list rather than inside it. Absent on an older hq-edge (no `pluginSource`
888
+ * in the report) — then nothing is rendered at all.
889
+ */
890
+ function pluginSourceGroup(report, copy, actions) {
891
+ const source = normalizeSource(report.pluginSource);
892
+ if (source === null) return null;
893
+ const section = el("section", "hqpu-group");
894
+ section.dataset.group = "plugin-source";
895
+ const head = el("div", "hqpu-group-head");
896
+ head.append(el("h3", "hqpu-group-title", copy.pluginSource.title));
897
+ section.append(head);
898
+ const facts = el("dl", "hqpu-facts");
899
+ sourceFactRow(facts, source, copy);
900
+ section.append(facts);
901
+ section.append(sourceEditor("plugin", source, copy, actions));
902
+ return section;
903
+ }
622
904
  /** The independent HQ Edge runtime report, as ruled fact rows. */
623
- function runtimeGroup(report, copy) {
905
+ function runtimeGroup(report, copy, actions) {
624
906
  const section = el("section", "hqpu-group");
625
907
  section.dataset.group = "runtime";
626
908
  const head = el("div", "hqpu-group-head");
@@ -628,6 +910,7 @@ window.__ModuleLoader__.load({
628
910
  section.append(head);
629
911
  const facts = el("dl", "hqpu-facts");
630
912
  const runtime = report.runtime;
913
+ const latestVersion = runtimeLatestVersion(runtime);
631
914
  const row = (label, value, code = false) => {
632
915
  const wrap = el("div");
633
916
  wrap.append(el("dt", "", label));
@@ -639,15 +922,51 @@ window.__ModuleLoader__.load({
639
922
  wrap.append(dd);
640
923
  facts.append(wrap);
641
924
  };
642
- if (runtime.configured === false) row(copy.runtime.source, copy.runtime.notConfigured);
643
- else {
925
+ const source = normalizeSource(runtime.source);
926
+ if (runtime.configured === false) {
927
+ if (source !== null) sourceFactRow(facts, source, copy);
928
+ else row(copy.runtime.source, copy.runtime.notConfigured);
929
+ } else {
644
930
  row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown);
645
- if (runtime.updateAvailable && runtime.latest != null) row(copy.runtime.latest, runtime.latest);
931
+ if (runtime.updateAvailable && latestVersion !== null) row(copy.runtime.latest, latestVersion);
646
932
  if (report.manifestVersion !== null) row(copy.runtime.set, report.manifestVersion, true);
647
933
  if (typeof runtime.checkedAt === "string" && runtime.checkedAt.length > 0) row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString());
934
+ if (source !== null) sourceFactRow(facts, source, copy);
648
935
  }
649
936
  section.append(facts);
650
- section.append(el("p", "hqpu-status", copy.runtime.note));
937
+ if (source !== null) section.append(sourceEditor("runtime", source, copy, actions));
938
+ if (runtime.updateAvailable && latestVersion !== null) {
939
+ const row = el("div", "hqpu-actions hqpu-runtime-actions");
940
+ if (actions.accepted) {
941
+ const line = el("span", "hqpu-status hqpu-status--dot");
942
+ line.setAttribute("role", "status");
943
+ line.append(el("span", "hqpu-spinner"), el("span", "", copy.runtime.updateAccepted));
944
+ row.append(line);
945
+ } else if (actions.busy) {
946
+ const line = el("span", "hqpu-status hqpu-status--dot");
947
+ line.setAttribute("role", "status");
948
+ line.append(el("span", "hqpu-spinner"), el("span", "", copy.runtime.updating));
949
+ row.append(line);
950
+ } else if (actions.armed) {
951
+ row.append(el("p", "hqpu-status hqpu-runtime-confirm", copy.runtime.updateConfirm(latestVersion)));
952
+ const yes = el("button", "hqpu-btn hqpu-btn--primary hqpu-btn--sm", copy.confirmInstall);
953
+ yes.type = "button";
954
+ yes.dataset.runtimeConfirm = "";
955
+ yes.addEventListener("click", actions.onConfirm);
956
+ const no = el("button", "hqpu-btn hqpu-btn--outline hqpu-btn--sm", copy.cancel);
957
+ no.type = "button";
958
+ no.addEventListener("click", actions.onCancel);
959
+ row.append(yes, no);
960
+ } else {
961
+ const start = el("button", "hqpu-btn hqpu-btn--primary hqpu-btn--sm");
962
+ start.type = "button";
963
+ start.dataset.runtimeUpdate = "";
964
+ start.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.runtime.update}</span>`;
965
+ start.addEventListener("click", actions.onArm);
966
+ row.append(start);
967
+ }
968
+ section.append(row);
969
+ } else section.append(el("p", "hqpu-status", copy.runtime.note));
651
970
  if (typeof runtime.lastError === "string" && runtime.lastError.length > 0) {
652
971
  const line = el("p", "hqpu-status hqpu-status--dot");
653
972
  line.setAttribute("role", "status");
@@ -680,10 +999,17 @@ window.__ModuleLoader__.load({
680
999
  if (node !== null) section.append(node);
681
1000
  }
682
1001
  }
683
- function renderRuntime(section, report, copy) {
1002
+ function renderRuntime(section, report, copy, actions) {
684
1003
  section.textContent = "";
685
1004
  if (report === null) return;
686
- section.append(runtimeGroup(report, copy));
1005
+ section.append(runtimeGroup(report, copy, actions));
1006
+ }
1007
+ /** The plugin-source group, above the plugin groups. No-op without the field. */
1008
+ function renderPluginSource(section, report, copy, actions) {
1009
+ section.textContent = "";
1010
+ if (report === null) return;
1011
+ const node = pluginSourceGroup(report, copy, actions);
1012
+ if (node !== null) section.append(node);
687
1013
  }
688
1014
  /** Run the install transaction, then keep polling until the state settles. */
689
1015
  async function runInstall(store, copy) {
@@ -826,10 +1152,11 @@ window.__ModuleLoader__.load({
826
1152
  panel.append(head);
827
1153
  const scroll = el("div", "hqpu-scroll");
828
1154
  const column = el("div", "hqpu-column");
1155
+ const pluginSourceSection = el("div", "hqpu-stack");
829
1156
  const listSection = el("div", "hqpu-stack");
830
1157
  const runtimeSection = el("div", "hqpu-stack");
831
1158
  const toastSection = el("div", "hqpu-toast-host");
832
- column.append(listSection, runtimeSection, toastSection);
1159
+ column.append(pluginSourceSection, listSection, runtimeSection, toastSection);
833
1160
  scroll.append(column);
834
1161
  panel.append(scroll);
835
1162
  const footer = el("div", "hqpu-footer");
@@ -841,6 +1168,144 @@ window.__ModuleLoader__.load({
841
1168
  toastHost = toastSection;
842
1169
  panel.focus();
843
1170
  const disposers = [];
1171
+ let runtimeArmed = false;
1172
+ let runtimeBusy = false;
1173
+ let runtimeAccepted = false;
1174
+ const runtimeSourceState = {
1175
+ editing: false,
1176
+ saving: false,
1177
+ draft: ""
1178
+ };
1179
+ const pluginSourceState = {
1180
+ editing: false,
1181
+ saving: false,
1182
+ draft: ""
1183
+ };
1184
+ const runtimeSourceActions = sourceActionsFor("runtime", runtimeSourceState);
1185
+ const runtimeActions = {
1186
+ get busy() {
1187
+ return runtimeBusy;
1188
+ },
1189
+ get armed() {
1190
+ return runtimeArmed;
1191
+ },
1192
+ get accepted() {
1193
+ return runtimeAccepted;
1194
+ },
1195
+ onArm: () => {
1196
+ runtimeArmed = true;
1197
+ render();
1198
+ },
1199
+ onCancel: () => {
1200
+ runtimeArmed = false;
1201
+ render();
1202
+ },
1203
+ onConfirm: () => {
1204
+ requestRuntime();
1205
+ },
1206
+ get sourceEditing() {
1207
+ return runtimeSourceActions.sourceEditing;
1208
+ },
1209
+ get sourceSaving() {
1210
+ return runtimeSourceActions.sourceSaving;
1211
+ },
1212
+ get sourceDraft() {
1213
+ return runtimeSourceActions.sourceDraft;
1214
+ },
1215
+ onSourceEdit: () => runtimeSourceActions.onSourceEdit(),
1216
+ onSourceDraft: (value) => runtimeSourceActions.onSourceDraft(value),
1217
+ onSourceSave: () => runtimeSourceActions.onSourceSave(),
1218
+ onSourceCancel: () => runtimeSourceActions.onSourceCancel(),
1219
+ onSourceReset: () => runtimeSourceActions.onSourceReset()
1220
+ };
1221
+ const pluginActions = sourceActionsFor("plugin", pluginSourceState);
1222
+ /** Build the {@link SourceEditorActions} half for one manifest source. */
1223
+ function sourceActionsFor(kind, state) {
1224
+ return {
1225
+ get sourceEditing() {
1226
+ return state.editing;
1227
+ },
1228
+ get sourceSaving() {
1229
+ return state.saving;
1230
+ },
1231
+ get sourceDraft() {
1232
+ return state.draft;
1233
+ },
1234
+ onSourceEdit: () => {
1235
+ const report = store.report();
1236
+ state.draft = (kind === "runtime" ? normalizeSource(report?.runtime.source) : normalizeSource(report?.pluginSource))?.url ?? "";
1237
+ state.editing = true;
1238
+ render();
1239
+ },
1240
+ onSourceDraft: (value) => {
1241
+ state.draft = value;
1242
+ },
1243
+ onSourceSave: () => {
1244
+ submitSource(kind, state);
1245
+ },
1246
+ onSourceCancel: () => {
1247
+ state.editing = false;
1248
+ render();
1249
+ },
1250
+ onSourceReset: () => {
1251
+ submitSource(kind, state, "");
1252
+ }
1253
+ };
1254
+ }
1255
+ /**
1256
+ * Save one manifest source — or clear the override with an empty URL —
1257
+ * then re-run discovery.
1258
+ *
1259
+ * The re-check is not optional: the report is cached, so without a fresh
1260
+ * `POST /check` the facts and the update button would keep naming the OLD
1261
+ * manifest's offer after the source changed. hq-edge answers 422 with a
1262
+ * message meant for a person on refusal (bad scheme, credentials, …), which
1263
+ * the toast shows verbatim.
1264
+ */
1265
+ async function submitSource(kind, state, url = state.draft) {
1266
+ state.saving = true;
1267
+ render();
1268
+ const labels = kind === "runtime" ? copy.runtime : copy.pluginSource;
1269
+ try {
1270
+ if (kind === "runtime") await setRuntimeUpdateSource(url);
1271
+ else await setPluginUpdateSource(url);
1272
+ state.editing = false;
1273
+ showToast(labels.saved, "success");
1274
+ try {
1275
+ await checkUpdates();
1276
+ await store.refresh();
1277
+ } catch {}
1278
+ } catch (err) {
1279
+ showToast(`${labels.saveFailed}: ${err instanceof Error ? err.message : String(err)}`, "error");
1280
+ } finally {
1281
+ state.saving = false;
1282
+ render();
1283
+ }
1284
+ }
1285
+ async function requestRuntime() {
1286
+ const version = runtimeLatestVersion(store.report()?.runtime);
1287
+ if (version === null) {
1288
+ showToast(copy.checkFailed, "error");
1289
+ return;
1290
+ }
1291
+ runtimeArmed = false;
1292
+ runtimeBusy = true;
1293
+ render();
1294
+ try {
1295
+ const result = await requestRuntimeUpdate(version);
1296
+ if (result?.error !== void 0) {
1297
+ showToast(`${copy.installError}: ${result.error}`, "error");
1298
+ return;
1299
+ }
1300
+ runtimeAccepted = true;
1301
+ showToast(copy.runtime.updateAccepted, "success");
1302
+ } catch (err) {
1303
+ showToast(`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`, "error");
1304
+ } finally {
1305
+ runtimeBusy = false;
1306
+ render();
1307
+ }
1308
+ }
844
1309
  const render = () => {
845
1310
  const report = store.report();
846
1311
  const installing = store.installing();
@@ -850,7 +1315,8 @@ window.__ModuleLoader__.load({
850
1315
  listSection.textContent = "";
851
1316
  listSection.append(skeleton(copy));
852
1317
  } else renderList(listSection, report, copy, installing, store);
853
- renderRuntime(runtimeSection, report, copy);
1318
+ renderPluginSource(pluginSourceSection, report, copy, pluginActions);
1319
+ renderRuntime(runtimeSection, report, copy, runtimeActions);
854
1320
  renderActions(actionBar, report, copy, store, installing);
855
1321
  };
856
1322
  const unsubscribe = store.subscribe(render);
@@ -1559,6 +2025,69 @@ window.__ModuleLoader__.load({
1559
2025
  gap: 8px;
1560
2026
  }
1561
2027
 
2028
+ /* ── Runtime update action row (arm → confirm step) ───────────────────── */
2029
+ .hqpu-runtime-actions {
2030
+ margin-top: 12px;
2031
+ }
2032
+ .hqpu-runtime-confirm {
2033
+ flex: 1 1 100%;
2034
+ margin: 0;
2035
+ }
2036
+
2037
+ /* ── Runtime update source (fact row + editable URL field) ────────────── */
2038
+ .hqpu-source-line {
2039
+ display: inline-flex;
2040
+ align-items: center;
2041
+ gap: 8px;
2042
+ min-width: 0;
2043
+ max-width: 100%;
2044
+ }
2045
+ .hqpu-source-url {
2046
+ min-width: 0;
2047
+ overflow: hidden;
2048
+ text-overflow: ellipsis;
2049
+ white-space: nowrap;
2050
+ }
2051
+ .hqpu-source-editor {
2052
+ display: flex;
2053
+ align-items: center;
2054
+ flex-wrap: wrap;
2055
+ gap: 8px;
2056
+ margin-top: 12px;
2057
+ }
2058
+ /* The plugin-source editor sits inside its own group right under the fact
2059
+ * row, so it needs less air than the runtime one under the facts block. */
2060
+ .hqpu-source-editor--plugin {
2061
+ margin-top: 4px;
2062
+ }
2063
+ .hqpu-source-hint {
2064
+ flex: 1 1 100%;
2065
+ margin: 0;
2066
+ }
2067
+ .hqpu-source-controls {
2068
+ flex: 1 1 100%;
2069
+ }
2070
+ .hqpu-source-input {
2071
+ flex: 1 1 240px;
2072
+ min-width: 0;
2073
+ height: 28px;
2074
+ box-sizing: border-box;
2075
+ padding: 0 10px;
2076
+ border: 0.5px solid var(--dsw-alias-border-l4);
2077
+ border-radius: var(--dsw-radius-md);
2078
+ background: var(--dsw-alias-bg-layer-3);
2079
+ color: var(--dsw-alias-label-primary);
2080
+ font: inherit;
2081
+ font-size: 12.5px;
2082
+ }
2083
+ .hqpu-source-input:disabled {
2084
+ opacity: 0.5;
2085
+ }
2086
+ .hqpu-source-input:focus-visible {
2087
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
2088
+ outline-offset: 1px;
2089
+ }
2090
+
1562
2091
  /* ── Footer action row (ui-primitives Modal .footer) ───────────────────── */
1563
2092
  .hqpu-footer {
1564
2093
  display: flex;
@@ -1815,10 +2344,6 @@ window.__ModuleLoader__.load({
1815
2344
  fn();
1816
2345
  } catch {}
1817
2346
  });
1818
- const labelState = {
1819
- label: entryCopy(locale).label,
1820
- tooltip: entryCopy(locale).tooltip
1821
- };
1822
2347
  const openView = () => {
1823
2348
  ensureDesignTokens();
1824
2349
  store.check().then((report) => {
@@ -1831,8 +2356,8 @@ window.__ModuleLoader__.load({
1831
2356
  plugin: PLUGIN_ID,
1832
2357
  icon: UPDATE_ICON,
1833
2358
  css: ENTRY_CSS,
1834
- label: () => labelState.label,
1835
- tooltip: () => labelState.tooltip,
2359
+ label: () => entryCopy(locale).label,
2360
+ tooltip: () => entryCopy(locale).tooltip,
1836
2361
  refresh: { subscribe: refreshSubscribe },
1837
2362
  badge: {
1838
2363
  subscribe: (listener) => store.subscribe(listener),