@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 +12 -0
- package/client/client.js +107 -8
- package/client/index.js +26 -8
- package/client/mobile-styles.js +18 -2
- package/client/resource-url-compat.js +90 -0
- package/lib/compat.js +43 -0
- package/lib/index.js +138 -25
- package/lib/restart-helper.mjs +101 -0
- package/package.json +2 -2
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
|
-
|
|
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:
|
|
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 <=
|
|
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 >
|
|
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 >
|
|
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 >
|
|
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 >
|
|
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 <=
|
|
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 面板管理:仅在移动端(<=
|
|
3854
|
+
// 1.1 移动端右侧边栏 / Workbench 面板管理:仅在移动端(<= 767px)挂载“返回对话”收起按钮,PC端保持纯净
|
|
3837
3855
|
const ensurePanelCloseButton = () => {
|
|
3838
3856
|
if (typeof window === 'undefined') return;
|
|
3839
|
-
if (window.innerWidth >
|
|
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 >
|
|
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 >
|
|
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 >
|
|
4905
|
+
if (window.innerWidth > MOBILE_MAX_WIDTH) return; // 仅移动端
|
|
4888
4906
|
const LS_KEY = 'dsh-composer-fold';
|
|
4889
4907
|
|
|
4890
4908
|
let bar = null; // 折叠态底部"点击输入"细条
|
package/client/mobile-styles.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
1325
|
-
process.exit(0);
|
|
1433
|
+
settle(err);
|
|
1326
1434
|
}
|
|
1327
|
-
}
|
|
1435
|
+
});
|
|
1328
1436
|
|
|
1329
|
-
|
|
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.
|
|
3
|
+
"version": "2.10.11",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.10.
|
|
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": {
|