@mrrisega/dsh-remote 0.6.0-beta.3 → 0.6.0-beta.4
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/clients/dsh-remote/dsh-bridge.mjs +14 -1
- package/clients/dsh-remote/mobile-adapter.mjs +291 -0
- package/clients/dsh-remote/test/bridge-html-inject.test.mjs +110 -0
- package/clients/dsh-remote/test/mobile-adapter.test.mjs +102 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/index.js +1 -1
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* DSH_BRIDGE_API 账号 API 地址(默认云端服务地址;自建模式无需设置)
|
|
42
42
|
* DSH_BRIDGE_LOCAL_KEY 开源自部署:访问密钥(设后经 router POST /_login 换本地 JWT,免账号体系)
|
|
43
43
|
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认 15000ms)
|
|
44
|
+
* DSH_MOBILE_ADAPTER 经隧道访问的官方 dsh web 移动端适配注入开关:0 关闭(默认开启,仅 ≤820px 生效)
|
|
44
45
|
*/
|
|
45
46
|
|
|
46
47
|
import WebSocket from "ws";
|
|
@@ -52,6 +53,8 @@ import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
|
|
|
52
53
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
53
54
|
import { promisify } from "node:util";
|
|
54
55
|
import { gzip as gzipCb } from "node:zlib";
|
|
56
|
+
// 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
|
|
57
|
+
import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
|
|
55
58
|
|
|
56
59
|
// ---------- 强制直连:清除代理环境变量 ----------
|
|
57
60
|
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
@@ -404,12 +407,22 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
404
407
|
}
|
|
405
408
|
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
|
|
406
409
|
let buf = Buffer.from(await res.arrayBuffer());
|
|
410
|
+
// 移动端适配层:text/html(含 </head> 且匹配官方特征)在 gzip 前注入响应式 <style>/<script>;
|
|
411
|
+
// 非 html / SSE / 二进制 / 上游已压缩等其余响应一律原样(env DSH_MOBILE_ADAPTER=0 关闭)。
|
|
407
412
|
// sanitizeResponseHeaders 会剥 content-encoding(undici 已解压,原头会误导浏览器);
|
|
408
413
|
// 若我们自行 gzip,必须在 sanitize 之后把 content-encoding: gzip 补回,手机才能正确解压。
|
|
409
414
|
const headers = sanitizeResponseHeaders(Object.fromEntries(res.headers.entries()));
|
|
415
|
+
const contentType = res.headers.get("content-type") || "";
|
|
416
|
+
if (!contentType.includes("text/event-stream")) { // SSE 长连不缓冲不注入(见模块注释)
|
|
417
|
+
const m = maybeInjectMobileAdapter({ buf, contentType });
|
|
418
|
+
if (m.injected) {
|
|
419
|
+
console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
|
|
420
|
+
buf = m.buf;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
410
423
|
const compressed = await maybeCompressResponse({
|
|
411
424
|
buf,
|
|
412
|
-
contentType
|
|
425
|
+
contentType,
|
|
413
426
|
contentEncoding: res.headers.get("content-encoding") || "",
|
|
414
427
|
acceptEncoding: headerValue(reqHeaders, "accept-encoding"),
|
|
415
428
|
status: res.status,
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mobile-adapter.mjs — bridge 侧注入的“经隧道访问的官方 dsh web”移动端适配层。
|
|
3
|
+
*
|
|
4
|
+
* 注入目标(仅经隧道回传的手机页面;官方包 / 桌面宽屏不受影响):
|
|
5
|
+
* - 官方 dsh web(dsh-web-frontend 0.1.2-rc.1)index.html 的 </head> 前,由 dsh-bridge.mjs
|
|
6
|
+
* 对 content-type 含 text/html 的上游响应注入 <style id="dsh-mobile-adapter-css"> 与
|
|
7
|
+
* <script id="dsh-mobile-adapter-js">。
|
|
8
|
+
*
|
|
9
|
+
* 设计依据(实测 390×844 DOM,非猜测):
|
|
10
|
+
* - 布局外壳:官方 .pI_x6G_frame 为三列 grid(内联 gridTemplateColumns),侧栏/详情列通过
|
|
11
|
+
* data-sidebar-collapsed / data-details-collapsed 表达状态;官方在 <1024px 时把侧栏自动
|
|
12
|
+
* 收成 56px 图标 rail,再手动展开(narrowExpanded)时却把中心列挤到 ~110px(实测 grid
|
|
13
|
+
* "280px 110px 0px")——这就是“侧栏挤压/设置内容可视区不足”的根因。
|
|
14
|
+
* - 适配策略:
|
|
15
|
+
* 1) ≤820px 强制三列轨宽为 0 / 1fr / 0,内容区全宽;侧栏/详情改造成离屏浮层(off-canvas),
|
|
16
|
+
* 顶部放一个小鲸鱼/菜单按钮,点击 = 程序化点击官方 toggle(aria-label 打开/关闭侧边栏),
|
|
17
|
+
* 不复制官方状态,数据属性仍是唯一真源;
|
|
18
|
+
* 2) 设置面板(实测 .VOzbGW_overlay 是 z-index:1000 的 fixed 全屏,panel 内 nav 188px +
|
|
19
|
+
* content 154px 左右并排)→ 手机改为上下堆叠:顶部固定 tab 行(横向滚动),下面全高滚动内容;
|
|
20
|
+
* 3) 输入控件字号 ≥16px 防 iOS 聚焦缩放;hero 的工作区/模式座位行允许换行,防 212px 座位
|
|
21
|
+
* 右缘伸出视口(实测 cubgiG_seat right=409 > 390);
|
|
22
|
+
* 4) html/body overflow-x 防护、safe-area inset、触控目标友好;配色只用官方 CSS 变量。
|
|
23
|
+
*
|
|
24
|
+
* 宿主特征门(防误注入其它 html):
|
|
25
|
+
* - 注入前:content-type 必须 text/html 且含 </head> 且原文含官方标记
|
|
26
|
+
* (__ModuleLoader__ / @deepseek-ai/dsh-client-* / <title>DeepSeek Harness</title>);
|
|
27
|
+
* - 运行时:注入脚本在 window.innerWidth ≤ 820 且 DOM 出现官方 frame/overlay 特征时才动作。
|
|
28
|
+
*
|
|
29
|
+
* 类名变化退化策略(官方升级重打包导致 pI_x6G_* / hHd-Xa_* / VOzbGW_* 等前缀改变时):
|
|
30
|
+
* - 结构优先用官方固定输出的 data-* 属性与 aria-label;
|
|
31
|
+
* - JS 用结构探测(frame → 子列 → 打 dsh-ma-* 稳定类)承载布局,不以 hashed 类为唯一钩子;
|
|
32
|
+
* - 命中不到 frame 时静默跳过,页面保持官方默认,绝不报错/破坏。
|
|
33
|
+
*
|
|
34
|
+
* 开关:环境变量 DSH_MOBILE_ADAPTER=0 整体关闭(默认开启)。
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const ADAPTER_ID = "dsh-mobile-adapter";
|
|
38
|
+
|
|
39
|
+
/* 注入前 raw HTML 层官方特征(0.1.2-rc.1 index.html 原文稳定存在)。 */
|
|
40
|
+
const HOST_FEATURE_RE =
|
|
41
|
+
/(__ModuleLoader__|@deepseek-ai\/dsh-client-modules|@deepseek-ai\/dsh-client-connection|<title>\s*DeepSeek Harness)/i;
|
|
42
|
+
|
|
43
|
+
/* 运行时 DOM 特征(注入脚本内再次校验;data-* 由官方组件固定输出,不随 CSS modules 改名)。 */
|
|
44
|
+
const RUNTIME_HOST_RE =
|
|
45
|
+
/(data-sidebar-collapsed|data-details-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
|
|
46
|
+
|
|
47
|
+
const STYLE = `
|
|
48
|
+
/* ===== dsh-remote mobile adapter (bridge 注入;仅 ≤820px 生效,桌面宽屏无任何规则) ===== */
|
|
49
|
+
@media (max-width: 820px) {
|
|
50
|
+
html, body { max-width: 100%; overflow-x: hidden; }
|
|
51
|
+
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
|
52
|
+
|
|
53
|
+
/* --- 三列 frame → 内容区全宽(官方内联 gridTemplateColumns 用 !important 覆盖) --- */
|
|
54
|
+
div.pI_x6G_frame, div.dsh-ma-frame { grid-template-columns: 0 minmax(0, 1fr) 0 !important; min-width: 0; }
|
|
55
|
+
/* 侧栏/详情已移出 grid 流(fixed),唯一在流的中心列须显式落到 1fr 轨道,否则自动放置会被塞进 0px 轨 */
|
|
56
|
+
div.pI_x6G_centerCol, div.dsh-ma-center { grid-column: 2; min-width: 0; width: auto; }
|
|
57
|
+
|
|
58
|
+
/* --- 侧栏 / 详情:离屏浮层(off-canvas),展开态滑入 --- */
|
|
59
|
+
div.pI_x6G_sidebarCol, div.dsh-ma-sidebar {
|
|
60
|
+
position: fixed; left: 0; top: 0; bottom: 0; margin: 0;
|
|
61
|
+
width: min(84vw, 340px); max-width: 92vw;
|
|
62
|
+
z-index: 300; overflow: hidden;
|
|
63
|
+
border-right: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.25));
|
|
64
|
+
box-shadow: 0 10px 44px rgba(0,0,0,.26);
|
|
65
|
+
transform: translateX(-103%);
|
|
66
|
+
transition: transform .22s var(--ds-ease-in-out, ease);
|
|
67
|
+
will-change: transform;
|
|
68
|
+
}
|
|
69
|
+
html.dsh-ma-sidebar-open div.pI_x6G_sidebarCol,
|
|
70
|
+
html.dsh-ma-sidebar-open div.dsh-ma-sidebar { transform: none; }
|
|
71
|
+
|
|
72
|
+
div.pI_x6G_detailsCol, div.dsh-ma-details {
|
|
73
|
+
position: fixed; right: 0; top: 0; bottom: 0; margin: 0;
|
|
74
|
+
width: min(92vw, 400px); max-width: 96vw;
|
|
75
|
+
z-index: 300; overflow: hidden;
|
|
76
|
+
border-left: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.25));
|
|
77
|
+
box-shadow: 0 10px 44px rgba(0,0,0,.26);
|
|
78
|
+
transform: translateX(103%);
|
|
79
|
+
transition: transform .22s var(--ds-ease-in-out, ease);
|
|
80
|
+
will-change: transform;
|
|
81
|
+
}
|
|
82
|
+
html.dsh-ma-details-open div.pI_x6G_detailsCol,
|
|
83
|
+
html.dsh-ma-details-open div.dsh-ma-details { transform: none; }
|
|
84
|
+
|
|
85
|
+
/* --- 展开遮罩(在浮层下、在官方弹层下) --- */
|
|
86
|
+
div.dsh-ma-scrim {
|
|
87
|
+
position: fixed; inset: 0; z-index: 290;
|
|
88
|
+
background: rgba(0,0,0,.32);
|
|
89
|
+
opacity: 0; pointer-events: none;
|
|
90
|
+
transition: opacity .22s ease;
|
|
91
|
+
}
|
|
92
|
+
html.dsh-ma-sidebar-open div.dsh-ma-scrim,
|
|
93
|
+
html.dsh-ma-details-open div.dsh-ma-scrim { opacity: 1; pointer-events: auto; }
|
|
94
|
+
|
|
95
|
+
/* --- 顶部小鲸鱼/菜单按钮(侧栏收起时可见) --- */
|
|
96
|
+
button.dsh-ma-hamburger {
|
|
97
|
+
position: fixed;
|
|
98
|
+
top: max(10px, env(safe-area-inset-top));
|
|
99
|
+
left: max(10px, env(safe-area-inset-left));
|
|
100
|
+
z-index: 260;
|
|
101
|
+
width: 42px; height: 42px; padding: 0;
|
|
102
|
+
display: flex; align-items: center; justify-content: center;
|
|
103
|
+
border-radius: 12px;
|
|
104
|
+
background: var(--dsw-alias-button-elevated-fill, rgba(255,255,255,.96));
|
|
105
|
+
border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
|
|
106
|
+
box-shadow: 0 1px 6px rgba(0,0,0,.16);
|
|
107
|
+
color: var(--dsw-alias-label-primary, #111);
|
|
108
|
+
cursor: pointer; touch-action: manipulation;
|
|
109
|
+
}
|
|
110
|
+
button.dsh-ma-hamburger:active { transform: scale(.92); }
|
|
111
|
+
|
|
112
|
+
/* --- 输入字号 ≥16px(防 iOS 聚焦自动缩放) --- */
|
|
113
|
+
textarea,
|
|
114
|
+
input:not([type]), input[type="text"], input[type="search"], input[type="email"],
|
|
115
|
+
input[type="url"], input[type="tel"], input[type="number"], input[type="password"],
|
|
116
|
+
[contenteditable="true"] { font-size: 16px !important; }
|
|
117
|
+
select, button, a, [role="button"], [role="menuitem"], [role="option"], [role="tab"] { touch-action: manipulation; }
|
|
118
|
+
|
|
119
|
+
/* --- hero 工作区/模式座位行:可换行,防右缘被裁(实测 cubgiG_seat right=409>390) --- */
|
|
120
|
+
div.wSkVaW_heroWorkspaceRow { flex-wrap: wrap; row-gap: 8px; column-gap: 8px; justify-content: center; min-width: 0; }
|
|
121
|
+
div.wSkVaW_heroWorkspaceRow > * { max-width: calc(100vw - 32px); }
|
|
122
|
+
div.wSkVaW_composerStack, div.wSkVaW_composerHero, div.uV2eYG_card { min-width: 0; }
|
|
123
|
+
|
|
124
|
+
/* --- 设置面板(实测 .VOzbGW_overlay fixed z-index:1000,panel 内 nav 188 + content 154 并排)
|
|
125
|
+
手机改为上下堆叠:顶部 tab 行固定/横向滚动,下方内容区最大化滚动 --- */
|
|
126
|
+
div.VOzbGW_overlay { justify-content: center; align-items: stretch; }
|
|
127
|
+
div.VOzbGW_panel {
|
|
128
|
+
width: 100%; max-width: 100%;
|
|
129
|
+
height: 100%; max-height: 100%;
|
|
130
|
+
border-radius: 0;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
}
|
|
133
|
+
div.VOzbGW_nav {
|
|
134
|
+
box-sizing: border-box;
|
|
135
|
+
width: 100% !important; height: auto; flex: none;
|
|
136
|
+
flex-direction: row; align-items: center;
|
|
137
|
+
gap: 8px; padding: 8px 10px 4px;
|
|
138
|
+
overflow-x: auto; overflow-y: hidden;
|
|
139
|
+
}
|
|
140
|
+
div.VOzbGW_navTitle { flex: none; }
|
|
141
|
+
div.VOzbGW_navList {
|
|
142
|
+
box-sizing: border-box;
|
|
143
|
+
flex: 1 1 auto; min-width: 0;
|
|
144
|
+
flex-direction: row !important;
|
|
145
|
+
gap: 6px; overflow-x: auto; overflow-y: hidden;
|
|
146
|
+
padding-bottom: 2px;
|
|
147
|
+
}
|
|
148
|
+
div.VOzbGW_navList > button, button.VOzbGW_navCell {
|
|
149
|
+
flex: none; min-width: max-content; padding: 10px 14px;
|
|
150
|
+
}
|
|
151
|
+
div.VOzbGW_content { width: 100% !important; min-width: 0; flex: 1 1 auto; min-height: 0; }
|
|
152
|
+
div.VOzbGW_options {
|
|
153
|
+
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
|
154
|
+
-webkit-overflow-scrolling: touch;
|
|
155
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* 桌面宽屏(>820px):本文件无任何规则 → 对直连/桌面零影响。 */
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
const SCRIPT = `(() => {
|
|
163
|
+
"use strict";
|
|
164
|
+
try {
|
|
165
|
+
const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
|
|
166
|
+
const NARROW = () => window.innerWidth <= 820;
|
|
167
|
+
const HOSTISH = () =>
|
|
168
|
+
HOST_RE.test(document.documentElement.outerHTML.slice(0, 200000)) ||
|
|
169
|
+
!!document.querySelector("[data-shell-overlay], [data-sidebar-collapsed], [data-details-collapsed]");
|
|
170
|
+
|
|
171
|
+
let done = false;
|
|
172
|
+
const boot = () => {
|
|
173
|
+
if (done || !NARROW() || !HOSTISH()) return;
|
|
174
|
+
const frame = document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed]");
|
|
175
|
+
if (!frame || !frame.querySelector) return;
|
|
176
|
+
done = true;
|
|
177
|
+
frame.classList.add("dsh-ma-frame");
|
|
178
|
+
|
|
179
|
+
/* 给 frame 子列打稳定标记(dsh-ma-*),规避官方类名前缀随构建变化 */
|
|
180
|
+
let sidebar = null, details = null, center = null;
|
|
181
|
+
const cols = [...frame.children].filter((c) => c instanceof HTMLElement);
|
|
182
|
+
for (const col of cols) {
|
|
183
|
+
const cls = typeof col.className === "string" ? col.className : "";
|
|
184
|
+
if (/sidebar/i.test(cls) || col.querySelector(".hHd-Xa_root, [aria-label*='侧边栏'], [aria-label*='sidebar' i]")) {
|
|
185
|
+
col.classList.add("dsh-ma-sidebar"); sidebar = sidebar || col;
|
|
186
|
+
} else if (/details/i.test(cls)) {
|
|
187
|
+
col.classList.add("dsh-ma-details"); details = details || col;
|
|
188
|
+
} else if (/center/i.test(cls)) {
|
|
189
|
+
col.classList.add("dsh-ma-center"); center = center || col;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!sidebar && cols[0]) { cols[0].classList.add("dsh-ma-sidebar"); sidebar = sidebar || cols[0]; }
|
|
193
|
+
if (!center && cols[1]) { cols[1].classList.add("dsh-ma-center"); center = center || cols[1]; }
|
|
194
|
+
if (!details && cols[cols.length - 1] && cols[cols.length - 1] !== sidebar && cols[cols.length - 1] !== center) {
|
|
195
|
+
cols[cols.length - 1].classList.add("dsh-ma-details"); details = details || cols[cols.length - 1];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const toggleSel = '[aria-label="打开侧边栏"], [aria-label="关闭侧边栏"], [aria-label="Open sidebar"], [aria-label="Close sidebar"]';
|
|
199
|
+
const toggleOf = () => document.querySelector(toggleSel) || document.querySelector(".hHd-Xa_toggle");
|
|
200
|
+
const expanded = () => !frame.hasAttribute("data-sidebar-collapsed");
|
|
201
|
+
|
|
202
|
+
/* 遮罩:点击 = 点官方 toggle(走官方 store,无私有状态) */
|
|
203
|
+
const scrim = document.createElement("div");
|
|
204
|
+
scrim.className = "dsh-ma-scrim";
|
|
205
|
+
document.body.appendChild(scrim);
|
|
206
|
+
scrim.addEventListener("click", (e) => { e.stopPropagation(); const t = toggleOf(); if (t) t.click(); });
|
|
207
|
+
|
|
208
|
+
/* 顶部小鲸鱼/菜单按钮 */
|
|
209
|
+
const hamburger = document.createElement("button");
|
|
210
|
+
hamburger.type = "button";
|
|
211
|
+
hamburger.className = "dsh-ma-hamburger";
|
|
212
|
+
hamburger.setAttribute("aria-label", "打开菜单");
|
|
213
|
+
hamburger.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>';
|
|
214
|
+
document.body.appendChild(hamburger);
|
|
215
|
+
hamburger.addEventListener("click", (e) => {
|
|
216
|
+
e.stopPropagation(); e.preventDefault();
|
|
217
|
+
const t = toggleOf();
|
|
218
|
+
if (t) t.click();
|
|
219
|
+
else document.documentElement.classList.add("dsh-ma-sidebar-open"); /* 兜底 */
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/* 状态唯一真源 = 官方 frame 的 data 属性 */
|
|
223
|
+
const sync = () => {
|
|
224
|
+
const open = expanded();
|
|
225
|
+
document.documentElement.classList.toggle("dsh-ma-sidebar-open", open);
|
|
226
|
+
if (hamburger) hamburger.style.display = open ? "none" : "";
|
|
227
|
+
const dOpen = !frame.hasAttribute("data-details-collapsed") && !!details;
|
|
228
|
+
document.documentElement.classList.toggle("dsh-ma-details-open", dOpen);
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
new MutationObserver(sync).observe(frame, { attributes: true, attributeFilter: ["data-sidebar-collapsed", "data-details-collapsed"] });
|
|
232
|
+
} catch (e2) { /* 退化:仅在下次 boot 同步 */ }
|
|
233
|
+
sync();
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
|
|
237
|
+
/* 官方模块系统异步挂载 UI,晚一点再补几次(幂等,done 守卫) */
|
|
238
|
+
[700, 1600, 3200, 7000, 12000].forEach((ms) => setTimeout(boot, ms));
|
|
239
|
+
} catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
|
|
240
|
+
})();`;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 纯函数:把适配层注入到上游 HTML 的 </head> 之前。
|
|
244
|
+
* @param {string} html
|
|
245
|
+
* @returns {{html:string, injected:boolean}}
|
|
246
|
+
*/
|
|
247
|
+
export function injectMobileAdapter(html) {
|
|
248
|
+
const src = String(html);
|
|
249
|
+
const headIdx = src.toLowerCase().lastIndexOf("</head>");
|
|
250
|
+
if (headIdx === -1) return { html: src, injected: false };
|
|
251
|
+
const block =
|
|
252
|
+
`\n<style id="${ADAPTER_ID}-css" data-dsh-mobile-adapter>${STYLE}</style>` +
|
|
253
|
+
`\n<script id="${ADAPTER_ID}-js" data-dsh-mobile-adapter>${SCRIPT}</script>\n`;
|
|
254
|
+
return { html: src.slice(0, headIdx) + block + src.slice(headIdx), injected: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 环境开关:DSH_MOBILE_ADAPTER=0 关闭(默认开启)。 */
|
|
258
|
+
export function mobileAdapterEnabled() {
|
|
259
|
+
return process.env.DSH_MOBILE_ADAPTER !== "0";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 判断上游响应是否应注入(仅官方 dsh web 的 text/html)。
|
|
264
|
+
* @param {{contentType?:string, html?:string}} opts
|
|
265
|
+
*/
|
|
266
|
+
export function shouldInjectHtml({ contentType, html }) {
|
|
267
|
+
const ct = String(contentType || "");
|
|
268
|
+
if (!/text\/html/i.test(ct)) return false;
|
|
269
|
+
const src = String(html || "");
|
|
270
|
+
if (!/<\/head>/i.test(src)) return false;
|
|
271
|
+
if (/\uFFFD/.test(src)) return false; // 非 UTF-8,不冒险改写
|
|
272
|
+
return HOST_FEATURE_RE.test(src);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* bridge 接线主入口:开关 + 类型/特征 gate + 注入,一步完成。
|
|
277
|
+
* @param {{buf:Buffer, contentType?:string}} args
|
|
278
|
+
* @returns {{buf:Buffer, injected:boolean}}
|
|
279
|
+
*/
|
|
280
|
+
export function maybeInjectMobileAdapter({ buf, contentType }) {
|
|
281
|
+
if (!mobileAdapterEnabled() || !Buffer.isBuffer(buf) || buf.length === 0) {
|
|
282
|
+
return { buf, injected: false };
|
|
283
|
+
}
|
|
284
|
+
const text = buf.toString("utf8");
|
|
285
|
+
if (!shouldInjectHtml({ contentType, html: text })) return { buf, injected: false };
|
|
286
|
+
const out = injectMobileAdapter(text);
|
|
287
|
+
return { buf: Buffer.from(out.html, "utf8"), injected: out.injected };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const ADAPTER_TAG = ADAPTER_ID;
|
|
291
|
+
export default { injectMobileAdapter, maybeInjectMobileAdapter, shouldInjectHtml, mobileAdapterEnabled };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bridge HTML 注入集成测试(经 doHttp/handleHttpFrame 全链路):
|
|
4
|
+
* - 官方特征 html 响应 → reply body 含注入的 <style data-dsh-mobile-adapter>;
|
|
5
|
+
* - 同时请求带 gzip Accept-Encoding → 仍是 gzip 且解压后含注入块(注入发生在 gzip 前);
|
|
6
|
+
* - DSH_MOBILE_ADAPTER=0 → 原样回传;
|
|
7
|
+
* - 非 html(SSE/json/二进制)不动。
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import test, { after } from "node:test";
|
|
15
|
+
import { gunzipSync } from "node:zlib";
|
|
16
|
+
|
|
17
|
+
const OFFICIAL_HTML = `<!doctype html><html><head>
|
|
18
|
+
<script>window.__ModuleLoader__ = { mode: "queue" }</script>
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
20
|
+
<title>DeepSeek Harness</title>
|
|
21
|
+
</head><body><div id="root"></div></body></html>`;
|
|
22
|
+
|
|
23
|
+
const upstream = http.createServer((req, res) => {
|
|
24
|
+
if (req.url === "/page") {
|
|
25
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
26
|
+
res.end(OFFICIAL_HTML);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (req.url === "/events") {
|
|
30
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
31
|
+
res.end("data: hi\n\n"); // 结束的 SSE 帧(便于缓冲测试)
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (req.url === "/app.js") {
|
|
35
|
+
res.writeHead(200, { "content-type": "application/javascript" });
|
|
36
|
+
res.end("console.log(1)");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
res.writeHead(404);
|
|
40
|
+
res.end("nope");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-html-inject-"));
|
|
44
|
+
process.env.DSH_BRIDGE_DEVICE_ID = "dev-htmltest0001";
|
|
45
|
+
process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
|
|
46
|
+
delete process.env.DSH_MOBILE_ADAPTER; // 默认开启
|
|
47
|
+
|
|
48
|
+
const { handleHttpFrame } = await new Promise((resolve, reject) => {
|
|
49
|
+
upstream.listen(0, "127.0.0.1", () => {
|
|
50
|
+
process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
|
|
51
|
+
import("../dsh-bridge.mjs").then(resolve, reject);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
after(() => {
|
|
56
|
+
try { upstream.close(); } catch {}
|
|
57
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function request(frame) {
|
|
61
|
+
let reply;
|
|
62
|
+
const sender = (obj) => { reply = obj; return true; };
|
|
63
|
+
await handleHttpFrame(sender, { type: "http", ...frame });
|
|
64
|
+
return reply;
|
|
65
|
+
}
|
|
66
|
+
const decode = (reply) => {
|
|
67
|
+
const buf = Buffer.from(reply.body, "base64");
|
|
68
|
+
if (reply.headers["content-encoding"] === "gzip") return gunzipSync(buf).toString("utf8");
|
|
69
|
+
return buf.toString("utf8");
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
test("集成:官方特征 html 经 doHttp 回传时注入适配层", async () => {
|
|
73
|
+
const reply = await request({ id: "h1", method: "GET", path: "/page", headers: { "accept-encoding": "gzip", "user-agent": "phone" } });
|
|
74
|
+
assert.equal(reply.status, 200);
|
|
75
|
+
const text = decode(reply);
|
|
76
|
+
assert.ok(text.includes('data-dsh-mobile-adapter'), "应注入 style/script");
|
|
77
|
+
const headIdx = text.toLowerCase().lastIndexOf("</head>");
|
|
78
|
+
assert.ok(text.indexOf("dsh-mobile-adapter-css") < headIdx, "注入块应位于 </head> 前");
|
|
79
|
+
// 原文其余部分保持(仅 splice)
|
|
80
|
+
const oi = OFFICIAL_HTML.toLowerCase().lastIndexOf("</head>");
|
|
81
|
+
assert.ok(text.startsWith(OFFICIAL_HTML.slice(0, oi)));
|
|
82
|
+
assert.ok(text.endsWith(OFFICIAL_HTML.slice(oi)));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("集成:注入在 gzip 前发生,回包仍可 gzip 解压且含注入块", async () => {
|
|
86
|
+
const reply = await request({ id: "h2", method: "GET", path: "/page", headers: { "accept-encoding": "gzip, deflate", "user-agent": "phone" } });
|
|
87
|
+
assert.equal(reply.headers["content-encoding"], "gzip", "压缩后应带 content-encoding");
|
|
88
|
+
const text = decode(reply);
|
|
89
|
+
assert.ok(text.includes("dsh-mobile-adapter-js"));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("集成:DSH_MOBILE_ADAPTER=0 关闭注入,原样回传", async () => {
|
|
93
|
+
process.env.DSH_MOBILE_ADAPTER = "0";
|
|
94
|
+
try {
|
|
95
|
+
const reply = await request({ id: "h3", method: "GET", path: "/page", headers: { "user-agent": "phone" } });
|
|
96
|
+
const text = decode(reply);
|
|
97
|
+
assert.ok(!text.includes("data-dsh-mobile-adapter"), "关闭时应原样");
|
|
98
|
+
assert.equal(text, OFFICIAL_HTML);
|
|
99
|
+
} finally {
|
|
100
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("集成:非 html(SSE / js / json)不动", async () => {
|
|
105
|
+
const ev = await request({ id: "h4", method: "GET", path: "/events", headers: { "accept-encoding": "gzip", "user-agent": "phone" } });
|
|
106
|
+
assert.equal(decode(ev), "data: hi\n\n");
|
|
107
|
+
const js = await request({ id: "h5", method: "GET", path: "/app.js", headers: { "accept-encoding": "gzip", "user-agent": "phone" } });
|
|
108
|
+
assert.equal(decode(js), "console.log(1)");
|
|
109
|
+
assert.ok(!decode(js).includes("dsh-mobile-adapter"));
|
|
110
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mobile-adapter 注入契约测试:
|
|
4
|
+
* injectMobileAdapter 纯函数:含 </head> 才注入、注入块出现在 <head> 内;
|
|
5
|
+
* shouldInjectHtml 只注入 text/html 且带官方特征的官方 dsh web 页面;
|
|
6
|
+
* maybeInjectMobileAdapter 整段 gate(env 开关 / 非 html / 非 UTF-8 不动);
|
|
7
|
+
* env DSH_MOBILE_ADAPTER=0 关闭。
|
|
8
|
+
* 注意:doHttp 集成(经 handleHttpFrame → 本地上游的 html 注入 + gzip 兼容)见 bridge-html 集成用例。
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { after } from "node:test";
|
|
16
|
+
|
|
17
|
+
// 官方 dsh web 特征片段(与真实 index.html 同源):__ModuleLoader__ + DeepSeek Harness title
|
|
18
|
+
const OFFICIAL_HTML = `<!doctype html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head><base href="/"><script>window.__ModuleLoader__ = { mode: "queue" }</script>
|
|
21
|
+
<link rel="preload" as="script" href="/plugins/??@deepseek-ai/dsh-client-modules/client.js">
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
23
|
+
<title>DeepSeek Harness</title></head>
|
|
24
|
+
<body><div id="root"></div></body></html>`;
|
|
25
|
+
|
|
26
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-mobile-adapter-"));
|
|
27
|
+
process.env.DSH_BRIDGE_DEVICE_ID = "dev-mobiletest0001";
|
|
28
|
+
process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
|
|
29
|
+
|
|
30
|
+
const mod = await import("../mobile-adapter.mjs");
|
|
31
|
+
const { injectMobileAdapter, shouldInjectHtml, maybeInjectMobileAdapter, mobileAdapterEnabled } = mod;
|
|
32
|
+
|
|
33
|
+
after(() => {
|
|
34
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("injectMobileAdapter:含 </head> 的 html 注入 <style>/<script>,且位于 <head> 内", () => {
|
|
38
|
+
const r = injectMobileAdapter(OFFICIAL_HTML);
|
|
39
|
+
assert.equal(r.injected, true);
|
|
40
|
+
const headEnd = r.html.toLowerCase().lastIndexOf("</head>");
|
|
41
|
+
const cssAt = r.html.indexOf("dsh-mobile-adapter-css");
|
|
42
|
+
const jsAt = r.html.indexOf("dsh-mobile-adapter-js");
|
|
43
|
+
assert.ok(cssAt > 0 && cssAt < headEnd, "style 应在 </head> 之前");
|
|
44
|
+
assert.ok(jsAt > 0 && jsAt < headEnd, "script 应在 </head> 之前");
|
|
45
|
+
// 只做 splice:注入块前后字节应与原文一致
|
|
46
|
+
const headIdx = OFFICIAL_HTML.toLowerCase().lastIndexOf("</head>");
|
|
47
|
+
assert.ok(r.html.startsWith(OFFICIAL_HTML.slice(0, headIdx)), "head 前缀原样");
|
|
48
|
+
assert.ok(r.html.endsWith(OFFICIAL_HTML.slice(headIdx)), "head 之后原样");
|
|
49
|
+
// 内容含窄屏 media query 与基础规则
|
|
50
|
+
assert.match(r.html, /@media \(max-width: 820px\)/);
|
|
51
|
+
assert.match(r.html, /overflow-x: hidden/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("injectMobileAdapter:无 </head> 的 html 原样返回", () => {
|
|
55
|
+
const r = injectMobileAdapter("<div>partial stream, no head</div>");
|
|
56
|
+
assert.equal(r.injected, false);
|
|
57
|
+
assert.equal(r.html, "<div>partial stream, no head</div>");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("shouldInjectHtml:只接受 text/html + </head> + 官方特征", () => {
|
|
61
|
+
assert.equal(shouldInjectHtml({ contentType: "text/html; charset=utf-8", html: OFFICIAL_HTML }), true);
|
|
62
|
+
// 非官方 html(无 __ModuleLoader__/DeepSeek Harness 特征)
|
|
63
|
+
assert.equal(shouldInjectHtml({ contentType: "text/html", html: "<html><head><title>x</title></head><body>x</body></html>" }), false);
|
|
64
|
+
// 非 html
|
|
65
|
+
assert.equal(shouldInjectHtml({ contentType: "application/json", html: OFFICIAL_HTML }), false);
|
|
66
|
+
assert.equal(shouldInjectHtml({ contentType: "text/event-stream", html: OFFICIAL_HTML }), false);
|
|
67
|
+
// 无 </head>
|
|
68
|
+
assert.equal(shouldInjectHtml({ contentType: "text/html", html: "<html><title>x</title>" }), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("maybeInjectMobileAdapter:Buffer 级入口(默认开启时注入)", () => {
|
|
72
|
+
const { buf, injected } = maybeInjectMobileAdapter({ buf: Buffer.from(OFFICIAL_HTML, "utf8"), contentType: "text/html; charset=utf-8" });
|
|
73
|
+
assert.equal(injected, true);
|
|
74
|
+
assert.ok(Buffer.isBuffer(buf));
|
|
75
|
+
assert.ok(buf.toString("utf8").includes("data-dsh-mobile-adapter"));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("maybeInjectMobileAdapter:env DSH_MOBILE_ADAPTER=0 时关闭", () => {
|
|
79
|
+
process.env.DSH_MOBILE_ADAPTER = "0";
|
|
80
|
+
try {
|
|
81
|
+
assert.equal(mobileAdapterEnabled(), false);
|
|
82
|
+
const { buf, injected } = maybeInjectMobileAdapter({ buf: Buffer.from(OFFICIAL_HTML, "utf8"), contentType: "text/html; charset=utf-8" });
|
|
83
|
+
assert.equal(injected, false);
|
|
84
|
+
assert.equal(buf.toString("utf8"), OFFICIAL_HTML);
|
|
85
|
+
} finally {
|
|
86
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
87
|
+
}
|
|
88
|
+
assert.equal(mobileAdapterEnabled(), true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("maybeInjectMobileAdapter:非 html / 空 body / 非 UTF-8 原样", () => {
|
|
92
|
+
assert.equal(maybeInjectMobileAdapter({ buf: Buffer.from(OFFICIAL_HTML, "utf8"), contentType: "image/png" }).injected, false);
|
|
93
|
+
assert.equal(maybeInjectMobileAdapter({ buf: Buffer.alloc(0), contentType: "text/html" }).injected, false);
|
|
94
|
+
const latin1 = Buffer.from("\x80\x81\x82\xfe\xff" + "<html><head></head></html>", "latin1");
|
|
95
|
+
const r = maybeInjectMobileAdapter({ buf: latin1, contentType: "text/html" });
|
|
96
|
+
assert.equal(r.injected, false);
|
|
97
|
+
assert.deepEqual(r.buf, latin1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("适配层文本不含口令类敏感标记(仅结构提示)", () => {
|
|
101
|
+
assert.ok(!/password|secret|token\s*=/i.test(mod.ADAPTER_TAG + "")); // ADAPTER_TAG 只是 id 常量
|
|
102
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.0-beta.
|
|
3
|
+
"version": "0.6.0-beta.4",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -946,7 +946,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
946
946
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
947
947
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
948
948
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
949
|
-
const PLUGIN_VERSION = "0.6.0-beta.
|
|
949
|
+
const PLUGIN_VERSION = "0.6.0-beta.4";
|
|
950
950
|
const UPDATE_LOG = ".dsh-update.log";
|
|
951
951
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
952
952
|
|