@wenbin_wb/dsh-bridge 2.10.11 → 2.10.13

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,38 @@
4
4
 
5
5
  ---
6
6
 
7
- ## [v2.10.11] - 2026-09-16
7
+ ## [v2.10.13] - 2026-09-23
8
+
9
+ > 本版为可靠性与兼容性加固:修复 cloudflared 连接器"假死"(进程存活但边缘连接全掉)造成的公网隧道静默失效(新增运行时健康探针与输出落盘);跟随 DSH 0.1.5 的官方目录选择器,让本机访问不再误弹插件的远程抽屉;并修复添加工作区弹窗的状态泄漏卡死。
10
+
11
+ ### ✨ 新功能
12
+
13
+ - **cloudflared 运行时健康探针**:解析 cloudflared 自报 metrics 地址并每 30s 探活官方 `/ready`(判据与上游一致:HTTP 200 且 `readyConnections > 0`)。连续 3 次失败(约 90s)→ 面板状态可见降级「⚠️ 连接器已无健康边缘连接」;连续 10 次(约 5min)→ 判定假死、终止进程并走既有自愈链路强制重建。重建链路(含重启后握手失败)**永不进入终态**——网络恢复后会自动自愈,不再需要人工干预。
14
+ - **cloudflared 输出落盘**:子进程输出写入 `~/.dsh/dsh-bridge/cloudflared.log`,超 5MB 自动轮转为 `.1`;隧道 Token 落盘前强制脱敏(含跨输出块切分、以及 Token 前缀残留等边界,确保任何 Token 片段都不落盘)。
15
+
16
+ ### 🐞 修复
17
+
18
+ - **公网入口静默失效数天无人发现**(现象:HTTP 530 + `error code: 1033`):旧逻辑只监听子进程退出,而假死连接器既不退出也不报错,故永不触发自愈。探针上线后最迟约 5 分钟即可自动重建。
19
+ - **本机点「添加工作区」误弹插件的远程抽屉**(issue #28 第 2 条):DSH 0.1.5 起自带官方目录选择器(`dsh-host-directory-picker-auto` 按宿主能力分发 native / browse),且注册到与插件完全相同的 Slot;DSH 的 slots 是 shadow 语义(动态注册的插件天然覆盖内置实现),于是本机 `127.0.0.1:3080` 也被插件的远程目录选择器接管。现改为「本机访问 且 官方 picker 可用」时**不注册**该 Slot,让位给 DSH 官方——macOS / Windows 走系统原生文件夹对话框,无桌面环境的 Linux 走官方网页选择器;**旧版 DSH 与远程 / 移动访问仍由插件接管**,保留管理密码解锁与 `local_only` 目录策略。
20
+ - **添加工作区弹窗点过「已注册工作区」后永久卡死**(PR #42):`switchToWorkspace()` 只置位 `isSubmitting` 而从不复位,同一弹窗实例的 `loadDirectory()` / `doSubmit()` 守卫让弹窗彻底无响应(点目录不进目录、点添加无反应且按钮停在「正在添加并切换…」);改为与 `doSubmit()` 对称的 `try/finally` 复位,`render()` 抛错与将来新增的提前 return 都不会再泄漏。
21
+ - **探针自身异常不得拖垮宿主**:畸形 metrics 地址、日志通道自身抛错等任何探针路径异常都不会变成未处理 rejection 结束 DSH 主进程(独立子进程实验验证)。
22
+ - **新增 18 条探针回归测试**(含防误杀正常重连连接器、防终态、脱敏泄漏/吞字符、跨 chunk 脱敏、崩溃回归等)。
23
+ - **新增 15 条回归测试**:12 条目录选择器让位(让位判定真值表、官方 picker 探测、Slot 注册顺序结构断言、产物同步)+ 3 条添加工作区弹窗 `isSubmitting` 复位与产物同步。
24
+
25
+ > ⚠️ 生效说明:本版需要重启一次 dsh(`systemctl --user restart dsh-web.service` 或面板「重启 DSH」)才加载新代码;重启会短暂中断当前会话与公网隧道(数十秒)。
26
+
27
+ ---
28
+
29
+ ## [v2.10.12] - 2026-09-16
30
+
31
+ > 本版汇总 v2.10.11 的修复内容,并含 4 处可靠性加固(systemctl 退出码判定、单元名解析、助手失败日志、注入分段判重)。
8
32
 
9
33
  ### 🐞 修复
10
34
 
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 托管的环境需先手动重启一次让本修复生效,之后按钮即可正常使用。
35
+ - **iOS 16 / 旧 Safari 打不开 DSH**(红屏 `Failed to load plugins … Can't find variable: Iterator`):代理注入 HTML 时补齐 `Promise.withResolvers` 与 `Iterator` 垫片(Safari 分别是 17.4 / 18.4 才有),只在缺失时安装,其它浏览器零改动。
36
+ - **鸿蒙(HuaweiBrowser / ArkWeb)文档预览一直显示「文件资源服务不可用。」**:该内核解析 `dsh-resource://` 地址取不到 hostname,新增 URL 兼容层兜底;引擎正常时不介入。
37
+ - **移动端顶栏压住全屏面板顶部 52px(#41)**:改为直接位移面板容器本身,并让移动端断点与宿主判据对齐(`≤767px`)。
38
+ - **「重启 DSH」在 systemd 托管下点了没用、DSH 不再自动起来**:改为识别 systemd 单元并交给 `systemctl restart`,按退出码判定成败;失败时保持运行并提示手动重启命令。
16
39
 
17
40
  ---
18
41
 
package/client/client.js CHANGED
@@ -1067,6 +1067,21 @@ async function unlockAdmin(rpcCall, password) {
1067
1067
  }
1068
1068
  }
1069
1069
 
1070
+ // client/picker-yield.js
1071
+ var OFFICIAL_WORKSPACE_SEAT = "uiWorkspace";
1072
+ function hasOfficialDirectoryPicker(ctx) {
1073
+ try {
1074
+ if (typeof ctx?.get !== "function") return false;
1075
+ const seat = ctx.get(OFFICIAL_WORKSPACE_SEAT);
1076
+ return typeof seat?.pickDirectory === "function";
1077
+ } catch {
1078
+ return false;
1079
+ }
1080
+ }
1081
+ function shouldYieldToOfficialPicker(facts) {
1082
+ return Boolean(facts?.local) && Boolean(facts?.officialPicker);
1083
+ }
1084
+
1070
1085
  // lib/bridge-rpc-constants.js
1071
1086
  var BRIDGE_RPC_CHANNEL = "/dsh-bridge";
1072
1087
  var BRIDGE_ENDPOINTS = {
@@ -5941,60 +5956,64 @@ function showRemoteWorkspaceDialog(rpcCall, onWorkspaceAdded, clientCtx, onPicke
5941
5956
  async function switchToWorkspace(wsId, wsPath) {
5942
5957
  if (isSubmitting) return;
5943
5958
  isSubmitting = true;
5944
- statusMessage = `\u6B63\u5728\u5207\u6362\u5DE5\u4F5C\u533A\u2026`;
5945
- isErrorMessage = false;
5946
- render();
5947
- if (typeof onPicked === "function" && wsPath) {
5948
- try {
5949
- onPicked(wsPath);
5950
- } catch (e) {
5951
- }
5952
- }
5953
- let switched = false;
5954
- if (clientCtx?.workspaces?.startSession && wsId) {
5955
- try {
5956
- clientCtx.workspaces.startSession(wsId);
5957
- switched = true;
5958
- } catch (e) {
5959
- console.warn("[dsh-bridge] startSession failed:", e);
5959
+ try {
5960
+ statusMessage = `\u6B63\u5728\u5207\u6362\u5DE5\u4F5C\u533A\u2026`;
5961
+ isErrorMessage = false;
5962
+ render();
5963
+ if (typeof onPicked === "function" && wsPath) {
5964
+ try {
5965
+ onPicked(wsPath);
5966
+ } catch (e) {
5967
+ }
5960
5968
  }
5961
- }
5962
- if (!switched && wsPath) {
5963
- try {
5964
- if (clientCtx?.workspaces?.create) {
5965
- const ws = await clientCtx.workspaces.create({ path: wsPath });
5966
- if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
5967
- clientCtx.workspaces.startSession(ws.workspaceId);
5968
- switched = true;
5969
- }
5969
+ let switched = false;
5970
+ if (clientCtx?.workspaces?.startSession && wsId) {
5971
+ try {
5972
+ clientCtx.workspaces.startSession(wsId);
5973
+ switched = true;
5974
+ } catch (e) {
5975
+ console.warn("[dsh-bridge] startSession failed:", e);
5970
5976
  }
5971
- if (!switched) {
5972
- const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
5973
- const res = raw?.value || raw;
5974
- if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
5975
- try {
5976
- clientCtx.workspaces.startSession(res.workspaceId);
5977
+ }
5978
+ if (!switched && wsPath) {
5979
+ try {
5980
+ if (clientCtx?.workspaces?.create) {
5981
+ const ws = await clientCtx.workspaces.create({ path: wsPath });
5982
+ if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
5983
+ clientCtx.workspaces.startSession(ws.workspaceId);
5977
5984
  switched = true;
5978
- } catch (e) {
5979
5985
  }
5980
5986
  }
5981
- if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
5982
- try {
5983
- clientCtx.sessions.open(res.sessionId);
5984
- switched = true;
5985
- } catch (e) {
5987
+ if (!switched) {
5988
+ const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
5989
+ const res = raw?.value || raw;
5990
+ if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
5991
+ try {
5992
+ clientCtx.workspaces.startSession(res.workspaceId);
5993
+ switched = true;
5994
+ } catch (e) {
5995
+ }
5996
+ }
5997
+ if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
5998
+ try {
5999
+ clientCtx.sessions.open(res.sessionId);
6000
+ switched = true;
6001
+ } catch (e) {
6002
+ }
5986
6003
  }
5987
6004
  }
6005
+ } catch (e) {
5988
6006
  }
5989
- } catch (e) {
5990
6007
  }
6008
+ statusMessage = `\u2713 \u5DF2\u5207\u6362\u81F3\u5DE5\u4F5C\u533A\uFF01`;
6009
+ render();
6010
+ setTimeout(() => {
6011
+ closeModal();
6012
+ document.body.classList.remove("dsh-drawer-open");
6013
+ }, 400);
6014
+ } finally {
6015
+ isSubmitting = false;
5991
6016
  }
5992
- statusMessage = `\u2713 \u5DF2\u5207\u6362\u81F3\u5DE5\u4F5C\u533A\uFF01`;
5993
- render();
5994
- setTimeout(() => {
5995
- closeModal();
5996
- document.body.classList.remove("dsh-drawer-open");
5997
- }, 400);
5998
6017
  }
5999
6018
  async function loadDirectory(targetPath) {
6000
6019
  if (isSubmitting) return;
@@ -6388,6 +6407,10 @@ function apply(ctx) {
6388
6407
  ctx.slots.inject(
6389
6408
  "conversation.hero.workspace.directoryFlow",
6390
6409
  () => ctx.slots.inject("sidebar.workspaces.directoryFlow", function* () {
6410
+ if (shouldYieldToOfficialPicker({
6411
+ local: isLocalEnvironment(),
6412
+ officialPicker: hasOfficialDirectoryPicker(ctx)
6413
+ })) return;
6391
6414
  yield ctx.slots.register(
6392
6415
  {
6393
6416
  name: "conversation.hero.workspace.directoryFlow",
package/client/index.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  queuePendingOperation, unlockAdmin, onUnlocked,
5
5
  fetchLoopbackTokenOnce,
6
6
  } from './unlock-manager.js'
7
+ import { hasOfficialDirectoryPicker, shouldYieldToOfficialPicker } from './picker-yield.js'
7
8
  // dsh-bridge 客户端插件:设置页「远程访问」面板
8
9
 
9
10
  // 兼容非 HTTPS 环境(如手机局域网 HTTP 访问):为非安全上下文补齐 crypto.randomUUID
@@ -4545,60 +4546,68 @@ function showRemoteWorkspaceDialog(rpcCall, onWorkspaceAdded, clientCtx, onPicke
4545
4546
  async function switchToWorkspace(wsId, wsPath) {
4546
4547
  if (isSubmitting) return;
4547
4548
  isSubmitting = true;
4548
- statusMessage = `正在切换工作区…`;
4549
- isErrorMessage = false;
4550
- render();
4551
-
4552
- if (typeof onPicked === 'function' && wsPath) {
4553
- try {
4554
- onPicked(wsPath);
4555
- } catch (e) {}
4556
- }
4549
+ try {
4550
+ statusMessage = `正在切换工作区…`;
4551
+ isErrorMessage = false;
4552
+ render();
4557
4553
 
4558
- let switched = false;
4559
- if (clientCtx?.workspaces?.startSession && wsId) {
4560
- try {
4561
- clientCtx.workspaces.startSession(wsId);
4562
- switched = true;
4563
- } catch (e) {
4564
- console.warn('[dsh-bridge] startSession failed:', e);
4554
+ if (typeof onPicked === 'function' && wsPath) {
4555
+ try {
4556
+ onPicked(wsPath);
4557
+ } catch (e) {}
4565
4558
  }
4566
- }
4567
4559
 
4568
- if (!switched && wsPath) {
4569
- try {
4570
- if (clientCtx?.workspaces?.create) {
4571
- const ws = await clientCtx.workspaces.create({ path: wsPath });
4572
- if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
4573
- clientCtx.workspaces.startSession(ws.workspaceId);
4574
- switched = true;
4575
- }
4560
+ let switched = false;
4561
+ if (clientCtx?.workspaces?.startSession && wsId) {
4562
+ try {
4563
+ clientCtx.workspaces.startSession(wsId);
4564
+ switched = true;
4565
+ } catch (e) {
4566
+ console.warn('[dsh-bridge] startSession failed:', e);
4576
4567
  }
4577
- if (!switched) {
4578
- const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
4579
- const res = raw?.value || raw;
4580
- if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
4581
- try {
4582
- clientCtx.workspaces.startSession(res.workspaceId);
4568
+ }
4569
+
4570
+ if (!switched && wsPath) {
4571
+ try {
4572
+ if (clientCtx?.workspaces?.create) {
4573
+ const ws = await clientCtx.workspaces.create({ path: wsPath });
4574
+ if (ws?.workspaceId && clientCtx?.workspaces?.startSession) {
4575
+ clientCtx.workspaces.startSession(ws.workspaceId);
4583
4576
  switched = true;
4584
- } catch (e) {}
4577
+ }
4585
4578
  }
4586
- if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
4587
- try {
4588
- clientCtx.sessions.open(res.sessionId);
4589
- switched = true;
4590
- } catch (e) {}
4579
+ if (!switched) {
4580
+ const raw = await authRpc(BRIDGE_ENDPOINTS.addRemoteWorkspace, { path: wsPath });
4581
+ const res = raw?.value || raw;
4582
+ if (res?.workspaceId && clientCtx?.workspaces?.startSession) {
4583
+ try {
4584
+ clientCtx.workspaces.startSession(res.workspaceId);
4585
+ switched = true;
4586
+ } catch (e) {}
4587
+ }
4588
+ if (!switched && res?.sessionId && clientCtx?.sessions?.open) {
4589
+ try {
4590
+ clientCtx.sessions.open(res.sessionId);
4591
+ switched = true;
4592
+ } catch (e) {}
4593
+ }
4591
4594
  }
4592
- }
4593
- } catch (e) {}
4594
- }
4595
+ } catch (e) {}
4596
+ }
4595
4597
 
4596
- statusMessage = `✓ 已切换至工作区!`;
4597
- render();
4598
- setTimeout(() => {
4599
- closeModal();
4600
- document.body.classList.remove('dsh-drawer-open');
4601
- }, 400);
4598
+ statusMessage = `✓ 已切换至工作区!`;
4599
+ render();
4600
+ setTimeout(() => {
4601
+ closeModal();
4602
+ document.body.classList.remove('dsh-drawer-open');
4603
+ }, 400);
4604
+ } finally {
4605
+ // 与 doSubmit 对称:本函数此前只置位、从不复位,导致点过一次「已注册工作区」后,
4606
+ // 同一弹窗实例的 isSubmitting 永久为 true,loadDirectory/doSubmit 的守卫
4607
+ // 让整个弹窗彻底无响应(点目录、点添加都没反应)。用 finally 兜住所有路径,
4608
+ // 包括 render() 抛错以及将来新增的提前 return。
4609
+ isSubmitting = false;
4610
+ }
4602
4611
  }
4603
4612
 
4604
4613
  async function loadDirectory(targetPath) {
@@ -5041,9 +5050,22 @@ function apply(ctx) {
5041
5050
 
5042
5051
  const injected = () => ({ pick: () => ctx.workspaces?.pickDirectory?.() });
5043
5052
 
5044
- // 注册至 DSH 原生目录选择 Slot(设置 priority: -10 覆盖原生 Electron 选择器,在远程/移动网页端生效)
5053
+ // 注册至 DSH 原生目录选择 Slot(priority: -10 覆盖原生 Electron 选择器,在远程/移动网页端生效)
5054
+ //
5055
+ // 但 DSH 0.1.5 起自带官方目录选择器(@deepseek-ai/dsh-host-directory-picker-auto
5056
+ // 按宿主能力分发 native / browse),且注册到完全相同的两个 Slot。DSH 的 slots 是
5057
+ // shadow 语义(动态注册的插件天然覆盖内置实现,详见 client/picker-yield.js),
5058
+ // 因此本机 + 官方 picker 可用时必须「不注册」才能让位 —— 用低优先级注册是无效的,
5059
+ // 否则本机 127.0.0.1 点「添加工作区」弹的是插件的远程抽屉(issue #28 第 2 条)。
5060
+ // 旧版 DSH(无官方 picker)与远程/移动访问继续由插件接管,后者保留管理密码解锁
5061
+ // 与 local_only 目录策略(换成官方 picker 会绕过插件的访问限制)。
5045
5062
  ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
5046
5063
  ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
5064
+ if (shouldYieldToOfficialPicker({
5065
+ local: isLocalEnvironment(),
5066
+ officialPicker: hasOfficialDirectoryPicker(ctx),
5067
+ })) return;
5068
+
5047
5069
  yield ctx.slots.register(
5048
5070
  {
5049
5071
  name: 'conversation.hero.workspace.directoryFlow',
@@ -0,0 +1,77 @@
1
+ // DSH 官方目录选择器的「让位」判定(issue #28 第 2 条)
2
+ //
3
+ // 背景:DSH 0.1.5 起自带官方目录选择器,且注册到与 dsh-bridge 完全相同的两个 Slot:
4
+ // conversation.hero.workspace.directoryFlow
5
+ // sidebar.workspaces.directoryFlow
6
+ // 后端由 @deepseek-ai/dsh-host-directory-picker-auto 按宿主事实分发:非回环绑定 /
7
+ // SSH / Linux 无 zenity·kdialog 等图形 chooser → browse(网页树形选择器),
8
+ // macOS·Windows 或 Linux 有 chooser → native(系统对话框)。
9
+ //
10
+ // 而 DSH 的 slots 是 shadow 语义:@deepseek-ai/dsh-cordis-client-runner 的注释明确
11
+ // "Page-local shadowing rank. A later registration receives a lower priority."
12
+ // "a dynamically registered entry is assigned a lower priority than the shipped
13
+ // one, which makes it the winner"
14
+ // 即数值越小越优先、后注册者胜。动态加载的 dsh-bridge 因此天然覆盖内置 picker,
15
+ // 叠加本插件显式写的 `priority: -10`,连本机(127.0.0.1 / Electron)也被插件自己的
16
+ // 远程目录选择器接管:点「添加工作区」弹的是插件的远程抽屉而非 DSH 官方选择器。
17
+ //
18
+ // 让位条件(两者同时成立才让位):
19
+ // 1. 本机访问(isLocalEnvironment())—— 本机有完整权限,官方 picker 直接可用;
20
+ // 2. DSH 已自带官方 picker —— 旧版 DSH 没有,必须继续由插件兜底,
21
+ // 否则本机点「添加工作区」将彻底没有反应。
22
+ // 远程 / 移动访问一律继续由插件接管:那里需要管理密码解锁与 local_only 目录策略,
23
+ // 不能换成官方 picker(会绕过插件的访问限制)。
24
+ //
25
+ // 为什么探测用 ctx.get() 而不是 ctx.uiWorkspace:
26
+ // cordis 本体对**未在插件 inject 中声明**的服务做属性访问会直接抛错:
27
+ // cannot get property "uiWorkspace" without inject (cordis/lib/index.js)
28
+ // —— dsh-bridge 的 client half 是静态 client 插件,其 ctx 是真 cordis Context
29
+ // (不是动态包的 runner guard facade),所以拦在这里的是 cordis 本体的规则。
30
+ // 而 `ctx.get(name)` 是允许的「可选查找」,不要求声明。
31
+ // 又不能把 uiWorkspace 写进 inject:旧版 DSH(无该 seat)会因此把整个插件 park 掉
32
+ // (provider 缺失即不 apply),所以只能用 ctx.get() 探测。
33
+ //
34
+ // ⚠️ 已知时序依赖(独立验收记录,真机 6/6 冷启动实测未触发):
35
+ // cordis 的 ctx.get() 在 provider fiber 尚未进入 ACTIVE 时会返回 undefined。理论上若
36
+ // 本判定早于 ui-workspace 插件 apply 完成,就会把「有官方 picker」误判为「无」而仍然
37
+ // 注册,让位失效。当前稳定是因为 dsh-bridge 自身 inject 的 workspaces / sessions 由
38
+ // roster 中排在 ui-workspace **之后**的插件提供,插件真正激活时 ui-workspace 早已
39
+ // apply 完成 —— 这是对 roster 顺序的**隐式耦合,不是显式保证**。若将来调整 roster
40
+ // 顺序或去掉 workspaces 依赖,需重新评估(加固思路:注册后等 uiWorkspace 可用时
41
+ // 再撤销自己的注册)。
42
+
43
+ /** 官方 workspace seat 的服务名(DSH 0.1.5 起提供 pickDirectory) */
44
+ export const OFFICIAL_WORKSPACE_SEAT = 'uiWorkspace';
45
+
46
+ /**
47
+ * 探测 DSH 是否已提供官方目录选择器。
48
+ *
49
+ * 用 `ctx.get()` 做可选查找:seat 不存在(旧版 DSH)、宿主未暴露 pickDirectory、
50
+ * 查询或属性读取抛错时一律返回 false —— 探测本身绝不外抛影响插件加载。
51
+ *
52
+ * @param {{ get?: (name: string) => unknown }} [ctx] cordis 客户端 context
53
+ * @returns {boolean} 官方 picker 是否可用
54
+ */
55
+ export function hasOfficialDirectoryPicker(ctx) {
56
+ try {
57
+ if (typeof ctx?.get !== 'function') return false;
58
+ const seat = ctx.get(OFFICIAL_WORKSPACE_SEAT);
59
+ // typeof 也放在 try 内:seat 可能是带异常 getter 的对象或 Proxy,
60
+ // 读 pickDirectory 本身就可能抛错。
61
+ return typeof seat?.pickDirectory === 'function';
62
+ } catch {
63
+ // ctx.get 抛错(宿主异常),或属性读取抛错(异常 getter / Proxy):
64
+ // 一律按「无官方 picker」处理,保持插件原有兜底行为,探测绝不外抛。
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 是否应让位给 DSH 官方目录选择器。
71
+ *
72
+ * @param {{ local?: boolean, officialPicker?: boolean }} [facts]
73
+ * @returns {boolean} true = 不注册插件选择器,本机交给 DSH 官方处理
74
+ */
75
+ export function shouldYieldToOfficialPicker(facts) {
76
+ return Boolean(facts?.local) && Boolean(facts?.officialPicker);
77
+ }
@@ -1,11 +1,12 @@
1
1
  import { spawn, execSync } from 'node:child_process';
2
- import { createWriteStream, createReadStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { createWriteStream, createReadStream, existsSync, mkdirSync, readFileSync, statSync, renameSync, writeFileSync } from 'node:fs';
3
3
  import { chmod, stat, unlink, rename } from 'node:fs/promises';
4
4
  import { homedir, platform, arch } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { join, dirname } from 'node:path';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { get as httpsGet } from 'node:https';
9
+ import { get as httpGet } from 'node:http';
9
10
 
10
11
  const CLOUDFLARED_VERSION = '2024.10.0';
11
12
  const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 分钟
@@ -15,6 +16,47 @@ const RETRY_BASE_MS = 5 * 1000; // 自愈退避起点 5s
15
16
  const RETRY_MAX_MS = 5 * 60 * 1000; // 自愈退避封顶 5min
16
17
  const DEFAULT_MAX_RETRIES = 12; // 连续失败超过该次数转为 error,不再无限重试
17
18
 
19
+ // ── 运行时健康探针 ────────────────────────────────────────────────────────
20
+ // 事故背景:cloudflared 与 Cloudflare 边缘的连接全部掉光后进程仍存活("半死/假死"),
21
+ // 既不退出也不报错,因此只监听 exit 的旧自愈逻辑永远不会触发,隧道会静默失效
22
+ // (现象:公网 530 + 正文 error code: 1033,持续 2 天无人发现)。
23
+ //
24
+ // 关键约束(一手源码 cloudflared 2024.10.0 `tunnelstate/conntracker.go`):/ready 的
25
+ // readyConnections 取自 CountActiveConns(),而 Disconnected / Reconnecting /
26
+ // RegisteringTunnel / Unregistering 全部把 IsConnected 置 false —— 即
27
+ // **正常重连期间 /ready 同样是 503**。因此单次 503 无法区分"连接器假死"与
28
+ // "网络暂时全断、cloudflared 正按自身退避重连"。唯一能区分的是"持续多久",
29
+ // 故采用两级升级,且强制重建永不进入终态(见 _scheduleRestart 的 terminal 选项)。
30
+ const HEALTH_PROBE_INTERVAL_MS = 30 * 1000; // 探活间隔
31
+ const HEALTH_PROBE_TIMEOUT_MS = 5 * 1000; // 单次探活总时限(远程调用防失控)
32
+ const HEALTH_DEGRADED_THRESHOLD = 3; // 连续失败达此数 → 面板可见降级(约 90s 发现)
33
+ const HEALTH_RESTART_THRESHOLD = 10; // 连续失败达此数 → 终止进程强制重建(约 5min)
34
+
35
+ // ── cloudflared 输出落盘 ──────────────────────────────────────────────────
36
+ // 事故时 journal 里查不到任何 cloudflared 日志,导致事后无法定位断连时间点与原因。
37
+ export const CLOUDFLARED_LOG_NAME = 'cloudflared.log';
38
+ const CLOUDFLARED_LOG_MAX_BYTES = 5 * 1024 * 1024; // 超限轮转为 .1(覆盖旧的)
39
+
40
+ // 从 cloudflared 自报日志中解析 metrics 服务地址(运行时健康探针的唯一入口)。
41
+ // 真实输出形如:INF Starting metrics server on 127.0.0.1:41143/metrics
42
+ // 解析不到 → 显式告警并跳过探活(绝不静默降级,也绝不因探测不到而误杀进程)。
43
+ //
44
+ // 必须在这里就把非法地址拦掉:node:http 的 get() 对畸形 URL 会**同步抛错**
45
+ // (ERR_INVALID_URL),异常会穿透探针 Promise 造成未处理 rejection ——
46
+ // Node 默认 unhandled-rejections=throw 会直接结束整个 DSH 主进程,
47
+ // 也就是说"本该保护服务的探针"反而会把服务干掉。端口必须落在 1..65535。
48
+ export function parseMetricsAddress(text) {
49
+ if (!text) return null;
50
+ const m = /Starting metrics server on (\S+?)\/metrics/.exec(text);
51
+ if (!m) return null;
52
+ const addr = m[1];
53
+ const parts = /^([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\]):(\d{1,5})$/.exec(addr);
54
+ if (!parts) return null;
55
+ const port = Number(parts[2]);
56
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
57
+ return addr;
58
+ }
59
+
18
60
  // ── 确定性失败特征:cloudflared 因配置/用法错误退出(非网络瞬态)─────────
19
61
  // 这类失败重试无意义,识别后直接置 error 让用户看到明确原因,而不是
20
62
  // 误判成"意外退出"退避重连 N 次(issue #35 作者建议)。
@@ -138,10 +180,26 @@ export class CloudflaredManager {
138
180
  * @param {number} [opts.handshakeTimeoutMs=90000] 等待隧道就绪的握手超时(测试可注入小值)。
139
181
  * @param {object} [opts.spawnOptions] 透传给 child_process.spawn 的额外选项(测试注入用,
140
182
  * 如 Windows 下需 shell:true 才能运行 .cmd mock;生产不传)。
183
+ * @param {number} [opts.healthProbeIntervalMs=30000] 就绪后运行时探活间隔。
184
+ * @param {number} [opts.healthProbeTimeoutMs=5000] 单次探活总时限。
185
+ * @param {number} [opts.healthDegradedThreshold=3] 连续失败达此数即在面板上显示降级
186
+ * (默认约 90s——这是"发现故障"的时点)。
187
+ * @param {number} [opts.healthRestartThreshold=10] 连续失败达此数才终止进程强制重建
188
+ * (默认约 5min)。必须显著大于 degraded 阈值:/ready 无法区分"假死"与
189
+ * "正在自行重连",只有持续时长能区分,过早重建会误杀本可自愈的连接器。
190
+ * @param {string|null} [opts.logFilePath=null] cloudflared stdout/stderr 落盘路径;
191
+ * null 表示不落盘(默认)。生产由调用方显式传入(见 lib/index.js),
192
+ * 避免库的默认行为在单测里往真实运维日志目录写测试内容。
193
+ * @param {number} [opts.logMaxBytes=5242880] 单文件超限后轮转为 `<logFilePath>.1`。
141
194
  */
142
195
  constructor({ port, home, token, hostname, onStateChange, logger,
143
196
  binaryPath, retryPolicy, noAutoupdate = true, binaryVersion = CLOUDFLARED_VERSION,
144
- handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS, spawnOptions = null }) {
197
+ handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS, spawnOptions = null,
198
+ healthProbeIntervalMs = HEALTH_PROBE_INTERVAL_MS,
199
+ healthProbeTimeoutMs = HEALTH_PROBE_TIMEOUT_MS,
200
+ healthDegradedThreshold = HEALTH_DEGRADED_THRESHOLD,
201
+ healthRestartThreshold = HEALTH_RESTART_THRESHOLD,
202
+ logFilePath = null, logMaxBytes = CLOUDFLARED_LOG_MAX_BYTES }) {
145
203
  this.port = port;
146
204
  this.home = home || join(homedir(), '.dsh-bridge');
147
205
  this.token = token ? String(token).trim() : null;
@@ -170,18 +228,42 @@ export class CloudflaredManager {
170
228
  // 测试注入的 spawn 选项(如 Windows 的 shell);生产为 null 不影响默认行为
171
229
  this._spawnOptions = spawnOptions || null;
172
230
 
231
+ // 运行时健康探针参数(测试可注入小值)
232
+ this.healthProbeIntervalMs = healthProbeIntervalMs;
233
+ this.healthProbeTimeoutMs = healthProbeTimeoutMs;
234
+ this.healthDegradedThreshold = healthDegradedThreshold;
235
+ this.healthRestartThreshold = healthRestartThreshold;
236
+ this.logFilePath = logFilePath || null;
237
+ this.logMaxBytes = logMaxBytes;
238
+
173
239
  this.process = null;
174
240
  this.url = null;
175
241
  this.binaryPath = null;
176
242
  this._stopped = false;
177
243
  this._retryTimer = null;
178
244
  this._restartCount = 0; // 连续启动失败/意外退出次数,就绪后清零
245
+
246
+ this._metricsAddress = null; // 本次 spawn 的 cloudflared metrics 地址(探活入口)
247
+ this._metricsParseTail = ''; // metrics 日志行的跨 chunk 拼接缓冲
248
+ this._probeTimer = null;
249
+ this._probeFailures = 0;
250
+ this._probeInFlight = false; // 防重叠探活堆积
251
+ this._probeCount = 0; // 已完成的探活次数(供测试断言探针确实在跑)
252
+ this._probeLogFailures = 0; // 探针日志通道自身抛错的次数(供自查是否丢过日志)
253
+ this._spawnSeq = 0; // spawn 代数:探活结果据此作废,避免算到新进程头上
254
+ this._healthRecovering = false; // 健康探针触发的重建链路中(此期间不允许终态)
255
+ this._readyState = null; // 最近一次 ready 状态,供探活恢复后回写
256
+ this._logStream = null;
257
+ this._logBytesWritten = 0;
258
+ this._redactCarry = ''; // 跨 chunk 的脱敏残留(防 Token 被切分而漏网)
179
259
  }
180
260
 
181
261
  // 异步启动,立即返回——调用方不需要 await
182
262
  start() {
183
263
  this._stopped = false;
184
264
  this._restartCount = 0;
265
+ this._healthRecovering = false;
266
+ this._probeInFlight = false;
185
267
  if (this._retryTimer) {
186
268
  clearTimeout(this._retryTimer);
187
269
  this._retryTimer = null;
@@ -206,6 +288,9 @@ export class CloudflaredManager {
206
288
  }
207
289
 
208
290
  // 退避自愈调度:唯一入口在"启动失败"与"就绪后意外退出"。stop()/超限 终止。
291
+ // _healthRecovering 期间(健康探针判定假死后触发的整条重建链路,含重启后握手失败)
292
+ // 永不进入终态——必须覆盖整条链路而不只是那次 kill:重启时网络往往仍不通,
293
+ // 新进程握手失败仍会走这里,若在此处转 error 就把"可自愈的断网"变成人工故障。
209
294
  _scheduleRestart(reason) {
210
295
  if (this._stopped) return; // 用户已停止,绝不复活
211
296
  if (this._retryTimer) return; // 已在倒计时中,避免叠加调度
@@ -215,15 +300,25 @@ export class CloudflaredManager {
215
300
  }
216
301
  this._restartCount++;
217
302
  if (this._restartCount > this.retry.maxRetries) {
218
- this._setState('error', `${reason}(已自动重试 ${this.retry.maxRetries} 次仍失败,请检查网络/Token,或点击「关闭」停止)`);
219
- return;
303
+ if (!this._healthRecovering) {
304
+ this._setState('error', `${reason}(已自动重试 ${this.retry.maxRetries} 次仍失败,请检查网络/Token,或点击「关闭」停止)`);
305
+ return;
306
+ }
307
+ // 健康探针触发的重建链路永不进入终态:长时断网时 cloudflared 自身也是无限退避
308
+ // 重连,这里保持同样语义。退避仍随 _restartCount 增长并封顶 maxDelayMs,
309
+ // 不会退化成高频重启风暴;重新就绪(tryResolve)后该标记自动清除。
220
310
  }
221
311
  const delay = Math.min(
222
312
  this.retry.baseDelayMs * 2 ** (this._restartCount - 1),
223
313
  this.retry.maxDelayMs
224
314
  );
225
- this._setState('reconnecting',
226
- `${reason},${Math.max(1, Math.round(delay / 1000))}s 后自动重连(第 ${this._restartCount}/${this.retry.maxRetries} 次)`);
315
+ // 文案必须如实:健康重建链路会故意突破 maxRetries 持续重试,
316
+ // 若沿用"第 N/M 次"会出现"第 9/2 次"这种自相矛盾的提示,反而误导排障。
317
+ const delayText = delay >= 1000 ? `${Math.round(delay / 1000)}s` : `${delay}ms`;
318
+ const beyondCap = this._restartCount > this.retry.maxRetries;
319
+ this._setState('reconnecting', beyondCap
320
+ ? `${reason},${delayText} 后继续自动重连(已重试 ${this._restartCount} 次;隧道长时间无法建立,请检查网络与 Token)`
321
+ : `${reason},${delayText} 后自动重连(第 ${this._restartCount}/${this.retry.maxRetries} 次)`);
227
322
  this._retryTimer = setTimeout(() => {
228
323
  this._retryTimer = null;
229
324
  this._restartAttempt();
@@ -402,6 +497,14 @@ export class CloudflaredManager {
402
497
  });
403
498
  this.process = proc;
404
499
 
500
+ // 本次 spawn 独立状态:metrics 地址与日志流都随进程生命周期重建;
501
+ // 代数(_spawnSeq)用于让飞行中的旧探活结果作废——重启后 cloudflared 很
502
+ // 可能复用同一个 metrics 端口,仅比对地址字符串不足以识别"这是新进程"。
503
+ this._metricsAddress = null;
504
+ this._metricsParseTail = '';
505
+ this._spawnSeq++;
506
+ this._openLogStream();
507
+
405
508
  let resolved = false;
406
509
  let timeoutTimer = null;
407
510
  // 累积 stderr 尾部(供 exit 时判断确定性失败:CLI 用法错误 / 配置错误)
@@ -418,8 +521,10 @@ export class CloudflaredManager {
418
521
  clearTimeout(timeoutTimer);
419
522
  timeoutTimer = null;
420
523
  }
421
- // 就绪即证明链路可用,连续失败计数清零
524
+ // 就绪即证明链路可用:连续失败计数清零,并退出"健康重建"模式
422
525
  this._restartCount = 0;
526
+ this._healthRecovering = false;
527
+ this._startHealthProbe(); // 就绪后才探活:启动阶段的问题由握手超时/exit 负责
423
528
  resolve();
424
529
  }
425
530
  };
@@ -460,10 +565,34 @@ export class CloudflaredManager {
460
565
  }
461
566
  };
462
567
 
463
- proc.stdout.on('data', (d) => parseUrl(d.toString()));
568
+ // metrics 地址必须先于就绪文本被记录:cloudflared 是先起 metrics 服务再注册连接。
569
+ // 两处稳健性处理:
570
+ // 1) 该日志行本身也可能被 chunk 切断 → 用滚动缓冲拼接后再匹配;
571
+ // 2) 地址可能"迟到"(就绪时还没解析到)→ 一旦补上就补启探活,否则安全网永久不启用。
572
+ const captureMetricsAddress = (text) => {
573
+ if (this._metricsAddress) return;
574
+ const buffered = (this._metricsParseTail + text).slice(-512);
575
+ this._metricsParseTail = buffered;
576
+ const addr = parseMetricsAddress(buffered);
577
+ if (!addr) return;
578
+ this._metricsAddress = addr;
579
+ if (resolved && !this._stopped && !this._probeTimer) {
580
+ this._probeLog('info', 'metrics 地址在就绪后才解析到,补启运行时健康探活: %s', addr);
581
+ this._startHealthProbe();
582
+ }
583
+ };
584
+
585
+ proc.stdout.on('data', (d) => {
586
+ const text = d.toString();
587
+ this._appendCloudflaredLog(text);
588
+ captureMetricsAddress(text);
589
+ parseUrl(text);
590
+ });
464
591
  proc.stderr.on('data', (d) => {
465
592
  const text = d.toString();
466
593
  this.logger?.debug('cloudflared: %s', text.trim());
594
+ this._appendCloudflaredLog(text);
595
+ captureMetricsAddress(text);
467
596
  stderrTail = (stderrTail + text).slice(-2000); // 只保留尾部 2KB
468
597
  parseUrl(text);
469
598
  if (text.includes('Registered tunnel') && !resolved) {
@@ -476,6 +605,9 @@ export class CloudflaredManager {
476
605
  clearTimeout(timeoutTimer);
477
606
  timeoutTimer = null;
478
607
  }
608
+ // 探活定时器与日志流都绑定本次 spawn,退出即回收(自愈会重新建立)
609
+ this._stopHealthProbe();
610
+ this._closeLogStream();
479
611
  // exit 时"仍是当前进程"才允许清理引用 + 调度自愈。
480
612
  // 自愈已 spawn 新进程后,旧进程迟到的 exit 不满足 stillCurrent → 静默,
481
613
  // 避免"新进程连接中、旧 exit 又触发一次重启"的竞态(否则会叠出第三进程)。
@@ -491,7 +623,8 @@ export class CloudflaredManager {
491
623
  if (isFatalCloudflaredError(stderrTail)) err.fatal = true;
492
624
  reject(err);
493
625
  } else if (!this._stopped && stillCurrent) {
494
- // 就绪后的意外退出(崩溃 / OOM / 误杀 / autoupdate 残留自替换)→ 退避自愈
626
+ // 就绪后的意外退出(崩溃 / OOM / 误杀 / autoupdate 残留自替换 / 健康探针判定假死)
627
+ // → 退避自愈。是否允许终态由 _scheduleRestart 依 _healthRecovering 判定。
495
628
  this._scheduleRestart(`cloudflared 进程意外退出 (code=${code ?? ''}${signal ? `, ${signal}` : ''})`);
496
629
  } else {
497
630
  this._setState('idle', '');
@@ -503,6 +636,8 @@ export class CloudflaredManager {
503
636
  clearTimeout(timeoutTimer);
504
637
  timeoutTimer = null;
505
638
  }
639
+ this._stopHealthProbe();
640
+ this._closeLogStream();
506
641
  if (!resolved) reject(err);
507
642
  });
508
643
 
@@ -518,7 +653,298 @@ export class CloudflaredManager {
518
653
  });
519
654
  }
520
655
 
521
- _setState(phase, detail) {
656
+ // ── cloudflared 输出落盘(含 Token 脱敏与大小轮转)──────────────────────
657
+
658
+ // Token 属敏感凭据,落盘前一律替换;不依赖上游是否会自行脱敏。
659
+ //
660
+ // 关键:stderr 的 'data' 事件不保证按行对齐,Token 可能被切分在两个块之间。
661
+ // 这里用**逐字符扫描**而不是"整块 split 替换":
662
+ // - 任意位置命中完整 Token → 替换为 ***(完整 Token 绝不会明文落盘);
663
+ // - 只在"剩余不足一个 Token 长度、且恰好是 Token 前缀"时才扣住,等下一块补齐。
664
+ // 不能退化成"扣掉最长的 Token 前缀后缀"这种简化写法:若 Token 自身存在 border
665
+ // (例如首尾字符相同),完整 Token 结尾处也会被误判成前缀而扣下一截,结果前半段
666
+ // 与扣下的那截先后写出、在文件里重新拼成完整 Token(已实测的泄漏路径)。
667
+ _redactToken(text) {
668
+ if (!this.token) return text;
669
+ const token = this.token;
670
+ const L = token.length;
671
+ const combined = this._redactCarry + text;
672
+ this._redactCarry = '';
673
+ let out = '';
674
+ let i = 0;
675
+ while (i < combined.length) {
676
+ if (i + L <= combined.length && combined.startsWith(token, i)) {
677
+ out += '***';
678
+ i += L;
679
+ continue;
680
+ }
681
+ if (i + L > combined.length) {
682
+ const rest = combined.slice(i);
683
+ if (token.startsWith(rest)) {
684
+ this._redactCarry = rest; // 可能是被切开的 Token 前半段
685
+ break;
686
+ }
687
+ }
688
+ out += combined[i];
689
+ i++;
690
+ }
691
+ return out;
692
+ }
693
+
694
+ _openLogStream() {
695
+ this._closeLogStream(); // 幂等:自愈重启时先关掉上一个进程的流,避免句柄堆积
696
+ if (!this.logFilePath) return;
697
+ try {
698
+ mkdirSync(dirname(this.logFilePath), { recursive: true });
699
+ // 同步把文件建出来:createWriteStream 是异步 open,若不在此时落地,
700
+ // 紧随其后的轮转判定会遇到"文件还没存在"而 rename 落空(运行中轮转失效)。
701
+ writeFileSync(this.logFilePath, '', { flag: 'a' });
702
+ this._rotateIfOversize();
703
+ this._logBytesWritten = existsSync(this.logFilePath) ? statSync(this.logFilePath).size : 0;
704
+ } catch (err) {
705
+ // 目录创建/建文件/取大小失败:不阻断日志写入,但必须让问题可见
706
+ this.logger?.warn('cloudflared 日志目录或文件准备失败(本次可能不落盘): %s', err.message);
707
+ }
708
+ try {
709
+ const stream = createWriteStream(this.logFilePath, { flags: 'a' });
710
+ stream.on('error', (err) => {
711
+ // 流已不可写:置空引用,避免后续继续往死流写并重复刷同一条告警
712
+ this._logStream = null;
713
+ this.logger?.warn('cloudflared 日志写入失败: %s', err.message);
714
+ });
715
+ this._logStream = stream;
716
+ } catch (err) {
717
+ this._logStream = null;
718
+ this.logger?.warn('无法打开 cloudflared 日志文件 %s: %s', this.logFilePath, err.message);
719
+ }
720
+ }
721
+
722
+ _rotateIfOversize() {
723
+ if (!this.logFilePath) return;
724
+ try {
725
+ if (existsSync(this.logFilePath) && statSync(this.logFilePath).size >= this.logMaxBytes) {
726
+ renameSync(this.logFilePath, `${this.logFilePath}.1`);
727
+ this._logBytesWritten = 0;
728
+ }
729
+ } catch (err) {
730
+ this.logger?.warn('cloudflared 日志轮转失败(继续追加写): %s', err.message);
731
+ }
732
+ }
733
+
734
+ _closeLogStream() {
735
+ // 无论流是否可用,carry 都必须清空——否则残留的 Token 片段会污染下一个
736
+ // 进程的第一行日志(流先报 error 置空、随后 close 早退的场景实测可复现)。
737
+ const carry = this._redactCarry;
738
+ this._redactCarry = '';
739
+ if (!this._logStream) return;
740
+ // 流关闭时仍扣着的必然是 Token 的一个前缀(见 _redactToken):以占位符收尾。
741
+ // 取舍说明:这会牺牲最多 token.length-1 个正常字符的日志保真度,
742
+ // 但换来"任何 Token 片段都不落盘"。极端情况下宁可少一行尾巴,不可留密钥片段。
743
+ if (carry) this._logStream.write('***');
744
+ this._logStream.end();
745
+ this._logStream = null;
746
+ }
747
+
748
+ _appendCloudflaredLog(text) {
749
+ if (!this._logStream || !text) return;
750
+ const out = this._redactToken(text);
751
+ if (!out) return;
752
+ const bytes = Buffer.byteLength(out, 'utf8');
753
+ // 运行中也检查轮转:只在 spawn 时检查会让长寿命进程把日志写爆。
754
+ // 这里按"本次运行已写入字节数"判定,不依赖 statSync——写流是异步的,
755
+ // 刚写完就 stat 可能仍是旧大小,会造成轮转判定抖动。
756
+ if (this._logBytesWritten + bytes >= this.logMaxBytes) {
757
+ this._closeLogStream();
758
+ this._renameToRotated();
759
+ this._openLogStream();
760
+ if (!this._logStream) return;
761
+ }
762
+ this._logStream.write(out);
763
+ this._logBytesWritten += bytes;
764
+ }
765
+
766
+ _renameToRotated() {
767
+ if (!this.logFilePath) return;
768
+ try {
769
+ if (existsSync(this.logFilePath)) renameSync(this.logFilePath, `${this.logFilePath}.1`);
770
+ } catch (err) {
771
+ this.logger?.warn('cloudflared 日志轮转失败(继续追加写): %s', err.message);
772
+ }
773
+ this._logBytesWritten = 0;
774
+ }
775
+
776
+ // ── 运行时健康探针 ──────────────────────────────────────────────────────
777
+
778
+ // 就绪后启动探活。metrics 地址解析失败时显式告警并跳过——绝不静默降级,
779
+ // 也绝不在没有可信探活通道的情况下误杀进程;同时把降级写进面板可见的状态详情,
780
+ // 否则"整个安全网失效"只会是一行没人看的 journal warning(本次事故的教训)。
781
+ // 探针专用安全日志:探针跑在定时器与子进程数据回调里,异常没有任何上层接住,
782
+ // 一旦 logger 自身抛错就会变成未处理 rejection —— Node 默认 unhandled-rejections=throw
783
+ // 会直接结束宿主进程,即"本该保护服务的探针把服务干掉"。故此处绝不允许抛出。
784
+ _probeLog(level, msg, ...args) {
785
+ try {
786
+ this.logger?.[level]?.(msg, ...args);
787
+ } catch (logErr) {
788
+ // 日志通道本身故障:此处再抛就会拖垮宿主进程,故刻意吞掉。
789
+ // 这不是"静默降级"——要输出的降级信息本身就在这条已损坏的通道上,无处可写;
790
+ // 且关键降级仍经 _setState → 面板独立可见,不依赖 logger。
791
+ // 注意:该计数目前只在单测中被读取,生产尚无出口(后续可接入面板/统计),
792
+ // 因此不要把它当作"生产可自查"的手段。
793
+ this._probeLogFailures++;
794
+ }
795
+ }
796
+
797
+ _startHealthProbe() {
798
+ this._stopHealthProbe();
799
+ if (!this._metricsAddress) {
800
+ const detail = `${this._readyState?.detail || '隧道已建立'};⚠️ 运行时健康探活未启用`
801
+ + '(未识别到 cloudflared metrics 地址),连接器若静默假死将无法自动发现';
802
+ this._probeLog('warn', '未从 cloudflared 输出识别到 metrics 地址,本次运行跳过健康探活'
803
+ + '(连接器若静默假死将无法自动发现,请检查 cloudflared 版本是否变更了 metrics 日志文案)');
804
+ // record:false —— 这是降级提示,不能污染"纯净的 ready 详情",
805
+ // 否则地址迟到后补启探活时将无法把面板文案恢复成正常状态。
806
+ this._setState('ready', detail, { record: false });
807
+ return;
808
+ }
809
+ // 地址迟到后补启探活:把面板文案恢复为正常的 ready 详情
810
+ if (this._readyState) this._setState(this._readyState.phase, this._readyState.detail);
811
+ this._probeTimer = setInterval(() => {
812
+ // 必须显式 catch:探针里任何逃逸的异常(如畸形 metrics 地址让 http.get 同步抛错)
813
+ // 都会变成未处理 rejection,而 Node 默认 unhandled-rejections=throw 会直接
814
+ // 结束整个 DSH 主进程 —— 那正是探针本该防止的"服务整体消失"。
815
+ // 用 _probeLog 而非 logger 直调:catch 处理器自身也不允许再抛。
816
+ this._probeOnce().catch((err) => {
817
+ this._probeLog('warn', '健康探活出现未预期异常(已忽略,不影响连接器运行): %s', err?.message ?? err);
818
+ });
819
+ }, this.healthProbeIntervalMs);
820
+ this._probeTimer.unref?.(); // 不因探活定时器而阻止进程退出
821
+ }
822
+
823
+ _stopHealthProbe() {
824
+ if (this._probeTimer) {
825
+ clearInterval(this._probeTimer);
826
+ this._probeTimer = null;
827
+ }
828
+ this._probeFailures = 0;
829
+ }
830
+
831
+ async _probeOnce() {
832
+ // 防重叠:单次探活最长 healthProbeTimeoutMs,若上一轮还没结论就不再叠加请求
833
+ if (this._probeInFlight) return;
834
+ const addr = this._metricsAddress;
835
+ if (!addr) return;
836
+ const seq = this._spawnSeq; // spawn 代数:据此识别"结果属于哪个进程"
837
+
838
+ this._probeInFlight = true;
839
+ let result;
840
+ try {
841
+ result = await this._checkTunnelReady(addr);
842
+ } catch (err) {
843
+ // 兜底:探针内部任何异常都不得向外逃逸(见 setInterval 处的说明)
844
+ result = { healthy: false, reason: `探针内部异常: ${err?.message ?? err}` };
845
+ } finally {
846
+ this._probeInFlight = false;
847
+ }
848
+ this._probeCount++;
849
+
850
+ // 探活期间可能已被 stop()、或已自愈重启到新进程 → 结果作废。
851
+ // 必须比对代数而非地址:重启后 cloudflared 很可能复用同一个 metrics 端口。
852
+ if (this._stopped || this._spawnSeq !== seq) return;
853
+
854
+ if (result.healthy) {
855
+ if (this._probeFailures >= this.healthDegradedThreshold) {
856
+ this._probeLog('info', '隧道健康探活已自行恢复(此前连续失败 %d 次)', this._probeFailures);
857
+ if (this._readyState) this._setState(this._readyState.phase, this._readyState.detail);
858
+ }
859
+ this._probeFailures = 0;
860
+ return;
861
+ }
862
+
863
+ this._probeFailures++;
864
+ const fails = this._probeFailures;
865
+ const why = result.reason || '未知原因';
866
+
867
+ if (fails === this.healthDegradedThreshold) {
868
+ // 第一级:可见降级。此时不杀进程——/ready 为 503 也可能只是 cloudflared
869
+ // 正在按自身退避重连,过早介入反而会把本可自愈的故障变成人工故障。
870
+ this._probeLog('warn', '隧道健康探活连续 %d 次失败(%s):连接器可能已无健康边缘连接,'
871
+ + '转入降级观察,达 %d 次将强制重建', fails, why, this.healthRestartThreshold);
872
+ this._setState('reconnecting', `健康探活连续 ${fails} 次失败:连接器已无健康边缘连接,`
873
+ + `正在观察能否自行恢复(${fails}/${this.healthRestartThreshold})`);
874
+ } else if (fails < this.healthDegradedThreshold) {
875
+ this._probeLog('warn', '隧道健康探活失败(第 %d/%d 次): %s;原因: %s',
876
+ fails, this.healthRestartThreshold, addr, why);
877
+ } else {
878
+ this._probeLog('warn', '隧道健康探活仍失败(第 %d/%d 次): %s', fails, this.healthRestartThreshold, why);
879
+ }
880
+
881
+ if (fails < this.healthRestartThreshold) return;
882
+
883
+ // 第二级:持续不恢复 → 判定假死,强制重建。
884
+ // 置 _healthRecovering:整条重建链路(含重启后握手失败)都不允许进入终态。
885
+ this._probeLog('error', '隧道连接器连续 %d 次探活失败(约 %d 分钟):判定为假死,'
886
+ + '终止进程以触发重建;该重建不设终态上限,网络恢复后仍会自愈',
887
+ fails, Math.round(fails * this.healthProbeIntervalMs / 60000));
888
+ this._stopHealthProbe();
889
+ this._healthRecovering = true;
890
+ this._terminateProcess(); // 不自行重启:交给 exit → _scheduleRestart 既有链路
891
+ }
892
+
893
+ // 探活判据:HTTP 200 且 readyConnections > 0(与 cloudflared 官方 /ready 语义一致)。
894
+ // 返回 { healthy, reason }:reason 会进入 warn 级日志,生产关闭 debug 时也能看到原因。
895
+ // 注意:http 的 timeout 选项只是 **socket 不活动** 超时,不是总时限——对"持续滴流
896
+ // 字节"的响应永不触发,因此这里额外加一个绝对截止时间兜底。
897
+ _checkTunnelReady(addr) {
898
+ return new Promise((resolve) => {
899
+ let settled = false;
900
+ let deadline = null;
901
+ let req = null;
902
+ const done = (healthy, reason) => {
903
+ if (settled) return;
904
+ settled = true;
905
+ if (deadline) clearTimeout(deadline);
906
+ try { req?.destroy(); } catch { /* 已结束的请求 destroy 无害 */ }
907
+ resolve({ healthy, reason });
908
+ };
909
+
910
+ deadline = setTimeout(() => {
911
+ done(false, `探活超过总时限 ${this.healthProbeTimeoutMs}ms`);
912
+ }, this.healthProbeTimeoutMs);
913
+
914
+ // httpGet 对畸形 URL 会同步抛错;虽然 parseMetricsAddress 已在入口校验,
915
+ // 这里再兜一层,确保该函数永不向外抛(否则会变成未处理 rejection 拖垮主进程)。
916
+ try {
917
+ req = httpGet(`http://${addr}/ready`, { timeout: this.healthProbeTimeoutMs }, (res) => {
918
+ let body = '';
919
+ res.setEncoding('utf8');
920
+ res.on('data', (chunk) => { body += chunk; });
921
+ res.on('end', () => {
922
+ if (res.statusCode !== 200) {
923
+ done(false, `HTTP ${res.statusCode}`);
924
+ return;
925
+ }
926
+ try {
927
+ const parsed = JSON.parse(body);
928
+ const conns = Number(parsed?.readyConnections);
929
+ done(conns > 0, conns > 0 ? '' : `HTTP 200 但 readyConnections=${parsed?.readyConnections}`);
930
+ } catch (err) {
931
+ done(false, `响应无法解析为 JSON: ${err.message}`);
932
+ }
933
+ });
934
+ });
935
+ } catch (err) {
936
+ done(false, `无法发起探活请求(地址可能非法): ${err.message}`);
937
+ return;
938
+ }
939
+ req.on('timeout', () => { done(false, `socket 不活动超过 ${this.healthProbeTimeoutMs}ms`); });
940
+ req.on('error', (err) => { done(false, `请求失败: ${err.message}`); });
941
+ });
942
+ }
943
+
944
+ _setState(phase, detail, { record = true } = {}) {
945
+ // 记住最近一次"纯净"的 ready 状态:探活由降级恢复、或地址迟到后补启探活时
946
+ // 要回写它,避免面板长期停留在降级文案上(record:false 用于降级提示本身)。
947
+ if (phase === 'ready' && record) this._readyState = { phase, detail };
522
948
  this.onStateChange?.({ phase, detail });
523
949
  }
524
950
 
@@ -529,6 +955,8 @@ export class CloudflaredManager {
529
955
  this._retryTimer = null;
530
956
  }
531
957
  this._restartCount = 0;
958
+ this._stopHealthProbe();
959
+ this._closeLogStream();
532
960
  if (this.process) {
533
961
  this.logger?.info('停止 cloudflared...');
534
962
  this._terminateProcess();
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@ import { spawn } from 'node:child_process';
16
16
  import QRCode from 'qrcode';
17
17
  import { installBridgeRpc } from './bridge-rpc.js';
18
18
  import { CustomTunnelClient } from './tunnel-client.mjs';
19
- import { CloudflaredManager } from './cloudflared-manager.mjs';
19
+ import { CloudflaredManager, CLOUDFLARED_LOG_NAME } from './cloudflared-manager.mjs';
20
20
  import { PlatformManager } from './platform/manager.js';
21
21
  import { applyRestoredPlatformConfig, RESTORED_STRING_FIELDS, PLATFORM_TIMING_FIELDS } from './platform/config-restore.js';
22
22
  import { WechatService } from './wechat/index.js';
@@ -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) {
@@ -997,6 +1005,9 @@ class BridgeService {
997
1005
  home: this.home,
998
1006
  token: this.cloudflaredConfig?.token,
999
1007
  hostname: this.cloudflaredConfig?.hostname,
1008
+ // cloudflared 自身的 stdout/stderr 落盘:事故时 journal 里没有任何连接器日志,
1009
+ // 导致断连时间点与原因完全无法定位(内含 Token 脱敏与 5MB 轮转)。
1010
+ logFilePath: join(this.home ?? join(homedir(), '.dsh-bridge'), CLOUDFLARED_LOG_NAME),
1000
1011
  onStateChange: (state) => {
1001
1012
  this.cloudflaredState = state;
1002
1013
  // 注意:不在 error 时清空 this.cloudflared 引用。
@@ -1378,26 +1389,39 @@ class BridgeService {
1378
1389
 
1379
1390
  if (plan.kind === 'systemd') {
1380
1391
  const args = [...(plan.scope === 'user' ? ['--user'] : []), 'restart', '--no-block', plan.unit];
1381
- const spawnError = await new Promise((resolve) => {
1392
+ // 关键:必须以 **systemctl 的退出码** 判定成败,而不是"派生成功"。
1393
+ // `--no-block` 下 systemctl 只发请求就返回(实测往返 ~6ms),但单元名不存在会 rc=5、
1394
+ // 权限不足会 rc=1;旧写法只看 'spawn' 事件 → 必然失败也返回 ok:true 再 exit(0),
1395
+ // 而干净退出不满足 Restart=on-failure → DSH 直接下线且不自动拉起。
1396
+ const outcome = await new Promise((resolve) => {
1382
1397
  let settled = false;
1383
1398
  const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
1384
1399
  try {
1385
1400
  const child = spawnImpl('systemctl', args, { detached: true, stdio: 'ignore', env });
1386
- child.once?.('error', (err) => settle(err));
1387
- child.once?.('spawn', () => settle(null));
1401
+ child.once?.('error', (err) => settle({ error: err }));
1402
+ child.once?.('close', (code) => settle({ code }));
1388
1403
  child.unref?.();
1389
1404
  } catch (err) {
1390
- settle(err);
1405
+ settle({ error: err });
1391
1406
  }
1392
1407
  });
1393
1408
 
1394
- if (!spawnError) {
1395
- writeLog(`已请求 systemd 重启:systemctl ${args.join(' ')}`);
1409
+ if (!outcome.error && (outcome.code === 0 || outcome.code === null || outcome.code === undefined)) {
1410
+ writeLog(`已请求 systemd 重启(systemctl 退出码 ${outcome.code ?? 'n/a'}):systemctl ${args.join(' ')}`);
1396
1411
  scheduleExit(1500);
1397
1412
  const scopeText = plan.scope === 'user' ? '用户会话' : '系统';
1398
1413
  return { ok: true, message: `已请求 systemd 重启 ${plan.unit}(${scopeText}),由 systemd 负责停止与拉起…` };
1399
1414
  }
1400
- writeLog(`调用 systemctl 失败:${spawnError.message};回退到独立助手方式`);
1415
+
1416
+ // 失败时**不退化为自派生**:systemd 下主进程干净退出既不会被拉起,
1417
+ // 默认 KillMode=control-group 还会把 detached 助手一起杀掉 —— 那等于"报着重启却永久下线"。
1418
+ // 因此保持现状(DSH 继续运行)并如实报错,由用户手动处理。
1419
+ const reason = outcome.error ? outcome.error.message : `systemctl 退出码 ${outcome.code}`;
1420
+ writeLog(`systemctl 重启失败(${reason}),为不让 DSH 掉线未做任何退出`);
1421
+ return {
1422
+ ok: false,
1423
+ error: `systemctl ${args.join(' ')} 失败:${reason}。DSH 仍在运行;请手动执行「systemctl ${plan.scope === 'user' ? '--user ' : ''}restart ${plan.unit}」或检查单元。`,
1424
+ };
1401
1425
  } else if (plan.kind === 'daemon') {
1402
1426
  writeLog(`交由守护进程拉起(${plan.reason})`);
1403
1427
  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.13",
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.13】可靠性加固 + 跟随 DSH 0.1.5 目录选择器\n• 新增:cloudflared 运行时健康探针——每 30s 探活官方 /ready,连续 3 次失败(约 90s)面板可见降级,连续 10 次(约 5min)判定假死自动强制重建,重建永不进终态(网络恢复后自愈)\n• 新增:cloudflared 输出落盘 ~/.dsh/dsh-bridge/cloudflared.log(5MB 轮转 .1),Token 跨 chunk 强制脱敏\n• 修复:连接器假死时公网入口静默失效数天无人发现(HTTP 530 / error code 1033),旧逻辑只监听进程退出故永不触发\n• 修复:本机点「添加工作区」误弹插件的远程抽屉(issue #28)——DSH 0.1.5 自带官方目录选择器且 slots 为 shadow 语义,插件同名 Slot 覆盖了官方实现;现本机 + 官方 picker 可用时不再注册该 Slot(macOS/Windows 走系统原生对话框、无桌面 Linux 走官方网页选择器),旧版 DSH 与远程/移动仍由插件兜底\n• 修复:添加工作区弹窗点过「已注册工作区」后永久卡死(PR #42)——switchToWorkspace 只置位 isSubmitting 从不复位,改为与 doSubmit 对称的 try/finally\n• 修复:探针路径任何异常(畸形地址、日志通道抛错)都不会拖垮 DSH 主进程\n• 测试:新增 18 条探针 + 12 条目录选择器让位 + 3 条弹窗状态复位回归测试\n• 说明:本版需重启一次 dsh(systemctl --user restart dsh-web.service)才生效",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {