dsh-dock 0.12.0 → 0.13.0
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/README.md +51 -6
- package/client.js +633 -234
- package/features/mobile/view.js +305 -0
- package/features/mobile-relay/gateway.js +187 -11
- package/features/mobile-relay/host.js +175 -50
- package/features/mobile-relay/view.jsx +217 -86
- package/index.js +5 -1
- package/package.json +2 -2
- package/src/client.jsx +3 -2
- package/src/host-core.js +129 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【手机适配】· 客户端视图(纯 Client 功能,无宿主半部)
|
|
2
|
+
//
|
|
3
|
+
// 职责:手机/窄屏下的排版与触控兜底。dsh 布局自身在 <1024px(SIDEBAR_AUTO_COLLAPSE)
|
|
4
|
+
// 已有窄屏抽屉,本模块只补它没管的四件事:
|
|
5
|
+
// 1. 输入类控件字号拉到 16px —— iOS Safari 聚焦任何 <16px 的输入框都会强制放大页面,
|
|
6
|
+
// 聊天输入框 14px 字号让手机端每点一次输入框整页就缩放一次,是最影响体验的一件事;
|
|
7
|
+
// 2. 触控基础:去点按高亮、touch-action: manipulation(消双击缩放判定延迟),
|
|
8
|
+
// 纯图标按钮(唯一子元素为 svg)面积兜底到 ≥36px(实测一批 28×28 的工具钮手机上很难点中);
|
|
9
|
+
// 3. 代码块横向滚动 + 行内代码换行(窄屏不撑破版面);
|
|
10
|
+
// 4. 浮动侧栏开关(FAB):拇指可达的 44px 圆钮,经 dsh 官方 ctx.layout.toggleSidebar()
|
|
11
|
+
// 驱动窄屏抽屉(自己不发状态,只调官方动作,和侧栏右上角的折叠钮行为完全一致);
|
|
12
|
+
// 顺带给 viewport meta 补 viewport-fit=cover,standalone 模式下 env(safe-area-*) 才有值。
|
|
13
|
+
//
|
|
14
|
+
// 开关协议(关键):dsh-dock 的 css 字段会「无条件」并入全局 <style>(外壳 fullDockCss
|
|
15
|
+
// 不区分功能启停),所以本模块所有全局样式都必须挂在 body[data-dk-mobile="1"] 属性下——
|
|
16
|
+
// 该属性由 Overlay 在「功能启用 && 视口命中」时打上、其余情况摘除。停用功能 = 属性消失 =
|
|
17
|
+
// 全部样式一秒还原,不需要任何 !important 互相拆台。
|
|
18
|
+
//
|
|
19
|
+
// 命中条件:窗口宽 ≤820px,或 主指针为粗触且无悬停(真手机/平板)。桌面触屏(主指针 fine)
|
|
20
|
+
// 不会误命中;桌面把窗口拖窄到 820px 以下也会命中——这是有意的,方便桌面调试手机效果。
|
|
21
|
+
//
|
|
22
|
+
// ⚠️ 命名空间用 dkmob- 前缀:dkm- 已被【模型设置】模块占用,撞了会互相覆盖样式。
|
|
23
|
+
import react from "react";
|
|
24
|
+
import { useEffect, useState } from "react";
|
|
25
|
+
|
|
26
|
+
/** 视口命中条件(任一即视为手机形态)。 */
|
|
27
|
+
const MOBILE_QUERY_NARROW = "(max-width: 820px)";
|
|
28
|
+
const MOBILE_QUERY_COARSE = "(pointer: coarse) and (hover: none)";
|
|
29
|
+
|
|
30
|
+
// 浮动侧栏按钮的显示偏好(localStorage 持久化,按浏览器来源各自记忆——手机上关掉
|
|
31
|
+
// 不影响桌面)。缺省显示:功能启用 + 视口命中时,首次就来一个可用的拇指开关。
|
|
32
|
+
const MOBILE_STORE_KEY = "dsh-dock/mobile/v1";
|
|
33
|
+
const mobilePref = { floating: true };
|
|
34
|
+
try {
|
|
35
|
+
if (typeof localStorage !== "undefined") {
|
|
36
|
+
const raw = localStorage.getItem(MOBILE_STORE_KEY);
|
|
37
|
+
const obj = raw ? JSON.parse(raw) : null;
|
|
38
|
+
if (obj && typeof obj === "object" && typeof obj.floating === "boolean") mobilePref.floating = obj.floating;
|
|
39
|
+
}
|
|
40
|
+
} catch { /* localStorage 不可用时用默认值 */ }
|
|
41
|
+
|
|
42
|
+
const prefListeners = new Set();
|
|
43
|
+
function notifyPref() {
|
|
44
|
+
for (const fn of prefListeners) {
|
|
45
|
+
try { fn(); } catch { /* 订阅者渲染失败不影响其他 */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 浮动侧栏按钮当前是否显示。 */
|
|
49
|
+
export function floatingShown() {
|
|
50
|
+
return mobilePref.floating !== false;
|
|
51
|
+
}
|
|
52
|
+
/** 设置浮动侧栏按钮显示偏好(立即生效并持久化)。 */
|
|
53
|
+
export function setFloatingShown(value) {
|
|
54
|
+
mobilePref.floating = value !== false;
|
|
55
|
+
try {
|
|
56
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(MOBILE_STORE_KEY, JSON.stringify(mobilePref));
|
|
57
|
+
} catch { /* 持久化失败静默 */ }
|
|
58
|
+
notifyPref();
|
|
59
|
+
}
|
|
60
|
+
function subscribePref(fn) {
|
|
61
|
+
prefListeners.add(fn);
|
|
62
|
+
return () => { prefListeners.delete(fn); };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 当前视口是否命中手机形态(SSR/无 window 时恒 false)。 */
|
|
66
|
+
export function mobileActive() {
|
|
67
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
|
68
|
+
try {
|
|
69
|
+
return window.matchMedia(MOBILE_QUERY_NARROW).matches || window.matchMedia(MOBILE_QUERY_COARSE).matches;
|
|
70
|
+
} catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// —— Overlay:功能启用期间常驻挂载,负责属性开关、viewport meta 与浮动按钮 ——
|
|
74
|
+
// 属性管理放 effect 里(含卸载清理):功能停用 → Overlay 卸载 → 属性摘除 → 全局样式失效。
|
|
75
|
+
//
|
|
76
|
+
// ⚠️ 与【远程访问】(mobile-relay) 的手机 chrome 互斥协调:窄屏 ≤700px 且远程访问启用时,
|
|
77
|
+
// 它的 Overlay 会自建「抽屉把手 + 底部 Tab 栏」整套 chrome,并经 window.__dshDockMobileDrawer
|
|
78
|
+
// 旗标('host' | 'client',先到先得)声明归属。本模块见到旗标即让出浮动按钮——侧栏开合
|
|
79
|
+
// 已有把手负责,两个浮标并排是重复 UI;CSS 兜底层不受影响,互补生效。功能坞面板开关
|
|
80
|
+
// 全局重渲染时本组件会跟着重渲染,旗标消失(远程访问被停用)后浮动按钮自动回来。
|
|
81
|
+
export function MobileOverlay(props) {
|
|
82
|
+
const ctx = props && props.ctx;
|
|
83
|
+
const [active, setActive] = useState(() => mobileActive());
|
|
84
|
+
const [floating, setFloating] = useState(() => floatingShown());
|
|
85
|
+
|
|
86
|
+
// 视口命中监听(matchMedia change;老内核降级 addListener)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
|
|
89
|
+
let mqs = [];
|
|
90
|
+
try {
|
|
91
|
+
mqs = [window.matchMedia(MOBILE_QUERY_NARROW), window.matchMedia(MOBILE_QUERY_COARSE)];
|
|
92
|
+
} catch { return undefined; }
|
|
93
|
+
const update = () => setActive(mqs.some((mq) => mq.matches));
|
|
94
|
+
update();
|
|
95
|
+
for (const mq of mqs) {
|
|
96
|
+
if (typeof mq.addEventListener === "function") mq.addEventListener("change", update);
|
|
97
|
+
else if (typeof mq.addListener === "function") mq.addListener(update);
|
|
98
|
+
}
|
|
99
|
+
return () => {
|
|
100
|
+
for (const mq of mqs) {
|
|
101
|
+
if (typeof mq.removeEventListener === "function") mq.removeEventListener("change", update);
|
|
102
|
+
else if (typeof mq.removeListener === "function") mq.removeListener(update);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
// body 属性 = 本模块全部全局样式的总开关;顺带补 viewport-fit=cover(幂等)。
|
|
108
|
+
// 只补不删:cover 在非 standalone 环境无副作用,删了反而让用户自己在 PWA 里的布局抖一次。
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (typeof document === "undefined") return undefined;
|
|
111
|
+
if (active) {
|
|
112
|
+
document.body.setAttribute("data-dk-mobile", "1");
|
|
113
|
+
try {
|
|
114
|
+
const meta = document.querySelector('meta[name="viewport"]');
|
|
115
|
+
if (meta && !/(^|,)\s*viewport-fit\s*=/.test(meta.content || "")) {
|
|
116
|
+
meta.setAttribute("content", (meta.content || "width=device-width, initial-scale=1") + ", viewport-fit=cover");
|
|
117
|
+
}
|
|
118
|
+
} catch { /* meta 缺失不致命 */ }
|
|
119
|
+
} else {
|
|
120
|
+
document.body.removeAttribute("data-dk-mobile");
|
|
121
|
+
}
|
|
122
|
+
return () => { document.body.removeAttribute("data-dk-mobile"); };
|
|
123
|
+
}, [active]);
|
|
124
|
+
|
|
125
|
+
// 偏好变化(功能设置页里的开关)实时反映到浮动按钮
|
|
126
|
+
useEffect(() => subscribePref(() => setFloating(floatingShown())), []);
|
|
127
|
+
|
|
128
|
+
// 远程访问手机 chrome 的工作带宽(≤700px)。旗标在但视口出了它的带(700~820px 区间)
|
|
129
|
+
// 时它的元素只是被 sync 隐藏、仍在 DOM 里——此时浮动按钮必须回归,否则这个区间
|
|
130
|
+
// 没有任何侧栏浮标。监听 700px 边界,跨线即重渲染。
|
|
131
|
+
const [relayBand, setRelayBand] = useState(() => {
|
|
132
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
|
133
|
+
try { return window.matchMedia("(max-width: 700px)").matches; } catch { return false; }
|
|
134
|
+
});
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
|
|
137
|
+
let mq = null;
|
|
138
|
+
try { mq = window.matchMedia("(max-width: 700px)"); } catch { return undefined; }
|
|
139
|
+
const update = () => setRelayBand(mq.matches);
|
|
140
|
+
update();
|
|
141
|
+
if (typeof mq.addEventListener === "function") mq.addEventListener("change", update);
|
|
142
|
+
else if (typeof mq.addListener === "function") mq.addListener(update);
|
|
143
|
+
return () => {
|
|
144
|
+
if (typeof mq.removeEventListener === "function") mq.removeEventListener("change", update);
|
|
145
|
+
else if (typeof mq.removeListener === "function") mq.removeListener(update);
|
|
146
|
+
};
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
if (!active || !floating) return null;
|
|
150
|
+
// 远程访问的手机 chrome 在场且视口在它的带内:把手/底部 Tab 已提供侧栏开合,让位
|
|
151
|
+
const relayChromeActive = typeof window !== "undefined" && window.__dshDockMobileDrawer != null && relayBand;
|
|
152
|
+
if (relayChromeActive) return null;
|
|
153
|
+
const onToggle = () => {
|
|
154
|
+
try {
|
|
155
|
+
const layout = ctx && ctx.get ? ctx.get("layout") : null;
|
|
156
|
+
if (layout && typeof layout.toggleSidebar === "function") layout.toggleSidebar();
|
|
157
|
+
else console.warn("[dsh-dock] 手机适配:ctx.layout.toggleSidebar 不可用(dsh 版本过旧?)");
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error("[dsh-dock] 手机适配:切换侧栏失败:", err);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
return react.createElement("button", {
|
|
163
|
+
type: "button",
|
|
164
|
+
className: "dkmob-fab",
|
|
165
|
+
"aria-label": "开关侧栏",
|
|
166
|
+
title: "开关侧栏",
|
|
167
|
+
onClick: onToggle,
|
|
168
|
+
}, react.createElement("svg", { width: 20, height: 20, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.3, "aria-hidden": true },
|
|
169
|
+
react.createElement("rect", { x: 1.5, y: 2.5, width: 13, height: 11, rx: 2 }),
|
|
170
|
+
react.createElement("line", { x1: 6, y1: 2.5, x2: 6, y2: 13.5 })));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// —— 功能页:状态一览 + 浮动按钮开关 + 说明 ——
|
|
174
|
+
function useViewportSize() {
|
|
175
|
+
const [size, setSize] = useState(() => (typeof window === "undefined" ? null : { w: window.innerWidth, h: window.innerHeight }));
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (typeof window === "undefined") return undefined;
|
|
178
|
+
const update = () => setSize({ w: window.innerWidth, h: window.innerHeight });
|
|
179
|
+
window.addEventListener("resize", update);
|
|
180
|
+
return () => window.removeEventListener("resize", update);
|
|
181
|
+
}, []);
|
|
182
|
+
return size;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function MobileView() {
|
|
186
|
+
const [active, setActive] = useState(() => mobileActive());
|
|
187
|
+
const [floating, setFloating] = useState(() => floatingShown());
|
|
188
|
+
const size = useViewportSize();
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
|
|
191
|
+
let mqs = [];
|
|
192
|
+
try {
|
|
193
|
+
mqs = [window.matchMedia(MOBILE_QUERY_NARROW), window.matchMedia(MOBILE_QUERY_COARSE)];
|
|
194
|
+
} catch { return undefined; }
|
|
195
|
+
const update = () => setActive(mqs.some((mq) => mq.matches));
|
|
196
|
+
update();
|
|
197
|
+
for (const mq of mqs) {
|
|
198
|
+
if (typeof mq.addEventListener === "function") mq.addEventListener("change", update);
|
|
199
|
+
else if (typeof mq.addListener === "function") mq.addListener(update);
|
|
200
|
+
}
|
|
201
|
+
return () => {
|
|
202
|
+
for (const mq of mqs) {
|
|
203
|
+
if (typeof mq.removeEventListener === "function") mq.removeEventListener("change", update);
|
|
204
|
+
else if (typeof mq.removeListener === "function") mq.removeListener(update);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}, []);
|
|
208
|
+
return react.createElement("div", { className: "dkmob-root" },
|
|
209
|
+
react.createElement("div", { className: "dkmob-row" },
|
|
210
|
+
react.createElement("span", { className: "dkmob-label" }, "手机模式"),
|
|
211
|
+
react.createElement("span", { className: "dkmob-state" + (active ? " on" : "") }, active ? "已命中" : "未命中"),
|
|
212
|
+
react.createElement("span", { className: "dkmob-dim" },
|
|
213
|
+
size ? size.w + "×" + size.h + "px" : "",
|
|
214
|
+
"(≤820px 宽,或主指针为触屏无悬停时命中;桌面把窗口拖窄也会命中,方便调试)")),
|
|
215
|
+
react.createElement("div", { className: "dkmob-row" },
|
|
216
|
+
react.createElement("span", { className: "dkmob-label" }, "浮动侧栏按钮"),
|
|
217
|
+
react.createElement("button", {
|
|
218
|
+
type: "button",
|
|
219
|
+
className: "dock-sw" + (floating ? " on" : ""),
|
|
220
|
+
role: "switch",
|
|
221
|
+
"aria-checked": floating,
|
|
222
|
+
"aria-label": (floating ? "隐藏" : "显示") + "浮动侧栏按钮",
|
|
223
|
+
title: floating ? "隐藏左下角的浮动侧栏开关" : "显示左下角的浮动侧栏开关",
|
|
224
|
+
onClick: () => setFloatingShown(!floating),
|
|
225
|
+
}),
|
|
226
|
+
react.createElement("span", { className: "dkmob-dim" }, "44px 圆钮固定在左下角,调用 dsh 官方 toggleSidebar 开合窄屏抽屉;不需要时可在此关闭。")),
|
|
227
|
+
react.createElement("div", { className: "dkmob-note" },
|
|
228
|
+
"生效中的优化:输入类控件字号拉到 16px(避免 iOS 聚焦缩放整页)· 纯图标按钮面积兜底 ≥36px · ",
|
|
229
|
+
"去掉点按高亮与双击缩放判定 · 代码块横向滚动、行内代码自动换行 · viewport meta 补 viewport-fit=cover",
|
|
230
|
+
"(standalone 全屏时给圆角/刘海留安全区)。停用本功能即全部还原。"));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// —— 首页概要:一行说清当前命中状态 ——
|
|
234
|
+
function MobileStat() {
|
|
235
|
+
const [active, setActive] = useState(() => mobileActive());
|
|
236
|
+
const [floating, setFloating] = useState(() => floatingShown());
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
|
|
239
|
+
let mqs = [];
|
|
240
|
+
try {
|
|
241
|
+
mqs = [window.matchMedia(MOBILE_QUERY_NARROW), window.matchMedia(MOBILE_QUERY_COARSE)];
|
|
242
|
+
} catch { return undefined; }
|
|
243
|
+
const update = () => setActive(mqs.some((mq) => mq.matches));
|
|
244
|
+
update();
|
|
245
|
+
for (const mq of mqs) {
|
|
246
|
+
if (typeof mq.addEventListener === "function") mq.addEventListener("change", update);
|
|
247
|
+
else if (typeof mq.addListener === "function") mq.addListener(update);
|
|
248
|
+
}
|
|
249
|
+
return () => {
|
|
250
|
+
for (const mq of mqs) {
|
|
251
|
+
if (typeof mq.removeEventListener === "function") mq.removeEventListener("change", update);
|
|
252
|
+
else if (typeof mq.removeListener === "function") mq.removeListener(update);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}, []);
|
|
256
|
+
useEffect(() => subscribePref(() => setFloating(floatingShown())), []);
|
|
257
|
+
if (!active) return react.createElement("span", null, "当前视口未命中手机形态,样式未注入");
|
|
258
|
+
return react.createElement("span", null, "手机模式生效中 · 浮动侧栏按钮" + (floating ? "显示" : "已隐藏"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// —— 样式:FAB 自身样式(随功能启停)+ 全局兜底(全部挂在 body[data-dk-mobile="1"] 下,
|
|
262
|
+
// 见文件头的开关协议说明。!important 只用在输入字号上:dsh 组件层对输入框有自己的
|
|
263
|
+
// 字号声明,具体度相同的层叠顺序对插件的 <style> 插入时机敏感,这里以内容正确性优先)——
|
|
264
|
+
const css = [
|
|
265
|
+
// 浮动侧栏按钮:44px 触控面积(Apple HIG 最低建议),token 取主题变量自动适配亮暗色;
|
|
266
|
+
// z-index 190 压在功能坞弹层(dockm-backdrop,200)之下,弹层打开时被遮罩盖住不误触。
|
|
267
|
+
".dkmob-fab{position:fixed;left:calc(env(safe-area-inset-left,0px) + 62px);bottom:calc(env(safe-area-inset-bottom,0px) + 74px);z-index:190;width:44px;height:44px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;justify-content:center;padding:0;box-shadow:0 4px 14px rgb(0 0 0 / .22);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;transition:transform .12s var(--ds-ease-in-out),background .15s var(--ds-ease-in-out),color .15s var(--ds-ease-in-out);}",
|
|
268
|
+
".dkmob-fab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
|
|
269
|
+
".dkmob-fab:active{transform:scale(.94);}",
|
|
270
|
+
// 输入类控件 16px:防 iOS 聚焦自动放大(<16px 的输入框聚焦时 Safari 强制缩放页面)
|
|
271
|
+
'body[data-dk-mobile="1"] :is(textarea, input:not([type]), input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="number"], input[type="email"], [contenteditable="true"]){font-size:16px !important;}',
|
|
272
|
+
// 触控基础:去点按高亮、消双击缩放判定
|
|
273
|
+
'body[data-dk-mobile="1"] :is(button, [role="button"]){-webkit-tap-highlight-color:transparent;touch-action:manipulation;}',
|
|
274
|
+
// 纯图标按钮面积兜底:实测窄屏下一批 28×28 的工具钮很难点中;36px 是不引起行高剧变的上限。
|
|
275
|
+
// 只收「唯一子元素是 svg」的按钮,避免撑坏带文字的行内控件;排除远程访问的抽屉把手
|
|
276
|
+
// (.dsh-mobile-drawer-btn 贴边设计 30px 宽,被撑到 36 会破坏边缘吸附观感)与本模块 FAB。
|
|
277
|
+
'body[data-dk-mobile="1"] button:has(> svg:only-child):not(.dsh-mobile-drawer-btn):not(.dkmob-fab){min-width:36px;min-height:36px;}',
|
|
278
|
+
// 代码块横向滚动、行内代码换行:窄屏不撑破版面
|
|
279
|
+
'body[data-dk-mobile="1"] pre{max-width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;}',
|
|
280
|
+
'body[data-dk-mobile="1"] code{overflow-wrap:anywhere;}',
|
|
281
|
+
'body[data-dk-mobile="1"] pre code{overflow-wrap:normal;}',
|
|
282
|
+
// iOS 横竖屏切换时的字号自动膨胀关掉(:has 挂到 html 上;dsh 自身样式已依赖 :has,基线足够)
|
|
283
|
+
'html:has(body[data-dk-mobile="1"]){-webkit-text-size-adjust:100%;text-size-adjust:100%;}',
|
|
284
|
+
// 功能页自身
|
|
285
|
+
".dkmob-root{display:flex;flex-direction:column;gap:10px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.6;}",
|
|
286
|
+
".dkmob-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
|
|
287
|
+
".dkmob-label{font-weight:600;flex:none;}",
|
|
288
|
+
".dkmob-state{flex:none;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 10px;font-size:11px;}",
|
|
289
|
+
".dkmob-state.on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);}",
|
|
290
|
+
".dkmob-dim{color:var(--dsw-alias-label-secondary);font-size:12px;}",
|
|
291
|
+
".dkmob-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.7;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;}",
|
|
292
|
+
].join("\n");
|
|
293
|
+
|
|
294
|
+
export const feature = {
|
|
295
|
+
id: "mobile",
|
|
296
|
+
name: "手机适配",
|
|
297
|
+
order: 170,
|
|
298
|
+
accent: "#34d399",
|
|
299
|
+
description: "手机/窄屏排版与触控兜底:16px 输入防 iOS 缩放、图标按钮触控面积、代码块滚动、浮动侧栏开关",
|
|
300
|
+
defaultEnabled: true,
|
|
301
|
+
css,
|
|
302
|
+
Overlay: MobileOverlay,
|
|
303
|
+
View: MobileView,
|
|
304
|
+
HomeStat: MobileStat,
|
|
305
|
+
};
|
|
@@ -6,17 +6,20 @@
|
|
|
6
6
|
// 主实例:绑定回环时把 Host/Origin 改写为回环(回环恒被信任),已绑 0.0.0.0 时
|
|
7
7
|
// 透传以走局域网信任派生。宿主可注入 mintUpstreamCookie:服务端兑换 dsh web 的
|
|
8
8
|
// 启动令牌会话并在代理时自动附带(含 WebSocket 升级),远程浏览器只需账号密码。
|
|
9
|
-
// HTML 响应注入远程标记(window.__DSH_REMOTE__
|
|
10
|
-
// crypto.randomUUID
|
|
9
|
+
// HTML 响应注入远程标记(window.__DSH_REMOTE__,供面板显示退出按钮)、
|
|
10
|
+
// crypto.randomUUID 兜底脚本引用、PWA 元信息(manifest/图标,手机「添加到主屏幕」
|
|
11
|
+
// 后全屏打开)与断线监视脚本(网关健康探测,断线时页面顶部出提示胶囊)。
|
|
11
12
|
import { createServer, request as httpRequest } from 'node:http'
|
|
12
13
|
import { networkInterfaces } from 'node:os'
|
|
13
14
|
import { randomBytes } from 'node:crypto'
|
|
15
|
+
import { deflateSync } from 'node:zlib'
|
|
14
16
|
|
|
15
17
|
const COOKIE_NAME = 'dsh_remote_session'
|
|
16
18
|
const LOGIN_PATH = '/__dsh_auth/login'
|
|
17
19
|
const LOGOUT_PATH = '/__dsh_auth/logout'
|
|
18
20
|
const HEALTH_PATH = '/__dsh_auth/health'
|
|
19
21
|
const COMPAT_PATH = '/__dsh_mobile/compat.js'
|
|
22
|
+
const MANIFEST_PATH = '/__dsh_auth/manifest.webmanifest'
|
|
20
23
|
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
21
24
|
const MAX_LOGIN_BODY = 1024
|
|
22
25
|
const MAX_HTML_BYTES = 2 * 1024 * 1024
|
|
@@ -27,9 +30,14 @@ const MAX_LOGIN_ATTEMPTS = 10
|
|
|
27
30
|
// 构造 memory 镜像、永不读档,设置/模型页全部显示 unavailable)。而服务端信任栏
|
|
28
31
|
// 只看 Host/Origin——经本网关(登录后)的请求早已映射为回环信任,特权接口实际
|
|
29
32
|
// 全部放行。唯一拦住手机用户的只剩客户端这一处自我判定,所以对本 bundle 做定点
|
|
30
|
-
//
|
|
33
|
+
// 改写(判定恒真)。候选片段按代际排列(dsh ≥0.1.7 在判定前面加了 ownsHost 短路,
|
|
34
|
+
// 旧片段在新产物里不再出现——0.1.7 上线时手机设置/模型页因此再次 unavailable);
|
|
35
|
+
// 全部匹配不上则原样透传(退化为官方行为,不影响其他功能)。
|
|
31
36
|
const CONNECTION_BUNDLE_PREFIX = '/plugins/@deepseek-ai/dsh-client-connection/client.js'
|
|
32
|
-
const
|
|
37
|
+
const CONNECTION_ISLOOPBACK_SNIPPETS = [
|
|
38
|
+
'isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)',
|
|
39
|
+
'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)',
|
|
40
|
+
]
|
|
33
41
|
const CONNECTION_ISLOOPBACK_PATCHED = 'isLoopback: !0 /* dsh-dock remote gateway */'
|
|
34
42
|
const MAX_ASSET_BYTES = 4 * 1024 * 1024
|
|
35
43
|
const PATCH_CACHE_LIMIT = 16
|
|
@@ -41,6 +49,149 @@ const PATCH_CACHE_LIMIT = 16
|
|
|
41
49
|
// property and on Crypto.prototype. Injected before every DSH bootstrap script.
|
|
42
50
|
const MOBILE_COMPAT_JS = `(function(){var g=typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof self!=='undefined'?self:undefined));if(!g)return;if(!g.crypto){try{g.crypto={}}catch(e){return}}var c=g.crypto;var nativeRng=typeof c.getRandomValues==='function'?c.getRandomValues.bind(c):null;function rng(bytes){if(nativeRng){nativeRng(bytes);return bytes}for(var i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256)&255;return bytes}function uuid(){var b=rng(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=[];for(var i=0;i<16;i++)h.push((b[i]+256).toString(16).slice(1));return h.slice(0,4).join('')+'-'+h.slice(4,6).join('')+'-'+h.slice(6,8).join('')+'-'+h.slice(8,10).join('')+'-'+h.slice(10).join('')}function fill(array){rng(array);return array}function define(obj,name,value){if(!obj||typeof obj[name]==='function')return;try{Object.defineProperty(obj,name,{value:value,configurable:true})}catch(e){try{obj[name]=value}catch(e2){}}}define(c,'randomUUID',uuid);if(!nativeRng)define(c,'getRandomValues',fill);define(g.Crypto&&g.Crypto.prototype||null,'randomUUID',uuid);if(!nativeRng)define(g.Crypto&&g.Crypto.prototype||null,'getRandomValues',fill);if(typeof c.randomUUID!=='function'){var fresh={getRandomValues:fill,randomUUID:uuid};if(c.subtle)fresh.subtle=c.subtle;var keyOrigin=Object.create(null);for(var k in c){try{keyOrigin[k]=c[k]}catch(e3){}}for(var k2 in keyOrigin){if(typeof fresh[k2]==='undefined')fresh[k2]=keyOrigin[k2]}try{Object.defineProperty(g,'crypto',{value:fresh,configurable:true})}catch(e4){try{g.crypto=fresh}catch(e5){}}}})()`
|
|
43
51
|
|
|
52
|
+
// ---- PWA:手机「添加到主屏幕」后全屏打开(无浏览器地址栏,像 App 一样使用)----
|
|
53
|
+
// head 标签注入进所有经网关的 HTML(登录页 + DSH 页面);manifest 与图标由网关
|
|
54
|
+
// 自行服务(登录前也要可取,否则添加主屏会拿不到清单)。
|
|
55
|
+
const PWA_HEAD_TAGS = [
|
|
56
|
+
'<meta name="theme-color" content="#0f1115"/>',
|
|
57
|
+
'<meta name="mobile-web-app-capable" content="yes"/>',
|
|
58
|
+
'<meta name="apple-mobile-web-app-capable" content="yes"/>',
|
|
59
|
+
'<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>',
|
|
60
|
+
'<meta name="apple-mobile-web-app-title" content="DSH"/>',
|
|
61
|
+
'<link rel="manifest" href="' + MANIFEST_PATH + '"/>',
|
|
62
|
+
'<link rel="apple-touch-icon" href="/__dsh_auth/icon-192.png"/>',
|
|
63
|
+
'<link rel="icon" type="image/png" href="/__dsh_auth/icon-192.png"/>',
|
|
64
|
+
].join('')
|
|
65
|
+
// 远程标记并入监视脚本开头:面板的「退出登录」按钮靠它拿到注销地址(桌面本机访问
|
|
66
|
+
// 时没有这段脚本,按钮不显示——没有它手机上点了退出没反应)。
|
|
67
|
+
// 断线监视:手机换网(WiFi↔蜂窝)/锁屏久置后连接可能已死,页面本身不会提示——
|
|
68
|
+
// 顶部出「连接已断开,正在重连…」胶囊,恢复后自动消失。经网关健康端点探测
|
|
69
|
+
// (连续 2 次失败才提示,避免瞬时抖动闪现);离线/恢复事件立即响应。ES5,自含样式。
|
|
70
|
+
const REMOTE_WATCH_JS = "window.__DSH_REMOTE__={gateway:true,logout:'/__dsh_auth/logout'};(function(){var HEALTH='/__dsh_auth/health',pill=null,checking=false,fails=0;function css(){var s=document.createElement('style');s.setAttribute('data-dsh-remote-watch','');s.textContent='.dsh-remote-net{position:fixed;top:calc(env(safe-area-inset-top,0px) + 10px);left:50%;transform:translateX(-50%);z-index:2147483000;display:none;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;font:600 12px/1.4 system-ui,-apple-system,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.35);pointer-events:none;white-space:nowrap}.dsh-remote-net .dot{width:8px;height:8px;border-radius:50%;background:#fff;opacity:.95;animation:dsh-remote-blink 1.1s ease-in-out infinite}@keyframes dsh-remote-blink{0%,100%{opacity:.95}50%{opacity:.25}}@media (prefers-reduced-motion:reduce){.dsh-remote-net .dot{animation:none}}';(document.head||document.documentElement).appendChild(s)}function ensure(){if(pill)return;css();pill=document.createElement('div');pill.className='dsh-remote-net';pill.setAttribute('role','status');pill.innerHTML='<span class=\"dot\"></span><span>连接已断开,正在重连…</span>';if(document.body)document.body.appendChild(pill);else{var t=setInterval(function(){if(document.body){clearInterval(t);document.body.appendChild(pill)}},200)}}function show(){ensure();pill.style.display='flex'}function hide(){if(pill)pill.style.display='none'}function finish(ok){checking=false;if(ok){fails=0;hide()}else if(++fails>=2){show()}}function ping(){if(checking||document.visibilityState==='hidden')return;checking=true;var done=false,t=setTimeout(function(){if(!done){done=true;finish(false)}},5000);fetch(HEALTH,{method:'POST',cache:'no-store'}).then(function(r){if(!done){done=true;clearTimeout(t);finish(r.ok||r.status===401||r.status===302)}}).catch(function(){if(!done){done=true;clearTimeout(t);finish(false)}})}window.addEventListener('offline',function(){show()});window.addEventListener('online',function(){fails=1;ping()});document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')ping()});setInterval(ping,10000);ping()})()"
|
|
71
|
+
|
|
72
|
+
// ---- 零依赖 PNG 图标:远程访问 PWA 图标(品牌渐变圆角块 + 白色对话气泡)----
|
|
73
|
+
// 不引第三方图像依赖:像素逐点光栅化(SDF 抗锯齿),PNG 只需 IHDR/IDAT/IEND
|
|
74
|
+
// 三块(zlib deflate + CRC32)。maskable 变体用满幅方形(无圆角),系统裁切成
|
|
75
|
+
// 各种形状时内容都在安全区内。
|
|
76
|
+
const crcTable = (() => {
|
|
77
|
+
const table = new Int32Array(256)
|
|
78
|
+
for (let n = 0; n < 256; n++) {
|
|
79
|
+
let c = n
|
|
80
|
+
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
|
|
81
|
+
table[n] = c
|
|
82
|
+
}
|
|
83
|
+
return table
|
|
84
|
+
})()
|
|
85
|
+
function crc32(buf) {
|
|
86
|
+
let c = -1
|
|
87
|
+
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xFF] ^ (c >>> 8)
|
|
88
|
+
return (c ^ -1) >>> 0
|
|
89
|
+
}
|
|
90
|
+
function pngChunk(type, data) {
|
|
91
|
+
const out = Buffer.alloc(8 + data.length + 4)
|
|
92
|
+
out.writeUInt32BE(data.length, 0)
|
|
93
|
+
out.write(type, 4, 'ascii')
|
|
94
|
+
data.copy(out, 8)
|
|
95
|
+
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length)
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
function segmentDist(px, py, ax, ay, bx, by) {
|
|
99
|
+
const abx = bx - ax, aby = by - ay
|
|
100
|
+
const t = Math.min(1, Math.max(0, ((px - ax) * abx + (py - ay) * aby) / (abx * abx + aby * aby)))
|
|
101
|
+
return Math.hypot(px - (ax + abx * t), py - (ay + aby * t))
|
|
102
|
+
}
|
|
103
|
+
function triangleSdf(px, py, tri) {
|
|
104
|
+
const ax = tri[0], ay = tri[1], bx = tri[2], by = tri[3], cx = tri[4], cy = tri[5]
|
|
105
|
+
const d = Math.min(
|
|
106
|
+
segmentDist(px, py, ax, ay, bx, by),
|
|
107
|
+
segmentDist(px, py, bx, by, cx, cy),
|
|
108
|
+
segmentDist(px, py, cx, cy, ax, ay),
|
|
109
|
+
)
|
|
110
|
+
const s1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax)
|
|
111
|
+
const s2 = (cx - bx) * (py - by) - (cy - by) * (px - bx)
|
|
112
|
+
const s3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx)
|
|
113
|
+
const inside = (s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0)
|
|
114
|
+
return inside ? -d : d
|
|
115
|
+
}
|
|
116
|
+
function roundedRectSdf(x, y, x0, y0, x1, y1, r) {
|
|
117
|
+
const cx = Math.min(Math.max(x, x0 + r), x1 - r)
|
|
118
|
+
const cy = Math.min(Math.max(y, y0 + r), y1 - r)
|
|
119
|
+
return Math.hypot(x - cx, y - cy) - r
|
|
120
|
+
}
|
|
121
|
+
function renderIconPng(size, maskable) {
|
|
122
|
+
const rgba = Buffer.alloc(size * size * 4)
|
|
123
|
+
const c1 = [0x38, 0xbd, 0xf8] // #38bdf8(登录页品牌渐变起点)
|
|
124
|
+
const c2 = [0x63, 0x66, 0xf1] // #6366f1(终点)
|
|
125
|
+
const inset = maskable ? 0 : size * 0.04
|
|
126
|
+
const corner = maskable ? 0 : size * 0.22
|
|
127
|
+
const s = size
|
|
128
|
+
const bubble = [s * 0.26, s * 0.24, s * 0.74, s * 0.58, s * 0.07]
|
|
129
|
+
const tail = [s * 0.305, s * 0.565, s * 0.305, s * 0.72, s * 0.455, s * 0.565]
|
|
130
|
+
for (let y = 0; y < size; y++) {
|
|
131
|
+
for (let x = 0; x < size; x++) {
|
|
132
|
+
const at = (y * size + x) * 4
|
|
133
|
+
const px = x + 0.5, py = y + 0.5
|
|
134
|
+
const tile = roundedRectSdf(px, py, inset, inset, s - inset, s - inset, corner)
|
|
135
|
+
const tileA = Math.min(Math.max(0.5 - tile, 0), 1)
|
|
136
|
+
if (tileA <= 0) { rgba[at + 3] = 0; continue }
|
|
137
|
+
const t = (px + py) / (2 * s)
|
|
138
|
+
let r = c1[0] + (c2[0] - c1[0]) * t
|
|
139
|
+
let g = c1[1] + (c2[1] - c1[1]) * t
|
|
140
|
+
let b = c1[2] + (c2[2] - c1[2]) * t
|
|
141
|
+
const glyph = Math.min(
|
|
142
|
+
roundedRectSdf(px, py, bubble[0], bubble[1], bubble[2], bubble[3], bubble[4]),
|
|
143
|
+
triangleSdf(px, py, tail),
|
|
144
|
+
)
|
|
145
|
+
const glyphA = Math.min(Math.max(0.5 - glyph, 0), 1)
|
|
146
|
+
r += (255 - r) * glyphA
|
|
147
|
+
g += (255 - g) * glyphA
|
|
148
|
+
b += (255 - b) * glyphA
|
|
149
|
+
rgba[at] = Math.round(r)
|
|
150
|
+
rgba[at + 1] = Math.round(g)
|
|
151
|
+
rgba[at + 2] = Math.round(b)
|
|
152
|
+
rgba[at + 3] = Math.round(tileA * 255)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const ihdr = Buffer.alloc(13)
|
|
156
|
+
ihdr.writeUInt32BE(size, 0)
|
|
157
|
+
ihdr.writeUInt32BE(size, 4)
|
|
158
|
+
ihdr[8] = 8; ihdr[9] = 6
|
|
159
|
+
const raw = Buffer.alloc((size * 4 + 1) * size)
|
|
160
|
+
for (let y = 0; y < size; y++) {
|
|
161
|
+
raw[y * (size * 4 + 1)] = 0
|
|
162
|
+
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4)
|
|
163
|
+
}
|
|
164
|
+
return Buffer.concat([
|
|
165
|
+
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
166
|
+
pngChunk('IHDR', ihdr),
|
|
167
|
+
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
168
|
+
pngChunk('IEND', Buffer.alloc(0)),
|
|
169
|
+
])
|
|
170
|
+
}
|
|
171
|
+
const iconCache = new Map()
|
|
172
|
+
function iconPng(size, maskable) {
|
|
173
|
+
const key = (maskable ? 'm' : 'a') + size
|
|
174
|
+
if (!iconCache.has(key)) iconCache.set(key, renderIconPng(size, maskable))
|
|
175
|
+
return iconCache.get(key)
|
|
176
|
+
}
|
|
177
|
+
function manifestJson() {
|
|
178
|
+
return JSON.stringify({
|
|
179
|
+
name: 'DSH 远程访问',
|
|
180
|
+
short_name: 'DSH',
|
|
181
|
+
description: '登录后使用与本机完全一致的 DSH:会话、任务进度实时一致',
|
|
182
|
+
start_url: '/',
|
|
183
|
+
scope: '/',
|
|
184
|
+
display: 'standalone',
|
|
185
|
+
background_color: '#0f1115',
|
|
186
|
+
theme_color: '#0f1115',
|
|
187
|
+
icons: [
|
|
188
|
+
{ src: '/__dsh_auth/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
189
|
+
{ src: '/__dsh_auth/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
|
190
|
+
{ src: '/__dsh_auth/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
44
195
|
function secret() { return randomBytes(32).toString('base64url') }
|
|
45
196
|
function now() { return Date.now() }
|
|
46
197
|
function isPrivateIpv4(ip) {
|
|
@@ -107,7 +258,9 @@ function loginPageHtml(withError) {
|
|
|
107
258
|
const errorRow = withError
|
|
108
259
|
? `<div class="error" role="alert">账号或密码不正确,请重试。</div>`
|
|
109
260
|
: `<div class="noerror"></div>`
|
|
110
|
-
|
|
261
|
+
// 「显示密码」:手机上输错密码的成本高(键盘小、自动更正捣乱),明文切换是刚需。
|
|
262
|
+
// 提交后禁用按钮防重复提交(失败走 303 重载,不存在卡死)。
|
|
263
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light">${PWA_HEAD_TAGS}<title>DSH 远程访问</title><style>${PAGE_STYLE}.pw{position:relative}.pw input{padding-right:64px}.pw .peek{position:absolute;right:8px;top:22px;width:auto;margin:0;padding:6px 10px;border:0;background:transparent;color:#8b93a3;font-size:12px;font-weight:600;cursor:pointer}</style></head><body><main class="card"><form method="POST" action="${LOGIN_PATH}" onsubmit="var b=this.querySelector('button[type=submit]');if(b)b.disabled=true"><div class="brand">${PAGE_ICON}<div><h1>DSH 远程访问</h1><small>登录后可使用完整功能</small></div></div>${errorRow}<label>账号<input name="username" autocomplete="username" autofocus required></label><label for="dsh-pw">密码</label><div class="pw"><input id="dsh-pw" type="password" name="password" autocomplete="current-password" required><button type="button" class="peek" onclick="var i=document.getElementById('dsh-pw');var s=i.type==='password';i.type=s?'text':'password';this.textContent=s?'隐藏':'显示'">显示</button></div><button type="submit">登 录</button><p class="hint">仅限授权设备登录 · 会话 7 天内免重复登录</p></form></main></body></html>`
|
|
111
264
|
}
|
|
112
265
|
|
|
113
266
|
function logoutPageHtml() {
|
|
@@ -138,8 +291,10 @@ function htmlInjectionHeaders(headers) {
|
|
|
138
291
|
}
|
|
139
292
|
function injectMobileCompat(html) {
|
|
140
293
|
if (html.includes('data-dsh-mobile-compat')) return html
|
|
141
|
-
const match = /<head(?:\s[^>]*)?>/i
|
|
142
|
-
|
|
294
|
+
const match = html.match(/<head(?:\s[^>]*)?>/i)
|
|
295
|
+
// DSH 页面 head 前置:crypto 兜底脚本引用 + PWA head 标签(PWA_HEAD_TAGS 为常量;
|
|
296
|
+
// 远程标记与断线监视在 compat.js 文件内,见 COMPAT_PATH 路由)。
|
|
297
|
+
const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>${PWA_HEAD_TAGS}`
|
|
143
298
|
if (!match) return script + html
|
|
144
299
|
const at = match.index + match[0].length
|
|
145
300
|
return html.slice(0, at) + script + html.slice(at)
|
|
@@ -312,9 +467,10 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
312
467
|
let body = Buffer.concat(chunks)
|
|
313
468
|
if (isConnectionBundle) {
|
|
314
469
|
const text = body.toString('utf8')
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
470
|
+
// 按代际找片段:新版先试(新版文本里不含旧片段,顺序无歧义)
|
|
471
|
+
const hit = CONNECTION_ISLOOPBACK_SNIPPETS.map((snippet) => ({ snippet, at: text.indexOf(snippet) })).find((c) => c.at >= 0)
|
|
472
|
+
if (hit) {
|
|
473
|
+
body = Buffer.from(text.slice(0, hit.at) + CONNECTION_ISLOOPBACK_PATCHED + text.slice(hit.at + hit.snippet.length), 'utf8')
|
|
318
474
|
if (patchedBundles.size >= PATCH_CACHE_LIMIT) patchedBundles.clear()
|
|
319
475
|
patchedBundles.set(req.url, body)
|
|
320
476
|
}
|
|
@@ -344,6 +500,22 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
344
500
|
|
|
345
501
|
if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
|
|
346
502
|
|
|
503
|
+
// PWA 资源放行在登录之前:登录页也引用 manifest/图标,「添加到主屏幕」在
|
|
504
|
+
// 未登录时就能装好(装完后打开 / 未登录自然进登录页)。
|
|
505
|
+
if (url.pathname === MANIFEST_PATH && req.method === 'GET') {
|
|
506
|
+
res.writeHead(200, { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'no-store' })
|
|
507
|
+
return res.end(manifestJson())
|
|
508
|
+
}
|
|
509
|
+
{
|
|
510
|
+
const icon = url.pathname.match(/^\/__dsh_auth\/icon-(192|512|maskable-512)\.png$/)
|
|
511
|
+
if (icon && req.method === 'GET') {
|
|
512
|
+
const maskable = icon[1] === 'maskable-512'
|
|
513
|
+
const size = maskable ? 512 : Number(icon[1])
|
|
514
|
+
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' })
|
|
515
|
+
return res.end(iconPng(size, maskable))
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
347
519
|
if (url.pathname === LOGIN_PATH && req.method === 'GET') {
|
|
348
520
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
349
521
|
return res.end(loginPageHtml(url.searchParams.get('e') === '1'))
|
|
@@ -402,7 +574,9 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
402
574
|
'cache-control': 'no-store',
|
|
403
575
|
'x-content-type-options': 'nosniff',
|
|
404
576
|
})
|
|
405
|
-
|
|
577
|
+
// 兜底脚本之后接远程标记 + 断线监视:同一个外部脚本文件,页面加载早期执行
|
|
578
|
+
// (远程标记供面板「退出登录」用;监视脚本在断线时出顶部胶囊)。
|
|
579
|
+
return res.end(MOBILE_COMPAT_JS + '\n' + REMOTE_WATCH_JS)
|
|
406
580
|
}
|
|
407
581
|
return proxyHttp(req, res)
|
|
408
582
|
})
|
|
@@ -448,6 +622,8 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
448
622
|
resolve({
|
|
449
623
|
port: listenedPort,
|
|
450
624
|
addresses: lanAddresses(),
|
|
625
|
+
/** 当前有效的登录会话数(面板显示「已登录设备 N 台」)。 */
|
|
626
|
+
sessionCount() { return sessions.size },
|
|
451
627
|
revokeAllSessions() {
|
|
452
628
|
sessions.clear()
|
|
453
629
|
for (const [socket, value] of upgradedSockets) {
|