dsh-remote-mobile 1.2.1 → 1.4.1
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 +82 -0
- package/README.md +94 -13
- package/README_EN.md +93 -13
- package/lib/auth/crypto.js +2 -2
- package/lib/auth/gate.d.ts +15 -0
- package/lib/auth/gate.js +49 -0
- package/lib/auth/token.js +9 -4
- package/lib/bridge/compat.d.ts +79 -9
- package/lib/bridge/compat.js +562 -51
- package/lib/client.js +1114 -303
- package/lib/index.d.ts +1 -0
- package/lib/index.js +9 -6
- package/lib/routes/api.d.ts +6 -1
- package/lib/routes/api.js +85 -2
- package/lib/styles/style-snippets.d.ts +133 -0
- package/lib/styles/style-snippets.js +932 -0
- package/package.json +2 -2
package/lib/bridge/compat.js
CHANGED
|
@@ -11,15 +11,27 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 2. 【底层 HTTP 门禁劫持与回环上下文虚拟化 (Loopback Context Virtualizer)】:
|
|
13
13
|
* - 挂载全链路 HTTP 门禁中间件,对未授权请求实施 302 重定向到 /auth 或 401 拦截;
|
|
14
|
-
* - 对通过认证(已扫码配对 / 长期密码 /
|
|
15
|
-
* 统一将 Host、Origin、Socket RemoteAddress 及 Sec-Fetch-Site 规范化虚拟为 127.0.0.1
|
|
16
|
-
*
|
|
14
|
+
* - 对通过认证(已扫码配对 / 长期密码 / 免密直连)的外部请求,在进入下游各业务插件前,
|
|
15
|
+
* 统一将 Host、Origin、Socket RemoteAddress 及 Sec-Fetch-Site 规范化虚拟为 127.0.0.1 回环特征,
|
|
16
|
+
* 解除底层 WebServer 的 DNS-Rebinding 阻断以及第三方插件写死的 loopback-only 403 限制;
|
|
17
|
+
* - 本机回环 socket(127.0.0.1 / ::1)不做上述洗白:保留原始 Host / Origin / Sec-Fetch-Site,
|
|
18
|
+
* 让下游自身的同源与 DNS-Rebinding 校验继续生效,阻断恶意网页借道本机发起的跨站写攻击。
|
|
17
19
|
*
|
|
18
|
-
* 3.
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
20
|
+
* 3. 【移动端样式片段注入 (Mobile Style Snippets Injection)】:
|
|
21
|
+
* - 在网关响应层按 User-Agent 判定移动端,向主工作区 index.html 与 /auth 登录页
|
|
22
|
+
* 注入可拼装去重的 CSS 片段(内置预设 + 用户自定义,见 styles/style-snippets.ts),
|
|
23
|
+
* 并给 <html> 打上 data-dsh-mobile 标记供样式选择器使用。
|
|
24
|
+
*
|
|
25
|
+
* 4. 【Cordis 服务桥接 (remoteWebUiPairing Bridge)】:
|
|
26
|
+
* - 以标准 Cordis Service 模式向全局暴露 remoteWebUiPairing 服务;
|
|
27
|
+
* - 该名称是远程/Web 接入类插件的通用共享服务名,可能与其他同类插件冲突,
|
|
28
|
+
* 采用「延迟裁决」策略:等待激活窗口结束后检测服务名归属,
|
|
29
|
+
* 已被占用则主动让出并置冲突标记(设置页展示警示横幅),无人注册才接管;
|
|
30
|
+
* - 判定基于 Cordis 公开运行时结构(服务占用 + fiber 运行态),不针对特定插件;
|
|
31
|
+
* - 保证与其他远程接入类插件共存时必定可以正常启动(不再触发整树回滚致命错误)。
|
|
21
32
|
*/
|
|
22
33
|
import { getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
|
|
34
|
+
import { isMobileUserAgent, applyDataMobileAttr, buildMobileStyleTag, } from '../styles/style-snippets.js';
|
|
23
35
|
/**
|
|
24
36
|
* 注入到 index.html <head> 最前列的 Polyfill 脚本代码片段
|
|
25
37
|
* 针对非安全上下文(HTTP + 外部 IP)提供轻量高精度的 UUID v4 生成能力
|
|
@@ -40,6 +52,260 @@ export const CRYPTO_POLYFILL_SNIPPET = `
|
|
|
40
52
|
}
|
|
41
53
|
})();
|
|
42
54
|
`;
|
|
55
|
+
/**
|
|
56
|
+
* 注入到 index.html <head> 最前列的 Clipboard API Polyfill 脚本
|
|
57
|
+
* 针对非安全上下文(HTTP + 外部 IP,如 Tailscale / 局域网)下
|
|
58
|
+
* navigator.clipboard 为 undefined 的场景:提供 execCommand('copy') 降级实现,
|
|
59
|
+
* 避免官方 bundle 直接调用 navigator.clipboard.writeText 时抛
|
|
60
|
+
* "Cannot read properties of undefined (reading 'writeText')" 崩溃。
|
|
61
|
+
* 原生可用时不做任何替换。
|
|
62
|
+
*/
|
|
63
|
+
export const CLIPBOARD_POLYFILL_SNIPPET = `
|
|
64
|
+
(function() {
|
|
65
|
+
if (typeof navigator === "undefined") return;
|
|
66
|
+
try {
|
|
67
|
+
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") return;
|
|
68
|
+
var writeText = function(text) {
|
|
69
|
+
try {
|
|
70
|
+
var ta = document.createElement('textarea');
|
|
71
|
+
ta.value = String(text == null ? '' : text);
|
|
72
|
+
ta.setAttribute('readonly', '');
|
|
73
|
+
ta.style.position = 'fixed';
|
|
74
|
+
ta.style.top = '-9999px';
|
|
75
|
+
ta.style.left = '-9999px';
|
|
76
|
+
document.body.appendChild(ta);
|
|
77
|
+
ta.select();
|
|
78
|
+
var ok = document.execCommand('copy');
|
|
79
|
+
document.body.removeChild(ta);
|
|
80
|
+
return ok ? Promise.resolve() : Promise.reject(new Error('copy failed'));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return Promise.reject(err);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var readText = function() {
|
|
86
|
+
return Promise.reject(new Error('clipboard readText is not supported in insecure context'));
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
if (!navigator.clipboard) navigator.clipboard = {};
|
|
90
|
+
if (typeof navigator.clipboard.writeText !== "function") navigator.clipboard.writeText = writeText;
|
|
91
|
+
if (typeof navigator.clipboard.readText !== "function") navigator.clipboard.readText = readText;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
try {
|
|
94
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
95
|
+
value: { writeText: writeText, readText: readText },
|
|
96
|
+
configurable: true,
|
|
97
|
+
});
|
|
98
|
+
} catch (err2) {}
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {}
|
|
101
|
+
})();
|
|
102
|
+
`;
|
|
103
|
+
/**
|
|
104
|
+
* 注入到 index.html 的移动端悬浮入口拖拽与位置记忆脚本
|
|
105
|
+
* 允许用户在移动端上下随心拖动左上角入口把手,避免遮挡聊天内容,并自动保存位置
|
|
106
|
+
*/
|
|
107
|
+
export const DRAGGABLE_NAV_SNIPPET = `
|
|
108
|
+
(function() {
|
|
109
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
110
|
+
|
|
111
|
+
/* ===== 防误收起守卫 =====
|
|
112
|
+
* 第三方生态插件(如 @linxin666/dsh-web-ui-all 的移动端抽屉增强)会在用户点击
|
|
113
|
+
* 侧边栏条目(role=treeitem)后,于下一帧程序化点击「收起侧边栏」按钮自动收回抽屉。
|
|
114
|
+
* 该判定无法区分「选中条目回主区」与「点击条目内的次级操作图标」(更多操作 ⋯ /
|
|
115
|
+
* 展开箭头等),导致后者抽屉被误关。
|
|
116
|
+
* 这里记录最近一次对条目内次级控件(按钮 / 箭头图标)的触摸时间,并包装
|
|
117
|
+
* HTMLButtonElement.prototype.click:短时间窗口内的程序化收起调用直接忽略;
|
|
118
|
+
* 用户真实点击收起按钮(浏览器合成事件不走 prototype.click)与点击条目主体
|
|
119
|
+
* (非按钮/箭头)的官方行为均不受影响。 */
|
|
120
|
+
if (!window.__dshRmAntiCollapseInstalled__) {
|
|
121
|
+
window.__dshRmAntiCollapseInstalled__ = true;
|
|
122
|
+
var lastSecondaryTapAt = 0;
|
|
123
|
+
document.addEventListener('pointerdown', function (e) {
|
|
124
|
+
try {
|
|
125
|
+
var t = e.target;
|
|
126
|
+
if (!t || !t.closest) return;
|
|
127
|
+
if (!t.closest('[data-pane="sidebar"], [class*="_sidebarCol"]')) return;
|
|
128
|
+
var entry = t.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]');
|
|
129
|
+
if (!entry) return;
|
|
130
|
+
var isSecondaryControl =
|
|
131
|
+
!!t.closest('button, [role="button"]') ||
|
|
132
|
+
!!t.closest('[class*="_arrow"]');
|
|
133
|
+
if (isSecondaryControl) lastSecondaryTapAt = Date.now();
|
|
134
|
+
} catch (err) {}
|
|
135
|
+
}, { capture: true, passive: true });
|
|
136
|
+
|
|
137
|
+
var protoClick = HTMLButtonElement.prototype.click;
|
|
138
|
+
HTMLButtonElement.prototype.click = function () {
|
|
139
|
+
try {
|
|
140
|
+
var isSidebarToggle =
|
|
141
|
+
this.matches('[data-dsh-responsive-part="sidebar-toggle"]') ||
|
|
142
|
+
/收起侧边栏|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');
|
|
143
|
+
if (isSidebarToggle && Date.now() - lastSecondaryTapAt < 900) {
|
|
144
|
+
return; // 吞掉紧随次级操作图标点击之后的程序化收起
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {}
|
|
147
|
+
return protoClick.apply(this, arguments);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function initDraggableNav() {
|
|
152
|
+
var logoRow = document.querySelector('[data-pane="sidebar"] [class*="_logoRow"], [class*="_sidebarCol"] [class*="_logoRow"]');
|
|
153
|
+
if (!logoRow || logoRow.__dsh_drag_init__) return;
|
|
154
|
+
logoRow.__dsh_drag_init__ = true;
|
|
155
|
+
|
|
156
|
+
var savedTop = localStorage.getItem('dsh_mobile_nav_top');
|
|
157
|
+
if (savedTop && !isNaN(Number(savedTop))) {
|
|
158
|
+
var topVal = Math.max(10, Math.min(window.innerHeight - 60, Number(savedTop)));
|
|
159
|
+
logoRow.style.setProperty('top', topVal + 'px', 'important');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
var startY = 0;
|
|
163
|
+
var initialTop = 0;
|
|
164
|
+
var isDragging = false;
|
|
165
|
+
var hasMoved = false;
|
|
166
|
+
|
|
167
|
+
function onTouchStart(e) {
|
|
168
|
+
var sidebar = document.querySelector('[data-pane="sidebar"], [class*="_sidebarCol"]');
|
|
169
|
+
if (sidebar && !sidebar.querySelector('[class*="_collapsed"]')) return;
|
|
170
|
+
|
|
171
|
+
var touch = e.touches ? e.touches[0] : e;
|
|
172
|
+
startY = touch.clientY;
|
|
173
|
+
var rect = logoRow.getBoundingClientRect();
|
|
174
|
+
initialTop = rect.top;
|
|
175
|
+
isDragging = true;
|
|
176
|
+
hasMoved = false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function onTouchMove(e) {
|
|
180
|
+
if (!isDragging) return;
|
|
181
|
+
var touch = e.touches ? e.touches[0] : e;
|
|
182
|
+
var deltaY = touch.clientY - startY;
|
|
183
|
+
|
|
184
|
+
if (!hasMoved && Math.abs(deltaY) > 5) {
|
|
185
|
+
hasMoved = true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (hasMoved) {
|
|
189
|
+
if (e.cancelable) e.preventDefault();
|
|
190
|
+
var newTop = initialTop + deltaY;
|
|
191
|
+
var minTop = 10;
|
|
192
|
+
var maxTop = window.innerHeight - 56;
|
|
193
|
+
newTop = Math.max(minTop, Math.min(maxTop, newTop));
|
|
194
|
+
logoRow.style.setProperty('top', newTop + 'px', 'important');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function onTouchEnd(e) {
|
|
199
|
+
if (!isDragging) return;
|
|
200
|
+
isDragging = false;
|
|
201
|
+
if (hasMoved) {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
e.stopPropagation();
|
|
204
|
+
var currentRect = logoRow.getBoundingClientRect();
|
|
205
|
+
try {
|
|
206
|
+
localStorage.setItem('dsh_mobile_nav_top', String(Math.round(currentRect.top)));
|
|
207
|
+
} catch (err) {}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
logoRow.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
212
|
+
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
213
|
+
window.addEventListener('touchend', onTouchEnd, { capture: true });
|
|
214
|
+
|
|
215
|
+
logoRow.addEventListener('mousedown', onTouchStart);
|
|
216
|
+
window.addEventListener('mousemove', onTouchMove);
|
|
217
|
+
window.addEventListener('mouseup', onTouchEnd, { capture: true });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (document.readyState === 'loading') {
|
|
221
|
+
document.addEventListener('DOMContentLoaded', initDraggableNav);
|
|
222
|
+
} else {
|
|
223
|
+
initDraggableNav();
|
|
224
|
+
}
|
|
225
|
+
setInterval(initDraggableNav, 800);
|
|
226
|
+
})();
|
|
227
|
+
`;
|
|
228
|
+
/**
|
|
229
|
+
* 注入到 index.html 的移动端抽屉「点外部收起」脚本
|
|
230
|
+
*
|
|
231
|
+
* 【设计职责】
|
|
232
|
+
* 仅当 @linxin666/dsh-web-ui-all 未启用时生效——web-ui 已自带 installMobileSidebarDismiss,
|
|
233
|
+
* 本脚本通过检测 style[data-dsh-compat="responsive"] 动态让位,绝不与 web-ui 叠加双份收起逻辑。
|
|
234
|
+
*
|
|
235
|
+
* 定位全部基于官方 CSS Modules 稳定后缀(_frame / _sidebarCol / _toggle,前缀 hash 随机、
|
|
236
|
+
* 后缀稳定),不依赖 web-ui 注入的 data-pane / data-dsh-frame 属性,因此装/不装 web-ui
|
|
237
|
+
* 两种环境行为一致。折叠状态沿用官方写入的 data-sidebar-collapsed 属性。
|
|
238
|
+
*/
|
|
239
|
+
export const MOBILE_DISMISS_SNIPPET = `
|
|
240
|
+
(function() {
|
|
241
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
242
|
+
|
|
243
|
+
var frameSelector = '[class*="_frame"]';
|
|
244
|
+
var sidebarSelector = '[class*="_sidebarCol"]';
|
|
245
|
+
|
|
246
|
+
function webUiOwnsDrawer() {
|
|
247
|
+
return !!document.querySelector('style[data-dsh-compat="responsive"]');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function findToggle(frame) {
|
|
251
|
+
return frame.querySelector('[class*="_toggle"]') ||
|
|
252
|
+
Array.prototype.filter.call(frame.querySelectorAll('button'), function (b) {
|
|
253
|
+
return /收起侧边栏|Collapse sidebar|Open sidebar/i.test(b.getAttribute('aria-label') || '');
|
|
254
|
+
})[0] || null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function install(frame) {
|
|
258
|
+
if (frame.__dshRmDismissInstalled__) return;
|
|
259
|
+
frame.__dshRmDismissInstalled__ = true;
|
|
260
|
+
var raf = 0;
|
|
261
|
+
|
|
262
|
+
frame.addEventListener('click', function (event) {
|
|
263
|
+
try {
|
|
264
|
+
// web-ui 可能运行中才注入 responsive style:每次事件都复查,动态让位
|
|
265
|
+
if (webUiOwnsDrawer()) return;
|
|
266
|
+
var target = event.target;
|
|
267
|
+
if (!target || !(target instanceof Element)) return;
|
|
268
|
+
if (typeof window.matchMedia !== 'function' || !window.matchMedia('(max-width: 900px)').matches) return;
|
|
269
|
+
var sidebar = frame.querySelector(sidebarSelector);
|
|
270
|
+
var toggle = findToggle(frame);
|
|
271
|
+
if (!sidebar || !toggle) return;
|
|
272
|
+
|
|
273
|
+
// 抽屉展开时点击正文(sidebar 外)→ 触发官方收起按钮,并拦截被吞掉的点击;
|
|
274
|
+
// 找不到收起按钮时上面已 return,绝不误拦正文交互
|
|
275
|
+
if (!frame.hasAttribute('data-sidebar-collapsed') && !sidebar.contains(target)) {
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
event.stopPropagation();
|
|
278
|
+
toggle.click();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// 点击侧边栏条目(选中后回主区)→ 下一帧程序化收起。
|
|
282
|
+
// 既有防误收起守卫(dsh-draggable-nav 包装 HTMLButtonElement.prototype.click
|
|
283
|
+
// + 900ms 窗口)会拦截「点条目内次级操作图标」后的误收起,行为与 web-ui 一致。
|
|
284
|
+
if (target.closest('[class*="_toggle"]')) return;
|
|
285
|
+
if (!target.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]')) return;
|
|
286
|
+
if (raf !== 0) cancelAnimationFrame(raf);
|
|
287
|
+
raf = requestAnimationFrame(function () {
|
|
288
|
+
raf = 0;
|
|
289
|
+
if (!frame.hasAttribute('data-sidebar-collapsed')) toggle.click();
|
|
290
|
+
});
|
|
291
|
+
} catch (err) {}
|
|
292
|
+
}, true);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function ensure() {
|
|
296
|
+
if (webUiOwnsDrawer()) return;
|
|
297
|
+
var frame = document.querySelector(frameSelector);
|
|
298
|
+
if (frame) install(frame);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (document.readyState === 'loading') {
|
|
302
|
+
document.addEventListener('DOMContentLoaded', ensure);
|
|
303
|
+
} else {
|
|
304
|
+
ensure();
|
|
305
|
+
}
|
|
306
|
+
setInterval(ensure, 800);
|
|
307
|
+
})();
|
|
308
|
+
`;
|
|
43
309
|
/**
|
|
44
310
|
* 挂载底层 http.Server 门禁劫持与回环上下文虚拟化代理
|
|
45
311
|
*
|
|
@@ -47,7 +313,7 @@ export const CRYPTO_POLYFILL_SNIPPET = `
|
|
|
47
313
|
* @param gateMiddleware - 全局安全门禁拦截器函数
|
|
48
314
|
* @param store - 会话与配置管理器实例
|
|
49
315
|
*/
|
|
50
|
-
export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store) {
|
|
316
|
+
export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, styleStore) {
|
|
51
317
|
if (!server || server.__dsh_tailscale_gate_patched__)
|
|
52
318
|
return;
|
|
53
319
|
server.__dsh_tailscale_gate_patched__ = true;
|
|
@@ -55,6 +321,13 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store) {
|
|
|
55
321
|
const originalListeners = server.listeners('request').slice(0);
|
|
56
322
|
server.removeAllListeners('request');
|
|
57
323
|
server.on('request', (req, res) => {
|
|
324
|
+
// 0. 移动端判定(UA)与样式片段收集。
|
|
325
|
+
// 样式注入与 UA 无关:collectCss() 产出按【视口宽度档】包装的 CSS
|
|
326
|
+
// (mobileEnabled → @media (max-width:900px) 窄屏生效;pcEnabled → @media (min-width:901px) 宽屏生效),
|
|
327
|
+
// 因此 PC 浏览器拉小窗口时移动端样式同样生效。isMobile 仍用于 data-dsh-mobile 标记与拖拽脚本注入。
|
|
328
|
+
const userAgent = req.headers['user-agent'] || '';
|
|
329
|
+
const isMobile = isMobileUserAgent(userAgent);
|
|
330
|
+
const mobileStyleCss = styleStore ? styleStore.collectCss() : '';
|
|
58
331
|
// 0. 优先在 Socket 首道关卡记录未经虚拟化篡改的真实客户端物理 IP (防止 Keep-Alive 连接复用污染)
|
|
59
332
|
if (req.socket && !req.socket.__dsh_real_remote_address__) {
|
|
60
333
|
req.socket.__dsh_real_remote_address__ = req.socket.remoteAddress;
|
|
@@ -75,39 +348,106 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store) {
|
|
|
75
348
|
res.end = function (chunk, ...args) {
|
|
76
349
|
const currentCt = this.getHeader?.('content-type') || this.getHeader?.('Content-Type') || '';
|
|
77
350
|
const shouldInject = isHtml || String(currentCt).includes('text/html');
|
|
351
|
+
// 是否实际改写了响应体(注入会改变 body 字节长度)
|
|
352
|
+
let injected = false;
|
|
78
353
|
if (shouldInject && chunk) {
|
|
79
354
|
if (typeof chunk === 'string' && chunk.includes('<head>')) {
|
|
80
|
-
|
|
355
|
+
let html = chunk;
|
|
356
|
+
if (isMobile)
|
|
357
|
+
html = applyDataMobileAttr(html);
|
|
358
|
+
if (mobileStyleCss) {
|
|
359
|
+
html = html.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
|
|
360
|
+
}
|
|
361
|
+
// 悬浮把手拖拽脚本全端注入:脚本自带“仅折叠时工作”守卫,
|
|
362
|
+
// 预设按视口宽度生效后 PC 窄窗口同样需要可拖动的展开把手
|
|
363
|
+
html = html.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
|
|
364
|
+
// 移动端抽屉「点外部收起」全端注入:脚本自带 web-ui 让位守卫,
|
|
365
|
+
// web-ui 在场时完全交给它处理,未装时复刻等价行为
|
|
366
|
+
html = html.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
|
|
367
|
+
html = html.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
|
|
368
|
+
// Clipboard Polyfill 最后 replace → 位于 <head> 最前,先于官方 bundle 生效
|
|
369
|
+
html = html.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
|
|
370
|
+
chunk = html;
|
|
371
|
+
injected = true;
|
|
81
372
|
}
|
|
82
373
|
else if (Buffer.isBuffer(chunk)) {
|
|
83
|
-
|
|
374
|
+
let str = chunk.toString('utf8');
|
|
84
375
|
if (str.includes('<head>')) {
|
|
85
|
-
|
|
376
|
+
if (isMobile)
|
|
377
|
+
str = applyDataMobileAttr(str);
|
|
378
|
+
if (mobileStyleCss) {
|
|
379
|
+
str = str.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
|
|
380
|
+
}
|
|
381
|
+
str = str.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
|
|
382
|
+
str = str.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
|
|
383
|
+
str = str.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
|
|
384
|
+
// Clipboard Polyfill 最后 replace → 位于 <head> 最前,先于官方 bundle 生效
|
|
385
|
+
str = str.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
|
|
386
|
+
chunk = Buffer.from(str, 'utf8');
|
|
387
|
+
injected = true;
|
|
86
388
|
}
|
|
87
389
|
}
|
|
88
390
|
}
|
|
391
|
+
// 防截断守卫:仅当头部尚未发出时移除显式的 Content-Length,
|
|
392
|
+
// 让 Node 按最终 body 重新计算长度(writeHead 已发出后 removeHeader 无效,属不可挽救场景)
|
|
393
|
+
if (injected && !this.headersSent) {
|
|
394
|
+
try {
|
|
395
|
+
if (typeof this.removeHeader === 'function')
|
|
396
|
+
this.removeHeader('content-length');
|
|
397
|
+
}
|
|
398
|
+
catch { }
|
|
399
|
+
}
|
|
89
400
|
return originalEnd.apply(this, [chunk, ...args]);
|
|
90
401
|
};
|
|
91
402
|
// 2. 请求层门禁判定与上下文虚拟化
|
|
92
403
|
const allowed = gateMiddleware(req, res, () => {
|
|
93
404
|
// 记录真实客户端来源信息(内部专用头,无条件覆盖避免伪造)
|
|
94
|
-
|
|
95
|
-
req.headers['x-real-ip'] =
|
|
405
|
+
const realIp = getClientIp(req);
|
|
406
|
+
req.headers['x-dsh-real-ip'] = realIp;
|
|
407
|
+
req.headers['x-real-ip'] = realIp;
|
|
96
408
|
if (!req.headers['x-forwarded-host'] && req.headers.host) {
|
|
97
409
|
req.headers['x-forwarded-host'] = req.headers.host;
|
|
98
410
|
}
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
411
|
+
// 回环 socket(本机浏览器 / 本机程序)不做 Host/Origin/sec-fetch-site 洗白:
|
|
412
|
+
// 保留浏览器原始同源信号,让下游自身的 CSRF 与 DNS-Rebinding 校验继续生效,
|
|
413
|
+
// 防御恶意网页借道本机发起的 drive-by 跨站写与 Rebinding 攻击;虚拟化仅服务于外部合法流量。
|
|
414
|
+
const isLoopbackSocket = realIp === '127.0.0.1' || realIp === '::1';
|
|
415
|
+
if (!isLoopbackSocket) {
|
|
416
|
+
// 虚拟化 Host 与 Origin(解决外部合法流量的 DNS-Rebinding 400 阻断与同源 host === origin 严格检查)
|
|
417
|
+
const hostHeader = req.headers.host || '';
|
|
418
|
+
const port = hostHeader.includes(':') ? hostHeader.split(':')[1] : '3080';
|
|
419
|
+
req.headers.host = `127.0.0.1:${port}`;
|
|
420
|
+
if (req.headers.origin) {
|
|
421
|
+
req.headers.origin = `http://127.0.0.1:${port}`;
|
|
422
|
+
}
|
|
423
|
+
// 补齐浏览器同源探针标记(解决 task-board 等插件对 sec-fetch-site 的检查)
|
|
424
|
+
if (!req.headers['sec-fetch-site'] || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
425
|
+
req.headers['sec-fetch-site'] = 'same-origin';
|
|
426
|
+
}
|
|
105
427
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
req.headers
|
|
428
|
+
else {
|
|
429
|
+
// 仅归一化本地 Host 变体(localhost / [::1] → 127.0.0.1),其余原样保留供下游校验
|
|
430
|
+
const hostHeader = req.headers.host || '';
|
|
431
|
+
let bareHost = hostHeader;
|
|
432
|
+
if (bareHost.startsWith('[')) {
|
|
433
|
+
bareHost = bareHost.slice(1).split(']')[0];
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
bareHost = bareHost.split(':')[0];
|
|
437
|
+
}
|
|
438
|
+
bareHost = bareHost.toLowerCase();
|
|
439
|
+
if (bareHost === 'localhost' || bareHost === '::1' || /^127\./.test(bareHost)) {
|
|
440
|
+
const portMatch = hostHeader.match(/:(\d+)$/);
|
|
441
|
+
req.headers.host = `127.0.0.1:${portMatch ? portMatch[1] : '3080'}`;
|
|
442
|
+
if (req.headers.origin) {
|
|
443
|
+
req.headers.origin = `http://${req.headers.host}`;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
109
446
|
}
|
|
110
|
-
// 统一虚拟化 socket.remoteAddress 为 127.0.0.1(解除 dsh-ssh / plugin-manager 等插件写死的 loopback-only
|
|
447
|
+
// 统一虚拟化 socket.remoteAddress 为 127.0.0.1(解除 dsh-ssh / plugin-manager 等插件写死的 loopback-only 限制)。
|
|
448
|
+
// 说明:该改写仅对外部来源流量产生实际效果——真实回环请求的 remoteAddress 本就是 127.0.0.1(下方守卫使其为 no-op);
|
|
449
|
+
// drive-by 攻击者的浏览器同样真实 originating 自 127.0.0.1,socket 层无法也无需区分攻击者,
|
|
450
|
+
// 跨站防御由门禁层 Origin / Sec-Fetch-Site 校验(虚拟化改写之前执行)与上方回环 Host 保留共同承担。
|
|
111
451
|
if (req.socket && req.socket.remoteAddress !== '127.0.0.1') {
|
|
112
452
|
try {
|
|
113
453
|
Object.defineProperty(req.socket, 'remoteAddress', {
|
|
@@ -136,7 +476,10 @@ export function mountIndexInjections(ctx) {
|
|
|
136
476
|
if (ctx.webServer && typeof ctx.webServer.tapIndex === 'function') {
|
|
137
477
|
ctx.webServer.tapIndex((html) => {
|
|
138
478
|
if (html.includes('<head>')) {
|
|
139
|
-
return html
|
|
479
|
+
return html
|
|
480
|
+
.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`)
|
|
481
|
+
// Clipboard Polyfill 后 replace → 位于 <head> 最前,先于官方 bundle 生效
|
|
482
|
+
.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
|
|
140
483
|
}
|
|
141
484
|
return html;
|
|
142
485
|
});
|
|
@@ -148,44 +491,212 @@ export function mountIndexInjections(ctx) {
|
|
|
148
491
|
placement: 'head',
|
|
149
492
|
text: CRYPTO_POLYFILL_SNIPPET,
|
|
150
493
|
});
|
|
494
|
+
table.push({
|
|
495
|
+
kind: 'script',
|
|
496
|
+
placement: 'head',
|
|
497
|
+
text: CLIPBOARD_POLYFILL_SNIPPET,
|
|
498
|
+
});
|
|
151
499
|
}
|
|
152
500
|
});
|
|
153
501
|
}
|
|
502
|
+
const pairingBridgeState = {
|
|
503
|
+
mode: 'pending',
|
|
504
|
+
conflictWith: null,
|
|
505
|
+
conflictEntryId: null,
|
|
506
|
+
};
|
|
507
|
+
/** 读取配对桥接当前裁决状态(快照) */
|
|
508
|
+
export function getPairingBridgeState() {
|
|
509
|
+
return { ...pairingBridgeState };
|
|
510
|
+
}
|
|
511
|
+
const PAIRING_SERVICE_NAME = 'remoteWebUiPairing';
|
|
512
|
+
/** 本插件在 loader 树中的模块名(从“兄弟 entry 尚未落定”判定中排除自身) */
|
|
513
|
+
export const PLUGIN_MODULE_NAME = 'dsh-remote-mobile';
|
|
154
514
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
515
|
+
* Cordis Fiber 运行态镜像(跨包 const enum 无运行时对象):
|
|
516
|
+
* 0=PENDING,1=LOADING,2=ACTIVE,3=FAILED
|
|
517
|
+
*/
|
|
518
|
+
const FIBER_STATE_PENDING = 0;
|
|
519
|
+
const FIBER_STATE_LOADING = 1;
|
|
520
|
+
/** 最短观察期:给并发创建中的兄弟 loader entry 留出出现时间 */
|
|
521
|
+
const MIN_SETTLE_GRACE_MS = 600;
|
|
522
|
+
/** 激活窗口硬性兜底时限(超过即按“无其他注册者”处理) */
|
|
523
|
+
const BRIDGE_MAX_WAIT_MS = 10000;
|
|
524
|
+
/** 裁决轮询间隔 */
|
|
525
|
+
const BRIDGE_DECISION_POLL_MS = 200;
|
|
526
|
+
/**
|
|
527
|
+
* 向 Cordis 容器桥接注册 remoteWebUiPairing 服务(延迟裁决版,通用共存保护)
|
|
528
|
+
*
|
|
529
|
+
* 背景:remoteWebUiPairing 是远程/Web 接入类插件的通用共享服务名(生态插件经
|
|
530
|
+
* inject 消费)。任何其他插件先注册同名服务后,本插件再
|
|
531
|
+
* 注册必然抛错;而 loader 以 Promise.allSettled 并发激活全部 entry,任一失败会
|
|
532
|
+
* 回滚整棵插件树并使进程退出(致命)。
|
|
533
|
+
*
|
|
534
|
+
* 因此这里不「立即抢注」,而是轮询裁决:
|
|
535
|
+
* 1. 服务名已被占用 → 让出(mode: yielded),并尽力定位提供方包名供界面提示;
|
|
536
|
+
* 2. 兄弟 entry 全部落定且无人注册(未安装 / 被禁用 / 自身激活失败)→ 接管;
|
|
537
|
+
* 3. 最长等待 BRIDGE_MAX_WAIT_MS 兜底;极端竞速下若注册瞬间输给对方,
|
|
538
|
+
* 捕获异常同样按让出处理,绝不向外抛错。
|
|
539
|
+
*
|
|
540
|
+
* 判定完全基于 Cordis 公开运行时结构(服务名占用 + fiber 运行态),不针对
|
|
541
|
+
* 任何特定第三方插件。
|
|
157
542
|
*
|
|
158
543
|
* @param ctx - Cordis 上下文对象
|
|
159
544
|
* @param store - 会话管理器实例
|
|
160
545
|
*/
|
|
161
546
|
export function registerRemoteWebUiPairingBridge(ctx, store) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
547
|
+
const startedAt = Date.now();
|
|
548
|
+
let settled = false;
|
|
549
|
+
let timer = null;
|
|
550
|
+
const clearTimer = () => {
|
|
551
|
+
if (timer) {
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
timer = null;
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
const settleYielded = (provider) => {
|
|
557
|
+
if (settled)
|
|
558
|
+
return;
|
|
559
|
+
settled = true;
|
|
560
|
+
clearTimer();
|
|
561
|
+
pairingBridgeState.mode = 'yielded';
|
|
562
|
+
pairingBridgeState.conflictWith = provider.name;
|
|
563
|
+
pairingBridgeState.conflictEntryId = provider.id;
|
|
564
|
+
ctx?.logger?.warn?.(`[dsh-remote-mobile] 检测到${provider.name ? ` ${provider.name} ` : '其他插件'}已注册 "${PAIRING_SERVICE_NAME}" 配对共享服务:` +
|
|
565
|
+
'本插件已自动让出以避免启动冲突。两套远程接入功能并存可能出现重复入口,建议保留其一(禁用其中一款远程插件后重启)。详见设置页顶部提示。');
|
|
566
|
+
};
|
|
567
|
+
const settleActive = () => {
|
|
568
|
+
if (settled)
|
|
569
|
+
return;
|
|
570
|
+
settled = true;
|
|
571
|
+
clearTimer();
|
|
572
|
+
pairingBridgeState.mode = 'active';
|
|
573
|
+
pairingBridgeState.conflictWith = null;
|
|
574
|
+
ctx?.logger?.info?.(`[dsh-remote-mobile] ${PAIRING_SERVICE_NAME} 配对共享服务已由本插件提供(生态插件免登互通可用)`);
|
|
575
|
+
};
|
|
576
|
+
const serviceTaken = () => {
|
|
577
|
+
try {
|
|
578
|
+
// ctx.get 对未注册服务返回 undefined 而不抛错;strict 模式只认激活中的提供者
|
|
579
|
+
return Boolean(ctx?.get?.(PAIRING_SERVICE_NAME));
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
const getLoaderEntries = () => {
|
|
586
|
+
try {
|
|
587
|
+
const loader = typeof ctx?.get === 'function' ? ctx.get('loader') : undefined;
|
|
588
|
+
if (loader && typeof loader.entries === 'function')
|
|
589
|
+
return [...loader.entries()];
|
|
590
|
+
}
|
|
591
|
+
catch { }
|
|
592
|
+
return [];
|
|
593
|
+
};
|
|
594
|
+
/**
|
|
595
|
+
* 定位当前占用 PAIRING_SERVICE_NAME 的插件(包名 + loader entry id):
|
|
596
|
+
* provide() 会把服务实现记录在提供方的 fiber.store 上(cordis 公开行为),
|
|
597
|
+
* 据此无需针对特定插件做识别。entry id 用于生成精确的 disabled: true 修复配置。
|
|
598
|
+
* 找不到则返回 { name: null, id: null }(前端回退通用文案)。
|
|
599
|
+
*/
|
|
600
|
+
const findProviderEntry = () => {
|
|
601
|
+
for (const entry of getLoaderEntries()) {
|
|
602
|
+
try {
|
|
603
|
+
const opts = entry?.options;
|
|
604
|
+
if (!opts || opts.group)
|
|
605
|
+
continue;
|
|
606
|
+
if (entry.fiber?.store?.[PAIRING_SERVICE_NAME]) {
|
|
607
|
+
const name = typeof opts.name === 'string' ? opts.name : '';
|
|
608
|
+
const id = typeof entry.id === 'string' && entry.id ? entry.id : null;
|
|
609
|
+
return { name: name || null, id };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch { }
|
|
613
|
+
}
|
|
614
|
+
return { name: null, id: null };
|
|
615
|
+
};
|
|
616
|
+
/**
|
|
617
|
+
* 是否仍存在「尚未落定」的其他启用插件 entry(fiber 未挂上 / PENDING /
|
|
618
|
+
* LOADING)。全部落定前抢注服务有输给慢加载插件的风险,因此等待;
|
|
619
|
+
* 自身 entry 通过模块名与对象身份双通道排除。
|
|
620
|
+
*/
|
|
621
|
+
const othersSettling = () => {
|
|
622
|
+
for (const entry of getLoaderEntries()) {
|
|
623
|
+
try {
|
|
624
|
+
const opts = entry?.options;
|
|
625
|
+
if (!opts || opts.group)
|
|
626
|
+
continue;
|
|
627
|
+
if (opts.name === PLUGIN_MODULE_NAME)
|
|
628
|
+
continue;
|
|
629
|
+
if (ctx?.fiber && entry === ctx.fiber.entry)
|
|
630
|
+
continue;
|
|
631
|
+
if (entry.disabled)
|
|
632
|
+
continue;
|
|
633
|
+
const state = entry.fiber?.state;
|
|
634
|
+
if (!entry.fiber || state === FIBER_STATE_PENDING || state === FIBER_STATE_LOADING)
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
catch { }
|
|
638
|
+
}
|
|
639
|
+
return false;
|
|
640
|
+
};
|
|
641
|
+
const tryRegisterOurs = async () => {
|
|
642
|
+
if (settled)
|
|
643
|
+
return;
|
|
644
|
+
// 双重检查:对方可能刚好在我们裁决前完成同步注册
|
|
645
|
+
if (serviceTaken())
|
|
646
|
+
return settleYielded(findProviderEntry());
|
|
647
|
+
try {
|
|
648
|
+
// 经 new Function 动态导入,避免打包期解析内部模块说明符
|
|
649
|
+
const importCordis = new Function('return import("@deepseek-ai/cordis")');
|
|
650
|
+
const m = await importCordis();
|
|
165
651
|
const ServiceClass = m?.Service || ctx?.constructor?.Service;
|
|
166
|
-
if (ServiceClass)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return
|
|
184
|
-
|
|
652
|
+
if (!ServiceClass)
|
|
653
|
+
throw new Error('cordis Service unavailable');
|
|
654
|
+
class RemoteWebUiPairingBridge extends ServiceClass {
|
|
655
|
+
constructor(c) {
|
|
656
|
+
super(c, PAIRING_SERVICE_NAME);
|
|
657
|
+
}
|
|
658
|
+
isPairedDevice(req) {
|
|
659
|
+
const clientIp = getClientIp(req);
|
|
660
|
+
const options = store.getOptions();
|
|
661
|
+
// 1. 免密直连设备自动放行
|
|
662
|
+
if (options.allowTailscale && isTailscaleIp(clientIp))
|
|
663
|
+
return true;
|
|
664
|
+
if (options.allowLan && isLanIp(clientIp))
|
|
665
|
+
return true;
|
|
666
|
+
// 2. 已通过配对码/长期密码认证的设备放行
|
|
667
|
+
const token = store.extractTokenFromRequest(req);
|
|
668
|
+
if (token && store.validateToken(token, clientIp))
|
|
669
|
+
return true;
|
|
670
|
+
return false;
|
|
185
671
|
}
|
|
186
|
-
new RemoteWebUiPairingBridge(ctx);
|
|
187
672
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
673
|
+
new RemoteWebUiPairingBridge(ctx);
|
|
674
|
+
settleActive();
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
// 输掉最后时刻的竞速 → 让出;其余错误仅降级(安全门禁不依赖该服务),绝不向外抛错
|
|
678
|
+
if (serviceTaken())
|
|
679
|
+
return settleYielded(findProviderEntry());
|
|
680
|
+
settled = true;
|
|
681
|
+
clearTimer();
|
|
682
|
+
pairingBridgeState.mode = 'active';
|
|
683
|
+
pairingBridgeState.conflictWith = null;
|
|
684
|
+
ctx?.logger?.warn?.(`[dsh-remote-mobile] ${PAIRING_SERVICE_NAME} 服务注册失败(不影响本插件安全门禁与远程接入): ${error instanceof Error ? error.message : String(error)}`);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
const decide = async () => {
|
|
688
|
+
if (settled)
|
|
689
|
+
return;
|
|
690
|
+
if (serviceTaken())
|
|
691
|
+
return settleYielded(findProviderEntry());
|
|
692
|
+
const elapsed = Date.now() - startedAt;
|
|
693
|
+
// 满足任一条件即接管:兄弟 entry 全部落定(过了最短观察期)或到达兜底时限
|
|
694
|
+
if ((elapsed >= MIN_SETTLE_GRACE_MS && !othersSettling()) || elapsed >= BRIDGE_MAX_WAIT_MS) {
|
|
695
|
+
return tryRegisterOurs();
|
|
696
|
+
}
|
|
697
|
+
timer = setTimeout(() => { decide().catch(() => { }); }, BRIDGE_DECISION_POLL_MS);
|
|
698
|
+
if (typeof timer.unref === 'function')
|
|
699
|
+
timer.unref();
|
|
700
|
+
};
|
|
701
|
+
decide().catch(() => { });
|
|
191
702
|
}
|