dsh-cliproxyapi-pro 1.5.0 → 1.6.0

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
@@ -12,6 +12,8 @@ window.__ModuleLoader__.load({
12
12
  const PACKAGE_NAME = "dsh-cliproxyapi-pro";
13
13
  const MODULE_ID = "dsh-cliproxyapi-pro";
14
14
  const NS = "dsh-cliproxyapi-pro";
15
+ // Stable id of the account/model page in the Settings left navigation.
16
+ const SETTINGS_SECTION_ID = "cliproxyapi";
15
17
 
16
18
  // Document-relative API paths (compatible with <base href="./"> in DSH Web/Desktop)
17
19
  const API = {
@@ -26,6 +28,17 @@ window.__ModuleLoader__.load({
26
28
  updateDownload: "api/dsh-cliproxyapi/updates/download",
27
29
  };
28
30
 
31
+ // Public, versioned dshmarket Update API (UPDATE-API-V1.md in dshmarket).
32
+ // Installation is delegated to the market; this plugin never runs a
33
+ // package manager. Paths are document-relative like API above.
34
+ const MARKET_API = {
35
+ capabilities: "dsh-market/api/v1/capabilities",
36
+ updates: "dsh-market/api/v1/updates",
37
+ operations: "dsh-market/api/v1/operations",
38
+ restart: "dsh-market/api/v1/restart",
39
+ };
40
+ const MARKET_SCHEMA = "dsh-market/update-api/v1";
41
+
29
42
  const zh = {
30
43
  nav: "CLIProxyAPI",
31
44
  title: "CLIProxyAPI 自动发现与模型筛选",
@@ -123,6 +136,7 @@ window.__ModuleLoader__.load({
123
136
  paramsHint: "留空并保存即恢复 CLIProxyAPI 自动探测值。修改后请点击「保存模型勾选」才会写入模型选择器。",
124
137
  discoveredValue: "探测值",
125
138
  updateTitle: "插件更新",
139
+ updatePageHint: "这里只管理插件自身的更新。CLIProxyAPI 的账号、模型和协议设置位于「设置」左侧导航的「CLIProxyAPI」页。",
126
140
  updateToggleDetails: "展开或收起插件更新设置",
127
141
  updateCompactCurrent: "当前 {version}",
128
142
  updateCompactAvailable: "有新版本 {version}",
@@ -131,7 +145,9 @@ window.__ModuleLoader__.load({
131
145
  updateCompactStatusUnavailable: "更新服务未就绪",
132
146
  updateCompactErrorNetwork: "网络异常",
133
147
  updateCompactErrorChannel: "通道未发布",
134
- updateStatusUnavailableHint: "无法读取更新服务,可能是插件宿主代码尚未重载:重载插件后刷新页面,或检查更新接口是否可用。",
148
+ updateStatusUnavailableHint: "无法读取更新服务,可能是插件宿主代码尚未重载:重启 DSH(仅刷新页面不够),或检查更新接口是否可用。",
149
+ updateServiceUnavailableError: "更新服务未就绪:当前运行的插件宿主代码仍是旧版,可能尚未重启 DSH。重启 DSH 后重试。",
150
+ updateOriginError: "请求来源校验未通过,更新操作已拒绝。请从 DSH 打开的页面重试;使用反向代理时请保持与页面一致的地址访问。",
135
151
  updateSubtitle: "仅更新 CLIProxyAPI 插件自身,不更新 CPA 服务或 DSH 宿主。设置修改后立即保存。",
136
152
  updateNewVersion: "新版本",
137
153
  updateVersionSummary: "当前版本 {current} · 最新版本 {latest}",
@@ -194,7 +210,51 @@ window.__ModuleLoader__.load({
194
210
  updateDownloadPathLabel: "已校验的离线包",
195
211
  updateUpgradeCommandLabel: "关闭 DSH 后执行升级命令",
196
212
  updateCommandUnavailable: "未生成升级命令。请先确认 DSH 支持的离线包升级方式;升级前关闭 DSH,完成后重新启动。",
197
- updateOfflineHint: "自动下载不等于自动安装。插件不会关闭、重启 DSH,也不会覆盖正在使用的插件文件。",
213
+ updateOfflineHint: "离线包下载不会自动安装。本插件自身不会运行包管理器、关闭或重启 DSH;一键更新和自动安装都交给 DSH 插件市场(dshmarket)完成。",
214
+ updateCompactInstalled: "{version} 已安装",
215
+ updateMarketLabel: "通过插件市场更新",
216
+ updateMarketDetecting: "正在检测 DSH 插件市场…",
217
+ updateMarketReadyHint: "已检测到 DSH 插件市场(dshmarket),可一键安装新版本。安装和版本解析都由插件市场完成,可能需要重启 DSH 或刷新页面才能生效。",
218
+ updateMarketUnavailableHint: "未检测到 DSH 插件市场(dshmarket)的更新接口。安装插件市场后即可在此一键更新或自动更新;目前可使用下方的离线包下载。",
219
+ updateMarketLinkedHint: "当前为本地链接安装,不通过插件市场安装,以免替换源码链接。",
220
+ updateMarketInstallBtn: "立即更新",
221
+ updateMarketInstalledBtn: "已安装",
222
+ updateMarketInstalling: "正在通过插件市场安装…",
223
+ updateMarketProgress: "正在通过插件市场安装… {percent}%",
224
+ updateMarketRedetectBtn: "重新检测",
225
+ updateMarketConfirmTitle: "通过插件市场安装版本 {version}?",
226
+ updateMarketConfirmHint: "DSH 插件市场会安装本插件的新版本,并在安装后核对实际装上的版本,不一致时恢复原版本。安装期间请勿关闭 DSH;完成后可能需要重启 DSH 或刷新页面。",
227
+ updateMarketConfirmBtn: "确认安装",
228
+ updateMarketAutomaticPrefix: "自动更新:",
229
+ updateMarketInstalled: "已安装版本 {version}。",
230
+ updateMarketVersionLower: "插件市场安装的是 {installed},低于检查到的 {target}:插件市场按其自身的通道与镜像解析版本。",
231
+ updateMarketRestartHint: "重启 DSH 后新版本生效。",
232
+ updateMarketRestartDesktopHint: "请完全退出 DSH 桌面版并重新打开,新版本才会生效。",
233
+ updateMarketRestartManualHint: "请重启 DSH 服务,新版本才会生效。",
234
+ updateMarketRefreshHint: "刷新页面后新版本界面生效。",
235
+ updateMarketAppliedHint: "新版本已生效;若界面仍是旧版,请刷新页面。",
236
+ updateMarketRestartBtn: "重启 DSH",
237
+ updateMarketRestarting: "正在请求重启…",
238
+ updateMarketRestartRequested: "已请求重启 DSH,页面将在 DSH 重新启动后恢复。",
239
+ updateMarketRefreshBtn: "刷新页面",
240
+ updateMarketUnchanged: "插件市场报告完成,但插件版本没有变化。",
241
+ updateMarketCancelled: "插件市场的安装任务已取消。",
242
+ updateMarketRolledBack: "插件市场已回滚此次更新,插件保持原版本。",
243
+ updateMarketForceBtn: "仍然安装",
244
+ updateMarketBusy: "插件市场正在执行另一项更新,请稍后重试。",
245
+ updateMarketAgentsRunning: "有智能体正在运行,插件市场暂不安装。请等待任务结束后重试。",
246
+ updateMarketTooFresh: "新版本刚发布,仍在插件市场的发布冷却期内。可稍后重试,或选择「仍然安装」。",
247
+ updateMarketVersionUnchanged: "插件市场没有解析到更新的版本(镜像可能尚未同步)。可稍后重试,或选择「仍然安装」。",
248
+ updateMarketDowngrade: "插件市场解析到的版本低于当前版本,已自动恢复,未做更改。",
249
+ updateMarketMismatch: "插件市场安装的版本与目标版本不一致,已自动恢复。请等待镜像同步后重试。",
250
+ updateMarketTimeout: "插件市场安装超时,请稍后重试。",
251
+ updateMarketForbidden: "插件市场拒绝了此次更新请求,请从 DSH 打开的页面重试。",
252
+ updateMarketLost: "无法继续跟踪插件市场的安装任务(DSH 可能已重启)。请重新检查更新以确认当前版本。",
253
+ updateMarketUnreachable: "无法连接 DSH 插件市场的更新接口,请确认 dshmarket 已启用后重试。",
254
+ updateMarketRestartFailed: "重启请求失败,请手动重启 DSH。",
255
+ updateMarketFailed: "插件市场安装失败。可在插件市场中查看详情,稍后重试,或使用离线包下载。",
256
+ updateAutoInstallLabel: "自动安装更新",
257
+ updateAutoInstallHint: "自动检查发现新版本后,通过 DSH 插件市场自动安装(需开启自动检查并安装 dshmarket,仅在 DSH 页面打开时进行;同一版本 6 小时内最多尝试一次)。生效可能需要重启 DSH 或刷新页面。",
198
258
  };
199
259
 
200
260
  const en = {
@@ -294,6 +354,7 @@ window.__ModuleLoader__.load({
294
354
  paramsHint: "Clear a value and save to restore the value discovered from CLIProxyAPI. Click Apply Selected Models to write changes to the picker.",
295
355
  discoveredValue: "Discovered",
296
356
  updateTitle: "Plugin Updates",
357
+ updatePageHint: "This page only manages updates of the plugin itself. CLIProxyAPI accounts, models and protocol settings are on the \"CLIProxyAPI\" page in the Settings dialog's left navigation.",
297
358
  updateToggleDetails: "Expand or collapse plugin update settings",
298
359
  updateCompactCurrent: "Current {version}",
299
360
  updateCompactAvailable: "Update {version}",
@@ -302,7 +363,9 @@ window.__ModuleLoader__.load({
302
363
  updateCompactStatusUnavailable: "Update service unavailable",
303
364
  updateCompactErrorNetwork: "Network error",
304
365
  updateCompactErrorChannel: "Channel unpublished",
305
- updateStatusUnavailableHint: "Could not reach the update service; the host plugin code may not be reloaded yet. Reload the plugin and refresh the page, or check that the update endpoint is registered.",
366
+ 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.",
367
+ 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.",
368
+ updateOriginError: "The request origin was rejected, so the update action was refused. Retry from the page DSH opened, and reach it through the same address you see (relevant behind a reverse proxy).",
306
369
  updateSubtitle: "Updates this CLIProxyAPI plugin only, not the CPA server or DSH. Changes to update settings are saved immediately.",
307
370
  updateNewVersion: "New Version",
308
371
  updateVersionSummary: "Current {current} · Latest {latest}",
@@ -365,7 +428,51 @@ window.__ModuleLoader__.load({
365
428
  updateDownloadPathLabel: "Verified Offline Package",
366
429
  updateUpgradeCommandLabel: "Upgrade Command — Run After Closing DSH",
367
430
  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.",
368
- updateOfflineHint: "Automatic downloading is not automatic installation. This plugin never closes or restarts DSH, or overwrites plugin files that are in use.",
431
+ updateOfflineHint: "Downloading an offline package never installs it. This plugin itself never runs a package manager, closes or restarts DSH; one-click and automatic installation are handed to the DSH plugin market (dshmarket).",
432
+ updateCompactInstalled: "{version} installed",
433
+ updateMarketLabel: "Update Through Plugin Market",
434
+ updateMarketDetecting: "Detecting the DSH plugin market…",
435
+ updateMarketReadyHint: "The DSH plugin market (dshmarket) was detected, so new versions can be installed in one click. The market installs and resolves the version; a DSH restart or page refresh may be needed for it to take effect.",
436
+ updateMarketUnavailableHint: "The DSH plugin market (dshmarket) update API was not detected. Install the plugin market to enable one-click and automatic updates here; until then, use the offline package download below.",
437
+ updateMarketLinkedHint: "This is a linked local installation, so it is not installed through the plugin market, which would replace the source link.",
438
+ updateMarketInstallBtn: "Update Now",
439
+ updateMarketInstalledBtn: "Installed",
440
+ updateMarketInstalling: "Installing through the plugin market…",
441
+ updateMarketProgress: "Installing through the plugin market… {percent}%",
442
+ updateMarketRedetectBtn: "Detect Again",
443
+ updateMarketConfirmTitle: "Install version {version} through the plugin market?",
444
+ updateMarketConfirmHint: "The DSH plugin market installs the new version of this plugin, then verifies the version it actually placed and restores the previous one on a mismatch. Keep DSH open while it runs; a DSH restart or page refresh may be needed afterwards.",
445
+ updateMarketConfirmBtn: "Confirm Install",
446
+ updateMarketAutomaticPrefix: "Automatic update:",
447
+ updateMarketInstalled: "Version {version} is installed.",
448
+ updateMarketVersionLower: "The plugin market installed {installed}, older than the checked {target}: the market resolves versions with its own channel and mirror.",
449
+ updateMarketRestartHint: "Restart DSH for the new version to take effect.",
450
+ updateMarketRestartDesktopHint: "Quit the DSH desktop app completely and reopen it for the new version to take effect.",
451
+ updateMarketRestartManualHint: "Restart the DSH service for the new version to take effect.",
452
+ updateMarketRefreshHint: "Refresh the page to load the new interface.",
453
+ updateMarketAppliedHint: "The new version is active; refresh the page if the interface still looks old.",
454
+ updateMarketRestartBtn: "Restart DSH",
455
+ updateMarketRestarting: "Requesting restart…",
456
+ updateMarketRestartRequested: "A DSH restart was requested. The page recovers once DSH is back.",
457
+ updateMarketRefreshBtn: "Refresh Page",
458
+ updateMarketUnchanged: "The plugin market reported success, but the plugin version did not change.",
459
+ updateMarketCancelled: "The plugin market cancelled the installation.",
460
+ updateMarketRolledBack: "The plugin market rolled this update back; the previous version is kept.",
461
+ updateMarketForceBtn: "Install Anyway",
462
+ updateMarketBusy: "The plugin market is running another update. Retry shortly.",
463
+ updateMarketAgentsRunning: "Agents are running, so the plugin market will not install now. Retry when they finish.",
464
+ updateMarketTooFresh: "This release is still inside the plugin market's release-age wait. Retry later, or choose Install Anyway.",
465
+ updateMarketVersionUnchanged: "The plugin market did not resolve a newer version (its mirror may still be syncing). Retry later, or choose Install Anyway.",
466
+ updateMarketDowngrade: "The plugin market resolved a version older than the current one and restored it; nothing changed.",
467
+ updateMarketMismatch: "The plugin market installed a version other than the target and restored the previous one. Retry after the mirror catches up.",
468
+ updateMarketTimeout: "The plugin market installation timed out. Retry later.",
469
+ updateMarketForbidden: "The plugin market refused this update request. Retry from the page DSH opened.",
470
+ updateMarketLost: "The plugin market operation can no longer be followed (DSH may have restarted). Check for updates again to confirm the current version.",
471
+ updateMarketUnreachable: "Could not reach the DSH plugin market update API. Make sure dshmarket is enabled and retry.",
472
+ updateMarketRestartFailed: "The restart request failed. Restart DSH manually.",
473
+ updateMarketFailed: "The plugin market installation failed. See the plugin market for details, retry later, or use the offline package download.",
474
+ updateAutoInstallLabel: "Automatically Install Updates",
475
+ updateAutoInstallHint: "Install versions found by automatic checks through the DSH plugin market (requires Auto Check and dshmarket; runs only while a DSH page is open, at most once per version every 6 hours). A DSH restart or page refresh may be needed for it to take effect.",
369
476
  };
370
477
 
371
478
  const CSS = `
@@ -856,7 +963,9 @@ window.__ModuleLoader__.load({
856
963
  .dcpa-update-summary-current { flex: 0 1 auto; }
857
964
  .dcpa-update-summary-status { flex: 1; text-align: right; }
858
965
  .dcpa-update-summary-status[data-state="available"],
859
- .dcpa-update-summary-status[data-state="downloaded"] { color: var(--dsw-alias-brand-primary); }
966
+ .dcpa-update-summary-status[data-state="downloaded"],
967
+ .dcpa-update-summary-status[data-state="installing"],
968
+ .dcpa-update-summary-status[data-state="installed"] { color: var(--dsw-alias-brand-primary); }
860
969
  .dcpa-update-summary-status[data-state="error"] { color: var(--dsw-alias-state-error-primary); }
861
970
  .dcpa-update-summary::after {
862
971
  content: "";
@@ -985,10 +1094,13 @@ window.__ModuleLoader__.load({
985
1094
  : "dcpa-dot dcpa-dot-idle";
986
1095
  }
987
1096
 
1097
+ const SERVICE_UNAVAILABLE_STATUS = new Set([404, 405, 501]);
988
1098
  const UPDATE_ERROR_KEYS = {
989
1099
  "network-error": "updateNetworkError",
990
1100
  "invalid-metadata": "updateMetadataError",
991
1101
  "invalid-response": "updateMetadataError",
1102
+ "service-unavailable": "updateServiceUnavailableError",
1103
+ "forbidden-origin": "updateOriginError",
992
1104
  "channel-unavailable": "updateChannelUnavailable",
993
1105
  "unavailable-channel": "updateChannelUnavailable",
994
1106
  "integrity-error": "updateIntegrityError",
@@ -1039,7 +1151,9 @@ window.__ModuleLoader__.load({
1039
1151
  body = await res.json();
1040
1152
  } catch {
1041
1153
  const failure = new Error("Invalid update response");
1042
- failure.updateCode = "invalid-response";
1154
+ // A missing route (host plugin not reloaded) answers 404/405 with no
1155
+ // update payload; that is "service unavailable", not bad metadata.
1156
+ failure.updateCode = !res.ok && SERVICE_UNAVAILABLE_STATUS.has(res.status) ? "service-unavailable" : "invalid-response";
1043
1157
  if (!res.ok) failure.httpStatus = res.status;
1044
1158
  throw failure;
1045
1159
  }
@@ -1047,7 +1161,8 @@ window.__ModuleLoader__.load({
1047
1161
  if (!res.ok || body?.ok === false || !validState ||
1048
1162
  payload !== undefined && (body.update.error || body.update.errorCode)) {
1049
1163
  const failure = new Error("Update request failed");
1050
- failure.updateCode = body?.errorCode || body?.update?.errorCode || (!validState ? "invalid-response" : null);
1164
+ failure.updateCode = body?.errorCode || body?.update?.errorCode ||
1165
+ (!validState ? (!res.ok && SERVICE_UNAVAILABLE_STATUS.has(res.status) ? "service-unavailable" : "invalid-response") : null);
1051
1166
  failure.httpStatus = !res.ok ? res.status : null;
1052
1167
  failure.updateState = validState ? body.update : null;
1053
1168
  throw failure;
@@ -1055,8 +1170,303 @@ window.__ModuleLoader__.load({
1055
1170
  return body.update;
1056
1171
  }
1057
1172
 
1173
+ // #region dshmarket installation (Update API v1)
1174
+
1175
+ const MARKET_TERMINAL_STATES = new Set(["succeeded", "failed", "cancelled", "rolled-back"]);
1176
+ const MARKET_POLL_MS = 1500;
1177
+ const MARKET_MAX_POLL_FAILURES = 20;
1178
+ const MARKET_FORCEABLE = new Set(["RELEASE_TOO_FRESH", "VERSION_UNCHANGED"]);
1179
+ const AUTO_INSTALL_FIRST_MS = 15000;
1180
+ const AUTO_INSTALL_INTERVAL_MS = 5 * 60 * 1000;
1181
+ const AUTO_INSTALL_RETRY_MS = 6 * 60 * 60 * 1000;
1182
+ const AUTO_INSTALL_MEMORY_KEY = `${PACKAGE_NAME}:market-auto-install`;
1183
+ const MARKET_ERROR_KEYS = {
1184
+ OPERATION_BUSY: "updateMarketBusy",
1185
+ AGENTS_RUNNING: "updateMarketAgentsRunning",
1186
+ RELEASE_TOO_FRESH: "updateMarketTooFresh",
1187
+ VERSION_UNCHANGED: "updateMarketVersionUnchanged",
1188
+ DOWNGRADE_DETECTED: "updateMarketDowngrade",
1189
+ RESOLVED_VERSION_MISMATCH: "updateMarketMismatch",
1190
+ UPDATE_TIMEOUT: "updateMarketTimeout",
1191
+ UPDATE_FORBIDDEN: "updateMarketForbidden",
1192
+ PLUGIN_NOT_INSTALLED: "updateNotInstalledHint",
1193
+ OPERATION_LOST: "updateMarketLost",
1194
+ MARKET_UNREACHABLE: "updateMarketUnreachable",
1195
+ RESTART_FAILED: "updateMarketRestartFailed",
1196
+ };
1197
+
1198
+ function parseVersion(value) {
1199
+ const match = typeof value === "string"
1200
+ ? /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
1201
+ : null;
1202
+ return match ? { parts: match.slice(1, 4).map(Number), pre: match[4] ? match[4].split(".") : [] } : null;
1203
+ }
1204
+
1205
+ /** SemVer precedence, or null when either side is not a version. */
1206
+ function compareVersions(a, b) {
1207
+ const x = parseVersion(a), y = parseVersion(b);
1208
+ if (!x || !y) return null;
1209
+ for (let i = 0; i < 3; i++) if (x.parts[i] !== y.parts[i]) return x.parts[i] > y.parts[i] ? 1 : -1;
1210
+ if (!x.pre.length || !y.pre.length) return x.pre.length ? -1 : y.pre.length ? 1 : 0;
1211
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
1212
+ const p = x.pre[i], q = y.pre[i];
1213
+ if (p === undefined || q === undefined) return p === undefined ? -1 : 1;
1214
+ if (p === q) continue;
1215
+ const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
1216
+ if (pn && qn) return Number(p) > Number(q) ? 1 : -1;
1217
+ if (pn !== qn) return pn ? -1 : 1;
1218
+ return p > q ? 1 : -1;
1219
+ }
1220
+ return 0;
1221
+ }
1222
+
1223
+ function validMarketOperation(value) {
1224
+ return !!value && typeof value === "object" && value.schema === MARKET_SCHEMA &&
1225
+ typeof value.operationId === "string" && value.operationId !== "" &&
1226
+ value.packageName === PACKAGE_NAME && typeof value.state === "string";
1227
+ }
1228
+
1229
+ const marketFailureCode = (operation) =>
1230
+ typeof operation?.failure?.code === "string" && operation.failure.code ? operation.failure.code : "UPDATE_FAILED";
1231
+
1232
+ function marketErrorText(t, code) {
1233
+ return t(Object.prototype.hasOwnProperty.call(MARKET_ERROR_KEYS, code) ? MARKET_ERROR_KEYS[code] : "updateMarketFailed");
1234
+ }
1235
+
1236
+ /**
1237
+ * Shared, page-wide client of the dshmarket Update API. One instance lives
1238
+ * per client activation so the settings card and the background
1239
+ * auto-install runner observe the same operation.
1240
+ */
1241
+ function createMarketUpdater(io = {}) {
1242
+ const doFetch = io.fetch ?? ((url, init) => fetch(url, init));
1243
+ const later = io.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
1244
+ const cancelLater = io.clearTimeout ?? ((id) => clearTimeout(id));
1245
+ let state = {
1246
+ phase: "unknown", // unknown | discovering | ready | unavailable
1247
+ runtime: null,
1248
+ restartSupported: false,
1249
+ installing: false,
1250
+ automatic: false,
1251
+ targetVersion: null,
1252
+ operation: null,
1253
+ errorCode: null,
1254
+ restarting: false,
1255
+ restartRequested: false,
1256
+ };
1257
+ const listeners = new Set();
1258
+ const controllers = new Set();
1259
+ let disposed = false;
1260
+ let discovering = null;
1261
+ let pollTimer = null;
1262
+ let pollFailures = 0;
1263
+
1264
+ const set = (patch) => {
1265
+ if (disposed) return;
1266
+ state = { ...state, ...patch };
1267
+ for (const listener of [...listeners]) listener(state);
1268
+ };
1269
+ const request = async (url, init = {}) => {
1270
+ const controller = new AbortController();
1271
+ controllers.add(controller);
1272
+ try {
1273
+ const res = await doFetch(url, { cache: "no-store", ...init, signal: controller.signal });
1274
+ let body = null;
1275
+ try { body = await res.json(); } catch {}
1276
+ return { status: res.status, ok: res.ok, body };
1277
+ } finally {
1278
+ controllers.delete(controller);
1279
+ }
1280
+ };
1281
+ const post = (url, payload) => request(url, {
1282
+ method: "POST",
1283
+ headers: { "content-type": "application/json" },
1284
+ body: JSON.stringify(payload),
1285
+ });
1286
+ const applyOperation = (operation) => {
1287
+ const done = MARKET_TERMINAL_STATES.has(operation.state);
1288
+ set({
1289
+ operation,
1290
+ installing: !done,
1291
+ errorCode: done && operation.state === "failed" ? marketFailureCode(operation) : null,
1292
+ });
1293
+ if (!done) schedulePoll();
1294
+ };
1295
+ const schedulePoll = () => {
1296
+ if (pollTimer !== null) cancelLater(pollTimer);
1297
+ pollTimer = later(poll, MARKET_POLL_MS);
1298
+ };
1299
+ const poll = async () => {
1300
+ pollTimer = null;
1301
+ const id = state.operation?.operationId;
1302
+ if (disposed || !id) return;
1303
+ try {
1304
+ const { status, body } = await request(`${MARKET_API.operations}?operationId=${encodeURIComponent(id)}`);
1305
+ if (disposed || state.operation?.operationId !== id) return;
1306
+ if (status === 404) {
1307
+ // Operations live in the current Host process only.
1308
+ set({ installing: false, errorCode: "OPERATION_LOST" });
1309
+ return;
1310
+ }
1311
+ if (status !== 200 || !validMarketOperation(body?.operation) || body.operation.operationId !== id) {
1312
+ throw new Error("Invalid market operation");
1313
+ }
1314
+ pollFailures = 0;
1315
+ applyOperation(body.operation);
1316
+ } catch (error) {
1317
+ if (disposed || error?.name === "AbortError") return;
1318
+ if (++pollFailures >= MARKET_MAX_POLL_FAILURES) set({ installing: false, errorCode: "OPERATION_LOST" });
1319
+ else schedulePoll();
1320
+ }
1321
+ };
1322
+
1323
+ return {
1324
+ getState: () => state,
1325
+ subscribe(listener) {
1326
+ listeners.add(listener);
1327
+ return () => listeners.delete(listener);
1328
+ },
1329
+ /** Reads only the local capability document; never contacts a registry. */
1330
+ discover() {
1331
+ if (disposed) return Promise.resolve(state);
1332
+ if (discovering) return discovering;
1333
+ if (state.phase !== "ready") set({ phase: "discovering" });
1334
+ discovering = request(MARKET_API.capabilities)
1335
+ .then(({ ok, body }) => {
1336
+ const usable = ok && body?.schema === MARKET_SCHEMA && body.apiVersion === 1 && body.features?.update === true;
1337
+ set({
1338
+ phase: usable ? "ready" : "unavailable",
1339
+ runtime: usable && typeof body.runtime === "string" ? body.runtime : null,
1340
+ restartSupported: usable && body.features?.restart === true && body.restart?.supported === true,
1341
+ });
1342
+ }, () => {
1343
+ set({ phase: "unavailable", runtime: null, restartSupported: false });
1344
+ })
1345
+ .finally(() => { discovering = null; })
1346
+ .then(() => state);
1347
+ return discovering;
1348
+ },
1349
+ /** Starts one market update operation, then follows it until it settles. */
1350
+ async install({ targetVersion = null, force = false, automatic = false } = {}) {
1351
+ if (disposed || state.phase !== "ready" || state.installing) return state;
1352
+ set({ installing: true, automatic, targetVersion, operation: null, errorCode: null, restartRequested: false });
1353
+ try {
1354
+ const { status, body } = await post(
1355
+ MARKET_API.updates,
1356
+ force ? { packageName: PACKAGE_NAME, force: true } : { packageName: PACKAGE_NAME }
1357
+ );
1358
+ if (disposed) return state;
1359
+ if (status === 202 && validMarketOperation(body?.operation)) {
1360
+ pollFailures = 0;
1361
+ applyOperation(body.operation);
1362
+ return state;
1363
+ }
1364
+ const code = typeof body?.failure?.code === "string" && body.failure.code ? body.failure.code
1365
+ : status === 409 ? "OPERATION_BUSY"
1366
+ : status === 403 ? "UPDATE_FORBIDDEN"
1367
+ : status === 400 ? "UPDATE_REJECTED"
1368
+ : status === 404 ? "MARKET_UNREACHABLE"
1369
+ : "UPDATE_FAILED";
1370
+ set({ installing: false, errorCode: code });
1371
+ } catch (error) {
1372
+ if (!disposed) set({ installing: false, errorCode: error?.name === "AbortError" ? null : "MARKET_UNREACHABLE" });
1373
+ }
1374
+ return state;
1375
+ },
1376
+ /** Only offered when the market reports that it owns process restarts. */
1377
+ async restart() {
1378
+ if (disposed || !state.restartSupported || state.restarting) return state;
1379
+ set({ restarting: true, errorCode: null });
1380
+ try {
1381
+ const { ok } = await post(MARKET_API.restart, {});
1382
+ set({ restarting: false, restartRequested: ok, errorCode: ok ? null : "RESTART_FAILED" });
1383
+ } catch (error) {
1384
+ set({ restarting: false, errorCode: error?.name === "AbortError" ? null : "RESTART_FAILED" });
1385
+ }
1386
+ return state;
1387
+ },
1388
+ dispose() {
1389
+ disposed = true;
1390
+ if (pollTimer !== null) cancelLater(pollTimer);
1391
+ pollTimer = null;
1392
+ for (const controller of controllers) controller.abort();
1393
+ controllers.clear();
1394
+ listeners.clear();
1395
+ },
1396
+ };
1397
+ }
1398
+
1399
+ function readAutoInstallMemory() {
1400
+ try {
1401
+ const raw = JSON.parse(globalThis.localStorage?.getItem(AUTO_INSTALL_MEMORY_KEY) ?? "null");
1402
+ return raw && typeof raw.version === "string" && Number.isFinite(raw.at) ? raw : null;
1403
+ } catch {
1404
+ return null;
1405
+ }
1406
+ }
1407
+
1408
+ function writeAutoInstallMemory(record) {
1409
+ try { globalThis.localStorage?.setItem(AUTO_INSTALL_MEMORY_KEY, JSON.stringify(record)); } catch {}
1410
+ }
1411
+
1412
+ /**
1413
+ * Opt-in automatic installation. The Host's own automatic check finds the
1414
+ * version (channel/region aware); dshmarket installs it. Runs only while a
1415
+ * DSH page is open, reads the local status endpoint only, and tries each
1416
+ * version at most once per {@link AUTO_INSTALL_RETRY_MS} across reloads.
1417
+ */
1418
+ function startAutoInstall(market, io = {}) {
1419
+ const later = io.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
1420
+ const cancelLater = io.clearTimeout ?? ((id) => clearTimeout(id));
1421
+ const now = io.now ?? (() => Date.now());
1422
+ const memory = io.memory ?? { read: readAutoInstallMemory, write: writeAutoInstallMemory };
1423
+ const controller = new AbortController();
1424
+ const readStatus = io.readStatus ?? ((signal) => requestPluginUpdate(API.updateStatus, undefined, signal));
1425
+ let stopped = false;
1426
+ let timer = null;
1427
+ const unsubscribe = market.subscribe((state) => {
1428
+ const operation = state.operation;
1429
+ if (state.automatic && operation?.state === "succeeded" && typeof state.targetVersion === "string" &&
1430
+ compareVersions(operation.installedVersion, operation.beforeVersion) === 1) {
1431
+ memory.write({ version: state.targetVersion, at: now(), done: true });
1432
+ }
1433
+ });
1434
+ const tick = async () => {
1435
+ timer = null;
1436
+ try {
1437
+ const status = await readStatus(controller.signal);
1438
+ if (stopped) return;
1439
+ const version = status?.latestVersion;
1440
+ if (status?.autoInstall !== true || status.autoCheck !== true || status.available !== true ||
1441
+ status.blockedReason || typeof version !== "string" || compareVersions(version, status.currentVersion) !== 1) return;
1442
+ const last = memory.read();
1443
+ if (last && last.version === version && (last.done === true || now() - last.at < AUTO_INSTALL_RETRY_MS)) return;
1444
+ if (market.getState().phase !== "ready") await market.discover();
1445
+ const snapshot = market.getState();
1446
+ if (stopped || snapshot.phase !== "ready" || snapshot.installing) return;
1447
+ memory.write({ version, at: now() });
1448
+ await market.install({ targetVersion: version, automatic: true });
1449
+ } catch {
1450
+ // Background work never surfaces errors; the card shows the market state.
1451
+ } finally {
1452
+ if (!stopped) timer = later(tick, AUTO_INSTALL_INTERVAL_MS);
1453
+ }
1454
+ };
1455
+ timer = later(tick, AUTO_INSTALL_FIRST_MS);
1456
+ return () => {
1457
+ stopped = true;
1458
+ if (timer !== null) cancelLater(timer);
1459
+ controller.abort();
1460
+ unsubscribe();
1461
+ };
1462
+ }
1463
+
1464
+ let sharedMarket = null;
1465
+
1466
+ // #endregion
1467
+
1058
1468
  /** Update state is isolated: polling never reloads account data or drafts. */
1059
- function PluginUpdatePanel({ t }) {
1469
+ function PluginUpdatePanel({ t, market = null, initiallyOpen = false }) {
1060
1470
  const [update, setUpdate] = useState(null);
1061
1471
  const [loading, setLoading] = useState(true);
1062
1472
  const [busy, setBusy] = useState(null);
@@ -1162,6 +1572,21 @@ window.__ModuleLoader__.load({
1162
1572
  }
1163
1573
  }, []);
1164
1574
 
1575
+ // dshmarket state is shared with the background auto-install runner.
1576
+ const [marketState, setMarketState] = useState(() => market?.getState() ?? null);
1577
+ const [confirmInstall, setConfirmInstall] = useState(null);
1578
+ useEffect(() => {
1579
+ if (!market) return undefined;
1580
+ let live = true;
1581
+ const unsubscribe = market.subscribe((next) => { if (live) setMarketState(next); });
1582
+ setMarketState(market.getState());
1583
+ if (market.getState().phase === "unknown") void market.discover();
1584
+ return () => {
1585
+ live = false;
1586
+ unsubscribe();
1587
+ };
1588
+ }, [market]);
1589
+
1165
1590
  const working = loading || busy !== null || update?.checking === true || update?.downloading === true;
1166
1591
  const blockedReason = update?.blockedReason;
1167
1592
  const downloadBlocked = !!blockedReason && blockedReason !== "linked-install";
@@ -1182,7 +1607,17 @@ window.__ModuleLoader__.load({
1182
1607
  const statusError = update?.error || update?.errorCode
1183
1608
  ? { updateCode: update.errorCode }
1184
1609
  : null;
1185
- const compactState = loading ? "loading" : downloading ? "downloading" : checking ? "checking" :
1610
+ const linkedInstall = blockedReason === "linked-install";
1611
+ const marketReady = marketState?.phase === "ready";
1612
+ const marketOperation = marketState?.operation ?? null;
1613
+ const marketInstalling = marketState?.installing === true;
1614
+ const marketInstalledVersion = marketOperation?.state === "succeeded" &&
1615
+ compareVersions(marketOperation.installedVersion, marketOperation.beforeVersion) === 1
1616
+ ? marketOperation.installedVersion : null;
1617
+ const canMarketInstall = marketReady && !linkedInstall && !downloadBlocked && !marketInstalling &&
1618
+ !marketInstalledVersion && !!update?.available && typeof update.latestVersion === "string" && !!update.latestVersion;
1619
+ const compactState = loading ? "loading" : marketInstalling ? "installing" : downloading ? "downloading" : checking ? "checking" :
1620
+ marketInstalledVersion ? "installed" :
1186
1621
  error || statusError ? "error" : update?.downloadedVersion && update.downloadedVersion === update.latestVersion ? "downloaded" :
1187
1622
  update?.available ? "available" : "idle";
1188
1623
  // Name the actual failure instead of a generic "update error": the
@@ -1192,9 +1627,12 @@ window.__ModuleLoader__.load({
1192
1627
  const compactErrorKey = error
1193
1628
  ? error.source === "status" ? "updateCompactStatusUnavailable" : "updateCompactError"
1194
1629
  : failureCode === "network-error" ? "updateCompactErrorNetwork"
1630
+ : failureCode === "service-unavailable" ? "updateCompactStatusUnavailable"
1195
1631
  : failureCode === "channel-unavailable" ? "updateCompactErrorChannel"
1196
1632
  : "updateCompactError";
1197
- const compactText = loading ? t("updateLoading") : downloading ? t("updateDownloading") : checking ? t("updateChecking") :
1633
+ const compactText = loading ? t("updateLoading") : marketInstalling ? t("updateMarketInstalling") :
1634
+ downloading ? t("updateDownloading") : checking ? t("updateChecking") :
1635
+ marketInstalledVersion ? t("updateCompactInstalled").replace("{version}", marketInstalledVersion) :
1198
1636
  error || statusError ? t(compactErrorKey) : compactState === "downloaded"
1199
1637
  ? t("updateCompactDownloaded").replace("{version}", update.downloadedVersion)
1200
1638
  : update?.available ? t("updateCompactAvailable").replace("{version}", availableVersion)
@@ -1221,7 +1659,7 @@ window.__ModuleLoader__.load({
1221
1659
  }, t(key))
1222
1660
  )
1223
1661
  );
1224
- const toggle = (field, label, hint, checked) =>
1662
+ const toggle = (field, label, hint, checked, unavailable = false) =>
1225
1663
  h(
1226
1664
  "div",
1227
1665
  { className: "dcpa-update-row dcpa-switch-row" },
@@ -1235,16 +1673,143 @@ window.__ModuleLoader__.load({
1235
1673
  className: "dcpa-switch",
1236
1674
  "aria-label": t(label),
1237
1675
  "aria-checked": checked,
1238
- disabled: working || !update,
1676
+ // An unavailable feature can still be switched off, never on.
1677
+ disabled: working || !update || unavailable && !checked,
1239
1678
  onClick: () => { void runAction("save", { [field]: !checked }); },
1240
1679
  }, h("span", { className: "dcpa-switch-thumb" }))
1241
1680
  );
1242
1681
 
1682
+ // One-click/automatic installation through dshmarket. Hidden entirely
1683
+ // when the card is rendered without a market client (tests, previews).
1684
+ let marketSection = null;
1685
+ if (market) {
1686
+ const phase = marketState?.phase ?? "unknown";
1687
+ const hintKey = phase === "ready"
1688
+ ? linkedInstall ? "updateMarketLinkedHint" : "updateMarketReadyHint"
1689
+ : phase === "unavailable" ? "updateMarketUnavailableHint" : "updateMarketDetecting";
1690
+ const operation = marketOperation;
1691
+ const progress = operation?.progress;
1692
+ const percent = typeof progress?.percent === "number" && Number.isFinite(progress.percent) ? Math.round(progress.percent) : null;
1693
+ const errorCode = marketState?.errorCode ?? null;
1694
+ const outcome = operation?.outcome ?? {};
1695
+ const target = marketState?.targetVersion;
1696
+ const prefix = marketState?.automatic ? `${t("updateMarketAutomaticPrefix")} ` : "";
1697
+ let result = null;
1698
+ if (marketInstalling) {
1699
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
1700
+ h("p", { className: "dcpa-label" }, prefix + (percent === null
1701
+ ? t("updateMarketInstalling")
1702
+ : t("updateMarketProgress").replace("{percent}", String(percent))))
1703
+ );
1704
+ } else if (marketInstalledVersion) {
1705
+ const restartHint = outcome.restartRequired
1706
+ ? marketState?.restartSupported ? "updateMarketRestartHint"
1707
+ : marketState?.runtime === "desktop" ? "updateMarketRestartDesktopHint" : "updateMarketRestartManualHint"
1708
+ : outcome.refreshRequired ? "updateMarketRefreshHint" : "updateMarketAppliedHint";
1709
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
1710
+ h("p", { className: "dcpa-success" }, prefix + t("updateMarketInstalled").replace("{version}", marketInstalledVersion)),
1711
+ typeof target === "string" && compareVersions(marketInstalledVersion, target) === -1
1712
+ ? h("p", { className: "dcpa-hint" }, t("updateMarketVersionLower").replace("{installed}", marketInstalledVersion).replace("{target}", target))
1713
+ : null,
1714
+ h("p", { className: "dcpa-hint" }, t(marketState?.restartRequested ? "updateMarketRestartRequested" : restartHint)),
1715
+ outcome.restartRequired && marketState?.restartSupported && !marketState?.restartRequested
1716
+ ? h("div", { className: "dcpa-actions" },
1717
+ h("button", {
1718
+ type: "button",
1719
+ className: "dcpa-btn dcpa-btn-primary",
1720
+ disabled: marketState?.restarting === true,
1721
+ onClick: () => { void market.restart(); },
1722
+ }, marketState?.restarting ? t("updateMarketRestarting") : t("updateMarketRestartBtn")))
1723
+ : !outcome.restartRequired && outcome.refreshRequired
1724
+ ? h("div", { className: "dcpa-actions" },
1725
+ h("button", {
1726
+ type: "button",
1727
+ className: "dcpa-btn dcpa-btn-primary",
1728
+ onClick: () => { globalThis.location?.reload(); },
1729
+ }, t("updateMarketRefreshBtn")))
1730
+ : null
1731
+ );
1732
+ } else if (operation?.state === "succeeded") {
1733
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
1734
+ h("p", { className: "dcpa-hint" }, prefix + t("updateMarketUnchanged")));
1735
+ } else if (operation?.state === "cancelled" || operation?.state === "rolled-back") {
1736
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
1737
+ h("p", { className: "dcpa-hint" }, prefix + t(operation.state === "cancelled" ? "updateMarketCancelled" : "updateMarketRolledBack")));
1738
+ }
1739
+ const errorBlock = errorCode && !marketInstalling
1740
+ ? h("div", { className: "dcpa-update-notice" },
1741
+ h("p", { className: "dcpa-error", role: "alert" }, prefix + marketErrorText(t, errorCode)),
1742
+ MARKET_FORCEABLE.has(errorCode) && canMarketInstall
1743
+ ? h("div", { className: "dcpa-actions" },
1744
+ h("button", {
1745
+ type: "button",
1746
+ className: "dcpa-btn",
1747
+ disabled: working,
1748
+ onClick: () => { void market.install({ targetVersion: update.latestVersion, force: true }); },
1749
+ }, t("updateMarketForceBtn")))
1750
+ : null)
1751
+ : null;
1752
+ marketSection = h(React.Fragment, null,
1753
+ h("div", { className: "dcpa-update-row" },
1754
+ h("div", { className: "dcpa-field" },
1755
+ h("span", { className: "dcpa-label" }, t("updateMarketLabel")),
1756
+ h("p", { className: "dcpa-hint" }, t(hintKey))
1757
+ ),
1758
+ phase === "ready" && !linkedInstall
1759
+ ? h("div", { className: "dcpa-actions" },
1760
+ h("button", {
1761
+ type: "button",
1762
+ className: "dcpa-btn dcpa-btn-primary",
1763
+ disabled: working || !canMarketInstall,
1764
+ onClick: () => { setConfirmVersion(null); setConfirmInstall(update.latestVersion); },
1765
+ }, marketInstalling ? t("updateMarketInstalling")
1766
+ : marketInstalledVersion ? t("updateMarketInstalledBtn") : t("updateMarketInstallBtn")))
1767
+ : phase === "unavailable"
1768
+ ? h("div", { className: "dcpa-actions" },
1769
+ h("button", { type: "button", className: "dcpa-btn", onClick: () => { void market.discover(); } }, t("updateMarketRedetectBtn")))
1770
+ : null
1771
+ ),
1772
+ confirmInstall ? h("div", {
1773
+ className: "dcpa-update-notice",
1774
+ role: "alertdialog",
1775
+ "aria-modal": false,
1776
+ "aria-label": t("updateMarketConfirmTitle").replace("{version}", confirmInstall),
1777
+ },
1778
+ h("p", { className: "dcpa-label" }, t("updateMarketConfirmTitle").replace("{version}", confirmInstall)),
1779
+ h("p", { className: "dcpa-hint" }, t("updateMarketConfirmHint")),
1780
+ h("div", { className: "dcpa-actions" },
1781
+ h("button", {
1782
+ type: "button",
1783
+ className: "dcpa-btn dcpa-btn-primary",
1784
+ disabled: working || !canMarketInstall || confirmInstall !== update?.latestVersion,
1785
+ onClick: () => {
1786
+ const version = confirmInstall;
1787
+ setConfirmInstall(null);
1788
+ void market.install({ targetVersion: version });
1789
+ },
1790
+ }, t("updateMarketConfirmBtn")),
1791
+ h("button", { type: "button", className: "dcpa-btn", onClick: () => setConfirmInstall(null) }, t("updateCancelBtn"))
1792
+ ),
1793
+ confirmInstall !== update?.latestVersion ? h("p", { className: "dcpa-hint" }, t("updateStaleVersion")) : null
1794
+ ) : null,
1795
+ result,
1796
+ errorBlock
1797
+ );
1798
+ }
1799
+
1243
1800
  // Native details starts closed. Do not control `open`: polling and saves
1244
1801
  // must preserve the user's expanded/collapsed choice without remounting.
1802
+ // `initiallyOpen` (the update-only plugin page) passes a constant `true`
1803
+ // that never changes, so React never rewrites it after mount and the
1804
+ // user can still collapse the card.
1245
1805
  return h(
1246
1806
  "details",
1247
- { className: "dcpa-card dcpa-update-card", "aria-label": t("updateTitle"), "aria-busy": working },
1807
+ {
1808
+ className: "dcpa-card dcpa-update-card",
1809
+ "aria-label": t("updateTitle"),
1810
+ "aria-busy": working,
1811
+ ...(initiallyOpen ? { open: true } : {}),
1812
+ },
1248
1813
  h("summary", { className: "dcpa-update-summary", title: t("updateToggleDetails") },
1249
1814
  h("span", { className: "dcpa-update-summary-title" }, t("updateTitle")),
1250
1815
  h("span", { className: "dcpa-update-summary-current" },
@@ -1272,9 +1837,9 @@ window.__ModuleLoader__.load({
1272
1837
  }, checking ? t("updateChecking") : !update && !loading ? t("updateRetryBtn") : t("updateCheckBtn")),
1273
1838
  h("button", {
1274
1839
  type: "button",
1275
- className: "dcpa-btn dcpa-btn-primary",
1840
+ className: marketReady && !linkedInstall ? "dcpa-btn" : "dcpa-btn dcpa-btn-primary",
1276
1841
  disabled: working || !canDownload,
1277
- onClick: () => setConfirmVersion(update.latestVersion),
1842
+ onClick: () => { setConfirmInstall(null); setConfirmVersion(update.latestVersion); },
1278
1843
  }, downloading ? t("updateDownloading") : update?.downloadedVersion === update?.latestVersion && update?.downloadedVersion
1279
1844
  ? t("updateDownloadedBtn") : t("updateDownloadBtn"))
1280
1845
  )
@@ -1298,6 +1863,7 @@ window.__ModuleLoader__.load({
1298
1863
  ),
1299
1864
  confirmVersion !== update?.latestVersion ? h("p", { className: "dcpa-hint" }, t("updateStaleVersion")) : null
1300
1865
  ) : null,
1866
+ marketSection,
1301
1867
  h("div", { className: "dcpa-update-row" },
1302
1868
  h("div", { className: "dcpa-field" },
1303
1869
  h("span", { className: "dcpa-label" }, t("updateChannelLabel")),
@@ -1319,6 +1885,8 @@ window.__ModuleLoader__.load({
1319
1885
  ),
1320
1886
  toggle("autoCheck", "updateAutoCheckLabel", "updateAutoCheckHint", update?.autoCheck !== false),
1321
1887
  toggle("autoDownload", "updateAutoDownloadLabel", "updateAutoDownloadHint", update?.autoDownload === true),
1888
+ toggle("autoInstall", "updateAutoInstallLabel", "updateAutoInstallHint", update?.autoInstall === true,
1889
+ !marketReady || linkedInstall || downloadBlocked),
1322
1890
  blockedReason ? h("div", { className: "dcpa-update-notice" }, h("p", { className: "dcpa-hint" }, t(blockedKey))) : null,
1323
1891
  update?.downloadedVersion && update?.downloadPath ? h("div", { className: "dcpa-update-notice", role: "status" },
1324
1892
  h("p", { className: "dcpa-label" }, t(blockedReason === "linked-install" ? "updateLinkedDownloadedHint" : "updateDownloadedHint").replace("{version}", update.downloadedVersion)),
@@ -1343,8 +1911,23 @@ window.__ModuleLoader__.load({
1343
1911
  }
1344
1912
 
1345
1913
  /**
1346
- * Account list + switcher. Each account is edited in its own
1347
- * {@link AccountPanel}, remounted when the selection changes.
1914
+ * The plugin's own page under Plugins (bundle page and Plugins tab): only
1915
+ * plugin updates. Account and model settings live in their own settings
1916
+ * section ({@link CpaSettingsPanel}).
1917
+ */
1918
+ function PluginUpdatePage({ t }) {
1919
+ return h(
1920
+ "div",
1921
+ { className: "dcpa-wrap", "data-dsh-plugin": MODULE_ID },
1922
+ h("p", { className: "dcpa-hint" }, t("updatePageHint")),
1923
+ h(PluginUpdatePanel, { t, market: sharedMarket, initiallyOpen: true })
1924
+ );
1925
+ }
1926
+
1927
+ /**
1928
+ * Account list + switcher (the "CLIProxyAPI" settings section). Each
1929
+ * account is edited in its own {@link AccountPanel}, remounted when the
1930
+ * selection changes.
1348
1931
  */
1349
1932
  function CpaSettingsPanel({ t }) {
1350
1933
  const [loading, setLoading] = useState(true);
@@ -1496,7 +2079,6 @@ window.__ModuleLoader__.load({
1496
2079
  error ? h("p", { className: "dcpa-error", role: "alert" }, error) : null,
1497
2080
  toast ? h("p", { className: "dcpa-success", role: "status" }, toast) : null
1498
2081
  ),
1499
- h(PluginUpdatePanel, { t }),
1500
2082
  active
1501
2083
  ? h(AccountPanel, {
1502
2084
  key: active.id,
@@ -2470,7 +3052,37 @@ window.__ModuleLoader__.load({
2470
3052
  );
2471
3053
  }
2472
3054
 
2473
- // 1. Register on the Bundle detail page in Sidebar -> Plugins -> CLIProxyAPI
3055
+ // dshmarket installation client, shared by the card and the opt-in
3056
+ // background auto-install runner. No DOM writes; timers and requests
3057
+ // are disposed with this activation (HMR/unmount).
3058
+ ctx.effect(() => {
3059
+ const market = createMarketUpdater();
3060
+ sharedMarket = market;
3061
+ const stopAutoInstall = startAutoInstall(market);
3062
+ return () => {
3063
+ stopAutoInstall();
3064
+ market.dispose();
3065
+ if (sharedMarket === market) sharedMarket = null;
3066
+ };
3067
+ }, "dsh-cliproxyapi: market updates");
3068
+
3069
+ // 1. Accounts and models: a page of its own in the Settings dialog's left
3070
+ // navigation (like dshmarket's "Market" section). The slot owner gives
3071
+ // unregistered ids the default nav glyph; no DOM is touched here.
3072
+ ctx.slots.inject("settings.section", () =>
3073
+ ctx.slots.register(
3074
+ {
3075
+ name: "settings.section",
3076
+ id: SETTINGS_SECTION_ID,
3077
+ order: 45,
3078
+ label: () => t("nav"),
3079
+ locale: NS,
3080
+ },
3081
+ () => h(CpaSettingsPanel, { t })
3082
+ )
3083
+ );
3084
+
3085
+ // 2. Plugin updates only, on the bundle page in Sidebar -> Plugins.
2474
3086
  ctx.slots.inject("plugins.bundle.config", () =>
2475
3087
  ctx.slots.register(
2476
3088
  {
@@ -2478,11 +3090,11 @@ window.__ModuleLoader__.load({
2478
3090
  key: MODULE_ID,
2479
3091
  locale: NS,
2480
3092
  },
2481
- (ownerProps = {}) => (ownerProps.view === "summary" ? null : h(CpaSettingsPanel, { t }))
3093
+ (ownerProps = {}) => (ownerProps.view === "summary" ? null : h(PluginUpdatePage, { t }))
2482
3094
  )
2483
3095
  );
2484
3096
 
2485
- // 2. Register as a dedicated tab in Settings -> Built-in plugins -> CLIProxyAPI
3097
+ // 3. Plugin updates only, as a tab in Settings -> Plugins.
2486
3098
  ctx.slots.inject("settings.plugins.tab", () =>
2487
3099
  ctx.slots.register(
2488
3100
  {
@@ -2492,7 +3104,7 @@ window.__ModuleLoader__.load({
2492
3104
  label: () => t("nav"),
2493
3105
  locale: NS,
2494
3106
  },
2495
- () => h(CpaSettingsPanel, { t })
3107
+ () => h(PluginUpdatePage, { t })
2496
3108
  )
2497
3109
  );
2498
3110
  }