dsh-mobile-plugin 0.4.9 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/icon.png +0 -0
- package/lib/client.js +80 -1
- package/lib/index.js +9 -3
- package/lib/mobile/history-derive.js +86 -2
- package/lib/mobile/host-settings.js +103 -6
- package/lib/mobile/live-steps.js +356 -0
- package/lib/mobile/model-prices-table.cjs +23 -0
- package/lib/mobile/model-prices.cjs +88 -0
- package/lib/mobile/session-cache.js +17 -1
- package/lib/mobile/session-step.js +226 -0
- package/lib/mobile/stats-collect.cjs +40 -0
- package/lib/mobile/stats-ledger.cjs +45 -0
- package/lib/mobile/step-index.js +108 -0
- package/lib/mobile/stream.js +29 -0
- package/lib/panel/index.html +11 -11
- package/lib/relay/connection-check.js +115 -0
- package/lib/relay/firewall-probe.js +300 -0
- package/lib/relay/preview.js +147 -24
- package/lib/routes/control-plane.js +26 -0
- package/lib/routes/mobile-api.js +3 -0
- package/lib/shell/half-route.js +73 -0
- package/lib/shell/half-view.js +37 -0
- package/lib/shell/runtime.js +117 -21
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +5 -1
package/lib/relay/preview.js
CHANGED
|
@@ -3,21 +3,29 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 预览字节管道(宿主侧)。
|
|
5
5
|
*
|
|
6
|
-
* 手机 App 的内置浏览器要看到桌面机上「开发中的网页」,而那些 dev server
|
|
7
|
-
*
|
|
6
|
+
* 手机 App 的内置浏览器要看到桌面机上「开发中的网页」,而那些 dev server 只在某一块网卡的
|
|
7
|
+
* 某个端口上,手机、中转、局域网代理都够不着。所以链路上有一段跑在宿主进程里的搬运工。
|
|
8
8
|
*
|
|
9
|
-
* 是字节管道而不是 HTTP 代理:一个页面是几百次请求加一条活着的 HMR WebSocket
|
|
9
|
+
* 是字节管道而不是 HTTP 代理:一个页面是几百次请求加一条活着的 HMR WebSocket,目标在建立
|
|
10
10
|
* 管道时协商一次,之后完全不解析内容。于是内置浏览器的地址栏与实际 Host 都是
|
|
11
11
|
* `localhost:3001`,dev server 生成的绝对地址自动闭环、零配置;做路径改写反而会立刻全 404。
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* 目标(`host` / `scheme` / `port`)由手机从查询串带过来,解析与拨号都发生在电脑这一侧:
|
|
14
|
+
* 手机永远只跟自己的 localhost 说明文,证书、ATS、明文限制那套就彻底与它无关了。https 的
|
|
15
|
+
* TLS 也在这里发起——目标是本机或私网,自签证书是本地开发的常态。
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* 环回目标要双栈试(macOS 上不少 dev server 只监听 `::1`);别的目标只拨那一个。
|
|
18
|
+
* WS 升级、SSE、分块传输原样穿过。
|
|
19
|
+
*
|
|
20
|
+
* 代价:宿主无法审查内容,边界只有「端口」与「目标主机」两道。前者由 `protectedPorts()` 按
|
|
21
|
+
* 身份拒绝——DSH 自身的 web 端口永远不许从预览进来;后者由 `isAllowedPreviewHost` 收到本机、
|
|
22
|
+
* 私网与保留域名——放开公网,这条管道就等于一台任意目标的连接中转,移动 API 的能力面就扩了。
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
const net = require('node:net');
|
|
26
|
+
const tls = require('node:tls');
|
|
20
27
|
const { WebSocketServer } = require('ws');
|
|
28
|
+
const { ipv4Number, ipv6Number } = require('./lan-network.cjs');
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* 同一个宿主上同时在跑的预览连接上限。
|
|
@@ -39,7 +47,7 @@ const SEND_RESUME_POLL_MS = 20;
|
|
|
39
47
|
const UPSTREAM_CONNECT_TIMEOUT_MS = 10_000;
|
|
40
48
|
|
|
41
49
|
/**
|
|
42
|
-
* WS
|
|
50
|
+
* WS 握手已成、但上游还没连上的那几百微秒里,手机已经发出来的字节
|
|
43
51
|
* 最多攒这么多。浏览器不会等我们连好上游才发请求,所以这段字节必须存住;攒过头说明
|
|
44
52
|
* 上游根本连不上,这时宁可断掉这条管道,也不能无限吃内存。
|
|
45
53
|
*/
|
|
@@ -68,6 +76,89 @@ function parsePreviewPort(req) {
|
|
|
68
76
|
return port;
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
/** 环回类主机:这些写法都指「电脑自己」,拨号要走双栈(见 `previewDialTargets`)。 */
|
|
80
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '::']);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 能从预览拨过去的 IPv4 段。
|
|
84
|
+
*
|
|
85
|
+
* 比 `lan-network.cjs` 的「局域网候选」多两段:100.64/10(运营商级 NAT,VPN 与容器网段常用)
|
|
86
|
+
* 与 169.254/16(链路本地)——它们不该当直连候选,但都是开发服务真的会绑上去的地址。
|
|
87
|
+
*/
|
|
88
|
+
const ALLOWED_IPV4_RANGES = [
|
|
89
|
+
[0x0a000000, 0x0affffff], // 10/8
|
|
90
|
+
[0xac100000, 0xac1fffff], // 172.16/12
|
|
91
|
+
[0xc0a80000, 0xc0a8ffff], // 192.168/16
|
|
92
|
+
[0x64400000, 0x647fffff], // 100.64/10
|
|
93
|
+
[0xa9fe0000, 0xa9feffff], // 169.254/16
|
|
94
|
+
[0x7f000000, 0x7fffffff], // 127/8 回环
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 只有内网 DNS / mDNS 才解析得了的保留后缀。
|
|
99
|
+
*
|
|
100
|
+
* `.test`、`.localhost` 是 RFC 留给本机测试的,`.local` 是 mDNS,`.internal` / `.lan` /
|
|
101
|
+
* `.home.arpa` 是各家路由器与公司内网的惯例。公网域名一律不放,哪怕它此刻解析到私网地址
|
|
102
|
+
* ——解析结果随时会变,白名单必须是静态可判的。
|
|
103
|
+
*/
|
|
104
|
+
const RESERVED_HOST_SUFFIXES = ['.localhost', '.test', '.local', '.internal', '.lan', '.home.arpa'];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 这个主机能不能从预览拨过去(能力边界,和 App 侧「哪条地址该进内置浏览器」是同一条线)。
|
|
108
|
+
*
|
|
109
|
+
* @param {string} host 已经归一化(小写、无方括号)的主机
|
|
110
|
+
*/
|
|
111
|
+
function isAllowedPreviewHost(host) {
|
|
112
|
+
if (LOOPBACK_HOSTS.has(host)) return true;
|
|
113
|
+
const v4 = ipv4Number(host);
|
|
114
|
+
if (v4 !== null) return ALLOWED_IPV4_RANGES.some(([start, end]) => v4 >= start && v4 <= end);
|
|
115
|
+
const v6 = ipv6Number(host);
|
|
116
|
+
if (v6 !== null) {
|
|
117
|
+
// fc00::/7 是唯一本地地址(内网自己编的前缀),fe80::/10 只在本链路内通。
|
|
118
|
+
return v6 >> 121n === 0x7en || v6 >> 118n === 0x3fan;
|
|
119
|
+
}
|
|
120
|
+
return RESERVED_HOST_SUFFIXES.some((suffix) => host.length > suffix.length && host.endsWith(suffix));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 依次要拨的地址。
|
|
125
|
+
*
|
|
126
|
+
* 环回一律**双栈**:macOS 上 Vite 之类的 dev server 默认只监听 `::1`,只连 `127.0.0.1`
|
|
127
|
+
* 必然是 ECONNREFUSED,用户看到的就是「电脑上这个端口没有服务」。两个地址都是电脑自己,
|
|
128
|
+
* 谁在听就用谁。别的目标只拨它自己那一个——换地址重试就是把「连不上」变成「连上了另一台
|
|
129
|
+
* 机器」,那比失败更糟。
|
|
130
|
+
*
|
|
131
|
+
* @returns {string[]} 有顺序的拨号目标,前一个「还没连上就失败」才换下一个
|
|
132
|
+
*/
|
|
133
|
+
function previewDialTargets(host) {
|
|
134
|
+
return LOOPBACK_HOSTS.has(host) ? ['127.0.0.1', '::1'] : [host];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 从升级请求的查询串里取 host 与 scheme。
|
|
139
|
+
*
|
|
140
|
+
* 和 `port` 一样只能靠查询串带(中转的升级分发不看路径,宿主的升级匹配又按 pathname 精确、
|
|
141
|
+
* 忽略 query)。缺省是「电脑自己的环回 + 明文」,也就是内置浏览器最常见的那个形状。
|
|
142
|
+
*
|
|
143
|
+
* @returns {{host: string, scheme: 'http'|'https'}|null} host 为空、scheme 认不出都算非法
|
|
144
|
+
*/
|
|
145
|
+
function parsePreviewTarget(req) {
|
|
146
|
+
let params;
|
|
147
|
+
try {
|
|
148
|
+
params = new URL(req && req.url ? req.url : '/', 'http://preview.invalid').searchParams;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const rawHost = params.get('host');
|
|
153
|
+
// 地址栏那种方括号写法(`[::1]`)会原样带过来,剥掉再判:留着它既不是环回、也拼不出地址。
|
|
154
|
+
const host = (rawHost === null ? 'localhost' : rawHost.trim().toLowerCase()).replace(/^\[|\]$/g, '');
|
|
155
|
+
if (host === '') return null;
|
|
156
|
+
const rawScheme = params.get('scheme');
|
|
157
|
+
const scheme = rawScheme === null || rawScheme === '' ? 'http' : rawScheme.trim().toLowerCase();
|
|
158
|
+
if (scheme !== 'http' && scheme !== 'https') return null;
|
|
159
|
+
return { host, scheme };
|
|
160
|
+
}
|
|
161
|
+
|
|
71
162
|
const HTML_HEAD = '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
|
|
72
163
|
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
73
164
|
+ '<title>预览未建立</title><style>'
|
|
@@ -136,9 +227,11 @@ function createPreviewService({ authorized, protectedPorts, onLog }) {
|
|
|
136
227
|
}
|
|
137
228
|
|
|
138
229
|
/**
|
|
139
|
-
* 一条管道:完成 WebSocket
|
|
230
|
+
* 一条管道:完成 WebSocket 握手,然后把它与目标对搬。
|
|
231
|
+
*
|
|
232
|
+
* @param {{port: number, scheme: 'http'|'https', hosts: string[], servername?: string}} target 拨号计划
|
|
140
233
|
*/
|
|
141
|
-
function openPipe(ws, port) {
|
|
234
|
+
function openPipe(ws, { port, scheme, hosts, servername }) {
|
|
142
235
|
activeStreams += 1;
|
|
143
236
|
noteGrant(port);
|
|
144
237
|
|
|
@@ -197,29 +290,33 @@ function createPreviewService({ authorized, protectedPorts, onLog }) {
|
|
|
197
290
|
ws.on('close', finish);
|
|
198
291
|
ws.on('error', finish);
|
|
199
292
|
|
|
200
|
-
//
|
|
201
|
-
// `127.0.0.1` 必然是 ECONNREFUSED,用户看到的就是「电脑上这个端口没有服务」。
|
|
202
|
-
// 两个地址都是环回,谁在听就用谁;只有在前一个「还没连上就失败」时才换下一个。
|
|
203
|
-
const upstreamHosts = ['127.0.0.1', '::1'];
|
|
293
|
+
// 拨号计划在进门时就定好了(见 `previewDialTargets`):环回两个地址依次试,别的只拨一个。
|
|
204
294
|
let hostIndex = 0;
|
|
205
295
|
let connectTimer = null;
|
|
206
296
|
let lastError = 'ECONNREFUSED';
|
|
207
297
|
|
|
298
|
+
/** 失败页与日志里怎么写「拨的是哪儿」:IPv6 带方括号,https 连协议一起写。 */
|
|
299
|
+
const describeTarget = (host) => {
|
|
300
|
+
const address = host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`;
|
|
301
|
+
return scheme === 'https' ? `https://${address}` : address;
|
|
302
|
+
};
|
|
303
|
+
|
|
208
304
|
const clearConnectTimer = () => {
|
|
209
305
|
if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; }
|
|
210
306
|
};
|
|
211
307
|
|
|
212
|
-
/**
|
|
308
|
+
/** 计划里的地址都连不上:握手已经成了,没法再用 HTTP 状态码,只能借管道送失败页。 */
|
|
213
309
|
const failUpstream = () => {
|
|
214
310
|
log(`preview port ${port}: ${lastError}`);
|
|
215
311
|
// 这条管道本来就是透明的,索性由宿主写一段 502 响应借它送进内置浏览器——比让
|
|
216
|
-
// WebView
|
|
312
|
+
// WebView 显示系统错误页清楚得多。目标要照实写:用户得知道电脑去连的是哪儿。
|
|
217
313
|
if (ws.readyState === ws.OPEN) {
|
|
314
|
+
const unreachable = hosts.map(describeTarget).join(',也连不上 ');
|
|
218
315
|
const body = Buffer.from(renderFailureHtml({
|
|
219
316
|
title: '电脑上这个端口没有服务',
|
|
220
317
|
detail: lastError === 'ECONNREFUSED'
|
|
221
|
-
?
|
|
222
|
-
: `宿主连不上
|
|
318
|
+
? `宿主连不上 ${unreachable}:开发服务可能已经停了。回到 DSH 重新启动它,再刷新这个页面。`
|
|
319
|
+
: `宿主连不上 ${unreachable}(${lastError})。`,
|
|
223
320
|
port,
|
|
224
321
|
}), 'utf8');
|
|
225
322
|
const head = Buffer.from(
|
|
@@ -234,17 +331,23 @@ function createPreviewService({ authorized, protectedPorts, onLog }) {
|
|
|
234
331
|
finish();
|
|
235
332
|
};
|
|
236
333
|
|
|
237
|
-
/**
|
|
334
|
+
/** 换计划里的下一个地址;都试过了就按失败收场。 */
|
|
238
335
|
const advanceHost = () => {
|
|
239
336
|
if (finished) return;
|
|
240
337
|
hostIndex += 1;
|
|
241
|
-
if (hostIndex <
|
|
338
|
+
if (hostIndex < hosts.length) { dialUpstream(); return; }
|
|
242
339
|
failUpstream();
|
|
243
340
|
};
|
|
244
341
|
|
|
245
342
|
function dialUpstream() {
|
|
246
|
-
const host =
|
|
247
|
-
|
|
343
|
+
const host = hosts[hostIndex];
|
|
344
|
+
// https 的 TLS 在这里发起,手机那侧始终只看到自己环回上的明文。`rejectUnauthorized`
|
|
345
|
+
// 必须关掉:目标是本机或私网,自签证书是本地开发的常态。SNI 用手机点名的那个主机
|
|
346
|
+
// (见 `servername`)——浏览器访问 `localhost:5173` 发的就是 `localhost`,哪怕实际
|
|
347
|
+
// 拨过去的是 127.0.0.1。
|
|
348
|
+
const socketToUpstream = scheme === 'https'
|
|
349
|
+
? tls.connect({ host, port, rejectUnauthorized: false, servername })
|
|
350
|
+
: net.connect({ host, port });
|
|
248
351
|
upstream = socketToUpstream;
|
|
249
352
|
|
|
250
353
|
// 握上手之前给一发超时;连上之后这条 TCP 就是长连接(SSE、HMR 都从它过),
|
|
@@ -271,9 +374,10 @@ function createPreviewService({ authorized, protectedPorts, onLog }) {
|
|
|
271
374
|
failUpstream();
|
|
272
375
|
});
|
|
273
376
|
|
|
274
|
-
|
|
377
|
+
// TLS 的「连上了」以握手完成为准:`connect` 在 TLSSocket 上只代表 TCP 通了。
|
|
378
|
+
socketToUpstream.on(scheme === 'https' ? 'secureConnect' : 'connect', () => {
|
|
275
379
|
clearConnectTimer();
|
|
276
|
-
log(`preview port ${port}: 管道已接通(${host})`);
|
|
380
|
+
log(`preview port ${port}: 管道已接通(${describeTarget(host)})`);
|
|
277
381
|
upstreamReady = true;
|
|
278
382
|
|
|
279
383
|
socketToUpstream.on('drain', () => { if (!finished && ws.readyState === ws.OPEN) ws.resume(); });
|
|
@@ -315,10 +419,20 @@ function createPreviewService({ authorized, protectedPorts, onLog }) {
|
|
|
315
419
|
const port = parsePreviewPort(req);
|
|
316
420
|
if (port === null) return refuseUpgrade(socket, 400, 'Bad Request', '缺少或非法的 port 参数');
|
|
317
421
|
|
|
422
|
+
const target = parsePreviewTarget(req);
|
|
423
|
+
if (target === null) return refuseUpgrade(socket, 400, 'Bad Request', '非法的 host 或 scheme 参数');
|
|
424
|
+
|
|
318
425
|
let allowed = false;
|
|
319
426
|
try { allowed = Boolean(authorized(req)); } catch { allowed = false; }
|
|
320
427
|
if (!allowed) return refuseUpgrade(socket, 401, 'Unauthorized', '移动端凭据无效');
|
|
321
428
|
|
|
429
|
+
// 目标白名单与端口同一个道理:这条管道只负责把手机的一条连接接到电脑这一侧,能拨到
|
|
430
|
+
// 哪儿就是它能做成什么。公网目标在这里掐掉(见文件头与 `isAllowedPreviewHost`)。
|
|
431
|
+
if (!isAllowedPreviewHost(target.host)) {
|
|
432
|
+
return refuseUpgrade(socket, 403, 'Forbidden',
|
|
433
|
+
`目标 ${target.host} 不在预览允许的范围内(只允许本机、私网与保留域名)`);
|
|
434
|
+
}
|
|
435
|
+
|
|
322
436
|
// DSH 自身的端口按**身份**拒绝,不是按数字拉黑。3080 是宿主 web 服务的实际端口
|
|
323
437
|
// (`ctx.webServer.port`,可能被配置改过),从预览进来等于把桌面端 GUI/API 搬到了
|
|
324
438
|
// 手机内置浏览器里——那条路刻意不做,所以在这里用同一个真值掐掉。
|
|
@@ -330,7 +444,14 @@ function createPreviewService({ authorized, protectedPorts, onLog }) {
|
|
|
330
444
|
return refuseUpgrade(socket, 503, 'Service Unavailable', '预览连接数已达上限');
|
|
331
445
|
}
|
|
332
446
|
|
|
333
|
-
wss.handleUpgrade(req, socket, head, (ws) => openPipe(ws,
|
|
447
|
+
wss.handleUpgrade(req, socket, head, (ws) => openPipe(ws, {
|
|
448
|
+
port,
|
|
449
|
+
scheme: target.scheme,
|
|
450
|
+
hosts: previewDialTargets(target.host),
|
|
451
|
+
// IP 字面量不给 SNI:Node 直接把 IP 当 servername 判成非法参数,环回双栈掉落出来的
|
|
452
|
+
// 地址也一样——所以这里取的是手机点名的那个主机。
|
|
453
|
+
servername: net.isIP(target.host) ? undefined : target.host,
|
|
454
|
+
}));
|
|
334
455
|
}
|
|
335
456
|
|
|
336
457
|
return {
|
|
@@ -352,6 +473,8 @@ module.exports = {
|
|
|
352
473
|
MAX_PREVIEW_STREAMS,
|
|
353
474
|
MAX_PREVIEW_MESSAGE_BYTES,
|
|
354
475
|
createPreviewService,
|
|
476
|
+
isAllowedPreviewHost,
|
|
355
477
|
parsePreviewPort,
|
|
478
|
+
previewDialTargets,
|
|
356
479
|
renderFailureHtml,
|
|
357
480
|
};
|
|
@@ -33,6 +33,9 @@ const { canListen } = require('../relay/port-allocator.cjs');
|
|
|
33
33
|
// `advertisedCandidates` 同口径。IPv6 那份只**显示**、不进那个选择:它由手机侧实测择优
|
|
34
34
|
// (和公网中转比速度),做成可选中等于骗人(同 A3-40 的刻意设计)。
|
|
35
35
|
const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
|
|
36
|
+
// 「手机连不上时哪一环断了」这份连接自检:本机只读、不改任何东西(口径见
|
|
37
|
+
// connection-check.js 与 firewall-probe.js 的文件头)。
|
|
38
|
+
const { createConnectionCheck } = require('../relay/connection-check.js');
|
|
36
39
|
const desktopView = require('../desktop-view.cjs');
|
|
37
40
|
const readReceipt = require('../read-receipt.cjs');
|
|
38
41
|
// 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
|
|
@@ -275,6 +278,14 @@ function registerControlPlaneRoutes(deps) {
|
|
|
275
278
|
getLanProxyStats,
|
|
276
279
|
});
|
|
277
280
|
const webAccessWindow = createWebAccessWindow();
|
|
281
|
+
/**
|
|
282
|
+
* 连接自检:面板的引导页与「连接状态」卡读它。
|
|
283
|
+
*
|
|
284
|
+
* 三条取值器都是现成的:`status()` 给监听与隧道、`lanCandidates()` 给监听地址清单、
|
|
285
|
+
* `mobileActivity()` 给「手机最近来过没有」。多出来的只有防火墙那一项,它自带缓存
|
|
286
|
+
* (见 `../relay/firewall-probe.js`)—— 面板打开弹窗与后台那一次巡检共用同一份结果。
|
|
287
|
+
*/
|
|
288
|
+
const connectionCheck = createConnectionCheck({ status, lanCandidates, mobileActivity });
|
|
278
289
|
const localOnly = createWebAccessLocalOnly({
|
|
279
290
|
localOnly: baseLocalOnly,
|
|
280
291
|
trustedLocalUi: typeof trustedLocalUi === 'function' ? trustedLocalUi : () => false,
|
|
@@ -704,6 +715,20 @@ function registerControlPlaneRoutes(deps) {
|
|
|
704
715
|
}
|
|
705
716
|
sendJson(res, 200, { ok: true, port, available: await canListen(port, '0.0.0.0'), ours: false, listening, currentPort })
|
|
706
717
|
}) })
|
|
718
|
+
/**
|
|
719
|
+
* 连接自检:`GET /api/relay/selfcheck`(只读)。
|
|
720
|
+
*
|
|
721
|
+
* 回答「手机连不上时,这台电脑这一侧断在哪一环」。载荷与五档结论的口径写在
|
|
722
|
+
* `../relay/connection-check.js` 的文件头;防火墙那几格的口径写在
|
|
723
|
+
* `../relay/firewall-probe.js` 的文件头。
|
|
724
|
+
*
|
|
725
|
+
* **只在有人看的时候跑**(面板弹窗打开、或手机上久没来过时的那一次巡检):
|
|
726
|
+
* 防火墙那条要 fork 进程,跟着面板三秒一轮的状态轮询跑等于每三秒起一个进程。
|
|
727
|
+
*/
|
|
728
|
+
registerRoute({ kind: 'exact', path: '/api/relay/selfcheck', handler: localOnly('GET', async (req, res) => {
|
|
729
|
+
const force = /[?&]force=1\b/.test(String(req?.url || ''))
|
|
730
|
+
sendJson(res, 200, await connectionCheck.run({ force }))
|
|
731
|
+
}) })
|
|
707
732
|
registerRoute({ kind: 'exact', path: '/api/relay/reconnect', handler: localOnly('POST', async (_req, res) => {
|
|
708
733
|
await syncSidecar()
|
|
709
734
|
sendJson(res, 200, status())
|
|
@@ -966,6 +991,7 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
966
991
|
'/api/relay/revoke-all',
|
|
967
992
|
'/api/relay/config',
|
|
968
993
|
'/api/relay/check-port',
|
|
994
|
+
'/api/relay/selfcheck',
|
|
969
995
|
'/api/relay/reconnect',
|
|
970
996
|
'/api/relay/update/status',
|
|
971
997
|
'/api/relay/update/check',
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -17,6 +17,7 @@ const { orderCandidates, withManualCandidate } = require('../relay/lan-network.c
|
|
|
17
17
|
const sessions = require('../mobile/sessions.js');
|
|
18
18
|
const sessionModelOptions = require('../mobile/session-model-options.js');
|
|
19
19
|
const sessionFile = require('../mobile/session-file.js');
|
|
20
|
+
const sessionStep = require('../mobile/session-step.js');
|
|
20
21
|
const fileRead = require('../mobile/file-read.js');
|
|
21
22
|
const stats = require('../mobile/stats.js');
|
|
22
23
|
const agentPresets = require('../mobile/agent-presets.js');
|
|
@@ -225,6 +226,8 @@ function registerMobileRoutes(deps) {
|
|
|
225
226
|
route('/api/mobile/v1/push-token', pushNotifyRoute.createPushTokenHandler({ pushNotify: deps.pushNotify }))
|
|
226
227
|
route('/api/mobile/v1/session', sessions.handleSession)
|
|
227
228
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
229
|
+
// 一步的过程详情:清单只带一行摘要,点开某一步才来这里取参数 / 输出 / diff / 思考全文。
|
|
230
|
+
route('/api/mobile/v1/session/step', sessionStep.handleSessionStep)
|
|
228
231
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
229
232
|
// 会话文件:允许本会话 present 声明的路径及会话工作区内的文件(见 mobile/session-file.js)。
|
|
230
233
|
route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主半边的自检口:`/api/relay/host-half` 与它的 `/reload`。
|
|
3
|
+
*
|
|
4
|
+
* 这两条**必须由外壳注册**,不能放进实现:实现装载失败时整张 `/api/relay/*` 都不在,
|
|
5
|
+
* 而「它为什么没起来」只有外壳答得出来(外壳自己活得好好的是刻意的故障边界)。
|
|
6
|
+
* 现场的样子是设置页一句「插件宿主侧没有加载」,原因只打在宿主 stderr 里、桌面端不落盘,
|
|
7
|
+
* 用户看不见、我们也查不出来 —— 这两条就是那个原因唯一的出口。
|
|
8
|
+
*
|
|
9
|
+
* 放在 `lib/shell/` 而不是 `lib/index.js` 里,是因为外壳的守卫钉着「只该有装载逻辑」。
|
|
10
|
+
* 这里只干两件小事:把 state 拍成响应体(`half-view.js`),以及把「重新装载」转给
|
|
11
|
+
* runtime 那套换代秩序(串行、先停后起、失败回滚)。
|
|
12
|
+
*
|
|
13
|
+
* 两条都走 `localOnly`:拒绝中转/局域网转发来的请求、拒绝跨站、钉住 method。它们能读到的
|
|
14
|
+
* 是这台机器的插件装载状态,不值得让别处看见。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
18
|
+
import { hostHalfView } from './half-view.js'
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url)
|
|
21
|
+
const { localOnly } = require('../http/routing.js')
|
|
22
|
+
const { sendJson } = require('../shared/http-util.js')
|
|
23
|
+
|
|
24
|
+
export const HOST_HALF_PATH = '/api/relay/host-half'
|
|
25
|
+
export const HOST_HALF_RELOAD_PATH = '/api/relay/host-half/reload'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 挂上两条路由,返回清理函数(给 `ctx.effect` 当返回值用)。
|
|
29
|
+
*
|
|
30
|
+
* @param {object} ctx 外壳那份 context(只用它的 `webServer`)。
|
|
31
|
+
* @param {{status: () => object, reload: () => Promise<unknown>}} runtime 代际运行时。
|
|
32
|
+
* @param {{installedVersion?: () => string|null, installedShellHash?: () => string|null,
|
|
33
|
+
* runningShellHash?: string|null}} [deps] 盘上那一份的坐标,只为「要不要重启」这一格。
|
|
34
|
+
*/
|
|
35
|
+
export function installHostHalf(ctx, runtime, deps = {}) {
|
|
36
|
+
const server = ctx?.webServer
|
|
37
|
+
if (typeof server?.register !== 'function') return () => {}
|
|
38
|
+
const installedVersion = typeof deps.installedVersion === 'function' ? deps.installedVersion : () => null
|
|
39
|
+
const installedShellHash = typeof deps.installedShellHash === 'function' ? deps.installedShellHash : () => null
|
|
40
|
+
const runningShellHash = typeof deps.runningShellHash === 'string' ? deps.runningShellHash : null
|
|
41
|
+
const view = () => hostHalfView(runtime.status(), {
|
|
42
|
+
installedVersion: installedVersion(),
|
|
43
|
+
shellChanged: installedShellHash() !== null && installedShellHash() !== runningShellHash,
|
|
44
|
+
})
|
|
45
|
+
const offs = [
|
|
46
|
+
server.register({
|
|
47
|
+
kind: 'exact',
|
|
48
|
+
path: HOST_HALF_PATH,
|
|
49
|
+
handler: localOnly('GET', async (_req, res) => sendJson(res, 200, view())),
|
|
50
|
+
}),
|
|
51
|
+
server.register({
|
|
52
|
+
kind: 'exact',
|
|
53
|
+
path: HOST_HALF_RELOAD_PATH,
|
|
54
|
+
handler: localOnly('POST', async (_req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
await runtime.reload()
|
|
57
|
+
return sendJson(res, 200, view())
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// 装载失败时外壳已经按老规矩回滚过了,这里只把原因与回滚后的状态一起交出去:
|
|
60
|
+
// 面板要能原样显示「还是不行,因为……」。
|
|
61
|
+
return sendJson(res, 200, {
|
|
62
|
+
...view(),
|
|
63
|
+
ok: false,
|
|
64
|
+
error: { code: 'reload-failed', message: String((error && error.message) || error) },
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
}),
|
|
69
|
+
].filter(off => typeof off === 'function')
|
|
70
|
+
return () => {
|
|
71
|
+
for (const off of offs) off()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主半边自检的响应体:把 `runtime.status()` 收成设置页能读的那几格。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个纯函数:拼响应体的地方在外壳里,而外壳不好在测试里跑起来(它一 apply
|
|
5
|
+
* 就真的去装载实现)。这里只做取值与白名单,测试直接喂一份 status 就能钉住形状。
|
|
6
|
+
*
|
|
7
|
+
* **`lastError` 是这一条接口存在的理由**:实现装载失败时外壳仍然活着,于是路由 404、
|
|
8
|
+
* 设置页只显示一句「插件宿主侧没有加载」,而真正的原因(等哪个服务超时、哪一代装不上)
|
|
9
|
+
* 只打在宿主 stderr 里,桌面端不落盘、用户看不见、我们也就查不出来。这条接口把它原样
|
|
10
|
+
* 交给设置页。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} status `runtime.status()` 的快照。
|
|
15
|
+
* @param {{installedVersion?: string|null, shellChanged?: boolean}} [extra]
|
|
16
|
+
* 盘上那一份的坐标(市场装完立刻变,和跑着的对照就看得出要不要重启)。
|
|
17
|
+
*/
|
|
18
|
+
export function hostHalfView(status, extra = {}) {
|
|
19
|
+
const s = status && typeof status === 'object' ? status : {};
|
|
20
|
+
const info = extra && typeof extra === 'object' ? extra : {};
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
half: {
|
|
24
|
+
version: typeof s.version === 'string' ? s.version : null,
|
|
25
|
+
source: typeof s.source === 'string' ? s.source : null,
|
|
26
|
+
previousVersion: typeof s.previousVersion === 'string' ? s.previousVersion : null,
|
|
27
|
+
activatedAt: typeof s.activatedAt === 'string' ? s.activatedAt : null,
|
|
28
|
+
lastError: typeof s.lastError === 'string' ? s.lastError : null,
|
|
29
|
+
/** 启动时那几次没成、现在仍在后台按长间隔重试 —— 面板据此劝人「再等等」而不是重装。 */
|
|
30
|
+
retrying: s.retrying === true,
|
|
31
|
+
retryAttempts: Number.isFinite(s.retryAttempts) ? s.retryAttempts : 0,
|
|
32
|
+
nextRetryAt: typeof s.nextRetryAt === 'string' ? s.nextRetryAt : null,
|
|
33
|
+
installedVersion: typeof info.installedVersion === 'string' ? info.installedVersion : null,
|
|
34
|
+
shellChanged: info.shellChanged === true,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/lib/shell/runtime.js
CHANGED
|
@@ -16,10 +16,26 @@ import { checkGenerationAlive } from './self-check-after-swap.js'
|
|
|
16
16
|
|
|
17
17
|
/** 换代前等在飞请求归零的上限。 */
|
|
18
18
|
const DRAIN_TIMEOUT_MS = 2000
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。
|
|
21
|
+
*
|
|
22
|
+
* 20s 在慢机器上不够:失败的首要原因就是注入的服务(typertGateway / apiProxy)比本插件
|
|
23
|
+
* 晚就位,而 Windows 冷启动、装了一堆插件的 DSH 要几十秒才轮到那条服务。真实现场是
|
|
24
|
+
* 「重启还是不行」—— 设置页一句 404,日志里什么都没有。
|
|
25
|
+
*/
|
|
26
|
+
const ACTIVATE_TIMEOUT_MS = 60000
|
|
21
27
|
/** 首次装载全失败后的重试间隔与次数(重试理由见 `start()`)。 */
|
|
22
28
|
const START_RETRY_LIMIT = 6
|
|
29
|
+
/**
|
|
30
|
+
* 启动那一轮没成之后,后台继续按这个节奏重试多久。
|
|
31
|
+
*
|
|
32
|
+
* 失败代价是**整张控制面路由表都不在**,而根因多半是「服务晚到」这种会自己好的事。
|
|
33
|
+
* 只重试启动那六次(约五分钟)然后就躺平,等于把一个瞬态问题固定成故障,用户唯一的
|
|
34
|
+
* 出路是重启 —— 而重启正好又落回同一个时间窗里。这里按长间隔再试一小时,并让设置页
|
|
35
|
+
* 看得到「还在试」。
|
|
36
|
+
*/
|
|
37
|
+
const RETRY_BACKLOG_MS = 120000
|
|
38
|
+
const RETRY_BACKLOG_LIMIT = 30
|
|
23
39
|
/** 间隔可用环境变量压低,只给测试(与 `DSH_MOBILE_PROFILE_NODE_MODULES` 同款逃生口)。 */
|
|
24
40
|
const START_RETRY_MS = Number(process.env.DSH_MOBILE_START_RETRY_MS) > 0
|
|
25
41
|
? Number(process.env.DSH_MOBILE_START_RETRY_MS)
|
|
@@ -68,6 +84,15 @@ export function createRuntime() {
|
|
|
68
84
|
* 读到这条已经发生过的事实(`lastApply.rolledBack` 就是从它来的)。
|
|
69
85
|
*/
|
|
70
86
|
let lastRollback = null
|
|
87
|
+
/**
|
|
88
|
+
* 后台补齐重试的账:启动那一轮失败后接着试,试到成功或额度用完。
|
|
89
|
+
*
|
|
90
|
+
* 这三格同时也是说给用户听的:`retrying` 决定面板劝人「再等等」还是「重装」,
|
|
91
|
+
* `nextRetryAt` 让人知道下一次是几分钟后。
|
|
92
|
+
*/
|
|
93
|
+
let retryTimer = null
|
|
94
|
+
let retryAttempts = 0
|
|
95
|
+
let nextRetryAt = null
|
|
71
96
|
|
|
72
97
|
/** 对外状态快照。控制面接口直接把它交给客户端。 */
|
|
73
98
|
function status() {
|
|
@@ -80,6 +105,9 @@ export function createRuntime() {
|
|
|
80
105
|
inFlight,
|
|
81
106
|
swapping: swapping !== null,
|
|
82
107
|
lastRollback,
|
|
108
|
+
retrying: retryTimer !== null,
|
|
109
|
+
retryAttempts,
|
|
110
|
+
nextRetryAt,
|
|
83
111
|
}
|
|
84
112
|
}
|
|
85
113
|
|
|
@@ -261,6 +289,7 @@ export function createRuntime() {
|
|
|
261
289
|
throw error
|
|
262
290
|
}
|
|
263
291
|
}
|
|
292
|
+
clearBacklogRetry()
|
|
264
293
|
return status()
|
|
265
294
|
} catch (error) {
|
|
266
295
|
// 自检那条已经在上面退回过一次了,别退第二遍(第二遍会把刚装回去的旧代再卸一次)。
|
|
@@ -286,37 +315,103 @@ export function createRuntime() {
|
|
|
286
315
|
* (typertGateway / apiProxy)比本插件晚就位、撞上 20s 装载上限,或者第一次装载撞上
|
|
287
316
|
* IO/时序抖动。一次失败就永久躺平到下次重启,等于把一个瞬态问题固定成故障。
|
|
288
317
|
*/
|
|
318
|
+
/**
|
|
319
|
+
* 把当前可用的每一代都试一遍:成功就装成当前代并返回 `{ok:true}`,全失败返回
|
|
320
|
+
* `{ok:false, failures}`。`start()` 与后台补齐重试共用这一份,别各写一遍。
|
|
321
|
+
*
|
|
322
|
+
* 候选**现算**而不是启动时抓一份快照:市场换过代、OTA 落过一版之后,这里要能看见新目录。
|
|
323
|
+
*/
|
|
324
|
+
async function attemptAll() {
|
|
325
|
+
const failures = []
|
|
326
|
+
for (const target of resolveGenerations()) {
|
|
327
|
+
if (detached) throw new Error('插件已经卸载,不再重试装载')
|
|
328
|
+
try {
|
|
329
|
+
const { loaded, fiber: startedFiber } = await activate(target)
|
|
330
|
+
fiber = startedFiber
|
|
331
|
+
loadedCurrent = loaded
|
|
332
|
+
current = describe(loaded, target.dir)
|
|
333
|
+
activatedAt = new Date().toISOString()
|
|
334
|
+
lastError = failures.length
|
|
335
|
+
? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
|
|
336
|
+
: null
|
|
337
|
+
return { ok: true, version: target.version }
|
|
338
|
+
} catch (error) {
|
|
339
|
+
failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { ok: false, failures }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 停掉后台补齐重试(装成功后调用:再试一次只会撞同一批路由)。 */
|
|
346
|
+
function clearBacklogRetry() {
|
|
347
|
+
if (retryTimer !== null) {
|
|
348
|
+
clearTimeout(retryTimer)
|
|
349
|
+
retryTimer = null
|
|
350
|
+
}
|
|
351
|
+
retryAttempts = 0
|
|
352
|
+
nextRetryAt = null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 启动那一轮没成之后接着试:长间隔、有额度,成功即停。
|
|
357
|
+
*
|
|
358
|
+
* 它是 `lastError` 之外的第二次机会,也是「用户什么都不做也能自己好」的那条路。
|
|
359
|
+
*/
|
|
360
|
+
function scheduleBacklogRetry(log) {
|
|
361
|
+
if (detached || retryTimer !== null || retryAttempts >= RETRY_BACKLOG_LIMIT) return
|
|
362
|
+
retryAttempts += 1
|
|
363
|
+
nextRetryAt = new Date(Date.now() + RETRY_BACKLOG_MS).toISOString()
|
|
364
|
+
retryTimer = setTimeout(() => {
|
|
365
|
+
retryTimer = null
|
|
366
|
+
nextRetryAt = null
|
|
367
|
+
if (detached) return
|
|
368
|
+
void attemptAll().then(result => {
|
|
369
|
+
if (result.ok) {
|
|
370
|
+
retryAttempts = 0
|
|
371
|
+
log('info', 'dsh-mobile-plugin: 后台补齐成功,已装载 %s', result.version)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
lastError = `所有可用插件实现均装载失败:${result.failures.join(';')}`
|
|
375
|
+
log('warn', 'dsh-mobile-plugin: 后台补齐仍未成功:%s', lastError)
|
|
376
|
+
scheduleBacklogRetry(log)
|
|
377
|
+
}).catch(error => {
|
|
378
|
+
lastError = `后台补齐装载出错:${error?.message || error}`
|
|
379
|
+
scheduleBacklogRetry(log)
|
|
380
|
+
})
|
|
381
|
+
}, RETRY_BACKLOG_MS)
|
|
382
|
+
if (typeof retryTimer.unref === 'function') retryTimer.unref()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 第一次激活;最高代装载失败时按优先级尝试保留的回退代;全失败则按固定间隔重试有限次,
|
|
387
|
+
* 之后交给后台补齐接着试。
|
|
388
|
+
*
|
|
389
|
+
* 为什么要重试:一次失败 = **整张控制面路由表都不在**,而设置页
|
|
390
|
+
* 的「移动端」照样画得出来(客户端半边按包扫描,不看宿主 fiber 活没活),于是现场只表现成
|
|
391
|
+
* 「读取宿主状态失败: HTTP 404」。失败最常见的原因恰好是**可能自己好**的:注入的服务
|
|
392
|
+
* (typertGateway / apiProxy)比本插件晚就位、撞上装载上限,或者第一次装载撞上 IO/时序抖动。
|
|
393
|
+
* 一次失败就永久躺平到下次重启,等于把一个瞬态问题固定成故障。
|
|
394
|
+
*/
|
|
289
395
|
async function start(context, options = {}) {
|
|
290
396
|
ctx = context
|
|
291
397
|
const log = typeof options.log === 'function' ? options.log : () => {}
|
|
292
|
-
|
|
293
|
-
if (!candidates.length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
|
|
398
|
+
if (!resolveGenerations().length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
|
|
294
399
|
let lastFailure = ''
|
|
295
400
|
for (let attempt = 1; attempt <= START_RETRY_LIMIT; attempt += 1) {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const { loaded, fiber: startedFiber } = await activate(target)
|
|
301
|
-
fiber = startedFiber
|
|
302
|
-
loadedCurrent = loaded
|
|
303
|
-
current = describe(loaded, target.dir)
|
|
304
|
-
activatedAt = new Date().toISOString()
|
|
305
|
-
lastError = failures.length
|
|
306
|
-
? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
|
|
307
|
-
: null
|
|
308
|
-
return status()
|
|
309
|
-
} catch (error) {
|
|
310
|
-
failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
|
|
311
|
-
}
|
|
401
|
+
const result = await attemptAll()
|
|
402
|
+
if (result.ok) {
|
|
403
|
+
clearBacklogRetry()
|
|
404
|
+
return status()
|
|
312
405
|
}
|
|
313
|
-
lastFailure = `所有可用插件实现均装载失败:${failures.join(';')}`
|
|
406
|
+
lastFailure = `所有可用插件实现均装载失败:${result.failures.join(';')}`
|
|
314
407
|
if (attempt >= START_RETRY_LIMIT) break
|
|
315
408
|
lastError = `${lastFailure}(第 ${attempt} 次,${START_RETRY_MS / 1000}s 后重试)`
|
|
316
409
|
log('warn', 'dsh-mobile-plugin: 装载失败,%d 秒后重试:%s', START_RETRY_MS / 1000, lastFailure)
|
|
317
410
|
await sleep(START_RETRY_MS)
|
|
318
411
|
}
|
|
319
412
|
lastError = lastFailure
|
|
413
|
+
// 抛出去是为了让外壳那行日志照旧留下;后台补齐另起一条线,两条不冲突。
|
|
414
|
+
scheduleBacklogRetry(log)
|
|
320
415
|
throw new Error(lastFailure)
|
|
321
416
|
}
|
|
322
417
|
|
|
@@ -331,6 +426,7 @@ export function createRuntime() {
|
|
|
331
426
|
/** 外壳被卸载时调用:停当前代,之后不再接受换代。 */
|
|
332
427
|
async function detach() {
|
|
333
428
|
detached = true
|
|
429
|
+
clearBacklogRetry()
|
|
334
430
|
const oldFiber = fiber
|
|
335
431
|
fiber = null
|
|
336
432
|
current = null
|