dsh-mobile-plugin 0.4.8 → 0.5.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/icon.png +0 -0
- 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/plugin-update.js +16 -1
- 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/reconnect-scheduler.cjs +19 -6
- 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/relay/tunnel.js +26 -1
- package/lib/routes/control-plane.js +47 -1
- package/lib/routes/mobile-api.js +3 -0
- package/lib/sidecar-facade-worker.cjs +44 -2
- package/lib/update-install.cjs +301 -43
- 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
|
};
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -17,6 +17,10 @@ const { isFreshRequest } = require('../shared/fresh-request');
|
|
|
17
17
|
const { encodeJsonBody } = require('../shared/http-util');
|
|
18
18
|
const { withViaHeader } = require('../shared/via-header');
|
|
19
19
|
|
|
20
|
+
// 上报给中转的插件版本:只有包自己的 package.json 这一个来源(与 lib/relay/status.js 同口径)。
|
|
21
|
+
// 中转靠它判「这台宿主是不是太旧」,见 relay-core 的 PROTOCOL.md 的 register 那行。
|
|
22
|
+
const { version: PLUGIN_VERSION } = require('../../package.json');
|
|
23
|
+
|
|
20
24
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
21
25
|
// 中转 60s 收不到 pong 就把这条隧道 terminate 掉(TUNNEL_PONG_TIMEOUT_MS)。宿主必须比它
|
|
22
26
|
// 先动或同时动:反过来就是「被踢的那一刻我们还以为连着」,那段时间手机发来的每个请求都要
|
|
@@ -91,6 +95,10 @@ class RelayTunnel {
|
|
|
91
95
|
// 不差。纯转发节点(`MIRROR_CACHE=0`)收下整份镜像也只解析完丢掉,worker 据此只把
|
|
92
96
|
// 摘要推上去(lib/sidecar-facade-worker.cjs 的 digestFrame)。
|
|
93
97
|
this.relayMirror = true;
|
|
98
|
+
// 中转在注册阶段拒绝时回的那条控制帧(`code`/`message`)。宿主据此把「版本过旧」和
|
|
99
|
+
// 「连不上」分开:两件事的重连节奏差三个数量级(见 sidecar-facade-worker 的
|
|
100
|
+
// OUTDATED_RETRY_MS),混在一起就会拿 5 秒退避一路撞门。
|
|
101
|
+
this.lastControlError = null;
|
|
94
102
|
// 清除所有授权的等待回调(一次一个);由 requestRevokeAll 注入,revokeAllAck 触发。
|
|
95
103
|
this._revokeAllAck = null;
|
|
96
104
|
}
|
|
@@ -405,6 +413,8 @@ class RelayTunnel {
|
|
|
405
413
|
return {
|
|
406
414
|
instanceId: this.instanceId,
|
|
407
415
|
name: this.name,
|
|
416
|
+
// 中转用它判版本门槛(MIN_PLUGIN_VERSION);老中转不读它,多一个字段没有影响。
|
|
417
|
+
version: PLUGIN_VERSION,
|
|
408
418
|
// 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
|
|
409
419
|
...(this.registerSecret ? { secret: this.registerSecret } : {}),
|
|
410
420
|
...(this.lanUrls.length ? { lanUrls: this.lanUrls } : {}),
|
|
@@ -469,6 +479,7 @@ class RelayTunnel {
|
|
|
469
479
|
// 每条新连接都从「对端存镜像」重新判定:能走到推送的那一步一定已经收到过回执,
|
|
470
480
|
// 而回执缺这个字段的老中转本来就该按「存」处理。
|
|
471
481
|
this.relayMirror = true;
|
|
482
|
+
this.lastControlError = null;
|
|
472
483
|
return new Promise((resolve, reject) => {
|
|
473
484
|
let settled = false;
|
|
474
485
|
const done = (fn, arg) => {
|
|
@@ -520,7 +531,15 @@ class RelayTunnel {
|
|
|
520
531
|
this.cancelledHttp.clear();
|
|
521
532
|
this.cancelledWs.clear();
|
|
522
533
|
this.onClose(code, reason);
|
|
523
|
-
|
|
534
|
+
const closed = new Error(`隧道关闭: ${code} ${reason}`);
|
|
535
|
+
// 中转在关掉之前说过原因(比如「插件太旧」)就把它挂到 error 上:调用方要按 code
|
|
536
|
+
// 分流重连节奏,别把「被拒」当成「网络不好」按 5 秒退避一路撞下去。
|
|
537
|
+
if (this.lastControlError && typeof this.lastControlError.code === 'string') {
|
|
538
|
+
closed.code = this.lastControlError.code;
|
|
539
|
+
closed.pluginMessage = this.lastControlError.message || '';
|
|
540
|
+
closed.retryAfterMs = Number(this.lastControlError.retryAfterMs) || 0;
|
|
541
|
+
}
|
|
542
|
+
done(reject, closed);
|
|
524
543
|
});
|
|
525
544
|
ws.on('error', (err) => {
|
|
526
545
|
clearTimeout(timer);
|
|
@@ -573,6 +592,12 @@ class RelayTunnel {
|
|
|
573
592
|
// ── 帧分发 ────────────────────────────────────────────────────────
|
|
574
593
|
handleFrame(frame) {
|
|
575
594
|
const { type, id, payload } = frame;
|
|
595
|
+
// id=0 的 wsError 是中转在断开之前给的一句话(现在还只用于「注册被拒」):先记下来,
|
|
596
|
+
// 等随后的 close 回调把它带进 start() 的 reject,调用方按 `code` 分流。
|
|
597
|
+
if (type === T.wsError && id === 0) {
|
|
598
|
+
this.lastControlError = parseJson(payload);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
576
601
|
switch (type) {
|
|
577
602
|
case T.httpRequest: {
|
|
578
603
|
const msg = parseJson(payload);
|
|
@@ -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
|
// 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
|
|
@@ -44,7 +47,7 @@ const { createSelfCheck } = require('../mobile/self-check.cjs');
|
|
|
44
47
|
// 「查有没有新版」与「装新版」这两格都归插件自己:查直接问 npm 注册表,装在 profile 里跑
|
|
45
48
|
// 它自己的包管理器(见 `../update-registry.cjs` / `../update-install.cjs`)。不再借道市场。
|
|
46
49
|
const { checkForUpdate } = require('../update-registry.cjs');
|
|
47
|
-
const { declaredSource } = require('../update-install.cjs');
|
|
50
|
+
const { declaredSource, profileRegistry } = require('../update-install.cjs');
|
|
48
51
|
const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
|
|
49
52
|
const { recordSessionRead } = require('../mobile/session-last-turn.js');
|
|
50
53
|
// 收件目录的占用与清理:和移动端那条路由是同一份实现(`lib/mobile/inbox-clean.js`)。
|
|
@@ -253,6 +256,19 @@ function registerControlPlaneRoutes(deps) {
|
|
|
253
256
|
*/
|
|
254
257
|
dropDeviceStreams = () => 0,
|
|
255
258
|
} = deps;
|
|
259
|
+
/**
|
|
260
|
+
* 宿主发布的包管理器调用方式(`profileContext.packageManager`)。
|
|
261
|
+
*
|
|
262
|
+
* 「查更新」要用它去问这台机器上 pnpm 真正在用的注册表;拿不到就当没有(退回官方源),
|
|
263
|
+
* 不影响其它路由。
|
|
264
|
+
*/
|
|
265
|
+
const profilePackageManager = () => {
|
|
266
|
+
try {
|
|
267
|
+
return typeof ctx?.get === 'function' ? ctx.get('profileContext')?.packageManager ?? null : null
|
|
268
|
+
} catch {
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
256
272
|
const selfCheck = createSelfCheck({
|
|
257
273
|
ctx,
|
|
258
274
|
credentials,
|
|
@@ -262,6 +278,14 @@ function registerControlPlaneRoutes(deps) {
|
|
|
262
278
|
getLanProxyStats,
|
|
263
279
|
});
|
|
264
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 });
|
|
265
289
|
const localOnly = createWebAccessLocalOnly({
|
|
266
290
|
localOnly: baseLocalOnly,
|
|
267
291
|
trustedLocalUi: typeof trustedLocalUi === 'function' ? trustedLocalUi : () => false,
|
|
@@ -691,6 +715,20 @@ function registerControlPlaneRoutes(deps) {
|
|
|
691
715
|
}
|
|
692
716
|
sendJson(res, 200, { ok: true, port, available: await canListen(port, '0.0.0.0'), ours: false, listening, currentPort })
|
|
693
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
|
+
}) })
|
|
694
732
|
registerRoute({ kind: 'exact', path: '/api/relay/reconnect', handler: localOnly('POST', async (_req, res) => {
|
|
695
733
|
await syncSidecar()
|
|
696
734
|
sendJson(res, 200, status())
|
|
@@ -730,11 +768,18 @@ function registerControlPlaneRoutes(deps) {
|
|
|
730
768
|
// 卡片当场从「宿主模式」掉进取降级那一半 —— 用户看到的现象是「有新版本却没有『立即更新』」。
|
|
731
769
|
const snapshot = runtime ? { available: true, ...runtime.status() } : null
|
|
732
770
|
try {
|
|
771
|
+
// 查也跟装用同一个源:问这台机器上 pnpm 真正在用的注册表(镜像 / 私有源机器上,
|
|
772
|
+
// 写死官方源等于永远查不到)。问不到才退回官方源。
|
|
773
|
+
const registry = await profileRegistry({
|
|
774
|
+
profileNodeModules: typeof snapshot?.profileNodeModules === 'string' ? snapshot.profileNodeModules : null,
|
|
775
|
+
packageManager: profilePackageManager(),
|
|
776
|
+
})
|
|
733
777
|
const update = await checkForUpdate({
|
|
734
778
|
installedVersion: snapshot?.installedVersion ?? snapshot?.version ?? generationVersion(),
|
|
735
779
|
// 「安装来源」也自己看:profile 的依赖声明里写的是什么(npm / file: / link:)。读不到
|
|
736
780
|
// 才退回外壳上次问市场的记忆 —— 这条路上不为它去敲市场。
|
|
737
781
|
source: declaredSource(snapshot?.profileNodeModules ? path.dirname(snapshot.profileNodeModules) : null) ?? snapshot?.marketResult?.source ?? null,
|
|
782
|
+
base: registry ?? undefined,
|
|
738
783
|
})
|
|
739
784
|
return sendJson(res, 200, { ok: true, data: { update, status: snapshot } })
|
|
740
785
|
} catch (error) {
|
|
@@ -946,6 +991,7 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
946
991
|
'/api/relay/revoke-all',
|
|
947
992
|
'/api/relay/config',
|
|
948
993
|
'/api/relay/check-port',
|
|
994
|
+
'/api/relay/selfcheck',
|
|
949
995
|
'/api/relay/reconnect',
|
|
950
996
|
'/api/relay/update/status',
|
|
951
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)
|
|
@@ -89,6 +89,37 @@ const reconnect = createReconnectScheduler({
|
|
|
89
89
|
onFire: () => startTunnel(),
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* 被中转按「插件太旧」挡下来时的重连间隔。
|
|
94
|
+
*
|
|
95
|
+
* 这一档不是故障:链路是通的,是这台的插件版本低于中转的门槛(`MIN_PLUGIN_VERSION`)。
|
|
96
|
+
* 按 5 秒退避重连一点用都没有——门槛不会因为多敲几次就降下来,而所有老宿主一起敲会把
|
|
97
|
+
* 中转的握手与日志刷满。半小时一次既能让「装上新版之后半小时内自动恢复」,也不会给中转
|
|
98
|
+
* 添负担。宿主重启(装完插件之后通常就是重启 DSH)会立刻重来一次。
|
|
99
|
+
*/
|
|
100
|
+
const OUTDATED_RETRY_MS = 30 * 60 * 1000
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 被中转以「插件太旧」挡下来时的收尾:状态另立成 `outdated`、记录那句话、三十分钟后再试。
|
|
104
|
+
*
|
|
105
|
+
* 单独成函数而不是散在两处回调里,是因为它必须**带实例身份闸**(`telemetry` 之外的任何
|
|
106
|
+
* 状态写入点都要),而回调里那一行闸只管得到紧挨着的那一句;收成一个有闸的入口,
|
|
107
|
+
* 两处调用就都只有一行、也不会漏。
|
|
108
|
+
*
|
|
109
|
+
* 返回 true 表示这一轮已经按「版本过旧」处理完了,调用方别再走普通断线那条路。
|
|
110
|
+
*/
|
|
111
|
+
function markOutdated(current) {
|
|
112
|
+
if (tunnel !== current) return true
|
|
113
|
+
const info = (current && current.lastControlError) || null
|
|
114
|
+
if (!info || info.code !== 'plugin_too_old') return false
|
|
115
|
+
tunnelState = 'outdated'
|
|
116
|
+
tunnelError = info.message || '插件版本过旧,请更新'
|
|
117
|
+
publishState()
|
|
118
|
+
reconnect.cancel()
|
|
119
|
+
reconnect.schedule(OUTDATED_RETRY_MS)
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
92
123
|
function send(res, code, value) {
|
|
93
124
|
if (res.headersSent) return res.end()
|
|
94
125
|
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
@@ -336,7 +367,13 @@ function openTunnel() {
|
|
|
336
367
|
if (tunnel !== current) return; reconnect.reset()
|
|
337
368
|
tunnelState = 'connected'; tunnelError = null; relayMirror = current.relayMirror !== false; publishState()
|
|
338
369
|
},
|
|
339
|
-
onClose: () => {
|
|
370
|
+
onClose: () => {
|
|
371
|
+
if (tunnel !== current || !config.enabled) return
|
|
372
|
+
tunnelState = 'error'; tunnelError = '连接断开'; publishState(); scheduleReconnect()
|
|
373
|
+
// 中转在关掉之前说过「插件太旧」的话,这一档要改口径:状态另立成 outdated、复位成
|
|
374
|
+
// 三十分钟一次。markOutdated 自带实例身份闸,改完直接盖掉上面那句「连接断开」。
|
|
375
|
+
markOutdated(current)
|
|
376
|
+
},
|
|
340
377
|
onPairRequest: async (message) => {
|
|
341
378
|
try {
|
|
342
379
|
const result = await parentJson('/api/relay/internal/approval', { device: message.device || {} })
|
|
@@ -351,7 +388,12 @@ function openTunnel() {
|
|
|
351
388
|
const ready = current.start()
|
|
352
389
|
// 挂了就如实报,并按 5s 起退避重连(旧实现在 startTunnel 的 catch 里做同一件事)。
|
|
353
390
|
// 换地址失败时这里也会排一次:配置里已经是新地址,值得继续试它,而不是悄悄退回旧地址。
|
|
354
|
-
ready.catch(error => {
|
|
391
|
+
ready.catch(error => {
|
|
392
|
+
if (tunnel !== current) return
|
|
393
|
+
tunnelState = 'error'; tunnelError = error instanceof Error ? error.message : String(error); publishState(); scheduleReconnect()
|
|
394
|
+
// 与 onClose 同一套判据:被中转挡住(版本过旧)时改写状态并改排三十分钟,别落进 5 秒退避。
|
|
395
|
+
markOutdated(current)
|
|
396
|
+
})
|
|
355
397
|
return ready
|
|
356
398
|
}
|
|
357
399
|
function startTunnel() {
|