dsh-cliproxyapi-pro 1.4.1 → 1.5.1

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
@@ -7,7 +7,7 @@ window.__ModuleLoader__.load({
7
7
 
8
8
  const React = require("react");
9
9
  const h = React.createElement;
10
- const { useState, useEffect, useCallback, useMemo } = React;
10
+ const { useState, useEffect, useCallback, useMemo, useRef } = React;
11
11
 
12
12
  const PACKAGE_NAME = "dsh-cliproxyapi-pro";
13
13
  const MODULE_ID = "dsh-cliproxyapi-pro";
@@ -20,6 +20,10 @@ window.__ModuleLoader__.load({
20
20
  save: "api/dsh-cliproxyapi/save",
21
21
  addAccount: "api/dsh-cliproxyapi/accounts/add",
22
22
  removeAccount: "api/dsh-cliproxyapi/accounts/remove",
23
+ updateStatus: "api/dsh-cliproxyapi/updates/status",
24
+ updateCheck: "api/dsh-cliproxyapi/updates/check",
25
+ updateSave: "api/dsh-cliproxyapi/updates/save",
26
+ updateDownload: "api/dsh-cliproxyapi/updates/download",
23
27
  };
24
28
 
25
29
  const zh = {
@@ -118,6 +122,80 @@ window.__ModuleLoader__.load({
118
122
  resetParamsBtn: "恢复自动探测",
119
123
  paramsHint: "留空并保存即恢复 CLIProxyAPI 自动探测值。修改后请点击「保存模型勾选」才会写入模型选择器。",
120
124
  discoveredValue: "探测值",
125
+ updateTitle: "插件更新",
126
+ updateToggleDetails: "展开或收起插件更新设置",
127
+ updateCompactCurrent: "当前 {version}",
128
+ updateCompactAvailable: "有新版本 {version}",
129
+ updateCompactDownloaded: "{version} 已下载",
130
+ updateCompactError: "更新异常",
131
+ updateCompactStatusUnavailable: "更新服务未就绪",
132
+ updateCompactErrorNetwork: "网络异常",
133
+ updateCompactErrorChannel: "通道未发布",
134
+ updateStatusUnavailableHint: "无法读取更新服务,可能是插件宿主代码尚未重载:重启 DSH(仅刷新页面不够),或检查更新接口是否可用。",
135
+ updateServiceUnavailableError: "更新服务未就绪:当前运行的插件宿主代码仍是旧版,可能尚未重启 DSH。重启 DSH 后重试。",
136
+ updateSubtitle: "仅更新 CLIProxyAPI 插件自身,不更新 CPA 服务或 DSH 宿主。设置修改后立即保存。",
137
+ updateNewVersion: "新版本",
138
+ updateVersionSummary: "当前版本 {current} · 最新版本 {latest}",
139
+ updateUnknownVersion: "未知",
140
+ updateNotChecked: "尚未检查",
141
+ updateLoading: "正在读取更新状态…",
142
+ updateUpToDate: "已是所选通道最新版本",
143
+ updateAvailable: "有可用更新",
144
+ updateCheckBtn: "检查更新",
145
+ updateChecking: "正在检查…",
146
+ updateRetryBtn: "重新读取状态",
147
+ updateDownloadBtn: "下载更新",
148
+ updateDownloading: "正在下载并校验…",
149
+ updateDownloadedBtn: "重新下载",
150
+ updateChannelLabel: "更新通道",
151
+ updateChannelHint: "稳定版适合日常使用;Beta 和开发版可能不稳定。切换后请重新检查更新。",
152
+ updateChannelStable: "稳定",
153
+ updateChannelBeta: "Beta",
154
+ updateChannelDev: "开发",
155
+ updateRegionLabel: "下载区域",
156
+ updateRegionHint: "自动选择可用下载区域;也可手动指定全球或中国大陆源。",
157
+ updateRegionAuto: "自动",
158
+ updateRegionGlobal: "全球",
159
+ updateRegionCn: "中国大陆",
160
+ updateEffectiveRegion: "当前使用:{region}",
161
+ updateAutoCheckLabel: "自动检查更新",
162
+ updateAutoCheckHint: "启动时及每 6 小时检查所选通道;关闭后仍可手动检查。",
163
+ updateAutoDownloadLabel: "自动下载更新",
164
+ updateAutoDownloadHint: "自动检查发现新版本后下载并校验离线包;需开启自动检查,不会自动安装或重启 DSH。",
165
+ updateLastChecked: "上次检查:{time}",
166
+ updateSaving: "正在保存更新设置…",
167
+ updateSaved: "更新设置已保存",
168
+ updateLoadFailed: "无法读取插件更新状态,请重试。",
169
+ updateCheckFailed: "检查更新失败,请稍后重试或切换下载区域。",
170
+ updateSaveFailed: "更新设置保存失败,请重试。",
171
+ updateDownloadFailed: "下载更新失败,请稍后重试或切换下载区域。",
172
+ updateNetworkError: "无法连接更新源,请检查网络或切换下载区域后重试。",
173
+ updateMetadataError: "更新源返回了无效的版本信息,请稍后重试。",
174
+ updateChannelUnavailable: "所选通道尚未发布可用版本,请选择其他通道。",
175
+ updateIntegrityError: "下载包完整性校验失败,未应用任何更改。请重新下载。",
176
+ updateInvalidPackage: "下载包不是有效的本插件更新包,未应用任何更改。",
177
+ updateStorageError: "无法保存更新设置或离线包,请检查磁盘空间和目录写入权限。",
178
+ updateBusyError: "已有更新任务正在进行,请等待完成后重试。",
179
+ updateStaleVersion: "可用版本已变化,请重新检查更新后再下载。",
180
+ updateDisposedError: "插件正在重新加载,请稍后重新读取更新状态。",
181
+ updateHttpError: "更新服务请求失败(HTTP {status}),请重试。",
182
+ updateLinkedHint: "当前为本地链接安装,更新不会覆盖源码目录。可检查并下载离线包;若要保留源码开发方式,请自行更新代码。",
183
+ updateUnsupportedHint: "当前 DSH 宿主不支持此更新操作,请升级 DSH 后重试。",
184
+ updateNotInstalledHint: "未找到已安装的插件,无法准备更新。请先通过 DSH 插件管理器安装本插件。",
185
+ updateBlockedHint: "当前安装方式无法准备更新,请使用 DSH 插件管理器检查安装状态。",
186
+ updateConfirmTitle: "确认下载版本 {version}?",
187
+ updateConfirmHint: "只下载并校验离线包,不会自动安装或重启。下载后需关闭 DSH,执行提示的升级命令,再重新启动。",
188
+ updateLinkedConfirmHint: "只下载并校验离线包,不会覆盖源码、安装或重启。若要保留本地链接方式,请手动更新源码后重新启动 DSH;通过离线包重新安装会切换插件安装来源。",
189
+ updateConfirmBtn: "确认下载",
190
+ updateCancelBtn: "取消",
191
+ updateDownloadRequested: "下载任务已提交,完成后将在此显示离线包与升级命令。",
192
+ updateDownloadedHint: "版本 {version} 已下载并校验。请先保存工作并关闭 DSH,再执行下方命令升级,最后重新启动 DSH 生效。",
193
+ updateLinkedDownloadedHint: "版本 {version} 的离线包已下载并校验。当前为本地链接安装;如需保留源码方式,请手动更新源码,之后重新启动 DSH 生效。不要将离线包覆盖到正在使用的源码目录。",
194
+ updateLinkedSourceHint: "未提供自动升级命令以保护本地链接来源。通过离线包重新安装会切换插件安装来源。",
195
+ updateDownloadPathLabel: "已校验的离线包",
196
+ updateUpgradeCommandLabel: "关闭 DSH 后执行升级命令",
197
+ updateCommandUnavailable: "未生成升级命令。请先确认 DSH 支持的离线包升级方式;升级前关闭 DSH,完成后重新启动。",
198
+ updateOfflineHint: "自动下载不等于自动安装。插件不会关闭、重启 DSH,也不会覆盖正在使用的插件文件。",
121
199
  };
122
200
 
123
201
  const en = {
@@ -216,6 +294,80 @@ window.__ModuleLoader__.load({
216
294
  resetParamsBtn: "Reset to Discovered",
217
295
  paramsHint: "Clear a value and save to restore the value discovered from CLIProxyAPI. Click Apply Selected Models to write changes to the picker.",
218
296
  discoveredValue: "Discovered",
297
+ updateTitle: "Plugin Updates",
298
+ updateToggleDetails: "Expand or collapse plugin update settings",
299
+ updateCompactCurrent: "Current {version}",
300
+ updateCompactAvailable: "Update {version}",
301
+ updateCompactDownloaded: "{version} downloaded",
302
+ updateCompactError: "Update error",
303
+ updateCompactStatusUnavailable: "Update service unavailable",
304
+ updateCompactErrorNetwork: "Network error",
305
+ updateCompactErrorChannel: "Channel unpublished",
306
+ updateStatusUnavailableHint: "Could not reach the update service; the running host plugin code may still be the previous version. Restart DSH (refreshing the page is not enough), or check that the update endpoint is registered.",
307
+ updateServiceUnavailableError: "The update service is not ready: the running host plugin code is still the previous version, so DSH may not have been restarted. Restart DSH and retry.",
308
+ updateSubtitle: "Updates this CLIProxyAPI plugin only, not the CPA server or DSH. Changes to update settings are saved immediately.",
309
+ updateNewVersion: "New Version",
310
+ updateVersionSummary: "Current {current} · Latest {latest}",
311
+ updateUnknownVersion: "Unknown",
312
+ updateNotChecked: "Not checked yet",
313
+ updateLoading: "Loading update status…",
314
+ updateUpToDate: "Up to date on this channel",
315
+ updateAvailable: "Update available",
316
+ updateCheckBtn: "Check for Updates",
317
+ updateChecking: "Checking…",
318
+ updateRetryBtn: "Reload Status",
319
+ updateDownloadBtn: "Download Update",
320
+ updateDownloading: "Downloading & verifying…",
321
+ updateDownloadedBtn: "Download Again",
322
+ updateChannelLabel: "Update Channel",
323
+ updateChannelHint: "Stable is recommended for daily use. Beta and development builds may be unstable. Check again after switching channels.",
324
+ updateChannelStable: "Stable",
325
+ updateChannelBeta: "Beta",
326
+ updateChannelDev: "Dev",
327
+ updateRegionLabel: "Download Region",
328
+ updateRegionHint: "Automatically select an available region, or choose the global or Mainland China source manually.",
329
+ updateRegionAuto: "Auto",
330
+ updateRegionGlobal: "Global",
331
+ updateRegionCn: "Mainland China",
332
+ updateEffectiveRegion: "Using: {region}",
333
+ updateAutoCheckLabel: "Automatically Check for Updates",
334
+ updateAutoCheckHint: "Check the selected channel at startup and every 6 hours. Manual checks remain available when disabled.",
335
+ updateAutoDownloadLabel: "Automatically Download Updates",
336
+ updateAutoDownloadHint: "Download and verify packages found by automatic checks (requires Auto Check). Never installs or restarts DSH automatically.",
337
+ updateLastChecked: "Last checked: {time}",
338
+ updateSaving: "Saving update settings…",
339
+ updateSaved: "Update settings saved",
340
+ updateLoadFailed: "Could not load plugin update status. Please retry.",
341
+ updateCheckFailed: "Could not check for updates. Retry later or change the download region.",
342
+ updateSaveFailed: "Could not save update settings. Please retry.",
343
+ updateDownloadFailed: "Could not download the update. Retry later or change the download region.",
344
+ updateNetworkError: "Could not reach the update source. Check your network or change the download region and retry.",
345
+ updateMetadataError: "The update source returned invalid version information. Please retry later.",
346
+ updateChannelUnavailable: "No release has been published on this channel. Please choose another channel.",
347
+ updateIntegrityError: "Package integrity verification failed. No changes were applied. Please download again.",
348
+ updateInvalidPackage: "The downloaded file is not a valid update package for this plugin. No changes were applied.",
349
+ updateStorageError: "Could not save update settings or the offline package. Check free disk space and write permissions.",
350
+ updateBusyError: "Another update operation is in progress. Wait for it to finish and retry.",
351
+ updateStaleVersion: "The available version changed. Check for updates again before downloading.",
352
+ updateDisposedError: "The plugin is reloading. Please reload update status shortly.",
353
+ updateHttpError: "The update service request failed (HTTP {status}). Please retry.",
354
+ updateLinkedHint: "This is a linked local installation. Updates never overwrite its source directory. You can check and download an offline package; update the source yourself to keep the development setup.",
355
+ updateUnsupportedHint: "This DSH version does not support this update operation. Upgrade DSH and retry.",
356
+ updateNotInstalledHint: "No installed plugin was found, so an update cannot be prepared. Install this plugin through the DSH plugin manager first.",
357
+ updateBlockedHint: "An update cannot be prepared for this installation. Check it in the DSH plugin manager.",
358
+ updateConfirmTitle: "Download version {version}?",
359
+ updateConfirmHint: "Only downloads and verifies the offline package. Nothing is installed or restarted automatically. Then close DSH, run the provided upgrade command, and start DSH again.",
360
+ updateLinkedConfirmHint: "Only downloads and verifies the offline package. It never overwrites source code, installs, or restarts DSH. To keep the linked setup, update the source manually and restart DSH. Reinstalling from the offline package changes the installation source.",
361
+ updateConfirmBtn: "Confirm Download",
362
+ updateCancelBtn: "Cancel",
363
+ updateDownloadRequested: "Download requested. The verified package and upgrade command will appear here when ready.",
364
+ updateDownloadedHint: "Version {version} has been downloaded and verified. Save your work and close DSH, then run the command below to upgrade. Start DSH again for it to take effect.",
365
+ updateLinkedDownloadedHint: "The offline package for version {version} has been downloaded and verified. This is a linked installation. To keep the source-based setup, update the source manually, then restart DSH. Do not extract the package over source files that are in use.",
366
+ updateLinkedSourceHint: "No upgrade command is provided, to protect the linked installation source. Reinstalling from the offline package changes that source.",
367
+ updateDownloadPathLabel: "Verified Offline Package",
368
+ updateUpgradeCommandLabel: "Upgrade Command — Run After Closing DSH",
369
+ updateCommandUnavailable: "No upgrade command was generated. First verify the offline-package upgrade method supported by your DSH version. Close DSH before upgrading, then start it again.",
370
+ updateOfflineHint: "Automatic downloading is not automatic installation. This plugin never closes or restarts DSH, or overwrites plugin files that are in use.",
219
371
  };
220
372
 
221
373
  const CSS = `
@@ -443,7 +595,7 @@ window.__ModuleLoader__.load({
443
595
  .dcpa-seg-btn[data-active="true"] {
444
596
  background: var(--dsw-alias-bg-base);
445
597
  color: var(--dsw-alias-label-primary);
446
- box-shadow: 0 1px 2px rgba(0,0,0,0.08);
598
+ box-shadow: 0 1px 2px var(--dsw-alias-border-l1);
447
599
  }
448
600
  .dcpa-toolbar {
449
601
  display: flex;
@@ -650,6 +802,136 @@ window.__ModuleLoader__.load({
650
802
  .dcpa-btn-danger:hover:not(:disabled) {
651
803
  border-color: var(--dsw-alias-state-error-primary);
652
804
  }
805
+ .dcpa-update-row {
806
+ display: flex;
807
+ align-items: center;
808
+ justify-content: space-between;
809
+ gap: 16px;
810
+ padding: 11px 0;
811
+ border-top: 0.5px solid var(--dsw-alias-border-l1);
812
+ }
813
+ .dcpa-update-row .dcpa-field {
814
+ flex: 1;
815
+ min-width: 0;
816
+ }
817
+ .dcpa-update-row .dcpa-seg, .dcpa-update-row .dcpa-actions {
818
+ flex: none;
819
+ }
820
+ .dcpa-update-version {
821
+ font-size: 13px;
822
+ font-weight: 600;
823
+ color: var(--dsw-alias-label-primary);
824
+ overflow-wrap: anywhere;
825
+ }
826
+ .dcpa-update-card {
827
+ display: block;
828
+ padding: 0;
829
+ }
830
+ .dcpa-update-summary {
831
+ display: flex;
832
+ align-items: center;
833
+ gap: 10px;
834
+ min-height: 42px;
835
+ padding: 10px 14px;
836
+ border-radius: 12px;
837
+ cursor: pointer;
838
+ list-style: none;
839
+ }
840
+ .dcpa-update-summary::-webkit-details-marker { display: none; }
841
+ .dcpa-update-summary:hover { background: var(--dsw-alias-bg-layer-2); }
842
+ .dcpa-update-summary:focus-visible {
843
+ outline: 2px solid var(--dsw-alias-brand-primary);
844
+ outline-offset: 2px;
845
+ }
846
+ .dcpa-update-summary-title {
847
+ flex: none;
848
+ font-weight: 600;
849
+ }
850
+ .dcpa-update-summary-current, .dcpa-update-summary-status {
851
+ min-width: 0;
852
+ overflow: hidden;
853
+ text-overflow: ellipsis;
854
+ white-space: nowrap;
855
+ color: var(--dsw-alias-label-secondary);
856
+ font-size: 12px;
857
+ }
858
+ .dcpa-update-summary-current { flex: 0 1 auto; }
859
+ .dcpa-update-summary-status { flex: 1; text-align: right; }
860
+ .dcpa-update-summary-status[data-state="available"],
861
+ .dcpa-update-summary-status[data-state="downloaded"] { color: var(--dsw-alias-brand-primary); }
862
+ .dcpa-update-summary-status[data-state="error"] { color: var(--dsw-alias-state-error-primary); }
863
+ .dcpa-update-summary::after {
864
+ content: "";
865
+ flex: none;
866
+ width: 7px;
867
+ height: 7px;
868
+ margin-right: 3px;
869
+ border-right: 1.5px solid var(--dsw-alias-label-secondary);
870
+ border-bottom: 1.5px solid var(--dsw-alias-label-secondary);
871
+ transform: rotate(-45deg);
872
+ }
873
+ .dcpa-update-card[open] > .dcpa-update-summary::after { transform: rotate(45deg); }
874
+ .dcpa-update-content {
875
+ padding: 0 14px 12px;
876
+ border-top: 0.5px solid var(--dsw-alias-border-l1);
877
+ }
878
+ .dcpa-update-content > .dcpa-subtitle { padding: 10px 0; }
879
+ .dcpa-update-card .dcpa-seg {
880
+ flex-wrap: wrap;
881
+ }
882
+ .dcpa-update-card .dcpa-seg-btn:disabled, .dcpa-update-card .dcpa-switch:disabled {
883
+ opacity: 0.5;
884
+ cursor: default;
885
+ }
886
+ .dcpa-update-card button:focus-visible {
887
+ outline: 2px solid var(--dsw-alias-brand-primary);
888
+ outline-offset: 2px;
889
+ }
890
+ .dcpa-update-notice {
891
+ display: flex;
892
+ flex-direction: column;
893
+ gap: 8px;
894
+ margin: 8px 0;
895
+ padding: 10px 12px;
896
+ border: 0.5px solid var(--dsw-alias-border-l2);
897
+ border-radius: 8px;
898
+ background: var(--dsw-alias-bg-layer-2);
899
+ }
900
+ .dcpa-update-notice p {
901
+ margin: 0;
902
+ }
903
+ .dcpa-update-code {
904
+ display: block;
905
+ margin: 0;
906
+ padding: 8px 10px;
907
+ border: 0.5px solid var(--dsw-alias-border-l1);
908
+ border-radius: 6px;
909
+ background: var(--dsw-alias-bg-base);
910
+ color: var(--dsw-alias-label-primary);
911
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
912
+ font-size: 11px;
913
+ line-height: 17px;
914
+ white-space: pre-wrap;
915
+ overflow-wrap: anywhere;
916
+ user-select: text;
917
+ }
918
+ .dcpa-update-footer {
919
+ display: flex;
920
+ flex-direction: column;
921
+ gap: 8px;
922
+ border-top: 0.5px solid var(--dsw-alias-border-l1);
923
+ padding-top: 10px;
924
+ }
925
+ @media (max-width: 620px) {
926
+ .dcpa-update-row:not(.dcpa-switch-row) {
927
+ align-items: flex-start;
928
+ flex-direction: column;
929
+ gap: 8px;
930
+ }
931
+ .dcpa-update-row .dcpa-actions {
932
+ flex-wrap: wrap;
933
+ }
934
+ }
653
935
  `;
654
936
 
655
937
  const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
@@ -705,6 +987,369 @@ window.__ModuleLoader__.load({
705
987
  : "dcpa-dot dcpa-dot-idle";
706
988
  }
707
989
 
990
+ const SERVICE_UNAVAILABLE_STATUS = new Set([404, 405, 501]);
991
+ const UPDATE_ERROR_KEYS = {
992
+ "network-error": "updateNetworkError",
993
+ "invalid-metadata": "updateMetadataError",
994
+ "invalid-response": "updateMetadataError",
995
+ "service-unavailable": "updateServiceUnavailableError",
996
+ "channel-unavailable": "updateChannelUnavailable",
997
+ "unavailable-channel": "updateChannelUnavailable",
998
+ "integrity-error": "updateIntegrityError",
999
+ "invalid-package": "updateInvalidPackage",
1000
+ "storage-error": "updateStorageError",
1001
+ busy: "updateBusyError",
1002
+ "stale-version": "updateStaleVersion",
1003
+ disposed: "updateDisposedError",
1004
+ "linked-install": "updateLinkedHint",
1005
+ "unsupported-host": "updateUnsupportedHint",
1006
+ "not-installed": "updateNotInstalledHint",
1007
+ };
1008
+
1009
+ function updateErrorText(t, failure, fallback) {
1010
+ const code = failure?.updateCode;
1011
+ if (typeof code === "string" && Object.prototype.hasOwnProperty.call(UPDATE_ERROR_KEYS, code)) {
1012
+ return t(UPDATE_ERROR_KEYS[code]);
1013
+ }
1014
+ if (failure?.httpStatus) {
1015
+ return t("updateHttpError").replace("{status}", String(failure.httpStatus));
1016
+ }
1017
+ // Server messages may be English or contain internal paths. Do not expose them.
1018
+ return t(fallback);
1019
+ }
1020
+
1021
+ async function requestPluginUpdate(url, payload, signal) {
1022
+ let res;
1023
+ try {
1024
+ res = await fetch(url, {
1025
+ signal,
1026
+ cache: "no-store",
1027
+ ...(payload === undefined
1028
+ ? {}
1029
+ : {
1030
+ method: "POST",
1031
+ headers: { "content-type": "application/json" },
1032
+ body: JSON.stringify(payload),
1033
+ }),
1034
+ });
1035
+ } catch (err) {
1036
+ if (err?.name === "AbortError") throw err;
1037
+ const failure = new Error("Update network request failed");
1038
+ failure.updateCode = "network-error";
1039
+ throw failure;
1040
+ }
1041
+ let body;
1042
+ try {
1043
+ body = await res.json();
1044
+ } catch {
1045
+ const failure = new Error("Invalid update response");
1046
+ // A missing route (host plugin not reloaded) answers 404/405 with no
1047
+ // update payload; that is "service unavailable", not bad metadata.
1048
+ failure.updateCode = !res.ok && SERVICE_UNAVAILABLE_STATUS.has(res.status) ? "service-unavailable" : "invalid-response";
1049
+ if (!res.ok) failure.httpStatus = res.status;
1050
+ throw failure;
1051
+ }
1052
+ const validState = body?.update && typeof body.update === "object" && !Array.isArray(body.update);
1053
+ if (!res.ok || body?.ok === false || !validState ||
1054
+ payload !== undefined && (body.update.error || body.update.errorCode)) {
1055
+ const failure = new Error("Update request failed");
1056
+ failure.updateCode = body?.errorCode || body?.update?.errorCode ||
1057
+ (!validState ? (!res.ok && SERVICE_UNAVAILABLE_STATUS.has(res.status) ? "service-unavailable" : "invalid-response") : null);
1058
+ failure.httpStatus = !res.ok ? res.status : null;
1059
+ failure.updateState = validState ? body.update : null;
1060
+ throw failure;
1061
+ }
1062
+ return body.update;
1063
+ }
1064
+
1065
+ /** Update state is isolated: polling never reloads account data or drafts. */
1066
+ function PluginUpdatePanel({ t }) {
1067
+ const [update, setUpdate] = useState(null);
1068
+ const [loading, setLoading] = useState(true);
1069
+ const [busy, setBusy] = useState(null);
1070
+ const [error, setError] = useState(null);
1071
+ const [notice, setNotice] = useState(null);
1072
+ const [confirmVersion, setConfirmVersion] = useState(null);
1073
+ const lifecycle = useRef(null);
1074
+
1075
+ const loadStatus = useCallback(async () => {
1076
+ const life = lifecycle.current;
1077
+ if (!life?.mounted || life.action || life.polling) return;
1078
+ life.polling = true;
1079
+ const serial = ++life.serial;
1080
+ const controller = new AbortController();
1081
+ life.controllers.add(controller);
1082
+ life.statusController = controller;
1083
+ try {
1084
+ // This GET is read-only. Only an explicit check action probes the registry.
1085
+ const next = await requestPluginUpdate(API.updateStatus, undefined, controller.signal);
1086
+ if (!life.mounted || serial !== life.serial) return;
1087
+ life.snapshot = next;
1088
+ setUpdate(next);
1089
+ setError((previous) => (previous?.source === "status" ? null : previous));
1090
+ } catch (failure) {
1091
+ if (!life.mounted || serial !== life.serial || failure?.name === "AbortError") return;
1092
+ setError((previous) =>
1093
+ previous && previous.source !== "status"
1094
+ ? previous
1095
+ : { failure, fallback: "updateLoadFailed", source: "status" }
1096
+ );
1097
+ } finally {
1098
+ life.controllers.delete(controller);
1099
+ if (life.statusController === controller) life.statusController = null;
1100
+ life.polling = false;
1101
+ if (life.mounted) setLoading(false);
1102
+ }
1103
+ }, []);
1104
+
1105
+ useEffect(() => {
1106
+ // A fresh lifetime also prevents old StrictMode/HMR requests from winning.
1107
+ const life = {
1108
+ mounted: true,
1109
+ action: false,
1110
+ polling: false,
1111
+ serial: 0,
1112
+ controllers: new Set(),
1113
+ statusController: null,
1114
+ snapshot: null,
1115
+ };
1116
+ lifecycle.current = life;
1117
+ let timer;
1118
+ const poll = async () => {
1119
+ await loadStatus();
1120
+ if (life.mounted) {
1121
+ timer = setTimeout(poll, life.snapshot?.checking || life.snapshot?.downloading ? 2000 : 15000);
1122
+ }
1123
+ };
1124
+ void poll();
1125
+ return () => {
1126
+ life.mounted = false;
1127
+ life.serial += 1;
1128
+ clearTimeout(timer);
1129
+ for (const controller of life.controllers) controller.abort();
1130
+ life.controllers.clear();
1131
+ };
1132
+ }, [loadStatus]);
1133
+
1134
+ const runAction = useCallback(async (kind, payload = {}) => {
1135
+ const life = lifecycle.current;
1136
+ if (!life?.mounted || life.action || life.snapshot?.checking || life.snapshot?.downloading) return;
1137
+ life.action = true;
1138
+ life.serial += 1;
1139
+ life.statusController?.abort();
1140
+ setBusy(kind);
1141
+ setError(null);
1142
+ setNotice(null);
1143
+ setConfirmVersion(null);
1144
+ const controller = new AbortController();
1145
+ life.controllers.add(controller);
1146
+ const url = kind === "save" ? API.updateSave : kind === "download" ? API.updateDownload : API.updateCheck;
1147
+ try {
1148
+ const next = await requestPluginUpdate(url, payload, controller.signal);
1149
+ if (!life.mounted) return;
1150
+ life.snapshot = next;
1151
+ setUpdate(next);
1152
+ if (kind === "save") setNotice("updateSaved");
1153
+ if (kind === "download" && !next.downloadedVersion) setNotice("updateDownloadRequested");
1154
+ } catch (failure) {
1155
+ if (!life.mounted || failure?.name === "AbortError") return;
1156
+ if (failure?.updateState && typeof failure.updateState === "object") {
1157
+ life.snapshot = failure.updateState;
1158
+ setUpdate(failure.updateState);
1159
+ }
1160
+ setError({
1161
+ failure,
1162
+ fallback: kind === "save" ? "updateSaveFailed" : kind === "download" ? "updateDownloadFailed" : "updateCheckFailed",
1163
+ source: kind,
1164
+ });
1165
+ } finally {
1166
+ life.controllers.delete(controller);
1167
+ life.action = false;
1168
+ if (life.mounted) setBusy(null);
1169
+ }
1170
+ }, []);
1171
+
1172
+ const working = loading || busy !== null || update?.checking === true || update?.downloading === true;
1173
+ const blockedReason = update?.blockedReason;
1174
+ const downloadBlocked = !!blockedReason && blockedReason !== "linked-install";
1175
+ const blockedKey = blockedReason === "linked-install"
1176
+ ? "updateLinkedHint"
1177
+ : blockedReason === "unsupported-host"
1178
+ ? "updateUnsupportedHint"
1179
+ : blockedReason === "not-installed"
1180
+ ? "updateNotInstalledHint"
1181
+ : "updateBlockedHint";
1182
+ const canDownload = !!update?.available && typeof update.latestVersion === "string" && !!update.latestVersion && !downloadBlocked;
1183
+ const downloading = busy === "download" || update?.downloading === true;
1184
+ const checking = busy === "check" || update?.checking === true;
1185
+ const availableVersion = update?.latestVersion || t("updateNotChecked");
1186
+ const versionSummary = t("updateVersionSummary")
1187
+ .replace("{current}", update?.currentVersion || t("updateUnknownVersion"))
1188
+ .replace("{latest}", availableVersion);
1189
+ const statusError = update?.error || update?.errorCode
1190
+ ? { updateCode: update.errorCode }
1191
+ : null;
1192
+ const compactState = loading ? "loading" : downloading ? "downloading" : checking ? "checking" :
1193
+ error || statusError ? "error" : update?.downloadedVersion && update.downloadedVersion === update.latestVersion ? "downloaded" :
1194
+ update?.available ? "available" : "idle";
1195
+ // Name the actual failure instead of a generic "update error": the
1196
+ // status endpoint missing (host not reloaded) reads very differently
1197
+ // from a registry that could not be reached.
1198
+ const failureCode = statusError?.updateCode ?? update?.errorCode ?? null;
1199
+ const compactErrorKey = error
1200
+ ? error.source === "status" ? "updateCompactStatusUnavailable" : "updateCompactError"
1201
+ : failureCode === "network-error" ? "updateCompactErrorNetwork"
1202
+ : failureCode === "service-unavailable" ? "updateCompactStatusUnavailable"
1203
+ : failureCode === "channel-unavailable" ? "updateCompactErrorChannel"
1204
+ : "updateCompactError";
1205
+ const compactText = loading ? t("updateLoading") : downloading ? t("updateDownloading") : checking ? t("updateChecking") :
1206
+ error || statusError ? t(compactErrorKey) : compactState === "downloaded"
1207
+ ? t("updateCompactDownloaded").replace("{version}", update.downloadedVersion)
1208
+ : update?.available ? t("updateCompactAvailable").replace("{version}", availableVersion)
1209
+ : update?.latestVersion ? t("updateUpToDate") : t("updateNotChecked");
1210
+ let checkedAt = t("updateNotChecked");
1211
+ if (update?.lastCheckedAt) {
1212
+ const date = new Date(update.lastCheckedAt);
1213
+ if (Number.isFinite(date.getTime())) checkedAt = date.toLocaleString();
1214
+ }
1215
+ const regionName = (region) => t(region === "cn" ? "updateRegionCn" : "updateRegionGlobal");
1216
+ const segmented = (field, label, options, selected) =>
1217
+ h(
1218
+ "div",
1219
+ { className: "dcpa-seg", role: "group", "aria-label": t(label) },
1220
+ options.map(([value, key]) =>
1221
+ h("button", {
1222
+ key: value,
1223
+ type: "button",
1224
+ className: "dcpa-seg-btn",
1225
+ disabled: working || !update,
1226
+ "aria-pressed": selected === value,
1227
+ "data-active": selected === value ? "true" : "false",
1228
+ onClick: () => { if (selected !== value) void runAction("save", { [field]: value }); },
1229
+ }, t(key))
1230
+ )
1231
+ );
1232
+ const toggle = (field, label, hint, checked) =>
1233
+ h(
1234
+ "div",
1235
+ { className: "dcpa-update-row dcpa-switch-row" },
1236
+ h("div", { className: "dcpa-field" },
1237
+ h("span", { className: "dcpa-label" }, t(label)),
1238
+ h("p", { className: "dcpa-hint" }, t(hint))
1239
+ ),
1240
+ h("button", {
1241
+ type: "button",
1242
+ role: "switch",
1243
+ className: "dcpa-switch",
1244
+ "aria-label": t(label),
1245
+ "aria-checked": checked,
1246
+ disabled: working || !update,
1247
+ onClick: () => { void runAction("save", { [field]: !checked }); },
1248
+ }, h("span", { className: "dcpa-switch-thumb" }))
1249
+ );
1250
+
1251
+ // Native details starts closed. Do not control `open`: polling and saves
1252
+ // must preserve the user's expanded/collapsed choice without remounting.
1253
+ return h(
1254
+ "details",
1255
+ { className: "dcpa-card dcpa-update-card", "aria-label": t("updateTitle"), "aria-busy": working },
1256
+ h("summary", { className: "dcpa-update-summary", title: t("updateToggleDetails") },
1257
+ h("span", { className: "dcpa-update-summary-title" }, t("updateTitle")),
1258
+ h("span", { className: "dcpa-update-summary-current" },
1259
+ t("updateCompactCurrent").replace("{version}", update?.currentVersion || t("updateUnknownVersion"))),
1260
+ h("span", { className: "dcpa-update-summary-status", "data-state": compactState, role: "status", "aria-live": "polite" }, compactText)
1261
+ ),
1262
+ h("div", { className: "dcpa-update-content" },
1263
+ h("p", { className: "dcpa-subtitle" }, t("updateSubtitle")),
1264
+ h("div", { className: "dcpa-update-row" },
1265
+ h("div", { className: "dcpa-field" },
1266
+ h("span", { className: "dcpa-label" }, t("updateNewVersion")),
1267
+ h("span", { className: "dcpa-update-version", role: "status", "aria-live": "polite" },
1268
+ loading ? t("updateLoading") : downloading ? t("updateDownloading") : checking ? t("updateChecking") :
1269
+ update?.available ? availableVersion : update?.latestVersion ? t("updateUpToDate") : t("updateNotChecked")
1270
+ ),
1271
+ h("p", { className: "dcpa-hint" }, versionSummary),
1272
+ update?.available ? h("span", { className: "dcpa-hint" }, t("updateAvailable")) : null
1273
+ ),
1274
+ h("div", { className: "dcpa-actions" },
1275
+ h("button", {
1276
+ type: "button",
1277
+ className: "dcpa-btn",
1278
+ disabled: working,
1279
+ onClick: () => { if (update) void runAction("check"); else { setLoading(true); void loadStatus(); } },
1280
+ }, checking ? t("updateChecking") : !update && !loading ? t("updateRetryBtn") : t("updateCheckBtn")),
1281
+ h("button", {
1282
+ type: "button",
1283
+ className: "dcpa-btn dcpa-btn-primary",
1284
+ disabled: working || !canDownload,
1285
+ onClick: () => setConfirmVersion(update.latestVersion),
1286
+ }, downloading ? t("updateDownloading") : update?.downloadedVersion === update?.latestVersion && update?.downloadedVersion
1287
+ ? t("updateDownloadedBtn") : t("updateDownloadBtn"))
1288
+ )
1289
+ ),
1290
+ confirmVersion ? h("div", {
1291
+ className: "dcpa-update-notice",
1292
+ role: "alertdialog",
1293
+ "aria-modal": false,
1294
+ "aria-label": t("updateConfirmTitle").replace("{version}", confirmVersion),
1295
+ },
1296
+ h("p", { className: "dcpa-label" }, t("updateConfirmTitle").replace("{version}", confirmVersion)),
1297
+ h("p", { className: "dcpa-hint" }, t(blockedReason === "linked-install" ? "updateLinkedConfirmHint" : "updateConfirmHint")),
1298
+ h("div", { className: "dcpa-actions" },
1299
+ h("button", {
1300
+ type: "button",
1301
+ className: "dcpa-btn dcpa-btn-primary",
1302
+ disabled: working || !canDownload || confirmVersion !== update?.latestVersion,
1303
+ onClick: () => { void runAction("download", { version: confirmVersion }); },
1304
+ }, t("updateConfirmBtn")),
1305
+ h("button", { type: "button", className: "dcpa-btn", onClick: () => setConfirmVersion(null) }, t("updateCancelBtn"))
1306
+ ),
1307
+ confirmVersion !== update?.latestVersion ? h("p", { className: "dcpa-hint" }, t("updateStaleVersion")) : null
1308
+ ) : null,
1309
+ h("div", { className: "dcpa-update-row" },
1310
+ h("div", { className: "dcpa-field" },
1311
+ h("span", { className: "dcpa-label" }, t("updateChannelLabel")),
1312
+ h("p", { className: "dcpa-hint" }, t("updateChannelHint"))
1313
+ ),
1314
+ segmented("channel", "updateChannelLabel", [
1315
+ ["stable", "updateChannelStable"], ["beta", "updateChannelBeta"], ["dev", "updateChannelDev"],
1316
+ ], update?.channel || "stable")
1317
+ ),
1318
+ h("div", { className: "dcpa-update-row" },
1319
+ h("div", { className: "dcpa-field" },
1320
+ h("span", { className: "dcpa-label" }, t("updateRegionLabel")),
1321
+ h("p", { className: "dcpa-hint" }, t("updateRegionHint")),
1322
+ update?.effectiveRegion ? h("p", { className: "dcpa-hint" }, t("updateEffectiveRegion").replace("{region}", regionName(update.effectiveRegion))) : null
1323
+ ),
1324
+ segmented("region", "updateRegionLabel", [
1325
+ ["auto", "updateRegionAuto"], ["global", "updateRegionGlobal"], ["cn", "updateRegionCn"],
1326
+ ], update?.region || "auto")
1327
+ ),
1328
+ toggle("autoCheck", "updateAutoCheckLabel", "updateAutoCheckHint", update?.autoCheck !== false),
1329
+ toggle("autoDownload", "updateAutoDownloadLabel", "updateAutoDownloadHint", update?.autoDownload === true),
1330
+ blockedReason ? h("div", { className: "dcpa-update-notice" }, h("p", { className: "dcpa-hint" }, t(blockedKey))) : null,
1331
+ update?.downloadedVersion && update?.downloadPath ? h("div", { className: "dcpa-update-notice", role: "status" },
1332
+ h("p", { className: "dcpa-label" }, t(blockedReason === "linked-install" ? "updateLinkedDownloadedHint" : "updateDownloadedHint").replace("{version}", update.downloadedVersion)),
1333
+ h("span", { className: "dcpa-label" }, t("updateDownloadPathLabel")),
1334
+ h("code", { className: "dcpa-update-code" }, update.downloadPath),
1335
+ blockedReason !== "linked-install" && update.upgradeCommand ? h(React.Fragment, null,
1336
+ h("span", { className: "dcpa-label" }, t("updateUpgradeCommandLabel")),
1337
+ h("pre", { className: "dcpa-update-code" }, h("code", null, update.upgradeCommand))
1338
+ ) : h("p", { className: "dcpa-hint" }, t(blockedReason === "linked-install" ? "updateLinkedSourceHint" : "updateCommandUnavailable"))
1339
+ ) : null,
1340
+ h("div", { className: "dcpa-update-footer" },
1341
+ error ? h("p", { className: "dcpa-error", role: "alert" }, updateErrorText(t, error.failure, error.fallback)) :
1342
+ statusError ? h("p", { className: "dcpa-error", role: "alert" }, updateErrorText(t, statusError, "updateCheckFailed")) : null,
1343
+ error?.source === "status" ? h("p", { className: "dcpa-hint", role: "note" }, t("updateStatusUnavailableHint")) : null,
1344
+ busy === "save" ? h("p", { className: "dcpa-hint", role: "status" }, t("updateSaving")) :
1345
+ notice ? h("p", { className: "dcpa-success", role: "status" }, t(notice)) : null,
1346
+ h("p", { className: "dcpa-hint" }, t("updateLastChecked").replace("{time}", checkedAt)),
1347
+ h("p", { className: "dcpa-hint" }, t("updateOfflineHint"))
1348
+ )
1349
+ )
1350
+ );
1351
+ }
1352
+
708
1353
  /**
709
1354
  * Account list + switcher. Each account is edited in its own
710
1355
  * {@link AccountPanel}, remounted when the selection changes.
@@ -859,6 +1504,7 @@ window.__ModuleLoader__.load({
859
1504
  error ? h("p", { className: "dcpa-error", role: "alert" }, error) : null,
860
1505
  toast ? h("p", { className: "dcpa-success", role: "status" }, toast) : null
861
1506
  ),
1507
+ h(PluginUpdatePanel, { t }),
862
1508
  active
863
1509
  ? h(AccountPanel, {
864
1510
  key: active.id,