dsh-remote-web 0.7.5 → 0.7.6-beta.10

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
@@ -98,7 +98,7 @@ window.__ModuleLoader__.load({
98
98
  // 绝不写 :root / body —— 否则会污染整个 GUI。JS 解析出主题后往根节点写
99
99
  // data-dru-theme="dark|light",下面按属性切换;末一条 `:not([data-dru-theme])`
100
100
  // 是 JS 尚未执行时的纯 CSS 兜底(一旦 JS 写过属性就不再参与,不会打架)。
101
- ".dru-settings-section,.dru-popup,.dru-nav-remote{",
101
+ ".dru-settings-section,.dru-popup,.dru-nav-remote,.dru-standalone-card{",
102
102
  " color-scheme:light;",
103
103
  " --dru-surface:#f6f8fa;", // 卡片底
104
104
  " --dru-surface-2:#ffffff;", // 抬升面(输入框/行/弹窗卡)
@@ -149,10 +149,11 @@ window.__ModuleLoader__.load({
149
149
  // 深色:JS 写的 data-dru-theme 优先;宿主属性作 JS 未执行时的兜底。
150
150
  // 取值跟着宿主的深色基色走(body 实测 #151517 / 层 1 #232324 / 层 2 #2c2c2e / 层 3 #353638),
151
151
  // 语义色**在深底上重新取值**,不是沿用亮色值(亮色的 #1a7f37 / #cf222e 在深底对比度不足)。
152
- ".dru-settings-section[data-dru-theme=\"dark\"],.dru-popup[data-dru-theme=\"dark\"],.dru-nav-remote[data-dru-theme=\"dark\"],",
152
+ ".dru-settings-section[data-dru-theme=\"dark\"],.dru-popup[data-dru-theme=\"dark\"],.dru-nav-remote[data-dru-theme=\"dark\"],.dru-standalone-card[data-dru-theme=\"dark\"],",
153
153
  "body[data-ds-dark-theme] .dru-settings-section:not([data-dru-theme]),",
154
154
  "body[data-ds-dark-theme] .dru-popup:not([data-dru-theme]),",
155
- "body[data-ds-dark-theme] .dru-nav-remote:not([data-dru-theme]){",
155
+ "body[data-ds-dark-theme] .dru-nav-remote:not([data-dru-theme]),",
156
+ "body[data-ds-dark-theme] .dru-standalone-card:not([data-dru-theme]){",
156
157
  " color-scheme:dark;",
157
158
  " --dru-surface:#232324;",
158
159
  " --dru-surface-2:#2c2c2e;",
@@ -202,6 +203,15 @@ window.__ModuleLoader__.load({
202
203
  "}",
203
204
  // 设置页栏目容器(nav 选中后渲染在 settings.section 内容区)
204
205
  ".dru-settings-section{max-width:720px;display:flex;flex-direction:column;gap:14px;padding-top:2px;color:var(--dru-fg)}",
206
+ // 📱 独立「远程访问」弹层(2026-10-25):入口点击不再去"猜"官方设置面板的 DOM,
207
+ // 直接弹我们自己的一份设置页 —— 宿主改版也影响不到它。
208
+ ".dru-standalone-overlay{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dru-overlay)}",
209
+ ".dru-standalone-card{width:min(760px,100%);max-height:88vh;overflow:auto;box-sizing:border-box;padding:18px 20px 22px;border:1px solid var(--dru-border);border-radius:14px;background:var(--dru-surface-2);color:var(--dru-fg);box-shadow:0 18px 60px var(--dru-shadow)}",
210
+ ".dru-standalone-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px}",
211
+ ".dru-standalone-title{font-size:16px;font-weight:700}",
212
+ ".dru-standalone-close{appearance:none;border:1px solid var(--dru-border-ctl);background:var(--dru-surface-2);color:var(--dru-fg-muted);width:30px;height:30px;border-radius:8px;font-size:18px;line-height:1;cursor:pointer}",
213
+ ".dru-standalone-close:hover{background:var(--dru-hover);color:var(--dru-fg)}",
214
+ "@media (max-width:640px){.dru-standalone-overlay{padding:8px}.dru-standalone-card{max-height:94vh;border-radius:12px;padding:14px 14px 18px}}",
205
215
  ".dru-settings-head{display:flex;align-items:center;gap:10px;padding:6px 2px 2px}",
206
216
  ".dru-settings-icon{font-size:24px;line-height:1;flex:none}",
207
217
  // 标题/副标题优先沿用宿主语义令牌(自带深浅两套),取不到再退回本插件令牌
@@ -897,7 +907,10 @@ window.__ModuleLoader__.load({
897
907
  for (var i = 0; i < els.length; i++) {
898
908
  var el = els[i];
899
909
  if (!el || el.id === NAV_ENTRY_ID) continue; // 排除注入按钮自身(防递归)
900
- if (!navCellLike(el)) continue;
910
+ // 2026-10-25:新宿主把设置面板左栏的 navCell class 换掉了 → 只认 navCellLike 会漏掉
911
+ // 「远程访问」栏目,导致入口点击静默失败(用户实测:只能从更多→设置里手动点)。
912
+ // 在**设置弹层内**时放宽为「可见、文案精确匹配」,不再依赖 class 名。
913
+ if (!navCellLike(el) && !(insideDialog === true && navInDialog(el))) continue;
901
914
  if (NAV_BAD_RE.test(String(el.className || ""))) continue;
902
915
  var inDlg = navInDialog(el);
903
916
  if (insideDialog === true && !inDlg) continue;
@@ -928,6 +941,9 @@ window.__ModuleLoader__.load({
928
941
  if (!el.getBoundingClientRect || el.getBoundingClientRect().width === 0) continue;
929
942
  var t = String(el.textContent || "");
930
943
  if (t.indexOf("通用设置") !== -1 || t.indexOf("General") !== -1) return true;
944
+ // 2026-10-25:新版桌面端设置面板里是「远程访问 / 通用设置…」;若宿主把「通用设置」
945
+ // 改成了别的文案,只看它就会误判「设置没打开」→ 入口点击后一路走兜底/超时。
946
+ if (t.indexOf("远程访问") !== -1) return true;
931
947
  }
932
948
  } catch (e) { /* 忽略 */ }
933
949
  return false;
@@ -957,6 +973,15 @@ window.__ModuleLoader__.load({
957
973
  if (!el || el.id === NAV_ENTRY_ID) continue;
958
974
  var aria = String((el.getAttribute && el.getAttribute("aria-label")) || "");
959
975
  if (/账号菜单|Account menu/i.test(aria)) return el;
976
+ // 2026-10-25(用户反馈「只能从更多→设置里打开」):新宿主把设置收在**更多**菜单里,
977
+ // 这里补充「更多」形态的候选(aria-label / title / 文本 / ⋯ 字形)。
978
+ var title = String((el.getAttribute && (el.getAttribute("title") || "")) || "");
979
+ var txt = String((el.textContent || "")).replace(/\s+/g, "");
980
+ if (/更多|More|菜单|Menu/i.test(aria + " " + title)) {
981
+ var rMore = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
982
+ if (!rMore || (rMore.width > 0 && rMore.height > 0)) return el;
983
+ }
984
+ if (/^(更多|More|⋯|\.\.\.)$/i.test(txt) && el.getAttribute && el.getAttribute("aria-haspopup")) return el;
960
985
  var r = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
961
986
  if (!best && r && r.width > 0 && r.left < 360 && r.top > 300
962
987
  && String((el.getAttribute && el.getAttribute("aria-haspopup")) || "") === "menu") best = el;
@@ -1102,6 +1127,61 @@ window.__ModuleLoader__.load({
1102
1127
  0.2 实测该槽就在侧栏底部、且**当前是空的**)—— 位置、宽度、折叠态全由宿主负责。
1103
1128
  所以这里不再注入任何 DOM,直接注册进槽位。id 仍保留 `dru-nav-remote`:
1104
1129
  公告徽标 / 首次红点那套按 id 取节点的既有逻辑因此不用改。 */
1130
+ /* 📱 独立「远程访问」弹层(2026-10-25 用户拍板):
1131
+ 桌面端点击侧栏入口时,不再去"点官方设置/找栏目"(宿主一改版就点错或没反应,
1132
+ 实测会点到左上角「更多→浏览插件」),而是弹出我们自己的一份设置页。
1133
+ 「设置 → 远程访问」栏目保留,正常从官方设置页进入的用户仍走那条路。
1134
+ 实现上用 shell.overlay 槽(宿主最上层,不受侧栏容器裁剪),组件常驻、按事件开关。 */
1135
+ var standaloneOverlayMounted = false;
1136
+ function openRemoteStandalonePanel() {
1137
+ try { window.dispatchEvent(new CustomEvent("dsh-remote:open-standalone")); } catch (e) { /* 忽略 */ }
1138
+ }
1139
+ function RemoteStandaloneOverlay() {
1140
+ var st = useState(false);
1141
+ var open = st[0];
1142
+ var setOpen = st[1];
1143
+ useEffect(function () {
1144
+ standaloneOverlayMounted = true;
1145
+ function onOpen() { try { setOpen(true); } catch (e) { /* 忽略 */ } }
1146
+ function onKey(e) {
1147
+ if (!e) return;
1148
+ if (e.key === "Escape" || e.keyCode === 27) { try { setOpen(false); } catch (e2) { /* 忽略 */ } }
1149
+ }
1150
+ try {
1151
+ window.addEventListener("dsh-remote:open-standalone", onOpen);
1152
+ window.addEventListener("keydown", onKey);
1153
+ } catch (e3) { /* 忽略 */ }
1154
+ return function () {
1155
+ standaloneOverlayMounted = false;
1156
+ try {
1157
+ window.removeEventListener("dsh-remote:open-standalone", onOpen);
1158
+ window.removeEventListener("keydown", onKey);
1159
+ } catch (e4) { /* 忽略 */ }
1160
+ };
1161
+ }, []);
1162
+ if (!open) return null;
1163
+ var dark = false;
1164
+ try { dark = !!document.body && document.body.hasAttribute("data-ds-dark-theme"); } catch (e5) { /* 忽略 */ }
1165
+ return h("div", {
1166
+ className: "dru-standalone-overlay",
1167
+ role: "dialog",
1168
+ "aria-modal": "true",
1169
+ "aria-label": "远程访问",
1170
+ onClick: function (e) { if (e && e.target === e.currentTarget) setOpen(false); }
1171
+ },
1172
+ h("div", { className: "dru-standalone-card", "data-dru-theme": dark ? "dark" : "light" },
1173
+ h("div", { className: "dru-standalone-head" },
1174
+ h("b", { className: "dru-standalone-title" }, "📱 远程访问"),
1175
+ h("button", {
1176
+ type: "button",
1177
+ className: "dru-standalone-close",
1178
+ "aria-label": "关闭",
1179
+ onClick: function () { setOpen(false); }
1180
+ }, "×")),
1181
+ h(RemoteControlSection, { standalone: true })
1182
+ ));
1183
+ }
1184
+
1105
1185
  function RemoteNavEntry(props) {
1106
1186
  var wide = !(props && props.wide === false);
1107
1187
  var seenArr = useState(false);
@@ -1118,7 +1198,16 @@ window.__ModuleLoader__.load({
1118
1198
  try { e.preventDefault(); } catch (err) { /* 忽略 */ }
1119
1199
  setClicked(true);
1120
1200
  dotMarkSeen(); // 用过入口 = 已了解位置 → 收起首次红点
1121
- if (remoteNavOpen) remoteNavOpen();
1201
+ // 2026-10-25(用户拍板):优先弹**我们自己的独立设置页**(不再依赖官方设置面板 DOM);
1202
+ // 只有宿主没有 shell.overlay 槽(极老版本)时才退回旧的"打开官方设置栏目"链路。
1203
+ try {
1204
+ openRemoteStandalonePanel();
1205
+ setTimeout(function () {
1206
+ if (!standaloneOverlayMounted) {
1207
+ try { openRemoteSettings(); } catch (e2) { /* 忽略 */ }
1208
+ }
1209
+ }, 150);
1210
+ } catch (errOpen) { /* 绝不因打开失败抛到宿主 */ }
1122
1211
  }
1123
1212
  }, [
1124
1213
  h("span", { className: "ico", key: "ico" }, "📱"),
@@ -1177,6 +1266,7 @@ window.__ModuleLoader__.load({
1177
1266
  * 之所以是模块级的:拉取发生在 apply() 里(组件还没挂载),而消费方有两个
1178
1267
  * (设置面板的卡片 / shell.overlay 上的弹窗),模块级 store 才能让两边看到同一份数据。
1179
1268
  */
1269
+ var annRetryCount = 0; // 公告首次拉取瞬态失败的有界重试计数(成功即清零)
1180
1270
  var annState = {
1181
1271
  ok: false, // 拉取成功且结构是我们认识的那一种(老服务端 404 / 结构不符 → 一直是 false)
1182
1272
  bubble: false, // bubble_enabled === true 才置真(字段缺失一律当 false = fail closed)
@@ -1339,6 +1429,7 @@ window.__ModuleLoader__.load({
1339
1429
  var norm = annNormalize(b);
1340
1430
  if (!norm) { annState.ok = false; annState.bubble = false; annState.items = []; annState.unread = 0; annEmit(); return false; }
1341
1431
  annState.ok = true;
1432
+ annRetryCount = 0;
1342
1433
  annState.bubble = norm.bubble;
1343
1434
  annState.items = norm.items;
1344
1435
  annState.unread = norm.unread;
@@ -1347,13 +1438,24 @@ window.__ModuleLoader__.load({
1347
1438
  }
1348
1439
  annEmit();
1349
1440
  return true;
1350
- }).catch(function () {
1441
+ }).catch(function (err) {
1351
1442
  // 404(老服务端)/ 不可达 / 超时:fail closed —— 不显示入口、不弹窗,**绝不抛**
1352
1443
  annState.ok = false;
1353
1444
  annState.bubble = false;
1354
1445
  annState.items = [];
1355
1446
  annState.unread = 0;
1356
1447
  annEmit();
1448
+ // 2026-10-25(用户反馈「公告板块没了」):dsh 重启后页面常常比插件宿主路由先就绪,
1449
+ // 首次拉取失败一次,整页(sessionStorage 一次性标记)就再也没有公告入口。
1450
+ // 这里只对**瞬态失败**做有界重试(最多 2 次、间隔 1.5s/3s);404 明确是老服务端,不重试。
1451
+ // 注意:这只发生在**失败后**,成功路径仍然严格"每次页面加载只拉一次"。
1452
+ var st = Number(err && (err.status || err.statusCode));
1453
+ if (st !== 404 && annRetryCount < 2) {
1454
+ annRetryCount += 1;
1455
+ try {
1456
+ setTimeout(function () { annFetch({ openPopup: false }); }, 1500 * annRetryCount);
1457
+ } catch (e2) { /* 忽略 */ }
1458
+ }
1357
1459
  return false;
1358
1460
  });
1359
1461
  }
@@ -2950,6 +3052,8 @@ window.__ModuleLoader__.load({
2950
3052
  : "已停止";
2951
3053
  // 是否需要重启 DeepSeek harness(首次安装/在线更新后置顶提醒 + 底部常驻按钮)
2952
3054
  var restartInfo = (st && st.restart) || {};
3055
+ // 桌面端「暫存插件更新」:新版本已下载,退出应用后由 bridge 自动安装(见 dsh-bridge 的 applier)
3056
+ var desktopUpdatePending = (st && st.desktopUpdatePending) || null;
2953
3057
  var restartPending = !!restartInfo.pending;
2954
3058
  // 宿主形态(node 半下发):桌面端(Electron)Host 由桌面应用托管,重启这个动作
2955
3059
  // 在那边**做不到也不该做**(强行退出会弹原生致命错误)。面板据此:
@@ -5241,8 +5345,12 @@ window.__ModuleLoader__.load({
5241
5345
  function renderAnnouncementsCard() {
5242
5346
  if (!annState.ok || !annState.bubble) return null;
5243
5347
  var items = annState.items;
5244
- if (!items.length) return null;
5245
5348
  var unread = annState.unread;
5349
+ // 2026-10-25:列表为空时**保留板块**(显示「暂无公告」)而不是整卡消失 ——
5350
+ // 板块本身是用户确认"公告功能还在"的锚点,静默消失会被当成功能坏了。
5351
+ if (!items.length) {
5352
+ return card("📣 公告", [h("div", { className: "dru-hint" }, "暂无公告")]);
5353
+ }
5246
5354
  return card("📣 公告" + (unread > 0 ? "(" + unread + " 条未读)" : ""), [
5247
5355
  h("div", { className: "dru-ann-list" }, items.map(function (it) {
5248
5356
  return h("button", {
@@ -5309,12 +5417,18 @@ window.__ModuleLoader__.load({
5309
5417
  // 不该混在桌面端适配里顺手做掉。桌面端才走新文案。
5310
5418
  // 2026-10-17(业主):桌面端运行环境由应用托管、更新自动热加载 ——
5311
5419
  // **不再引导用户重启 DeepSeek Harness**,统一只说「刷新页面即可生效」。
5312
- var restartRowText = (desktopHost || !isRestartHard)
5313
- ? "🔄 插件已更新:刷新页面即可生效"
5314
- : "🔄 插件已更新:重启 dsh web 后生效";
5420
+ // 桌面端:插件更新需要退出并重新打开应用(应用独占 profile,不能热改);
5421
+ // 若已暂存新版本,bridge 会在应用退出后自动安装,重开即生效 —— 绝不说「刷新页面即可」。
5422
+ var restartRowText = desktopHost
5423
+ ? (desktopUpdatePending
5424
+ ? "🆕 新版本已下载:退出并重新打开 DeepSeek Harness 后自动生效(无需终端)"
5425
+ : "🔄 插件已更新:退出并重新打开 DeepSeek Harness 后生效")
5426
+ : (!isRestartHard
5427
+ ? "🔄 插件已更新:刷新页面即可生效"
5428
+ : "🔄 插件已更新:重启 dsh web 后生效");
5315
5429
  rows.push(h("div", { className: "dru-access-tip-row", key: "restart" },
5316
5430
  h("span", null, restartRowText),
5317
- (isRefreshOnly || !desktopHost) ? h("button", {
5431
+ (!desktopHost || isRefreshOnly) && !desktopHost ? h("button", {
5318
5432
  type: "button",
5319
5433
  className: "dru-linkbtn",
5320
5434
  disabled: busy !== "",
@@ -5366,13 +5480,17 @@ window.__ModuleLoader__.load({
5366
5480
  // **任何情况都不再引导"重启/退出重开应用"**,只说「刷新页面即可生效」。
5367
5481
  // 非桌面端(自建 dsh web)保留原有 isRestartHard 判据,文案不变。
5368
5482
  desktopHost
5369
- ? "插件已更新:刷新页面即可生效"
5483
+ ? (desktopUpdatePending
5484
+ ? "新版本已下载:退出并重新打开 DeepSeek Harness 后生效"
5485
+ : "插件已更新:退出并重新打开 DeepSeek Harness 后生效")
5370
5486
  : (isRestartHard
5371
5487
  ? "插件已更新,重启 dsh web 后生效"
5372
5488
  : (isRefreshOnly ? "插件已更新,刷新页面即可生效" : "需要重启 dsh web 才能载入新插件"))),
5373
5489
  h("div", { className: "dru-restart-alert-sub" },
5374
5490
  desktopHost
5375
- ? "新插件已经在磁盘上,刷新一下这个页面即可载入(手机端连接与已登录状态都不受影响)。如果刷新后仍看不到面板,在应用内「插件」页重新安装一次即可。"
5491
+ ? (desktopUpdatePending
5492
+ ? "新版本已经下载到本机;DeepSeek Harness 退出后,后台服务会自动完成安装,重新打开应用即生效(无需终端,手机端连接与已登录状态都不受影响)。"
5493
+ : "新插件已在磁盘上,但桌面应用仍在运行旧插件;退出 DeepSeek Harness 后重新打开即可载入新版(应用退出后后台服务会自动完成安装,无需终端)。")
5376
5494
  : (isRestartHard
5377
5495
  ? "新版本已经装到这台电脑上,但当前运行的 dsh web 还是旧代码(新的后台路由与面板要等它重新载入)。手机端连接与已登录状态都不受影响;重启期间页面会短暂断开,然后自动恢复。"
5378
5496
  : (isRefreshOnly
@@ -6103,6 +6221,13 @@ window.__ModuleLoader__.load({
6103
6221
  // 公告是锦上添花,**没有任何资格**把门关上 —— 任何异常都只是"这次没有弹窗"。
6104
6222
  }
6105
6223
 
6224
+ // 📱 独立「远程访问」弹层(入口点击用;见 RemoteStandaloneOverlay 注释)
6225
+ try {
6226
+ ctx.slots.inject("shell.overlay", function () {
6227
+ return ctx.slots.register({ name: "shell.overlay", id: "dsh-remote-standalone", order: 95 }, RemoteStandaloneOverlay);
6228
+ });
6229
+ } catch (eStand) { /* 老宿主没有 shell.overlay:入口会在 150ms 后回退到官方设置栏目链路 */ }
6230
+
6106
6231
  // 📣 公告:每次页面加载**只拉一次**(sessionStorage 一次性标记,见 annPullOnce)。
6107
6232
  // 放在 apply() 里是刻意的:apply 跑在 app mount **之前**,所以这里**绝不能碰产品 DOM**
6108
6233
  // (那时还没有),只做数据拉取;UI 全部交给上面那个槽位与面板卡片。
package/lib/index.js CHANGED
@@ -2817,6 +2817,29 @@ function writeRestartState(relayDir, state) {
2817
2817
  * 标记「需要用户动一下」的状态。kind = refresh(插件被运行时改写,刷新页面即可)。
2818
2818
  * 历史 kind(first-install / update)已废弃:热加载落地后,装插件与在线更新都不再需要重启 harness。
2819
2819
  */
2820
+ /** 桌面端「暫存插件更新」marker(dsh-setup 在应用运行时暂存;bridge 在应用退出后自动安装)。 */
2821
+ function readDesktopUpdatePending(relayDir) {
2822
+ try {
2823
+ const m = JSON.parse(readFileSync(join(relayDir, ".plugin-update-pending.json"), "utf8"));
2824
+ return { version: String(m.version || ""), stagedAt: Number(m.stagedAt) || 0 };
2825
+ } catch {
2826
+ return null;
2827
+ }
2828
+ }
2829
+
2830
+ /** 更新进程成功收尾后写「待生效」提示:桌面端暂存更新 → 退出应用后自动安装;其它 → 重启 dsh web。 */
2831
+ function markUpdateRestartPending(relayDir) {
2832
+ const staged = readDesktopUpdatePending(relayDir);
2833
+ if (staged) {
2834
+ return markRestartPending(
2835
+ relayDir,
2836
+ "restart-app",
2837
+ `新版本${staged.version ? ` ${staged.version}` : ""} 已下载:退出并重新打开 DeepSeek Harness 后自动生效(无需终端)`
2838
+ );
2839
+ }
2840
+ return markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
2841
+ }
2842
+
2820
2843
  function markRestartPending(relayDir, kind, reason) {
2821
2844
  const cur = readRestartState(relayDir);
2822
2845
  if (cur.pending && cur.kind === kind) return cur; // 幂等:同一原因不反复重写
@@ -3748,6 +3771,8 @@ async function composeStatus(relayDir) {
3748
3771
  // 是否需要重启 DeepSeek harness(首次安装 / 在线更新后必须重启才加载插件本体):
3749
3772
  // {pending, kind: first-install|update, reason, at},供面板顶部醒目提示 + 重启按钮。
3750
3773
  restart: readRestartState(relayDir),
3774
+ // 桌面端暂存的插件更新(存在 = 新版本已下载,退出应用后由 bridge 自动安装并生效)
3775
+ desktopUpdatePending: readDesktopUpdatePending(relayDir),
3751
3776
  // 安装/配对自检(2026-10-17 事故):运行时(clients/)与配对(.dsh-config.json)是否齐全、
3752
3777
  // 缺什么、下一步该做什么;配对主配置缺失但备份存在时给出可一键恢复的 backup.action。
3753
3778
  // 字段契约见 setupStateOf;connect.setup 是同一份对象(bridge-status 短轮询也能拿到)。
@@ -4586,7 +4611,7 @@ async function runOnlineUpdate(relayDir) {
4586
4611
  updateFailures.delete(String(relayDir));
4587
4612
  sweepIfSucceeded(); // C8:装完清残留(按更新前的 pid 点名 + 命令行复核)
4588
4613
  // 更新成功 → 落一条「必须重启 dsh web」的强提醒(见 run() 成功分支的同款注释)
4589
- markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
4614
+ markUpdateRestartPending(relayDir);
4590
4615
  }
4591
4616
  });
4592
4617
  child.unref();
@@ -4676,7 +4701,7 @@ async function runOnlineUpdate(relayDir) {
4676
4701
  // 客户端按 kind 一一对应地换文案与主按钮(见 client.js 的 restartAlert),
4677
4702
  // 混用一个 kind 就会再次出现"文案说刷新、状态其实是重启"。
4678
4703
  // settleRestartState() 靠 bootId 自动结清 —— 用户真的重启后这条提示自己消失,不需要额外清理。
4679
- markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
4704
+ markUpdateRestartPending(relayDir);
4680
4705
  }
4681
4706
  });
4682
4707
  child.unref();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.5",
3
+ "version": "0.7.6-beta.10",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -82,6 +82,7 @@ import WebSocket from "ws";
82
82
  import fs from "node:fs";
83
83
  import os from "node:os";
84
84
  import path from "node:path";
85
+ import net from "node:net";
85
86
  import { execSync, spawnSync } from "node:child_process";
86
87
  import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
87
88
  import { pathToFileURL, fileURLToPath } from "node:url";
@@ -557,6 +558,19 @@ let upstreamRefreshInflight = null;
557
558
  * 重新解析上游地址(带身份校验;显式 DSH_BRIDGE_UPSTREAM 时是恒等操作)。
558
559
  * 上游端口变了(Desktop 换端口 / dsh web 重启到别的端口)时,靠它自愈而不必重启 bridge。
559
560
  */
561
+ /** 轻量探测上游是否活着(refreshUpstream 用;4xx/5xx 也算活着)。 */
562
+ async function upstreamReachableForRefresh(url, timeoutMs = 800) {
563
+ try {
564
+ const ac = new AbortController();
565
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
566
+ await fetch(String(url).replace(/\/+$/, "") + "/", { signal: ac.signal });
567
+ clearTimeout(timer);
568
+ return true;
569
+ } catch {
570
+ return false;
571
+ }
572
+ }
573
+
560
574
  async function refreshUpstream(reason = "") {
561
575
  if (upstreamRefreshInflight) return upstreamRefreshInflight;
562
576
  const now = Date.now();
@@ -564,7 +578,22 @@ async function refreshUpstream(reason = "") {
564
578
  upstreamRefreshedAt = now;
565
579
  upstreamRefreshInflight = (async () => {
566
580
  try {
567
- const found = await discoverUpstream({ relayDir: RELAY_DIR });
581
+ let found = null;
582
+ // 🔴 2026-10-25(设备掉线实测):升级前装的 launchd plist 可能带着安装时的旧端口,
583
+ // 旧版 discoverUpstream 对「显式 env」**不探测直接采信** → 桥永久连旧端口、每次请求
584
+ // upstream_unreachable。这里先验证 env:死了就临时忽略它,按插件写的 .dsh-upstream/探测
585
+ // 重新发现;发现成功后清掉失效 env,后续都不再被它挡。
586
+ const envUrl = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "");
587
+ if (envUrl && !(await upstreamReachableForRefresh(envUrl))) {
588
+ const env2 = { ...process.env };
589
+ delete env2.DSH_BRIDGE_UPSTREAM;
590
+ found = await discoverUpstream({ relayDir: RELAY_DIR, env: env2 });
591
+ if (found && found.url && found.source !== "fallback") {
592
+ delete process.env.DSH_BRIDGE_UPSTREAM;
593
+ console.log(`[bridge] 旧上游 ${envUrl} 已不可达 → 改用 ${found.url}(来源 ${found.source})`);
594
+ }
595
+ }
596
+ if (!found) found = await discoverUpstream({ relayDir: RELAY_DIR });
568
597
  if (found.url && found.url !== UPSTREAM) {
569
598
  console.log(`[bridge] 上游地址切换: ${UPSTREAM} → ${found.url}(来源 ${found.source}${reason ? `,${reason}` : ""})`);
570
599
  UPSTREAM = found.url;
@@ -630,7 +659,10 @@ export function dcSend(dc, obj) {
630
659
  * · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
631
660
  * 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
632
661
  * 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
633
- * · 小帧(交互消息 / SSE 块)单独一条车道,优先于大帧的下一片(交互不被整帧传输拖住)。
662
+ * · **数据帧严格 FIFO**(2026-10-24 修):早期实现让小帧(交互/SSE 块)插到大帧下一片之前,
663
+ * 这在同一 WS 流里是**乱序** —— 生产表现为手机远程页「历史加载失败:session event stream
664
+ * emitted an entry before its opening cursor」。现在只有一个数据队列,大帧按片就地推进,
665
+ * 绝不因大小/类型被后来的帧超越;只有控制帧(不承载有序数据)保留插队权。
634
666
  * · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
635
667
  */
636
668
  export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
@@ -679,13 +711,19 @@ export function makePrioritySender({
679
711
  let pumping = false;
680
712
  let scheduled = false;
681
713
  let timerPending = false;
682
- const smallQ = []; // 小帧(可整帧直发):交互消息 / SSE 块 —— 优先于大帧的下一片
683
- const bigQ = []; // 大帧:{ frames: string[], i }
684
- const stats = { controlSent: 0, smallSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
685
-
686
- function enqueueBulk(text) {
714
+ // 🔴 2026-10-24 事故修复:数据帧**必须严格 FIFO**。
715
+ // 旧实现分「小帧车道 / 大帧车道」,小帧会插到大帧下一片之前 —— 这违反了同一 WS 流里
716
+ // 消息的顺序语义,生产表现为手机远程页「历史加载失败:session event stream emitted an
717
+ // entry before its opening cursor(gateway/internal)」:事件流请求/响应被乱序送达,
718
+ // 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
719
+ // 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
720
+ // 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
721
+ const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
722
+ const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
723
+
724
+ function enqueueData(text) {
687
725
  if (text.length <= chunkSize) {
688
- smallQ.push(text);
726
+ dataQ.push({ frames: [text], i: 0 });
689
727
  return;
690
728
  }
691
729
  const cid = `c${++chunkSeq}`;
@@ -695,7 +733,7 @@ export function makePrioritySender({
695
733
  frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
696
734
  }
697
735
  stats.bigFrames += 1;
698
- bigQ.push({ frames, i: 0 });
736
+ dataQ.push({ frames, i: 0 });
699
737
  }
700
738
 
701
739
  function scheduleNext(waitMs) {
@@ -715,7 +753,7 @@ export function makePrioritySender({
715
753
  if (pumping || closed) return;
716
754
  pumping = true;
717
755
  let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
718
- let budget = 32; // 一轮最多发多少条小帧
756
+ let budget = 64; // 一轮最多发多少条未分片小帧(大帧每轮只发一片)
719
757
  try {
720
758
  for (;;) {
721
759
  // ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
@@ -724,31 +762,31 @@ export function makePrioritySender({
724
762
  try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
725
763
  }
726
764
  if (closed) { yieldMode = ""; break; }
727
- // ② 小帧优先于大帧的下一片:大包传输期间,交互消息 / SSE 块不该排在它后面。
728
- if (smallQ.length > 0 && budget > 0) {
729
- try { rawSend(smallQ.shift()); stats.smallSent += 1; } catch { closed = true; break; }
765
+ if (dataQ.length === 0) { yieldMode = ""; break; }
766
+ if (budget <= 0) { yieldMode = "schedule"; break; }
767
+ const head = dataQ[0];
768
+ if (head.frames.length === 1) {
769
+ // 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
770
+ try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
771
+ dataQ.shift();
730
772
  budget -= 1;
731
773
  continue; // 回环顶先查控制车道
732
774
  }
733
- if (smallQ.length > 0) { yieldMode = "schedule"; break; } // 一轮预算用完
734
- if (bigQ.length === 0) { yieldMode = ""; break; }
735
- // ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
775
+ // 队头是大帧:每轮只推一片,片间让出事件循环(控制帧/入站帧不被饿死),顺序不变
736
776
  let pending = 0;
737
777
  try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
738
778
  if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
739
- const item = bigQ[0];
740
- try { rawSend(item.frames[item.i]); stats.bigChunks += 1; } catch { closed = true; break; }
741
- item.i += 1;
742
- if (item.i >= item.frames.length) bigQ.shift();
743
- yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
779
+ try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
780
+ head.i += 1;
781
+ if (head.i >= head.frames.length) dataQ.shift();
782
+ yieldMode = "schedule";
744
783
  break;
745
784
  }
746
785
  } finally {
747
786
  pumping = false;
748
787
  if (closed) {
749
788
  controlQ.length = 0;
750
- smallQ.length = 0;
751
- bigQ.length = 0;
789
+ dataQ.length = 0;
752
790
  } else if (yieldMode) {
753
791
  scheduleNext(yieldMode === "poll" ? pollMs : 0);
754
792
  }
@@ -760,7 +798,7 @@ export function makePrioritySender({
760
798
  try { text = JSON.stringify(obj); } catch { return false; }
761
799
  if (closed) { stats.dropped += 1; return false; }
762
800
  if (isControlFrame(obj)) controlQ.push(text);
763
- else enqueueBulk(text);
801
+ else enqueueData(text);
764
802
  pump();
765
803
  return true;
766
804
  }
@@ -769,8 +807,7 @@ export function makePrioritySender({
769
807
  function close() {
770
808
  closed = true;
771
809
  controlQ.length = 0;
772
- smallQ.length = 0;
773
- bigQ.length = 0;
810
+ dataQ.length = 0;
774
811
  }
775
812
 
776
813
  return {
@@ -778,11 +815,11 @@ export function makePrioritySender({
778
815
  close,
779
816
  stats: () => ({
780
817
  ...stats,
781
- queued: smallQ.length + bigQ.length,
818
+ queued: dataQ.length,
782
819
  controlQueued: controlQ.length,
783
820
  closed
784
821
  }),
785
- hasBacklog: () => smallQ.length > 0 || bigQ.length > 0 || controlQ.length > 0
822
+ hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
786
823
  };
787
824
  }
788
825
 
@@ -2302,7 +2339,7 @@ async function initE2ee(token) {
2302
2339
  const storeEnabled = process.env.DSH_BRIDGE_E2EE_SESSIONS !== "0";
2303
2340
  const store = e2ee.attachStore(storeDir, { enabled: storeEnabled });
2304
2341
  if (store.restored > 0) {
2305
- console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(加密落盘;手机重启/桥重启后免重新解锁)`);
2342
+ console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(加密落盘;安全策略=v2:全部标记「需重新握手」,不复用旧计数)`);
2306
2343
  }
2307
2344
  if (store.discarded) {
2308
2345
  // 丢弃 = 手机手里的旧票必然对不上:后续 ws-open/HTTP 会带 retryable 的 rehandshake 信号,
@@ -2723,9 +2760,109 @@ async function main() {
2723
2760
  console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
2724
2761
  }
2725
2762
  process.on("exit", releaseInstanceLock);
2763
+ // 桌面端插件「暂存更新」自动安装(应用退出后生效;见 applyPendingPluginUpdate 注释)
2764
+ startPendingPluginUpdateWatcher();
2726
2765
  return runTunnel();
2727
2766
  }
2728
2767
 
2768
+ // ── 桌面端插件「暫存安装」落地(2026-10-24)────────────────────────────────────────────
2769
+ // dsh-setup 在桌面应用运行时无法直接改写它的 profile(会触发应用退出/重启),改为暂存到
2770
+ // <relayDir>/pending-plugin/ 并写 marker;bridge 常驻、检测到应用退出后自动原子替换插件目录,
2771
+ // 用户下次打开应用即加载新版 —— 这样「一键更新」对桌面端也能真正生效(不再需要终端)。
2772
+ const PENDING_PLUGIN_MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
2773
+ const PENDING_PLUGIN_REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
2774
+ const PENDING_PLUGIN_DISABLED = process.env.DSH_BRIDGE_PENDING_UPDATE === "0";
2775
+
2776
+ /** upstream 端口是否在监听(= DeepSeek Harness 桌面端在运行)。TCP 判断,跨平台。 */
2777
+ export function probeUpstreamListening({ host = "127.0.0.1", port, timeoutMs = 400 } = {}) {
2778
+ return new Promise((resolve) => {
2779
+ if (!port) return resolve(false);
2780
+ const sock = net.connect({ host, port });
2781
+ let done = false;
2782
+ const finish = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
2783
+ sock.setTimeout(timeoutMs, () => finish(false));
2784
+ sock.once("connect", () => finish(true));
2785
+ sock.once("error", () => finish(false));
2786
+ });
2787
+ }
2788
+ function upstreamPortOf(url) {
2789
+ try { const u = new URL(String(url)); return Number(u.port) || (u.protocol === "https:" ? 443 : 80); } catch { return 0; }
2790
+ }
2791
+
2792
+ /**
2793
+ * 应用暂存的插件更新(仅在桌面应用不在运行时)。原子替换:staging → rename,失败保留旧版。
2794
+ * @returns {{applied:boolean, reason?:string, version?:string, error?:string}}
2795
+ */
2796
+ export async function applyPendingPluginUpdate({ relayDir = RELAY_DIR, isAppRunning, now = Date.now(), log = console } = {}) {
2797
+ const markerPath = path.join(relayDir, ".plugin-update-pending.json");
2798
+ let marker;
2799
+ try { marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); } catch { return { applied: false, reason: "no_pending" }; }
2800
+ const stageDir = String(marker.stageDir || "");
2801
+ const profileDir = String(marker.profileDir || "");
2802
+ const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
2803
+ if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
2804
+ try { fs.rmSync(markerPath, { force: true }); } catch { /* 忽略 */ }
2805
+ return { applied: false, reason: "staging_missing" };
2806
+ }
2807
+ const running = typeof isAppRunning === "function"
2808
+ ? await isAppRunning()
2809
+ : await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2810
+ if (running) return { applied: false, reason: "app_running" };
2811
+ const dest = path.join(profileDir, name);
2812
+ const stage = `${dest}.staging-${process.pid}`;
2813
+ const backup = `${dest}.backup-${process.pid}`;
2814
+ try {
2815
+ fs.rmSync(stage, { recursive: true, force: true });
2816
+ fs.cpSync(stageDir, stage, { recursive: true });
2817
+ for (const rel of PENDING_PLUGIN_REQUIRED) {
2818
+ if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
2819
+ }
2820
+ fs.rmSync(backup, { recursive: true, force: true });
2821
+ const had = fs.existsSync(dest);
2822
+ if (had) fs.renameSync(dest, backup);
2823
+ try {
2824
+ fs.renameSync(stage, dest);
2825
+ } catch (e) {
2826
+ if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
2827
+ throw e;
2828
+ }
2829
+ if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
2830
+ try {
2831
+ fs.writeFileSync(
2832
+ path.join(relayDir, ".plugin-update-applied.json"),
2833
+ JSON.stringify({ version: marker.version, appliedAt: now, profileDir }, null, 2) + "\n"
2834
+ );
2835
+ } catch { /* 忽略 */ }
2836
+ try { fs.rmSync(markerPath, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
2837
+ log.info?.(`[bridge] ✅ 插件新版本 ${marker.version} 已自动安装到 ${profileDir}(桌面应用退出后);重新打开应用即生效`);
2838
+ return { applied: true, version: marker.version };
2839
+ } catch (e) {
2840
+ try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
2841
+ log.warn?.(`[bridge] 插件暂存安装失败(保留旧版可回退):${e && e.message ? e.message : e}`);
2842
+ return { applied: false, reason: "apply_failed", error: String((e && e.message) || e) };
2843
+ }
2844
+ }
2845
+
2846
+ function startPendingPluginUpdateWatcher() {
2847
+ if (PENDING_PLUGIN_DISABLED) return null;
2848
+ let closedStreak = 0;
2849
+ const tick = async () => {
2850
+ try {
2851
+ if (!fs.existsSync(PENDING_PLUGIN_MARKER)) { closedStreak = 0; return; }
2852
+ const running = await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2853
+ if (running) { closedStreak = 0; return; }
2854
+ closedStreak += 1;
2855
+ if (closedStreak < 2) return; // 连续两次(≈30s)判定不在运行才动手,避开退出/重启瞬间
2856
+ await applyPendingPluginUpdate({});
2857
+ closedStreak = 0;
2858
+ } catch { /* 绝不影响隧道业务 */ }
2859
+ };
2860
+ const t = setInterval(tick, 15_000);
2861
+ t.unref?.();
2862
+ void tick();
2863
+ return t;
2864
+ }
2865
+
2729
2866
  // test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
2730
2867
  export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
2731
2868
  export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
@@ -792,14 +792,17 @@ export class E2eeService {
792
792
  profile: String(row.profile || this.profile),
793
793
  epoch: Number(row.epoch) || 0
794
794
  });
795
- session.verified = row.verified === true;
795
+ // ★ 2026-10-24 安全升级:从磁盘恢复的会话**一律标记未验证**,必须重新握手后才接受数据流。
796
+ // 原因:落盘的收发计数可能落后于手机侧(桥在 flush 与退出之间重启过),直接复用旧计数
797
+ // 会导致 nonce 重放/解不开 → 生产「无法解密」。宁可让手机自动重握手一次,也不冒错帧风险。
798
+ session.verified = false;
799
+ session.restoredNeedsHandshake = true;
796
800
  session.createdAt = Number(row.createdAt) || usedAt;
797
801
  session.usedAt = usedAt;
798
802
  this.sessions.set(sessId, session);
799
- const guards = { p2b: new SequenceGuard(), b2p: new SequenceGuard() };
800
- if (Number.isInteger(row.p2b)) guards.p2b.last = row.p2b;
801
- if (Number.isInteger(row.b2p)) guards.b2p.last = row.b2p;
802
- this.ctrlCounters.set(sessId, guards);
803
+ // ★ 计数同样**不恢复**:恢复的会话未验证,重新握手会从 0 重新计数;
804
+ // 继承旧计数只会在下一次探测时误判「nonce 重放」。
805
+ this.ctrlCounters.set(sessId, { p2b: new SequenceGuard(), b2p: new SequenceGuard() });
803
806
  } catch { /* 单条坏 → 跳过,不影响其它会话 */ }
804
807
  }
805
808
  this.prune();
@@ -932,7 +935,9 @@ export class E2eeService {
932
935
  // 会话持久化原语(2026-10-23;方案/信任边界见 E2eeService.attachStore 上方长注释)
933
936
  // ============================================================
934
937
 
935
- export const SESSION_STORE_VERSION = 1;
938
+ // 2026-10-24 → 2:0.7.5 的 v1 库恢复后直接复用旧计数,生产出现「nonce 重放/无法解密」。
939
+ // 升级到 v2 时**旧库一律作废**(restore 里版本不符会 clearSessionStore),手机重新握手一次。
940
+ export const SESSION_STORE_VERSION = 2;
936
941
  export const SESSION_STORE_FILE = ".e2ee-sessions.enc";
937
942
  /** 会话库 AAD(域分隔;换格式/换用途必须改它,旧文件自动解不开 → 丢弃重握手)。 */
938
943
  export const SESSION_STORE_AAD = Buffer.from(`${E2EE_DOMAIN}\x00sessions-store-v1`, "utf8");
@@ -1127,7 +1127,15 @@ const UPSTREAM_FILE = ".dsh-upstream";
1127
1127
  * 本次安装要**钉住**的上游(null = 不钉,运行时按 env → .dsh-upstream → 探测 取值)。
1128
1128
  * 见 desktopAppPort() 的注释:插件装进多个 profile 后,各宿主会互相覆盖 `.dsh-upstream`。
1129
1129
  */
1130
- let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
1130
+ // 🔴 2026-10-25 事故:以前把安装进程里的 DSH_BRIDGE_UPSTREAM(一个**当时的动态端口**)写进
1131
+ // launchd plist,之后 dsh 每次重启换端口,plist 里的旧端口就成了永久生效的"显式指定",
1132
+ // 守护/桥永远连不上 → 设备掉线。现在默认**不钉**(运行时按 插件写的 .dsh-upstream → 探测 取值);
1133
+ // 仅当部署方显式要求(DSH_BRIDGE_PIN_UPSTREAM=1)时才沿用旧行为。
1134
+ let PINNED_UPSTREAM = (() => {
1135
+ const pinFlag = String(process.env.DSH_BRIDGE_PIN_UPSTREAM || "").trim().toLowerCase();
1136
+ if (pinFlag !== "1" && pinFlag !== "true" && pinFlag !== "on") return null;
1137
+ return String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
1138
+ })();
1131
1139
 
1132
1140
  /**
1133
1141
  * 探测桌面端(Electron 应用)当前监听的**回环端口**。
@@ -1223,6 +1231,18 @@ async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
1223
1231
  */
1224
1232
  async function isDshWebUp(timeoutMs = 1200) {
1225
1233
  if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
1234
+ // 🔴 2026-10-25(设备掉线实测):Electron 每次启动可能换端口。端口文件由 dsh web 内的插件
1235
+ // 在每次启动时重写,比进程内的 discoveredUpstream 缓存新 —— 先直接试文件,别被旧缓存挡住。
1236
+ try {
1237
+ const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
1238
+ if (/^https?:\/\/[^\s]+$/i.test(fromFile)) {
1239
+ const normalized = fromFile.replace(/\/+$/, "");
1240
+ if (normalized !== upstreamUrl() && (await upstreamReachable(normalized, timeoutMs))) {
1241
+ discoveredUpstream = normalized;
1242
+ return true;
1243
+ }
1244
+ }
1245
+ } catch { /* 没有端口文件:继续走动态发现 */ }
1226
1246
  const before = upstreamUrl();
1227
1247
  const after = await refreshUpstreamDiscovery(true);
1228
1248
  if (after === before) return false;
@@ -1659,7 +1679,22 @@ function releaseSpawnLock() {
1659
1679
  }
1660
1680
 
1661
1681
  // ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
1682
+ /** 以插件写入的 .dsh-upstream 为准校正进程内的旧上游环境变量(dsh 换端口后不再连旧端口)。 */
1683
+ function reconcileUpstreamEnvFromFile() {
1684
+ try {
1685
+ const fileUp = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim().replace(/\/+$/, "");
1686
+ const envUp = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "");
1687
+ if (envUp && /^https?:\/\/[^\s]+$/i.test(fileUp) && fileUp !== envUp) {
1688
+ console.log(`[dsh-remote] 忽略旧的上游地址 ${envUp} → 使用插件写入的最新地址 ${fileUp}(防端口漂移掉线)`);
1689
+ process.env.DSH_BRIDGE_UPSTREAM = fileUp;
1690
+ discoveredUpstream = fileUp;
1691
+ }
1692
+ } catch { /* 无文件:保持原行为 */ }
1693
+ }
1694
+
1662
1695
  async function runBridge() {
1696
+ // 启动/每次拉起前都按插件写入的端口校正继承来的旧 env(旧 plist 可能钉着安装时的端口)
1697
+ reconcileUpstreamEnvFromFile();
1663
1698
  // 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
1664
1699
  // 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准。
1665
1700
  //
@@ -1856,9 +1891,13 @@ async function runBridge() {
1856
1891
  }
1857
1892
  warnedDuplicateBridge = false;
1858
1893
 
1894
+ reconcileUpstreamEnvFromFile(); // dsh 可能刚重启并换了端口:拉起前再以最新端口文件为准
1859
1895
  const alive = await checkUpstream();
1860
1896
  if (alive && childStopped(bridgeProc)) {
1861
1897
  starting = true;
1898
+ // dsh 重启可能换端口(Electron 每次启动端口可能变):拉起前**强制重新发现**,
1899
+ // 否则 discoveredUpstream 缓存旧端口 → bridge 连不上上游 → 设备掉线(2026-10-25 实测)。
1900
+ await refreshUpstreamDiscovery(true);
1862
1901
  console.log("[dsh-remote] dsh web 在线,启动 bridge...");
1863
1902
  // 清除代理环境变量(bridge 需直连 relay,不受本机代理影响)
1864
1903
  const childEnv = {
@@ -1900,6 +1939,17 @@ async function runBridge() {
1900
1939
  // 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
1901
1940
  // 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
1902
1941
  if (code === DUPLICATE_INSTANCE_EXIT_CODE) {
1942
+ // 🔴 2026-10-25:升级/重启后常见「上一代 bridge 变成孤儿继续占着单实例锁」,
1943
+ // 新 bridge 因此退出 code=4。旧逻辑一律暂停 5 分钟 → 设备在线要等 5 分钟(用户实测掉线)。
1944
+ // 现在先**清扫同运行时的孤儿 bridge**;扫到了说明冲突已解除,3 秒后立即重试;
1945
+ // 扫不到(多半真有另一个 watcher 的活实例)才按 5 分钟退避,避免乒乓互踢。
1946
+ let swept = [];
1947
+ try { swept = sweepStrayBridges(); } catch { /* 清扫失败不致命 */ }
1948
+ if (swept.length > 0) {
1949
+ duplicateBridgeBackoffUntil = Date.now() + 3000;
1950
+ console.log(`[dsh-remote] bridge 因同实例冲突退出(code=${code}),已清扫 ${swept.length} 个孤儿 bridge(pid ${swept.join(", ")})→ 3 秒后重试。`);
1951
+ return;
1952
+ }
1903
1953
  duplicateBridgeBackoffUntil = Date.now() + DUPLICATE_BRIDGE_BACKOFF_MS;
1904
1954
  console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
1905
1955
  return;
@@ -1913,6 +1963,8 @@ async function runBridge() {
1913
1963
  setTimeout(() => { starting = false; }, 5000);
1914
1964
  } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
1915
1965
  console.log("[dsh-remote] dsh web 离线,停止 bridge...");
1966
+ // dsh 重启后端口可能变化:清掉进程内缓存,下一轮优先用插件新写的端口文件探测
1967
+ discoveredUpstream = "";
1916
1968
  await stopChildGracefully(bridgeProc); // 同上:Windows 上 kill() 跑不到退出钩子
1917
1969
  }
1918
1970
  };
@@ -2024,9 +2076,11 @@ async function setup(argv) {
2024
2076
  if (!PINNED_UPSTREAM && pluginTargets.some((t) => t.name === "desktop")) {
2025
2077
  const p = desktopAppPort();
2026
2078
  if (p) {
2027
- PINNED_UPSTREAM = `http://127.0.0.1:${p}`;
2028
- try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), PINNED_UPSTREAM + "\n"); } catch { /* 忽略 */ }
2029
- console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 桥的上游已钉到它(避免与 Web 版互抢)`);
2079
+ // 🔴 2026-10-25:这里只能写运行时端口文件;**不能**回填 PINNED_UPSTREAM ——
2080
+ // 它会被写进 launchd plist 变成永久显式指定,dsh 换端口后守护/桥永远连旧端口
2081
+ // (用户实测 upstream_unreachable)。
2082
+ try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), `http://127.0.0.1:${p}` + "\n"); } catch { /* 忽略 */ }
2083
+ console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 已写入运行时上游端口(不再钉进自启动配置)`);
2030
2084
  }
2031
2085
  }
2032
2086
  } catch { /* 探测失败不阻断安装 */ }
@@ -2583,6 +2637,45 @@ function missingPluginFiles(dir) {
2583
2637
  *
2584
2638
  * 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
2585
2639
  */
2640
+ // ── 桌面端「暫存安装」:应用运行中不能改写它的 profile(会把它弄退出/重启),
2641
+ // 但用户点「一键更新」必须能装到桌面端。做法:先整份拷到 ~/.dsh-remote/pending-plugin/ 并写 marker,
2642
+ // 由常驻的 bridge 在检测到应用退出后自动原子替换 profile 内的插件目录(见 dsh-bridge.mjs)。
2643
+ const PENDING_PLUGIN_MARKER = path.join(CONFIG_DIR, ".plugin-update-pending.json");
2644
+ const PENDING_PLUGIN_DIR = path.join(CONFIG_DIR, "pending-plugin");
2645
+
2646
+ /** 暂存一份插件更新(供 bridge 在应用退出后安装)。返回 marker 对象。 */
2647
+ function stagePluginUpdateForProfile(profileDir, pluginDir) {
2648
+ fs.rmSync(PENDING_PLUGIN_DIR, { recursive: true, force: true });
2649
+ fs.mkdirSync(PENDING_PLUGIN_DIR, { recursive: true });
2650
+ const cpOpts = {
2651
+ recursive: true,
2652
+ filter: (src) => {
2653
+ if (src === pluginDir) return true;
2654
+ const rel = String(path.relative(pluginDir, src));
2655
+ const segs = rel.split(path.sep);
2656
+ if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
2657
+ const inBundledRuntime = segs[0] === "runtime";
2658
+ if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
2659
+ return true;
2660
+ }
2661
+ };
2662
+ fs.cpSync(pluginDir, PENDING_PLUGIN_DIR, cpOpts);
2663
+ for (const rel of PLUGIN_REQUIRED_FILES) {
2664
+ const f = path.join(PENDING_PLUGIN_DIR, rel);
2665
+ if (!fs.existsSync(f)) throw new Error(`暂存校验失败:缺少 ${rel}`);
2666
+ }
2667
+ const version = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8")).version;
2668
+ const marker = {
2669
+ version,
2670
+ profileDir,
2671
+ pluginDirName: PLUGIN_LOCAL_DIR,
2672
+ stageDir: PENDING_PLUGIN_DIR,
2673
+ stagedAt: Date.now()
2674
+ };
2675
+ fs.writeFileSync(PENDING_PLUGIN_MARKER, JSON.stringify(marker, null, 2) + "\n");
2676
+ return marker;
2677
+ }
2678
+
2586
2679
  function copyPluginIntoProfile(profileDir, pluginDir) {
2587
2680
  // 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
2588
2681
  for (const id of PLUGIN_LEGACY_IDS) {
@@ -3031,16 +3124,21 @@ async function pluginCmd(argv) {
3031
3124
  let last = null;
3032
3125
  let ok = 0;
3033
3126
  let skippedRunning = 0;
3127
+ let stagedRunning = 0;
3034
3128
  const force = hasFlag(argv, "--force");
3035
3129
  for (const t of targets) {
3036
3130
  // ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
3037
3131
  if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
3038
- console.log(`⚠️ 跳过 desktop:DeepSeek Harness 应用正在运行。`);
3039
- console.log(` 应用独占该 profile,运行中改写会让应用自己退出/重启。请任选其一:`);
3040
- console.log(` · 完全退出应用后重跑本命令;或`);
3041
- console.log(` · 在应用内「插件」页安装/更新 dsh-remote-web(官方路径,装完即热加载)。`);
3042
- console.log(` (确实要强行写入:加 --force)`);
3043
- skippedRunning += 1;
3132
+ console.log(`⚠️ DeepSeek Harness 应用正在运行 → 不直接改写它的 profile(会把它弄退出/重启)。`);
3133
+ try {
3134
+ const staged = stagePluginUpdateForProfile(t.dir, path.join(THIS_DIR, "packages", PLUGIN_ID));
3135
+ stagedRunning += 1;
3136
+ console.log(` ✅ 新版本 ${staged.version} 已暂存:**退出 DeepSeek Harness 后会自动安装**,重新打开应用即生效(无需终端)。`);
3137
+ } catch (e) {
3138
+ console.log(` ⚠️ 暂存失败:${e && e.message ? e.message : e}`);
3139
+ console.log(` 请完全退出应用后重跑本命令,或在应用内「插件」页安装/更新 dsh-remote-web。`);
3140
+ skippedRunning += 1;
3141
+ }
3044
3142
  continue;
3045
3143
  }
3046
3144
  if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
@@ -3050,6 +3148,7 @@ async function pluginCmd(argv) {
3050
3148
  if (r !== undefined && r !== null) last = r;
3051
3149
  }
3052
3150
  if (ok === 0 && skippedRunning === 0) process.exit(1);
3151
+ if (stagedRunning > 0) console.log(`ℹ 桌面端插件已暂存(${stagedRunning} 个):退出 DeepSeek Harness 后由后台服务自动安装,重开应用生效。`);
3053
3152
  if (skippedRunning > 0) console.log(`ℹ 本次跳过了 ${skippedRunning} 个 profile(应用在运行);桥与运行环境不受影响。`);
3054
3153
  return last;
3055
3154
  }