@nimoxie/dsh-ui-tweaks 0.2.1 → 0.2.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
@@ -17,7 +17,7 @@ window.__ModuleLoader__.load({
17
17
  const module = { exports: {} };
18
18
  const exports = module.exports;
19
19
 
20
- const BUILD = "0.2.1";
20
+ const BUILD = "0.2.2";
21
21
 
22
22
  /* 每个模块的注册各套一层 guard:失败只记 warn(座位/模块失败模式是「静默消失」,
23
23
  必须主动吭声;但不能让一个模块的异常打断同文件里其它模块的 apply)。 */
@@ -725,27 +725,62 @@ window.__ModuleLoader__.load({
725
725
  (预检 + 二次确认 + host 已写生效豁免)才会带上它。用完即删、终态也清。 */
726
726
  var forcedNames = {}; // name → true
727
727
 
728
- function hostPost(route, body) {
728
+ /* 调 host 半区的本机回环路由。**必须带超时**:回包丢了不能让面板永远停在
729
+ 「正在写入豁免表…」—— 真机遇到过响应没回来、阶段无限停留(写盘其实已成功)。
730
+ AbortController 到点 abort,结果统一收敛成 {ok:false, code:"timeout"}。 */
731
+ function hostPost(route, body, timeoutMs) {
732
+ var ms = Math.max(1000, Number(timeoutMs) || 15000);
733
+ var ctl = null, timer = null;
734
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
735
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
736
+ var opts = {
737
+ method: "POST", cache: "no-store",
738
+ headers: { "Content-Type": "application/json" },
739
+ body: JSON.stringify(body || {}),
740
+ };
741
+ if (ctl) {
742
+ opts.signal = ctl.signal;
743
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, ms);
744
+ }
729
745
  try {
730
- return fetch("/api/ui-tweaks/" + route, {
731
- method: "POST", cache: "no-store",
732
- headers: { "Content-Type": "application/json" },
733
- body: JSON.stringify(body || {}),
734
- }).then(function (r) {
746
+ return fetch("/api/ui-tweaks/" + route, opts).then(function (r) {
747
+ stop();
735
748
  return r.json().then(function (j) { return j; }, function () { return { ok: false, code: "HTTP " + r.status }; });
736
- }).catch(function (e) { return { ok: false, code: String((e && e.message) || e) }; });
749
+ }).catch(function (e) {
750
+ stop();
751
+ var aborted = false;
752
+ try { aborted = !!(ctl && ctl.signal && ctl.signal.aborted); } catch (e3) {}
753
+ return { ok: false, code: aborted ? "timeout" : String((e && e.message) || e) };
754
+ });
737
755
  } catch (e) {
756
+ stop();
738
757
  return Promise.resolve({ ok: false, code: String((e && e.message) || e) });
739
758
  }
740
759
  }
741
- function hostGet(route) {
760
+ function hostGet(route, timeoutMs) {
761
+ var ms = Math.max(1000, Number(timeoutMs) || 10000);
762
+ var ctl = null, timer = null;
763
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
764
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
765
+ var opts = { cache: "no-store" };
766
+ if (ctl) {
767
+ opts.signal = ctl.signal;
768
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, ms);
769
+ }
742
770
  try {
743
- return fetch("/api/ui-tweaks/" + route, { cache: "no-store" })
771
+ return fetch("/api/ui-tweaks/" + route, opts)
744
772
  .then(function (r) {
773
+ stop();
745
774
  return r.json().then(function (j) { return j; }, function () { return { ok: false, code: "HTTP " + r.status }; });
746
775
  })
747
- .catch(function (e) { return { ok: false, code: String((e && e.message) || e) }; });
776
+ .catch(function (e) {
777
+ stop();
778
+ var aborted = false;
779
+ try { aborted = !!(ctl && ctl.signal && ctl.signal.aborted); } catch (e3) {}
780
+ return { ok: false, code: aborted ? "timeout" : String((e && e.message) || e) };
781
+ });
748
782
  } catch (e) {
783
+ stop();
749
784
  return Promise.resolve({ ok: false, code: String((e && e.message) || e) });
750
785
  }
751
786
  }
@@ -1030,7 +1065,7 @@ window.__ModuleLoader__.load({
1030
1065
  rstStage = 'confirm';
1031
1066
  rstRender();
1032
1067
  }
1033
- hostGet('restart').then(function (j) { plan = j || { ok: false, code: 'empty' }; decide(); });
1068
+ hostGet('restart', 10000).then(function (j) { plan = j || { ok: false, code: 'empty' }; decide(); });
1034
1069
  runningSessions(function (n, names) { running = n; rstNames = names || []; decide(); });
1035
1070
  }
1036
1071
 
@@ -1040,7 +1075,7 @@ window.__ModuleLoader__.load({
1040
1075
  showScrim(true);
1041
1076
  armKillTimer();
1042
1077
  rstRender();
1043
- hostPost('restart', {}).then(function (j) {
1078
+ hostPost('restart', {}, 16000).then(function (j) {
1044
1079
  if (!j || !j.ok) {
1045
1080
  /* 回包中断多半就是宿主已经退出(连接先断是正常的),但"多半"不等于一定:
1046
1081
  如实报成失败并给可点的出口(重试 / 刷新页面看看),别留一个死状态。 */
@@ -1079,10 +1114,16 @@ window.__ModuleLoader__.load({
1079
1114
  var row = findRow(name);
1080
1115
  if (!row || row.state !== "cooling" || !row.latest) { return; }
1081
1116
  forceSet(name, "checking");
1082
- hostPost("precheck-exempt", { name: name, version: row.latest, windowMs: policyWindowMs }).then(function (r) {
1117
+ /* 预检要逐包查 registry 发布时长,可能十几秒;给足 30 秒但仍要有上限 */
1118
+ hostPost("precheck-exempt", { name: name, version: row.latest, windowMs: policyWindowMs }, 30000).then(function (r) {
1083
1119
  var cur = findRow(name);
1084
1120
  if (!cur || cur.state !== "cooling") { forceReset(name); return; }
1085
- if (!r || !r.ok) { forceSet(name, "error", "预检没能完成:" + String((r && r.code) || "未知原因")); return; }
1121
+ if (!r || !r.ok) {
1122
+ forceSet(name, "error", (r && r.code) === "timeout"
1123
+ ? "预检超时(registry 响应慢),稍后重试。"
1124
+ : "预检没能完成:" + String((r && r.code) || "未知原因"));
1125
+ return;
1126
+ }
1086
1127
  var b = (r.blocked || []).filter(function (x) { return x && x.name && x.name !== name; });
1087
1128
  if (b.length) { forceBlocked[name] = b; forceSet(name, "blocked"); return; }
1088
1129
  forceSet(name, "confirm");
@@ -1094,10 +1135,33 @@ window.__ModuleLoader__.load({
1094
1135
  if (!row || !row.latest) { return; }
1095
1136
  var ver = row.latest;
1096
1137
  forceSet(name, "writing");
1097
- hostPost("force-exempt", { name: name, version: ver }).then(function (r) {
1098
- if (!r || !r.ok) { forceSet(name, "error", "写入豁免表失败:" + String((r && r.code) || "未知原因")); return; }
1138
+ /* 写豁免表是幂等的(同值重复写返回 changed:false),所以超时可以安全重试一次:
1139
+ 回包丢失时上一次很可能已经写成功,重试只会立刻返回 changed:false。 */
1140
+ function attempt(left) {
1141
+ return hostPost("force-exempt", { name: name, version: ver }, 15000).then(function (r) {
1142
+ if (r && r.code === "timeout" && left > 0) { return attempt(left - 1); }
1143
+ return r;
1144
+ });
1145
+ }
1146
+ attempt(1).then(function (r) {
1147
+ if (!r || !r.ok) {
1148
+ forceSet(name, "error", (r && r.code) === "timeout"
1149
+ ? "写入豁免表没有等到回应(超时)。这一步是幂等的:很可能已经写进去了 —— 点「重试」或先在官方插件页看一下豁免表都安全。"
1150
+ : "写入豁免表失败:" + String((r && r.code) || "未知原因"));
1151
+ return;
1152
+ }
1099
1153
  forceReset(name);
1100
1154
  forcedNames[name] = true; // 硬闸放行凭据(一次性,终态清理)
1155
+ /* 豁免不能「装完立刻删」:pnpm 每次安装都拿整个 lockfile 校验供应链策略,
1156
+ 被强制装上的版本还没满 24h 时删掉豁免,接下来几小时里所有 pnpm 操作都会被
1157
+ 整体拒绝。登记成熟时刻(+60s 缓冲),由 host 到点自动删掉这一条。 */
1158
+ try {
1159
+ var mature = isFinite(row.matureAt) ? Number(row.matureAt) : NaN;
1160
+ if (!isFinite(mature) && isFinite(row.publishedAt)) { mature = Number(row.publishedAt) + policyWindowMs; }
1161
+ if (isFinite(mature) && mature > 0) {
1162
+ hostPost("schedule-exempt-cleanup", { name: name, version: ver, dropAt: mature + 60000 }, 8000);
1163
+ }
1164
+ } catch (e1) { /* 登记失败只是这条豁免会多留一阵,不影响安装 */ }
1101
1165
  enqueue(name, { force: true });
1102
1166
  });
1103
1167
  }
@@ -1173,9 +1237,40 @@ window.__ModuleLoader__.load({
1173
1237
  return null;
1174
1238
  }
1175
1239
 
1240
+ /* 安装进度心跳:pnpm 一次重链几十个包时可能好几分钟零输出,看门狗不能只按总时长
1241
+ 判死。官方 client 用 remote.$on 订阅安装事件(plugin-manager/install-log 等),
1242
+ 我们拿同一组事件当「还在动」的心跳;拿不到就退回按总时长兜底。 */
1243
+ var pmEventsBound = false;
1244
+ var lastPmProgressAt = 0;
1245
+ function bindPmEvents() {
1246
+ if (pmEventsBound) { return; }
1247
+ var root = null;
1248
+ try { root = (ctx && ctx.remote) || null; } catch (e0) { root = null; }
1249
+ if (!root) { try { root = pickService(null, "remote"); } catch (e1) { root = null; } }
1250
+ if (!root || typeof root.$on !== "function") { return; }
1251
+ pmEventsBound = true;
1252
+ function note() { lastPmProgressAt = Date.now(); }
1253
+ try {
1254
+ root.$on("plugin-manager/install-log", note);
1255
+ root.$on("plugin-manager/install-state", note);
1256
+ root.$on("plugin-manager/changed", note);
1257
+ } catch (e2) {}
1258
+ }
1259
+
1260
+ /* /latest 必须带超时:比对是**串行**的,一个请求挂住会让后面所有包永远停在
1261
+ 「比对中 …」(fetch 默认没有超时,网络半死/代理吞包时最容易撞上)。 */
1262
+ var REGISTRY_TIMEOUT_MS = 12000;
1176
1263
  function fetchLatest(name) {
1177
1264
  var path = String(name).split("/").map(encodeURIComponent).join("/");
1178
- return fetch("https://registry.npmjs.org/" + path + "/latest", { cache: "no-store" })
1265
+ var ctl = null, timer = null;
1266
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
1267
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
1268
+ var opts = { cache: "no-store" };
1269
+ if (ctl) {
1270
+ opts.signal = ctl.signal;
1271
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, REGISTRY_TIMEOUT_MS);
1272
+ }
1273
+ return fetch("https://registry.npmjs.org/" + path + "/latest", opts)
1179
1274
  .then(function (r) {
1180
1275
  if (r.status === 404) {
1181
1276
  /* npm 上没有这个包:本地直装(link:/file:)或尚未发布 —— 不是「检查失败」。
@@ -1189,6 +1284,13 @@ window.__ModuleLoader__.load({
1189
1284
  })
1190
1285
  .then(function (d) {
1191
1286
  return d && d.version ? { version: String(d.version), requiresDsh: dshRequirementOf(d) } : null;
1287
+ })
1288
+ .then(function (v) { stop(); return v; }, function (e) {
1289
+ stop();
1290
+ if (e && (e.name === "AbortError" || /abort/i.test(String(e.message || "")))) {
1291
+ throw new Error("registry 请求超时(" + Math.round(REGISTRY_TIMEOUT_MS / 1000) + " 秒)");
1292
+ }
1293
+ throw e;
1192
1294
  });
1193
1295
  }
1194
1296
 
@@ -1226,17 +1328,29 @@ window.__ModuleLoader__.load({
1226
1328
  * 来源判定主路径是 host 半区的 /api/ui-tweaks/local-plugins(读 profile
1227
1329
  * 依赖声明):本地路径(link:/file:)与仓库/URL 直装(git/github/http)分列,
1228
1330
  * host 未重启/路由不可用时退回 registry 404 签名。 */
1331
+ /* host 半区的来源名单:它**卡住整条比对链**(回调不回来,所有行都停在「比对中 …」),
1332
+ 所以同样要超时;拿不到就当没有,退回 404 签名判定。 */
1333
+ var LOCAL_NAMES_TIMEOUT_MS = 8000;
1229
1334
  function fetchLocalNames(cb) {
1230
- fetch("/api/ui-tweaks/local-plugins", { cache: "no-store" })
1335
+ var ctl = null, timer = null;
1336
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
1337
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
1338
+ var opts = { cache: "no-store" };
1339
+ if (ctl) {
1340
+ opts.signal = ctl.signal;
1341
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, LOCAL_NAMES_TIMEOUT_MS);
1342
+ }
1343
+ fetch("/api/ui-tweaks/local-plugins", opts)
1231
1344
  .then(function (r) { if (!r.ok) { throw new Error("HTTP " + r.status); } return r.json(); })
1232
1345
  .then(function (d) {
1346
+ stop();
1233
1347
  cb({
1234
1348
  local: d && d.ok && Array.isArray(d.local) ? d.local : [],
1235
1349
  remote: d && d.ok && Array.isArray(d.remote) ? d.remote : [],
1236
1350
  dsh: d && d.ok && typeof d.dsh === "string" ? d.dsh : ""
1237
1351
  });
1238
1352
  })
1239
- .catch(function () { cb({ local: [], remote: [] }); });
1353
+ .catch(function () { stop(); cb({ local: [], remote: [] }); });
1240
1354
  }
1241
1355
  function isLocalSrc(src) { return /^(link:|file:)/.test(String(src || "")); }
1242
1356
  function isRemoteSrc(src) { return /^(git[+:@]|github:|git:|http)/.test(String(src || "")); }
@@ -1257,8 +1371,25 @@ window.__ModuleLoader__.load({
1257
1371
  var busyBefore = ""; // 本次安装前该行的版本(回读比对基准,不受中途重查影响)
1258
1372
  var busyLatest = ""; // 本次安装解析出的目标版本(取消后退回「有新版本」要用)
1259
1373
  var cancelling = false;
1374
+ /* 队列放行闸:底层请求真的收尾(busyChainDone)+ 终态已判定(busyFinished)才释放
1375
+ busy 并 pump 下一项。真机上「看门狗到点先放行、旧请求还在跑」会让两条 pnpm
1376
+ 叠在一起抢 profile 锁,表现就是全都卡住。 */
1377
+ var busyChainDone = true;
1378
+ var busyFinished = true;
1379
+ var busyReleased = true;
1380
+ var busyWatchdog = 0;
1381
+ var busyStartedAt = 0;
1260
1382
  var checking = false;
1261
1383
  var updatedCount = 0; // 本次页面会话里成功更新的插件数(>0 时头部挂「重启生效」提示条)
1384
+ /* 本次会话里成功更新、但装完仍是「关闭」的插件:重启提示栏里给一枚「启用」按钮。
1385
+ 官方安装默认不启用(installBundle 的 enabled 默认 false),我们更新时又刻意保留
1386
+ 行内原状态,所以「原本关着的插件」更新完仍然关着。按钮逐个调
1387
+ pluginManager.setBundleEnabled(name, true)(官方插件页那个总开关用的同一 API),
1388
+ 只动**这次更新过**的包,不碰用户没更新的插件。 */
1389
+ var updatedNames = []; // 更新成功的插件名(按顺序,去重)
1390
+ var updatedOff = {}; // name → true:更新后回读清单发现仍是关闭
1391
+ var enableStage = ""; // "" | "running" | "error"
1392
+ var enableNote = ""; // 启用失败的说明
1262
1393
  var isDesktop = /Electron/i.test(String(navigator.userAgent || ""));
1263
1394
  var listeners = []; // 面板重绘订阅(面板未开时也维护状态)
1264
1395
 
@@ -1309,14 +1440,29 @@ window.__ModuleLoader__.load({
1309
1440
  var name = b.name || b.id || "";
1310
1441
  var ver = b.version != null ? String(b.version) : (b.installedVersion != null ? String(b.installedVersion) : "");
1311
1442
  var src = typeof b.installed === "string" ? b.installed : (typeof b.source === "string" ? b.source : "");
1443
+ var errCode = b.error && b.error.code ? String(b.error.code) : "";
1444
+ var errDiag = b.error && b.error.diagnostic ? String(b.error.diagnostic) : "";
1445
+ /* listBundles 对「装了但读不到」的包仍会返回一行(带 error、没有 version)。
1446
+ 旧实现只显示「无法比对」,看不出是文件缺失还是清单坏了;把原因带到
1447
+ 标签悬停里(见 tagHtml 的 unknown 分支)。 */
1448
+ var why = "";
1449
+ if (!ver) {
1450
+ if (errCode) {
1451
+ why = "插件清单报错 " + errCode + (errDiag ? "(" + errDiag.slice(0, 160) + ")" : "") + ":读不到已装版本";
1452
+ } else if (b.availability === "missing") {
1453
+ why = "安装文件缺失(package.json 读不到)";
1454
+ }
1455
+ }
1312
1456
  return {
1313
1457
  name: name, version: ver, latest: "",
1314
1458
  state: isLocalSrc(src) ? "local" : (isRemoteSrc(src) ? "remote" : (ver ? "checking" : "unknown")),
1315
- src: src, enabled: b.enabled !== false, why: ""
1459
+ src: src, enabled: b.enabled !== false, why: why
1316
1460
  };
1317
1461
  }).filter(function (x) { return x.name; })
1318
1462
  /* 官方 @deepseek-ai 插件由 dsh 本体更新流程负责,直接从检查列表里剔除 */
1319
1463
  .filter(function (x) { return !/^@deepseek-ai\//.test(x.name); });
1464
+ /* 重查顺便校正「更新过但仍关闭」的标记(比如刚在官方插件页手动打开了) */
1465
+ rows.forEach(function (x) { if (x.enabled !== false) { delete updatedOff[x.name]; } });
1320
1466
  /* 与在飞的安装对齐(见 reconcileInstallStates):重查不得抹掉「更新中/等待更新」 */
1321
1467
  reconcileInstallStates();
1322
1468
  checking = false;
@@ -1447,6 +1593,21 @@ window.__ModuleLoader__.load({
1447
1593
  queue.splice(i, 1);
1448
1594
  setRow(name, { state: "update", why: "" });
1449
1595
  }
1596
+ /* 放行队列(幂等):busy 状态、看门狗、强制凭据一起收掉,再 pump 下一项。
1597
+ 只有「底层请求已收尾 + 终态已判定」才该调用它(见 startOne / finishOne 的 done)。
1598
+ expectedName:旧一项的迟到回调(先前超时被放行的链)不得放行**当前**这一项。 */
1599
+ function releaseBusy(expectedName) {
1600
+ if (busyReleased) { return; }
1601
+ if (expectedName && busyName && expectedName !== busyName) { return; }
1602
+ busyReleased = true;
1603
+ if (busyWatchdog) { clearInterval(busyWatchdog); busyWatchdog = 0; }
1604
+ var nm = busyName;
1605
+ busyName = ""; busyReq = ""; busyBefore = ""; busyLatest = "";
1606
+ cancelling = false;
1607
+ if (nm) { delete forcedNames[nm]; } // 放行凭据只对一次安装生效,终态一律清掉
1608
+ emit();
1609
+ pump();
1610
+ }
1450
1611
 
1451
1612
  /* ---- 更新 = 「先卸载、再安装」(DSH 安装接口的硬约束)--------------------
1452
1613
  官方 installBundle 在**开着 HMR(热重载)的运行时**里会直接拒绝:
@@ -1484,6 +1645,12 @@ window.__ModuleLoader__.load({
1484
1645
  var REMOVE_POLL_MS = 400; // 轮询间隔
1485
1646
  var INSTALL_RETRY_TIMES = 3; // 被 already-installed 拒后的重试次数
1486
1647
  var INSTALL_RETRY_DELAY_MS = 1500; // 每次重试前等多久
1648
+ /* 看门狗(真机教训:一次重链 44 个包的卸载跑了 5 分 08 秒,旧的固定 240 秒把它
1649
+ 判成超时,队列提前放行,旧请求还在跑 → 两条 pnpm 抢锁,全都卡住): */
1650
+ var WATCHDOG_TICK_MS = 5000; // 巡检间隔
1651
+ var SLOW_NOTE_MS = 90000; // 无进度 90s:只在行内提示「还在跑」
1652
+ var HARD_CAP_MS = 15 * 60000; // 单项总时长上限:到点才判超时
1653
+ var HARD_CAP_GRACE_MS = 5 * 60000; // 判超时后再等底层收尾 5 分钟才放行队列
1487
1654
  function bundleStillListed(pm, nm) {
1488
1655
  return Promise.resolve(pm.listBundles()).then(function (r) {
1489
1656
  var list = r && r.ok === false ? null : (r && r.value) || [];
@@ -1521,8 +1688,23 @@ window.__ModuleLoader__.load({
1521
1688
  }
1522
1689
  return Promise.resolve(pm.removeBundle(nm)).then(function (rm) {
1523
1690
  var rmFail = resultFailure(rm);
1524
- /* 卸载被拒:把原始拒绝交回去,同时把"卸载失败"这个真实原因一起带出,别再吞掉 */
1525
- if (rmFail) { return { result: res, removed: false, removeFailure: rmFail }; }
1691
+ /* 卸载被拒:把原始拒绝交回去,同时把"卸载失败"这个真实原因一起带出,别再吞掉。
1692
+ 光一个 operation-error 没法排查 —— 把卸载那次的 diagnostic / pnpm 输出首行
1693
+ 一并带上(例如 pnpm 读不了 pnpm-workspace.yaml 时的 YAML 报错)。 */
1694
+ if (rmFail) {
1695
+ var diag = "";
1696
+ try {
1697
+ var st = rm && rm.value;
1698
+ diag = String((st && st.error && st.error.diagnostic) || (st && st.packageResult && st.packageResult.output) || "");
1699
+ } catch (e0) { diag = ""; }
1700
+ diag = stripAnsi(diag).split("\n").filter(function (ln) { return String(ln).trim(); })[0] || "";
1701
+ if (diag.length > 160) { diag = diag.slice(0, 160) + "…"; }
1702
+ try {
1703
+ var pr = rm && rm.value && rm.value.packageResult;
1704
+ if (pr && pr.logPath) { diag += (diag ? ";" : "") + "日志 " + pr.logPath; }
1705
+ } catch (e1) { /* 字段缺失就算了 */ }
1706
+ return { result: res, removed: false, removeFailure: rmFail + (diag ? ":" + diag : "") };
1707
+ }
1526
1708
  if (onRemoved) { onRemoved(); }
1527
1709
  return waitBundlesGone(pm, nm)
1528
1710
  .then(function () { return installRetrying(pm, spec, opts); })
@@ -1549,28 +1731,54 @@ window.__ModuleLoader__.load({
1549
1731
 
1550
1732
  function startOne(name) {
1551
1733
  var row = findRow(name);
1734
+ var pm = pickService(null, "remote.pluginManager");
1552
1735
  busyName = name;
1553
1736
  busyBefore = row ? row.version : "";
1554
1737
  busyLatest = (row && row.latest) || "";
1555
1738
  busyReq = uuid();
1739
+ busyChainDone = false;
1740
+ busyFinished = false;
1741
+ busyReleased = false;
1742
+ busyStartedAt = Date.now();
1556
1743
  setRow(name, { state: "busy", why: "" });
1557
1744
  emit();
1558
- var pm = pickService(null, "remote.pluginManager");
1559
- if (!pm) { finishOne(name, "failed", "remote.pluginManager 不可用"); return; }
1745
+ if (!pm) { busyChainDone = true; finishOne(name, "failed", "remote.pluginManager 不可用"); return; }
1560
1746
  var settled = false;
1561
1747
  var busyRemoved = false; // 本次更新走没走「先卸载再安装」:卸载过 → 失败必须回滚
1562
- /* 看门狗只作兜底:正常路径一次调用就定案,不等它。 */
1563
- var watchdog = setTimeout(function () {
1564
- finish("unknown", "更新超时(240 秒未结束);结果未知,请点「重新检查」确认");
1565
- try { var pm2 = pickService(null, "remote.pluginManager"); if (pm2 && pm2.cancelInstall && busyReq) { pm2.cancelInstall(busyReq); } } catch (e) {}
1566
- }, 240000);
1748
+ var lastProgress = busyStartedAt;
1749
+ var slowNote = "";
1750
+ bindPmEvents();
1751
+ /* 看门狗改成「有进度就不算卡」:①无输出 90s 只在行内提示仍在跑;②单项 15 分钟
1752
+ 才判超时;③判超时后仍等底层请求收尾(最多再 5 分钟)才放行队列 —— 否则旧请求
1753
+ 没结束、下一项又开始,两条 pnpm 会在 profile 锁上互相等,表现就是全都卡住。 */
1754
+ busyWatchdog = setInterval(function () {
1755
+ if (settled) { return; }
1756
+ var now = Date.now();
1757
+ if (lastPmProgressAt > lastProgress) { lastProgress = lastPmProgressAt; }
1758
+ var total = now - busyStartedAt;
1759
+ var idle = now - lastProgress;
1760
+ if (total >= HARD_CAP_MS) {
1761
+ clearInterval(busyWatchdog); busyWatchdog = 0;
1762
+ finish("unknown", "更新超时(" + Math.round(total / 60000) + " 分钟未结束);结果未知,请点「重新检查」确认");
1763
+ try { var pm2 = pickService(null, "remote.pluginManager"); if (pm2 && pm2.cancelInstall && busyReq) { pm2.cancelInstall(busyReq); } } catch (e) {}
1764
+ /* 判超时后底层请求可能还在跑:再给 5 分钟收尾,才放行队列 */
1765
+ setTimeout(function () { if (!busyReleased && busyName === name) { releaseBusy(name); } }, HARD_CAP_GRACE_MS);
1766
+ return;
1767
+ }
1768
+ if (idle >= SLOW_NOTE_MS && !settled) {
1769
+ var note = "pnpm 仍在运行(已 " + Math.max(1, Math.round(total / 60000)) +
1770
+ " 分钟,最近一次输出在 " + Math.round(idle / 1000) + " 秒前);要重链几十个包时可能超过 5 分钟,请再等等";
1771
+ if (note !== slowNote) { slowNote = note; setRow(name, { state: "busy", why: note }); emit(); }
1772
+ }
1773
+ }, WATCHDOG_TICK_MS);
1567
1774
  function finish(kind, why, meta) {
1568
1775
  if (settled) { return; }
1569
1776
  settled = true;
1570
- clearTimeout(watchdog);
1777
+ busyFinished = true;
1778
+ if (busyWatchdog) { clearInterval(busyWatchdog); busyWatchdog = 0; }
1571
1779
  /* 卸载成功但重装没成(失败/被取消)→ 把旧版本装回去,绝不把插件留在"没了"的状态。
1572
1780
  只有 failed / cancelled 自动回滚:unknown 时安装可能仍在飞,硬回滚会打架,
1573
- 改为在文案里讲清楚"中途卸载过,确认它装回来了没有"。 */
1781
+ 改为交给 recoverAfterUnknown 复查(见下)。 */
1574
1782
  if (busyRemoved && busyBefore && (kind === "failed" || kind === "cancelled")) {
1575
1783
  var pmR = pickService(null, "remote.pluginManager");
1576
1784
  if (pmR && typeof pmR.installBundle === "function") {
@@ -1589,11 +1797,40 @@ window.__ModuleLoader__.load({
1589
1797
  return;
1590
1798
  }
1591
1799
  if (busyRemoved && kind === "unknown") {
1592
- finishOne(name, kind, (why || "") + "(注意:本次更新中途卸载过该插件,请点「重新检查」确认它是否装回来了)", meta);
1800
+ recoverAfterUnknown(name, busyBefore, why || "", meta);
1593
1801
  return;
1594
1802
  }
1595
1803
  finishOne(name, kind, why || "", meta);
1596
1804
  }
1805
+ /* 超时(unknown)且本次中途卸载过:不能只写一句「请确认」就放走 —— 真机上
1806
+ @linxin666/dsh-session-archive 就是这样被卸掉后没装回来(后续「装回」步骤的
1807
+ 结果还会因为 settled 被丢弃)。这里主动复查清单:不在就自动装回旧版本。 */
1808
+ function recoverAfterUnknown(nm, ver, why, meta) {
1809
+ var pmR = pickService(null, "remote.pluginManager");
1810
+ if (!ver || !pmR || typeof pmR.listBundles !== "function") {
1811
+ finishOne(nm, "unknown", why + (ver ? ";本次中途卸载过该插件,请手动重装 " + nm + "@" + ver : ""), meta);
1812
+ return;
1813
+ }
1814
+ setRow(nm, { state: "busy", why: "更新超时,正在确认 " + nm + " 还在不在并自动回滚 …" });
1815
+ emit();
1816
+ Promise.resolve(pmR.listBundles()).then(function (r) {
1817
+ var list = r && r.ok !== false ? (r.value || []) : [];
1818
+ var present = false;
1819
+ for (var i = 0; i < list.length; i++) { if (list[i] && list[i].name === nm) { present = true; break; } }
1820
+ if (present) {
1821
+ finishOne(nm, "unknown", why + ";该插件仍在清单中,版本请点「重新检查」确认", meta);
1822
+ return;
1823
+ }
1824
+ return Promise.resolve(pmR.installBundle(nm + "@" + ver, { enabled: true })).then(function (rr) {
1825
+ var rf = resultFailure(rr);
1826
+ finishOne(nm, "unknown", why + (rf
1827
+ ? ";自动回滚失败(" + rf + "),请手动重装 " + nm + "@" + ver
1828
+ : ";已自动装回 " + nm + "@" + ver), meta);
1829
+ });
1830
+ }).catch(function () {
1831
+ finishOne(nm, "unknown", why + ";复查/回滚失败,请手动确认并重装 " + nm + "@" + ver, meta);
1832
+ });
1833
+ }
1597
1834
  function settle(st) {
1598
1835
  if (!st || typeof st !== "object") { finish("unknown", "安装未返回可判定的结果"); return; }
1599
1836
  var app = st.application;
@@ -1635,17 +1872,17 @@ window.__ModuleLoader__.load({
1635
1872
  function abortForCooling(version, publishedAt, matureAt) {
1636
1873
  if (settled) { return; }
1637
1874
  settled = true;
1638
- clearTimeout(watchdog);
1875
+ busyFinished = true;
1876
+ if (busyWatchdog) { clearInterval(busyWatchdog); busyWatchdog = 0; }
1639
1877
  setRow(name, {
1640
1878
  state: "cooling", latest: version, why: "", publishedAt: publishedAt, matureAt: matureAt,
1641
1879
  detail: "安装前校验:registry 记录 " + version + " 发布于 " + new Date(publishedAt).toISOString() +
1642
1880
  ",仍在 minimumReleaseAge 窗口内(" + fmtWindow(policyWindowMs) + "),本次安装已拦下(未安装)。"
1643
1881
  });
1644
1882
  scheduleCoolingRefresh(name, matureAt);
1645
- busyName = ""; busyReq = ""; busyBefore = ""; busyLatest = "";
1646
- cancelling = false;
1647
- emit();
1648
- pump();
1883
+ /* 还没开始装就被拦下:底层链不会再跑,直接放行队列 */
1884
+ busyChainDone = true;
1885
+ releaseBusy(name);
1649
1886
  }
1650
1887
  function guardMaturity(nm, version, go) {
1651
1888
  if (!version) { go(); return; } // 版本未知(@latest 兜底路径)不拦
@@ -1702,7 +1939,15 @@ window.__ModuleLoader__.load({
1702
1939
  finish("failed", text); return;
1703
1940
  }
1704
1941
  settle(res.value);
1705
- }).catch(function (e) { finish("failed", "安装请求失败:" + msgOf(e)); });
1942
+ }).catch(function (e) { finish("failed", "安装请求失败:" + msgOf(e)); })
1943
+ .then(chainSettled, chainSettled);
1944
+ /* 底层链真正收尾:只有「终态已判定 + 链已收尾」才放行队列 —— 旧实现看门狗到点就
1945
+ done()→pump(),旧请求还在跑,下一项又发 pnpm,两条操作叠在 profile 锁上。 */
1946
+ function chainSettled() {
1947
+ if (busyName !== name) { return; } // 这一项早已收尾/被放行:别动当前项
1948
+ busyChainDone = true;
1949
+ if (busyFinished) { releaseBusy(name); }
1950
+ }
1706
1951
  }
1707
1952
 
1708
1953
  /* 终态落定:回读清单核对真实版本(registry 解析与 pnpm 实际安装可能不一致,
@@ -1714,16 +1959,27 @@ window.__ModuleLoader__.load({
1714
1959
  var before = busyBefore;
1715
1960
  var latest = busyLatest;
1716
1961
  var pm = pickService(null, "remote.pluginManager");
1962
+ var doneOnce = false;
1963
+ var doneGuard = 0;
1717
1964
  function done() {
1718
- busyName = ""; busyReq = ""; busyBefore = ""; busyLatest = "";
1719
- cancelling = false;
1720
- delete forcedNames[name]; // 放行凭据只对一次安装生效,终态一律清掉
1721
- emit();
1722
- pump();
1723
- }
1724
- function judge(ver) {
1965
+ if (doneOnce) { return; }
1966
+ doneOnce = true;
1967
+ if (doneGuard) { clearTimeout(doneGuard); doneGuard = 0; }
1968
+ if (busyName !== name) { return; } // 迟到回调(旧一项已被放行):不改当前项
1969
+ busyFinished = true;
1970
+ /* 链已收尾就直接放行;否则等 chainSettled(例如 lostReply 还在等 waitForInstall) */
1971
+ if (busyChainDone) { releaseBusy(name); }
1972
+ }
1973
+ /* 回读清单本身也可能挂住(RPC 无超时):60 秒还没结论就先收尾,别让队列陪着它卡死 */
1974
+ doneGuard = setTimeout(done, 60000);
1975
+ function judge(ver, info) {
1725
1976
  if (ver && ver !== before) {
1726
1977
  updatedCount++; // 版本真的变了:装到盘上的已是新代码,运行中的进程还是旧的
1978
+ if (updatedNames.indexOf(name) < 0) { updatedNames.push(name); }
1979
+ /* 装完回读的 enabled 才是真的:官方安装默认不启用,我们传的是行内原状态,
1980
+ 所以「原本关着的插件」更新完仍然关着 —— 记下来给重启提示栏的启用按钮用。 */
1981
+ var onNow = info ? info.enabled !== false : ((findRow(name) || {}).enabled !== false);
1982
+ if (onNow) { delete updatedOff[name]; } else { updatedOff[name] = true; }
1727
1983
  setRow(name, { version: ver, state: "current", latest: ver, why: "", publishedAt: NaN, matureAt: NaN, detail: "" });
1728
1984
  /* 安装真的落地了:顺手让 host 半边归并豁免表,免得陈旧条遮蔽下一次安装 */
1729
1985
  normalizeExcludes();
@@ -1750,23 +2006,85 @@ window.__ModuleLoader__.load({
1750
2006
  done();
1751
2007
  }
1752
2008
  function attempt(n) {
1753
- if (!pm || !pm.listBundles) { judge(""); return; }
2009
+ if (!pm || !pm.listBundles) { judge("", null); return; }
1754
2010
  Promise.resolve(pm.listBundles()).then(function (r) {
1755
2011
  var bundles = (r && r.ok && r.value) || [];
1756
2012
  if (!bundles.length && n < 3) { setTimeout(function () { attempt(n + 1); }, 1500); return; }
1757
- var ver = "";
2013
+ var found = null;
1758
2014
  for (var i = 0; i < bundles.length; i++) {
1759
- if (bundles[i].name === name) { ver = bundles[i].version != null ? String(bundles[i].version) : ""; break; }
2015
+ if (bundles[i].name === name) { found = bundles[i]; break; }
1760
2016
  }
1761
- judge(ver);
2017
+ judge(found && found.version != null ? String(found.version) : "", found || null);
1762
2018
  }).catch(function () {
1763
2019
  if (n < 3) { setTimeout(function () { attempt(n + 1); }, 1500); return; }
1764
- judge("");
2020
+ judge("", null);
1765
2021
  });
1766
2022
  }
1767
2023
  attempt(0);
1768
2024
  }
1769
2025
 
2026
+ /* ---- 重启提示栏里的「启用已更新的插件」 ---------------------------------
2027
+ * 官方安装/更新的默认结果是**不启用**(installBundle 的 enabled 默认 false),
2028
+ * 面板更新又刻意保留行内原状态,于是「原本关着的插件」更新完仍然关着。
2029
+ * 这里给一个显式按钮:逐个调 setBundleEnabled(name, true)(官方插件页总开关同款),
2030
+ * 串行执行(host 每次启用都会写 manifest + reload,别并发),只动本次更新过的包。
2031
+ * 成功就从标记里删掉;失败留在按钮里可重试,原因写在提示条下方。 */
2032
+ function pendingEnableNames() {
2033
+ var out = [];
2034
+ for (var i = 0; i < updatedNames.length; i++) {
2035
+ if (updatedOff[updatedNames[i]]) { out.push(updatedNames[i]); }
2036
+ }
2037
+ return out;
2038
+ }
2039
+ function enableUpdated() {
2040
+ if (enableStage === "running") { return; }
2041
+ var todo = pendingEnableNames();
2042
+ if (!todo.length) { return; }
2043
+ var pm = pickService(null, "remote.pluginManager");
2044
+ if (!pm || typeof pm.setBundleEnabled !== "function") {
2045
+ enableStage = "error";
2046
+ enableNote = "这个 dsh 版本的插件管理器没有 setBundleEnabled,请到官方插件页手动打开这几个插件。";
2047
+ emit();
2048
+ return;
2049
+ }
2050
+ enableStage = "running";
2051
+ enableNote = "";
2052
+ emit();
2053
+ var failed = [];
2054
+ function step(i) {
2055
+ if (i >= todo.length) {
2056
+ enableStage = failed.length ? "error" : "";
2057
+ enableNote = failed.length ? "有 " + failed.length + " 个没启用成功:" + failed.join("、") + ";可以再点一次。" : "";
2058
+ emit();
2059
+ return;
2060
+ }
2061
+ var nm = todo[i];
2062
+ var p;
2063
+ try { p = Promise.resolve(pm.setBundleEnabled(nm, true)); }
2064
+ catch (e0) { failed.push(nm + "(" + msgOf(e0) + ")"); step(i + 1); return; }
2065
+ p.then(function (res) {
2066
+ var code = "";
2067
+ if (!res || res.ok === false) {
2068
+ code = String((res && res.error && res.error.code) || "unknown-error");
2069
+ } else {
2070
+ var st = res.value;
2071
+ if (st && st.application === "failed") { code = String((st.error && st.error.code) || "unknown-error"); }
2072
+ }
2073
+ if (code) {
2074
+ failed.push(nm + "(" + code + ")");
2075
+ } else {
2076
+ delete updatedOff[nm];
2077
+ setRow(nm, { enabled: true });
2078
+ }
2079
+ step(i + 1);
2080
+ }, function (e) {
2081
+ failed.push(nm + "(" + msgOf(e) + ")");
2082
+ step(i + 1);
2083
+ });
2084
+ }
2085
+ step(0);
2086
+ }
2087
+
1770
2088
  /* 取消:排队项只摘自己(cancelQueued);在装项请求取消**本次**,队列保留,
1771
2089
  等它落定后 done() → pump() 自然接着装下一项。
1772
2090
  旧实现把整个 queue 清空,于是「等待更新」的项全部退回「有新版本」——
@@ -1879,7 +2197,7 @@ window.__ModuleLoader__.load({
1879
2197
  var rw = liveWrap(RST_PANEL_ID, '.rwrap');
1880
2198
  if (rw) { placeNear(rstBtn, rw, RST_WIDTH, 180); }
1881
2199
  var pw = liveWrap(PANEL_ID, '.wrap');
1882
- if (pw) { placeNear(document.getElementById(BTN_ID), pw, PANEL_WIDTH, 220); }
2200
+ if (pw) { placeNear(document.getElementById(BTN_ID), pw, PANEL_WIDTH, 220); syncSb(pw); }
1883
2201
  };
1884
2202
  try { if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(run); return; } } catch (e) {}
1885
2203
  setTimeout(run, 0);
@@ -1923,7 +2241,20 @@ window.__ModuleLoader__.load({
1923
2241
  ".hd b{flex:1;font-size:13px;font-weight:600}" +
1924
2242
  ".x{cursor:pointer;opacity:.55;font-size:17px;line-height:1;padding:0 3px}" +
1925
2243
  ".x:hover{opacity:1}" +
1926
- ".rows{flex:1 1 auto;min-height:0;overflow:auto}" +
2244
+ ".rowswrap{flex:1 1 auto;min-height:0;position:relative;display:flex;flex-direction:column}" +
2245
+ /* 列表容器:隐藏原生滚动条(Windows 上会吃掉 17px 行内宽),改用浮层拇指 */
2246
+ ".rows{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;-ms-overflow-style:none}" +
2247
+ ".rows::-webkit-scrollbar{width:0;height:0}" +
2248
+ /* 浮层滚动条:与对话区那条自绘滚动条同一套观感(currentColor、7px→9px、可拖动) */
2249
+ ".sbbar{position:absolute;top:0;right:0;width:12px;height:100%;z-index:2;" +
2250
+ "user-select:none;-webkit-user-select:none;touch-action:none}" +
2251
+ ".sbbar[hidden]{display:none}" +
2252
+ ".sbthumb{position:absolute;right:0;top:0;width:100%;background:transparent}" +
2253
+ ".sbthumb::after{content:\"\";position:absolute;top:0;bottom:0;right:2px;width:7px;border-radius:4px;" +
2254
+ "background:rgba(128,128,128,.45);background:color-mix(in srgb,currentColor 36%,transparent);" +
2255
+ "transition:width .12s,right .12s,background .12s}" +
2256
+ ".sbbar:hover .sbthumb::after,.sbthumb.drag::after{right:1px;width:9px;" +
2257
+ "background:rgba(128,128,128,.68);background:color-mix(in srgb,currentColor 60%,transparent)}" +
1927
2258
  ".rowwrap{border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.12))}" +
1928
2259
  ".rowwrap:last-child{border-bottom:0}" +
1929
2260
  ".row{display:flex;align-items:center;gap:8px;padding:7px 12px}" +
@@ -1972,6 +2303,9 @@ window.__ModuleLoader__.load({
1972
2303
  ".restart{flex:0 0 auto;padding:6px 12px;font-size:12px;line-height:1.5;" +
1973
2304
  "background:rgba(62,207,142,.12);color:#4fbf8b;" +
1974
2305
  "border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.18))}" +
2306
+ /* 提示栏里的「启用已更新的插件」:与冷静期详情里的强制按钮同一套 .fbtn.fgo 样式 */
2307
+ ".restart .fbtn{margin-left:8px;vertical-align:1px}" +
2308
+ ".rnote{margin-top:4px;font-size:11px;line-height:1.45;color:#e07070;opacity:.9}" +
1975
2309
  ".foot{padding:8px 12px;font-size:11px;line-height:1.5;opacity:.6;" +
1976
2310
  "border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.12))}" +
1977
2311
  "</style><div class=\"wrap\" role=\"dialog\" aria-label=\"插件更新\" tabindex=\"-1\" hidden></div>";
@@ -2009,6 +2343,8 @@ window.__ModuleLoader__.load({
2009
2343
  rows.forEach(function (r) { if (r.state === "update" && queue.indexOf(r.name) < 0 && r.name !== busyName) { enqueue(r.name); } });
2010
2344
  return;
2011
2345
  }
2346
+ /* 重启提示栏里的「启用已更新的插件」(橙色按钮,样式同强制更新) */
2347
+ if (t.classList.contains("renable")) { enableUpdated(); return; }
2012
2348
  if (t.classList.contains("cooling")) {
2013
2349
  /* 冷静期标签:只展开 / 收起详情,绝不触发安装 */
2014
2350
  var nm0 = t.getAttribute("data-n");
@@ -2141,15 +2477,127 @@ window.__ModuleLoader__.load({
2141
2477
  return "<div class=\"warn\">⚠ 有会话在用「自动审查」:装/更新插件容易失败,工具调用也可能被审查拦掉。建议先切「工作区内修改」,装完再切回。</div>";
2142
2478
  }
2143
2479
 
2480
+ /* ---- 浮层滚动条(更新面板)-----------------------------------------------
2481
+ * 原生滚动条会在 Windows 上占掉列表 17px 的宽,把每一行往里挤;这里隐藏原生条,
2482
+ * 只在内容右缘画一条拇指(绝对定位、不参与布局)。
2483
+ * **抄对话区那条自绘滚动条的观感与手感**(模块 2,A/B 实测过的实现):
2484
+ * · 视觉:默认 `color-mix(currentColor 32%)`、悬停/拖动时加宽到 9px、55%;
2485
+ * 不自动隐藏(常驻半透明,一眼看得出还能滚)。
2486
+ * · 拖动:PointerCapture + 按下点偏移(不是「拇指中心跳过来」),
2487
+ * 点轨道时先把拇指中心移到点击处再顺势进入拖动;pointermove 同步写
2488
+ * scrollTop,scroll 事件回纠几何;轨道上滚轮转发给真容器。
2489
+ * 面板是 Shadow DOM 浮层:事件只挂在面板内部节点,不监听 document。 */
2490
+ var SB_THUMB_MIN = 28;
2491
+ function sbParts(wrap) {
2492
+ var sc = wrap && wrap.querySelector(".rows");
2493
+ var bar = wrap && wrap.querySelector(".sbbar");
2494
+ var th = bar && bar.querySelector(".sbthumb");
2495
+ return sc && bar && th ? { sc: sc, bar: bar, th: th } : null;
2496
+ }
2497
+ /* 几何:高 = 轨道 × 可见比,位置 = scrollTop / 可滚动范围。
2498
+ 注意别用 bar.clientHeight 量轨道高:`hidden` 时它是 0,会把自己永久锁在隐藏。
2499
+ 轨道与滚动容器同高(都填满 .rowswrap),用视口高即可。 */
2500
+ function syncSb(wrap) {
2501
+ var p = sbParts(wrap);
2502
+ if (!p) { return; }
2503
+ var viewH = p.sc.clientHeight;
2504
+ var range = p.sc.scrollHeight - viewH;
2505
+ if (range <= 0 || viewH <= 0) { p.bar.hidden = true; return; }
2506
+ p.bar.hidden = false;
2507
+ var barH = viewH;
2508
+ var h = Math.round(barH * viewH / p.sc.scrollHeight);
2509
+ if (h < SB_THUMB_MIN) { h = SB_THUMB_MIN; }
2510
+ if (h > barH) { h = barH; }
2511
+ var maxTop = barH - h;
2512
+ var top = maxTop > 0 ? Math.round(maxTop * p.sc.scrollTop / range) : 0;
2513
+ if (top < 0) { top = 0; }
2514
+ if (top > maxTop) { top = maxTop; }
2515
+ if (p.th.style.height !== h + "px") { p.th.style.height = h + "px"; }
2516
+ if (p.th.style.top !== top + "px") { p.th.style.top = top + "px"; }
2517
+ }
2518
+ function bindSb(wrap) {
2519
+ var p = sbParts(wrap);
2520
+ if (!p || p.bar.__sbBound) { return; }
2521
+ p.bar.__sbBound = true;
2522
+ var sc = p.sc, bar = p.bar, th = p.th;
2523
+ var drag = null;
2524
+ sc.addEventListener("scroll", function () { syncSb(wrap); }, { passive: true });
2525
+ bar.addEventListener("pointerdown", function (e) {
2526
+ if (e.button !== undefined && e.button !== 0) { return; }
2527
+ var range = sc.scrollHeight - sc.clientHeight;
2528
+ var barH = sc.clientHeight;
2529
+ if (range <= 0 || barH <= 0) { return; }
2530
+ syncSb(wrap);
2531
+ var barRect = bar.getBoundingClientRect();
2532
+ var thRect = th.getBoundingClientRect();
2533
+ if (e.clientY >= thRect.top && e.clientY <= thRect.bottom) {
2534
+ /* 按在拇指上:保持按下点与拇指顶部的偏移 */
2535
+ drag = { offsetY: e.clientY - thRect.top, barRect: barRect };
2536
+ } else {
2537
+ /* 按在轨道上:先把拇指中心挪到点击处,再顺势进入拖动 */
2538
+ var h = th.offsetHeight || SB_THUMB_MIN;
2539
+ var maxTop = Math.max(0, barH - h);
2540
+ var top0 = Math.round(e.clientY - barRect.top - h / 2);
2541
+ if (top0 < 0) { top0 = 0; }
2542
+ if (top0 > maxTop) { top0 = maxTop; }
2543
+ sc.scrollTop = maxTop > 0 ? top0 / maxTop * range : 0;
2544
+ syncSb(wrap);
2545
+ thRect = th.getBoundingClientRect();
2546
+ drag = { offsetY: e.clientY - thRect.top, barRect: barRect };
2547
+ }
2548
+ th.classList.add("drag");
2549
+ try { bar.setPointerCapture(e.pointerId); } catch (e0) {}
2550
+ e.preventDefault();
2551
+ });
2552
+ bar.addEventListener("pointermove", function (e) {
2553
+ if (!drag) { return; }
2554
+ var range = sc.scrollHeight - sc.clientHeight;
2555
+ var barH = sc.clientHeight;
2556
+ var h = th.offsetHeight || SB_THUMB_MIN;
2557
+ var maxTop = Math.max(0, barH - h);
2558
+ var top = e.clientY - drag.barRect.top - drag.offsetY;
2559
+ if (top < 0) { top = 0; }
2560
+ if (top > maxTop) { top = maxTop; }
2561
+ sc.scrollTop = maxTop > 0 ? top / maxTop * range : 0;
2562
+ syncSb(wrap);
2563
+ e.preventDefault();
2564
+ });
2565
+ function release(e) {
2566
+ if (!drag) { return; }
2567
+ drag = null;
2568
+ th.classList.remove("drag");
2569
+ try { bar.releasePointerCapture(e.pointerId); } catch (e0) {}
2570
+ }
2571
+ bar.addEventListener("pointerup", release);
2572
+ bar.addEventListener("pointercancel", release);
2573
+ /* 指针压在条上时,滚轮转发给真容器(条是覆盖层,不是滚动容器本身) */
2574
+ bar.addEventListener("wheel", function (e) {
2575
+ e.preventDefault();
2576
+ sc.scrollTop += e.deltaY;
2577
+ syncSb(wrap);
2578
+ }, { passive: false });
2579
+ syncSb(wrap);
2580
+ }
2581
+
2144
2582
  function paint() {
2145
2583
  var wrap = panelWrap();
2146
2584
  var upd = 0;
2147
2585
  /* 有成功更新就常驻提示:装到盘上的是新代码,运行中的还是旧的,
2148
2586
  不重启(桌面端)/不重启 dsh 并强刷(web 端)就不会生效。 */
2149
2587
  function restartBar() {
2150
- if (!updatedCount) { return ""; }
2151
- return "<div class=\"restart\">已更新 " + updatedCount + " 个插件," +
2152
- (isDesktop ? "重启客户端后生效" : "重启 dsh 服务并刷新页面后生效") + "</div>";
2588
+ var pending = pendingEnableNames();
2589
+ if (!updatedCount && !pending.length) { return ""; }
2590
+ var head = updatedCount
2591
+ ? "已更新 " + updatedCount + " 个插件" +
2592
+ (pending.length ? ",其中 " + pending.length + " 个仍是关闭状态" : "") +
2593
+ "," + (isDesktop ? "重启客户端后生效" : "重启 dsh 服务并刷新页面后生效")
2594
+ : "";
2595
+ var btn = pending.length
2596
+ ? "<span class=\"fbtn fgo renable\"" + KBD + " title=\"调用插件管理器的 setBundleEnabled,逐个打开这次更新过、但仍是关闭的插件\">" +
2597
+ (enableStage === "running" ? "正在启用 …" : "启用已更新的 " + pending.length + " 个插件") + "</span>"
2598
+ : "";
2599
+ var note = enableNote ? "<div class=\"rnote\">" + esc(enableNote) + "</div>" : "";
2600
+ return "<div class=\"restart\">" + head + btn + note + "</div>";
2153
2601
  }
2154
2602
  rows.forEach(function (r) { if (r.state === "update") { upd++; } });
2155
2603
  var allBtn = upd ? "<span class=\"allbtn\"" + KBD + ">全部更新(" + upd + ")</span>" : "<span class=\"allbtn\" hidden></span>";
@@ -2177,7 +2625,9 @@ window.__ModuleLoader__.load({
2177
2625
  }
2178
2626
  var html = "<div class=\"hd\"><b>插件更新 · " + (busyName ? "正在更新 " + esc(busyName) + " …" : (checking ? "检查中…" : (rows.length ? upd + " 个插件有新版本" : "插件更新检查"))) + "</b>" +
2179
2627
  "<span class=\"recheck\"" + KBD + " title=\"重新读取插件清单并比对 registry\">重新检查</span>" + allBtn +
2180
- "<span class=\"x\"" + KBD + " title=\"关闭\" aria-label=\"关闭\">×</span></div>" + restartBar() + accessModeBar() + "<div class=\"rows\">" + body + "</div>" +
2628
+ "<span class=\"x\"" + KBD + " title=\"关闭\" aria-label=\"关闭\">×</span></div>" + restartBar() + accessModeBar() +
2629
+ "<div class=\"rowswrap\"><div class=\"rows\">" + body + "</div>" +
2630
+ "<div class=\"sbbar\" aria-hidden=\"true\"><div class=\"sbthumb\"></div></div></div>" +
2181
2631
  /* 面板脚注:把「只查第三方」这条口径写在明处(plugin-manager 同款一句) */
2182
2632
  "<div class=\"foot\">只检查第三方插件;官方 @deepseek-ai/ 包随 DSH 本体升级。</div>";
2183
2633
  if (wrap.innerHTML !== html) {
@@ -2192,6 +2642,9 @@ window.__ModuleLoader__.load({
2192
2642
  wrap.hidden = false;
2193
2643
  /* 位置只有一个来源:那枚「检查更新」按钮(不可拖动) */
2194
2644
  placeNear(document.getElementById(BTN_ID), wrap, PANEL_WIDTH, 220);
2645
+ /* 浮层滚动条:先绑事件(innerHTML 换过就重绑),再按当前高度画拇指 */
2646
+ bindSb(wrap);
2647
+ syncSb(wrap);
2195
2648
  }
2196
2649
  }
2197
2650
 
@@ -2867,6 +3320,7 @@ window.__ModuleLoader__.load({
2867
3320
  * ====================================================================== */
2868
3321
  var ADAPTER_SUMMARY_SUFFIX = "/api/changes.summary";
2869
3322
  var ADAPTER_DIFF_SUFFIX = "/api/changes.diff";
3323
+ var ADAPTER_OPEN_SUFFIX = "/api/changes.open";
2870
3324
  var ADAPTER_CONTEXT_LINES = 3;
2871
3325
  var ADAPTER_MAX_HUNK_LINES = 2000;
2872
3326
 
@@ -2953,7 +3407,7 @@ window.__ModuleLoader__.load({
2953
3407
  });
2954
3408
  }
2955
3409
  out.sort(function (left, right) { return left.display < right.display ? -1 : (left.display > right.display ? 1 : 0); });
2956
- return { sessionId: String(sessionId), seq: String(seq), turn: turn, files: out, stateFiles: files };
3410
+ return { sessionId: String(sessionId), seq: String(seq), turn: turn, files: out, stateFiles: files, cwd: cwd || null };
2957
3411
  }
2958
3412
 
2959
3413
  /* 会话 cwd:官方 display 的基准。取不到就退化为绝对路径(不影响校验,只是显示不同)。 */
@@ -3013,6 +3467,54 @@ window.__ModuleLoader__.load({
3013
3467
  } catch (e) { return null; }
3014
3468
  }
3015
3469
 
3470
+ /* ---------- 动作路由补位(会话释放后官方 /api/changes.open 只回 404) ----------
3471
+ 官方那条路由要先查 Host 内存里的改动摘要才能把 file.path 解析成绝对路径,
3472
+ 会话一释放就 404 —— 文件却还在盘上。这里补两件事:
3473
+ GET → 由 host 枚举系统关联,返回真实应用列表(官方控件的主按钮=第一个应用,
3474
+ 菜单列全部;枚举不出来就给空数组,控件退化成只有「显示文件位置」);
3475
+ POST → 用自己 host 半区的 /api/ui-tweaks/open-path 打开 / 定位 / 用指定应用打开。
3476
+ 只对索引里已有的坐标生效,活会话照旧走官方原路(先试官方,404 才补位)。 */
3477
+ function changesAbsPath(entry, p) {
3478
+ var s = String(p == null ? "" : p);
3479
+ if (!s) { return null; }
3480
+ var isWin = /^[A-Za-z]:[\\/]/.test(s) || s.slice(0, 2) === "\\\\";
3481
+ if (isWin || s.charAt(0) === "/") { return s; }
3482
+ var cwd = entry && entry.cwd ? String(entry.cwd) : "";
3483
+ if (!cwd) { return null; }
3484
+ var sep = (/^[A-Za-z]:[\\/]/.test(cwd) || cwd.slice(0, 2) === "\\\\") ? "\\" : "/";
3485
+ return cwd.replace(/[\\/]+$/, "") + sep + s.replace(/^[\\/]+/, "").split("/").join(sep);
3486
+ }
3487
+ /* 调 host 半区回环路由(模块 5b 自身作用域;带超时,回包丢失不会吊死动作) */
3488
+ function adapterHostPost(route, body, timeoutMs) {
3489
+ var ms = Math.max(1000, Number(timeoutMs) || 8000);
3490
+ var ctl = null, timer = null;
3491
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
3492
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
3493
+ var opts = {
3494
+ method: "POST", cache: "no-store",
3495
+ headers: { "Content-Type": "application/json" },
3496
+ body: JSON.stringify(body || {}),
3497
+ };
3498
+ if (ctl) {
3499
+ opts.signal = ctl.signal;
3500
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, ms);
3501
+ }
3502
+ try {
3503
+ return fetch("/api/ui-tweaks/" + route, opts).then(function (r) {
3504
+ stop();
3505
+ return r.json().then(function (j) { return j; }, function () { return { ok: false, code: "HTTP " + r.status }; });
3506
+ }).catch(function (e) {
3507
+ stop();
3508
+ var aborted = false;
3509
+ try { aborted = !!(ctl && ctl.signal && ctl.signal.aborted); } catch (e3) {}
3510
+ return { ok: false, code: aborted ? "timeout" : String((e && e.message) || e) };
3511
+ });
3512
+ } catch (e) {
3513
+ stop();
3514
+ return Promise.resolve({ ok: false, code: String((e && e.message) || e) });
3515
+ }
3516
+ }
3517
+
3016
3518
  function installChangesFetchAdapter() {
3017
3519
  if (adapterInstalled) { return; }
3018
3520
  if (typeof window === "undefined" || typeof window.fetch !== "function") { return; }
@@ -3026,11 +3528,67 @@ window.__ModuleLoader__.load({
3026
3528
  var path = url.pathname || "";
3027
3529
  var isSummary = path.length >= ADAPTER_SUMMARY_SUFFIX.length && path.slice(-ADAPTER_SUMMARY_SUFFIX.length) === ADAPTER_SUMMARY_SUFFIX;
3028
3530
  var isDiff = path.length >= ADAPTER_DIFF_SUFFIX.length && path.slice(-ADAPTER_DIFF_SUFFIX.length) === ADAPTER_DIFF_SUFFIX;
3029
- if (!isSummary && !isDiff) { return original.apply(this, arguments); }
3531
+ var isOpen = path.length >= ADAPTER_OPEN_SUFFIX.length && path.slice(-ADAPTER_OPEN_SUFFIX.length) === ADAPTER_OPEN_SUFFIX;
3532
+ if (!isSummary && !isDiff && !isOpen) { return original.apply(this, arguments); }
3030
3533
  var sessionId = url.searchParams.get("sessionId");
3031
3534
  var seq = url.searchParams.get("seq");
3032
3535
  var entry = changesEntryFor(sessionId, seq);
3033
3536
  if (!entry) { return original.apply(this, arguments); }
3537
+ if (isOpen) {
3538
+ /* 先走官方原路:活会话它本来就能干活(有真应用列表)。只有它答 404
3539
+ (会话释放:Host 内存里没有改动摘要了)才由我们补位。 */
3540
+ var self = this, args = arguments;
3541
+ var fallbackOpen = function () {
3542
+ var method = "GET";
3543
+ try { method = String((init && init.method) || "GET").toUpperCase(); } catch (eM) { method = "GET"; }
3544
+ var openIndex = Number(url.searchParams.get("index"));
3545
+ var openFile = entry.files[openIndex] || null;
3546
+ var openTarget = changesAbsPath(entry, openFile ? openFile.path : "");
3547
+ if (method === "GET") {
3548
+ /* 应用列表交给 host(复用官方 native-command 枚举系统关联,只回真实应用);
3549
+ host 不可用或枚举失败就给空数组 —— 官方控件的退化形态就是「显示文件位置」。 */
3550
+ return adapterHostPost("open-apps", { path: openTarget || "" }, 20000).then(function (r) {
3551
+ var apps = (r && r.ok && Array.isArray(r.apps)) ? r.apps : [];
3552
+ var res = changesJsonResponse(apps);
3553
+ if (!res) { return new Response("unavailable", { status: 500 }); }
3554
+ try {
3555
+ console.info("[dsh-ui-tweaks:" + BUILD + "] 动作补位 · 应用列表 " + apps.length + " 项(" +
3556
+ ((r && r.source) || "fallback") + (r && r.code ? " · " + r.code : "") + ")");
3557
+ } catch (e2) {}
3558
+ return res;
3559
+ });
3560
+ }
3561
+ if (!openTarget) {
3562
+ try { console.warn("[dsh-ui-tweaks:" + BUILD + "] 动作补位失败:拿不到绝对路径", openFile); } catch (e3) {}
3563
+ return Promise.resolve(new Response("no-path", { status: 404 }));
3564
+ }
3565
+ var openAction = url.searchParams.get("action") === "reveal" ? "reveal" : "open";
3566
+ /* 菜单里选中的应用 id 原样交给 host(它按官方 openNativeFileApplication 启动);
3567
+ host 不认的 id 由它按默认关联打开,不会把点击变成失败。 */
3568
+ var openApp = url.searchParams.get("application") || "";
3569
+ return adapterHostPost("open-path", { path: openTarget, action: openAction, application: openApp }, 20000).then(function (r) {
3570
+ if (r && r.ok) { return new Response(null, { status: 204 }); }
3571
+ var code = r && r.code ? String(r.code) : "failed";
3572
+ var status = code === "path-not-found" ? 404 : (code === "timeout" ? 504 : 500);
3573
+ try {
3574
+ console.warn("[dsh-ui-tweaks:" + BUILD + "] 动作补位失败:" + code +
3575
+ (r && r.source ? "(" + r.source + ")" : "") + " · " + openTarget);
3576
+ } catch (e4) {}
3577
+ return new Response(code, { status: status });
3578
+ });
3579
+ };
3580
+ var attempt;
3581
+ try { attempt = original.apply(self, args); }
3582
+ catch (eA) { return fallbackOpen(); }
3583
+ if (!attempt || typeof attempt.then !== "function") {
3584
+ /* 不是 Promise(被别的包装改了签名):原样交回 */
3585
+ return attempt;
3586
+ }
3587
+ return attempt.then(function (res) {
3588
+ if (res && res.status === 404) { return fallbackOpen(); }
3589
+ return res;
3590
+ }, function () { return fallbackOpen(); });
3591
+ }
3034
3592
  var payload = isSummary ? changesSummaryPayload(entry) : changesDiffPayload(entry, Number(url.searchParams.get("index")));
3035
3593
  if (payload === null) { return original.apply(this, arguments); }
3036
3594
  var response = changesJsonResponse(payload);