dsh-mobile-plugin 0.4.9 → 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.
@@ -3,21 +3,29 @@
3
3
  /**
4
4
  * 预览字节管道(宿主侧)。
5
5
  *
6
- * 手机 App 的内置浏览器要看到桌面机上「开发中的网页」,而那些 dev server 只监听宿主的
7
- * 127.0.0.1:<port>,手机、中转、局域网代理都够不着。所以链路上有一段跑在宿主进程里的搬运工。
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
- * 环回地址要双栈试(macOS 上不少 dev server 只监听 `::1`)。WS 升级、SSE、分块传输原样穿过。
13
+ * 目标(`host` / `scheme` / `port`)由手机从查询串带过来,解析与拨号都发生在电脑这一侧:
14
+ * 手机永远只跟自己的 localhost 说明文,证书、ATS、明文限制那套就彻底与它无关了。https 的
15
+ * TLS 也在这里发起——目标是本机或私网,自签证书是本地开发的常态。
14
16
  *
15
- * 代价:宿主无法审查内容,端口就是唯一边界,所以 `protectedPorts()` 按身份拒绝——DSH 自身的
16
- * web 端口永远不许从预览进来。
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 握手已成、但上游 `net.connect` 还没连上的那几百微秒里,手机已经发出来的字节
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 握手,然后把它与 `127.0.0.1:<port>` 对搬。
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
- // 上游必须**双栈**拨号:macOS 上 Vite 之类的 dev server 默认只监听 `::1`,只连
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
- /** 两个环回地址都连不上:握手已经成了,没法再用 HTTP 状态码,只能借管道送失败页。 */
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
- ? '开发服务可能已经停了。回到 DSH 重新启动它,再刷新这个页面。'
222
- : `宿主连不上 127.0.0.1:${port},也连不上 [::1]:${port}(${lastError})。`,
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 < upstreamHosts.length) { dialUpstream(); return; }
338
+ if (hostIndex < hosts.length) { dialUpstream(); return; }
242
339
  failUpstream();
243
340
  };
244
341
 
245
342
  function dialUpstream() {
246
- const host = upstreamHosts[hostIndex];
247
- const socketToUpstream = net.connect({ host, port });
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
- socketToUpstream.on('connect', () => {
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, port));
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',
@@ -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)
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Mobile plugin",
4
+ "description": "Connect your phone or tablet to this computer's DSH: conversations, file transfer and plugin management on mobile."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "移动端插件",
4
+ "description": "把手机、平板接上这台电脑的 DSH:对话、文件转发、插件管理都在手上。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
+ "icon": "./icon.png",
6
7
  "engines": {
7
8
  "dsh": ">=0.2.0 <0.3.0"
8
9
  },
@@ -14,6 +15,7 @@
14
15
  "exports": {
15
16
  ".": "./lib/index.js",
16
17
  "./client": "./lib/client.js",
18
+ "./locale/*.json": "./locale/*.json",
17
19
  "./package.json": "./package.json"
18
20
  },
19
21
  "repository": {
@@ -37,6 +39,8 @@
37
39
  "lib/**/*.html",
38
40
  "lib/**/package.json",
39
41
  "dsh-mobile-credentials",
42
+ "locale/*.json",
43
+ "icon.png",
40
44
  "README.md"
41
45
  ],
42
46
  "publishConfig": {