@wenbin_wb/dsh-bridge 2.10.10 → 2.10.11

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,18 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.10.11] - 2026-09-16
8
+
9
+ ### 🐞 修复
10
+
11
+ - **移动端顶栏压住 `position: fixed` 全屏面板顶部 52px**(issue #41):桥的让位方式此前是给内容框架加 `padding-top: var(--dsh-mobile-header-h)`,但 `position: fixed` 元素的包含块是视口(CSS 2.1 §10.1),不跟随祖先内边距。官方右侧栏在窄视口(`viewportWidth < 768`)自动全屏(`.P3OORG_panel[data-sidebar-right-panel=fullscreen]`,即 `fixed; inset:0; z-index:40`),其顶部标签栏因此落进顶栏(`z-index: 9998`)覆盖区。由于顶栏背景透明且容器自身 `pointer-events: none`,实际表现并非「被不透明条挡住」,而是顶栏标题/图标与面板标签压字,外加两处 40×40 命中区抢占点击——面板右端的「全屏 / 收起」按钮右半(14×28px)会被顶栏「+」按钮夺走,误触即触发新建会话。现按 `data-sidebar-right-panel="fullscreen"` 直接位移面板容器本身(`top` 下移 52px 并同步收窄 `height` / `max-height`),不依赖宿主 CSS-module 哈希类名,宿主升级不失效。若用户此前用 `top` 自行让位过,本次修复与之同属性、级联只生效一条,**不会叠加**;但若用的是 `padding-top` / `margin-top` / `transform` 位移,请撤掉旧规则,否则会叠加下移。
12
+ - **移动端断点与宿主判据对齐**:桥原按 `max-width: 768px`(运行时亦为 `innerWidth <= 768`),而宿主用 `viewportWidth < 768` 决定右侧栏是否自动全屏,恰在 768px 这一宽度上出现「桥渲染移动端顶栏、宿主按桌面(push)布局」的错位。现移动端统一为 `<= 767px`(新增 `MOBILE_MAX_WIDTH = 767` 常量,消除散落的魔法值),`768px` 起彻底交还桌面布局。
13
+ - **鸿蒙(HuaweiBrowser / ArkWeb)上文档预览永远显示「文件资源服务不可用。」**:该内核解析非特殊 scheme 时 `new URL('dsh-resource://file/session/<id>/<path>').hostname` 返回空串(Chrome/Safari 返回 `file`),而 DSH 的客户端资源注册表 `@deepseek-ai/dsh-client-resources` 正是拿 URL 的 hostname 当「协议键」——取不到就把记录判成「没有 provider」,状态永久停在 `none`;注册表只在 provider 注册那一刻回挂一次已有记录,之后不再重试,所以刷新也不恢复。表现为右侧栏文件与对话生成文档的预览全部打不开(而日志/探针里 `resources.providers` 其实一直有 `file`,容易误判成插件没生效)。现桥侧新增 `client/resource-url-compat.js`:**先做特性探测**,只有引擎确有差异时才包一层 `window.URL`,且仅对 `dsh-resource:` 协议、原生 hostname 为空、原始串能解析出 host 的实例补上 `hostname`/`host`(写成实例自有访问器,读取时原生值优先、为空才回退现解析,赋值/改 href 后立即反映新值;setter 仍委托原生原型);引擎正常时一个字节都不动(判定 `not-needed`)。根因在宿主侧(用 URL.hostname 当协议键),已另行整理上游材料;上游若改为从原始串解析协议,本兼容层自动空转。
14
+ - **iOS 16 / 旧 Safari 完全打不开 DSH(页面红屏 `Failed to load plugins … Can't find variable: Iterator`)**:DSH 自带的 pdf.js 在**模块顶层**就执行 `if (typeof Iterator.prototype.join !== 'function') Iterator.prototype.join = …`,而 `Iterator` 这个全局是 Safari 18.4 才随「迭代器辅助方法」引入的——iOS 16 上该引用直接抛 `Can't find variable: Iterator`,导致 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` 导入失败,进而整棵客户端插件树加载失败(实测于 iOS 16 真机,并在本机以同构环境复现)。同一批缺口还有 `Promise.withResolvers`(Safari 17.4+):DSH 宿主 HTML 末尾的启动握手内联脚本、以及审批 / 用户提问 / cordis 运行时 `ctx.timeout`·`ctx.interval` / workspace-files 变更订阅等运行期路径都在用它。现桥在反向代理注入 HTML 时、于宿主所有脚本之前补齐两枚垫片:`Promise.withResolvers`(完整语义)与 `Iterator`(把真实的内置迭代器共享原型 `%IteratorPrototype%` 挂到 `Iterator.prototype`,使 pdf.js 的补丁落到真实迭代器原型而不是一个没用的空对象)。两者都**只在缺失时安装**,iOS 18.4+ / Android / 桌面端零改动;至此 iOS 16 可正常启动并使用。
15
+ - **「重启 DSH」按钮在 systemd 托管下点了没用,DSH 再也没起来(只能手动重启),且不留任何日志**:桥原实现是不识别托管器的「自派生子进程 + `process.exit(0)`」。在 systemd 单元(如 `dsh-web.service`,用户在 user slice 下)里这必然失败:`Restart=on-failure` 不认干净退出的 code 0(systemd 不会拉起),而默认 `KillMode=control-group` 会在主进程退出时把刚派生的子进程一并杀掉;旧实现还把子进程 stdio 设成 `ignore`,所以连失败原因都看不到。现按托管方式分流:从 `/proc/self/cgroup` 取出**最内层** systemd 单元名 → 交给 `systemctl [--user] restart --no-block <unit>`(stop+start 由 systemd 负责,新进程仍在正确 cgroup 内,并尊重单元自身的 Restart 策略);`DSH_DAEMON` / `PM2_HOME` 仍走守护进程退出;无托管器时派生独立助手 `lib/restart-helper.mjs`,**等旧进程真正退出、端口释放后**才拉起新 DSH,并在最长 120s 内确认端口可连接,全过程写入 `~/.dsh/dsh-bridge/restart.log`(子进程输出另存 `restart-child.log`)。前端在宿主明确返回 `ok:false`(例如 `systemctl` 与助手都派生失败)时**直接报错**,不再假装「正在重连」让用户面对永远转圈。注意:systemd 托管的环境需先手动重启一次让本修复生效,之后按钮即可正常使用。
16
+
17
+ ---
18
+
7
19
  ## [v2.10.10] - 2026-09-14
8
20
 
9
21
  ### ✨ 新功能
package/client/client.js CHANGED
@@ -51,7 +51,10 @@ var MOBILE_STYLES_CSS = `
51
51
  --dsh-mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
52
52
  }
53
53
 
54
- @media (max-width: 768px) {
54
+ /* \u65AD\u70B9\u4E0E\u5BBF\u4E3B\u5224\u636E\u5BF9\u9F50\uFF1A\u5BBF\u4E3B\u7528 viewportWidth < 768 \u51B3\u5B9A\u53F3\u4FA7\u680F\u81EA\u52A8\u5168\u5C4F
55
+ \uFF08dsh-client-ui-sidebar-right/lib/client.js\uFF09\uFF0C\u6545\u79FB\u52A8\u7AEF\u6837\u5F0F\u53D6 <=767px\uFF0C
56
+ 768px \u8D77\u5F7B\u5E95\u4EA4\u8FD8\u684C\u9762\u5E03\u5C40\uFF0C\u907F\u514D"\u6865\u6E32\u67D3\u9876\u680F\u3001\u5BBF\u4E3B\u5374\u672A\u5168\u5C4F"\u7684\u9519\u4F4D\u3002 */
57
+ @media (max-width: 767px) {
55
58
  /* 1. \u4E3B\u6846\u67B6\u4E3A Header \u817E\u51FA\u9876\u90E8\u7A7A\u95F4 */
56
59
  div[class*="_frame"] {
57
60
  display: flex !important;
@@ -66,6 +69,19 @@ var MOBILE_STYLES_CSS = `
66
69
  box-sizing: border-box !important;
67
70
  }
68
71
 
72
+ /* 1.1 fixed \u5168\u5C4F\u9762\u677F\u5355\u72EC\u8BA9\u4F4D\uFF1Aposition:fixed \u7684\u5305\u542B\u5757\u662F viewport\uFF08CSS 2.1 \xA710.1\uFF09\uFF0C
73
+ \u4E0D\u8DDF\u968F\u4E0A\u9762 frame \u7684 padding-top\uFF0C\u56E0\u6B64\u9876\u90E8 52px \u4F1A\u843D\u8FDB\u9876\u680F\u8986\u76D6\u533A\uFF08#41\uFF09\u3002
74
+ \u5B98\u65B9\u53F3\u4FA7\u680F\u5728 <768px \u81EA\u52A8\u5168\u5C4F\uFF08fixed; inset:0; z-index:40\uFF09\uFF0C\u8FD9\u91CC\u6309 data \u5C5E\u6027
75
+ \u76F4\u63A5\u4F4D\u79FB\u5BB9\u5668\u672C\u8EAB\uFF0C\u4E0D\u4F9D\u8D56\u5BBF\u4E3B CSS-module \u54C8\u5E0C\u7C7B\u540D\u3002
76
+ \u771F\u6B63\u8D77\u4F5C\u7528\u7684\u662F top\uFF1Bheight/max-height \u4E0E\u4E0B\u9762\u5DE5\u4F5C\u53F0\u9762\u677F\u90A3\u6BB5\u4FDD\u6301\u540C\u4E00\u5199\u6CD5\uFF1A
77
+ \u5BBF\u4E3B\u5F53\u524D\u7528 inset:0\uFF08\u65E0\u663E\u5F0F\u9AD8\u5EA6\uFF09\u65F6 top \u5355\u72EC\u5373\u53EF\uFF0C\u4F46\u5BBF\u4E3B\u5C06\u6765\u82E5\u7ED9\u51FA\u663E\u5F0F\u9AD8\u5EA6\uFF0C
78
+ \u663E\u5F0F height \u4ECD\u80FD\u628A\u76D2\u5B50\u6536\u5728\u9876\u680F\u4E4B\u4E0B\u3002 */
79
+ [data-sidebar-right-panel="fullscreen"] {
80
+ top: var(--dsh-mobile-header-h, 52px) !important;
81
+ height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
82
+ max-height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
83
+ }
84
+
69
85
  /* 2. \u9876\u90E8\u539F\u751F\u5BFC\u822A\u6761\uFF1A100% \u8FD8\u539F DeepSeek App (\u5DE6\u4FA7\u53CC\u6A2A\u7EBF\uFF0C\u53F3\u4FA7(+)\uFF0C\u4E2D\u95F4\u7559\u767D\uFF0C\u65E0\u591A\u4F59\u8BBE\u7F6E\u6309\u94AE) */
70
86
  .dsh-mobile-app-header {
71
87
  position: fixed !important;
@@ -923,7 +939,7 @@ var MOBILE_STYLES_CSS = `
923
939
  --dsw-alias-label-primary-foreground: #0f1115;
924
940
  }
925
941
 
926
- @media (min-width: 769px) {
942
+ @media (min-width: 768px) {
927
943
  .dsh-mobile-app-header,
928
944
  .dsh-mobile-backdrop,
929
945
  .dsh-mobile-panel-close-btn {
@@ -1103,6 +1119,81 @@ var BRIDGE_ENDPOINTS = {
1103
1119
  wechatUnbind: "wechatUnbind"
1104
1120
  };
1105
1121
 
1122
+ // client/resource-url-compat.js
1123
+ function installResourceUrlCompat(win = typeof window === "undefined" ? void 0 : window) {
1124
+ if (!win || typeof win.URL !== "function") return "no-url";
1125
+ const NativeURL = win.URL;
1126
+ let probe;
1127
+ try {
1128
+ probe = new NativeURL("dsh-resource://file/__dsh_probe__/x");
1129
+ } catch {
1130
+ return "probe-throw";
1131
+ }
1132
+ if (probe.hostname === "file") return "not-needed";
1133
+ const hostOf = (raw) => {
1134
+ const m = /^dsh-resource:\/\/([^/?#]*)/i.exec(String(raw || ""));
1135
+ return m && m[1] ? m[1].toLowerCase() : "";
1136
+ };
1137
+ function CompatURL(...args) {
1138
+ const url = new NativeURL(...args);
1139
+ try {
1140
+ if (String(url.protocol) === "dsh-resource:" && !url.hostname) {
1141
+ const proto = NativeURL.prototype;
1142
+ const hostnameDesc = Object.getOwnPropertyDescriptor(proto, "hostname");
1143
+ const hostDesc = Object.getOwnPropertyDescriptor(proto, "host");
1144
+ const hostOfUrl = (u) => {
1145
+ let native;
1146
+ try {
1147
+ native = hostnameDesc && hostnameDesc.get ? hostnameDesc.get.call(u) : u.hostname || "";
1148
+ } catch {
1149
+ native = "";
1150
+ }
1151
+ if (native) return native;
1152
+ let href;
1153
+ try {
1154
+ href = String(u.href);
1155
+ } catch {
1156
+ href = "";
1157
+ }
1158
+ return hostOf(href);
1159
+ };
1160
+ if (hostOfUrl(url)) {
1161
+ Object.defineProperty(url, "hostname", {
1162
+ configurable: true,
1163
+ enumerable: hostnameDesc ? hostnameDesc.enumerable : true,
1164
+ get: () => hostOfUrl(url),
1165
+ set: (v) => {
1166
+ if (hostnameDesc && hostnameDesc.set) hostnameDesc.set.call(url, v);
1167
+ }
1168
+ });
1169
+ if (hostDesc) {
1170
+ Object.defineProperty(url, "host", {
1171
+ configurable: true,
1172
+ enumerable: hostDesc.enumerable,
1173
+ get: () => hostOfUrl(url),
1174
+ set: (v) => {
1175
+ if (hostDesc.set) hostDesc.set.call(url, v);
1176
+ }
1177
+ });
1178
+ }
1179
+ }
1180
+ }
1181
+ } catch {
1182
+ }
1183
+ return url;
1184
+ }
1185
+ CompatURL.prototype = NativeURL.prototype;
1186
+ for (const key of Object.getOwnPropertyNames(NativeURL)) {
1187
+ if (key === "length" || key === "name" || key === "prototype") continue;
1188
+ try {
1189
+ CompatURL[key] = NativeURL[key];
1190
+ } catch {
1191
+ }
1192
+ }
1193
+ win.URL = CompatURL;
1194
+ return "installed";
1195
+ }
1196
+
1106
1197
  // client/index.js
1107
1198
  if (typeof window !== "undefined") {
1108
1199
  if (!window.crypto) {
@@ -1123,6 +1214,9 @@ if (typeof window !== "undefined") {
1123
1214
  };
1124
1215
  }
1125
1216
  }
1217
+ var RESOURCE_URL_COMPAT = installResourceUrlCompat();
1218
+ if (typeof window !== "undefined") window.__dshResourceUrlCompat = RESOURCE_URL_COMPAT;
1219
+ var MOBILE_MAX_WIDTH = 767;
1126
1220
  function isLocalEnvironment() {
1127
1221
  if (typeof window === "undefined") return true;
1128
1222
  const host = window.location.hostname || "";
@@ -3699,7 +3793,12 @@ function useDshRestart({ rpcCall, maxAttempts = 30, texts = {} } = {}) {
3699
3793
  setRestarting(true);
3700
3794
  setStatus({ phase: "restarting", text: T.restarting });
3701
3795
  try {
3702
- await rpcCall(BRIDGE_ENDPOINTS.restartDsh, {});
3796
+ const r = await rpcCall(BRIDGE_ENDPOINTS.restartDsh, {});
3797
+ if (r && r.ok === false) {
3798
+ setStatus({ phase: "timeout", text: `${T.timeout}\uFF1A${r.error || "\u672A\u77E5\u539F\u56E0"}` });
3799
+ setRestarting(false);
3800
+ return;
3801
+ }
3703
3802
  } catch {
3704
3803
  }
3705
3804
  setStatus({ phase: "reconnecting", text: T.reconnecting });
@@ -5264,7 +5363,7 @@ function setupMobileExperience(rpcCall, ctx) {
5264
5363
  if (document.body.classList.contains("dsh-drawer-open")) {
5265
5364
  document.body.classList.remove("dsh-drawer-open");
5266
5365
  }
5267
- if (typeof window !== "undefined" && window.innerWidth <= 768) {
5366
+ if (typeof window !== "undefined" && window.innerWidth <= MOBILE_MAX_WIDTH) {
5268
5367
  document.body.classList.remove("dsh-workbench-open");
5269
5368
  const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
5270
5369
  openPanels.forEach((p) => p.classList.add("nArs4W_panelHidden"));
@@ -5273,7 +5372,7 @@ function setupMobileExperience(rpcCall, ctx) {
5273
5372
  }
5274
5373
  const ensurePanelCloseButton = () => {
5275
5374
  if (typeof window === "undefined") return;
5276
- if (window.innerWidth > 768) {
5375
+ if (window.innerWidth > MOBILE_MAX_WIDTH) {
5277
5376
  document.querySelectorAll(".dsh-mobile-panel-close-btn").forEach((btn) => btn.remove());
5278
5377
  return;
5279
5378
  }
@@ -5299,14 +5398,14 @@ function setupMobileExperience(rpcCall, ctx) {
5299
5398
  panelObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
5300
5399
  window.addEventListener("resize", ensurePanelCloseButton);
5301
5400
  document.addEventListener("click", (e) => {
5302
- if (typeof window === "undefined" || window.innerWidth > 768) return;
5401
+ if (typeof window === "undefined" || window.innerWidth > MOBILE_MAX_WIDTH) return;
5303
5402
  const trigger = e.target.closest('button[aria-label*="\u9762\u677F"], button[aria-label*="\u5DE5\u4F5C\u533A"], div[class*="toggleCluster"] button, button[class*="subagent"], div[class*="headerActions"] button, div[class*="titleRow"] button');
5304
5403
  if (trigger && !trigger.classList.contains("dsh-mobile-panel-close-btn") && !trigger.classList.contains("dsh-header-menu-btn") && !trigger.classList.contains("dsh-header-new-btn")) {
5305
5404
  document.body.classList.add("dsh-workbench-open");
5306
5405
  }
5307
5406
  }, true);
5308
5407
  document.addEventListener("click", (e) => {
5309
- if (typeof window === "undefined" || window.innerWidth > 768) return;
5408
+ if (typeof window === "undefined" || window.innerWidth > MOBILE_MAX_WIDTH) return;
5310
5409
  const toggle = e.target.closest('button[aria-label*="\u6536\u8D77\u4FA7\u8FB9\u680F"], button[title*="\u6536\u8D77\u4FA7\u8FB9\u680F"]');
5311
5410
  if (toggle) {
5312
5411
  document.body.classList.remove("dsh-drawer-open");
@@ -6148,7 +6247,7 @@ function setupIosKeyboardAdapter() {
6148
6247
  }
6149
6248
  function setupComposerCollapse() {
6150
6249
  if (typeof window === "undefined" || typeof document === "undefined") return;
6151
- if (window.innerWidth > 768) return;
6250
+ if (window.innerWidth > MOBILE_MAX_WIDTH) return;
6152
6251
  const LS_KEY = "dsh-composer-fold";
6153
6252
  let bar = null;
6154
6253
  let busy = false;
package/client/index.js CHANGED
@@ -29,6 +29,17 @@ if (typeof window !== 'undefined') {
29
29
 
30
30
  import { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS } from '../lib/bridge-rpc-constants.js';
31
31
 
32
+ import { installResourceUrlCompat } from './resource-url-compat.js';
33
+
34
+ const RESOURCE_URL_COMPAT = installResourceUrlCompat();
35
+ if (typeof window !== 'undefined') window.__dshResourceUrlCompat = RESOURCE_URL_COMPAT;
36
+
37
+ // 移动端断点上限:宿主用 viewportWidth < 768 判定右侧栏自动全屏
38
+ // (@deepseek-ai/dsh-client-ui-sidebar-right 的 autoFullscreen),
39
+ // 故桥的移动端分支一律取「<= 767」,与 mobile-styles.js 的 @media (max-width: 767px) 对齐,
40
+ // 避免 768px 单点上"桥渲染顶栏、宿主却按桌面(push)布局"的错位。
41
+ const MOBILE_MAX_WIDTH = 767;
42
+
32
43
  function isLocalEnvironment() {
33
44
  if (typeof window === 'undefined') return true;
34
45
  const host = window.location.hostname || '';
@@ -2327,9 +2338,16 @@ function useDshRestart({ rpcCall, maxAttempts = 30, texts = {} } = {}) {
2327
2338
  setRestarting(true);
2328
2339
  setStatus({ phase: 'restarting', text: T.restarting });
2329
2340
  try {
2330
- await rpcCall(BRIDGE_ENDPOINTS.restartDsh, {});
2341
+ const r = await rpcCall(BRIDGE_ENDPOINTS.restartDsh, {});
2342
+ // 宿主已明确告知"没能安排重启"(例如 systemd 调用失败、助手都派生不出来):
2343
+ // 这时绝不能再假装"正在重连"——直接报错并停下,否则用户只会看到永远转圈。
2344
+ if (r && r.ok === false) {
2345
+ setStatus({ phase: 'timeout', text: `${T.timeout}:${r.error || '未知原因'}` });
2346
+ setRestarting(false);
2347
+ return;
2348
+ }
2331
2349
  } catch {
2332
- // 服务可能瞬间关闭导致连接断开,忽略
2350
+ // 服务可能瞬间关闭导致连接断开,忽略;下面的轮询会判断是否真的起来了
2333
2351
  }
2334
2352
 
2335
2353
  setStatus({ phase: 'reconnecting', text: T.reconnecting });
@@ -3825,7 +3843,7 @@ function setupMobileExperience(rpcCall, ctx) {
3825
3843
  document.body.classList.remove('dsh-drawer-open');
3826
3844
  }
3827
3845
  // 仅在移动端切换会话时自动收起右侧面板回到对话(PC端绝不干扰)
3828
- if (typeof window !== 'undefined' && window.innerWidth <= 768) {
3846
+ if (typeof window !== 'undefined' && window.innerWidth <= MOBILE_MAX_WIDTH) {
3829
3847
  document.body.classList.remove('dsh-workbench-open');
3830
3848
  const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3831
3849
  openPanels.forEach((p) => p.classList.add('nArs4W_panelHidden'));
@@ -3833,10 +3851,10 @@ function setupMobileExperience(rpcCall, ctx) {
3833
3851
  });
3834
3852
  }
3835
3853
 
3836
- // 1.1 移动端右侧边栏 / Workbench 面板管理:仅在移动端(<= 768px)挂载“返回对话”收起按钮,PC端保持纯净
3854
+ // 1.1 移动端右侧边栏 / Workbench 面板管理:仅在移动端(<= 767px)挂载“返回对话”收起按钮,PC端保持纯净
3837
3855
  const ensurePanelCloseButton = () => {
3838
3856
  if (typeof window === 'undefined') return;
3839
- if (window.innerWidth > 768) {
3857
+ if (window.innerWidth > MOBILE_MAX_WIDTH) {
3840
3858
  document.querySelectorAll('.dsh-mobile-panel-close-btn').forEach(btn => btn.remove());
3841
3859
  return;
3842
3860
  }
@@ -3865,7 +3883,7 @@ function setupMobileExperience(rpcCall, ctx) {
3865
3883
 
3866
3884
  // 移动端点击面板/工作区触发按钮时,自动激活 dsh-workbench-open
3867
3885
  document.addEventListener('click', (e) => {
3868
- if (typeof window === 'undefined' || window.innerWidth > 768) return;
3886
+ if (typeof window === 'undefined' || window.innerWidth > MOBILE_MAX_WIDTH) return;
3869
3887
  const trigger = e.target.closest('button[aria-label*="面板"], button[aria-label*="工作区"], div[class*="toggleCluster"] button, button[class*="subagent"], div[class*="headerActions"] button, div[class*="titleRow"] button');
3870
3888
  if (trigger && !trigger.classList.contains('dsh-mobile-panel-close-btn') && !trigger.classList.contains('dsh-header-menu-btn') && !trigger.classList.contains('dsh-header-new-btn')) {
3871
3889
  document.body.classList.add('dsh-workbench-open');
@@ -3874,7 +3892,7 @@ function setupMobileExperience(rpcCall, ctx) {
3874
3892
 
3875
3893
  // 移动端点击 DSH 自带的收起侧边栏图标时,自动收起抽屉
3876
3894
  document.addEventListener('click', (e) => {
3877
- if (typeof window === 'undefined' || window.innerWidth > 768) return;
3895
+ if (typeof window === 'undefined' || window.innerWidth > MOBILE_MAX_WIDTH) return;
3878
3896
  const toggle = e.target.closest('button[aria-label*="收起侧边栏"], button[title*="收起侧边栏"]');
3879
3897
  if (toggle) {
3880
3898
  document.body.classList.remove('dsh-drawer-open');
@@ -4884,7 +4902,7 @@ function setupIosKeyboardAdapter() {
4884
4902
  // 折叠态记忆到 localStorage(dsh-composer-fold),跨会话保持用户偏好。
4885
4903
  function setupComposerCollapse() {
4886
4904
  if (typeof window === 'undefined' || typeof document === 'undefined') return;
4887
- if (window.innerWidth > 768) return; // 仅移动端
4905
+ if (window.innerWidth > MOBILE_MAX_WIDTH) return; // 仅移动端
4888
4906
  const LS_KEY = 'dsh-composer-fold';
4889
4907
 
4890
4908
  let bar = null; // 折叠态底部"点击输入"细条
@@ -20,7 +20,10 @@ export const MOBILE_STYLES_CSS = `
20
20
  --dsh-mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
21
21
  }
22
22
 
23
- @media (max-width: 768px) {
23
+ /* 断点与宿主判据对齐:宿主用 viewportWidth < 768 决定右侧栏自动全屏
24
+ (dsh-client-ui-sidebar-right/lib/client.js),故移动端样式取 <=767px,
25
+ 768px 起彻底交还桌面布局,避免"桥渲染顶栏、宿主却未全屏"的错位。 */
26
+ @media (max-width: 767px) {
24
27
  /* 1. 主框架为 Header 腾出顶部空间 */
25
28
  div[class*="_frame"] {
26
29
  display: flex !important;
@@ -35,6 +38,19 @@ export const MOBILE_STYLES_CSS = `
35
38
  box-sizing: border-box !important;
36
39
  }
37
40
 
41
+ /* 1.1 fixed 全屏面板单独让位:position:fixed 的包含块是 viewport(CSS 2.1 §10.1),
42
+ 不跟随上面 frame 的 padding-top,因此顶部 52px 会落进顶栏覆盖区(#41)。
43
+ 官方右侧栏在 <768px 自动全屏(fixed; inset:0; z-index:40),这里按 data 属性
44
+ 直接位移容器本身,不依赖宿主 CSS-module 哈希类名。
45
+ 真正起作用的是 top;height/max-height 与下面工作台面板那段保持同一写法:
46
+ 宿主当前用 inset:0(无显式高度)时 top 单独即可,但宿主将来若给出显式高度,
47
+ 显式 height 仍能把盒子收在顶栏之下。 */
48
+ [data-sidebar-right-panel="fullscreen"] {
49
+ top: var(--dsh-mobile-header-h, 52px) !important;
50
+ height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
51
+ max-height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
52
+ }
53
+
38
54
  /* 2. 顶部原生导航条:100% 还原 DeepSeek App (左侧双横线,右侧(+),中间留白,无多余设置按钮) */
39
55
  .dsh-mobile-app-header {
40
56
  position: fixed !important;
@@ -892,7 +908,7 @@ export const MOBILE_STYLES_CSS = `
892
908
  --dsw-alias-label-primary-foreground: #0f1115;
893
909
  }
894
910
 
895
- @media (min-width: 769px) {
911
+ @media (min-width: 768px) {
896
912
  .dsh-mobile-app-header,
897
913
  .dsh-mobile-backdrop,
898
914
  .dsh-mobile-panel-close-btn {
@@ -0,0 +1,90 @@
1
+ // 资源地址方言兼容(鸿蒙 ArkWeb / HuaweiBrowser 等内核对非特殊 scheme 的 URL 解析差异)
2
+ //
3
+ // 现象:某些内核里 `new URL('dsh-resource://file/session/<id>/<path>').hostname` 返回空串,
4
+ // 而 Chrome/Safari 返回 "file"。DSH 的客户端资源注册表 `@deepseek-ai/dsh-client-resources`
5
+ // 恰好用 URL 的 hostname 当"协议键"(protocolOf),取不到就把该资源的记录判定成"没有 provider":
6
+ // - 记录创建时 protocol 为 undefined → 状态永远停在 "none";
7
+ // - 注册表只在 provider 注册时把已有记录重新挂载一次,之后不会重试 → 刷新也不会恢复;
8
+ // 表现即:右侧栏文件 / 对话里生成的文档预览永远显示「文件资源服务不可用。」,而
9
+ // `resources.providers` 里其实是有 "file" 的(宿主 @deepseek-ai/dsh-api-workspace-files 的
10
+ // provider 注册成功,坏掉的只是地址解析这一步)。鸿蒙(HuaweiBrowser / ArkWeb 7.0)实测复现。
11
+ //
12
+ // 处理:**先做特性探测**,只有在该引擎确有差异时才包一层 `window.URL`,并且只对
13
+ // `dsh-resource:` 协议、且原生 hostname 为空、且原始串能解析出 host 的实例补一个兜底
14
+ // hostname/host(写成实例自有访问器,setter 仍委托原生原型,避免影响赋值语义)。
15
+ // 引擎正常时完全不改动 URL(返回 'not-needed')。
16
+ //
17
+ // 说明:根因在宿主侧(DSH 用 URL.hostname 当协议键),此处是桥侧最小兜底;
18
+ // 宿主上游若改为从原始串解析协议,本模块会自动进入 'not-needed' 分支、不再介入。
19
+
20
+ /**
21
+ * 安装 `dsh-resource:` 地址的 URL 兼容层。
22
+ *
23
+ * @param {object} [win] 便于测试注入的 window(默认取全局 window)
24
+ * @returns {'not-needed'|'installed'|'no-url'|'probe-throw'} 本次的处理结果
25
+ * - `not-needed`:引擎解析正常,未做任何改动
26
+ * - `installed`:引擎有差异,已包装 window.URL
27
+ * - `no-url` / `probe-throw`:环境不适用(无 window.URL / 构造探测失败)
28
+ */
29
+ export function installResourceUrlCompat(win = typeof window === 'undefined' ? undefined : window) {
30
+ if (!win || typeof win.URL !== 'function') return 'no-url';
31
+ const NativeURL = win.URL;
32
+ let probe;
33
+ try {
34
+ probe = new NativeURL('dsh-resource://file/__dsh_probe__/x');
35
+ } catch {
36
+ return 'probe-throw';
37
+ }
38
+ if (probe.hostname === 'file') return 'not-needed';
39
+
40
+ const hostOf = (raw) => {
41
+ const m = /^dsh-resource:\/\/([^/?#]*)/i.exec(String(raw || ''));
42
+ return m && m[1] ? m[1].toLowerCase() : '';
43
+ };
44
+
45
+ function CompatURL(...args) {
46
+ const url = new NativeURL(...args);
47
+ try {
48
+ if (String(url.protocol) === 'dsh-resource:' && !url.hostname) {
49
+ const proto = NativeURL.prototype;
50
+ const hostnameDesc = Object.getOwnPropertyDescriptor(proto, 'hostname');
51
+ const hostDesc = Object.getOwnPropertyDescriptor(proto, 'host');
52
+ // 读取顺序:原生解析结果优先(赋值/改 href 后立即反映新值),为空才回退到"从当前 href 现解析"。
53
+ // 不能缓存成常量,否则 u.hostname='x' 或 u.href=... 之后读回仍是陈旧值(getter/setter 不自洽)。
54
+ const hostOfUrl = (u) => {
55
+ let native;
56
+ try { native = hostnameDesc && hostnameDesc.get ? hostnameDesc.get.call(u) : (u.hostname || ''); } catch { native = ''; }
57
+ if (native) return native;
58
+ let href;
59
+ try { href = String(u.href); } catch { href = ''; }
60
+ return hostOf(href);
61
+ };
62
+ if (hostOfUrl(url)) {
63
+ Object.defineProperty(url, 'hostname', {
64
+ configurable: true,
65
+ enumerable: hostnameDesc ? hostnameDesc.enumerable : true,
66
+ get: () => hostOfUrl(url),
67
+ set: (v) => { if (hostnameDesc && hostnameDesc.set) hostnameDesc.set.call(url, v); },
68
+ });
69
+ if (hostDesc) {
70
+ Object.defineProperty(url, 'host', {
71
+ configurable: true,
72
+ enumerable: hostDesc.enumerable,
73
+ get: () => hostOfUrl(url),
74
+ set: (v) => { if (hostDesc.set) hostDesc.set.call(url, v); },
75
+ });
76
+ }
77
+ }
78
+ }
79
+ } catch { /* 兜底失败就保持原生行为 */ }
80
+ return url;
81
+ }
82
+
83
+ CompatURL.prototype = NativeURL.prototype;
84
+ for (const key of Object.getOwnPropertyNames(NativeURL)) {
85
+ if (key === 'length' || key === 'name' || key === 'prototype') continue;
86
+ try { CompatURL[key] = NativeURL[key]; } catch { /* 只读静态属性忽略 */ }
87
+ }
88
+ win.URL = CompatURL;
89
+ return 'installed';
90
+ }
package/lib/compat.js CHANGED
@@ -127,3 +127,46 @@ export const BROWSER_ABORT_SIGNAL_POLYFILL = `<script data-dsh-bridge-polyfill="
127
127
  } catch (e) { /* 环境异常时不影响页面其余脚本 */ }
128
128
  }();
129
129
  </script>`
130
+
131
+ /**
132
+ * 浏览器侧垫片源码(二):iOS 16 / 旧 Safari 缺失的 Promise.withResolvers 与 Iterator。
133
+ *
134
+ * 背景(两者都是"整个 app 起不来"级别):
135
+ * 1. `Promise.withResolvers`:Safari 17.4+ / Chrome 119+ 才有。DSH 宿主 HTML 末尾的内联脚本
136
+ * 直接写 `(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()`;
137
+ * 另有多个客户端插件在运行期使用(审批、用户提问、cordis 运行时 ctx.timeout/ctx.interval、
138
+ * workspace-files 的文件变更订阅 Follower)。
139
+ * 2. `Iterator`:Safari 18.4+ 才有(迭代器辅助方法)。DSH 自带的 pdf.js 在**模块顶层**就执行
140
+ * `if (typeof Iterator.prototype.join !== "function") Iterator.prototype.join = ...`
141
+ * iOS 16 上没有 Iterator 全局,于是这一行抛 `Can't find variable: Iterator`,
142
+ * 导致 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` 导入失败 → 整个插件树加载失败,
143
+ * 页面直接显示 "Failed to load plugins"(实测于 iOS 16 真机 + 本机同构模拟)。
144
+ * 这里把真实的内置迭代器共享原型(%IteratorPrototype%)挂到 `Iterator.prototype` 上,
145
+ * 使 pdf.js 的补丁落到真实迭代器原型,而不是一个无用的空对象。
146
+ *
147
+ * 两者都在缺失时才安装;已有该能力的引擎不做任何事。多行可读源码,勿手工压缩成单行(配平易错)。
148
+ */
149
+ export const BROWSER_PROMISE_ITERATOR_POLYFILL = `<script data-dsh-bridge-polyfill="3">
150
+ !function () {
151
+ try {
152
+ if (typeof Promise !== 'undefined' && typeof Promise.withResolvers !== 'function') {
153
+ Promise.withResolvers = function () {
154
+ var resolve, reject;
155
+ var promise = new Promise(function (res, rej) { resolve = res; reject = rej; });
156
+ return { promise: promise, resolve: resolve, reject: reject };
157
+ };
158
+ }
159
+ } catch (e) { /* 环境异常时不影响页面其余脚本 */ }
160
+ try {
161
+ if (typeof self.Iterator === 'undefined') {
162
+ var proto = null;
163
+ try {
164
+ proto = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()));
165
+ } catch (e2) { proto = null; }
166
+ var IteratorShim = function Iterator() {};
167
+ if (proto && typeof proto === 'object') IteratorShim.prototype = proto;
168
+ self.Iterator = IteratorShim;
169
+ }
170
+ } catch (e) { /* 环境异常时不影响页面其余脚本 */ }
171
+ }();
172
+ </script>`
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, realpathSync } from 'node:fs';
13
+ import { readFileSync, appendFileSync, 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 QRCode from 'qrcode';
@@ -28,7 +28,7 @@ import { getDshLoopbackCookie as getDshLoopbackCookieImpl } from './auth/dsh-nat
28
28
  import { renderLoginPage } from './auth/login-template.js';
29
29
  import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
30
30
  import { stripSessionProjections } from './session-strip.js';
31
- import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL } from './compat.js';
31
+ import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL, BROWSER_PROMISE_ITERATOR_POLYFILL } from './compat.js';
32
32
 
33
33
  const name = 'dsh-bridge';
34
34
  // 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
@@ -187,7 +187,8 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
187
187
  <link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
188
188
  <style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
189
189
  <script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
190
- ${BROWSER_ABORT_SIGNAL_POLYFILL}`;
190
+ ${BROWSER_ABORT_SIGNAL_POLYFILL}
191
+ ${BROWSER_PROMISE_ITERATOR_POLYFILL}`;
191
192
  const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
192
193
 
193
194
  function isCompressed(headers) {
@@ -262,6 +263,47 @@ function sanitizeProxyHeaders(headers, extra) {
262
263
  * 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
263
264
  * 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
264
265
  */
266
+ /**
267
+ * 从 `/proc/self/cgroup` 内容里取出最内层(最后一段)的 systemd 单元名。
268
+ *
269
+ * 形如 `0::/user.slice/user-1000.slice/user@1000.service/app.slice/dsh-web.service`
270
+ * 时必须取 `dsh-web.service`(祖先里的 `user@1000.service` 是用户管理器,不是本服务)。
271
+ *
272
+ * @param {string} cgroup /proc/self/cgroup 的原始内容
273
+ * @returns {string} 单元名,取不到返回空串
274
+ */
275
+ export function parseSystemdUnit(cgroup) {
276
+ const parts = String(cgroup || '').split('/').filter(Boolean);
277
+ for (let i = parts.length - 1; i >= 0; i -= 1) {
278
+ if (parts[i].endsWith('.service')) return parts[i];
279
+ }
280
+ return '';
281
+ }
282
+
283
+ /**
284
+ * 判断当前 DSH 由谁托管,决定重启方式(纯函数,便于单测)。
285
+ *
286
+ * @param {object} [opts]
287
+ * @param {object} [opts.env] 环境变量(默认 process.env)
288
+ * @param {string} [opts.cgroup] /proc/self/cgroup 内容
289
+ * @returns {{kind:'daemon'|'systemd'|'self', scope?:'user'|'system', unit?:string, reason:string}}
290
+ */
291
+ export function detectSupervisor({ env = process.env, cgroup = '' } = {}) {
292
+ if (env.DSH_DAEMON || env.PM2_HOME) {
293
+ return { kind: 'daemon', reason: env.PM2_HOME ? 'PM2_HOME' : 'DSH_DAEMON' };
294
+ }
295
+ const text = String(cgroup || '');
296
+ // 只在确实处于 systemd 环境时才认(INVOCATION_ID/JOURNAL_STREAM 是 systemd 注入的标记),
297
+ // 避免在容器里存在同名 cgroup 文本时误判。
298
+ const systemdMarked = Boolean(env.INVOCATION_ID || env.JOURNAL_STREAM) || text.includes('.slice');
299
+ const unit = systemdMarked ? parseSystemdUnit(text) : '';
300
+ if (unit) {
301
+ const parts = text.split('/').filter(Boolean);
302
+ return { kind: 'systemd', scope: parts.includes('user.slice') ? 'user' : 'system', unit, reason: 'cgroup 检出 systemd 单元' };
303
+ }
304
+ return { kind: 'self', reason: '未检出托管器(无 DSH_DAEMON/PM2_HOME,也不在 systemd 单元内)' };
305
+ }
306
+
265
307
  class ProxyServer {
266
308
  constructor({ localPort, targetPort, authManager, logger, allowedOrigins }) {
267
309
  this.localPort = localPort;
@@ -1301,32 +1343,103 @@ class BridgeService {
1301
1343
  return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
1302
1344
  }
1303
1345
 
1304
- // 优雅重启 DSH 服务(支持守护进程自动拉起或独立派生子进程重启)
1305
- async restartDsh() {
1306
- this.logger?.info('收到 DSH 重启请求,正在调度重启...');
1307
- setTimeout(() => {
1308
- try {
1309
- if (process.env.DSH_DAEMON || process.env.PM2_HOME) {
1310
- process.exit(0);
1311
- } else {
1312
- // 常规 Node/CLI 模式:派生与当前参数一致的独立后台子进程并退出当前进程
1313
- const child = spawn(process.execPath, process.argv.slice(1), {
1314
- cwd: process.cwd(),
1315
- env: process.env,
1316
- detached: true,
1317
- stdio: 'ignore',
1318
- windowsHide: false,
1319
- });
1320
- child.unref();
1321
- process.exit(0);
1346
+ // 优雅重启 DSH 服务。
1347
+ //
1348
+ // 关键教训(真实事故):DSH 常以 systemd 用户单元(如 dsh-web.service,Restart=on-failure)托管,
1349
+ // 而"自己派生一个子进程再 process.exit(0)"在这种环境下必然失败:
1350
+ // - 干净退出(code 0)不满足 Restart=on-failure,systemd 不会拉起;
1351
+ // - 默认 KillMode=control-group,主进程退出时 systemd 会把 cgroup 里刚派生的子进程一起杀掉。
1352
+ // 结果就是"点了重启,dsh 再也没起来",而且没有任何日志(旧实现 stdio: 'ignore')。
1353
+ // 因此这里先识别托管方式:
1354
+ // - systemd(从 /proc/self/cgroup 取单元名)→ 交给 `systemctl [--user] restart --no-block <unit>`,
1355
+ // 由 systemd 负责 stop+start,新进程仍在正确的 cgroup 里;
1356
+ // - DSH_DAEMON / PM2 → 直接退出,交给守护进程拉起;
1357
+ // - 无托管器 → 派生独立助手(lib/restart-helper.mjs),等旧进程退出 + 端口释放后才拉起新进程,
1358
+ // 并把全过程写入 ~/.dsh/dsh-bridge/restart.log(失败不再无声)。
1359
+ // opts 仅供单测注入(spawnImpl / readCgroup / env / scheduleExit)。
1360
+ async restartDsh(opts = {}) {
1361
+ const env = opts.env ?? process.env;
1362
+ const spawnImpl = opts.spawnImpl ?? spawn;
1363
+ const readCgroup = opts.readCgroup
1364
+ ?? (() => { try { return readFileSync('/proc/self/cgroup', 'utf8'); } catch { return ''; } });
1365
+ const cgroup = opts.cgroup ?? readCgroup();
1366
+ const scheduleExit = opts.scheduleExit ?? ((ms) => setTimeout(() => process.exit(0), ms));
1367
+
1368
+ const dshHome = env.DSH_HOME ?? join(homedir(), '.dsh');
1369
+ const logFile = join(dshHome, 'dsh-bridge', 'restart.log');
1370
+ const childLog = join(dshHome, 'dsh-bridge', 'restart-child.log');
1371
+ const writeLog = (message) => {
1372
+ try { appendFileSync(logFile, `[${new Date().toISOString()}] ${message}\n`); } catch { /* 日志不可写时忽略 */ }
1373
+ };
1374
+
1375
+ const plan = detectSupervisor({ env, cgroup });
1376
+ writeLog(`重启请求:plan=${plan.kind}${plan.unit ? ` unit=${plan.unit}(${plan.scope})` : ''}(${plan.reason})pid=${process.pid} cwd=${process.cwd()} argv=${JSON.stringify(process.argv.slice(1))}`);
1377
+ this.logger?.info('收到 DSH 重启请求(%s)', plan.kind === 'systemd' ? `systemd:${plan.unit}` : plan.kind);
1378
+
1379
+ if (plan.kind === 'systemd') {
1380
+ const args = [...(plan.scope === 'user' ? ['--user'] : []), 'restart', '--no-block', plan.unit];
1381
+ const spawnError = await new Promise((resolve) => {
1382
+ let settled = false;
1383
+ const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
1384
+ try {
1385
+ const child = spawnImpl('systemctl', args, { detached: true, stdio: 'ignore', env });
1386
+ child.once?.('error', (err) => settle(err));
1387
+ child.once?.('spawn', () => settle(null));
1388
+ child.unref?.();
1389
+ } catch (err) {
1390
+ settle(err);
1322
1391
  }
1392
+ });
1393
+
1394
+ if (!spawnError) {
1395
+ writeLog(`已请求 systemd 重启:systemctl ${args.join(' ')}`);
1396
+ scheduleExit(1500);
1397
+ const scopeText = plan.scope === 'user' ? '用户会话' : '系统';
1398
+ return { ok: true, message: `已请求 systemd 重启 ${plan.unit}(${scopeText}),由 systemd 负责停止与拉起…` };
1399
+ }
1400
+ writeLog(`调用 systemctl 失败:${spawnError.message};回退到独立助手方式`);
1401
+ } else if (plan.kind === 'daemon') {
1402
+ writeLog(`交由守护进程拉起(${plan.reason})`);
1403
+ scheduleExit(600);
1404
+ return { ok: true, message: `DSH 正在重启(由 ${plan.reason} 守护进程拉起)…` };
1405
+ }
1406
+
1407
+ // 无托管器(或 systemd 调用失败)→ 独立助手:等旧进程退出、端口释放后再拉起新进程,全程留日志
1408
+ const helper = join(dirname(fileURLToPath(import.meta.url)), 'restart-helper.mjs');
1409
+ const payload = JSON.stringify({
1410
+ pid: process.pid,
1411
+ port: this.dshPort ?? 3080,
1412
+ argv: process.argv.slice(1),
1413
+ cwd: process.cwd(),
1414
+ execPath: process.execPath,
1415
+ logFile,
1416
+ childLog,
1417
+ });
1418
+ // spawn 的失败是异步事件('error'),只 try/catch 抓不到——必须挂监听,否则会变成未捕获异常
1419
+ const helperError = await new Promise((resolve) => {
1420
+ let settled = false;
1421
+ const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
1422
+ try {
1423
+ const child = spawnImpl(process.execPath, [helper, payload], {
1424
+ cwd: process.cwd(),
1425
+ env,
1426
+ detached: true,
1427
+ stdio: 'ignore',
1428
+ });
1429
+ child.once?.('error', (err) => settle(err));
1430
+ child.once?.('spawn', () => { writeLog(`已派生重启助手:helperPid=${child.pid ?? '(未知)'},日志:${logFile}`); settle(null); });
1431
+ child.unref?.();
1323
1432
  } catch (err) {
1324
- this.logger?.error('派生重启进程失败: %s,执行直接退出', err.message);
1325
- process.exit(0);
1433
+ settle(err);
1326
1434
  }
1327
- }, 600);
1435
+ });
1328
1436
 
1329
- return { ok: true, message: 'DSH 服务正在重启中,前端将在几秒后自动重新连接…' };
1437
+ if (helperError) {
1438
+ writeLog(`派生重启助手失败:${helperError.message}`);
1439
+ return { ok: false, error: `重启失败:${helperError.message}(DSH 仍在运行,未做任何退出)` };
1440
+ }
1441
+ scheduleExit(600);
1442
+ return { ok: true, message: '正在重启:独立助手会在旧进程退出、端口释放后拉起新的 DSH(若失败会记录在 ~/.dsh/dsh-bridge/restart.log)…' };
1330
1443
  }
1331
1444
 
1332
1445
  getSystemMetrics() {
@@ -0,0 +1,101 @@
1
+ // 无托管器场景下的 DSH 重启助手(由 BridgeService.restartDsh 派生为独立进程)。
2
+ //
3
+ // 为什么需要它:旧进程调用 process.exit(0) 后,端口不会立刻释放;若新进程马上启动,
4
+ // 很容易撞上 EADDRINUSE 然后无声退出(stdio 被 ignore 时连报错都看不到),
5
+ // 结果就是"点了重启,dsh 再也没起来"。本助手按顺序做三件事并把过程写进日志:
6
+ // 1. 等旧进程真正退出;
7
+ // 2. 等端口释放(能连上说明还被占着);
8
+ // 3. 才派生新的 dsh,并在最长 120s 内确认端口可连接;失败则记日志并以非 0 退出。
9
+ //
10
+ // 用法(由 lib/index.js 组装 payload):node restart-helper.mjs '<json>'
11
+ import { spawn } from 'node:child_process';
12
+ import { appendFileSync, openSync } from 'node:fs';
13
+ import { connect } from 'node:net';
14
+
15
+ const payload = JSON.parse(process.argv[2] ?? '{}');
16
+ const {
17
+ pid, port, argv, cwd, execPath, logFile, childLog,
18
+ // 超时可注入(单测用),生产用默认值
19
+ waitTimeoutMs = 30_000,
20
+ readyTimeoutMs = 120_000,
21
+ } = payload;
22
+ const startedAt = Date.now();
23
+
24
+ function log(message) {
25
+ try { appendFileSync(logFile, `[${new Date().toISOString()}] [helper] ${message}\n`); } catch { /* 日志不可写时忽略 */ }
26
+ }
27
+
28
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+
30
+ function processAlive(targetPid) {
31
+ try {
32
+ process.kill(targetPid, 0);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /** 端口上是否有人在监听(能建立连接即视为被占用) */
40
+ async function portAccepts(portNumber) {
41
+ return await new Promise((resolve) => {
42
+ let settled = false;
43
+ const done = (value) => { if (!settled) { settled = true; resolve(value); } };
44
+ let socket;
45
+ try {
46
+ socket = connect({ host: '127.0.0.1', port: portNumber });
47
+ } catch {
48
+ done(false);
49
+ return;
50
+ }
51
+ socket.setTimeout(1200);
52
+ socket.once('connect', () => { done(true); socket.destroy(); });
53
+ socket.once('timeout', () => { done(false); socket.destroy(); });
54
+ socket.once('error', () => { done(false); socket.destroy(); });
55
+ });
56
+ }
57
+
58
+ async function waitUntil(check, timeoutMs, label) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ for (;;) {
61
+ if (await check()) return true;
62
+ if (Date.now() >= deadline) {
63
+ log(`等待超时:${label}(${timeoutMs}ms)`);
64
+ return false;
65
+ }
66
+ await sleep(400);
67
+ }
68
+ }
69
+
70
+ async function main() {
71
+ log(`启动:oldPid=${pid} port=${port} cwd=${cwd}`);
72
+
73
+ if (!(await waitUntil(() => !processAlive(pid), waitTimeoutMs, `旧进程 ${pid} 退出`))) {
74
+ log('旧进程仍在运行,放弃本次重启(不做任何破坏性动作)');
75
+ process.exit(2);
76
+ }
77
+ if (!(await waitUntil(async () => !(await portAccepts(port)), waitTimeoutMs, `端口 ${port} 释放`))) {
78
+ log(`端口 ${port} 仍被占用,放弃本次重启`);
79
+ process.exit(3);
80
+ }
81
+
82
+ let stdio = 'ignore';
83
+ try { stdio = openSync(childLog, 'a'); } catch { /* 打不开就用 ignore */ }
84
+ let child;
85
+ try {
86
+ child = spawn(execPath, argv, { cwd, env: process.env, detached: true, stdio: ['ignore', stdio, stdio] });
87
+ } catch (err) {
88
+ log(`派生新 dsh 失败:${err?.message ?? err}`);
89
+ process.exit(4);
90
+ }
91
+ log(`已派生新 dsh:pid=${child.pid ?? '(未知)'}(输出见 ${childLog})`);
92
+ child.unref();
93
+
94
+ const up = await waitUntil(() => portAccepts(port), readyTimeoutMs, `新 dsh 监听 ${port}`);
95
+ log(up
96
+ ? `✅ 新 dsh 已就绪,耗时 ${Math.round((Date.now() - startedAt) / 1000)}s`
97
+ : `❌ 新 dsh 未能在 ${Math.round(readyTimeoutMs / 1000)}s 内就绪,请查看 ${childLog}`);
98
+ process.exit(up ? 0 : 1);
99
+ }
100
+
101
+ void main();
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.10.10",
3
+ "version": "2.10.11",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.10.10】\n• 新功能:设置页每个 IM 平台卡片内新增「高级设置」——可直接配置该平台远程会话的 Agent 预设(下拉列出本机可用预设,留空用 DSH 默认)、工作区目录、模型提供方/模型,以及摘要间隔、审批超时、单条消息上限、分块发送间隔;保存即生效并随宿主重启保留(此前只能手改 config.json)\n• 修复:DSH 0.1.5 系列上远程 IM 会话(微信/QQ/飞书/Telegram)工具集残缺(#40)——DSH 把工具/提示词/技能挪到 agent preset 后,桥只记录了预设名却从未真正挂载,会话落在「空预设层」因而缺工具(如无法读文件);现在新建与恢复会话都会真实挂载预设(未配置时用 DSH 默认),配置的预设若在本机不存在会回退默认并在会话里明确提示,不再静默降级\n• 修复:宿主重启后给已有会话发消息报「session ... already exists」、随后卡在「当前没有活动会话」(#39)——恢复会话时误判了 DSH 的持久化返回结构,把已存在的历史会话当成新会话创建;现按真实结构识别并恢复\n• 修复:远程会话的 cwd、agentPreset、agentProvider、agentModel 以及 QQ/飞书的摘要间隔、审批超时、分块延时保存后重启即丢(#40)——重启恢复与配置导入时会一并读回;配置的工作区目录不可用时回落到已注册工作区并提示\n• 修复:`/rename` 改名后重启即失效——此前调用的持久化接口在 DSH 中并不存在、被静默跳过;现改用 DSH 原生会话标题服务,改名真正落盘",
5
+ "releaseNotes": "【v2.10.11】\n• 修复:移动端顶栏压住 position:fixed 全屏面板顶部 52px(#41)——桥的让位方式此前只给内容框架加 padding-top,而 fixed 元素的包含块是视口、不跟随祖先内边距;官方右侧栏在窄视口(<768px)自动全屏(fixed; inset:0),顶部标签栏因此落进顶栏覆盖区,面板右端的「全屏 / 收起」按钮右半还会被顶栏「+」夺走点击,误触会直接新建会话。现按 [data-sidebar-right-panel=fullscreen] 直接位移面板容器本身(top 下移 52px 并同步收窄高度),不依赖宿主 CSS 模块哈希类名,宿主升级不失效。**若你此前用用户侧 CSS 以 top 手动让位过,本次修复与之同属性,级联只会生效一条、不会叠加;但若你用的是 padding-top / margin-top / transform 位移,请撤掉旧规则以免叠加下移 52px。**\n• 修复:移动端断点与宿主判据对齐——桥原按 ≤768px(运行时亦为 innerWidth<=768),宿主按 viewportWidth<768 判定右侧栏自动全屏,恰在 768px 出现「桥渲染移动端顶栏、宿主按桌面布局」的错位;现移动端统一为 ≤767px,768px 起彻底交还桌面布局。\n• 修复:鸿蒙(HuaweiBrowser / ArkWeb)手机上图右侧栏文件与对话生成文档的预览全部显示「文件资源服务不可用。」——该内核解析非特殊 scheme 时 new URL('dsh-resource://file/…').hostname 返回空串,而 DSH 的客户端资源注册表正是拿 hostname 当协议键,于是资源记录被判成「没有 provider」并永久停在 none(注册表只在 provider 注册那一刻回挂一次已有记录,之后不再重试,故刷新也不恢复)。现桥侧新增 dsh-resource 地址的 URL 兼容层:先做特性探测,只有引擎确有差异时才包装 window.URL,且仅对 hostname 为空的 dsh-resource 地址补上兜底 host;引擎正常时完全不介入。iOS / Android / 桌面端行为不变。\n• 修复:iOS 16 / 旧 Safari 完全打不开 DSH(红屏 Failed to load plugins … Can't find variable: Iterator)——DSH 自带 pdf.js 在模块顶层引用 Safari 18.4 才有的 Iterator 全局,iOS 16 上直接抛错并使整个客户端插件树加载失败;同类缺口还有 Promise.withResolvers(Safari 17.4+,DSH 启动握手与多个插件运行期都在用)。现桥在代理注入 HTML 时、于宿主所有脚本之前补齐这两枚垫片(Iterator 会把真实的内置迭代器共享原型挂到 Iterator.prototype),只在缺失时安装,iOS 18.4+ / Android / 桌面端零改动。\n• 修复:「重启 DSH」按钮在 systemd 托管下点了没用(DSH 没起来、只能手动重启、且不留日志)——旧实现不识别托管器,直接自派生子进程后 process.exit(0):systemd 下 Restart=on-failure 不认干净退出、默认 KillMode=control-group 又会把刚派生的子进程一起杀掉,加上 stdio 被 ignore,失败完全无声。现从 /proc/self/cgroup 识别 systemd 单元并交给 systemctl [--user] restart --no-block <unit>;DSH_DAEMON/PM2 仍走守护进程;无托管器时派生独立助手,等旧进程退出与端口释放后再拉起新 DSH,并在 120s 内确认端口可连接,全过程写入 ~/.dsh/dsh-bridge/restart.log。前端在宿主返回 ok:false 时直接报错,不再假装重连。systemd 环境需先手动重启一次让本修复生效。",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {