@wenbin_wb/dsh-bridge 2.10.7 → 2.10.8

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.10.8] - 2026-09-08
8
+
9
+ ### ✨ 新功能
10
+
11
+ - **登录 Session 持久化**(issue #36,PR #37):宿主(dsh web)进程重启后,已登录设备不再需要重输访问密码——手机远程访问的登录态不再随每次 DSH 重启丢失。改密码 / 切换模式 / 重新生成 Token 仍会同步吊销全部旧会话(安全语义保持);文件损坏/写盘失败安全降级。
12
+ - **设置页检测 DSH 宿主更新并一键升级**:版本横幅同步检测 `@deepseek-ai/dsh` 线上最新版,发现新版时黄色提示;检测到 npm 全局安装形态时提供「一键升级 DSH」按钮(升级后引导重启),非 npm 安装(源码 / 打包加壳等)仅提醒并给官方升级方式,避免误导。
13
+ - **移动端输入框可折叠**:聊天头部工具栏(Session 下载钮旁)新增折叠按钮,收起底部输入区后消息阅读区最大化(约 +20% 可视高度);折叠时底部保留「✏️ 点击输入消息」细条一键唤回;折叠偏好跨会话记忆。
14
+
15
+ ### 🐞 修复
16
+
17
+ - **iOS Safari 键盘弹起时聊天输入框上下跳动**:输入区为 sticky 吸底布局,键盘弹起时随 visual viewport 收缩反复重排抖动——现键盘弹起时固定输入框所属滚动容器高度,键盘收起后还原,仅 iOS 生效、不依赖宿主类名。
18
+
19
+ ---
20
+
7
21
  ## [v2.10.7] - 2026-09-08
8
22
 
9
23
  ### 🐞 修复
package/client/client.js CHANGED
@@ -425,6 +425,74 @@ var MOBILE_STYLES_CSS = `
425
425
  padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
426
426
  }
427
427
 
428
+ /* \u2500\u2500 \u79FB\u52A8\u7AEF\u8F93\u5165\u6846\u6298\u53E0\u6A21\u5F0F\uFF08body.dsh-composer-collapsed\uFF09\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
429
+ \u6298\u53E0\u540E\u9690\u85CF\u5438\u5E95\u8F93\u5165\u533A\uFF0C\u8BA9\u6D88\u606F viewArea \u81EA\u52A8\u4F38\u5C55\u5230\u5168\u9AD8\uFF0C\u6700\u5927\u5316\u9605\u8BFB\u533A\u3002
430
+ \u5165\u53E3\uFF1A\u804A\u5929\u5934\u90E8\u5DE5\u5177\u680F\uFF08Session \u4E0B\u8F7D\u94AE\u65C1\uFF09\u7684\u6298\u53E0\u6309\u94AE\uFF0C\u6216\u6298\u53E0\u6001\u5E95\u90E8\u7EC6\u8F93\u5165\u6761\u70B9\u5B83\u5524\u56DE\u3002
431
+ \u72B6\u6001\u6301\u4E45\u5316\u5230 localStorage\u3002 */
432
+ body.dsh-composer-collapsed div[class*="wSkVaW_composerSeat"] {
433
+ display: none !important;
434
+ }
435
+ body.dsh-composer-collapsed div[class*="wSkVaW_viewArea"] {
436
+ flex: 1 1 auto !important;
437
+ height: auto !important;
438
+ min-height: 0 !important;
439
+ }
440
+ /* \u6298\u53E0\u65F6\u6EDA\u52A8\u533A\u5E95\u90E8\u5BF9\u9F50 safe-area\uFF0C\u907F\u514D\u5185\u5BB9\u88AB iPhone \u5E95\u90E8\u6A2A\u6761\u906E\u6321 */
441
+ body.dsh-composer-collapsed div[class*="wSkVaW_scrollBody"] {
442
+ padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
443
+ }
444
+
445
+ /* \u6298\u53E0\u8F93\u5165\u6846\u6309\u94AE\uFF1A\u6CE8\u5165\u5728\u804A\u5929\u5934\u90E8\u5DE5\u5177\u680F\uFF08Session \u4E0B\u8F7D\u94AE\u65C1\uFF09\uFF0C
446
+ \u4E0E DSH sessionLogButton \u540C\u89C4\u683C\uFF0828px \u5706\u5F62\u56FE\u6807\u94AE\uFF09\uFF0C\u89C6\u89C9\u4E0E\u539F\u751F\u4E00\u81F4 */
447
+ .dsh-header-fold-btn {
448
+ min-width: 28px !important;
449
+ width: 28px !important;
450
+ height: 28px !important;
451
+ padding: 0 !important;
452
+ border-radius: 50% !important;
453
+ display: inline-flex !important;
454
+ align-items: center !important;
455
+ justify-content: center !important;
456
+ flex-shrink: 0 !important;
457
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1)) !important;
458
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.03)) !important;
459
+ color: var(--dsw-alias-label-secondary, #6b7280) !important;
460
+ cursor: pointer !important;
461
+ transition: opacity 0.15s !important;
462
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03) !important;
463
+ -webkit-tap-highlight-color: transparent !important;
464
+ }
465
+ .dsh-header-fold-btn:active {
466
+ opacity: 0.6 !important;
467
+ }
468
+ .dsh-header-fold-btn svg {
469
+ width: 15px !important;
470
+ height: 15px !important;
471
+ }
472
+
473
+ /* \u6298\u53E0\u6001\u5E95\u90E8\u7EC6\u8F93\u5165\u6761\uFF1A\u70B9\u51FB\u5524\u8D77\u8F93\u5165\u6846\uFF08\u4F4D\u4E8E\u539F\u8F93\u5165\u533A\u4F4D\u7F6E\uFF0Csticky \u5E95\u90E8\uFF09 */
474
+ .dsh-composer-collapsed-bar {
475
+ display: none !important;
476
+ position: sticky !important;
477
+ bottom: 0 !important;
478
+ margin: 8px 12px max(8px, env(safe-area-inset-bottom, 0px)) 12px !important;
479
+ padding: 11px 16px !important;
480
+ border-radius: 22px !important;
481
+ background: var(--dsw-alias-bg-layer-2, #f4f4f7) !important;
482
+ border: 1px solid rgba(0, 0, 0, 0.07) !important;
483
+ color: var(--dsw-alias-label-tertiary, #8b93a1) !important;
484
+ font-size: 13.5px !important;
485
+ line-height: 1.4 !important;
486
+ cursor: pointer !important;
487
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04) !important;
488
+ -webkit-tap-highlight-color: transparent !important;
489
+ z-index: 40 !important;
490
+ box-sizing: border-box !important;
491
+ }
492
+ body.dsh-composer-collapsed .dsh-composer-collapsed-bar {
493
+ display: block !important;
494
+ }
495
+
428
496
  /* \u8F93\u5165\u5361\u7247\uFF1ADeepSeek App \u5706\u89D2\u5927\u80F6\u56CA\u9020\u578B */
429
497
  div[class*="uV2eYG_card"] {
430
498
  border-radius: 26px !important;
@@ -999,6 +1067,7 @@ var BRIDGE_ENDPOINTS = {
999
1067
  setLanIp: "setLanIp",
1000
1068
  checkVersion: "checkVersion",
1001
1069
  upgradePlugin: "upgradePlugin",
1070
+ upgradeDsh: "upgradeDsh",
1002
1071
  restartDsh: "restartDsh",
1003
1072
  exportBackup: "exportBackup",
1004
1073
  importBackup: "importBackup",
@@ -3690,6 +3759,7 @@ function VersionBanner({ rpcCall }) {
3690
3759
  }, [check]);
3691
3760
  const hasUpdate = info?.latest && info?.current && !info.error && semverGt(info.latest, info.current);
3692
3761
  const isLatest = info?.latest && info?.current && !info.error && !semverGt(info.latest, info.current);
3762
+ const dshHasUpdate = !!(info?.dshLatest && info?.dshVersion && !info.error && semverGt(info.dshLatest, info.dshVersion));
3693
3763
  const handleUpgrade = React.useCallback(async () => {
3694
3764
  if (!info?.latest || upgrading) return;
3695
3765
  setUpgrading(true);
@@ -3711,6 +3781,28 @@ function VersionBanner({ rpcCall }) {
3711
3781
  setUpgrading(false);
3712
3782
  }
3713
3783
  }, [info?.latest, upgrading, rpcCall]);
3784
+ const [dshUpgrading, setDshUpgrading] = React.useState(false);
3785
+ const [dshUpgradeResult, setDshUpgradeResult] = React.useState(null);
3786
+ const handleUpgradeDsh = React.useCallback(async () => {
3787
+ if (!info?.dshLatest || dshUpgrading) return;
3788
+ setDshUpgrading(true);
3789
+ setDshUpgradeResult(null);
3790
+ setDismissRestart(false);
3791
+ resetRestartStatus();
3792
+ try {
3793
+ const r = await rpcCall(BRIDGE_ENDPOINTS.upgradeDsh, { version: info.dshLatest });
3794
+ if (r?.ok && r.value?.ok) {
3795
+ setDshUpgradeResult({ ok: true, message: `DSH \u5DF2\u6210\u529F\u5347\u7EA7\u5230 v${info.dshLatest}\uFF01` });
3796
+ } else {
3797
+ const msg = r?.value?.error || r?.error?.message || "\u5347\u7EA7\u5931\u8D25";
3798
+ setDshUpgradeResult({ ok: false, message: msg, manual: true });
3799
+ }
3800
+ } catch (e) {
3801
+ setDshUpgradeResult({ ok: false, message: e.message || "\u5347\u7EA7\u8BF7\u6C42\u5931\u8D25", manual: true });
3802
+ } finally {
3803
+ setDshUpgrading(false);
3804
+ }
3805
+ }, [info?.dshLatest, dshUpgrading, rpcCall]);
3714
3806
  const links = React.createElement(
3715
3807
  "div",
3716
3808
  { style: { display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" } },
@@ -3775,24 +3867,26 @@ function VersionBanner({ rpcCall }) {
3775
3867
  hasUpdate && React.createElement("span", { style: { fontWeight: 600, fontSize: 11 } }, `\u2794 v${info.latest}`),
3776
3868
  info?.error && React.createElement("span", { style: { color: "var(--dsw-alias-state-warn-primary,#d97706)", fontSize: 11 } }, "(\u7F51\u7EDC\u8D85\u65F6)")
3777
3869
  ),
3778
- // DSH 宿主版本标签
3870
+ // DSH 宿主版本标签(有新版时黄色高亮)
3779
3871
  info?.dshVersion && React.createElement(
3780
3872
  "span",
3781
3873
  {
3782
3874
  style: {
3783
3875
  ...s.tag,
3784
- background: "var(--dsw-alias-bg-layer-2,#f3f4f6)",
3785
- color: "var(--dsw-alias-label-tertiary,#6b7280)",
3876
+ background: dshHasUpdate ? "var(--dsw-alias-state-warn-bg,#fffbeb)" : "var(--dsw-alias-bg-layer-2,#f3f4f6)",
3877
+ color: dshHasUpdate ? "var(--dsw-alias-state-warn-primary,#d97706)" : "var(--dsw-alias-label-tertiary,#6b7280)",
3786
3878
  padding: "3px 10px",
3787
3879
  fontSize: 12,
3788
3880
  fontWeight: 500,
3789
3881
  display: "inline-flex",
3790
3882
  alignItems: "center",
3791
3883
  gap: 5
3792
- }
3884
+ },
3885
+ title: dshHasUpdate ? `\u53D1\u73B0 DSH \u65B0\u7248\u672C v${info.dshLatest}` : void 0
3793
3886
  },
3794
3887
  React.createElement("span", { style: { opacity: 0.75, fontSize: 11, fontWeight: 400 } }, "DSH"),
3795
- `v${info.dshVersion}`
3888
+ `v${info.dshVersion}`,
3889
+ dshHasUpdate && React.createElement("span", { style: { fontWeight: 600, fontSize: 11 } }, `\u2794 v${info.dshLatest}`)
3796
3890
  ),
3797
3891
  // 刷新检查按钮
3798
3892
  React.createElement(
@@ -3982,6 +4076,109 @@ function VersionBanner({ rpcCall }) {
3982
4076
  )
3983
4077
  )
3984
4078
  )
4079
+ ),
4080
+ // ── DSH 宿主有新版本时的提示 / 一键升级卡片 ──
4081
+ dshHasUpdate && React.createElement(
4082
+ "div",
4083
+ {
4084
+ style: {
4085
+ ...s.card,
4086
+ background: "var(--dsw-alias-state-warn-bg,#fffbeb)",
4087
+ border: "1px solid var(--dsw-alias-state-warn-border,#fde68a)",
4088
+ padding: "14px 16px",
4089
+ marginTop: 10,
4090
+ marginBottom: 0
4091
+ }
4092
+ },
4093
+ React.createElement(
4094
+ "div",
4095
+ { style: { display: "flex", alignItems: "flex-start", gap: 12 } },
4096
+ React.createElement("span", { style: { fontSize: 22 } }, "\u{1F6E0}\uFE0F"),
4097
+ React.createElement(
4098
+ "div",
4099
+ { style: { flex: 1, minWidth: 0 } },
4100
+ React.createElement(
4101
+ "div",
4102
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 8, marginBottom: 8 } },
4103
+ React.createElement("div", {
4104
+ style: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-state-warn-primary,#92400e)" }
4105
+ }, `DSH \u6709\u65B0\u7248\u672C v${info.dshLatest}\uFF08\u5F53\u524D v${info.dshVersion}\uFF09`),
4106
+ info?.dshUpgradable ? React.createElement(
4107
+ "button",
4108
+ {
4109
+ style: {
4110
+ ...s.btnPri,
4111
+ height: 28,
4112
+ fontSize: 12,
4113
+ padding: "0 14px",
4114
+ background: dshUpgradeResult?.ok ? "var(--dsw-alias-state-success-primary,#059669)" : "var(--dsw-alias-brand-primary,#4f6ef7)",
4115
+ opacity: dshUpgrading || restarting ? 0.6 : 1
4116
+ },
4117
+ onClick: handleUpgradeDsh,
4118
+ disabled: dshUpgrading || restarting || dshUpgradeResult?.ok
4119
+ },
4120
+ dshUpgrading ? React.createElement(
4121
+ "span",
4122
+ { style: { display: "inline-flex", alignItems: "center", gap: 6 } },
4123
+ React.createElement("span", { style: { animation: "spin 1s linear infinite", display: "inline-flex" } }, React.createElement(Icons.refresh)),
4124
+ "\u6B63\u5728\u5347\u7EA7 DSH\u2026"
4125
+ ) : dshUpgradeResult?.ok ? "\u2713 DSH \u5347\u7EA7\u5B8C\u6210" : `\u4E00\u952E\u5347\u7EA7 DSH \u5230 v${info.dshLatest}`
4126
+ ) : React.createElement("a", {
4127
+ href: GITHUB_URL,
4128
+ target: "_blank",
4129
+ rel: "noreferrer",
4130
+ style: { ...s.btnLink, fontSize: 12, fontWeight: 600 }
4131
+ }, "\u67E5\u770B\u5B98\u65B9\u5347\u7EA7\u65B9\u5F0F \u2197")
4132
+ ),
4133
+ React.createElement(
4134
+ "div",
4135
+ {
4136
+ style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.6 }
4137
+ },
4138
+ info?.dshUpgradable ? "\u5347\u7EA7 DSH \u547D\u4EE4\u884C\u5DE5\u5177\u540E\u9700\u91CD\u542F DSH \u670D\u52A1\u751F\u6548\u3002" : info?.dshUpgradeReason || "\u5F53\u524D DSH \u975E npm \u5168\u5C40\u5B89\u88C5\uFF0C\u65E0\u6CD5\u4E00\u952E\u81EA\u52A8\u5347\u7EA7\uFF0C\u8BF7\u6309\u5B98\u65B9\u6E20\u9053\u624B\u52A8\u66F4\u65B0\u3002"
4139
+ ),
4140
+ dshUpgradeResult && React.createElement("div", {
4141
+ style: {
4142
+ marginTop: 10,
4143
+ fontSize: 12,
4144
+ lineHeight: 1.6,
4145
+ color: dshUpgradeResult.ok ? "var(--dsw-alias-state-success-primary,#059669)" : "var(--dsw-alias-state-error-primary,#dc2626)"
4146
+ }
4147
+ }, dshUpgradeResult.message),
4148
+ dshUpgradeResult?.ok && !dismissRestart && React.createElement(
4149
+ "div",
4150
+ {
4151
+ style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginTop: 10 }
4152
+ },
4153
+ React.createElement("button", {
4154
+ style: { ...s.btnPri, height: 30, fontSize: 12, padding: "0 14px", background: "var(--dsw-alias-state-success-primary,#059669)" },
4155
+ onClick: handleRestart
4156
+ }, "\u{1F504} \u7ACB\u5373\u91CD\u542F DSH \u670D\u52A1"),
4157
+ React.createElement("button", {
4158
+ style: { ...s.btnGhost, height: 30, fontSize: 12, padding: "0 12px" },
4159
+ onClick: () => setDismissRestart(true)
4160
+ }, "\u7A0D\u540E\u624B\u52A8\u91CD\u542F")
4161
+ ),
4162
+ (restarting || restartStatus) && React.createElement(
4163
+ "div",
4164
+ {
4165
+ style: {
4166
+ display: "flex",
4167
+ alignItems: "center",
4168
+ gap: 8,
4169
+ fontSize: 12,
4170
+ marginTop: 8,
4171
+ color: restartStatus?.phase === "success" ? "var(--dsw-alias-state-success-primary,#059669)" : restartStatus?.phase === "timeout" ? "var(--dsw-alias-state-error-primary,#dc2626)" : "var(--dsw-alias-state-info-primary,#2563eb)",
4172
+ fontWeight: 500
4173
+ }
4174
+ },
4175
+ restartStatus?.phase !== "success" && restartStatus?.phase !== "timeout" && React.createElement("span", {
4176
+ style: { animation: "spin 1s linear infinite", display: "inline-flex" }
4177
+ }, React.createElement(Icons.refresh)),
4178
+ restartStatus?.text || "\u6B63\u5728\u5904\u7406\u2026"
4179
+ )
4180
+ )
4181
+ )
3985
4182
  )
3986
4183
  );
3987
4184
  }
@@ -5756,10 +5953,196 @@ function RemoteDirectoryFlow(props) {
5756
5953
  }, [open, pick]);
5757
5954
  return null;
5758
5955
  }
5956
+ function setupIosKeyboardAdapter() {
5957
+ if (typeof window === "undefined" || !window.visualViewport) return;
5958
+ if (!/iPhone|iPad|iPod/.test(navigator.userAgent || "")) return;
5959
+ const vv = window.visualViewport;
5960
+ const isEditable = (el) => el && (el.isContentEditable || el.tagName === "TEXTAREA" || el.tagName === "INPUT");
5961
+ let keyboardOpen = false;
5962
+ let pinnedEl = null;
5963
+ let pinnedH = null;
5964
+ let pinnedInline = null;
5965
+ const findScrollContainer = (el) => {
5966
+ let n = el;
5967
+ while (n && n !== document.body) {
5968
+ const cs = getComputedStyle(n);
5969
+ if ((cs.overflowY === "auto" || cs.overflowY === "scroll") && n.scrollHeight > n.clientHeight) {
5970
+ return n;
5971
+ }
5972
+ n = n.parentElement;
5973
+ }
5974
+ return null;
5975
+ };
5976
+ vv.addEventListener("resize", () => {
5977
+ const ratio = vv.height / window.innerHeight;
5978
+ const nowOpen = ratio < 0.75;
5979
+ if (nowOpen === keyboardOpen) return;
5980
+ keyboardOpen = nowOpen;
5981
+ const el = document.activeElement;
5982
+ if (nowOpen) {
5983
+ const scroller = isEditable(el) ? findScrollContainer(el) : null;
5984
+ if (scroller) {
5985
+ pinnedEl = scroller;
5986
+ pinnedInline = scroller.style.height || "";
5987
+ pinnedH = scroller.getBoundingClientRect().height;
5988
+ scroller.style.height = `${Math.round(pinnedH)}px`;
5989
+ }
5990
+ requestAnimationFrame(() => {
5991
+ if (isEditable(el)) {
5992
+ try {
5993
+ el.scrollIntoView({ block: "nearest" });
5994
+ } catch {
5995
+ }
5996
+ }
5997
+ });
5998
+ } else {
5999
+ if (pinnedEl) {
6000
+ pinnedEl.style.height = pinnedInline;
6001
+ pinnedEl = null;
6002
+ pinnedH = null;
6003
+ pinnedInline = null;
6004
+ }
6005
+ }
6006
+ });
6007
+ }
6008
+ function setupComposerCollapse() {
6009
+ if (typeof window === "undefined" || typeof document === "undefined") return;
6010
+ if (window.innerWidth > 768) return;
6011
+ const LS_KEY = "dsh-composer-fold";
6012
+ let bar = null;
6013
+ let busy = false;
6014
+ let lastHadSeat = null;
6015
+ const getFoldBtn = () => {
6016
+ const existing = document.querySelector(".dsh-header-fold-btn");
6017
+ if (existing) return existing;
6018
+ const utils = document.querySelector('div[class*="wSkVaW_headerUtilities"], div[class*="headerUtilities"]');
6019
+ const logBtn = document.querySelector('button[class*="sessionLogButton"], button[class*="nL4_yW_sessionLogButton"]');
6020
+ if (!utils) return null;
6021
+ const btn = document.createElement("button");
6022
+ btn.className = "dsh-header-fold-btn";
6023
+ btn.setAttribute("aria-label", "\u6536\u8D77/\u5C55\u5F00\u8F93\u5165\u6846");
6024
+ btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
6025
+ if (logBtn && logBtn.parentElement === utils) utils.insertBefore(btn, logBtn);
6026
+ else utils.appendChild(btn);
6027
+ return btn;
6028
+ };
6029
+ const isCollapsed = () => document.body.classList.contains("dsh-composer-collapsed");
6030
+ const readPref = () => {
6031
+ try {
6032
+ return localStorage.getItem(LS_KEY) === "1";
6033
+ } catch {
6034
+ return false;
6035
+ }
6036
+ };
6037
+ const ensureBar = () => {
6038
+ if (bar && document.body.contains(bar)) return bar;
6039
+ const seat = document.querySelector('div[class*="composerSeat"]');
6040
+ const scrollBody = seat ? seat.parentElement : null;
6041
+ if (!scrollBody) return null;
6042
+ bar = document.createElement("div");
6043
+ bar.className = "dsh-composer-collapsed-bar";
6044
+ bar.textContent = "\u270F\uFE0F \u70B9\u51FB\u8F93\u5165\u6D88\u606F\u2026";
6045
+ scrollBody.insertBefore(bar, seat);
6046
+ bar.addEventListener("click", () => setCollapsed(false));
6047
+ return bar;
6048
+ };
6049
+ const removeBar = () => {
6050
+ if (bar) {
6051
+ try {
6052
+ bar.remove();
6053
+ } catch {
6054
+ }
6055
+ bar = null;
6056
+ }
6057
+ };
6058
+ const setCollapsed = (collapsed) => {
6059
+ if (busy) return;
6060
+ busy = true;
6061
+ try {
6062
+ if (isCollapsed() !== collapsed) {
6063
+ document.body.classList.toggle("dsh-composer-collapsed", collapsed);
6064
+ try {
6065
+ localStorage.setItem(LS_KEY, collapsed ? "1" : "0");
6066
+ } catch {
6067
+ }
6068
+ updateButton(collapsed);
6069
+ }
6070
+ if (collapsed) ensureBar();
6071
+ else removeBar();
6072
+ } finally {
6073
+ busy = false;
6074
+ }
6075
+ };
6076
+ const updateButton = (collapsed) => {
6077
+ const btn = getFoldBtn();
6078
+ if (!btn) return;
6079
+ btn.title = collapsed ? "\u5C55\u5F00\u8F93\u5165\u6846" : "\u6536\u8D77\u8F93\u5165\u6846\uFF0C\u6700\u5927\u5316\u5BF9\u8BDD\u9605\u8BFB\u533A";
6080
+ const icon = collapsed ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"></rect><line x1="6" y1="10" x2="6.01" y2="10"></line><line x1="10" y1="10" x2="10.01" y2="10"></line><line x1="14" y1="10" x2="14.01" y2="10"></line><line x1="6" y1="14" x2="10" y2="14"></line><line x1="14" y1="14" x2="18" y2="14"></line></svg>' : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
6081
+ const existing = btn.querySelector("svg");
6082
+ if (!existing || existing.outerHTML !== icon) btn.innerHTML = icon;
6083
+ };
6084
+ const handleSeatPresent = () => {
6085
+ const btn = getFoldBtn();
6086
+ if (!btn) return false;
6087
+ btn.style.display = "inline-flex";
6088
+ if (!btn._dshFoldWired) {
6089
+ btn._dshFoldWired = true;
6090
+ btn.onclick = (e) => {
6091
+ e.stopPropagation();
6092
+ setCollapsed(!isCollapsed());
6093
+ };
6094
+ }
6095
+ document.body.classList.add("dsh-composer-active");
6096
+ const want = readPref();
6097
+ if (want !== isCollapsed()) {
6098
+ setCollapsed(want);
6099
+ } else {
6100
+ updateButton(isCollapsed());
6101
+ }
6102
+ return true;
6103
+ };
6104
+ const handleSeatAbsent = () => {
6105
+ const btn = getFoldBtn();
6106
+ if (btn) {
6107
+ btn.style.display = "none";
6108
+ }
6109
+ document.body.classList.remove("dsh-composer-active");
6110
+ removeBar();
6111
+ };
6112
+ const onComposerChange = () => {
6113
+ const hasSeat = !!document.querySelector('div[class*="composerSeat"]');
6114
+ const hasBtn = !!document.querySelector(".dsh-header-fold-btn");
6115
+ if (busy) return;
6116
+ if (hasSeat === lastHadSeat && hasSeat === hasBtn) return;
6117
+ busy = true;
6118
+ try {
6119
+ if (hasSeat) {
6120
+ lastHadSeat = handleSeatPresent();
6121
+ } else {
6122
+ handleSeatAbsent();
6123
+ lastHadSeat = false;
6124
+ }
6125
+ } finally {
6126
+ busy = false;
6127
+ }
6128
+ };
6129
+ const observer = new MutationObserver(() => {
6130
+ observer.disconnect();
6131
+ try {
6132
+ onComposerChange();
6133
+ } finally {
6134
+ observer.observe(document.body, { childList: true, subtree: true });
6135
+ }
6136
+ });
6137
+ observer.observe(document.body, { childList: true, subtree: true });
6138
+ onComposerChange();
6139
+ }
5759
6140
  function apply(ctx) {
5760
6141
  window.__dshClientCtx = ctx;
5761
6142
  const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(BRIDGE_RPC_CHANNEL, endpoint, payload, signal);
5762
6143
  window.__dshOpenRemoteWorkspaceModal = (onAdded, onPickDirect, onCancel) => showRemoteWorkspaceDialog(rpcCall, onAdded, ctx, onPickDirect, onCancel);
6144
+ setupIosKeyboardAdapter();
6145
+ setupComposerCollapse();
5763
6146
  setupMobileExperience(rpcCall, ctx);
5764
6147
  const injected = () => ({ pick: () => ctx.workspaces?.pickDirectory?.() });
5765
6148
  ctx.slots.inject(
package/client/index.js CHANGED
@@ -2397,6 +2397,8 @@ function VersionBanner({ rpcCall }) {
2397
2397
 
2398
2398
  const hasUpdate = info?.latest && info?.current && !info.error && semverGt(info.latest, info.current);
2399
2399
  const isLatest = info?.latest && info?.current && !info.error && !semverGt(info.latest, info.current);
2400
+ // DSH 宿主更新状态:有线上新版 + 当前版本可比较
2401
+ const dshHasUpdate = !!(info?.dshLatest && info?.dshVersion && !info.error && semverGt(info.dshLatest, info.dshVersion));
2400
2402
 
2401
2403
  const handleUpgrade = React.useCallback(async () => {
2402
2404
  if (!info?.latest || upgrading) return;
@@ -2420,6 +2422,30 @@ function VersionBanner({ rpcCall }) {
2420
2422
  }
2421
2423
  }, [info?.latest, upgrading, rpcCall]);
2422
2424
 
2425
+ // DSH 宿主一键升级:仅当服务端判定可自动升级(npm 全局安装)时按钮才会出现
2426
+ const [dshUpgrading, setDshUpgrading] = React.useState(false);
2427
+ const [dshUpgradeResult, setDshUpgradeResult] = React.useState(null);
2428
+ const handleUpgradeDsh = React.useCallback(async () => {
2429
+ if (!info?.dshLatest || dshUpgrading) return;
2430
+ setDshUpgrading(true);
2431
+ setDshUpgradeResult(null);
2432
+ setDismissRestart(false);
2433
+ resetRestartStatus();
2434
+ try {
2435
+ const r = await rpcCall(BRIDGE_ENDPOINTS.upgradeDsh, { version: info.dshLatest });
2436
+ if (r?.ok && r.value?.ok) {
2437
+ setDshUpgradeResult({ ok: true, message: `DSH 已成功升级到 v${info.dshLatest}!` });
2438
+ } else {
2439
+ const msg = r?.value?.error || r?.error?.message || '升级失败';
2440
+ setDshUpgradeResult({ ok: false, message: msg, manual: true });
2441
+ }
2442
+ } catch (e) {
2443
+ setDshUpgradeResult({ ok: false, message: e.message || '升级请求失败', manual: true });
2444
+ } finally {
2445
+ setDshUpgrading(false);
2446
+ }
2447
+ }, [info?.dshLatest, dshUpgrading, rpcCall]);
2448
+
2423
2449
  const links = React.createElement('div', { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
2424
2450
  React.createElement('a', {
2425
2451
  href: GITHUB_URL, target: '_blank', rel: 'noreferrer', style: s.btnLink,
@@ -2478,12 +2504,16 @@ function VersionBanner({ rpcCall }) {
2478
2504
  hasUpdate && React.createElement('span', { style: { fontWeight: 600, fontSize: 11 } }, `➔ v${info.latest}`),
2479
2505
  info?.error && React.createElement('span', { style: { color: 'var(--dsw-alias-state-warn-primary,#d97706)', fontSize: 11 } }, '(网络超时)'),
2480
2506
  ),
2481
- // DSH 宿主版本标签
2507
+ // DSH 宿主版本标签(有新版时黄色高亮)
2482
2508
  info?.dshVersion && React.createElement('span', {
2483
2509
  style: {
2484
2510
  ...s.tag,
2485
- background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)',
2486
- color: 'var(--dsw-alias-label-tertiary,#6b7280)',
2511
+ background: dshHasUpdate
2512
+ ? 'var(--dsw-alias-state-warn-bg,#fffbeb)'
2513
+ : 'var(--dsw-alias-bg-layer-2,#f3f4f6)',
2514
+ color: dshHasUpdate
2515
+ ? 'var(--dsw-alias-state-warn-primary,#d97706)'
2516
+ : 'var(--dsw-alias-label-tertiary,#6b7280)',
2487
2517
  padding: '3px 10px',
2488
2518
  fontSize: 12,
2489
2519
  fontWeight: 500,
@@ -2491,9 +2521,11 @@ function VersionBanner({ rpcCall }) {
2491
2521
  alignItems: 'center',
2492
2522
  gap: 5,
2493
2523
  },
2524
+ title: dshHasUpdate ? `发现 DSH 新版本 v${info.dshLatest}` : undefined,
2494
2525
  },
2495
2526
  React.createElement('span', { style: { opacity: 0.75, fontSize: 11, fontWeight: 400 } }, 'DSH'),
2496
2527
  `v${info.dshVersion}`,
2528
+ dshHasUpdate && React.createElement('span', { style: { fontWeight: 600, fontSize: 11 } }, `➔ v${info.dshLatest}`),
2497
2529
  ),
2498
2530
  // 刷新检查按钮
2499
2531
  React.createElement('button', {
@@ -2672,6 +2704,102 @@ function VersionBanner({ rpcCall }) {
2672
2704
  ),
2673
2705
  ),
2674
2706
  ),
2707
+
2708
+ // ── DSH 宿主有新版本时的提示 / 一键升级卡片 ──
2709
+ dshHasUpdate && React.createElement('div', {
2710
+ style: {
2711
+ ...s.card,
2712
+ background: 'var(--dsw-alias-state-warn-bg,#fffbeb)',
2713
+ border: '1px solid var(--dsw-alias-state-warn-border,#fde68a)',
2714
+ padding: '14px 16px',
2715
+ marginTop: 10,
2716
+ marginBottom: 0,
2717
+ },
2718
+ },
2719
+ React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 12 } },
2720
+ React.createElement('span', { style: { fontSize: 22 } }, '🛠️'),
2721
+ React.createElement('div', { style: { flex: 1, minWidth: 0 } },
2722
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8, marginBottom: 8 } },
2723
+ React.createElement('div', {
2724
+ style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary,#92400e)' },
2725
+ }, `DSH 有新版本 v${info.dshLatest}(当前 v${info.dshVersion})`),
2726
+ info?.dshUpgradable
2727
+ ? React.createElement('button', {
2728
+ style: {
2729
+ ...s.btnPri,
2730
+ height: 28,
2731
+ fontSize: 12,
2732
+ padding: '0 14px',
2733
+ background: dshUpgradeResult?.ok
2734
+ ? 'var(--dsw-alias-state-success-primary,#059669)'
2735
+ : 'var(--dsw-alias-brand-primary,#4f6ef7)',
2736
+ opacity: (dshUpgrading || restarting) ? 0.6 : 1,
2737
+ },
2738
+ onClick: handleUpgradeDsh,
2739
+ disabled: dshUpgrading || restarting || dshUpgradeResult?.ok,
2740
+ },
2741
+ dshUpgrading
2742
+ ? React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
2743
+ React.createElement('span', { style: { animation: 'spin 1s linear infinite', display: 'inline-flex' } }, React.createElement(Icons.refresh)),
2744
+ '正在升级 DSH…',
2745
+ )
2746
+ : dshUpgradeResult?.ok
2747
+ ? '✓ DSH 升级完成'
2748
+ : `一键升级 DSH 到 v${info.dshLatest}`,
2749
+ )
2750
+ : React.createElement('a', {
2751
+ href: GITHUB_URL, target: '_blank', rel: 'noreferrer',
2752
+ style: { ...s.btnLink, fontSize: 12, fontWeight: 600 },
2753
+ }, '查看官方升级方式 ↗'),
2754
+ ),
2755
+ React.createElement('div', {
2756
+ style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 },
2757
+ },
2758
+ info?.dshUpgradable
2759
+ ? '升级 DSH 命令行工具后需重启 DSH 服务生效。'
2760
+ : (info?.dshUpgradeReason || '当前 DSH 非 npm 全局安装,无法一键自动升级,请按官方渠道手动更新。'),
2761
+ ),
2762
+ dshUpgradeResult && React.createElement('div', {
2763
+ style: {
2764
+ marginTop: 10,
2765
+ fontSize: 12,
2766
+ lineHeight: 1.6,
2767
+ color: dshUpgradeResult.ok
2768
+ ? 'var(--dsw-alias-state-success-primary,#059669)'
2769
+ : 'var(--dsw-alias-state-error-primary,#dc2626)',
2770
+ },
2771
+ }, dshUpgradeResult.message),
2772
+ dshUpgradeResult?.ok && !dismissRestart && React.createElement('div', {
2773
+ style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 10 },
2774
+ },
2775
+ React.createElement('button', {
2776
+ style: { ...s.btnPri, height: 30, fontSize: 12, padding: '0 14px', background: 'var(--dsw-alias-state-success-primary,#059669)' },
2777
+ onClick: handleRestart,
2778
+ }, '🔄 立即重启 DSH 服务'),
2779
+ React.createElement('button', {
2780
+ style: { ...s.btnGhost, height: 30, fontSize: 12, padding: '0 12px' },
2781
+ onClick: () => setDismissRestart(true),
2782
+ }, '稍后手动重启'),
2783
+ ),
2784
+ (restarting || restartStatus) && React.createElement('div', {
2785
+ style: {
2786
+ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginTop: 8,
2787
+ color: restartStatus?.phase === 'success'
2788
+ ? 'var(--dsw-alias-state-success-primary,#059669)'
2789
+ : restartStatus?.phase === 'timeout'
2790
+ ? 'var(--dsw-alias-state-error-primary,#dc2626)'
2791
+ : 'var(--dsw-alias-state-info-primary,#2563eb)',
2792
+ fontWeight: 500,
2793
+ },
2794
+ },
2795
+ restartStatus?.phase !== 'success' && restartStatus?.phase !== 'timeout' && React.createElement('span', {
2796
+ style: { animation: 'spin 1s linear infinite', display: 'inline-flex' },
2797
+ }, React.createElement(Icons.refresh)),
2798
+ restartStatus?.text || '正在处理…',
2799
+ ),
2800
+ ),
2801
+ ),
2802
+ ),
2675
2803
  );
2676
2804
  }
2677
2805
 
@@ -4567,6 +4695,199 @@ function RemoteDirectoryFlow(props) {
4567
4695
 
4568
4696
  // ---- 插件入口 ----
4569
4697
 
4698
+ // iOS Safari 键盘弹起时输入框上下跳动适配。
4699
+ //
4700
+ // 根因(实测 2026-09):聊天输入区容器(宿主 wSkVaW_composerSeat)为
4701
+ // position:sticky; bottom:0,其滚动容器高度由 100% 链决定。iOS 键盘弹起时
4702
+ // visual viewport 收缩,sticky 基准随视口变化 + iOS 反复 scroll 调整 → 每敲一字跳动。
4703
+ //
4704
+ // 通用解法(不依赖宿主类名):键盘弹起瞬间找到焦点输入框所属的滚动容器,
4705
+ // 把它当前高度用 px 固定(避免随视口继续收缩/重排),键盘收起后还原;
4706
+ // 并顺带做一次精确 scrollIntoView,阻止 iOS 二次乱滚。
4707
+ function setupIosKeyboardAdapter() {
4708
+ if (typeof window === 'undefined' || !window.visualViewport) return;
4709
+ if (!/iPhone|iPad|iPod/.test(navigator.userAgent || '')) return;
4710
+
4711
+ const vv = window.visualViewport;
4712
+ const isEditable = (el) => el && (el.isContentEditable || el.tagName === 'TEXTAREA' || el.tagName === 'INPUT');
4713
+ let keyboardOpen = false;
4714
+ let pinnedEl = null; // 被固定高度的滚动容器
4715
+ let pinnedH = null; // 原始 height(还原用)
4716
+ let pinnedInline = null;
4717
+
4718
+ const findScrollContainer = (el) => {
4719
+ let n = el;
4720
+ while (n && n !== document.body) {
4721
+ const cs = getComputedStyle(n);
4722
+ if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && n.scrollHeight > n.clientHeight) {
4723
+ return n;
4724
+ }
4725
+ n = n.parentElement;
4726
+ }
4727
+ return null;
4728
+ };
4729
+
4730
+ vv.addEventListener('resize', () => {
4731
+ const ratio = vv.height / window.innerHeight;
4732
+ const nowOpen = ratio < 0.75;
4733
+ if (nowOpen === keyboardOpen) return;
4734
+ keyboardOpen = nowOpen;
4735
+ const el = document.activeElement;
4736
+
4737
+ if (nowOpen) {
4738
+ // 键盘弹起:固定滚动容器高度,防随视口继续收缩导致 sticky 输入框跳动
4739
+ const scroller = isEditable(el) ? findScrollContainer(el) : null;
4740
+ if (scroller) {
4741
+ pinnedEl = scroller;
4742
+ pinnedInline = scroller.style.height || '';
4743
+ pinnedH = scroller.getBoundingClientRect().height;
4744
+ scroller.style.height = `${Math.round(pinnedH)}px`;
4745
+ }
4746
+ requestAnimationFrame(() => {
4747
+ if (isEditable(el)) {
4748
+ try { el.scrollIntoView({ block: 'nearest' }); } catch { /* 忽略 */ }
4749
+ }
4750
+ });
4751
+ } else {
4752
+ // 键盘收起:还原滚动容器高度,让布局回到正常
4753
+ if (pinnedEl) {
4754
+ pinnedEl.style.height = pinnedInline;
4755
+ pinnedEl = null; pinnedH = null; pinnedInline = null;
4756
+ }
4757
+ }
4758
+ });
4759
+ }
4760
+
4761
+ // 移动端输入框折叠/展开(body.dsh-composer-collapsed)。
4762
+ // 底部输入区(composer)占用 ~116px,顶部菜单 + header 又占 ~120px,屏幕有限时
4763
+ // 中间消息可视区很小。折叠后 composer 隐藏、消息 viewArea 自动伸展全高,阅读区显著增大。
4764
+ // 折叠态记忆到 localStorage(dsh-composer-fold),跨会话保持用户偏好。
4765
+ function setupComposerCollapse() {
4766
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
4767
+ if (window.innerWidth > 768) return; // 仅移动端
4768
+ const LS_KEY = 'dsh-composer-fold';
4769
+
4770
+ let bar = null; // 折叠态底部"点击输入"细条
4771
+ let busy = false; // 防重入锁(observer 由我们自己改动触发时跳过)
4772
+ let lastHadSeat = null; // 上次检测是否有输入区(避免重复处理)
4773
+
4774
+ // 折叠按钮:注入到聊天头部工具栏(headerUtilities)的 Session 下载按钮旁,
4775
+ // 与 DSH 原生按钮同规格(28px 圆形),视觉与原生一致。返回 null 表示工具栏未就绪。
4776
+ const getFoldBtn = () => {
4777
+ const existing = document.querySelector('.dsh-header-fold-btn');
4778
+ if (existing) return existing;
4779
+ const utils = document.querySelector('div[class*="wSkVaW_headerUtilities"], div[class*="headerUtilities"]');
4780
+ const logBtn = document.querySelector('button[class*="sessionLogButton"], button[class*="nL4_yW_sessionLogButton"]');
4781
+ if (!utils) return null;
4782
+ const btn = document.createElement('button');
4783
+ btn.className = 'dsh-header-fold-btn';
4784
+ btn.setAttribute('aria-label', '收起/展开输入框');
4785
+ btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
4786
+ // 插到 session 下载按钮左侧(紧邻原生工具钮)
4787
+ if (logBtn && logBtn.parentElement === utils) utils.insertBefore(btn, logBtn);
4788
+ else utils.appendChild(btn);
4789
+ return btn;
4790
+ };
4791
+ const isCollapsed = () => document.body.classList.contains('dsh-composer-collapsed');
4792
+ const readPref = () => { try { return localStorage.getItem(LS_KEY) === '1'; } catch { return false; } };
4793
+
4794
+ const ensureBar = () => {
4795
+ if (bar && document.body.contains(bar)) return bar;
4796
+ const seat = document.querySelector('div[class*="composerSeat"]');
4797
+ const scrollBody = seat ? seat.parentElement : null;
4798
+ if (!scrollBody) return null;
4799
+ bar = document.createElement('div');
4800
+ bar.className = 'dsh-composer-collapsed-bar';
4801
+ bar.textContent = '\u270f\ufe0f 点击输入消息…';
4802
+ scrollBody.insertBefore(bar, seat);
4803
+ bar.addEventListener('click', () => setCollapsed(false));
4804
+ return bar;
4805
+ };
4806
+ const removeBar = () => { if (bar) { try { bar.remove(); } catch {} bar = null; } };
4807
+
4808
+ // 折叠态切换:改 class + 记忆 + 图标 + 细条显隐
4809
+ const setCollapsed = (collapsed) => {
4810
+ if (busy) return;
4811
+ busy = true;
4812
+ try {
4813
+ if (isCollapsed() !== collapsed) {
4814
+ document.body.classList.toggle('dsh-composer-collapsed', collapsed);
4815
+ try { localStorage.setItem(LS_KEY, collapsed ? '1' : '0'); } catch {}
4816
+ updateButton(collapsed);
4817
+ }
4818
+ if (collapsed) ensureBar(); else removeBar();
4819
+ } finally { busy = false; }
4820
+ };
4821
+
4822
+ // 更新顶部折叠钮图标(仅内容变化时写入,避免无谓 DOM 抖动)
4823
+ const updateButton = (collapsed) => {
4824
+ const btn = getFoldBtn();
4825
+ if (!btn) return;
4826
+ btn.title = collapsed ? '展开输入框' : '收起输入框,最大化对话阅读区';
4827
+ const icon = collapsed
4828
+ ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"></rect><line x1="6" y1="10" x2="6.01" y2="10"></line><line x1="10" y1="10" x2="10.01" y2="10"></line><line x1="14" y1="10" x2="14.01" y2="10"></line><line x1="6" y1="14" x2="10" y2="14"></line><line x1="14" y1="14" x2="18" y2="14"></line></svg>'
4829
+ : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
4830
+ const existing = btn.querySelector('svg');
4831
+ if (!existing || existing.outerHTML !== icon) btn.innerHTML = icon;
4832
+ };
4833
+
4834
+ // 有输入区(对话页):确保按钮可用 + 按记忆恢复折叠态
4835
+ const handleSeatPresent = () => {
4836
+ const btn = getFoldBtn();
4837
+ if (!btn) return false;
4838
+ btn.style.display = 'inline-flex';
4839
+ if (!btn._dshFoldWired) {
4840
+ btn._dshFoldWired = true;
4841
+ btn.onclick = (e) => { e.stopPropagation(); setCollapsed(!isCollapsed()); };
4842
+ }
4843
+ document.body.classList.add('dsh-composer-active');
4844
+ const want = readPref();
4845
+ if (want !== isCollapsed()) {
4846
+ setCollapsed(want);
4847
+ } else {
4848
+ updateButton(isCollapsed());
4849
+ }
4850
+ return true;
4851
+ };
4852
+
4853
+ // 无输入区:隐藏折叠钮与细条(保留记忆)
4854
+ const handleSeatAbsent = () => {
4855
+ const btn = getFoldBtn();
4856
+ if (btn) { btn.style.display = 'none'; }
4857
+ document.body.classList.remove('dsh-composer-active');
4858
+ removeBar();
4859
+ };
4860
+
4861
+ // 单次检测(observer 回调 / 初始)。
4862
+ // 仅当"是否有输入区"或"折叠钮是否已注入"发生变化时才全量处理,
4863
+ // 避免消息流式输出等高频 DOM 变化下 observer 反复执行注入/状态恢复。
4864
+ // busy 锁 + 注入幂等共同防止自触发循环。
4865
+ const onComposerChange = () => {
4866
+ const hasSeat = !!document.querySelector('div[class*="composerSeat"]');
4867
+ const hasBtn = !!document.querySelector('.dsh-header-fold-btn');
4868
+ if (busy) return;
4869
+ if (hasSeat === lastHadSeat && hasSeat === hasBtn) return; // 状态与注入都没变:忽略
4870
+ busy = true;
4871
+ try {
4872
+ if (hasSeat) {
4873
+ lastHadSeat = handleSeatPresent(); // 内部幂等;工具栏未就绪返回 false,等下次再试
4874
+ } else {
4875
+ handleSeatAbsent();
4876
+ lastHadSeat = false;
4877
+ }
4878
+ } finally { busy = false; }
4879
+ };
4880
+
4881
+ // observer:断开→处理→重连,杜绝"处理里改 DOM 再触发自己"的死循环
4882
+ const observer = new MutationObserver(() => {
4883
+ observer.disconnect();
4884
+ try { onComposerChange(); } finally { observer.observe(document.body, { childList: true, subtree: true }); }
4885
+ });
4886
+ observer.observe(document.body, { childList: true, subtree: true });
4887
+ onComposerChange(); // 初始检查
4888
+ }
4889
+
4890
+
4570
4891
  function apply(ctx) {
4571
4892
  window.__dshClientCtx = ctx;
4572
4893
  const rpcCall = (endpoint, payload, signal) =>
@@ -4575,6 +4896,9 @@ function apply(ctx) {
4575
4896
  window.__dshOpenRemoteWorkspaceModal = (onAdded, onPickDirect, onCancel) =>
4576
4897
  showRemoteWorkspaceDialog(rpcCall, onAdded, ctx, onPickDirect, onCancel);
4577
4898
 
4899
+ setupIosKeyboardAdapter();
4900
+ setupComposerCollapse();
4901
+
4578
4902
  setupMobileExperience(rpcCall, ctx);
4579
4903
 
4580
4904
  const injected = () => ({ pick: () => ctx.workspaces?.pickDirectory?.() });
@@ -394,6 +394,74 @@ export const MOBILE_STYLES_CSS = `
394
394
  padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
395
395
  }
396
396
 
397
+ /* ── 移动端输入框折叠模式(body.dsh-composer-collapsed)──────────────
398
+ 折叠后隐藏吸底输入区,让消息 viewArea 自动伸展到全高,最大化阅读区。
399
+ 入口:聊天头部工具栏(Session 下载钮旁)的折叠按钮,或折叠态底部细输入条点它唤回。
400
+ 状态持久化到 localStorage。 */
401
+ body.dsh-composer-collapsed div[class*="wSkVaW_composerSeat"] {
402
+ display: none !important;
403
+ }
404
+ body.dsh-composer-collapsed div[class*="wSkVaW_viewArea"] {
405
+ flex: 1 1 auto !important;
406
+ height: auto !important;
407
+ min-height: 0 !important;
408
+ }
409
+ /* 折叠时滚动区底部对齐 safe-area,避免内容被 iPhone 底部横条遮挡 */
410
+ body.dsh-composer-collapsed div[class*="wSkVaW_scrollBody"] {
411
+ padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
412
+ }
413
+
414
+ /* 折叠输入框按钮:注入在聊天头部工具栏(Session 下载钮旁),
415
+ 与 DSH sessionLogButton 同规格(28px 圆形图标钮),视觉与原生一致 */
416
+ .dsh-header-fold-btn {
417
+ min-width: 28px !important;
418
+ width: 28px !important;
419
+ height: 28px !important;
420
+ padding: 0 !important;
421
+ border-radius: 50% !important;
422
+ display: inline-flex !important;
423
+ align-items: center !important;
424
+ justify-content: center !important;
425
+ flex-shrink: 0 !important;
426
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1)) !important;
427
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.03)) !important;
428
+ color: var(--dsw-alias-label-secondary, #6b7280) !important;
429
+ cursor: pointer !important;
430
+ transition: opacity 0.15s !important;
431
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03) !important;
432
+ -webkit-tap-highlight-color: transparent !important;
433
+ }
434
+ .dsh-header-fold-btn:active {
435
+ opacity: 0.6 !important;
436
+ }
437
+ .dsh-header-fold-btn svg {
438
+ width: 15px !important;
439
+ height: 15px !important;
440
+ }
441
+
442
+ /* 折叠态底部细输入条:点击唤起输入框(位于原输入区位置,sticky 底部) */
443
+ .dsh-composer-collapsed-bar {
444
+ display: none !important;
445
+ position: sticky !important;
446
+ bottom: 0 !important;
447
+ margin: 8px 12px max(8px, env(safe-area-inset-bottom, 0px)) 12px !important;
448
+ padding: 11px 16px !important;
449
+ border-radius: 22px !important;
450
+ background: var(--dsw-alias-bg-layer-2, #f4f4f7) !important;
451
+ border: 1px solid rgba(0, 0, 0, 0.07) !important;
452
+ color: var(--dsw-alias-label-tertiary, #8b93a1) !important;
453
+ font-size: 13.5px !important;
454
+ line-height: 1.4 !important;
455
+ cursor: pointer !important;
456
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04) !important;
457
+ -webkit-tap-highlight-color: transparent !important;
458
+ z-index: 40 !important;
459
+ box-sizing: border-box !important;
460
+ }
461
+ body.dsh-composer-collapsed .dsh-composer-collapsed-bar {
462
+ display: block !important;
463
+ }
464
+
397
465
  /* 输入卡片:DeepSeek App 圆角大胶囊造型 */
398
466
  div[class*="uV2eYG_card"] {
399
467
  border-radius: 26px !important;
@@ -3,6 +3,9 @@
3
3
 
4
4
  import { randomBytes, pbkdf2 as pbkdf2Callback, timingSafeEqual } from 'node:crypto'
5
5
  import { promisify } from 'node:util'
6
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join, dirname } from 'node:path'
6
9
 
7
10
  const pbkdf2Async = promisify(pbkdf2Callback)
8
11
 
@@ -18,6 +21,50 @@ const LEGACY_PBKDF2_ITERATIONS = 10000
18
21
  const PBKDF2_KEYLEN = 32
19
22
  const PBKDF2_PREFIX = 'pbkdf2-sha256$'
20
23
 
24
+ // 登录 Session 持久化:宿主(dsh web 进程)重启后,已登录设备免重输访问密码。
25
+ // 落盘文件与 config.json 同目录(~/.dsh/dsh-bridge/sessions.json,权限 600);
26
+ // sessionToken 与 secretToken 同级机密(48 位随机 hex),同目录存储不扩大暴露面。
27
+ // 改密码 / 切模式 / 重新生成 token 时的 sessions.clear() 会同步清空文件(吊销语义保持);
28
+ // 文件缺失或损坏时安全降级为空 Map(等价于旧行为:重新登录一次)。
29
+ // 路径可通过构造参数 sessionsFile 注入(测试传临时目录,避免读写真实环境)。
30
+ const DEFAULT_SESSIONS_FILE = join(
31
+ process.env.DSH_HOME ?? join(homedir(), '.dsh'),
32
+ 'dsh-bridge', 'sessions.json'
33
+ )
34
+
35
+ class PersistentSessionMap extends Map {
36
+ /** @param {string} file 会话落盘文件路径 */
37
+ constructor(file) {
38
+ super()
39
+ this._file = file
40
+ }
41
+ _flush() {
42
+ try {
43
+ mkdirSync(dirname(this._file), { recursive: true })
44
+ writeFileSync(this._file, JSON.stringify([...this.entries()]), { mode: 0o600 })
45
+ } catch { /* 落盘失败降级为纯内存语义,不阻断认证流程 */ }
46
+ }
47
+ static load(file) {
48
+ const m = new PersistentSessionMap(file)
49
+ try {
50
+ const arr = JSON.parse(readFileSync(file, 'utf8'))
51
+ if (Array.isArray(arr)) {
52
+ const now = Date.now()
53
+ for (const [k, v] of arr) {
54
+ // 只恢复结构合法且未过期的会话
55
+ if (typeof k === 'string' && v && Number.isFinite(v.expiresAt) && v.expiresAt > now) {
56
+ Map.prototype.set.call(m, k, { createdAt: Number(v.createdAt) || 0, expiresAt: Number(v.expiresAt) })
57
+ }
58
+ }
59
+ }
60
+ } catch { /* 文件缺失/损坏:降级为空 */ }
61
+ return m
62
+ }
63
+ set(k, v) { super.set(k, v); this._flush(); return this }
64
+ delete(k) { const r = super.delete(k); if (r) this._flush(); return r }
65
+ clear() { super.clear(); this._flush() }
66
+ }
67
+
21
68
  export class AuthManager {
22
69
  /**
23
70
  * @param {object} opts
@@ -30,9 +77,10 @@ export class AuthManager {
30
77
  * @param {boolean} [opts.config.allowLoopback=true]
31
78
  * @param {boolean} [opts.config.adminProtection=true] 管理保护独立开关:关闭后管理操作免 adminToken
32
79
  * @param {(patch: object) => Promise<void>|void} [opts.onPersist]
80
+ * @param {string} [opts.sessionsFile] 登录会话持久化文件路径(默认 ~/.dsh/dsh-bridge/sessions.json;测试注入临时目录)
33
81
  * @param {object} [opts.logger]
34
82
  */
35
- constructor({ config = {}, onPersist, logger = console } = {}) {
83
+ constructor({ config = {}, onPersist, sessionsFile, logger = console } = {}) {
36
84
  this.logger = logger
37
85
  this.onPersist = onPersist
38
86
 
@@ -51,8 +99,9 @@ export class AuthManager {
51
99
  // 内部隧道专用鉴权密钥(内存生成,用于辨别本地自建隧道转发流量与真实本机访问)
52
100
  this.internalTunnelSecret = randomBytes(24).toString('hex')
53
101
 
54
- // Session 内存存储:sessionToken -> { createdAt, expiresAt }
55
- this.sessions = new Map()
102
+ // Session 存储与重启恢复:sessionToken -> { createdAt, expiresAt }
103
+ // 持久化到 sessions.json(路径可经 sessionsFile 注入),宿主(dsh web)重启后已登录设备免重输访问密码
104
+ this.sessions = PersistentSessionMap.load(sessionsFile ?? DEFAULT_SESSIONS_FILE)
56
105
  // 管理员解锁 Session 内存存储:adminToken -> { createdAt, expiresAt }
57
106
  this.adminSessions = new Map()
58
107
  // 防暴力破解:ip -> { failedCount, lockUntil }
@@ -540,7 +589,8 @@ export class AuthManager {
540
589
 
541
590
  dispose() {
542
591
  clearInterval(this._cleanupTimer)
543
- this.sessions.clear()
592
+ // 不清空 sessions:进程退出时内存随进程自然消失;
593
+ // 若此处 clear() 会把持久化文件一并清空,宿主重启后已登录设备仍会全部丢失
544
594
  this.rateLimits.clear()
545
595
  }
546
596
  }
@@ -16,6 +16,7 @@ export const BRIDGE_ENDPOINTS = {
16
16
  setLanIp: 'setLanIp',
17
17
  checkVersion: 'checkVersion',
18
18
  upgradePlugin: 'upgradePlugin',
19
+ upgradeDsh: 'upgradeDsh',
19
20
  restartDsh: 'restartDsh',
20
21
  exportBackup: 'exportBackup',
21
22
  importBackup: 'importBackup',
package/lib/bridge-rpc.js CHANGED
@@ -289,6 +289,14 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
289
289
  return ok(result);
290
290
  }
291
291
 
292
+ if (endpoint === BRIDGE_ENDPOINTS.upgradeDsh) {
293
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
294
+ if (adminErr) return adminErr;
295
+
296
+ const result = await service.upgradeDsh(payload);
297
+ return ok(result);
298
+ }
299
+
292
300
  if (endpoint === BRIDGE_ENDPOINTS.restartDsh) {
293
301
  const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
294
302
  if (adminErr) return adminErr;
package/lib/index.js CHANGED
@@ -10,7 +10,7 @@ import { get as httpsGet } from 'node:https';
10
10
  import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
11
11
  import { join, dirname, basename, resolve, normalize } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
- import { readFileSync, existsSync } from 'node:fs';
13
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
14
14
  import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
15
15
  import { spawn } from 'node:child_process';
16
16
  import { createHash, createHmac } from 'node:crypto';
@@ -1033,6 +1033,63 @@ class BridgeService {
1033
1033
  }
1034
1034
 
1035
1035
  // 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
1036
+ // 探测 DSH 宿主 CLI 的安装形态,判断能否通过"npm 全局升级"自动更新。
1037
+ // 仅当 dsh 命令 resolve 到当前 node 同 prefix 的 node_modules/@deepseek-ai/dsh(标准 npm 全局安装)
1038
+ // 时才允许一键升级;pnpm / 源码 / Electron 加壳等形态无法用 npm -g 升级,仅返回提示。
1039
+ // @returns {Promise<{upgradable:boolean, rootGlobal?:string, dshRealPath?:string, reason?:string}>}
1040
+ async _probeDshUpgrade() {
1041
+ const isWin = process.platform === 'win32';
1042
+ const nodeDir = dirname(process.execPath);
1043
+ const existingPath = process.env.PATH || process.env.Path || '';
1044
+ const extraPaths = isWin ? [nodeDir] : [nodeDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin', join(homedir(), '.local/bin'), join(homedir(), '.npm-global/bin')];
1045
+ const separator = isWin ? ';' : ':';
1046
+ const augmentedEnv = { ...process.env, PATH: [...extraPaths, existingPath].filter(Boolean).join(separator) };
1047
+ if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
1048
+
1049
+ const runCmd = (cmd, args) => new Promise((resolve) => {
1050
+ let cp;
1051
+ try {
1052
+ cp = spawn(cmd, args, { windowsHide: true, shell: true, env: augmentedEnv, timeout: 8000 });
1053
+ } catch {
1054
+ return resolve(null);
1055
+ }
1056
+ let stdout = ''; let stderr = '';
1057
+ cp.stdout?.on('data', (d) => { stdout += d.toString(); });
1058
+ cp.stderr?.on('data', (d) => { stderr += d.toString(); });
1059
+ cp.on('error', () => resolve(null));
1060
+ cp.on('close', (code) => {
1061
+ if (code === 0 && stdout.trim()) resolve(stdout.trim().split(/\r?\n/)[0]);
1062
+ else resolve(null);
1063
+ });
1064
+ });
1065
+
1066
+ try {
1067
+ // 1. 找 dsh bin 的真实路径(Windows which 不存在,用 where)
1068
+ const dshBin = await runCmd(isWin ? 'where' : 'which', [isWin ? 'dsh.cmd' : 'dsh']);
1069
+ if (!dshBin) {
1070
+ return { upgradable: false, reason: '未找到 dsh 命令(可能通过源码/打包方式运行,非 npm 全局安装)' };
1071
+ }
1072
+ // realpath 在主进程内解析(子进程 node -e + shell 引号嵌套易出错)
1073
+ let dshRealPath = dshBin;
1074
+ try {
1075
+ dshRealPath = realpathSync(dshBin) || dshBin;
1076
+ } catch { /* 保留原始路径继续判断 */ }
1077
+ // 2. 当前 node 对应的全局 node_modules 根
1078
+ const globalNodeModules = await runCmd('npm', ['root', '-g']);
1079
+ const rootGlobal = globalNodeModules ? dirname(globalNodeModules) : dirname(nodeDir);
1080
+ const marker = join('node_modules', '@deepseek-ai', 'dsh');
1081
+ if (dshRealPath.includes(marker) && (dshRealPath.startsWith(rootGlobal) || dshRealPath.includes('node-v') && dshRealPath.includes('lib'))) {
1082
+ return { upgradable: true, rootGlobal, dshRealPath };
1083
+ }
1084
+ return {
1085
+ upgradable: false,
1086
+ reason: 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
1087
+ };
1088
+ } catch {
1089
+ return { upgradable: false, reason: '探测 dsh 安装形态失败,请按官方渠道手动更新' };
1090
+ }
1091
+ }
1092
+
1036
1093
  async checkVersion() {
1037
1094
  // TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
1038
1095
  if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
@@ -1062,18 +1119,27 @@ class BridgeService {
1062
1119
  try {
1063
1120
  const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
1064
1121
  .catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
1122
+ // DSH 宿主 CLI 最新版本(npm 全局包 @deepseek-ai/dsh;与插件同源镜像,失败不阻断面板)
1123
+ const dshLatestData = await fetchRegistry('https://registry.npmmirror.com/@deepseek-ai/dsh/latest', 3500)
1124
+ .catch(() => fetchRegistry('https://registry.npmjs.org/@deepseek-ai/dsh/latest', 5000))
1125
+ .catch(() => null);
1126
+ // 安装形态探测(决定是否给"一键升级 DSH"按钮):npm 全局可升级,加壳/源码仅提醒
1127
+ const dshProbe = await this._probeDshUpgrade();
1065
1128
  const result = {
1066
1129
  current: VERSION,
1067
1130
  latest: latestData?.version ?? null,
1068
1131
  releaseNotes: latestData?.releaseNotes ?? null,
1069
1132
  dshVersion: await this.getDshVersion(),
1133
+ dshLatest: dshLatestData?.version ?? null,
1134
+ dshUpgradable: dshProbe.upgradable,
1135
+ dshUpgradeReason: dshProbe.reason || '',
1070
1136
  };
1071
1137
  this._versionCheckCache = result;
1072
1138
  this._versionCheckCachedAt = Date.now();
1073
1139
  return result;
1074
1140
  } catch (e) {
1075
1141
  // 失败不缓存(让下次调用可重试),但记录便于诊断
1076
- return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
1142
+ return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion(), dshLatest: null, dshUpgradable: false, dshUpgradeReason: '' };
1077
1143
  }
1078
1144
  }
1079
1145
 
@@ -1165,6 +1231,96 @@ class BridgeService {
1165
1231
  return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
1166
1232
  }
1167
1233
 
1234
+ // 一键升级 DSH 宿主 CLI(npm 全局包 @deepseek-ai/dsh)。
1235
+ // 升级的是"与当前 node 配对"的全局 prefix(dsh 命令所在目录),完成后需重启 DSH 生效。
1236
+ async upgradeDsh({ version } = {}) {
1237
+ const targetVersion = version ? String(version).trim() : 'latest';
1238
+ // 严格 SemVer 白名单校验(dsh 用 rc 版本号,如 0.1.2-rc.1)
1239
+ if (!/^(latest|\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/.test(targetVersion)) {
1240
+ return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
1241
+ }
1242
+
1243
+ // 安装形态守卫:非标准 npm 全局安装(Electron/源码/pnpm 等)拒绝自动升级,
1244
+ // 避免"装一份不生效的新副本、误导用户以为升级成功"
1245
+ const probe = await this._probeDshUpgrade();
1246
+ if (!probe.upgradable) {
1247
+ return {
1248
+ ok: false,
1249
+ error: probe.reason || '当前 DSH 为非 npm 全局安装,无法自动升级;请按官方渠道手动更新',
1250
+ version: targetVersion,
1251
+ };
1252
+ }
1253
+
1254
+ const pkgSpec = `@deepseek-ai/dsh@${targetVersion}`;
1255
+ const isWin = process.platform === 'win32';
1256
+ const nodeDir = dirname(process.execPath);
1257
+ const home = homedir();
1258
+ const extraPaths = isWin ? [nodeDir] : [
1259
+ nodeDir,
1260
+ '/opt/homebrew/bin',
1261
+ '/opt/homebrew/sbin',
1262
+ '/usr/local/bin',
1263
+ '/usr/bin',
1264
+ '/bin',
1265
+ join(home, '.nvm/current/bin'),
1266
+ join(home, '.fnm/current/bin'),
1267
+ join(home, '.local/bin'),
1268
+ join(home, '.cargo/bin'),
1269
+ ];
1270
+ const separator = isWin ? ';' : ':';
1271
+ const existingPath = process.env.PATH || process.env.Path || '';
1272
+ const augmentedEnv = {
1273
+ ...process.env,
1274
+ PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
1275
+ };
1276
+ if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
1277
+
1278
+ // 与当前 node 配对的 npm 绝对路径(保证装进 dsh 所在 prefix,而非 PATH 里其它 npm)
1279
+ const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
1280
+ const npmBin = existsSync(siblingNpm) ? siblingNpm : 'npm';
1281
+ // 优先用探测到的全局 root(dsh 实际所在 prefix),其次 nodeDir 父目录
1282
+ const globalRoot = probe.rootGlobal || dirname(nodeDir);
1283
+
1284
+ const tasks = [
1285
+ { cmd: npmBin, args: ['install', '-g', '--prefix', globalRoot, pkgSpec] },
1286
+ { cmd: npmBin, args: ['install', '-g', pkgSpec] },
1287
+ ];
1288
+
1289
+ let lastError = null;
1290
+ for (const task of tasks) {
1291
+ try {
1292
+ const res = await new Promise((resolve, reject) => {
1293
+ let cp;
1294
+ try {
1295
+ cp = spawn(task.cmd, task.args, {
1296
+ windowsHide: true,
1297
+ shell: true,
1298
+ env: augmentedEnv,
1299
+ timeout: 180000,
1300
+ });
1301
+ } catch (spawnErr) {
1302
+ return reject(spawnErr);
1303
+ }
1304
+ let stdout = '';
1305
+ let stderr = '';
1306
+ cp.stdout?.on('data', (d) => { stdout += d.toString(); });
1307
+ cp.stderr?.on('data', (d) => { stderr += d.toString(); });
1308
+ cp.on('error', reject);
1309
+ cp.on('close', (code) => {
1310
+ if (code === 0) resolve({ stdout, stderr });
1311
+ else reject(new Error(stderr || stdout || `进程退出码 ${code}`));
1312
+ });
1313
+ });
1314
+ const output = (res.stdout || res.stderr || '升级成功').trim().slice(-500);
1315
+ this.logger?.info('dsh-bridge: DSH 升级命令成功: %s %s', task.cmd, task.args.join(' '));
1316
+ return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
1317
+ } catch (err) {
1318
+ lastError = err;
1319
+ }
1320
+ }
1321
+ return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
1322
+ }
1323
+
1168
1324
  // 优雅重启 DSH 服务(支持守护进程自动拉起或独立派生子进程重启)
1169
1325
  async restartDsh() {
1170
1326
  this.logger?.info('收到 DSH 重启请求,正在调度重启...');
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.10.7",
3
+ "version": "2.10.8",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.10.7】\n• 修复:Cloudflare 固定域名(Token 模式)隧道秒退(issue #35)——`--no-autoupdate` 参数位置修正(v2.10.6 引入),固定域名隧道现在可正常建立并保持;同时将 Incorrect Usage / 无效 Token 等确定性配置错误直接提示为配置错误,不再误判为\"意外退出\"退避重连 12 次\n• 优化:Cloudflare 固定域名配置区新增「查看申请与配置教程」入口(含账号注册、域名接入、创建隧道、绑定子域名到面板的完整图文步骤)",
5
+ "releaseNotes": "【v2.10.8】\n• 新增:登录 Session 持久化(#36)——宿主 dsh web 重启后已登录设备免重输访问密码,手机远程访问不再每天重登;改密码/切换模式仍同步吊销全部旧会话(安全语义保持)\n• 新增:设置页检测 DSH 宿主更新并支持一键升级(npm 全局安装形态)——发现新版黄色提示 + 一键升级按钮;非 npm 安装(源码/打包)仅提醒并给出官方升级方式\n• 修复:iOS Safari 键盘弹起时聊天输入框上下跳动——键盘弹起时固定输入框滚动容器高度,避免随键盘反复重排抖动\n• 新增:移动端输入框可折叠——聊天头部工具栏新增折叠按钮,收起输入框后消息阅读区最大化;底部保留「点击输入消息」细条一键唤回;折叠偏好跨会话记忆",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {