@wenbin_wb/dsh-bridge 2.10.11 → 2.10.12

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,15 +4,16 @@
4
4
 
5
5
  ---
6
6
 
7
- ## [v2.10.11] - 2026-09-16
7
+ ## [v2.10.12] - 2026-09-16
8
+
9
+ > v2.10.11 未正式发布到 npm,其内容已并入本版。
8
10
 
9
11
  ### 🐞 修复
10
12
 
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 托管的环境需先手动重启一次让本修复生效,之后按钮即可正常使用。
13
+ - **iOS 16 / Safari 打不开 DSH**(红屏 `Failed to load plugins Can't find variable: Iterator`):代理注入 HTML 时补齐 `Promise.withResolvers` `Iterator` 垫片(Safari 分别是 17.4 / 18.4 才有),只在缺失时安装,其它浏览器零改动。
14
+ - **鸿蒙(HuaweiBrowser / ArkWeb)文档预览一直显示「文件资源服务不可用。」**:该内核解析 `dsh-resource://` 地址取不到 hostname,新增 URL 兼容层兜底;引擎正常时不介入。
15
+ - **移动端顶栏压住全屏面板顶部 52px(#41)**:改为直接位移面板容器本身,并让移动端断点与宿主判据对齐(`≤767px`)。
16
+ - **「重启 DSH」在 systemd 托管下点了没用、DSH 不再自动起来**:改为识别 systemd 单元并交给 `systemctl restart`,按退出码判定成败;失败时保持运行并提示手动重启命令。
16
17
 
17
18
  ---
18
19
 
package/lib/index.js CHANGED
@@ -177,7 +177,7 @@ const PWA_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 5
177
177
  <circle cx="256" cy="80" r="16" fill="#4f6ef7"/>
178
178
  </svg>`;
179
179
 
180
- const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
180
+ const HTML_HEAD_ASSETS = `<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
181
181
  <meta name="apple-mobile-web-app-capable" content="yes">
182
182
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
183
183
  <meta name="apple-mobile-web-app-title" content="DSH">
@@ -187,9 +187,14 @@ 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}
191
- ${BROWSER_PROMISE_ITERATOR_POLYFILL}`;
192
- const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
190
+ `;
191
+ /** 可分别判重注入的三段:各自的 data 标记即去重键(旧版只按 marker 1 判重,
192
+ * 两层桥串联时会出现"上游有 1 没有 2/3 → 整段跳过"的漏注入)。 */
193
+ const HTML_INJECTION_PARTS = [
194
+ { mark: 'data-dsh-bridge-polyfill="1"', html: HTML_HEAD_ASSETS },
195
+ { mark: 'data-dsh-bridge-polyfill="2"', html: BROWSER_ABORT_SIGNAL_POLYFILL },
196
+ { mark: 'data-dsh-bridge-polyfill="3"', html: BROWSER_PROMISE_ITERATOR_POLYFILL },
197
+ ];
193
198
 
194
199
  function isCompressed(headers) {
195
200
  return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
@@ -274,10 +279,11 @@ function sanitizeProxyHeaders(headers, extra) {
274
279
  */
275
280
  export function parseSystemdUnit(cgroup) {
276
281
  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 '';
282
+ // 只认**最内层**组件:若最内层不是 .service(例如 .scope、或 docker/k8s 的容器目录),
283
+ // 说明本进程不是被某个 service 直接托管,绝不能向上游误取祖先单元
284
+ // (否则会把 docker.service / cron.service 之类重启掉)。
285
+ const innermost = parts[parts.length - 1] ?? '';
286
+ return innermost.endsWith('.service') ? innermost : '';
281
287
  }
282
288
 
283
289
  /**
@@ -508,8 +514,10 @@ class ProxyServer {
508
514
  const outHeaders = { ...proxyRes.headers };
509
515
  if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
510
516
  let html = out.toString('utf8');
511
- if (!html.includes(INJECT_MARK)) {
512
- html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
517
+ const missingParts = HTML_INJECTION_PARTS.filter((part) => !html.includes(part.mark));
518
+ if (missingParts.length > 0) {
519
+ const payload = missingParts.map((part) => part.html).join('\n');
520
+ html = html.replace(/<head[^>]*>/i, (m) => `${m}${payload}`);
513
521
  }
514
522
  out = Buffer.from(html, 'utf8');
515
523
  } else if (isSessionProjection) {
@@ -1378,26 +1386,39 @@ class BridgeService {
1378
1386
 
1379
1387
  if (plan.kind === 'systemd') {
1380
1388
  const args = [...(plan.scope === 'user' ? ['--user'] : []), 'restart', '--no-block', plan.unit];
1381
- const spawnError = await new Promise((resolve) => {
1389
+ // 关键:必须以 **systemctl 的退出码** 判定成败,而不是"派生成功"。
1390
+ // `--no-block` 下 systemctl 只发请求就返回(实测往返 ~6ms),但单元名不存在会 rc=5、
1391
+ // 权限不足会 rc=1;旧写法只看 'spawn' 事件 → 必然失败也返回 ok:true 再 exit(0),
1392
+ // 而干净退出不满足 Restart=on-failure → DSH 直接下线且不自动拉起。
1393
+ const outcome = await new Promise((resolve) => {
1382
1394
  let settled = false;
1383
1395
  const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
1384
1396
  try {
1385
1397
  const child = spawnImpl('systemctl', args, { detached: true, stdio: 'ignore', env });
1386
- child.once?.('error', (err) => settle(err));
1387
- child.once?.('spawn', () => settle(null));
1398
+ child.once?.('error', (err) => settle({ error: err }));
1399
+ child.once?.('close', (code) => settle({ code }));
1388
1400
  child.unref?.();
1389
1401
  } catch (err) {
1390
- settle(err);
1402
+ settle({ error: err });
1391
1403
  }
1392
1404
  });
1393
1405
 
1394
- if (!spawnError) {
1395
- writeLog(`已请求 systemd 重启:systemctl ${args.join(' ')}`);
1406
+ if (!outcome.error && (outcome.code === 0 || outcome.code === null || outcome.code === undefined)) {
1407
+ writeLog(`已请求 systemd 重启(systemctl 退出码 ${outcome.code ?? 'n/a'}):systemctl ${args.join(' ')}`);
1396
1408
  scheduleExit(1500);
1397
1409
  const scopeText = plan.scope === 'user' ? '用户会话' : '系统';
1398
1410
  return { ok: true, message: `已请求 systemd 重启 ${plan.unit}(${scopeText}),由 systemd 负责停止与拉起…` };
1399
1411
  }
1400
- writeLog(`调用 systemctl 失败:${spawnError.message};回退到独立助手方式`);
1412
+
1413
+ // 失败时**不退化为自派生**:systemd 下主进程干净退出既不会被拉起,
1414
+ // 默认 KillMode=control-group 还会把 detached 助手一起杀掉 —— 那等于"报着重启却永久下线"。
1415
+ // 因此保持现状(DSH 继续运行)并如实报错,由用户手动处理。
1416
+ const reason = outcome.error ? outcome.error.message : `systemctl 退出码 ${outcome.code}`;
1417
+ writeLog(`systemctl 重启失败(${reason}),为不让 DSH 掉线未做任何退出`);
1418
+ return {
1419
+ ok: false,
1420
+ error: `systemctl ${args.join(' ')} 失败:${reason}。DSH 仍在运行;请手动执行「systemctl ${plan.scope === 'user' ? '--user ' : ''}restart ${plan.unit}」或检查单元。`,
1421
+ };
1401
1422
  } else if (plan.kind === 'daemon') {
1402
1423
  writeLog(`交由守护进程拉起(${plan.reason})`);
1403
1424
  scheduleExit(600);
@@ -81,15 +81,33 @@ async function main() {
81
81
 
82
82
  let stdio = 'ignore';
83
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();
84
+
85
+ // spawn 的失败是异步 'error' 事件(例如 execPath 不存在 → ENOENT),只 try/catch 抓不到;
86
+ // 不挂监听会变成未捕获异常,而且父进程给助手设的是 stdio:'ignore' 失败细节全部丢失。
87
+ // 这里挂监听并把原因写进日志,且**确认 spawn 成功后才**记录"已派生"。
88
+ const spawned = await new Promise((resolve) => {
89
+ let settled = false;
90
+ const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
91
+ let child;
92
+ try {
93
+ child = spawn(execPath, argv, { cwd, env: process.env, detached: true, stdio: ['ignore', stdio, stdio] });
94
+ } catch (err) {
95
+ log(`派生新 dsh 失败(同步异常):${err?.message ?? err}`);
96
+ settle(null);
97
+ return;
98
+ }
99
+ child.once('error', (err) => {
100
+ log(`派生新 dsh 失败:${err?.message ?? err}`);
101
+ settle(null);
102
+ });
103
+ child.once('spawn', () => {
104
+ log(`已派生新 dsh:pid=${child.pid ?? '(未知)'}(输出见 ${childLog})`);
105
+ settle(child);
106
+ });
107
+ });
108
+
109
+ if (!spawned) process.exit(4);
110
+ spawned.unref();
93
111
 
94
112
  const up = await waitUntil(() => portAccepts(port), readyTimeoutMs, `新 dsh 监听 ${port}`);
95
113
  log(up
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.10.11",
3
+ "version": "2.10.12",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
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 环境需先手动重启一次让本修复生效。",
5
+ "releaseNotes": "【v2.10.12】(含未正式发布的 v2.10.11 内容)\n• 修复:iOS 16 / 旧 Safari 打不开 DSH(红屏 Failed to load plugins)——补齐 Promise.withResolvers Iterator 垫片,只在缺失时安装\n• 修复:鸿蒙手机上文档预览一直显示「文件资源服务不可用。」——新增 dsh-resource 地址的 URL 兼容层\n• 修复:移动端顶栏压住全屏面板顶部 52px(#41),并把移动端断点与宿主对齐(≤767px)\n• 修复:「重启 DSH」在 systemd 托管下点了没用、DSH 不再自动起来——改为交给 systemctl 重启并按退出码判定成败\n• 说明:systemd 环境需手动重启一次 dsh(systemctl --user restart dsh-web.service)让本版生效",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {