@wenbin_wb/dsh-bridge 2.10.10 → 2.10.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -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 +163 -29
- package/lib/restart-helper.mjs +119 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [v2.10.12] - 2026-09-16
|
|
8
|
+
|
|
9
|
+
> v2.10.11 未正式发布到 npm,其内容已并入本版。
|
|
10
|
+
|
|
11
|
+
### 🐞 修复
|
|
12
|
+
|
|
13
|
+
- **iOS 16 / 旧 Safari 打不开 DSH**(红屏 `Failed to load plugins … Can't find variable: Iterator`):代理注入 HTML 时补齐 `Promise.withResolvers` 与 `Iterator` 垫片(Safari 分别是 17.4 / 18.4 才有),只在缺失时安装,其它浏览器零改动。
|
|
14
|
+
- **鸿蒙(HuaweiBrowser / ArkWeb)文档预览一直显示「文件资源服务不可用。」**:该内核解析 `dsh-resource://` 地址取不到 hostname,新增 URL 兼容层兜底;引擎正常时不介入。
|
|
15
|
+
- **移动端顶栏压住全屏面板顶部 52px(#41)**:改为直接位移面板容器本身,并让移动端断点与宿主判据对齐(`≤767px`)。
|
|
16
|
+
- **「重启 DSH」在 systemd 托管下点了没用、DSH 不再自动起来**:改为识别 systemd 单元并交给 `systemctl restart`,按退出码判定成败;失败时保持运行并提示手动重启命令。
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
7
20
|
## [v2.10.10] - 2026-09-14
|
|
8
21
|
|
|
9
22
|
### ✨ 新功能
|
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
|
|
@@ -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
|
|
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,8 +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
|
-
|
|
191
|
-
|
|
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
|
+
];
|
|
192
198
|
|
|
193
199
|
function isCompressed(headers) {
|
|
194
200
|
return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
|
|
@@ -262,6 +268,48 @@ function sanitizeProxyHeaders(headers, extra) {
|
|
|
262
268
|
* 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
|
|
263
269
|
* 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
|
|
264
270
|
*/
|
|
271
|
+
/**
|
|
272
|
+
* 从 `/proc/self/cgroup` 内容里取出最内层(最后一段)的 systemd 单元名。
|
|
273
|
+
*
|
|
274
|
+
* 形如 `0::/user.slice/user-1000.slice/user@1000.service/app.slice/dsh-web.service`
|
|
275
|
+
* 时必须取 `dsh-web.service`(祖先里的 `user@1000.service` 是用户管理器,不是本服务)。
|
|
276
|
+
*
|
|
277
|
+
* @param {string} cgroup /proc/self/cgroup 的原始内容
|
|
278
|
+
* @returns {string} 单元名,取不到返回空串
|
|
279
|
+
*/
|
|
280
|
+
export function parseSystemdUnit(cgroup) {
|
|
281
|
+
const parts = String(cgroup || '').split('/').filter(Boolean);
|
|
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 : '';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 判断当前 DSH 由谁托管,决定重启方式(纯函数,便于单测)。
|
|
291
|
+
*
|
|
292
|
+
* @param {object} [opts]
|
|
293
|
+
* @param {object} [opts.env] 环境变量(默认 process.env)
|
|
294
|
+
* @param {string} [opts.cgroup] /proc/self/cgroup 内容
|
|
295
|
+
* @returns {{kind:'daemon'|'systemd'|'self', scope?:'user'|'system', unit?:string, reason:string}}
|
|
296
|
+
*/
|
|
297
|
+
export function detectSupervisor({ env = process.env, cgroup = '' } = {}) {
|
|
298
|
+
if (env.DSH_DAEMON || env.PM2_HOME) {
|
|
299
|
+
return { kind: 'daemon', reason: env.PM2_HOME ? 'PM2_HOME' : 'DSH_DAEMON' };
|
|
300
|
+
}
|
|
301
|
+
const text = String(cgroup || '');
|
|
302
|
+
// 只在确实处于 systemd 环境时才认(INVOCATION_ID/JOURNAL_STREAM 是 systemd 注入的标记),
|
|
303
|
+
// 避免在容器里存在同名 cgroup 文本时误判。
|
|
304
|
+
const systemdMarked = Boolean(env.INVOCATION_ID || env.JOURNAL_STREAM) || text.includes('.slice');
|
|
305
|
+
const unit = systemdMarked ? parseSystemdUnit(text) : '';
|
|
306
|
+
if (unit) {
|
|
307
|
+
const parts = text.split('/').filter(Boolean);
|
|
308
|
+
return { kind: 'systemd', scope: parts.includes('user.slice') ? 'user' : 'system', unit, reason: 'cgroup 检出 systemd 单元' };
|
|
309
|
+
}
|
|
310
|
+
return { kind: 'self', reason: '未检出托管器(无 DSH_DAEMON/PM2_HOME,也不在 systemd 单元内)' };
|
|
311
|
+
}
|
|
312
|
+
|
|
265
313
|
class ProxyServer {
|
|
266
314
|
constructor({ localPort, targetPort, authManager, logger, allowedOrigins }) {
|
|
267
315
|
this.localPort = localPort;
|
|
@@ -466,8 +514,10 @@ class ProxyServer {
|
|
|
466
514
|
const outHeaders = { ...proxyRes.headers };
|
|
467
515
|
if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
|
|
468
516
|
let html = out.toString('utf8');
|
|
469
|
-
|
|
470
|
-
|
|
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}`);
|
|
471
521
|
}
|
|
472
522
|
out = Buffer.from(html, 'utf8');
|
|
473
523
|
} else if (isSessionProjection) {
|
|
@@ -1301,32 +1351,116 @@ class BridgeService {
|
|
|
1301
1351
|
return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
|
|
1302
1352
|
}
|
|
1303
1353
|
|
|
1304
|
-
// 优雅重启 DSH
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1354
|
+
// 优雅重启 DSH 服务。
|
|
1355
|
+
//
|
|
1356
|
+
// 关键教训(真实事故):DSH 常以 systemd 用户单元(如 dsh-web.service,Restart=on-failure)托管,
|
|
1357
|
+
// 而"自己派生一个子进程再 process.exit(0)"在这种环境下必然失败:
|
|
1358
|
+
// - 干净退出(code 0)不满足 Restart=on-failure,systemd 不会拉起;
|
|
1359
|
+
// - 默认 KillMode=control-group,主进程退出时 systemd 会把 cgroup 里刚派生的子进程一起杀掉。
|
|
1360
|
+
// 结果就是"点了重启,dsh 再也没起来",而且没有任何日志(旧实现 stdio: 'ignore')。
|
|
1361
|
+
// 因此这里先识别托管方式:
|
|
1362
|
+
// - systemd(从 /proc/self/cgroup 取单元名)→ 交给 `systemctl [--user] restart --no-block <unit>`,
|
|
1363
|
+
// 由 systemd 负责 stop+start,新进程仍在正确的 cgroup 里;
|
|
1364
|
+
// - DSH_DAEMON / PM2 → 直接退出,交给守护进程拉起;
|
|
1365
|
+
// - 无托管器 → 派生独立助手(lib/restart-helper.mjs),等旧进程退出 + 端口释放后才拉起新进程,
|
|
1366
|
+
// 并把全过程写入 ~/.dsh/dsh-bridge/restart.log(失败不再无声)。
|
|
1367
|
+
// opts 仅供单测注入(spawnImpl / readCgroup / env / scheduleExit)。
|
|
1368
|
+
async restartDsh(opts = {}) {
|
|
1369
|
+
const env = opts.env ?? process.env;
|
|
1370
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
1371
|
+
const readCgroup = opts.readCgroup
|
|
1372
|
+
?? (() => { try { return readFileSync('/proc/self/cgroup', 'utf8'); } catch { return ''; } });
|
|
1373
|
+
const cgroup = opts.cgroup ?? readCgroup();
|
|
1374
|
+
const scheduleExit = opts.scheduleExit ?? ((ms) => setTimeout(() => process.exit(0), ms));
|
|
1375
|
+
|
|
1376
|
+
const dshHome = env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
1377
|
+
const logFile = join(dshHome, 'dsh-bridge', 'restart.log');
|
|
1378
|
+
const childLog = join(dshHome, 'dsh-bridge', 'restart-child.log');
|
|
1379
|
+
const writeLog = (message) => {
|
|
1380
|
+
try { appendFileSync(logFile, `[${new Date().toISOString()}] ${message}\n`); } catch { /* 日志不可写时忽略 */ }
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
const plan = detectSupervisor({ env, cgroup });
|
|
1384
|
+
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))}`);
|
|
1385
|
+
this.logger?.info('收到 DSH 重启请求(%s)', plan.kind === 'systemd' ? `systemd:${plan.unit}` : plan.kind);
|
|
1386
|
+
|
|
1387
|
+
if (plan.kind === 'systemd') {
|
|
1388
|
+
const args = [...(plan.scope === 'user' ? ['--user'] : []), 'restart', '--no-block', plan.unit];
|
|
1389
|
+
// 关键:必须以 **systemctl 的退出码** 判定成败,而不是"派生成功"。
|
|
1390
|
+
// `--no-block` 下 systemctl 只发请求就返回(实测往返 ~6ms),但单元名不存在会 rc=5、
|
|
1391
|
+
// 权限不足会 rc=1;旧写法只看 'spawn' 事件 → 必然失败也返回 ok:true 再 exit(0),
|
|
1392
|
+
// 而干净退出不满足 Restart=on-failure → DSH 直接下线且不自动拉起。
|
|
1393
|
+
const outcome = await new Promise((resolve) => {
|
|
1394
|
+
let settled = false;
|
|
1395
|
+
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
1396
|
+
try {
|
|
1397
|
+
const child = spawnImpl('systemctl', args, { detached: true, stdio: 'ignore', env });
|
|
1398
|
+
child.once?.('error', (err) => settle({ error: err }));
|
|
1399
|
+
child.once?.('close', (code) => settle({ code }));
|
|
1400
|
+
child.unref?.();
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
settle({ error: err });
|
|
1322
1403
|
}
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
if (!outcome.error && (outcome.code === 0 || outcome.code === null || outcome.code === undefined)) {
|
|
1407
|
+
writeLog(`已请求 systemd 重启(systemctl 退出码 ${outcome.code ?? 'n/a'}):systemctl ${args.join(' ')}`);
|
|
1408
|
+
scheduleExit(1500);
|
|
1409
|
+
const scopeText = plan.scope === 'user' ? '用户会话' : '系统';
|
|
1410
|
+
return { ok: true, message: `已请求 systemd 重启 ${plan.unit}(${scopeText}),由 systemd 负责停止与拉起…` };
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// 失败时**不退化为自派生**:systemd 下主进程干净退出既不会被拉起,
|
|
1414
|
+
// 默认 KillMode=control-group 还会把 detached 助手一起杀掉 —— 那等于"报着重启却永久下线"。
|
|
1415
|
+
// 因此保持现状(DSH 继续运行)并如实报错,由用户手动处理。
|
|
1416
|
+
const reason = outcome.error ? outcome.error.message : `systemctl 退出码 ${outcome.code}`;
|
|
1417
|
+
writeLog(`systemctl 重启失败(${reason}),为不让 DSH 掉线未做任何退出`);
|
|
1418
|
+
return {
|
|
1419
|
+
ok: false,
|
|
1420
|
+
error: `systemctl ${args.join(' ')} 失败:${reason}。DSH 仍在运行;请手动执行「systemctl ${plan.scope === 'user' ? '--user ' : ''}restart ${plan.unit}」或检查单元。`,
|
|
1421
|
+
};
|
|
1422
|
+
} else if (plan.kind === 'daemon') {
|
|
1423
|
+
writeLog(`交由守护进程拉起(${plan.reason})`);
|
|
1424
|
+
scheduleExit(600);
|
|
1425
|
+
return { ok: true, message: `DSH 正在重启(由 ${plan.reason} 守护进程拉起)…` };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// 无托管器(或 systemd 调用失败)→ 独立助手:等旧进程退出、端口释放后再拉起新进程,全程留日志
|
|
1429
|
+
const helper = join(dirname(fileURLToPath(import.meta.url)), 'restart-helper.mjs');
|
|
1430
|
+
const payload = JSON.stringify({
|
|
1431
|
+
pid: process.pid,
|
|
1432
|
+
port: this.dshPort ?? 3080,
|
|
1433
|
+
argv: process.argv.slice(1),
|
|
1434
|
+
cwd: process.cwd(),
|
|
1435
|
+
execPath: process.execPath,
|
|
1436
|
+
logFile,
|
|
1437
|
+
childLog,
|
|
1438
|
+
});
|
|
1439
|
+
// spawn 的失败是异步事件('error'),只 try/catch 抓不到——必须挂监听,否则会变成未捕获异常
|
|
1440
|
+
const helperError = await new Promise((resolve) => {
|
|
1441
|
+
let settled = false;
|
|
1442
|
+
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
1443
|
+
try {
|
|
1444
|
+
const child = spawnImpl(process.execPath, [helper, payload], {
|
|
1445
|
+
cwd: process.cwd(),
|
|
1446
|
+
env,
|
|
1447
|
+
detached: true,
|
|
1448
|
+
stdio: 'ignore',
|
|
1449
|
+
});
|
|
1450
|
+
child.once?.('error', (err) => settle(err));
|
|
1451
|
+
child.once?.('spawn', () => { writeLog(`已派生重启助手:helperPid=${child.pid ?? '(未知)'},日志:${logFile}`); settle(null); });
|
|
1452
|
+
child.unref?.();
|
|
1323
1453
|
} catch (err) {
|
|
1324
|
-
|
|
1325
|
-
process.exit(0);
|
|
1454
|
+
settle(err);
|
|
1326
1455
|
}
|
|
1327
|
-
}
|
|
1456
|
+
});
|
|
1328
1457
|
|
|
1329
|
-
|
|
1458
|
+
if (helperError) {
|
|
1459
|
+
writeLog(`派生重启助手失败:${helperError.message}`);
|
|
1460
|
+
return { ok: false, error: `重启失败:${helperError.message}(DSH 仍在运行,未做任何退出)` };
|
|
1461
|
+
}
|
|
1462
|
+
scheduleExit(600);
|
|
1463
|
+
return { ok: true, message: '正在重启:独立助手会在旧进程退出、端口释放后拉起新的 DSH(若失败会记录在 ~/.dsh/dsh-bridge/restart.log)…' };
|
|
1330
1464
|
}
|
|
1331
1465
|
|
|
1332
1466
|
getSystemMetrics() {
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
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();
|
|
111
|
+
|
|
112
|
+
const up = await waitUntil(() => portAccepts(port), readyTimeoutMs, `新 dsh 监听 ${port}`);
|
|
113
|
+
log(up
|
|
114
|
+
? `✅ 新 dsh 已就绪,耗时 ${Math.round((Date.now() - startedAt) / 1000)}s`
|
|
115
|
+
: `❌ 新 dsh 未能在 ${Math.round(readyTimeoutMs / 1000)}s 内就绪,请查看 ${childLog}`);
|
|
116
|
+
process.exit(up ? 0 : 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
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.12",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.10.10
|
|
5
|
+
"releaseNotes": "【v2.10.12】(含未正式发布的 v2.10.11 内容)\n• 修复:iOS 16 / 旧 Safari 打不开 DSH(红屏 Failed to load plugins)——补齐 Promise.withResolvers 与 Iterator 垫片,只在缺失时安装\n• 修复:鸿蒙手机上文档预览一直显示「文件资源服务不可用。」——新增 dsh-resource 地址的 URL 兼容层\n• 修复:移动端顶栏压住全屏面板顶部 52px(#41),并把移动端断点与宿主对齐(≤767px)\n• 修复:「重启 DSH」在 systemd 托管下点了没用、DSH 不再自动起来——改为交给 systemctl 重启并按退出码判定成败\n• 说明:systemd 环境需手动重启一次 dsh(systemctl --user restart dsh-web.service)让本版生效",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|