@huaqiu/dsh-tool-plugin-update 0.6.1 → 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
@@ -255,6 +255,22 @@ window.__ModuleLoader__.load({
255
255
  * @module
256
256
  */
257
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
+ }
258
274
  async function request(path, init) {
259
275
  const res = await fetch(`${UPDATE_BASE}${path}`, {
260
276
  cache: "no-store",
@@ -294,6 +310,13 @@ window.__ModuleLoader__.load({
294
310
  body: JSON.stringify({ setVersion })
295
311
  });
296
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
+ }
297
320
  /** POST unignore for the set version (reverses an ignore). */
298
321
  function unignoreOffer(setVersion) {
299
322
  return request("/unignore", {
@@ -301,6 +324,37 @@ window.__ModuleLoader__.load({
301
324
  body: JSON.stringify({ setVersion })
302
325
  });
303
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
+ }
304
358
  const COPY = {
305
359
  zh: {
306
360
  entryLabel: "插件更新",
@@ -348,11 +402,39 @@ window.__ModuleLoader__.load({
348
402
  source: "更新源",
349
403
  unknown: "版本未知",
350
404
  notConfigured: "未配置更新源",
351
- 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"
352
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: "未配置",
353
433
  lastError: "上次检查失败",
354
434
  installError: "安装失败",
355
- installSuccess: "更新已安装,正在重启 DSH…"
435
+ installSuccess: "更新已安装,正在重启 DSH…",
436
+ confirmInstall: "确认安装",
437
+ cancel: "取消"
356
438
  },
357
439
  en: {
358
440
  entryLabel: "Plugin Updates",
@@ -400,11 +482,39 @@ window.__ModuleLoader__.load({
400
482
  source: "Update source",
401
483
  unknown: "Version unknown",
402
484
  notConfigured: "No update source configured",
403
- 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"
404
509
  },
510
+ originOverride: "Custom",
511
+ originConfig: "Configured",
512
+ originUnset: "Not configured",
405
513
  lastError: "Last check failed",
406
514
  installError: "Install failed",
407
- installSuccess: "Update installed — restarting DSH…"
515
+ installSuccess: "Update installed — restarting DSH…",
516
+ confirmInstall: "Confirm install",
517
+ cancel: "Cancel"
408
518
  }
409
519
  };
410
520
  //#endregion
@@ -645,8 +755,154 @@ window.__ModuleLoader__.load({
645
755
  section.setAttribute("aria-label", copy.loading);
646
756
  return section;
647
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
+ }
648
904
  /** The independent HQ Edge runtime report, as ruled fact rows. */
649
- function runtimeGroup(report, copy) {
905
+ function runtimeGroup(report, copy, actions) {
650
906
  const section = el("section", "hqpu-group");
651
907
  section.dataset.group = "runtime";
652
908
  const head = el("div", "hqpu-group-head");
@@ -654,6 +910,7 @@ window.__ModuleLoader__.load({
654
910
  section.append(head);
655
911
  const facts = el("dl", "hqpu-facts");
656
912
  const runtime = report.runtime;
913
+ const latestVersion = runtimeLatestVersion(runtime);
657
914
  const row = (label, value, code = false) => {
658
915
  const wrap = el("div");
659
916
  wrap.append(el("dt", "", label));
@@ -665,15 +922,51 @@ window.__ModuleLoader__.load({
665
922
  wrap.append(dd);
666
923
  facts.append(wrap);
667
924
  };
668
- if (runtime.configured === false) row(copy.runtime.source, copy.runtime.notConfigured);
669
- 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 {
670
930
  row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown);
671
- if (runtime.updateAvailable && runtime.latest != null) row(copy.runtime.latest, runtime.latest);
931
+ if (runtime.updateAvailable && latestVersion !== null) row(copy.runtime.latest, latestVersion);
672
932
  if (report.manifestVersion !== null) row(copy.runtime.set, report.manifestVersion, true);
673
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);
674
935
  }
675
936
  section.append(facts);
676
- 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));
677
970
  if (typeof runtime.lastError === "string" && runtime.lastError.length > 0) {
678
971
  const line = el("p", "hqpu-status hqpu-status--dot");
679
972
  line.setAttribute("role", "status");
@@ -706,10 +999,17 @@ window.__ModuleLoader__.load({
706
999
  if (node !== null) section.append(node);
707
1000
  }
708
1001
  }
709
- function renderRuntime(section, report, copy) {
1002
+ function renderRuntime(section, report, copy, actions) {
1003
+ section.textContent = "";
1004
+ if (report === null) return;
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) {
710
1009
  section.textContent = "";
711
1010
  if (report === null) return;
712
- section.append(runtimeGroup(report, copy));
1011
+ const node = pluginSourceGroup(report, copy, actions);
1012
+ if (node !== null) section.append(node);
713
1013
  }
714
1014
  /** Run the install transaction, then keep polling until the state settles. */
715
1015
  async function runInstall(store, copy) {
@@ -852,10 +1152,11 @@ window.__ModuleLoader__.load({
852
1152
  panel.append(head);
853
1153
  const scroll = el("div", "hqpu-scroll");
854
1154
  const column = el("div", "hqpu-column");
1155
+ const pluginSourceSection = el("div", "hqpu-stack");
855
1156
  const listSection = el("div", "hqpu-stack");
856
1157
  const runtimeSection = el("div", "hqpu-stack");
857
1158
  const toastSection = el("div", "hqpu-toast-host");
858
- column.append(listSection, runtimeSection, toastSection);
1159
+ column.append(pluginSourceSection, listSection, runtimeSection, toastSection);
859
1160
  scroll.append(column);
860
1161
  panel.append(scroll);
861
1162
  const footer = el("div", "hqpu-footer");
@@ -867,6 +1168,144 @@ window.__ModuleLoader__.load({
867
1168
  toastHost = toastSection;
868
1169
  panel.focus();
869
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
+ }
870
1309
  const render = () => {
871
1310
  const report = store.report();
872
1311
  const installing = store.installing();
@@ -876,7 +1315,8 @@ window.__ModuleLoader__.load({
876
1315
  listSection.textContent = "";
877
1316
  listSection.append(skeleton(copy));
878
1317
  } else renderList(listSection, report, copy, installing, store);
879
- renderRuntime(runtimeSection, report, copy);
1318
+ renderPluginSource(pluginSourceSection, report, copy, pluginActions);
1319
+ renderRuntime(runtimeSection, report, copy, runtimeActions);
880
1320
  renderActions(actionBar, report, copy, store, installing);
881
1321
  };
882
1322
  const unsubscribe = store.subscribe(render);
@@ -1585,6 +2025,69 @@ window.__ModuleLoader__.load({
1585
2025
  gap: 8px;
1586
2026
  }
1587
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
+
1588
2091
  /* ── Footer action row (ui-primitives Modal .footer) ───────────────────── */
1589
2092
  .hqpu-footer {
1590
2093
  display: flex;