@mrrisega/dsh-remote 0.6.0-beta.3 → 0.6.0-beta.5

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.
@@ -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: res.headers.get("content-type") || "",
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,541 @@
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) 设置面板(实测挂载在侧栏 .hHd-Xa_settingsArea 子树里的 fixed 弹层;panel 内
19
+ * nav 188px + content 154px 左右并排)→ 手机改为上下堆叠:顶部 tab 行(横向滚动)置顶,
20
+ * 下方内容区全高滚动。fix2:官方模块 CSS 运行期才注入 head(晚于本 <style>),普通规则
21
+ * 被官方同特异性后加载规则覆盖(nav 是 <nav>,圆1 的 div.VOzbGW_nav 从未命中;overlay
22
+ * 被官方 min(800px,84vw) 钉成左靠 328px 抽屉感)→ 本版设置区全部用 [class~=...] 精确类
23
+ * + 几何 !important;并把侧栏/详情浮层的 will-change:transform 去掉(transform 祖先会让
24
+ * 侧栏子树里的 fixed 设置弹层以侧栏为 containing block,被压到 84vw——fix2 设置窄的根因);
25
+ * 顶部 tab 字号 ≤13px、行宽拉满可横向滚动,当前项自动滚入可视;
26
+ * 3) 输入控件字号 ≥16px 防 iOS 聚焦缩放;hero 的工作区/模式座位行允许换行,防 212px 座位
27
+ * 右缘伸出视口(实测 cubgiG_seat right=409 > 390);
28
+ * 4) html/body overflow-x 防护、safe-area inset、触控目标友好;配色只用官方 CSS 变量;
29
+ * 5) 鲸鱼余额挂件(用户另装的第三方 dsh-whale-widget,浮动 .dshwv-root,z-index:9999):
30
+ * fix2 实测它的窄屏“卡左上角盖标题”与“拖不动”均为其自身缺陷的放大:
31
+ * - 拖不动:触屏上 pointer 拖拽被页面滚动抢占(pointercancel,坐标归 0 → 挂件被“瞬移”),
32
+ * 鼠标路径正常 → 适配层在鲸鱼实体像素的 touchstart 上 preventDefault(仅 ≤820),
33
+ * 让浏览器不抢滚动,挂件原生拖拽在手机上恢复;
34
+ * - 默认位置:它把锚点记忆存 localStorage(dshw-pos),一旦曾落在「左+上」就每次载入
35
+ * 盖住左上会话标题 → 适配层在页面解析期(挂件 widget.js 是 defer,晚于本脚本)清掉
36
+ * left+top 陈旧锚点,默认回到右下;再兜底一次“卡左上角”检测(合成鼠标事件走它自己
37
+ * 的拖拽收尾,state/localStorage 由它自洽)。
38
+ * 仅对 ≤820 且锚点命中 left+top 干预;透明区点击/滚动仍穿透,与桌面语义一致。
39
+ *
40
+ * 宿主特征门(防误注入其它 html):
41
+ * - 注入前:content-type 必须 text/html 且含 </head> 且原文含官方标记
42
+ * (__ModuleLoader__ / @deepseek-ai/dsh-client-* / <title>DeepSeek Harness</title>);
43
+ * - 运行时:注入脚本在 window.innerWidth ≤ 820 且 DOM 出现官方 frame/overlay 特征时才动作。
44
+ *
45
+ * 类名变化退化策略(官方升级重打包导致 pI_x6G_* / hHd-Xa_* / VOzbGW_* 等前缀改变时):
46
+ * - 结构优先用官方固定输出的 data-* 属性与 aria-label;
47
+ * - JS 用结构探测(frame → 子列 → 打 dsh-ma-* 稳定类)承载布局,不以 hashed 类为唯一钩子;
48
+ * - 命中不到 frame 时静默跳过,页面保持官方默认,绝不报错/破坏。
49
+ *
50
+ * 开关:环境变量 DSH_MOBILE_ADAPTER=0 整体关闭(默认开启)。
51
+ */
52
+
53
+ const ADAPTER_ID = "dsh-mobile-adapter";
54
+
55
+ /* 注入前 raw HTML 层官方特征(0.1.2-rc.1 index.html 原文稳定存在)。 */
56
+ const HOST_FEATURE_RE =
57
+ /(__ModuleLoader__|@deepseek-ai\/dsh-client-modules|@deepseek-ai\/dsh-client-connection|<title>\s*DeepSeek Harness)/i;
58
+
59
+ /* 运行时 DOM 特征(注入脚本内再次校验;data-* 由官方组件固定输出,不随 CSS modules 改名)。 */
60
+ const RUNTIME_HOST_RE =
61
+ /(data-sidebar-collapsed|data-details-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
62
+
63
+ const STYLE = `
64
+ /* ===== dsh-remote mobile adapter (bridge 注入;仅 ≤820px 生效,桌面宽屏无任何规则) ===== */
65
+ @media (max-width: 820px) {
66
+ html, body { max-width: 100%; overflow-x: hidden; }
67
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
68
+
69
+ /* --- 三列 frame → 内容区全宽(官方内联 gridTemplateColumns 用 !important 覆盖) --- */
70
+ div.pI_x6G_frame, div.dsh-ma-frame { grid-template-columns: 0 minmax(0, 1fr) 0 !important; min-width: 0; }
71
+ /* 侧栏/详情已移出 grid 流(fixed),唯一在流的中心列须显式落到 1fr 轨道,否则自动放置会被塞进 0px 轨 */
72
+ div.pI_x6G_centerCol, div.dsh-ma-center { grid-column: 2; min-width: 0; width: auto; }
73
+
74
+ /* --- 侧栏 / 详情:离屏浮层(off-canvas),展开态滑入 ---
75
+ 注意:不能给浮层设 will-change:transform / 持久 transform —— 官方把设置面板等
76
+ fixed 弹层的 DOM 挂在侧栏子树里,transform 祖先会成为 fixed 的 containing block,
77
+ 导致弹层被限制在 84vw 侧栏宽内(实测 328px“抽屉感”,fix2 根因之一)。 */
78
+ div.pI_x6G_sidebarCol, div.dsh-ma-sidebar {
79
+ position: fixed; left: 0; top: 0; bottom: 0; margin: 0;
80
+ width: min(84vw, 340px); max-width: 92vw;
81
+ z-index: 300; overflow: hidden;
82
+ border-right: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.25));
83
+ box-shadow: 0 10px 44px rgba(0,0,0,.26);
84
+ transform: translateX(-103%);
85
+ transition: transform .22s var(--ds-ease-in-out, ease);
86
+ }
87
+ html.dsh-ma-sidebar-open div.pI_x6G_sidebarCol,
88
+ html.dsh-ma-sidebar-open div.dsh-ma-sidebar { transform: none; }
89
+
90
+ div.pI_x6G_detailsCol, div.dsh-ma-details {
91
+ position: fixed; right: 0; top: 0; bottom: 0; margin: 0;
92
+ width: min(92vw, 400px); max-width: 96vw;
93
+ z-index: 300; overflow: hidden;
94
+ border-left: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.25));
95
+ box-shadow: 0 10px 44px rgba(0,0,0,.26);
96
+ transform: translateX(103%);
97
+ transition: transform .22s var(--ds-ease-in-out, ease);
98
+ }
99
+ html.dsh-ma-details-open div.pI_x6G_detailsCol,
100
+ html.dsh-ma-details-open div.dsh-ma-details { transform: none; }
101
+
102
+ /* --- 展开遮罩(在浮层下、在官方弹层下) --- */
103
+ div.dsh-ma-scrim {
104
+ position: fixed; inset: 0; z-index: 290;
105
+ background: rgba(0,0,0,.32);
106
+ opacity: 0; pointer-events: none;
107
+ transition: opacity .22s ease;
108
+ }
109
+ html.dsh-ma-sidebar-open div.dsh-ma-scrim,
110
+ html.dsh-ma-details-open div.dsh-ma-scrim { opacity: 1; pointer-events: auto; }
111
+
112
+ /* --- 顶部小鲸鱼/菜单按钮(侧栏收起时可见) --- */
113
+ button.dsh-ma-hamburger {
114
+ position: fixed;
115
+ top: max(10px, env(safe-area-inset-top));
116
+ left: max(10px, env(safe-area-inset-left));
117
+ z-index: 260;
118
+ width: 42px; height: 42px; padding: 0;
119
+ display: flex; align-items: center; justify-content: center;
120
+ border-radius: 12px;
121
+ background: var(--dsw-alias-button-elevated-fill, rgba(255,255,255,.96));
122
+ border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
123
+ box-shadow: 0 1px 6px rgba(0,0,0,.16);
124
+ color: var(--dsw-alias-label-primary, #111);
125
+ cursor: pointer; touch-action: manipulation;
126
+ }
127
+ button.dsh-ma-hamburger:active { transform: scale(.92); }
128
+
129
+ /* --- 输入字号 ≥16px(防 iOS 聚焦自动缩放) --- */
130
+ textarea,
131
+ input:not([type]), input[type="text"], input[type="search"], input[type="email"],
132
+ input[type="url"], input[type="tel"], input[type="number"], input[type="password"],
133
+ [contenteditable="true"] { font-size: 16px !important; }
134
+ select, button, a, [role="button"], [role="menuitem"], [role="option"], [role="tab"] { touch-action: manipulation; }
135
+
136
+ /* --- hero 工作区/模式座位行:可换行,防右缘被裁(实测 cubgiG_seat right=409>390) --- */
137
+ div.wSkVaW_heroWorkspaceRow { flex-wrap: wrap; row-gap: 8px; column-gap: 8px; justify-content: center; min-width: 0; }
138
+ div.wSkVaW_heroWorkspaceRow > * { max-width: calc(100vw - 32px); }
139
+ div.wSkVaW_composerStack, div.wSkVaW_composerHero, div.uV2eYG_card { min-width: 0; }
140
+
141
+ /* --- 设置面板(fix2 硬化版;实测 390×844:官方模块 CSS 运行期注入在适配层之后,
142
+ 普通规则会被官方后加载样式覆盖——nav 是 <nav> 而非 div,圆1 的 div.VOzbGW_nav
143
+ 从未命中;overlay 被官方宽度 min(800px,84vw) 钉在左侧 328px,呈“抽屉感”。
144
+ 故本版全部用 [class~=...] 精确类 + 几何 !important,官方前缀变化则整段静默失效) --- */
145
+ [class~="VOzbGW_overlay"] {
146
+ position: fixed !important;
147
+ inset: 0 !important;
148
+ width: auto !important; max-width: none !important;
149
+ height: auto !important; max-height: none !important;
150
+ margin: 0 !important; padding: 0 !important;
151
+ border-radius: 0 !important;
152
+ box-sizing: border-box;
153
+ justify-content: center; align-items: stretch;
154
+ }
155
+ [class~="VOzbGW_panel"] {
156
+ box-sizing: border-box;
157
+ width: 100% !important; max-width: 100% !important;
158
+ height: 100% !important; max-height: 100% !important;
159
+ margin: 0 !important; border-radius: 0 !important;
160
+ flex-direction: column !important;
161
+ }
162
+ /* 顶部 tab 行:整行铺满,字号调小(≤13px),横向可滚动 */
163
+ [class~="VOzbGW_nav"] {
164
+ box-sizing: border-box;
165
+ flex: none;
166
+ width: 100% !important;
167
+ flex-direction: row !important;
168
+ align-items: center;
169
+ gap: 4px;
170
+ padding: max(4px, env(safe-area-inset-top)) 4px 0;
171
+ overflow: visible;
172
+ }
173
+ [class~="VOzbGW_navTitle"] {
174
+ flex: none;
175
+ font-size: 13px !important; line-height: 1.2;
176
+ padding: 0 4px 0 8px;
177
+ white-space: nowrap;
178
+ }
179
+ [class~="VOzbGW_navList"] {
180
+ box-sizing: border-box;
181
+ flex: 1 1 auto; min-width: 0;
182
+ flex-direction: row !important;
183
+ gap: 4px;
184
+ overflow-x: auto; overflow-y: hidden;
185
+ -webkit-overflow-scrolling: touch;
186
+ scrollbar-width: none;
187
+ padding: 0 0 6px;
188
+ }
189
+ [class~="VOzbGW_navList"]::-webkit-scrollbar { display: none; }
190
+ [class~="VOzbGW_navCell"] {
191
+ flex: none; min-width: max-content;
192
+ box-sizing: border-box;
193
+ font-size: 13px !important;
194
+ height: 32px !important;
195
+ padding: 0 12px !important;
196
+ }
197
+ /* 内容区:全宽拉满,收敛内边距,最大化有效宽度 */
198
+ [class~="VOzbGW_content"] {
199
+ box-sizing: border-box;
200
+ width: 100% !important; max-width: none !important;
201
+ flex: 1 1 auto; min-width: 0; min-height: 0;
202
+ }
203
+ [class~="VOzbGW_header"] {
204
+ box-sizing: border-box;
205
+ flex: none;
206
+ min-height: 40px;
207
+ padding: 6px 8px 4px 14px !important;
208
+ align-items: center;
209
+ }
210
+ [class~="VOzbGW_close"] { width: 32px; height: 32px; }
211
+ [class~="VOzbGW_options"] {
212
+ box-sizing: border-box;
213
+ flex: 1 1 auto; min-height: 0; overflow-y: auto;
214
+ -webkit-overflow-scrolling: touch;
215
+ padding: 2px 14px calc(14px + env(safe-area-inset-bottom)) !important;
216
+ }
217
+ }
218
+
219
+ /* 桌面宽屏(>820px):本文件无任何规则 → 对直连/桌面零影响。 */
220
+ `;
221
+
222
+ const SCRIPT = `(() => {
223
+ "use strict";
224
+ try {
225
+ const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
226
+ const NARROW = () => window.innerWidth <= 820;
227
+ const HOSTISH = () =>
228
+ HOST_RE.test(document.documentElement.outerHTML.slice(0, 200000)) ||
229
+ !!document.querySelector("[data-shell-overlay], [data-sidebar-collapsed], [data-details-collapsed]");
230
+
231
+ /* —— 鲸鱼余额挂件(dsh-whale-widget,第三方插件的浮动组件)窄屏协同 ——
232
+ 若挂件记忆的锚点是「左+上」(实测会盖住左上角会话标题/左上菜单按钮),在页面解析期
233
+ (鲸鱼 widget.js 是 defer 脚本,一定晚于本脚本执行)清掉该陈旧锚点 → 挂件回到自身
234
+ 默认右下角。仅窄屏 & 仅命中 left+top 组合,不影响其它自定义位置。 */
235
+ try {
236
+ if (window.innerWidth <= 820 && window.localStorage) {
237
+ const rawPos = window.localStorage.getItem("dshw-pos");
238
+ if (rawPos) {
239
+ const pos = JSON.parse(rawPos);
240
+ if (pos && pos.hAnchor === "left" && pos.vAnchor === "top") {
241
+ window.localStorage.removeItem("dshw-pos");
242
+ }
243
+ }
244
+ }
245
+ } catch (e3) { /* 私有模式/配额等:忽略 */ }
246
+
247
+ /* ================= 鲸鱼挂件手机辅助(仅 ≤820 生效;挂件类名 .dshwv-* 稳定) ========= */
248
+ let whaleGuardsOn = false;
249
+ let whaleHitMap = null;
250
+ let whaleUnstuckDone = false;
251
+
252
+ const WHALE_IMG = () => document.querySelector(".dshwv-img");
253
+ const WHALE_ROOT = () => document.querySelector(".dshwv-root");
254
+
255
+ /* 命中测试与挂件自身一致:鲸鱼图按 610×610 采样 alpha>10;贴左(镜像)时水平翻转。 */
256
+ function whaleBodyAt(x, y) {
257
+ const img = WHALE_IMG();
258
+ const root = WHALE_ROOT();
259
+ if (!img || !root) return false;
260
+ const flip = root.classList.contains("dshwv-left");
261
+ const r = img.getBoundingClientRect();
262
+ if (!r || r.width <= 0 || r.height <= 0) return false;
263
+ const lx0 = ((x - r.left) / r.width) * 610;
264
+ const ly0 = ((y - r.top) / r.height) * 610;
265
+ if (lx0 < 0 || ly0 < 0 || lx0 >= 610 || ly0 >= 610) return false;
266
+ const key = img.currentSrc || img.src || "";
267
+ if (!whaleHitMap || whaleHitMap.key !== key) {
268
+ if (!img.complete || !img.naturalWidth || img.naturalWidth === 0) return false; /* 图未就绪→不挡 */
269
+ try {
270
+ const cv = document.createElement("canvas");
271
+ cv.width = 610; cv.height = 610;
272
+ const ctx = cv.getContext("2d");
273
+ if (!ctx) return false;
274
+ ctx.drawImage(img, 0, 0, 610, 610);
275
+ whaleHitMap = { key: key, data: ctx.getImageData(0, 0, 610, 610).data };
276
+ } catch (e4) { whaleHitMap = null; return false; }
277
+ }
278
+ const lx = flip ? 610 - lx0 : lx0;
279
+ const ix = Math.min(609, Math.max(0, Math.floor(lx)));
280
+ const iy = Math.min(609, Math.max(0, Math.floor(ly0)));
281
+ return whaleHitMap.data[(iy * 610 + ix) * 4 + 3] > 10;
282
+ }
283
+
284
+ /* 手机上挂件自身拖拽被滚动抢占:触摸点在鲸鱼实体上时浏览器默认滚动 → pointercancel,
285
+ 挂件 pointermove 收不到且把它“瞬移”到取消点(实测拖到左上角)。鼠标路径无此问题。
286
+ 在 document capture 阶段对鲸鱼实体像素的 touchstart preventDefault → 浏览器不抢滚动,
287
+ 挂件原生的 pointer 拖拽在触屏上随之恢复;透明区仍穿透(与桌面一致)。仅窄屏注册。 */
288
+ function onWhaleTouchStart(ev) {
289
+ try {
290
+ if (!NARROW()) return;
291
+ const t = ev.touches && ev.touches[0];
292
+ if (!t) return;
293
+ const tg = ev.target;
294
+ if (tg && tg.closest && tg.closest(".dshwv-menu, .dshwv-menu-btn, .dshwv-bubble")) return;
295
+ if (whaleBodyAt(t.clientX, t.clientY)) ev.preventDefault();
296
+ } catch (e5) { /* 忽略 */ }
297
+ }
298
+
299
+ function whaleHookInit() {
300
+ if (whaleGuardsOn || !NARROW() || !WHALE_ROOT()) return;
301
+ whaleGuardsOn = true;
302
+ try { document.addEventListener("touchstart", onWhaleTouchStart, { capture: true, passive: false }); } catch (e6) {}
303
+ /* 兜底:极端时序下挂件若仍以 (0,0) 卡在左上(如解析期清锚点被竞态错过),settle 完再扶正一次 */
304
+ [1000, 3500].forEach(function (ms) {
305
+ setTimeout(function () { try { whaleUnstickIfStuck(); } catch (e7) {} }, ms);
306
+ });
307
+ }
308
+
309
+ function safeInsetSides() {
310
+ const ins = { right: 0, bottom: 0 };
311
+ try {
312
+ const p = document.createElement("div");
313
+ p.style.cssText = "position:fixed;top:0;left:0;width:10px;height:10px;visibility:hidden;pointer-events:none";
314
+ document.body.appendChild(p);
315
+ p.style.bottom = "env(safe-area-inset-bottom, 0px)";
316
+ p.style.top = "auto"; p.style.left = "0px"; p.style.right = "auto";
317
+ let r = p.getBoundingClientRect();
318
+ ins.bottom = Math.max(0, Math.round(window.innerHeight - r.top - 10));
319
+ p.style.bottom = "auto"; p.style.top = "0px";
320
+ p.style.right = "env(safe-area-inset-right, 0px)"; p.style.left = "auto";
321
+ r = p.getBoundingClientRect();
322
+ ins.right = Math.max(0, Math.round(window.innerWidth - r.left - 10));
323
+ p.remove();
324
+ } catch (e8) { /* 忽略 */ }
325
+ return ins;
326
+ }
327
+
328
+ function whaleBodyPoint() {
329
+ const img = WHALE_IMG();
330
+ if (!img) return null;
331
+ const r = img.getBoundingClientRect();
332
+ if (!r || r.width <= 0 || r.height <= 0) return null;
333
+ const cands = [[0.5, 0.5], [0.62, 0.64], [0.5, 0.72], [0.72, 0.6], [0.35, 0.58], [0.8, 0.68]];
334
+ for (let i = 0; i < cands.length; i++) {
335
+ const x = r.left + cands[i][0] * r.width;
336
+ const y = r.top + cands[i][1] * r.height;
337
+ if (whaleBodyAt(x, y)) return { x: Math.round(x), y: Math.round(y) };
338
+ }
339
+ return { x: Math.round(r.left + r.width * 0.62), y: Math.round(r.top + r.height * 0.66) };
340
+ }
341
+
342
+ function dispatchWhalePointer(type, x, y) {
343
+ try {
344
+ document.dispatchEvent(new PointerEvent(type, {
345
+ bubbles: true, cancelable: true, composed: true, pointerType: "mouse",
346
+ pointerId: 61001, isPrimary: true,
347
+ button: type === "pointerup" ? -1 : 0,
348
+ buttons: type === "pointerup" ? 0 : 1,
349
+ clientX: x, clientY: y
350
+ }));
351
+ } catch (e9) { /* 不支持 PointerEvent 的环境:跳过 */ }
352
+ }
353
+
354
+ /* 走挂件自己(鼠标路径)的拖拽把它送回右下:state/localStorage 全部由挂件收尾,我们只发合成事件。 */
355
+ function whaleNativeDragToBottomRight() {
356
+ const root = WHALE_ROOT();
357
+ if (!root || root.classList.contains("dshwv-dragging")) return false;
358
+ const start = whaleBodyPoint();
359
+ if (!start) return false;
360
+ const rect = root.getBoundingClientRect();
361
+ const w = rect.width || 122;
362
+ const h = rect.height || 122;
363
+ const ins = safeInsetSides();
364
+ const cx = window.innerWidth - w / 2 - Math.max(ins.right, 10);
365
+ const cy = window.innerHeight - h / 2 - Math.max(ins.bottom, 10);
366
+ if (cx < 0 || cy < 0 || cx < window.innerWidth * 0.5 || cy < window.innerHeight * 0.5) return false;
367
+ dispatchWhalePointer("pointerdown", start.x, start.y);
368
+ let started = false;
369
+ try { started = root.classList.contains("dshwv-dragging"); } catch (e10) {}
370
+ if (!started) { try { dispatchWhalePointer("pointerup", start.x, start.y); } catch (e11) {} return false; }
371
+ const N = 14;
372
+ for (let i = 1; i <= N; i++) {
373
+ const x = Math.round(start.x + ((cx - start.x) * i) / N);
374
+ const y = Math.round(start.y + ((cy - start.y) * i) / N);
375
+ dispatchWhalePointer("pointermove", x, y);
376
+ }
377
+ dispatchWhalePointer("pointerup", cx, cy);
378
+ return true;
379
+ }
380
+
381
+ function whaleUnstickIfStuck() {
382
+ if (!NARROW() || whaleUnstuckDone) return;
383
+ const root = WHALE_ROOT();
384
+ if (!root || root.classList.contains("dshwv-dragging")) return;
385
+ const rect = root.getBoundingClientRect();
386
+ if (rect.top > 20 || rect.left > 20) return; /* 只扶“真卡左上角”的,用户自己放的位置不动 */
387
+ whaleUnstuckDone = true;
388
+ try { if (window.localStorage) window.localStorage.removeItem("dshw-pos"); } catch (e12) {}
389
+ whaleNativeDragToBottomRight();
390
+ }
391
+
392
+ /* 设置面板打开时,把当前 tab 滚进横向可视区(顶部 tab 行手机横向滚动) */
393
+ let settingsWatchOn = false;
394
+ let settingsTick = 0;
395
+ function scrollActiveSettingsTab() {
396
+ const act = document.querySelector(
397
+ '[class~="VOzbGW_navCell"].VOzbGW_active, [class~="VOzbGW_navCell"][aria-current="true"]'
398
+ );
399
+ if (act && act.scrollIntoView) {
400
+ try { act.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (e13) {
401
+ try { act.scrollIntoView(); } catch (e14) {}
402
+ }
403
+ }
404
+ }
405
+ function watchSettings() {
406
+ if (settingsWatchOn || !NARROW()) return;
407
+ settingsWatchOn = true;
408
+ try {
409
+ const ob = new MutationObserver(function () {
410
+ clearTimeout(settingsTick);
411
+ settingsTick = setTimeout(function () { if (NARROW()) scrollActiveSettingsTab(); }, 140);
412
+ });
413
+ ob.observe(document.body, { childList: true, subtree: true });
414
+ } catch (e15) {}
415
+ }
416
+
417
+ let done = false;
418
+ const boot = () => {
419
+ if (done || !NARROW() || !HOSTISH()) return;
420
+ const frame = document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed]");
421
+ if (!frame || !frame.querySelector) return;
422
+ done = true;
423
+ frame.classList.add("dsh-ma-frame");
424
+
425
+ /* 给 frame 子列打稳定标记(dsh-ma-*),规避官方类名前缀随构建变化 */
426
+ let sidebar = null, details = null, center = null;
427
+ const cols = [...frame.children].filter((c) => c instanceof HTMLElement);
428
+ for (const col of cols) {
429
+ const cls = typeof col.className === "string" ? col.className : "";
430
+ if (/sidebar/i.test(cls) || col.querySelector(".hHd-Xa_root, [aria-label*='侧边栏'], [aria-label*='sidebar' i]")) {
431
+ col.classList.add("dsh-ma-sidebar"); sidebar = sidebar || col;
432
+ } else if (/details/i.test(cls)) {
433
+ col.classList.add("dsh-ma-details"); details = details || col;
434
+ } else if (/center/i.test(cls)) {
435
+ col.classList.add("dsh-ma-center"); center = center || col;
436
+ }
437
+ }
438
+ if (!sidebar && cols[0]) { cols[0].classList.add("dsh-ma-sidebar"); sidebar = sidebar || cols[0]; }
439
+ if (!center && cols[1]) { cols[1].classList.add("dsh-ma-center"); center = center || cols[1]; }
440
+ if (!details && cols[cols.length - 1] && cols[cols.length - 1] !== sidebar && cols[cols.length - 1] !== center) {
441
+ cols[cols.length - 1].classList.add("dsh-ma-details"); details = details || cols[cols.length - 1];
442
+ }
443
+
444
+ const toggleSel = '[aria-label="打开侧边栏"], [aria-label="关闭侧边栏"], [aria-label="Open sidebar"], [aria-label="Close sidebar"]';
445
+ const toggleOf = () => document.querySelector(toggleSel) || document.querySelector(".hHd-Xa_toggle");
446
+ const expanded = () => !frame.hasAttribute("data-sidebar-collapsed");
447
+
448
+ /* 遮罩:点击 = 点官方 toggle(走官方 store,无私有状态) */
449
+ const scrim = document.createElement("div");
450
+ scrim.className = "dsh-ma-scrim";
451
+ document.body.appendChild(scrim);
452
+ scrim.addEventListener("click", (e) => { e.stopPropagation(); const t = toggleOf(); if (t) t.click(); });
453
+
454
+ /* 顶部小鲸鱼/菜单按钮 */
455
+ const hamburger = document.createElement("button");
456
+ hamburger.type = "button";
457
+ hamburger.className = "dsh-ma-hamburger";
458
+ hamburger.setAttribute("aria-label", "打开菜单");
459
+ 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>';
460
+ document.body.appendChild(hamburger);
461
+ hamburger.addEventListener("click", (e) => {
462
+ e.stopPropagation(); e.preventDefault();
463
+ const t = toggleOf();
464
+ if (t) t.click();
465
+ else document.documentElement.classList.add("dsh-ma-sidebar-open"); /* 兜底 */
466
+ });
467
+
468
+ /* 状态唯一真源 = 官方 frame 的 data 属性 */
469
+ const sync = () => {
470
+ const open = expanded();
471
+ document.documentElement.classList.toggle("dsh-ma-sidebar-open", open);
472
+ if (hamburger) hamburger.style.display = open ? "none" : "";
473
+ const dOpen = !frame.hasAttribute("data-details-collapsed") && !!details;
474
+ document.documentElement.classList.toggle("dsh-ma-details-open", dOpen);
475
+ };
476
+ try {
477
+ new MutationObserver(sync).observe(frame, { attributes: true, attributeFilter: ["data-sidebar-collapsed", "data-details-collapsed"] });
478
+ } catch (e2) { /* 退化:仅在下次 boot 同步 */ }
479
+ sync();
480
+ whaleHookInit();
481
+ watchSettings();
482
+ };
483
+
484
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
485
+ /* 官方模块系统异步挂载 UI,晚一点再补几次(幂等,done 守卫);同时等鲸鱼/设置弹层就位 */
486
+ [700, 1600, 3200, 7000, 12000].forEach((ms) =>
487
+ setTimeout(() => { try { boot(); whaleHookInit(); watchSettings(); } catch (e) { /* 幂等重试 */ } }, ms)
488
+ );
489
+ } catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
490
+ })();`;
491
+
492
+ /**
493
+ * 纯函数:把适配层注入到上游 HTML 的 </head> 之前。
494
+ * @param {string} html
495
+ * @returns {{html:string, injected:boolean}}
496
+ */
497
+ export function injectMobileAdapter(html) {
498
+ const src = String(html);
499
+ const headIdx = src.toLowerCase().lastIndexOf("</head>");
500
+ if (headIdx === -1) return { html: src, injected: false };
501
+ const block =
502
+ `\n<style id="${ADAPTER_ID}-css" data-dsh-mobile-adapter>${STYLE}</style>` +
503
+ `\n<script id="${ADAPTER_ID}-js" data-dsh-mobile-adapter>${SCRIPT}</script>\n`;
504
+ return { html: src.slice(0, headIdx) + block + src.slice(headIdx), injected: true };
505
+ }
506
+
507
+ /** 环境开关:DSH_MOBILE_ADAPTER=0 关闭(默认开启)。 */
508
+ export function mobileAdapterEnabled() {
509
+ return process.env.DSH_MOBILE_ADAPTER !== "0";
510
+ }
511
+
512
+ /**
513
+ * 判断上游响应是否应注入(仅官方 dsh web 的 text/html)。
514
+ * @param {{contentType?:string, html?:string}} opts
515
+ */
516
+ export function shouldInjectHtml({ contentType, html }) {
517
+ const ct = String(contentType || "");
518
+ if (!/text\/html/i.test(ct)) return false;
519
+ const src = String(html || "");
520
+ if (!/<\/head>/i.test(src)) return false;
521
+ if (/\uFFFD/.test(src)) return false; // 非 UTF-8,不冒险改写
522
+ return HOST_FEATURE_RE.test(src);
523
+ }
524
+
525
+ /**
526
+ * bridge 接线主入口:开关 + 类型/特征 gate + 注入,一步完成。
527
+ * @param {{buf:Buffer, contentType?:string}} args
528
+ * @returns {{buf:Buffer, injected:boolean}}
529
+ */
530
+ export function maybeInjectMobileAdapter({ buf, contentType }) {
531
+ if (!mobileAdapterEnabled() || !Buffer.isBuffer(buf) || buf.length === 0) {
532
+ return { buf, injected: false };
533
+ }
534
+ const text = buf.toString("utf8");
535
+ if (!shouldInjectHtml({ contentType, html: text })) return { buf, injected: false };
536
+ const out = injectMobileAdapter(text);
537
+ return { buf: Buffer.from(out.html, "utf8"), injected: out.injected };
538
+ }
539
+
540
+ export const ADAPTER_TAG = ADAPTER_ID;
541
+ 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,124 @@
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
+ });
103
+
104
+ test("fix2:设置区用精确类+小字号,浮层不再持久 will-change,鲸鱼协同 JS 载荷存在", () => {
105
+ const r = injectMobileAdapter(OFFICIAL_HTML);
106
+ const css = r.html.slice(r.html.indexOf('<style id="dsh-mobile-adapter-css"'), r.html.indexOf("</style>"));
107
+ const js = r.html.slice(r.html.indexOf('<script id="dsh-mobile-adapter-js"'));
108
+ // 设置面板:全屏/精确类/顶部 tab 字号 ≤13px/内容拉满
109
+ assert.match(css, /\[class~="VOzbGW_overlay"\]/);
110
+ assert.match(css, /\[class~="VOzbGW_panel"\]/);
111
+ assert.match(css, /\[class~="VOzbGW_navCell"\]/);
112
+ assert.match(css, /font-size:\s*13px\s*!important/);
113
+ assert.match(css, /\[class~="VOzbGW_options"\]/);
114
+ // 浮层规则不应再携带会被 fixed 弹层当作 containing block 的持久 will-change(见文件注释)
115
+ assert.ok(!/(^|})\s*will-change:\s*transform\s*;/m.test(css), "CSS 规则不应再有 will-change: transform 声明(注释里的字样不算)");
116
+ // 鲸鱼挂件(dsh-whale-widget)窄屏协同载荷:锚点清理 + touch 拖动护栏 + 卡左上兜底
117
+ assert.match(js, /dshw-pos/);
118
+ assert.match(js, /\.dshwv-root/);
119
+ assert.match(js, /touchstart/);
120
+ assert.match(js, /whaleUnstickIfStuck|whaleNativeDragToBottomRight/);
121
+ // 上述 CSS/JS 都只能落在 ≤820px 运行时:CSS 规则整体在 media 内;JS 执行由 NARROW() 把关
122
+ assert.ok(css.indexOf("@media (max-width: 820px)") !== -1);
123
+ assert.match(js, /NARROW\s*=\s*\(\)\s*=>\s*window\.innerWidth\s*<=\s*820/);
124
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.0-beta.3",
3
+ "version": "0.6.0-beta.5",
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.3";
949
+ const PLUGIN_VERSION = "0.6.0-beta.5";
950
950
  const UPDATE_LOG = ".dsh-update.log";
951
951
  const UPDATE_MARKER = ".dsh-update-running";
952
952