@nszzj/dsh-webview-remote-server 0.0.0-stage → 1.0.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 +121 -2
- package/client/client.js +395 -0
- package/cordis.patch.yml +22 -0
- package/lib/gateway.js +1972 -0
- package/lib/index.js +360 -0
- package/package.json +41 -4
package/lib/gateway.js
ADDED
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// dsh-remote-gateway / lib/gateway.js
|
|
3
|
+
//
|
|
4
|
+
// 纯 Node 反向代理内核 —— 不 import 任何 DSH 包。
|
|
5
|
+
// 这一点是刻意的:它因此可以脱离 DSH 单独起、单独实测(对着正在运行的
|
|
6
|
+
// 127.0.0.1:19387 打),不需要重启 DSH 宿主。
|
|
7
|
+
//
|
|
8
|
+
// ── 它解决什么 ────────────────────────────────────────────────
|
|
9
|
+
// DSH 的 Web 服务只绑 127.0.0.1,且 /api 有一道 Host/Origin 围栏,
|
|
10
|
+
// 非 loopback 客户端(手机)默认进不来。本模块在别处另开一个监听口,
|
|
11
|
+
// 先做自己的密码鉴权,然后把请求转发给 127.0.0.1:<targetPort>。
|
|
12
|
+
//
|
|
13
|
+
// ── 关于上游的三个事实 ────────────────────────────────────────
|
|
14
|
+
// 【已实测】围栏不过 → 403;过围栏但没认证 → 401。
|
|
15
|
+
// (本机实测:/api/ 带 Host: evil.example → 403;
|
|
16
|
+
// 正常 Host 无 cookie → 401)
|
|
17
|
+
// 【已读码】围栏规则:/api 的 Host 必须是 loopback 或 trustedHosts 命中;
|
|
18
|
+
// 请求带 Origin 时,Origin 的 host:port 必须等于 Host;
|
|
19
|
+
// 带 sec-fetch-site: cross-site 直接拒。
|
|
20
|
+
// 【已读码】DSH 会话 = 进程启动 token → 签名 cookie。token 只在「根路径交换」
|
|
21
|
+
// 那一步被接受(查询参数名就是 token),每次进程重新随机;
|
|
22
|
+
// cookie 把 hostname+port 写进 cookie 名和签名载荷。
|
|
23
|
+
//
|
|
24
|
+
// ── 于是本模块的两个核心策略 ──────────────────────────────────
|
|
25
|
+
// 1) 对上游【一律】改写 Host 为 127.0.0.1:<targetPort>,同时把 Origin /
|
|
26
|
+
// referer 归一到同一个 authority。绝不能有时原样转发 —— 否则 DSH 每次
|
|
27
|
+
// 算出的 authority 不同、cookie 名对不上,会在 401 之间来回跳。
|
|
28
|
+
// 2) 在根请求上替客户端注入当前进程的启动 token(由调用方通过 resolveToken
|
|
29
|
+
// 提供)。这样手机端永远不需要看见 token,也不需要「reopen the URL
|
|
30
|
+
// printed by dsh web」。
|
|
31
|
+
//
|
|
32
|
+
// ── 安全边界(必须写清楚,这是策略 1 的代价)──────────────────
|
|
33
|
+
// 策略 1 等于把 DSH 那道 Origin 围栏,对「已通过本模块鉴权的流量」主动失效。
|
|
34
|
+
// 所以安全边界完全落在本模块的密码上 —— 密码泄露 = 对方能在本机执行任意命令。
|
|
35
|
+
// 这就是为什么 createGateway() 拒绝空密码:失败关闭,而不是开一个没锁的门。
|
|
36
|
+
// ============================================================
|
|
37
|
+
|
|
38
|
+
import http from 'node:http'
|
|
39
|
+
import { createHash, createHmac, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
40
|
+
|
|
41
|
+
const LOOPBACK = '127.0.0.1'
|
|
42
|
+
const PREFIX = '/__gateway'
|
|
43
|
+
const DAY_MS = 86400000
|
|
44
|
+
const SCRYPT_SALT = 'dsh-remote-gateway/v1'
|
|
45
|
+
|
|
46
|
+
/** 本网关自己的会话 cookie 名(与 DSH 的 dsh-auth-* 区分开)。 */
|
|
47
|
+
export const GATEWAY_COOKIE = 'dsh-gw'
|
|
48
|
+
|
|
49
|
+
/** DSH 会话 cookie 的前缀(完整名字里还带 authority 的哈希)。 */
|
|
50
|
+
const DSH_COOKIE_PREFIX = 'dsh-auth-'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 从客户端带来的 DSH 会话 cookie 里读出 `expiresAt` —— **只看不验签名**。
|
|
54
|
+
*
|
|
55
|
+
* 为什么不需要密钥:cookie 值是 `v1.<base64url(JSON)>.<hmac>`,第二段解出来就是
|
|
56
|
+
* `{ version, authority, issuedAt, expiresAt }`。我们只用它判断"这张 cookie 是不是
|
|
57
|
+
* 已经过期",**不验证真伪** —— 真伪由 DSH 自己验。我们判断错了最多多一次重定向,
|
|
58
|
+
* 没有安全问题(authority 由网关改写后固定,攻击者也无法借此换到别的 authority)。
|
|
59
|
+
*
|
|
60
|
+
* @returns 过期时刻(毫秒);解不出来返回 undefined。
|
|
61
|
+
*/
|
|
62
|
+
function dshSessionExpiresAt(cookieHeader) {
|
|
63
|
+
if (typeof cookieHeader !== 'string') return undefined
|
|
64
|
+
for (const part of cookieHeader.split(';')) {
|
|
65
|
+
const at = part.indexOf('=')
|
|
66
|
+
if (at === -1) continue
|
|
67
|
+
if (!part.slice(0, at).trim().startsWith(DSH_COOKIE_PREFIX)) continue
|
|
68
|
+
const segments = part.slice(at + 1).trim().split('.')
|
|
69
|
+
if (segments.length !== 3 || segments[0] !== 'v1') continue
|
|
70
|
+
try {
|
|
71
|
+
const body = segments[1].replaceAll('-', '+').replaceAll('_', '/')
|
|
72
|
+
const padding = '='.repeat((4 - (body.length % 4)) % 4)
|
|
73
|
+
const payload = JSON.parse(Buffer.from(body + padding, 'base64').toString('utf8'))
|
|
74
|
+
if (Number.isFinite(payload?.expiresAt)) return payload.expiresAt
|
|
75
|
+
} catch {
|
|
76
|
+
/* 解不出来 → 当作"没有可用 cookie" */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 客户端是否持有一张**看起来还没过期**的 DSH 会话 cookie。 */
|
|
83
|
+
function hasLiveDshSession(cookieHeader) {
|
|
84
|
+
const expiresAt = dshSessionExpiresAt(cookieHeader)
|
|
85
|
+
return expiresAt !== undefined && expiresAt > Date.now()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** hop-by-hop 头:转发时必须丢掉,否则 Node 的成帧会被搞坏。 */
|
|
89
|
+
const HOP_BY_HOP = new Set([
|
|
90
|
+
'connection',
|
|
91
|
+
'keep-alive',
|
|
92
|
+
'proxy-authenticate',
|
|
93
|
+
'proxy-authorization',
|
|
94
|
+
'te',
|
|
95
|
+
'trailer',
|
|
96
|
+
'transfer-encoding',
|
|
97
|
+
'upgrade',
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
const sha256 = (value) => createHash('sha256').update(String(value), 'utf8').digest()
|
|
101
|
+
|
|
102
|
+
const base64url = (buf) =>
|
|
103
|
+
buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 定长时间比较两个秘密。
|
|
107
|
+
* 先各自 sha256 再比,这样两个输入长度不同也不会让 timingSafeEqual 抛错,
|
|
108
|
+
* 同时比较耗时与输入长度无关。
|
|
109
|
+
*/
|
|
110
|
+
function sameSecret(a, b) {
|
|
111
|
+
return timingSafeEqual(sha256(a), sha256(b))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 解析 Cookie 头成普通对象。重复名取后者(与实际浏览器行为一致)。 */
|
|
115
|
+
function parseCookies(header) {
|
|
116
|
+
const out = {}
|
|
117
|
+
if (typeof header !== 'string') return out
|
|
118
|
+
for (const part of header.split(';')) {
|
|
119
|
+
const at = part.indexOf('=')
|
|
120
|
+
if (at === -1) continue
|
|
121
|
+
const name = part.slice(0, at).trim()
|
|
122
|
+
if (name !== '') out[name] = part.slice(at + 1).trim()
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 会话 cookie 的签发/校验器。
|
|
129
|
+
* 无状态:cookie 值就是 `<过期毫秒>.<HMAC>`,密钥由密码派生。
|
|
130
|
+
* 好处是改密码即等于吊销所有设备,不需要任何存储。
|
|
131
|
+
*/
|
|
132
|
+
function makeSigner(password, sessionDays) {
|
|
133
|
+
const key = scryptSync(password, SCRYPT_SALT, 32)
|
|
134
|
+
const mac = (payload) => base64url(createHmac('sha256', key).update(payload).digest())
|
|
135
|
+
return {
|
|
136
|
+
issue() {
|
|
137
|
+
const payload = String(Date.now() + sessionDays * DAY_MS)
|
|
138
|
+
return `${payload}.${mac(payload)}`
|
|
139
|
+
},
|
|
140
|
+
verify(value) {
|
|
141
|
+
if (typeof value !== 'string') return false
|
|
142
|
+
const at = value.indexOf('.')
|
|
143
|
+
if (at === -1) return false
|
|
144
|
+
const payload = value.slice(0, at)
|
|
145
|
+
const got = Buffer.from(value.slice(at + 1), 'utf8')
|
|
146
|
+
const want = Buffer.from(mac(payload), 'utf8')
|
|
147
|
+
if (got.length !== want.length || !timingSafeEqual(got, want)) return false
|
|
148
|
+
const expiresAt = Number(payload)
|
|
149
|
+
return Number.isSafeInteger(expiresAt) && expiresAt > Date.now()
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 【用户要求】这里**没有网页登录页**。
|
|
155
|
+
//
|
|
156
|
+
// 这个端口只负责 App(机器对机器)通信,**不负责给浏览器显示任何东西** ——
|
|
157
|
+
// 无凭据的直接 GET 就是"没有东西":最小的 401,正文不携带页面。
|
|
158
|
+
// 登录界面只存在于客户端 App 里(原生 Flutter 表单)。
|
|
159
|
+
//
|
|
160
|
+
// 为什么这么做:
|
|
161
|
+
// 1) 登录页要是也成了网页,那用 App 就没意义了 —— 还不如直接用浏览器;
|
|
162
|
+
// 2) 网页登录页会白送一串麻烦:开放重定向、表单 CSRF、密码走 body、
|
|
163
|
+
// 还要维护一套只在浏览器里才走得到的 HTML 和样式。
|
|
164
|
+
//
|
|
165
|
+
// 于是原先的 loginPage / safeNext / readBody 三个辅助函数**一并删除** ——
|
|
166
|
+
// 它们只服务于那个已经不存在的东西。
|
|
167
|
+
|
|
168
|
+
/** 构造转发给上游的请求头:改写 authority、丢掉 hop-by-hop、摘掉我们自己的 cookie。 */
|
|
169
|
+
function upstreamHeaders(incoming, targetPort) {
|
|
170
|
+
const headers = {}
|
|
171
|
+
for (const [name, value] of Object.entries(incoming)) {
|
|
172
|
+
if (value === undefined) continue
|
|
173
|
+
const lower = name.toLowerCase()
|
|
174
|
+
if (HOP_BY_HOP.has(lower)) continue
|
|
175
|
+
headers[lower] = value
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── 第 1 步:authority 归一。这是策略 1 的核心,不能省。
|
|
179
|
+
const authority = `${LOOPBACK}:${targetPort}`
|
|
180
|
+
const origin = `http://${authority}`
|
|
181
|
+
headers.host = authority
|
|
182
|
+
// DSH 的围栏要求 Origin 的 host:port 等于 Host。浏览器发来的 Origin 是
|
|
183
|
+
// 网关自己的地址(如 http://192.168.1.110:8080),不改就会 403。
|
|
184
|
+
if (headers.origin !== undefined) headers.origin = origin
|
|
185
|
+
if (headers.referer !== undefined) {
|
|
186
|
+
try {
|
|
187
|
+
const ref = new URL(String(headers.referer))
|
|
188
|
+
headers.referer = origin + ref.pathname + ref.search
|
|
189
|
+
} catch {
|
|
190
|
+
delete headers.referer
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 第 2 步:摘掉网关自己的 cookie。上游不认识它,没必要让它看见。
|
|
195
|
+
if (headers.cookie !== undefined) {
|
|
196
|
+
const kept = String(headers.cookie)
|
|
197
|
+
.split(';')
|
|
198
|
+
.map((part) => part.trim())
|
|
199
|
+
.filter((part) => part !== '' && !part.startsWith(`${GATEWAY_COOKIE}=`))
|
|
200
|
+
if (kept.length === 0) delete headers.cookie
|
|
201
|
+
else headers.cookie = kept.join('; ')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return headers
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 上游响应头:同样丢掉 hop-by-hop,并把指向 loopback 的绝对 Location 改回相对。 */
|
|
208
|
+
function downstreamHeaders(upstream, targetPort) {
|
|
209
|
+
const headers = {}
|
|
210
|
+
for (const [name, value] of Object.entries(upstream)) {
|
|
211
|
+
if (value === undefined) continue
|
|
212
|
+
if (HOP_BY_HOP.has(name.toLowerCase())) continue
|
|
213
|
+
headers[name] = value
|
|
214
|
+
}
|
|
215
|
+
const location = headers.location
|
|
216
|
+
if (typeof location === 'string') {
|
|
217
|
+
for (const base of [`http://${LOOPBACK}:${targetPort}`, `https://${LOOPBACK}:${targetPort}`]) {
|
|
218
|
+
if (location.startsWith(base)) {
|
|
219
|
+
const rest = location.slice(base.length)
|
|
220
|
+
headers.location = rest === '' ? '/' : rest
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return headers
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 注入到 index.html 里的兼容垫片。
|
|
230
|
+
*
|
|
231
|
+
* ── 为什么需要 ────────────────────────────────────────────────
|
|
232
|
+
* Android 的 **WebView 是一个可独立更新的系统组件**,和同一台机器上的 Chrome
|
|
233
|
+
* 是两码事,版本可以差很远。实测在一台 Android 12 平板上遇到:
|
|
234
|
+
*
|
|
235
|
+
* Promise.withResolvers → undefined (Chrome 119+ 才有)
|
|
236
|
+
* AbortSignal.any → undefined (Chrome 116+ 才有)
|
|
237
|
+
*
|
|
238
|
+
* 于是 DSH 的实时通道一建立就抛异常 → 客户端无限重连(console 里刷
|
|
239
|
+
* `[connection] connection lost, retry #N`),而静态 HTML/CSS 照常渲染 ——
|
|
240
|
+
* 因为那些不碰这两个 API。**同样的 origin 在手机 Chrome 里却完全正常。**
|
|
241
|
+
*
|
|
242
|
+
* ── 为什么注入在这里能生效 ────────────────────────────────────
|
|
243
|
+
* 插在 `<head>` 之后的第一件事。它是**经典脚本**,解析到就立即执行;而页面自己
|
|
244
|
+
* 那些 `<script type="module">` 默认是 defer 的,必然排在它后面。所以垫片一定先跑。
|
|
245
|
+
*
|
|
246
|
+
* 只补**确实缺失**的,并把补了哪些打到 console —— 这样下次一眼就能看出某台设备
|
|
247
|
+
* 还缺什么,不用一个个猜。
|
|
248
|
+
*/
|
|
249
|
+
const CLIENT_SHIM = `<script>
|
|
250
|
+
// ── 这个垫片的自我诊断原则 ────────────────────────────────────────
|
|
251
|
+
// 它跑在设备上的 WebView 里,出错时外面完全看不见(没连 DevTools 就是黑箱)。
|
|
252
|
+
// 所以:
|
|
253
|
+
// 1) 先把状态对象挂到 globalThis 上,再干别的 —— 这样即使中间抛异常,
|
|
254
|
+
// 宿主 App 的调试面板里也能看到 installed 和 error 两个字段。
|
|
255
|
+
// 2) 整段包在 try/catch 里,异常写进 report.error,而不是无声消失。
|
|
256
|
+
// 3) 里面不许出现反斜杠转义,也不许出现反引号 —— 这段代码写在 gateway.js
|
|
257
|
+
// 的模板字符串里:反斜杠会被吃掉(曾使整段脚本语法错误、垫片全失效),
|
|
258
|
+
// 反引号会直接把模板字符串提前结束(曾使 gateway.js 自己都加载不了)。
|
|
259
|
+
// 这两个坑都踩过,见 tools/shim-check.mjs。
|
|
260
|
+
(function () {
|
|
261
|
+
var report = globalThis.__DSH_GATEWAY_SHIM__ = {
|
|
262
|
+
ua: navigator.userAgent,
|
|
263
|
+
kernel: null,
|
|
264
|
+
minKernel: 114,
|
|
265
|
+
installed: [],
|
|
266
|
+
error: null,
|
|
267
|
+
enterShim: false,
|
|
268
|
+
keyboardShim: false
|
|
269
|
+
};
|
|
270
|
+
var installed = report.installed;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
|
|
274
|
+
// Promise.withResolvers —— ES2024,Chrome 119+
|
|
275
|
+
if (typeof Promise.withResolvers !== 'function') {
|
|
276
|
+
Promise.withResolvers = function () {
|
|
277
|
+
var resolve, reject;
|
|
278
|
+
var promise = new Promise(function (res, rej) { resolve = res; reject = rej; });
|
|
279
|
+
return { promise: promise, resolve: resolve, reject: reject };
|
|
280
|
+
};
|
|
281
|
+
installed.push('Promise.withResolvers');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// AbortSignal.any —— Chrome 116+
|
|
285
|
+
if (typeof AbortSignal.any !== 'function') {
|
|
286
|
+
AbortSignal.any = function (signals) {
|
|
287
|
+
var controller = new AbortController();
|
|
288
|
+
function onAbort(event) {
|
|
289
|
+
for (var i = 0; i < signals.length; i++) {
|
|
290
|
+
signals[i].removeEventListener('abort', onAbort);
|
|
291
|
+
}
|
|
292
|
+
controller.abort(event.target.reason);
|
|
293
|
+
}
|
|
294
|
+
for (var i = 0; i < signals.length; i++) {
|
|
295
|
+
var signal = signals[i];
|
|
296
|
+
if (signal.aborted) { controller.abort(signal.reason); return controller.signal; }
|
|
297
|
+
signal.addEventListener('abort', onAbort);
|
|
298
|
+
}
|
|
299
|
+
return controller.signal;
|
|
300
|
+
};
|
|
301
|
+
installed.push('AbortSignal.any');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// AbortSignal.timeout —— Chrome 103+
|
|
305
|
+
if (typeof AbortSignal.timeout !== 'function') {
|
|
306
|
+
AbortSignal.timeout = function (ms) {
|
|
307
|
+
var controller = new AbortController();
|
|
308
|
+
setTimeout(function () {
|
|
309
|
+
controller.abort(new DOMException('The operation timed out.', 'TimeoutError'));
|
|
310
|
+
}, ms);
|
|
311
|
+
return controller.signal;
|
|
312
|
+
};
|
|
313
|
+
installed.push('AbortSignal.timeout');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Object.hasOwn —— Chrome 93+
|
|
317
|
+
if (typeof Object.hasOwn !== 'function') {
|
|
318
|
+
Object.hasOwn = function (object, key) {
|
|
319
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
320
|
+
};
|
|
321
|
+
installed.push('Object.hasOwn');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// URL.canParse —— Chrome 120+
|
|
325
|
+
if (typeof URL.canParse !== 'function') {
|
|
326
|
+
URL.canParse = function (input, base) {
|
|
327
|
+
try { new URL(input, base); return true; } catch (e) { return false; }
|
|
328
|
+
};
|
|
329
|
+
installed.push('URL.canParse');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// structuredClone —— Chrome 98+
|
|
333
|
+
if (typeof structuredClone !== 'function') {
|
|
334
|
+
globalThis.structuredClone = function (value) { return JSON.parse(JSON.stringify(value)); };
|
|
335
|
+
installed.push('structuredClone(JSON 近似实现)');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function defineArrayMethod(name, fn) {
|
|
339
|
+
if (typeof Array.prototype[name] === 'function') return;
|
|
340
|
+
Object.defineProperty(Array.prototype, name, { value: fn, writable: true, configurable: true });
|
|
341
|
+
installed.push('Array.prototype.' + name);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Array.prototype.at —— Chrome 92+
|
|
345
|
+
defineArrayMethod('at', function (n) {
|
|
346
|
+
n = Math.trunc(n) || 0;
|
|
347
|
+
if (n < 0) n += this.length;
|
|
348
|
+
return (n < 0 || n >= this.length) ? undefined : this[n];
|
|
349
|
+
});
|
|
350
|
+
// findLast / findLastIndex —— Chrome 97+
|
|
351
|
+
defineArrayMethod('findLast', function (predicate, thisArg) {
|
|
352
|
+
for (var i = this.length - 1; i >= 0; i--) {
|
|
353
|
+
if (predicate.call(thisArg, this[i], i, this)) return this[i];
|
|
354
|
+
}
|
|
355
|
+
return undefined;
|
|
356
|
+
});
|
|
357
|
+
defineArrayMethod('findLastIndex', function (predicate, thisArg) {
|
|
358
|
+
for (var i = this.length - 1; i >= 0; i--) {
|
|
359
|
+
if (predicate.call(thisArg, this[i], i, this)) return i;
|
|
360
|
+
}
|
|
361
|
+
return -1;
|
|
362
|
+
});
|
|
363
|
+
// 非破坏性数组方法 —— Chrome 110+
|
|
364
|
+
defineArrayMethod('toSorted', function (compare) {
|
|
365
|
+
return Array.prototype.slice.call(this).sort(compare);
|
|
366
|
+
});
|
|
367
|
+
defineArrayMethod('toReversed', function () {
|
|
368
|
+
return Array.prototype.slice.call(this).reverse();
|
|
369
|
+
});
|
|
370
|
+
defineArrayMethod('toSpliced', function (start, deleteCount) {
|
|
371
|
+
var copy = Array.prototype.slice.call(this);
|
|
372
|
+
var rest = Array.prototype.slice.call(arguments, 2);
|
|
373
|
+
copy.splice.apply(copy, [start, deleteCount].concat(rest));
|
|
374
|
+
return copy;
|
|
375
|
+
});
|
|
376
|
+
defineArrayMethod('with', function (index, value) {
|
|
377
|
+
var copy = Array.prototype.slice.call(this);
|
|
378
|
+
var i = index < 0 ? this.length + index : index;
|
|
379
|
+
if (i < 0 || i >= copy.length) throw new RangeError('Invalid index');
|
|
380
|
+
copy[i] = value;
|
|
381
|
+
return copy;
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Object.groupBy / Map.groupBy —— Chrome 117+
|
|
385
|
+
if (typeof Object.groupBy !== 'function') {
|
|
386
|
+
Object.groupBy = function (items, keyFn) {
|
|
387
|
+
var out = Object.create(null);
|
|
388
|
+
var i = 0;
|
|
389
|
+
for (var item of items) {
|
|
390
|
+
var key = keyFn(item, i++);
|
|
391
|
+
(out[key] || (out[key] = [])).push(item);
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
};
|
|
395
|
+
installed.push('Object.groupBy');
|
|
396
|
+
}
|
|
397
|
+
if (typeof Map.groupBy !== 'function') {
|
|
398
|
+
Map.groupBy = function (items, keyFn) {
|
|
399
|
+
var out = new Map();
|
|
400
|
+
var i = 0;
|
|
401
|
+
for (var item of items) {
|
|
402
|
+
var key = keyFn(item, i++);
|
|
403
|
+
var bucket = out.get(key);
|
|
404
|
+
if (bucket === undefined) { bucket = []; out.set(key, bucket); }
|
|
405
|
+
bucket.push(item);
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
};
|
|
409
|
+
installed.push('Map.groupBy');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── 曾经补过 crypto.randomUUID —— 已删除(2026-10-14)────────────
|
|
413
|
+
//
|
|
414
|
+
// 背景:我们的源是 http://<局域网IP>:<端口>,不是可信源 → isSecureContext=false
|
|
415
|
+
// → crypto.randomUUID 这类 [SecureContext] 门控的 API 缺失。当初怀疑 DSH 客户端
|
|
416
|
+
// 要用它生成 clientId、缺了就建不起 $events,于是补了一个等价实现。
|
|
417
|
+
//
|
|
418
|
+
// 后来查清了:**DSH 根本不用这个 API。**
|
|
419
|
+
// packages/util/crypto/src/index.ts 的 randomUUID() 是用
|
|
420
|
+
// crypto.getRandomValues 自己造的 —— 而 getRandomValues **不受**安全上下文限制。
|
|
421
|
+
// 那个模块的注释还明确写着"这一份实现取代了各处调用者自己的补丁",
|
|
422
|
+
// 并有 lint 规则把调 crypto.randomUUID 的人指过去。
|
|
423
|
+
// 实测也印证:两台设备上 crypto.getRandomValues 都在。
|
|
424
|
+
//
|
|
425
|
+
// 而且**真原因早就被证明不是它** —— 那次补上之后症状照旧(见 开发思路.md §17)。
|
|
426
|
+
// 本次的真原因与安全上下文无关,见仓库根目录「鸿蒙ArkWeb-onLine错报-调查报告.md」。
|
|
427
|
+
//
|
|
428
|
+
// ⚠️ 上面那几段**版本补丁不要动**(Promise.withResolvers / AbortSignal.any /
|
|
429
|
+
// URL.canParse / Object.groupBy / Map.groupBy)—— 那是安卓 Chrome/114 缺的,
|
|
430
|
+
// 删了安卓会立刻坏(开发思路.md §17 记的那个"一直重连中"就是它)。
|
|
431
|
+
|
|
432
|
+
// ── 内核版本下限检查 ─────────────────────────────────────────
|
|
433
|
+
// 本项目的支持下限:Chrome / WebView 【114】(见 开发思路.md §12)。
|
|
434
|
+
// 低于它不保证可用 —— 但至少要说清楚,而不是让人对着空页面猜半天。
|
|
435
|
+
//
|
|
436
|
+
// 内核版本识别:故意不用正则。这段代码写在 gateway.js 的模板字符串里,
|
|
437
|
+
// 而"反斜杠加字母"这种非法转义会被吃掉反斜杠,使正则变成语法错误 ——
|
|
438
|
+
// 那会让整段 script 作废、垫片全部失效,而症状又和"内核太老"一模一样,
|
|
439
|
+
// 极容易误判。纯字符串扫描没有这个风险。
|
|
440
|
+
var MIN_KERNEL = report.minKernel;
|
|
441
|
+
var ua = navigator.userAgent;
|
|
442
|
+
var kernel = null;
|
|
443
|
+
var marker = ua.indexOf('Chrome/');
|
|
444
|
+
if (marker >= 0) {
|
|
445
|
+
var digits = '';
|
|
446
|
+
for (var index = marker + 7; index < ua.length; index++) {
|
|
447
|
+
var ch = ua.charAt(index);
|
|
448
|
+
if (ch < '0' || ch > '9') break;
|
|
449
|
+
digits += ch;
|
|
450
|
+
}
|
|
451
|
+
if (digits !== '') kernel = parseInt(digits, 10);
|
|
452
|
+
}
|
|
453
|
+
report.kernel = kernel;
|
|
454
|
+
|
|
455
|
+
console.log('[dsh-gateway-shim] UA: ' + navigator.userAgent);
|
|
456
|
+
console.log('[dsh-gateway-shim] 本次补上的 API: ' + (installed.length > 0 ? installed.join(', ') : '(无)'));
|
|
457
|
+
|
|
458
|
+
if (kernel === null) {
|
|
459
|
+
console.warn('[dsh-gateway-shim] 认不出内核版本;本项目支持下限是 Chrome/' + MIN_KERNEL + '。');
|
|
460
|
+
} else if (kernel < MIN_KERNEL) {
|
|
461
|
+
console.warn(
|
|
462
|
+
'[dsh-gateway-shim] 内核 Chrome/' + kernel + ' 低于支持下限 Chrome/' + MIN_KERNEL +
|
|
463
|
+
':缺失的 API 会尽量垫,但不保证实时通道可用。'
|
|
464
|
+
);
|
|
465
|
+
} else {
|
|
466
|
+
console.log('[dsh-gateway-shim] 内核 Chrome/' + kernel + ',满足支持下限 Chrome/' + MIN_KERNEL + '。');
|
|
467
|
+
}
|
|
468
|
+
} catch (error) {
|
|
469
|
+
// 任何一步失败都写进 report.error,宿主 App 的调试面板会显示出来。
|
|
470
|
+
// 注意:这只兜得住运行时异常。如果整段是**语法错误**,块内代码一行都不会跑,
|
|
471
|
+
// 那种情况只能靠外部的 parse 检查(tools/shim-check.mjs)。
|
|
472
|
+
report.error = String((error && error.message) || error);
|
|
473
|
+
console.error('[dsh-gateway-shim] 垫片执行失败:', error);
|
|
474
|
+
}
|
|
475
|
+
})();
|
|
476
|
+
</script>`
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 移动端的「回车改成换行」注入。
|
|
480
|
+
*
|
|
481
|
+
* ── 背景 ──────────────────────────────────────────────────────
|
|
482
|
+
* DSH 的桌面行为是「**回车发送**、Shift+回车换行」。而**手机 IME 没有 Shift**,
|
|
483
|
+
* 于是手机上根本打不出换行 —— 一按回车就发出去。
|
|
484
|
+
* (DSH 没有对应的配置项:已查过 ui-conversation / ui-chat 的实时 schema,
|
|
485
|
+
* 能配的只有 busyEnter、transcriptView、performanceUsage、linkOpening。)
|
|
486
|
+
*
|
|
487
|
+
* ── 做法 ──────────────────────────────────────────────────────
|
|
488
|
+
* 把「无修饰键的回车」改写成换行;发送交给界面上的发送按钮。
|
|
489
|
+
*
|
|
490
|
+
* 两个关键点:
|
|
491
|
+
* 1) **捕获阶段** —— React 17+ 把事件监听挂在根容器上,捕获阶段先于它执行,
|
|
492
|
+
* 这里的 stopPropagation 能让应用完全看不到这个回车。
|
|
493
|
+
* 2) **必须用 execCommand** —— 它会把换行真正写进输入框并触发 input 事件,
|
|
494
|
+
* React 的受控组件才会读到新值。直接改 DOM 的 value 会被下一次 render 覆盖。
|
|
495
|
+
*
|
|
496
|
+
* 只在移动端启用(桌面保持"回车发送"原样),且只处理 textarea / contenteditable
|
|
497
|
+
* —— 单行 input 里换行没意义,拦了只会把它弄坏。
|
|
498
|
+
*/
|
|
499
|
+
const MOBILE_ENTER_SHIM = `<script>
|
|
500
|
+
(function () {
|
|
501
|
+
var TAG = '[dsh-gateway-shim:enter]';
|
|
502
|
+
if (!/Android|iPhone|iPad|iPod|HarmonyOS|Mobile/i.test(navigator.userAgent)) return;
|
|
503
|
+
|
|
504
|
+
function isMultilineEditable(el) {
|
|
505
|
+
if (!el) return false;
|
|
506
|
+
if (el.tagName === 'TEXTAREA') return true;
|
|
507
|
+
return el.isContentEditable === true;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 只用来判断"那一下到底有没有产生效果",**不参与插入**。
|
|
511
|
+
function snapshot(el) {
|
|
512
|
+
if (!el) return '';
|
|
513
|
+
return el.tagName === 'TEXTAREA' ? (el.value || '') : (el.textContent || '');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function insertNewline(el) {
|
|
517
|
+
// 关键:**我们自己绝不改 DOM**。
|
|
518
|
+
//
|
|
519
|
+
// 早先的实现用 document.execCommand('insertLineBreak' / 'insertText') 直接改 DOM。
|
|
520
|
+
// 这么做绕过了 DSH 自己的状态管理:它下一次重绘时按自己的状态把内容写回去,
|
|
521
|
+
// 结果是**用户刚打的字整段消失**(手机上实测到的就是这个)。
|
|
522
|
+
//
|
|
523
|
+
// 现在改成:合成一个 shiftKey=true 的 Enter 交给页面,让 **DSH 自己的多行
|
|
524
|
+
// 代码路径**去插入。它自己插的内容,它自己的状态自然是一致的。
|
|
525
|
+
//
|
|
526
|
+
// 失败模式也更安全:如果页面根本不看 keydown(或不用 Shift+Enter 表示换行),
|
|
527
|
+
// 最坏结果只是"回车没反应",**不会吃掉用户打的内容**。
|
|
528
|
+
var before = snapshot(el);
|
|
529
|
+
|
|
530
|
+
var fake = new KeyboardEvent('keydown', {
|
|
531
|
+
key: 'Enter',
|
|
532
|
+
code: 'Enter',
|
|
533
|
+
shiftKey: true,
|
|
534
|
+
bubbles: true,
|
|
535
|
+
cancelable: true,
|
|
536
|
+
composed: true
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// KeyboardEvent 的构造器**不接受** keyCode / which(它们是遗留的只读属性,
|
|
540
|
+
// 传进去会被忽略、读出来是 0)。有些老式处理器就认 keyCode,所以在实例上
|
|
541
|
+
// 单独把它们定义出来 —— 定义失败也无所谓,event.key 已经是对的。
|
|
542
|
+
try {
|
|
543
|
+
Object.defineProperty(fake, 'keyCode', { get: function () { return 13; } });
|
|
544
|
+
Object.defineProperty(fake, 'which', { get: function () { return 13; } });
|
|
545
|
+
} catch (e) { /* 忽略 */ }
|
|
546
|
+
|
|
547
|
+
el.dispatchEvent(fake);
|
|
548
|
+
|
|
549
|
+
return snapshot(el) !== before;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
var announced = false;
|
|
553
|
+
|
|
554
|
+
document.addEventListener('keydown', function (event) {
|
|
555
|
+
if (event.key !== 'Enter') return;
|
|
556
|
+
// 带修饰键的 Enter 一律放行(我们合成的那个事件也走这条,所以不会被自己拦第二次)
|
|
557
|
+
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
|
558
|
+
if (!isMultilineEditable(event.target)) return;
|
|
559
|
+
// 输入法组字中不拦:那一下回车是"确认候选词"
|
|
560
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
561
|
+
|
|
562
|
+
event.preventDefault();
|
|
563
|
+
event.stopPropagation();
|
|
564
|
+
|
|
565
|
+
var effective = false;
|
|
566
|
+
try {
|
|
567
|
+
effective = insertNewline(event.target);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
effective = false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (!announced) {
|
|
573
|
+
announced = true;
|
|
574
|
+
console.log(
|
|
575
|
+
TAG + ' 回车已改写为换行,发送请点发送按钮(本次' +
|
|
576
|
+
(effective ? '生效' : '没生效 —— 页面可能不用 Shift+Enter 表示换行') + ')'
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}, true);
|
|
580
|
+
|
|
581
|
+
globalThis.__DSH_GATEWAY_SHIM__ = Object.assign(globalThis.__DSH_GATEWAY_SHIM__ || {}, {
|
|
582
|
+
enterShim: true
|
|
583
|
+
});
|
|
584
|
+
})();
|
|
585
|
+
</script>`
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 键盘弹出时的「零重排」补丁。
|
|
589
|
+
*
|
|
590
|
+
* ── 问题 ──────────────────────────────────────────────────────
|
|
591
|
+
* Android 11+ 的键盘弹出/收起是**动画**,窗口尺寸在约 250ms 内连续变化。
|
|
592
|
+
* 配上 `adjustResize`,WebView 会被连续缩放 → 页面连续重排几十次 → 明显卡顿。
|
|
593
|
+
* (这是移动网页里很典型的一类卡顿。)
|
|
594
|
+
*
|
|
595
|
+
* ── 做法 ──────────────────────────────────────────────────────
|
|
596
|
+
* 1) manifest 里把 `windowSoftInputMode` 改成 **`adjustNothing`**
|
|
597
|
+
* —— 窗口**不再改尺寸**,于是零重排。
|
|
598
|
+
* 2) 这里用 `visualViewport` 量出被键盘遮住的高度,把 `body` 整体**位移**上去。
|
|
599
|
+
* `transform` 只走合成器、**不触发布局**,所以同样不产生重排。
|
|
600
|
+
*
|
|
601
|
+
* DSH 的输入框在底部,整体上移后正好落在键盘上方可见。
|
|
602
|
+
*
|
|
603
|
+
* ⚠️ 副作用:`body` 一旦被 transform,其内部的 `position: fixed` 元素会改为
|
|
604
|
+
* 相对 body 定位。若发现某些面板错位,就把这里改成"只位移输入框的容器"。
|
|
605
|
+
*/
|
|
606
|
+
// ⚠️ 【已停用,2026-10-01】不再注入。代码留着只为对照,别加回 injection。
|
|
607
|
+
//
|
|
608
|
+
// 为什么停用:它靠 transform 把内容顶上去"模拟"窗口缩小,但
|
|
609
|
+
// · 直接 transform body → 实测在鸿蒙上**整页被顶上去**,顶部栏推出屏幕;
|
|
610
|
+
// · 改成只抬输入框那一条 → 底部消息仍被键盘挡住、滚不到,
|
|
611
|
+
// 因为它根本没改变可用的布局高度。
|
|
612
|
+
// 正解是**让窗口照常缩小**(Android 的 adjustResize / Flutter 的 viewInsets),
|
|
613
|
+
// 再用 Dart 侧的 _DebouncedSize 把平台视图的 resize 合并成一次 ——
|
|
614
|
+
// 布局正确 + 只重排一次。这个补丁是绕路。
|
|
615
|
+
const KEYBOARD_INSET_SHIM = `<script>
|
|
616
|
+
(function () {
|
|
617
|
+
if (!/Android|iPhone|iPad|iPod|HarmonyOS|Mobile/i.test(navigator.userAgent)) return;
|
|
618
|
+
var vv = window.visualViewport;
|
|
619
|
+
if (!vv) return;
|
|
620
|
+
|
|
621
|
+
var applied = -1;
|
|
622
|
+
var moved = null;
|
|
623
|
+
|
|
624
|
+
// 抬谁?—— 只抬输入框那一条,不要抬整个页面。
|
|
625
|
+
//
|
|
626
|
+
// 实测教训(鸿蒙,2026-10-01):直接 transform body 会把整页顶上去,
|
|
627
|
+
// 顶部栏被推出屏幕,体验很怪。
|
|
628
|
+
// 现在从焦点元素往上找"底部那一条":
|
|
629
|
+
// 横向够宽(≥70% 视口)× 不太高(≤60% 视口高)× 贴着底部(或 fixed/sticky)
|
|
630
|
+
// 取【最外层】满足条件的那个 —— 再往上一级就是整页容器,会被"不太高"排除。
|
|
631
|
+
// 找不到就退回 body(与旧行为一致,不会更糟)。
|
|
632
|
+
// 无论平移谁,transform 都只走合成器、不触发布局 —— 零重排的性质不变。
|
|
633
|
+
function pickTarget() {
|
|
634
|
+
var node = document.activeElement;
|
|
635
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return document.body;
|
|
636
|
+
|
|
637
|
+
var viewportHeight = window.innerHeight;
|
|
638
|
+
var viewportWidth = window.innerWidth;
|
|
639
|
+
var best = null;
|
|
640
|
+
var current = node;
|
|
641
|
+
|
|
642
|
+
while (current && current !== document.documentElement) {
|
|
643
|
+
var rect = current.getBoundingClientRect();
|
|
644
|
+
var position = window.getComputedStyle(current).position;
|
|
645
|
+
var spansWidth = rect.width >= viewportWidth * 0.7;
|
|
646
|
+
var shortEnough = rect.height > 0 && rect.height <= viewportHeight * 0.6;
|
|
647
|
+
var anchoredBottom = rect.bottom >= viewportHeight * 0.85;
|
|
648
|
+
var positioned = position === 'fixed' || position === 'sticky';
|
|
649
|
+
if (spansWidth && shortEnough && (anchoredBottom || positioned)) best = current;
|
|
650
|
+
current = current.parentElement;
|
|
651
|
+
}
|
|
652
|
+
return best || document.body;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function apply() {
|
|
656
|
+
// 这个垫片注入在 <head> 里,**那一刻还没有 document.body**。
|
|
657
|
+
// 直接取 body.style 会抛 TypeError,而抛出点在这段脚本的中间 ——
|
|
658
|
+
// 后面的 keyboardShim 标记也一并写不上(实测症状:调试面板里看不到
|
|
659
|
+
// keyboardShim)。所以先判空,再把 apply 挂到 DOMContentLoaded 上补一次。
|
|
660
|
+
if (!document.body) return;
|
|
661
|
+
|
|
662
|
+
// 键盘高度 ≈ 布局视口高 - 视觉视口高
|
|
663
|
+
var inset = Math.max(0, Math.round(window.innerHeight - vv.height));
|
|
664
|
+
if (inset === applied) return;
|
|
665
|
+
applied = inset;
|
|
666
|
+
|
|
667
|
+
var target = inset > 0 ? pickTarget() : (moved || document.body);
|
|
668
|
+
if (target !== moved) {
|
|
669
|
+
if (moved) moved.style.transform = '';
|
|
670
|
+
moved = target;
|
|
671
|
+
}
|
|
672
|
+
target.style.transformOrigin = 'top left';
|
|
673
|
+
target.style.transform = inset > 0 ? 'translateY(-' + inset + 'px)' : '';
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (document.readyState === 'loading') {
|
|
677
|
+
document.addEventListener('DOMContentLoaded', apply);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
vv.addEventListener('resize', apply);
|
|
681
|
+
vv.addEventListener('scroll', apply);
|
|
682
|
+
window.addEventListener('orientationchange', apply);
|
|
683
|
+
// 焦点换到别的容器(比如切另一个输入框)时,重新挑一次平移目标
|
|
684
|
+
document.addEventListener('focusin', function () { applied = -1; apply(); }, true);
|
|
685
|
+
apply();
|
|
686
|
+
|
|
687
|
+
globalThis.__DSH_GATEWAY_SHIM__ = Object.assign(globalThis.__DSH_GATEWAY_SHIM__ || {}, {
|
|
688
|
+
keyboardShim: true
|
|
689
|
+
});
|
|
690
|
+
console.log('[dsh-gateway-shim:keyboard] 键盘零重排补丁已装');
|
|
691
|
+
})();
|
|
692
|
+
</script>`
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* 把页面里的错误/告警**回传**到网关。
|
|
696
|
+
*
|
|
697
|
+
* 为什么需要(2026-10-05):鸿蒙上完全看不到 WebView 内部的报错 ——
|
|
698
|
+
* · Dart 的 debugPrint 进不了 hilog(实测)
|
|
699
|
+
* · ArkWeb 自己的 console 也不出现在 hilog 里(实测)
|
|
700
|
+
* · 手机上没有 DevTools
|
|
701
|
+
* 而"页面加载正常、UI 却报连接异常"这类问题,答案一定在页面的 JS 报错里。
|
|
702
|
+
* 所以让页面主动把错误发给网关,由网关写进日志。
|
|
703
|
+
*
|
|
704
|
+
* 只回传 error / unhandledrejection / console.error / console.warn,
|
|
705
|
+
* 且每个页面最多 300 条。原来是 40 —— 实测太低:一个过宽的探针就能吃掉 24 条,
|
|
706
|
+
* 结果真正关键的那几行(比如 open 触发时的 readyState)反而被挤掉了。
|
|
707
|
+
*/
|
|
708
|
+
const PAGE_LOG_SHIM = `<script>
|
|
709
|
+
(function () {
|
|
710
|
+
var ENDPOINT = '/__gateway/log';
|
|
711
|
+
var sent = 0;
|
|
712
|
+
function send(kind, text) {
|
|
713
|
+
if (sent > 300) return;
|
|
714
|
+
sent++;
|
|
715
|
+
// 带上**页面自己的时钟**(相对页面开始的毫秒数)。
|
|
716
|
+
// 为什么:这些回传是异步的,到达网关的先后可能和页面里真实发生的先后不一致。
|
|
717
|
+
// 有了页面时钟,即使乱序到达也能重新排回去 —— 这是排查 ArkWeb 时序问题的前提。
|
|
718
|
+
var pageMs = -1;
|
|
719
|
+
try {
|
|
720
|
+
if (typeof performance !== 'undefined' && performance.now) pageMs = Math.round(performance.now());
|
|
721
|
+
} catch (e) { /* 取不到就留 -1 */ }
|
|
722
|
+
var tag = pageMs >= 0 ? '(+' + pageMs + 'ms) ' : '';
|
|
723
|
+
var body = kind + ': ' + tag + String(text).slice(0, 2000);
|
|
724
|
+
try {
|
|
725
|
+
if (navigator.sendBeacon) navigator.sendBeacon(ENDPOINT, body);
|
|
726
|
+
else fetch(ENDPOINT, { method: 'POST', body: body, keepalive: true });
|
|
727
|
+
} catch (e) { /* 回传失败绝不能影响页面 */ }
|
|
728
|
+
|
|
729
|
+
// ── 兜底通道:把内容塞进一个普通 GET 的路径里(2026-10-05 加)──────
|
|
730
|
+
// 为什么必须要有:实测在这台设备上 sendBeacon 完全不出现于网关日志
|
|
731
|
+
//(垫片确实注入了、语法也 OK、gateway 侧的 [ws] 日志照常)——
|
|
732
|
+
// 也就是"诊断通道本身坏了",而它坏掉的样子恰好伪装成"页面很安静",
|
|
733
|
+
// 让排查彻底瞎掉。普通 GET 是最难失败的一条路:网关的 [req] 日志必然会记下它。
|
|
734
|
+
try {
|
|
735
|
+
var short = kind + ': ' + tag + String(text).slice(0, 600);
|
|
736
|
+
fetch('/__shimlog/' + encodeURIComponent(short), { keepalive: true }).catch(function () {});
|
|
737
|
+
} catch (e) { /* 同上 */ }
|
|
738
|
+
}
|
|
739
|
+
window.addEventListener('error', function (event) {
|
|
740
|
+
send('error',
|
|
741
|
+
(event.message || '') + ' @ ' + (event.filename || '?') +
|
|
742
|
+
':' + (event.lineno || 0) + ':' + (event.colno || 0));
|
|
743
|
+
}, true);
|
|
744
|
+
window.addEventListener('unhandledrejection', function (event) {
|
|
745
|
+
var reason = event.reason;
|
|
746
|
+
send('unhandledrejection',
|
|
747
|
+
(reason && reason.message) ? reason.message : String(reason));
|
|
748
|
+
}, true);
|
|
749
|
+
var originalError = console.error;
|
|
750
|
+
console.error = function () {
|
|
751
|
+
var parts = [];
|
|
752
|
+
for (var i = 0; i < arguments.length; i++) {
|
|
753
|
+
var item = arguments[i];
|
|
754
|
+
parts.push(item instanceof Error ? (item.message + ' | ' + item.stack) : String(item));
|
|
755
|
+
}
|
|
756
|
+
send('console.error', parts.join(' '));
|
|
757
|
+
return originalError.apply(console, arguments);
|
|
758
|
+
};
|
|
759
|
+
var originalWarn = console.warn;
|
|
760
|
+
console.warn = function () {
|
|
761
|
+
var parts = [];
|
|
762
|
+
for (var i = 0; i < arguments.length; i++) parts.push(String(arguments[i]));
|
|
763
|
+
send('console.warn', parts.join(' '));
|
|
764
|
+
return originalWarn.apply(console, arguments);
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// console.log / info 也拦 —— 为什么(2026-10-05):
|
|
768
|
+
// 之前只拦 error/warn,结果一条都没收到,说明失败是被静默吞掉的。
|
|
769
|
+
// 但**连接状态**这类诊断信息通常走的是普通 log。所以把 log 也接上,
|
|
770
|
+
// 每条截断到 300 字符、总量仍受 send() 的 40 条上限约束。
|
|
771
|
+
var originalLog = console.log;
|
|
772
|
+
console.log = function () {
|
|
773
|
+
try {
|
|
774
|
+
var parts = [];
|
|
775
|
+
for (var i = 0; i < arguments.length; i++) parts.push(String(arguments[i]).slice(0, 300));
|
|
776
|
+
send('console.log', parts.join(' '));
|
|
777
|
+
} catch (e) { /* 忽略 */ }
|
|
778
|
+
return originalLog.apply(console, arguments);
|
|
779
|
+
};
|
|
780
|
+
var originalInfo = console.info;
|
|
781
|
+
console.info = function () {
|
|
782
|
+
try {
|
|
783
|
+
var parts = [];
|
|
784
|
+
for (var i = 0; i < arguments.length; i++) parts.push(String(arguments[i]).slice(0, 300));
|
|
785
|
+
send('console.info', parts.join(' '));
|
|
786
|
+
} catch (e) { /* 忽略 */ }
|
|
787
|
+
return originalInfo.apply(console, arguments);
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
// console.debug 也要拦(2026-10-05 加)。
|
|
791
|
+
// 为什么:之前只拦 log/info/warn/error,页面显得"完全沉默",一条插件相关的
|
|
792
|
+
// 日志都没有 —— 而这类运行时(Cordis 插件系统)通常把激活/连接过程打在
|
|
793
|
+
// debug 级别上。不拦它,就等于把最可能说话的那条路堵住了。
|
|
794
|
+
var originalDebug = console.debug;
|
|
795
|
+
console.debug = function () {
|
|
796
|
+
try {
|
|
797
|
+
var parts = [];
|
|
798
|
+
for (var i = 0; i < arguments.length; i++) parts.push(String(arguments[i]).slice(0, 300));
|
|
799
|
+
send('console.debug', parts.join(' '));
|
|
800
|
+
} catch (e) { /* 忽略 */ }
|
|
801
|
+
return originalDebug.apply(console, arguments);
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// ── 已删除的两个探针(2026-10-05 收尾)────────────────────────────
|
|
805
|
+
//
|
|
806
|
+
// 这里原来有两个用调用栈包名做判断的探针:包 AbortController(看连接循环有没有跑)
|
|
807
|
+
// 和包 AbortSignal.any(看有没有执行到 pumpEvents);另外还包了
|
|
808
|
+
// crypto.getRandomValues 来数"疑似第几次流 open"。
|
|
809
|
+
//
|
|
810
|
+
// 删掉的原因:
|
|
811
|
+
// 1) 结论已经拿到了 —— 连接循环确实在跑(日志里出现过"连接层创建了 AbortController"),
|
|
812
|
+
// 而 $events 依旧没发出去,所以目标转移到"发帧之前卡在哪",不在这两个 API 上。
|
|
813
|
+
// 2) 它们都挂在热路径上:每个 AbortController 都要取一次调用栈再搜字符串,开销实打实。
|
|
814
|
+
// 3) crypto.getRandomValues 那个钩子太宽 —— 每条一元 RPC 都会触发(RpcId 也用随机数),
|
|
815
|
+
// 实测一个页面刷出 24 条噪音,把 send 的条数预算吃光,反而把关键日志挤没了。
|
|
816
|
+
//
|
|
817
|
+
// 现在保留的是真正有用的那几类:WebSocket 生命周期与收发帧、AbortController.abort
|
|
818
|
+
// 的吞错捕获、console 钩子、窗口错误钩子、以及一次性的能力探测。
|
|
819
|
+
|
|
820
|
+
// ── 抓"被吞掉的错误":钩 AbortController.abort(2026-10-05 加)────
|
|
821
|
+
//
|
|
822
|
+
// 为什么必须这么做:DSH 客户端的 ConnectionController 在生成源失败时,
|
|
823
|
+
// 唯一的动作是调用 AbortController 的 abort(error) —— **不打印任何东西**
|
|
824
|
+
//(见 packages/client/connection/src/client/connection.ts 的 catch 分支)。
|
|
825
|
+
// 所以页面上完全看不到报错,我们一直只能看到"$events 没发出去"这个结果,
|
|
826
|
+
// 看不到原因。
|
|
827
|
+
//
|
|
828
|
+
// 而那个 error 对象**唯一的去处就是 abort(reason)**,
|
|
829
|
+
// 所以钩住它就能把消息和堆栈打出来 —— 堆栈会直接指到出错的那一行。
|
|
830
|
+
//
|
|
831
|
+
// ⚠️ 注意:这个文件里整段垫片是模板字符串,**注释里绝不能出现反引号**
|
|
832
|
+
//(写这一版时就因为一句带反引号的说明把字符串截断、导致语法错误,
|
|
833
|
+
// 见开发思路.md 第 13 节记的那个坑)。
|
|
834
|
+
// 注意:这一段必须整体包在 try/catch 里。
|
|
835
|
+
// 加它的时候我没包,结果整个 PAGE_LOG_SHIM 在运行期抛错 ——
|
|
836
|
+
// 症状是日志里**连 UA / probe 那些行都消失了**(因为 send 也是这段里定义的),
|
|
837
|
+
// 而网关侧的 [ws] 日志照常,非常容易被误读成"垫片没被注入"。
|
|
838
|
+
// 教训:诊断代码永远不能有能力弄死被诊断的东西。
|
|
839
|
+
var NativeAbortController = window.AbortController;
|
|
840
|
+
if (typeof NativeAbortController === 'function' && NativeAbortController.prototype) {
|
|
841
|
+
try {
|
|
842
|
+
var nativeAbort = NativeAbortController.prototype.abort;
|
|
843
|
+
NativeAbortController.prototype.abort = function (reason) {
|
|
844
|
+
try {
|
|
845
|
+
if (arguments.length > 0 && reason instanceof Error) {
|
|
846
|
+
// ⚠️ 这段里一个反斜杠都不能出现:整段垫片是模板字符串,
|
|
847
|
+
// 反斜杠会被它先吃掉。加这一段时连着栽了两次 ——
|
|
848
|
+
// 先是写了"换行"的转义写法,接着在"提醒别写转义"的注释里
|
|
849
|
+
// 举例写了"正则空白"的转义写法(连注释里的例子本身都被吃掉),
|
|
850
|
+
// 两次都把整段脚本弄成语法错误;而症状是"页面一条日志都没有",
|
|
851
|
+
// 看起来像 sendBeacon 坏了,白查了两轮。
|
|
852
|
+
// 所以:换行一律用 String.fromCharCode(10) 表达。
|
|
853
|
+
var newline = String.fromCharCode(10);
|
|
854
|
+
var stack = String(reason.stack || '').split(newline).slice(0, 6).join(' || ');
|
|
855
|
+
send('abort', reason.message + ' 堆栈: ' + stack.slice(0, 900));
|
|
856
|
+
}
|
|
857
|
+
} catch (e) { /* 记录失败不影响原行为 */ }
|
|
858
|
+
return nativeAbort.apply(this, arguments);
|
|
859
|
+
};
|
|
860
|
+
} catch (e) { /* 钩不上就算了;绝不能因此让整段诊断脚本失效 */ }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
// 为什么:网关侧已经证明通道健康、基线数据也到了客户端;但 UI 仍报连接异常
|
|
865
|
+
// 且没有会话。那就只剩"客户端有没有处理这些数据"这一个问题了。
|
|
866
|
+
// 包装 window.WebSocket,把生命周期的关键时刻和头几条消息回传到网关,
|
|
867
|
+
// 就能直接看出:onmessage 到底触发没有、收到之后它做了什么。
|
|
868
|
+
//
|
|
869
|
+
// 注意:prototype 直接共用原生那份,所以 instanceof 依然成立。
|
|
870
|
+
// ── WebSocket 仪表(2026-10-05 加)──────────────────────────────
|
|
871
|
+
// 为什么:网关侧已经证明通道健康、基线数据也到了客户端;但 UI 仍报连接异常
|
|
872
|
+
// 且没有会话。那就只剩"客户端有没有处理这些数据"这一个问题了。
|
|
873
|
+
// 包装 window.WebSocket,把生命周期的关键时刻和头几条消息回传到网关,
|
|
874
|
+
// 就能直接看出:onmessage 到底触发没有、收到之后它做了什么。
|
|
875
|
+
//
|
|
876
|
+
// 注意:prototype 直接共用原生那份,所以 instanceof 依然成立。
|
|
877
|
+
var NativeWebSocket = window.WebSocket;
|
|
878
|
+
if (typeof NativeWebSocket === 'function') {
|
|
879
|
+
var Instrumented = function (url, protocols) {
|
|
880
|
+
var socket = arguments.length > 1
|
|
881
|
+
? new NativeWebSocket(url, protocols)
|
|
882
|
+
: new NativeWebSocket(url);
|
|
883
|
+
try {
|
|
884
|
+
send('ws', 'new ' + url);
|
|
885
|
+
// ── readyState 诊断 + 规范对齐修正(2026-10-05 加)───────────────
|
|
886
|
+
//
|
|
887
|
+
// 怀疑:ArkWeb 可能在 readyState 仍是 CONNECTING(0) 的时候就触发 open 事件。
|
|
888
|
+
// 这不影响大多数代码,但 DSH 的 mux 客户端判断"socket 能不能用"靠的正是
|
|
889
|
+
// readyState === WebSocket.OPEN(见 packages/api/gateway/src/client/stream-client.ts
|
|
890
|
+
// 里 RemoteStreamMuxClient.waitForSocket 的实现)。
|
|
891
|
+
//
|
|
892
|
+
// 后果与实测现象吻合:第一条流 workspace/follow 走的是"等 open 事件"的路径,
|
|
893
|
+
// 能拿到 socket;紧接着 $events 来问的时候,this.socket 已经赋值、readyState
|
|
894
|
+
// 却还是 0,于是它去排队等一个再也不会来的 open 事件 —— 永远挂着、不报错、
|
|
895
|
+
// 不发帧。这正是日志里看到的样子。
|
|
896
|
+
//
|
|
897
|
+
// 这里做两件事:
|
|
898
|
+
// 1) 把 open 触发时的真实 readyState 报回来(直接证实或否掉这个怀疑)
|
|
899
|
+
// 2) 在实例上定义一个自己的 readyState getter:一旦 open 来过且尚未 close,
|
|
900
|
+
// 就按规范报 1(OPEN)。原生本来就正确的话,这一步没有副作用。
|
|
901
|
+
var readyStateGetter = Object.getOwnPropertyDescriptor(
|
|
902
|
+
NativeWebSocket.prototype, 'readyState'
|
|
903
|
+
);
|
|
904
|
+
var openSeen = false;
|
|
905
|
+
var closedSeen = false;
|
|
906
|
+
var readRealReadyState = function () {
|
|
907
|
+
if (readyStateGetter && typeof readyStateGetter.get === 'function') {
|
|
908
|
+
return readyStateGetter.get.call(socket);
|
|
909
|
+
}
|
|
910
|
+
return -1;
|
|
911
|
+
};
|
|
912
|
+
var overrideOk = false;
|
|
913
|
+
try {
|
|
914
|
+
Object.defineProperty(socket, 'readyState', {
|
|
915
|
+
configurable: true,
|
|
916
|
+
get: function () {
|
|
917
|
+
if (openSeen && !closedSeen) return 1;
|
|
918
|
+
return readRealReadyState();
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
overrideOk = true;
|
|
922
|
+
} catch (e) { /* 定义不了就只做诊断 */ }
|
|
923
|
+
socket.addEventListener('open', function () {
|
|
924
|
+
openSeen = true;
|
|
925
|
+
// 这一行是整个 readyState 怀疑的判据:
|
|
926
|
+
// 原生 getter 报 0(CONNECTING)就说明 ArkWeb 在 readyState 还没变 OPEN
|
|
927
|
+
// 的时候就触发了 open 事件;报 1 则说明怀疑不成立,得另找方向。
|
|
928
|
+
// 「改写」那一项说明实例上的 readyState getter 有没有装上(装不上就没法修正)。
|
|
929
|
+
send('ws.open', 'readyState=' + readRealReadyState()
|
|
930
|
+
+ ' 实例改写=' + (overrideOk ? '已装' : '装不上')
|
|
931
|
+
+ ' url=' + url);
|
|
932
|
+
});
|
|
933
|
+
socket.addEventListener('close', function () { closedSeen = true; });
|
|
934
|
+
// 出站消息也要记 —— 为什么(2026-10-05):
|
|
935
|
+
// 入站已经证明只有一条 baseline、始终没有 ready 帧;那就要看客户端到底
|
|
936
|
+
// 有没有把 $events 的订阅发出去。协议层的 Pong 不走 WebSocket.send,
|
|
937
|
+
// 所以这里只会记到**应用层**的发包,正是我们要看的。
|
|
938
|
+
var sentCount = 0;
|
|
939
|
+
var nativeSend = socket.send;
|
|
940
|
+
socket.send = function (payload) {
|
|
941
|
+
try {
|
|
942
|
+
sentCount++;
|
|
943
|
+
// 首次发包时把两种 readyState 都记下来:
|
|
944
|
+
// 一个是客户端实际读到的(可能已被我们改写),一个是原生 getter 的真实值。
|
|
945
|
+
// 两者不一致,就说明这个改写确实在替客户端兜住 ArkWeb 的时序问题。
|
|
946
|
+
if (sentCount === 1) {
|
|
947
|
+
send('ws.send.first', 'readyState(客户端所见)=' + socket.readyState
|
|
948
|
+
+ ' readyState(原生)=' + readRealReadyState());
|
|
949
|
+
}
|
|
950
|
+
if (sentCount <= 20) {
|
|
951
|
+
var info;
|
|
952
|
+
if (typeof payload === 'string') {
|
|
953
|
+
// 关键:把出站帧的**完整内容**打出来(截断到 240 字符)。
|
|
954
|
+
// 因为现在已知客户端只发了一个 type=open,下一步必须知道它开的是
|
|
955
|
+
// 哪条 logical stream —— $events 才该收到 ready 帧。
|
|
956
|
+
info = 'len=' + payload.length + ' 内容=' + payload.slice(0, 240);
|
|
957
|
+
} else {
|
|
958
|
+
info = '二进制 len=' + (payload && payload.byteLength ? payload.byteLength : '?');
|
|
959
|
+
}
|
|
960
|
+
send('ws.send', '#' + sentCount + ' ' + info);
|
|
961
|
+
}
|
|
962
|
+
} catch (e) { /* 仪表绝不能影响页面 */ }
|
|
963
|
+
return nativeSend.apply(socket, arguments);
|
|
964
|
+
};
|
|
965
|
+
socket.addEventListener('close', function (event) {
|
|
966
|
+
send('ws', 'close code=' + event.code + ' reason=' + event.reason +
|
|
967
|
+
' clean=' + event.wasClean);
|
|
968
|
+
});
|
|
969
|
+
socket.addEventListener('error', function () {
|
|
970
|
+
send('ws', 'error ' + url);
|
|
971
|
+
});
|
|
972
|
+
var seen = 0;
|
|
973
|
+
socket.addEventListener('message', function (event) {
|
|
974
|
+
seen++;
|
|
975
|
+
if (seen > 60) return;
|
|
976
|
+
var data = event.data;
|
|
977
|
+
var size;
|
|
978
|
+
if (typeof data === 'string') size = data.length;
|
|
979
|
+
else if (data && typeof data.byteLength === 'number') size = data.byteLength;
|
|
980
|
+
else size = -1;
|
|
981
|
+
|
|
982
|
+
// 关键:把每条消息的 type 打出来。
|
|
983
|
+
// 依据官方架构文档(.agents/notes/.../session-history-and-event-transport.zh.md):
|
|
984
|
+
// $events 逻辑流的第一帧必须是 { type: 'ready', clientId, host },
|
|
985
|
+
// 而 ConnectionController **只有收到 ready 才发布 connected**。
|
|
986
|
+
// 所以"页面能加载、却没会话、UI 报连接异常"最可能的原因就是 ready 没到。
|
|
987
|
+
// 只看前 120 字符是看不出这个的(之前的仪表就是这么写的,所以漏了)。
|
|
988
|
+
var kind = '?';
|
|
989
|
+
if (typeof data === 'string') {
|
|
990
|
+
try {
|
|
991
|
+
var parsed = JSON.parse(data);
|
|
992
|
+
kind = parsed && parsed.type ? String(parsed.type) : '(无 type)';
|
|
993
|
+
if (parsed && parsed.streamId) kind += ' stream=' + String(parsed.streamId).slice(0, 8);
|
|
994
|
+
if (parsed && parsed.value && parsed.value.type) kind += ' value.type=' + String(parsed.value.type);
|
|
995
|
+
} catch (e) {
|
|
996
|
+
kind = 'JSON解析失败:' + e.message;
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
kind = '二进制帧';
|
|
1000
|
+
}
|
|
1001
|
+
send('ws.message', '#' + seen + ' len=' + size + ' type=' + kind);
|
|
1002
|
+
});
|
|
1003
|
+
} catch (e) { /* 仪表绝不能影响页面 */ }
|
|
1004
|
+
return socket;
|
|
1005
|
+
};
|
|
1006
|
+
Instrumented.prototype = NativeWebSocket.prototype;
|
|
1007
|
+
Instrumented.CONNECTING = 0;
|
|
1008
|
+
Instrumented.OPEN = 1;
|
|
1009
|
+
Instrumented.CLOSING = 2;
|
|
1010
|
+
Instrumented.CLOSED = 3;
|
|
1011
|
+
try {
|
|
1012
|
+
window.WebSocket = Instrumented;
|
|
1013
|
+
} catch (e) { /* 有些环境不允许覆盖,那就没有仪表 */ }
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ── 本页第几次加载(计数器)────────────────────────────────────
|
|
1017
|
+
//
|
|
1018
|
+
// 为什么需要:怀疑 webview_flutter_ohos 会把同一个页面加载两次 —— 于是
|
|
1019
|
+
// **第一个页面被顶掉,但它那条 mux WebSocket 还挂在服务端上**,$events 永远
|
|
1020
|
+
// 不会来。表现正好是"第一次 ws 坏、后面全好,而且点重连(不重新加载页面)一定好"。
|
|
1021
|
+
//
|
|
1022
|
+
// 两条可疑路径(都读自插件源码,**均未验证**):
|
|
1023
|
+
// · WebViewHostApiImpl.ets:466-474 onControllerAttached 里设完 UA 会调 refresh()
|
|
1024
|
+
// · OhosWebView.ets:37-41 ArkWeb 组件的 src 绑着活值,而插件另有 loadUrl
|
|
1025
|
+
//
|
|
1026
|
+
// 内存里的计数器活不过重载,所以用 sessionStorage —— 它在**整个标签生命周期**里
|
|
1027
|
+
// 都在,页面重载不会清掉。网关日志里的 [req] GET / 也能数,但分不清"两次文档
|
|
1028
|
+
// 请求"是 303 重定向还是真的加载了两次;这个计数器只认"这段 JS 真的执行了一遍"。
|
|
1029
|
+
//
|
|
1030
|
+
// 同时报 navigation 类型(navigate / reload / back_forward):
|
|
1031
|
+
// · 第二次是 reload → 就是插件自己调了 refresh()
|
|
1032
|
+
// · 第二次仍是 navigate → 是"又发起了一次导航"(比如 src 被改)
|
|
1033
|
+
try {
|
|
1034
|
+
var loadKey = '__dsh_gateway_page_loads';
|
|
1035
|
+
var loads = parseInt(window.sessionStorage.getItem(loadKey) || '0', 10);
|
|
1036
|
+
if (!(loads >= 0)) loads = 0;
|
|
1037
|
+
loads += 1;
|
|
1038
|
+
window.sessionStorage.setItem(loadKey, String(loads));
|
|
1039
|
+
|
|
1040
|
+
var navType = '(取不到)';
|
|
1041
|
+
try {
|
|
1042
|
+
var navEntries = window.performance.getEntriesByType('navigation');
|
|
1043
|
+
if (navEntries && navEntries.length > 0) navType = String(navEntries[0].type);
|
|
1044
|
+
} catch (e) { /* 取不到就算了 */ }
|
|
1045
|
+
|
|
1046
|
+
send('pageLoad', '本页第 ' + loads + ' 次加载;导航类型=' + navType
|
|
1047
|
+
+ ';href=' + String(location.href).slice(0, 160));
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
send('pageLoad', '计数失败: ' + (e && e.message ? e.message : String(e)));
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ── "这个页面到底活着没有"探针 ──────────────────────────────────
|
|
1053
|
+
//
|
|
1054
|
+
// 为什么加:当前最可疑的是 **ArkWeb 被嵌进 Flutter 平台视图时把页面的
|
|
1055
|
+
// 后台/空闲任务节流了**。
|
|
1056
|
+
// 依据(已实测):同一台鸿蒙机器、同一个内核(ArkWeb 6.1.0.120 / Chrome 132)、
|
|
1057
|
+
// 同一个网关 —— **系统浏览器正常,App 里的 WebView 坏**;而电脑 Chrome 155 与
|
|
1058
|
+
// 安卓 WebView 114 都正常。所以剩下的变量只有"被谁嵌进去"。
|
|
1059
|
+
//
|
|
1060
|
+
// 这组数就是来量这件事的:定时器准不准、动画帧还在不在跑、空闲回调还在不在跑、
|
|
1061
|
+
// 页面被认为可见还是隐藏、视口尺寸是多少。
|
|
1062
|
+
//
|
|
1063
|
+
// ★ 关键:同一段探针在**系统浏览器**里也会跑 —— 这就是一次同内核的 A/B 对照。
|
|
1064
|
+
var alive = { raf: 0, idle: 0, idleRemain: -1, drift: -1, ticks: 0, online: 0, offline: 0 };
|
|
1065
|
+
try {
|
|
1066
|
+
// ── 网络状态(这一条是"最大嫌疑")──────────────────────────────
|
|
1067
|
+
// DSH 客户端的连接循环开头有一道闸门:
|
|
1068
|
+
// if (!networkAvailable && !immediateRetry) { 停在这里等网络 }
|
|
1069
|
+
// 而 networkAvailable 来自 navigator.onLine。**如果 ArkWeb 把它报成 false,
|
|
1070
|
+
// 连接循环就会按设计停在"离线"分支** —— 没有代际、没有 $events、没有报错、
|
|
1071
|
+
// 没有超时警告、页面还完全健康。而点重连会设 immediateRetry,正好绕过这道闸门。
|
|
1072
|
+
// 所以这里报:onLine 的当前值,以及 online / offline 事件各来过几次。
|
|
1073
|
+
try {
|
|
1074
|
+
window.addEventListener('online', function () { alive.online++; });
|
|
1075
|
+
window.addEventListener('offline', function () { alive.offline++; });
|
|
1076
|
+
} catch (e) { /* 挂不上就算了 */ }
|
|
1077
|
+
|
|
1078
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
1079
|
+
var rafTick = function () { alive.raf++; requestAnimationFrame(rafTick); };
|
|
1080
|
+
requestAnimationFrame(rafTick);
|
|
1081
|
+
}
|
|
1082
|
+
if (typeof requestIdleCallback === 'function') {
|
|
1083
|
+
var idleTick = function (deadline) {
|
|
1084
|
+
alive.idle++;
|
|
1085
|
+
try { alive.idleRemain = Math.round(deadline.timeRemaining()); } catch (e) { /* 取不到就算了 */ }
|
|
1086
|
+
requestIdleCallback(idleTick);
|
|
1087
|
+
};
|
|
1088
|
+
requestIdleCallback(idleTick);
|
|
1089
|
+
}
|
|
1090
|
+
var timerTick = function () {
|
|
1091
|
+
var startedAt = Date.now();
|
|
1092
|
+
setTimeout(function () {
|
|
1093
|
+
alive.drift = Date.now() - startedAt;
|
|
1094
|
+
alive.ticks++;
|
|
1095
|
+
timerTick();
|
|
1096
|
+
}, 1000);
|
|
1097
|
+
};
|
|
1098
|
+
timerTick();
|
|
1099
|
+
|
|
1100
|
+
var aliveReports = 0;
|
|
1101
|
+
var aliveTimer = setInterval(function () {
|
|
1102
|
+
aliveReports++;
|
|
1103
|
+
if (aliveReports > 30) { clearInterval(aliveTimer); return; }
|
|
1104
|
+
var doc = document;
|
|
1105
|
+
var focus = (typeof doc.hasFocus === 'function') ? String(doc.hasFocus()) : '?';
|
|
1106
|
+
send('alive',
|
|
1107
|
+
'可见=' + String(doc.visibilityState)
|
|
1108
|
+
+ ' 隐藏=' + String(doc.hidden)
|
|
1109
|
+
+ ' 有焦点=' + focus
|
|
1110
|
+
+ ' ★在线=' + String(navigator.onLine)
|
|
1111
|
+
+ ' online事件=' + alive.online
|
|
1112
|
+
+ ' offline事件=' + alive.offline
|
|
1113
|
+
+ ' | 动画帧累计=' + alive.raf
|
|
1114
|
+
+ ' 空闲回调累计=' + alive.idle
|
|
1115
|
+
+ ' 空闲余量=' + alive.idleRemain
|
|
1116
|
+
+ ' 定时器漂移=' + alive.drift + 'ms(第' + alive.ticks + '次)'
|
|
1117
|
+
+ ' | 视口=' + window.innerWidth + 'x' + window.innerHeight
|
|
1118
|
+
+ ' 文档=' + doc.documentElement.clientWidth + 'x' + doc.documentElement.clientHeight);
|
|
1119
|
+
}, 2000);
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
send('alive', '探针装不上: ' + (e && e.message ? e.message : String(e)));
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ── 能力探测(2026-10-05 加)────────────────────────────────────
|
|
1125
|
+
// 为什么:网关侧已证明通道健康、载荷完整且合法(len=6910 JSON_OK)、
|
|
1126
|
+
// 客户端的 onmessage 也触发了,但之后它什么都不做、UI 报连接异常 ——
|
|
1127
|
+
// 说明失败被它自己的 try/catch 吞了,看不到报错。
|
|
1128
|
+
// 那就反过来:让页面把"它依赖的那些能力到底能不能用"报回来。
|
|
1129
|
+
// 哪一项抛错,答案就在哪一项。
|
|
1130
|
+
function probe(name, run) {
|
|
1131
|
+
try {
|
|
1132
|
+
send('probe', name + ' = ' + run());
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
send('probe', name + ' 抛错: ' + (error && error.message ? error.message : String(error)));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
setTimeout(function () {
|
|
1138
|
+
probe('isSecureContext', function () { return String(window.isSecureContext); });
|
|
1139
|
+
probe('localStorage.setItem', function () {
|
|
1140
|
+
window.localStorage.setItem('__dsh_probe', '1');
|
|
1141
|
+
return 'OK';
|
|
1142
|
+
});
|
|
1143
|
+
probe('localStorage.getItem', function () {
|
|
1144
|
+
return String(window.localStorage.getItem('__dsh_probe'));
|
|
1145
|
+
});
|
|
1146
|
+
probe('sessionStorage.setItem', function () {
|
|
1147
|
+
window.sessionStorage.setItem('__dsh_probe', '1');
|
|
1148
|
+
return 'OK';
|
|
1149
|
+
});
|
|
1150
|
+
probe('indexedDB 存在', function () {
|
|
1151
|
+
return window.indexedDB ? 'OK' : '缺失';
|
|
1152
|
+
});
|
|
1153
|
+
probe('structuredClone 存在', function () {
|
|
1154
|
+
return typeof globalThis.structuredClone === 'function' ? 'OK' : '缺失';
|
|
1155
|
+
});
|
|
1156
|
+
probe('crypto.getRandomValues 存在', function () {
|
|
1157
|
+
return typeof window.crypto.getRandomValues === 'function' ? 'OK' : '缺失';
|
|
1158
|
+
});
|
|
1159
|
+
probe('Intl.DateTimeFormat', function () {
|
|
1160
|
+
return new Intl.DateTimeFormat('zh-CN').format(new Date(0));
|
|
1161
|
+
});
|
|
1162
|
+
probe('BigInt64Array', function () {
|
|
1163
|
+
return typeof BigInt64Array === 'function' ? 'OK' : '缺失';
|
|
1164
|
+
});
|
|
1165
|
+
}, 0);
|
|
1166
|
+
globalThis.__DSH_GATEWAY_SHIM__ = Object.assign(globalThis.__DSH_GATEWAY_SHIM__ || {}, {
|
|
1167
|
+
pageLogShim: true
|
|
1168
|
+
});
|
|
1169
|
+
})();
|
|
1170
|
+
</script>`
|
|
1171
|
+
|
|
1172
|
+
/** 注入到 `<head>` 之后的第一件事(保证早于页面自己的 defer 脚本)。 */
|
|
1173
|
+
// ── 启动时自查注入脚本的语法(2026-10-05 加)──────────────────────
|
|
1174
|
+
//
|
|
1175
|
+
// 为什么必须放在这儿:这几段垫片都是**模板字符串**,所以
|
|
1176
|
+
// · 注释里写反引号 → 直接把字符串截断 → gateway.js 自己语法就错了;
|
|
1177
|
+
// · 注释里写反斜杠转义(换行、正则空白之类) → 反斜杠被模板字符串吃掉,
|
|
1178
|
+
// gateway.js 依然能正常加载,**只有注入到页面之后才失效** ——
|
|
1179
|
+
// 症状是"页面一条日志都没有",看起来像回传通道坏了,极难定位。
|
|
1180
|
+
// 用 new Function 只编译不执行,能在起服务之前把这两类都挡下来。
|
|
1181
|
+
// (这个自查是因为同一类错误连栽了三次才补上的:靠人自觉显然不行。)
|
|
1182
|
+
for (const [shimName, shimSource] of [
|
|
1183
|
+
['PAGE_LOG_SHIM', PAGE_LOG_SHIM],
|
|
1184
|
+
['CLIENT_SHIM', CLIENT_SHIM],
|
|
1185
|
+
['MOBILE_ENTER_SHIM', MOBILE_ENTER_SHIM],
|
|
1186
|
+
['KEYBOARD_INSET_SHIM', KEYBOARD_INSET_SHIM],
|
|
1187
|
+
]) {
|
|
1188
|
+
const shimBody = shimSource.replace(/<\/?script[^>]*>/gi, '\n')
|
|
1189
|
+
try {
|
|
1190
|
+
new Function(shimBody)
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
throw new Error(
|
|
1193
|
+
'注入脚本 ' + shimName + ' 语法错误:' + error.message
|
|
1194
|
+
+ '(多半是注释里出现了反引号或反斜杠转义)',
|
|
1195
|
+
)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function injectClientShim(html) {
|
|
1200
|
+
// 空模式(2026-10-05):完全不注入,纯透明转发。
|
|
1201
|
+
//
|
|
1202
|
+
// 为什么需要:鸿蒙上客户端的 $events(以及 session/control)从来没被打开过,
|
|
1203
|
+
// 而垫片**从第一版起就一直注入在页面里**,却从来没做过"不注入"的对照 ——
|
|
1204
|
+
// 也就是说"是我的注入弄坏了插件的加载"这条一直没被排除。
|
|
1205
|
+
// 打开空模式后看不到 [page] 日志了,但网关侧的**字节数**仍是硬证据:
|
|
1206
|
+
// 客户端→上游 3 秒时约 117 字节 = 只发了一条 open;若有 400~600 字节,
|
|
1207
|
+
// 说明 5 条 open 都发出去了,那问题就在垫片。
|
|
1208
|
+
// 用环境变量打开,避免改代码。
|
|
1209
|
+
//
|
|
1210
|
+
// ⚠️ 这里必须打一行日志:上一次做空模式对照时,页面其实**仍在跑旧垫片**
|
|
1211
|
+
//(日志里照样有 [page] ws: new),于是那次对照是无效的、却没人发现。
|
|
1212
|
+
// 每个响应都打一行,才能确认空模式真的生效了。
|
|
1213
|
+
if (process.env.DSH_GATEWAY_BARE === '1') {
|
|
1214
|
+
// 注意:这里是**模块级函数**,拿不到 createGateway 的 logger。
|
|
1215
|
+
// 之前写成 logger.warn 直接把网关弄崩了(ReferenceError),
|
|
1216
|
+
// 而症状是客户端报 "Connection closed before full header was received" ——
|
|
1217
|
+
// 看起来像网络问题,其实是服务端自己挂了。用 console 输出到同一个日志文件。
|
|
1218
|
+
console.log('[shim] BARE 模式生效:本次页面 HTML 未注入任何东西')
|
|
1219
|
+
return html
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const open = /<head(?:\s[^>]*)?>/i.exec(html)
|
|
1223
|
+
if (open === null) return html
|
|
1224
|
+
const at = open.index + open[0].length
|
|
1225
|
+
// 只注入这三段。键盘垫片(KEYBOARD_INSET_SHIM)已停用 —— 见它定义处说明。
|
|
1226
|
+
// PAGE_LOG_SHIM 放最前,这样其它垫片和页面自己的报错都能被它捕获。
|
|
1227
|
+
return (
|
|
1228
|
+
html.slice(0, at) + PAGE_LOG_SHIM + CLIENT_SHIM + MOBILE_ENTER_SHIM + html.slice(at)
|
|
1229
|
+
)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* 创建网关(不自动监听)。
|
|
1234
|
+
*
|
|
1235
|
+
* @param options.password 网关密码。空 / 非字符串 → 直接抛错(失败关闭)。
|
|
1236
|
+
* @param options.targetPort 上游 DSH Web 端口,数字或返回数字的函数。
|
|
1237
|
+
* @param options.resolveToken 返回「当前进程的启动 token」;拿不到就返回 undefined。
|
|
1238
|
+
* 只有根请求会用到它。
|
|
1239
|
+
* @param options.host 监听地址,默认 0.0.0.0。
|
|
1240
|
+
* @param options.port 监听端口,默认 8080。
|
|
1241
|
+
* @param options.sessionDays 登录后 cookie 有效期,默认 30 天。
|
|
1242
|
+
* @param options.logger 日志出口,默认 console。
|
|
1243
|
+
*/
|
|
1244
|
+
export function createGateway(options) {
|
|
1245
|
+
const {
|
|
1246
|
+
password,
|
|
1247
|
+
targetPort,
|
|
1248
|
+
resolveToken = () => undefined,
|
|
1249
|
+
host = '0.0.0.0',
|
|
1250
|
+
port = 8080,
|
|
1251
|
+
sessionDays = 30,
|
|
1252
|
+
logger: rawLogger = console,
|
|
1253
|
+
/**
|
|
1254
|
+
* 客户端每次**成功换到 cookie** 时回调一次,参数 { address, platform, userAgent }。
|
|
1255
|
+
* 插件(dsh-webview-remote-server)用它写连接历史。可选,不传就是没有。
|
|
1256
|
+
* 回调抛错绝不影响这次认证 —— 记历史失败不该让人连不上。
|
|
1257
|
+
*/
|
|
1258
|
+
onSession,
|
|
1259
|
+
} = options ?? {}
|
|
1260
|
+
|
|
1261
|
+
// ── 给每条日志加时间戳(本轮加)──────────────────────────────────
|
|
1262
|
+
// 为什么必须有:在此之前整份日志**没有任何时间信息**,只能靠行号顺序推时序。
|
|
1263
|
+
// 而 "[page]" 那些行是页面异步回传的(sendBeacon / fetch),它的到达顺序和
|
|
1264
|
+
// "[ws]" 那些同步日志的先后**根本不可靠** —— 本项目已经因此误判过。
|
|
1265
|
+
// 放在唯一的出口上加前缀,所有调用点一行都不用改。
|
|
1266
|
+
const stampOf = (date) => {
|
|
1267
|
+
const pad = (value, width) => String(value).padStart(width, '0')
|
|
1268
|
+
return pad(date.getHours(), 2) + ':' + pad(date.getMinutes(), 2) + ':'
|
|
1269
|
+
+ pad(date.getSeconds(), 2) + '.' + pad(date.getMilliseconds(), 3)
|
|
1270
|
+
}
|
|
1271
|
+
const withTimestamp = (sink) => {
|
|
1272
|
+
const wrap = (name) => {
|
|
1273
|
+
const fn = sink?.[name]
|
|
1274
|
+
if (typeof fn !== 'function') return undefined
|
|
1275
|
+
return (...args) => fn.call(sink, '[' + stampOf(new Date()) + ']', ...args)
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
info: wrap('info'),
|
|
1279
|
+
warn: wrap('warn'),
|
|
1280
|
+
error: wrap('error'),
|
|
1281
|
+
debug: wrap('debug'),
|
|
1282
|
+
log: wrap('log'),
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
const logger = withTimestamp(rawLogger)
|
|
1286
|
+
|
|
1287
|
+
if (typeof password !== 'string' || password === '') {
|
|
1288
|
+
throw new Error(
|
|
1289
|
+
'dsh-remote-gateway: 拒绝以空密码启动。本网关把 DSH 的 Origin 围栏对已认证流量失效了,' +
|
|
1290
|
+
'密码就是唯一的安全边界 —— 空密码等于把「在本机执行任意命令」的能力开放给整个网络。',
|
|
1291
|
+
)
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const signer = makeSigner(password, sessionDays)
|
|
1295
|
+
const port_ = () => (typeof targetPort === 'function' ? targetPort() : targetPort)
|
|
1296
|
+
|
|
1297
|
+
/** 已升级(WebSocket)的客户端 socket,关服务时要显式销毁。 */
|
|
1298
|
+
const upgraded = new Set()
|
|
1299
|
+
|
|
1300
|
+
const setCookieHeader = (value, maxAgeSeconds) =>
|
|
1301
|
+
`${GATEWAY_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* 从**请求头**里取客户端给的密码。
|
|
1305
|
+
*
|
|
1306
|
+
* **刻意不支持 URL 查询参数**:URL 会进浏览器历史、Referer、以及各种
|
|
1307
|
+
* 中间层日志,HTTPS 也救不了 —— 密码必须走头或 body。
|
|
1308
|
+
*/
|
|
1309
|
+
function suppliedPassword(req) {
|
|
1310
|
+
const auth = req.headers.authorization
|
|
1311
|
+
if (typeof auth === 'string' && /^Bearer\s+/i.test(auth)) return auth.replace(/^Bearer\s+/i, '').trim()
|
|
1312
|
+
const header = req.headers['x-gateway-password']
|
|
1313
|
+
if (typeof header === 'string' && header !== '') return header
|
|
1314
|
+
return undefined
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function sendJson(res, status, body, extraHeaders) {
|
|
1318
|
+
const payload = Buffer.from(JSON.stringify(body), 'utf8')
|
|
1319
|
+
res.writeHead(status, {
|
|
1320
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1321
|
+
'content-length': String(payload.length),
|
|
1322
|
+
'cache-control': 'no-store',
|
|
1323
|
+
...extraHeaders,
|
|
1324
|
+
})
|
|
1325
|
+
res.end(payload)
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// ────────────────────────────────────────────────────────────
|
|
1329
|
+
// 把一次已鉴权的请求转发给上游
|
|
1330
|
+
// ────────────────────────────────────────────────────────────
|
|
1331
|
+
function proxyHttp(req, res, url) {
|
|
1332
|
+
const upstreamPort = port_()
|
|
1333
|
+
if (!Number.isInteger(upstreamPort)) {
|
|
1334
|
+
sendJson(res, 503, { error: 'gateway: upstream port unknown' })
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ── token 注入规则(见 开发思路.md §3)──────────────────────
|
|
1339
|
+
// 只在两个条件同时成立时注入:
|
|
1340
|
+
// 1) 路径【恰好】是 '/' —— DSH 的 token 交换只认这个路径,
|
|
1341
|
+
// `/index.html` 带 token 会落到 401
|
|
1342
|
+
// 2) 客户端【没有】一张看起来还没过期的 `dsh-auth` cookie
|
|
1343
|
+
//
|
|
1344
|
+
// 为什么不能"每个请求都注入":DSH 的 authorizeIndex 对「路径是 / 且带 token」
|
|
1345
|
+
// 的请求**一律 303 回 /**,从不返回首页 —— 于是
|
|
1346
|
+
// 每请求注入 → 303 → 跟随时又注入 → 再 303 → 无限重定向。
|
|
1347
|
+
//
|
|
1348
|
+
// 按本规则:
|
|
1349
|
+
// 没 cookie / 已过期 → 注入一次 → DSH 签新 cookie + 303
|
|
1350
|
+
// → 跟随请求已带有效 cookie → 不再注入 → 正常出首页
|
|
1351
|
+
let path = url.pathname + url.search
|
|
1352
|
+
if (
|
|
1353
|
+
url.pathname === '/' &&
|
|
1354
|
+
!url.searchParams.has('token') &&
|
|
1355
|
+
!hasLiveDshSession(req.headers.cookie)
|
|
1356
|
+
) {
|
|
1357
|
+
const token = resolveToken()
|
|
1358
|
+
if (typeof token === 'string' && token !== '') {
|
|
1359
|
+
const withToken = new URL(url.href)
|
|
1360
|
+
withToken.searchParams.set('token', token)
|
|
1361
|
+
path = withToken.pathname + withToken.search
|
|
1362
|
+
} else {
|
|
1363
|
+
logger.warn?.(
|
|
1364
|
+
'dsh-remote-gateway: 拿不到当前进程的启动 token,根请求会以 401 返回;' +
|
|
1365
|
+
'请确认 connection 服务可用,或改用手动带 token 的 URL。',
|
|
1366
|
+
)
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// 文档请求要往 HTML 正文里注入垫片,所以不能让上游 gzip(否则得先解压)。
|
|
1371
|
+
// 只对文档这么做,静态资源照旧走 gzip。
|
|
1372
|
+
const isDocument = url.pathname === '/' || url.pathname.endsWith('/index.html')
|
|
1373
|
+
const headers = upstreamHeaders(req.headers, upstreamPort)
|
|
1374
|
+
if (isDocument) headers['accept-encoding'] = 'identity'
|
|
1375
|
+
|
|
1376
|
+
// ── 请求日志(诊断用,2026-10-01 加)────────────────────────
|
|
1377
|
+
// 为什么需要:鸿蒙上 WebView 到底请求了什么、有没有带上会话 cookie,
|
|
1378
|
+
// **在设备侧看不到** —— Dart 的 debugPrint 不进 hilog(实测)。
|
|
1379
|
+
// 把网关这一步记下来,就能直接区分两类完全不同的故障:
|
|
1380
|
+
// · gw=无 → 客户端根本没带上我们的 cookie → 会拿到 404 空页
|
|
1381
|
+
// · gw=有 但页面仍不对 → 那是客户端那边的问题(比如#root 守卫误判)
|
|
1382
|
+
// ⚠️ 只记路径,**不记 token 值**(日志也是会被人看到的)。
|
|
1383
|
+
// /plugins/ 的完整 URL(含查询串)必须打出来 —— 每个插件是一个独立 URL,
|
|
1384
|
+
// 只看 pathname 会把它们全看成同一个 /plugins/,于是"某个插件 404 了"这种
|
|
1385
|
+
// 事在日志里完全看不出来(我前面就是这么被误导的)。
|
|
1386
|
+
const loggedPath = url.pathname.startsWith('/plugins/')
|
|
1387
|
+
? url.pathname + url.search
|
|
1388
|
+
: url.pathname
|
|
1389
|
+
logger.info?.(
|
|
1390
|
+
`[req] ${req.method} ${loggedPath} ` +
|
|
1391
|
+
`gw=${parseCookies(req.headers.cookie)[GATEWAY_COOKIE] ? '有' : '无'} ` +
|
|
1392
|
+
`dsh会话=${hasLiveDshSession(req.headers.cookie) ? '有' : '无'} ` +
|
|
1393
|
+
`token=${url.searchParams.has('token') || path.includes('token=') ? '注入' : '未注入'} ` +
|
|
1394
|
+
`文档=${isDocument ? '是' : '否'}`,
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
// 诊断(2026-10-05):/plugins/ 是**客户端 Remote 插件的加载入口**,
|
|
1398
|
+
// 而它间歇性返回 404 —— 那就会导致 $events 从没被打开、UI 永远"连接异常"。
|
|
1399
|
+
// 这里把这个请求的关键头和响应的缓存头都记下来,好和电脑上的正常请求对比。
|
|
1400
|
+
const isPluginsPath = url.pathname === '/plugins/' || url.pathname.startsWith('/plugins/')
|
|
1401
|
+
|
|
1402
|
+
const upstream = http.request({
|
|
1403
|
+
host: LOOPBACK,
|
|
1404
|
+
port: upstreamPort,
|
|
1405
|
+
method: req.method,
|
|
1406
|
+
path,
|
|
1407
|
+
headers,
|
|
1408
|
+
})
|
|
1409
|
+
|
|
1410
|
+
upstream.on('response', (upRes) => {
|
|
1411
|
+
const contentType = String(upRes.headers['content-type'] ?? '')
|
|
1412
|
+
logger.info?.(
|
|
1413
|
+
`[res] ${upRes.statusCode} ${url.pathname} type=${contentType === '' ? '(无)' : contentType}`,
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
// 页面 HTML 发出去了 → 进入启动窗口,接下来的 mux 升级会等零件装好。
|
|
1417
|
+
if (isDocument && req.method === 'GET') {
|
|
1418
|
+
lastIndexServedAt = Date.now()
|
|
1419
|
+
logger.info?.(
|
|
1420
|
+
'[ws] 已发出页面 HTML → 记下启动时刻;此后的 mux 升级是否先压住、压多久,'
|
|
1421
|
+
+ '取决于 MUX_WARMUP_WINDOW_MS(当前值见该常量,0 = 不压)',
|
|
1422
|
+
)
|
|
1423
|
+
}
|
|
1424
|
+
if (isPluginsPath) {
|
|
1425
|
+
logger.warn?.(
|
|
1426
|
+
`[plugins] ${url.pathname}${url.search} ` +
|
|
1427
|
+
`accept=${req.headers.accept ?? '(无)'} ` +
|
|
1428
|
+
`sec-fetch-dest=${req.headers['sec-fetch-dest'] ?? '(无)'} ` +
|
|
1429
|
+
`referer=${req.headers.referer ?? '(无)'} ` +
|
|
1430
|
+
`origin=${req.headers.origin ?? '(无)'} ` +
|
|
1431
|
+
`|| 响应 ${upRes.statusCode} cache-control=${upRes.headers['cache-control'] ?? '(无)'} ` +
|
|
1432
|
+
`etag=${upRes.headers.etag ?? '(无)'}`,
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
// 强制 /plugins/ 不缓存(2026-10-05 加)。
|
|
1436
|
+
//
|
|
1437
|
+
// 为什么:上游给它的响应头是
|
|
1438
|
+
// cache-control: public, max-age=31536000, immutable
|
|
1439
|
+
// —— 一年、而且永不校验。可这个 URL **没有内容哈希**:插件集合一变
|
|
1440
|
+
//(装 / 卸插件),客户端手里那份就是旧的。旧的插件包对不上当前的 Host →
|
|
1441
|
+
// 客户端 Remote / Session 插件起不来 → `$events` 与 `session/control`
|
|
1442
|
+
// 永远不开 → UI"连接异常";而 workspace/follow 在主 bundle 里,不受影响。
|
|
1443
|
+
//
|
|
1444
|
+
// 实测旁证:某次加载里页面**完全没有请求** /plugins/(直接吃了一年缓存),
|
|
1445
|
+
// 而同一次 `$events` 依旧没开。
|
|
1446
|
+
//
|
|
1447
|
+
// 这里直接改上游 headers —— downstreamHeaders 在这之后才计算,所以生效。
|
|
1448
|
+
delete upRes.headers['cache-control']
|
|
1449
|
+
delete upRes.headers.etag
|
|
1450
|
+
delete upRes.headers['last-modified']
|
|
1451
|
+
delete upRes.headers.expires
|
|
1452
|
+
upRes.headers['cache-control'] = 'no-store'
|
|
1453
|
+
logger.warn?.('[plugins] 已改写为 cache-control: no-store(强制每次重新取)')
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (contentType.startsWith('text/html')) {
|
|
1457
|
+
// HTML 只有几 KB,整段缓冲后注入垫片再发出。
|
|
1458
|
+
// ⚠️ 这条分支【绝不能】用在 SSE / 流式响应上 —— 所以只认 text/html。
|
|
1459
|
+
const chunks = []
|
|
1460
|
+
upRes.on('data', (chunk) => chunks.push(chunk))
|
|
1461
|
+
upRes.on('end', () => {
|
|
1462
|
+
// 注入失败绝不能把网关弄崩。
|
|
1463
|
+
// 曾经因为注入函数里一个 logger 引用错误,网关每次发页面就退出,
|
|
1464
|
+
// 客户端那边看到的是 "Connection closed before full header was received" ——
|
|
1465
|
+
// 完全联想不到是服务端自己挂了。失败就原样返回页面。
|
|
1466
|
+
const rawHtml = Buffer.concat(chunks).toString('utf8')
|
|
1467
|
+
let injectedHtml = rawHtml
|
|
1468
|
+
try {
|
|
1469
|
+
injectedHtml = injectClientShim(rawHtml)
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
logger.warn?.(`[shim] 注入失败,原样返回页面:${error.message}`)
|
|
1472
|
+
}
|
|
1473
|
+
const body = Buffer.from(injectedHtml, 'utf8')
|
|
1474
|
+
const outHeaders = downstreamHeaders(upRes.headers, upstreamPort)
|
|
1475
|
+
// 注入后长度变了,而且我们强制过 identity,所以这两个都要重算/去掉
|
|
1476
|
+
delete outHeaders['content-encoding']
|
|
1477
|
+
outHeaders['content-length'] = String(body.length)
|
|
1478
|
+
res.writeHead(upRes.statusCode ?? 502, outHeaders)
|
|
1479
|
+
res.end(body)
|
|
1480
|
+
})
|
|
1481
|
+
upRes.on('error', () => res.destroy())
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
res.writeHead(upRes.statusCode ?? 502, downstreamHeaders(upRes.headers, upstreamPort))
|
|
1486
|
+
// SSE 必须尽快把响应头推出去,否则客户端会以为请求还卡着。
|
|
1487
|
+
res.flushHeaders?.()
|
|
1488
|
+
upRes.pipe(res)
|
|
1489
|
+
upRes.on('error', () => res.destroy())
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
upstream.on('error', (error) => {
|
|
1493
|
+
// 上游连不上,最常见的唯一原因就是「**DSH 本体没在运行**」。
|
|
1494
|
+
// 单独把它说清楚:否则客户端只拿到一个笼统的 502,用户完全无从下手
|
|
1495
|
+
//(实测症状:服务端没起来时,客户端那边一直转圈、看不出究竟)。
|
|
1496
|
+
const refused = error.code === 'ECONNREFUSED'
|
|
1497
|
+
const message = refused
|
|
1498
|
+
? `上游 DSH 没在运行(${LOOPBACK}:${upstreamPort} 拒绝连接)—— 请先把 DSH 本体起起来`
|
|
1499
|
+
: `上游出错:${error.message}`
|
|
1500
|
+
logger.warn?.(`[!] ${url.pathname} → ${message}`)
|
|
1501
|
+
if (!res.headersSent) {
|
|
1502
|
+
sendJson(res, 502, { error: `gateway: ${message}` })
|
|
1503
|
+
} else {
|
|
1504
|
+
res.destroy()
|
|
1505
|
+
}
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
// 客户端断开(手机锁屏、切后台)时立刻掐掉上游,别把连接挂着。
|
|
1509
|
+
res.on('close', () => upstream.destroy())
|
|
1510
|
+
|
|
1511
|
+
// 请求体流式转发:上传大文件不会整个读进内存。
|
|
1512
|
+
req.pipe(upstream)
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// ────────────────────────────────────────────────────────────
|
|
1516
|
+
// 转发 WebSocket 升级
|
|
1517
|
+
// 【已读码】DSH 的 /api/remote.mux 是一条 WebSocket(Remote 多路复用 + 2s 心跳,
|
|
1518
|
+
// 由 dsh-api-gateway 经 registerUpgrade 注册)。不转发它,手机上 UI 的
|
|
1519
|
+
// 流/事件会静默失效 —— 页面能打开,但永远不更新。
|
|
1520
|
+
// ────────────────────────────────────────────────────────────
|
|
1521
|
+
// ── "等零件装好再连接"所需的状态(2026-10-05)────────────────────
|
|
1522
|
+
//
|
|
1523
|
+
// 背景(本轮实测):第一次连接时,客户端的 `$events`(连接代际的唯一来源)
|
|
1524
|
+
// 根本没轮到发出去 —— 它必须等客户端 loader 就绪后才 start();而同一条 mux
|
|
1525
|
+
// WebSocket 在插件构造时就建好了,所以 `workspace/follow` 先出去了、
|
|
1526
|
+
// `$events` 没有。表现就是"第一次连接异常、手动点重连才好"。
|
|
1527
|
+
//
|
|
1528
|
+
// 修法按用户说的办:**等所有零件装好再连接**。
|
|
1529
|
+
// 网关看不见客户端的 loader,所以用"页面启动窗口"近似:把**页面刚发出去之后
|
|
1530
|
+
// 这段窗口内**的第一次 mux 升级压住,等窗口过去再放行。
|
|
1531
|
+
// 窗口之外的升级(真正的重连)一律不拦。
|
|
1532
|
+
/** 最近一次把页面 HTML 发给客户端的时刻。 */
|
|
1533
|
+
let lastIndexServedAt = 0
|
|
1534
|
+
/**
|
|
1535
|
+
* 页面启动窗口:这段时间内的 mux 升级会被压住。
|
|
1536
|
+
*
|
|
1537
|
+
* 2026-10-05 重新启用,并把 3 秒改成 8 秒。
|
|
1538
|
+
*
|
|
1539
|
+
* 为什么之前那次(3 秒)没用:那道闸门只压住了**升级请求**,于是 socket 建立
|
|
1540
|
+
* 和连接循环的启动被**同时**往后推,两者的先后关系丝毫没变 —— 等于没做。
|
|
1541
|
+
* 真正要拉开的是「socket 建立」和「连接循环启动($events 由它发出)」之间的时间差。
|
|
1542
|
+
*
|
|
1543
|
+
* 而连接循环卡在 `ctx.get('loader').await()` 后面(源码:
|
|
1544
|
+
* packages/api/gateway/src/client/index.ts),从实测日志看它明显晚于 3 秒,
|
|
1545
|
+
* 所以 3 秒的窗口根本不够:socket 照样先开,第一条流照样是 workspace/follow。
|
|
1546
|
+
*
|
|
1547
|
+
* 用户从一开始的判断就是对的:这是加载时机问题 —— 安卓加载快、
|
|
1548
|
+
* 或者顺序不同,所以它从来不出事。
|
|
1549
|
+
*
|
|
1550
|
+
* ⚠️ 注意:这是网关侧的兜法,只在**页面刚发出去的窗口内**生效;
|
|
1551
|
+
* 窗口之外的升级(真正的重连)一律不拦。上限见 MUX_WARMUP_MAX_MS。
|
|
1552
|
+
*/
|
|
1553
|
+
// ⚠️ 已停用(对照实验):置 0 = **不压任何升级**。
|
|
1554
|
+
//
|
|
1555
|
+
// 为什么停用:这个机制是整段代码里**唯一一个"只针对页面加载后第一次 mux 升级"
|
|
1556
|
+
// 下手的地方**,而鸿蒙 ArkWeb 上的症状恰好是"第一次 ws 坏、后面全好" ——
|
|
1557
|
+
// 两者形状完全一致,所以必须先做这一次最小对照把它排除掉。
|
|
1558
|
+
//
|
|
1559
|
+
// 【待验证】置 0 之后鸿蒙上第一次 ws 是否恢复正常:
|
|
1560
|
+
// · 恢复正常 → 这个现象是"压"出来的(ArkWeb 的握手等不了这么久),
|
|
1561
|
+
// 那就把它改成"只在确认客户端还没热身时才压,且压的时长要短";
|
|
1562
|
+
// · 照旧坏 → 与本机制无关,恢复成 8000,改走别的路。
|
|
1563
|
+
const MUX_WARMUP_WINDOW_MS = 0
|
|
1564
|
+
/** 压住的上限;超时就照常放行,绝不把连接掐死。 */
|
|
1565
|
+
const MUX_WARMUP_MAX_MS = 15000
|
|
1566
|
+
|
|
1567
|
+
// ── 定点规避的参数(2026-10-05)────────────────────────────────
|
|
1568
|
+
//
|
|
1569
|
+
// 鸿蒙 ArkWeb 上,DSH 客户端有时不会发出 `$events` 那条逻辑流 ——
|
|
1570
|
+
// 它是唯一能把连接标记为"已建立"的流。此时客户端只发一条
|
|
1571
|
+
// workspace/follow,上行字节数停在 120 左右,之后只剩每 3 秒的 Pong;
|
|
1572
|
+
// 界面就一直"连接异常"、也没有会话。而**手动点一次"重连"之后一定能建立**
|
|
1573
|
+
//(手机端多次实测,日志里有完整帧序列)。
|
|
1574
|
+
//
|
|
1575
|
+
// 所以这里替用户点那一下:mux 建立后等 MUX_HEALTH_CHECK_MS,
|
|
1576
|
+
// 若客户端上行仍低于 MUX_HEALTH_MIN_BYTES(断言它没开出逻辑流),
|
|
1577
|
+
// 就主动断开这条 socket,让客户端走它自己那条已验证有效的重连路径。
|
|
1578
|
+
//
|
|
1579
|
+
// 阈值选 300 字节的理由:正常客户端在头一秒内就会发出多条 open
|
|
1580
|
+
//($events 108 + session/control 116 + workspace/follow 117 + session/follow 300 …),
|
|
1581
|
+
// 实测远超此值;出问题时只有 ~120 字节。
|
|
1582
|
+
// ⚠️ 已停用(2026-10-05 收工):置 0 = 不做这个健康检查。
|
|
1583
|
+
//
|
|
1584
|
+
// 停用原因:判据(6 秒内上行 < 300 字节就断开)**有可能误伤健康客户端** ——
|
|
1585
|
+
// 安卓正常时头几秒也可能只发出 workspace/follow(117) + $events(108) ≈ 225 字节,
|
|
1586
|
+
// 还没轮到 session/control。而它本身的效果**从未验证过**(收工前最后一轮没跑完)。
|
|
1587
|
+
// 用一个未验证、且可能掐断好连接的机制去换一个未验证的收益,不划算。
|
|
1588
|
+
// 要重新启用:把 MUX_HEALTH_CHECK_MS 设回 6000,并先按实测把阈值调准。
|
|
1589
|
+
const MUX_HEALTH_CHECK_MS = 0
|
|
1590
|
+
const MUX_HEALTH_MIN_BYTES = 300
|
|
1591
|
+
|
|
1592
|
+
function proxyUpgrade(req, socket, head, url) {
|
|
1593
|
+
const upstreamPort = port_()
|
|
1594
|
+
if (!Number.isInteger(upstreamPort)) {
|
|
1595
|
+
socket.destroy()
|
|
1596
|
+
return
|
|
1597
|
+
}
|
|
1598
|
+
if (!signer.verify(parseCookies(req.headers.cookie)[GATEWAY_COOKIE])) {
|
|
1599
|
+
logger.warn?.(`[ws] 401 ${url.pathname} ← 升级请求没带网关 cookie`)
|
|
1600
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n')
|
|
1601
|
+
socket.destroy()
|
|
1602
|
+
return
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// ── 等零件装好再连接(2026-10-05 修)────────────────────────────
|
|
1606
|
+
//
|
|
1607
|
+
// 依据(实测日志):第一次加载时客户端在 mux 上**只发了 workspace/follow**,
|
|
1608
|
+
// `$events` 根本没发。源码里两条路不同步:
|
|
1609
|
+
// · mux:插件构造时就 this.streams.start() → 早
|
|
1610
|
+
// · $events:必须等 ctx.get('loader').await() 之后 start() → 晚
|
|
1611
|
+
// 于是第一次连接总是缺 `$events`,客户端自己判 connection lost。
|
|
1612
|
+
//
|
|
1613
|
+
// 网关看不见 loader,所以用"页面启动窗口"近似:页面刚发出去这段时间内的
|
|
1614
|
+
// 第一次 mux 升级先压住,等窗口过去再放行。窗口之外(真正的重连)不拦。
|
|
1615
|
+
// 上限 MUX_WARMUP_MAX_MS,超时照常放行,绝不把连接掐死。
|
|
1616
|
+
const sincePageServed = lastIndexServedAt === 0 ? -1 : Date.now() - lastIndexServedAt
|
|
1617
|
+
if (url.pathname === '/api/remote.mux'
|
|
1618
|
+
&& sincePageServed >= 0
|
|
1619
|
+
&& sincePageServed < MUX_WARMUP_WINDOW_MS) {
|
|
1620
|
+
if (socket.__muxWaitStart === undefined) socket.__muxWaitStart = Date.now()
|
|
1621
|
+
const waitedMs = Date.now() - socket.__muxWaitStart
|
|
1622
|
+
if (waitedMs < MUX_WARMUP_MAX_MS && !socket.destroyed) {
|
|
1623
|
+
if (!socket.__muxWaitLogged) {
|
|
1624
|
+
socket.__muxWaitLogged = true
|
|
1625
|
+
logger.info?.(
|
|
1626
|
+
`[ws] 页面才发出去 ${sincePageServed}ms,零件还没装好 —— 先压住这次 mux 升级`,
|
|
1627
|
+
)
|
|
1628
|
+
}
|
|
1629
|
+
setTimeout(() => proxyUpgrade(req, socket, head, url), 120)
|
|
1630
|
+
return
|
|
1631
|
+
}
|
|
1632
|
+
logger.warn?.('[ws] 等启动窗口超时,照常放行 mux 升级')
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// ── 升级路径的日志(2026-10-02 加)────────────────────────────
|
|
1636
|
+
// 为什么单独加:DSH 的实时通道走 WebSocket 升级,而普通 HTTP 的日志
|
|
1637
|
+
// 覆盖不到它。上一轮"页面能打开、但 DSH 自己报连接失败"就是因为这条
|
|
1638
|
+
// 路径**完全没有日志**,查不到任何证据。只记路径和结果,不记 header
|
|
1639
|
+
// (header 里有 cookie)。
|
|
1640
|
+
const upgradePath = url.pathname
|
|
1641
|
+
logger.info?.(`[ws] 升级请求 ${req.method} ${upgradePath}`)
|
|
1642
|
+
|
|
1643
|
+
const headers = upstreamHeaders(req.headers, upstreamPort)
|
|
1644
|
+
headers.connection = 'Upgrade'
|
|
1645
|
+
if (typeof req.headers.upgrade === 'string') headers.upgrade = req.headers.upgrade
|
|
1646
|
+
|
|
1647
|
+
// 诊断(2026-10-05):**去掉 WebSocket 扩展协商**,即不让它用
|
|
1648
|
+
// permessage-deflate 压缩。原因:压缩后通道里流的是二进制,首帧采样
|
|
1649
|
+
// 只能看到乱码,没法判断那 7KB 到底是什么。去掉之后帧内容是明文,
|
|
1650
|
+
// 一眼可读,也顺带排除"客户端解压环节出问题"这一类可能。
|
|
1651
|
+
// 副作用可忽略:压缩只省流量,而这里是局域网。
|
|
1652
|
+
delete headers['sec-websocket-extensions']
|
|
1653
|
+
|
|
1654
|
+
const upstream = http.request({
|
|
1655
|
+
host: LOOPBACK,
|
|
1656
|
+
port: upstreamPort,
|
|
1657
|
+
method: req.method,
|
|
1658
|
+
path: url.pathname + url.search,
|
|
1659
|
+
headers,
|
|
1660
|
+
})
|
|
1661
|
+
|
|
1662
|
+
// ── 把客户端"预发"的首帧转给上游(2026-10-05 修)────────────────
|
|
1663
|
+
// `head` 是客户端在**收到 101 之前**就跟着升级请求一起发出来的字节。
|
|
1664
|
+
// 早先这里完全没处理它 —— 我当时看了一眼,心想"WS 升级时 head 通常是空的"
|
|
1665
|
+
// 就放过了,这就是那个 bug。
|
|
1666
|
+
//
|
|
1667
|
+
// 症状与它是怎么对上的:DSH 的客户端在插件激活时立即连接 mux,并且
|
|
1668
|
+
// 把 $events 的订阅帧抢在握手之前发出去。head 被丢掉 → Host 收不到订阅
|
|
1669
|
+
// → 永远不回 `ready` 帧 → ConnectionController 永不发布 connected
|
|
1670
|
+
// → UI 显示"连接异常";而手动重连时客户端已经热身,订阅帧在握手之后才发,
|
|
1671
|
+
// 所以就好了。安卓上时序不同,没抢发,所以从来没触发。
|
|
1672
|
+
upstream.on('finish', () => {
|
|
1673
|
+
if (head && head.length > 0) {
|
|
1674
|
+
logger.info?.(`[ws] 转发客户端预发首帧 ${head.length} 字节 → 上游`)
|
|
1675
|
+
upstream.socket.write(head)
|
|
1676
|
+
}
|
|
1677
|
+
})
|
|
1678
|
+
|
|
1679
|
+
let settled = false
|
|
1680
|
+
upstream.on('upgrade', (upRes, upSocket, upHead) => {
|
|
1681
|
+
settled = true
|
|
1682
|
+
const openedAt = Date.now()
|
|
1683
|
+
logger.info?.(`[ws] ${upRes.statusCode} ${upgradePath} ← 上游同意升级,通道建立`)
|
|
1684
|
+
const lines = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage ?? 'Switching Protocols'}`]
|
|
1685
|
+
const raw = upRes.rawHeaders
|
|
1686
|
+
for (let i = 0; i < raw.length; i += 2) lines.push(`${raw[i]}: ${raw[i + 1]}`)
|
|
1687
|
+
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
1688
|
+
if (upHead?.length) socket.write(upHead)
|
|
1689
|
+
|
|
1690
|
+
upgraded.add(socket)
|
|
1691
|
+
upgraded.add(upSocket)
|
|
1692
|
+
|
|
1693
|
+
// ── 通道生命周期日志(2026-10-02 加)──────────────────────────
|
|
1694
|
+
// 为什么需要:升级**成功**(101)不等于通道能用。DSH 的实时通道可能
|
|
1695
|
+
// 建立之后立刻断开 —— 而"页面能加载、UI 却报连接异常"恰好长这样。
|
|
1696
|
+
// 记存活时长和双向字节数就能一眼区分:
|
|
1697
|
+
// · 开了几十毫秒就关、字节数几乎为 0 → 协议/认证层被拒
|
|
1698
|
+
// · 活了很久、字节数在涨 → 通道是好的,问题在别处
|
|
1699
|
+
// 注意:只加计数器,不消费数据 —— pipe 照常负责搬运。
|
|
1700
|
+
let toUpstream = 0
|
|
1701
|
+
let toClient = 0
|
|
1702
|
+
let sampledFirstFrame = false
|
|
1703
|
+
socket.on('data', (chunk) => { toUpstream += chunk.length })
|
|
1704
|
+
upSocket.on('data', (chunk) => {
|
|
1705
|
+
toClient += chunk.length
|
|
1706
|
+
// 只采样**第一帧**、最多 300 字节,而且只采一次。
|
|
1707
|
+
// 为什么:通道建立后 DSH 会先推一坨东西(实测约 7KB)——
|
|
1708
|
+
// 如果那是一帧错误,答案就在开头。只采一次是为了不把会话内容
|
|
1709
|
+
// 源源不断地写进日志。
|
|
1710
|
+
if (!sampledFirstFrame) {
|
|
1711
|
+
sampledFirstFrame = true
|
|
1712
|
+
const sample = chunk.slice(0, 300).toString('utf8').replace(/[\r\n\t]+/g, ' ')
|
|
1713
|
+
logger.warn?.(`[ws] 下行首帧样本(前 300 字节): ${sample}`)
|
|
1714
|
+
}
|
|
1715
|
+
})
|
|
1716
|
+
|
|
1717
|
+
// 周期性活动日志(2026-10-05 加)—— **这一条才是关键**。
|
|
1718
|
+
// 下面那条只在 close 时打;而我们现在遇到的恰恰是"通道一直开着",
|
|
1719
|
+
// 于是那条永远不触发、等于没有数据。每 3 秒记一次就能直接看出:
|
|
1720
|
+
// · 字节数一直是 0 → 通道建起来了但没人说话
|
|
1721
|
+
// · 字节数在涨 → 通道正常收发,问题在 UI 侧
|
|
1722
|
+
let ticker = null
|
|
1723
|
+
|
|
1724
|
+
const reportClose = (why) => {
|
|
1725
|
+
if (ticker !== null) clearInterval(ticker)
|
|
1726
|
+
logger.warn?.(
|
|
1727
|
+
`[ws] 关闭 ${upgradePath}(${why})存活 ${Date.now() - openedAt}ms,` +
|
|
1728
|
+
`客户端→上游 ${toUpstream} 字节 / 上游→客户端 ${toClient} 字节`,
|
|
1729
|
+
)
|
|
1730
|
+
}
|
|
1731
|
+
socket.once('close', () => reportClose('客户端关闭'))
|
|
1732
|
+
upSocket.once('close', () => reportClose('上游关闭'))
|
|
1733
|
+
|
|
1734
|
+
const drop = () => {
|
|
1735
|
+
upgraded.delete(socket)
|
|
1736
|
+
upgraded.delete(upSocket)
|
|
1737
|
+
upSocket.destroy()
|
|
1738
|
+
socket.destroy()
|
|
1739
|
+
}
|
|
1740
|
+
socket.on('close', () => upgraded.delete(socket))
|
|
1741
|
+
upSocket.on('close', () => upgraded.delete(upSocket))
|
|
1742
|
+
socket.on('error', drop)
|
|
1743
|
+
upSocket.on('error', drop)
|
|
1744
|
+
|
|
1745
|
+
ticker = setInterval(() => {
|
|
1746
|
+
logger.info?.(
|
|
1747
|
+
`[ws] 活动 ${upgradePath} 存活 ${Math.round((Date.now() - openedAt) / 1000)}s,` +
|
|
1748
|
+
`客户端→上游 ${toUpstream} 字节 / 上游→客户端 ${toClient} 字节`,
|
|
1749
|
+
)
|
|
1750
|
+
}, 3000)
|
|
1751
|
+
ticker.unref?.()
|
|
1752
|
+
|
|
1753
|
+
// 定点规避已停用(MUX_HEALTH_CHECK_MS = 0),见常量处的说明。
|
|
1754
|
+
if (upgradePath === '/api/remote.mux' && MUX_HEALTH_CHECK_MS > 0) {
|
|
1755
|
+
const healthTimer = setTimeout(() => {
|
|
1756
|
+
if (socket.destroyed || socket.writableEnded) return
|
|
1757
|
+
if (toUpstream >= MUX_HEALTH_MIN_BYTES) return
|
|
1758
|
+
logger.warn?.(
|
|
1759
|
+
`[ws] 规避:mux 建立 ${MUX_HEALTH_CHECK_MS}ms 后客户端只发了 ${toUpstream} 字节`,
|
|
1760
|
+
)
|
|
1761
|
+
logger.warn?.('[ws] 规避:判定它没开出连接流 → 主动断开,让它自己重连一次')
|
|
1762
|
+
socket.end()
|
|
1763
|
+
}, MUX_HEALTH_CHECK_MS)
|
|
1764
|
+
healthTimer.unref?.()
|
|
1765
|
+
socket.once('close', () => clearTimeout(healthTimer))
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
upSocket.pipe(socket)
|
|
1769
|
+
socket.pipe(upSocket)
|
|
1770
|
+
})
|
|
1771
|
+
|
|
1772
|
+
// 上游没同意升级(比如它自己回了 401/403)→ 原样告诉客户端并断开。
|
|
1773
|
+
upstream.on('response', (upRes) => {
|
|
1774
|
+
if (settled) return
|
|
1775
|
+
settled = true
|
|
1776
|
+
logger.warn?.(`[ws] ${upRes.statusCode} ${upgradePath} ← 上游【拒绝】升级`)
|
|
1777
|
+
const lines = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage ?? ''}`]
|
|
1778
|
+
for (const [name, value] of Object.entries(downstreamHeaders(upRes.headers, upstreamPort))) {
|
|
1779
|
+
if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`)
|
|
1780
|
+
else lines.push(`${name}: ${value}`)
|
|
1781
|
+
}
|
|
1782
|
+
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
1783
|
+
socket.destroy()
|
|
1784
|
+
upRes.resume()
|
|
1785
|
+
})
|
|
1786
|
+
|
|
1787
|
+
upstream.on('error', () => {
|
|
1788
|
+
if (!settled) socket.destroy()
|
|
1789
|
+
})
|
|
1790
|
+
socket.on('error', () => upstream.destroy())
|
|
1791
|
+
|
|
1792
|
+
if (head?.length) upstream.write(head)
|
|
1793
|
+
upstream.end()
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// ────────────────────────────────────────────────────────────
|
|
1797
|
+
// HTTP 入口
|
|
1798
|
+
// ────────────────────────────────────────────────────────────
|
|
1799
|
+
const server = http.createServer((req, res) => {
|
|
1800
|
+
let url
|
|
1801
|
+
try {
|
|
1802
|
+
url = new URL(req.url ?? '/', 'http://gateway')
|
|
1803
|
+
} catch {
|
|
1804
|
+
sendJson(res, 400, { error: 'gateway: bad request url' })
|
|
1805
|
+
return
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
const done = (fn) => {
|
|
1809
|
+
try {
|
|
1810
|
+
fn()
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
logger.warn?.(`dsh-remote-gateway: ${error instanceof Error ? error.message : String(error)}`)
|
|
1813
|
+
if (!res.headersSent) sendJson(res, 500, { error: 'gateway: internal error' })
|
|
1814
|
+
else res.destroy()
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// ---- 网关自己的端点 ----
|
|
1819
|
+
// 密码换 cookie 的专用入口:客户端(App / curl)带 X-Gateway-Password 请求头
|
|
1820
|
+
// 打这里,拿一张 HttpOnly 的网关 cookie,之后交给 WebView。
|
|
1821
|
+
//
|
|
1822
|
+
// 为什么需要它:WebSocket 握手没法自定义请求头,而 App 在 WebView 的**初次导航**
|
|
1823
|
+
// 上加头又不一定被平台支持。走这个端点就与平台无关了。
|
|
1824
|
+
if (url.pathname === `${PREFIX}/session`) {
|
|
1825
|
+
done(() => {
|
|
1826
|
+
const given = suppliedPassword(req)
|
|
1827
|
+
if (given === undefined || !sameSecret(given, password)) {
|
|
1828
|
+
// 同样给空正文的 401,不留指纹。App 只读状态码。
|
|
1829
|
+
res.writeHead(401, { 'content-length': '0', 'cache-control': 'no-store' })
|
|
1830
|
+
res.end()
|
|
1831
|
+
return
|
|
1832
|
+
}
|
|
1833
|
+
res.writeHead(204, {
|
|
1834
|
+
'set-cookie': setCookieHeader(signer.issue(), sessionDays * 86400),
|
|
1835
|
+
'cache-control': 'no-store',
|
|
1836
|
+
})
|
|
1837
|
+
res.end()
|
|
1838
|
+
// 记一条接入记录。平台由客户端自己声明(App 会带 x-client-platform);
|
|
1839
|
+
// 拿不到就留空 —— 不要去猜(Dart 的 HttpClient 默认 UA 既不是安卓也不是鸿蒙)。
|
|
1840
|
+
try {
|
|
1841
|
+
onSession?.({
|
|
1842
|
+
address: req.socket?.remoteAddress ?? '',
|
|
1843
|
+
platform: String(req.headers['x-client-platform'] ?? '').toLowerCase(),
|
|
1844
|
+
userAgent: req.headers['user-agent'] ?? '',
|
|
1845
|
+
})
|
|
1846
|
+
} catch {
|
|
1847
|
+
/* 记历史失败绝不能影响这次认证 */
|
|
1848
|
+
}
|
|
1849
|
+
})
|
|
1850
|
+
return
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (url.pathname === `${PREFIX}/health`) {
|
|
1854
|
+
done(() => sendJson(res, 200, { ok: true, gateway: 'dsh-remote-gateway' }))
|
|
1855
|
+
return
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
if (url.pathname === `${PREFIX}/logout`) {
|
|
1859
|
+
// 清掉网关会话。返回 204,**不重定向到任何页面** ——
|
|
1860
|
+
// 这个端口不给浏览器显示东西(见文件上方那段说明)。
|
|
1861
|
+
done(() =>
|
|
1862
|
+
res
|
|
1863
|
+
.writeHead(204, {
|
|
1864
|
+
'set-cookie': setCookieHeader('', 0),
|
|
1865
|
+
'cache-control': 'no-store',
|
|
1866
|
+
})
|
|
1867
|
+
.end(),
|
|
1868
|
+
)
|
|
1869
|
+
return
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// ── 页面错误回传端点(垫片用,2026-10-05 加)────────────────────
|
|
1873
|
+
// 见 PAGE_LOG_SHIM 的说明:鸿蒙上看不到 WebView 内部报错,
|
|
1874
|
+
// 只能让页面主动发回来。要求带网关 cookie,避免变成任何人都能写的日志池。
|
|
1875
|
+
if (url.pathname === `${PREFIX}/log` && req.method === 'POST') {
|
|
1876
|
+
if (!signer.verify(parseCookies(req.headers.cookie)[GATEWAY_COOKIE])) {
|
|
1877
|
+
res.writeHead(401, { 'content-length': '0' }).end()
|
|
1878
|
+
return
|
|
1879
|
+
}
|
|
1880
|
+
const chunks = []
|
|
1881
|
+
let size = 0
|
|
1882
|
+
req.on('data', (chunk) => {
|
|
1883
|
+
size += chunk.length
|
|
1884
|
+
if (size <= 8192) chunks.push(chunk)
|
|
1885
|
+
})
|
|
1886
|
+
req.on('end', () => {
|
|
1887
|
+
const text = Buffer.concat(chunks).toString('utf8').replace(/[\r\n]+/g, ' ')
|
|
1888
|
+
logger.warn?.(`[page] ${text}`)
|
|
1889
|
+
done(() => res.writeHead(204, { 'content-length': '0' }).end())
|
|
1890
|
+
})
|
|
1891
|
+
return
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
if (url.pathname.startsWith(`${PREFIX}/`)) {
|
|
1895
|
+
done(() => sendJson(res, 404, { error: 'gateway: no such endpoint' }))
|
|
1896
|
+
return
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// ---- 鉴权闸门 ----
|
|
1900
|
+
done(() => {
|
|
1901
|
+
let authed = signer.verify(parseCookies(req.headers.cookie)[GATEWAY_COOKIE])
|
|
1902
|
+
|
|
1903
|
+
// 客户端的第一次请求可以直接带密码(Flutter 原生输入框用得上):
|
|
1904
|
+
// 校验通过就顺手发一个会话 cookie,后续请求(资源、SSE、WebSocket)才带得上。
|
|
1905
|
+
if (!authed) {
|
|
1906
|
+
const given = suppliedPassword(req)
|
|
1907
|
+
if (given !== undefined && sameSecret(given, password)) {
|
|
1908
|
+
authed = true
|
|
1909
|
+
res.setHeader('set-cookie', setCookieHeader(signer.issue(), sessionDays * 86400))
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
if (!authed) {
|
|
1914
|
+
// 无凭据 = **看起来这个路径根本不存在**。
|
|
1915
|
+
//
|
|
1916
|
+
// 【用户要求】这里返回 **404**,不是 401 ——
|
|
1917
|
+
// 401 本身就在说"这儿有个需要认证的东西",那也是一条指纹;
|
|
1918
|
+
// 404 则和"随便哪个 HTTP 服务上不存在的路径"长得一模一样。
|
|
1919
|
+
// 正文同样为空。
|
|
1920
|
+
//
|
|
1921
|
+
// 注意:`/__gateway/session` 那处**仍然返回 401** —— App 靠它区分
|
|
1922
|
+
// "密码不对"。那条路径要猜(`__gateway/session`),不构成入口指纹。
|
|
1923
|
+
//
|
|
1924
|
+
// 【用户原则】降低可发现性是有效的防御层次,但它**买的是时间、不是边界**:
|
|
1925
|
+
// 一旦被找到这层就归零,所以密码强度与传输加密(TLS)仍然必须到位。
|
|
1926
|
+
res.writeHead(404, { 'content-length': '0', 'cache-control': 'no-store' })
|
|
1927
|
+
res.end()
|
|
1928
|
+
return
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
proxyHttp(req, res, url)
|
|
1932
|
+
})
|
|
1933
|
+
})
|
|
1934
|
+
|
|
1935
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1936
|
+
let url
|
|
1937
|
+
try {
|
|
1938
|
+
url = new URL(req.url ?? '/', 'http://gateway')
|
|
1939
|
+
} catch {
|
|
1940
|
+
socket.destroy()
|
|
1941
|
+
return
|
|
1942
|
+
}
|
|
1943
|
+
proxyUpgrade(req, socket, head, url)
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
return {
|
|
1947
|
+
server,
|
|
1948
|
+
get port() {
|
|
1949
|
+
return server.address()?.port
|
|
1950
|
+
},
|
|
1951
|
+
/** 开始监听;listen 失败会 reject(端口占用等)。 */
|
|
1952
|
+
listen() {
|
|
1953
|
+
return new Promise((resolve, reject) => {
|
|
1954
|
+
const onError = (error) => reject(error)
|
|
1955
|
+
server.once('error', onError)
|
|
1956
|
+
server.listen(port, host, () => {
|
|
1957
|
+
server.off('error', onError)
|
|
1958
|
+
server.on('error', (error) => logger.error?.(error))
|
|
1959
|
+
resolve(server.address()?.port)
|
|
1960
|
+
})
|
|
1961
|
+
})
|
|
1962
|
+
},
|
|
1963
|
+
/** 关服务:先停新连接,再销毁所有已升级的长连接。 */
|
|
1964
|
+
async close() {
|
|
1965
|
+
const closed = new Promise((resolve) => server.close(() => resolve()))
|
|
1966
|
+
server.closeAllConnections?.()
|
|
1967
|
+
for (const socket of upgraded) socket.destroy()
|
|
1968
|
+
upgraded.clear()
|
|
1969
|
+
await closed
|
|
1970
|
+
},
|
|
1971
|
+
}
|
|
1972
|
+
}
|