dsh-mobile-plugin 0.5.26 → 0.5.27

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/lib/impl.js CHANGED
@@ -33,7 +33,7 @@ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManu
33
33
  const { pairingPayload, pairingText } = require('./relay/pairing-payload.cjs')
34
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
35
35
  const { mountTailcat, chooseTailcatTarget } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
36
- const { mountP2P } = require('./p2p/wiring.cjs') // 点对点隧道:只有助手会话(连接从隧道里进来,不必开回环门)
36
+ const { mountP2P, chooseP2PTarget } = require('./p2p/wiring.cjs') // 点对点隧道:只有助手会话(连接从隧道里进来,不必开回环门)+ 落点端口的选择
37
37
  const { createPreviewService } = require('./relay/preview.js')
38
38
  const { mountTerminal, mountCli } = require('./mobile/services.js')
39
39
  // 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
@@ -306,8 +306,8 @@ function apply(ctx) {
306
306
  const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
307
307
  // 直连隧道:不配中转也能连的兜底通道;挂不上就只是没这条通道,中转与局域网照旧。
308
308
  const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, targetPort: chooseTailcatTarget({ lanEnabled: () => state.lanEnabled, lanPort: lanPortInUse, webPort: () => ctx.webServer.port }), hostName: () => state.name, ctx, name })
309
- // 点对点隧道:两端打洞直连、数据不经过任何服务器。挂不上就只是没这条通道。
310
- const p2p = mountP2P({ mount, warn, state, saveState: core.saveState, localPort: lanPortInUse, ctx, name })
309
+ // 点对点隧道:两端打洞直连、数据不经过任何服务器。落点端口与直连隧道同一条判据(见 chooseP2PTarget)。
310
+ const p2p = mountP2P({ mount, warn, state, saveState: core.saveState, localPort: lanPortInUse, targetPort: chooseP2PTarget({ lanEnabled: () => state.lanEnabled, lanPort: lanPortInUse, webPort: () => ctx.webServer.port }), ctx, name })
311
311
  // 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
312
312
  // lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
313
313
  // 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
@@ -51,6 +51,18 @@ function normalizeCandidates(list) {
51
51
  * @param {() => number} [deps.lanPortInUse] 本机插件端口(隧道里那些连接最终转给它)
52
52
  */
53
53
  function createP2PRendezvousHandler(deps) {
54
+ /**
55
+ * 隧道里进来的连接该转到哪个本机端口。
56
+ *
57
+ * 会话门面自己知道(`lib/p2p/wiring.cjs` 的 `targetPort`:局域网开着转局域网端口、
58
+ * 关着转宿主 web 端口)。门面没给时才退回 `lanPortInUse` —— 那是老形状,局域网关着时
59
+ * 指向一个没人听的口,隧道会「连上了却毫无反应」。
60
+ */
61
+ const targetPortOf = () => {
62
+ const door = deps.p2p;
63
+ if (door && typeof door.targetPort === 'function') return Number(door.targetPort()) || 0;
64
+ return typeof deps.lanPortInUse === 'function' ? deps.lanPortInUse() : 0;
65
+ };
54
66
  return async function handleRendezvous(_ctx, req, res) {
55
67
  if (!deps.p2p || typeof deps.p2p.open !== 'function') {
56
68
  // 这台电脑没装助手 / 没起这条通道:如实说,手机那边据此把这一条标成不可用,
@@ -76,7 +88,7 @@ function createP2PRendezvousHandler(deps) {
76
88
  phonePublicKey,
77
89
  candidates,
78
90
  localCandidates,
79
- targetPort: typeof deps.lanPortInUse === 'function' ? deps.lanPortInUse() : 0,
91
+ targetPort: targetPortOf(),
80
92
  });
81
93
  if (!opened || opened.ok === false) {
82
94
  sendJson(res, 200, {
@@ -8,6 +8,11 @@
8
8
  * 之后就固定了,需要知道的只有「对方通到哪」(打洞候选)。混进同一个模块会让两边各有一半
9
9
  * 用不上的字段,也容易把「打不通报 nat-blocked」和「中继没会合」说成同一件事。
10
10
  *
11
+ * **一台电脑同时服务多台手机**:每把手机公钥各有一套状态(`peers` 那张表),助手那边每条
12
+ * 隧道也各是独立的 netstack,同一个隧道内端口在各自栈里互不冲突。所以这里不再有「谁先来
13
+ * 归谁、后来者回 busy」—— 那种退让是单条隧道时的权宜,多台共存之后它只会把第二台永远挡在
14
+ * 外面。
15
+ *
11
16
  * 三件必须一直成立:
12
17
  * 1. 私钥落在插件自己的存储里(`relay.json`),助手不落盘 —— 插件永远是唯一真相。
13
18
  * 2. 公钥可以明着给出去:配对二维码里那份、以及 `/connection-info` 报的都是公钥。
@@ -31,7 +36,8 @@ const STATE = {
31
36
  /**
32
37
  * 电脑在**隧道里**听的那个端口(手机拨它)。与手机侧 `p2p.port` 同一个数。
33
38
  *
34
- * 它与「本机插件端口」是两件事:那个口是本机的,隧道里根本看不见。
39
+ * 它与「本机插件端口」是两件事:那个口是本机的,隧道里根本看不见。多台手机也是同一个
40
+ * 数 —— 每条隧道各有独立的用户态网络栈,同一个端口号在各自栈里互不冲突。
35
41
  */
36
42
  const P2P_PORT = 48950;
37
43
 
@@ -41,6 +47,34 @@ function candidateList(value) {
41
47
  return value.filter((item) => typeof item === 'string' && item.length > 0 && item.length <= 64).slice(0, 16);
42
48
  }
43
49
 
50
+ /**
51
+ * 一份候选的**规范化集合**:去重、排序。
52
+ *
53
+ * 用集合而不是列表比:手机每次报上来的顺序本来就不稳定(STUN 各家回的先后、网卡枚举的
54
+ * 先后都会变),按顺序比会把「一模一样的一份地址」判成变了,于是每轮会合都重开一次 ——
55
+ * 那等于每分钟把已经打通的洞踢掉一次。
56
+ *
57
+ * 公网与私网两格分开比、不合成一坨:助手拿它们做的不是一件事(公网那份是打洞目标,
58
+ * 私网那份是同一张网里的一跳),把「同一个地址从私网那格挪到公网那格」当成没变是错的。
59
+ */
60
+ function candidateSet(value) {
61
+ return [...new Set(candidateList(value))].sort();
62
+ }
63
+
64
+ function sameCandidates(left, right) {
65
+ return left.public.length === right.public.length
66
+ && left.public.every((item, index) => item === right.public[index])
67
+ && left.local.length === right.local.length
68
+ && left.local.every((item, index) => item === right.local[index]);
69
+ }
70
+
71
+ /** 助手报的 path 有四种:direct / failed / punching(在打)/ waiting(等人来打)。 */
72
+ function normalizePath(value) {
73
+ return value === 'direct' || value === 'failed' || value === 'punching' || value === 'waiting'
74
+ ? value
75
+ : 'unknown';
76
+ }
77
+
44
78
  /** 等助手把隧道起来 / 报一次状态的上限。 */
45
79
  const P2P_TIMEOUT_MS = 10_000;
46
80
 
@@ -59,8 +93,75 @@ function createP2PSession(options) {
59
93
  let binary = options.host && !options.resolveBinary ? 'injected' : findBinary();
60
94
  let host = null;
61
95
  let lastError = '';
62
- /** 最近一次报上来的隧道现状(打洞是异步的,这个随时可能变)。 */
63
- let tunnel = { running: false, url: '', path: 'unknown', active: false, detail: '', candidates: [] };
96
+
97
+ /** 一套「某台手机那条隧道」的状态。字段与 `status()` 的对外字段同名,别改。 */
98
+ function emptyTunnel() {
99
+ return {
100
+ running: false,
101
+ url: '',
102
+ path: 'unknown',
103
+ active: false,
104
+ detail: '',
105
+ candidates: [],
106
+ };
107
+ }
108
+
109
+ /**
110
+ * 按手机公钥的那张表:每把公钥一套 `{ ...tunnel, runningCandidates, dialing, stamp }`。
111
+ *
112
+ * `runningCandidates` 单独记一格的理由与单条时代一样:「隧道在跑」不等于「跑的就是你现在
113
+ * 这份候选」。电脑第一次往往是在手机还没报出候选时以「只听不打」起来的;之后手机把真实
114
+ * 候选报上来,若只判公钥,就永远复用一个没有对端地址的隧道 —— 永远不往手机打,公网永远
115
+ * 不通(局域网能通只因为手机摸得到电脑的内网地址、先打过来了)。
116
+ *
117
+ * `dialing`:命令已经发给助手、还没收到回执。这中间再来一发会合必须认同一份,否则会连发
118
+ * 两条 `p2pStart`,两条各自开一次 socket、洞白开。
119
+ *
120
+ * `stamp`:最近一次被动到的顺序。`status()` 那几格老字段取「最近活跃的那条」,靠它排。
121
+ *
122
+ * 空串那把是**匿名槽**:老助手的事件里没有 `peerKey`、而这次会话又还没收到过任何手机
123
+ * 公钥时,事件落在它上面。它不进 `status().peers` 的详单。
124
+ */
125
+ const peers = new Map();
126
+ let stampSeq = 0;
127
+
128
+ function ensurePeer(key) {
129
+ let entry = peers.get(key);
130
+ if (!entry) {
131
+ entry = { ...emptyTunnel(), runningCandidates: { public: [], local: [] }, dialing: false, stamp: 0 };
132
+ peers.set(key, entry);
133
+ }
134
+ entry.stamp = ++stampSeq;
135
+ return entry;
136
+ }
137
+
138
+ /** 最近活跃的那一条(没有就是 null)。 */
139
+ function recentPeer() {
140
+ let best = null;
141
+ for (const entry of peers.values()) {
142
+ if (!best || entry.stamp > best.stamp) best = entry;
143
+ }
144
+ return best;
145
+ }
146
+
147
+ /**
148
+ * 助手发来的事件属于哪一台手机。
149
+ *
150
+ * 新助手会带 `peerKey`(手机公钥),按它路由。**老助手不带**:退回「最近会合的那一台」;
151
+ * 连那一台都还没有、而表里恰好只有一把公钥时就用它;什么都没有就落进匿名槽。任何一条
152
+ * 退路都不抛错 —— 老助手配新插件必须照常能用。
153
+ */
154
+ function routeKey(event) {
155
+ const announced = typeof event.peerKey === 'string' ? event.peerKey.trim() : '';
156
+ if (announced) {
157
+ lastPeer = announced;
158
+ return announced;
159
+ }
160
+ if (lastPeer) return lastPeer;
161
+ const named = [...peers.keys()].filter(key => key);
162
+ return named.length === 1 ? named[0] : '';
163
+ }
164
+
64
165
  /**
65
166
  * 正在等助手回执的那些请求(**一组,不是一个**)。
66
167
  *
@@ -70,16 +171,8 @@ function createP2PSession(options) {
70
171
  * 而电脑那头一切正常。轮询与会合本来就会同时等着,两个都得能收到同一条事件。
71
172
  */
72
173
  const waiters = new Set();
73
- /** 最近一次配对 / 会合换进来的手机公钥。空串时会从盘上那份读回来。 */
74
- let currentPeer = '';
75
- /**
76
- * 正在跑的那条隧道**配的是哪把手机公钥**(空串=还没起来)。
77
- *
78
- * 单独记一格而不是从 `currentPeer` 推:`currentPeer` 在会合一开始就被新来那台手机改写了,
79
- * 而此刻隧道里跑的仍是上一台。拿它当判据就等于「已经换成新的了」,于是换设备时永不重开 ——
80
- * 隧道里对端还是旧的,新手机发多少包都进不来。
81
- */
82
- let runningPeer = '';
174
+ /** 最近一次配对 / 会合换进来的手机公钥(老助手事件没有 peerKey 时靠它兜底路由)。 */
175
+ let lastPeer = '';
83
176
  /** 状态轮询定时器与开关(见 `watch`)。 */
84
177
  let timer = null;
85
178
  let polling = false;
@@ -93,8 +186,9 @@ function createP2PSession(options) {
93
186
  enabled: state[STATE.enabled] !== false,
94
187
  privateKey: typeof state[STATE.privateKey] === 'string' ? state[STATE.privateKey] : '',
95
188
  publicKey: typeof state[STATE.publicKey] === 'string' ? state[STATE.publicKey] : '',
96
- // 对端公钥:进程重启之后 `currentPeer` 是空的,得从盘上那份接回来 —— 否则电脑会用
97
- // 空的对端公钥去配设备,起不来,表现成「这条通道一直在启动中」。
189
+ // 对端公钥:进程重启之后内存表是空的,得从盘上那份接回来 —— 否则电脑会用空的对端
190
+ // 公钥去配设备,起不来,表现成「这条通道一直在启动中」。盘上只存得下**最后一台**
191
+ // 会合过的手机,其余那几台等它们下一轮会合(60 秒)自然会补齐。
98
192
  peerKey: typeof state[STATE.peerKey] === 'string' ? state[STATE.peerKey] : '',
99
193
  };
100
194
  } catch {
@@ -112,20 +206,40 @@ function createP2PSession(options) {
112
206
  }
113
207
  }
114
208
 
115
- function settle(value) {
209
+ /**
210
+ * 唤醒等待者。
211
+ *
212
+ * 多台手机之后这里要按公钥分道:两台同时会合时,A 那条 `p2pReady` 绝不能拿去回 B ——
213
+ * 拿着别人的地址当自己的,B 会往一个自己够不到的 URL 上走。所以等待者按公钥认领,
214
+ * 只唤醒「等这一台的」与「不挑的」(轮询)。
215
+ *
216
+ * `key` 为空串是**认不出是哪台**的情况(老助手的事件里没有公钥):这时一律广播 ——
217
+ * 老助手一条进程只有一条隧道,它报的就是「有隧道起来了」,分道反而会把等着的那台饿死。
218
+ */
219
+ function settle(value, key = '') {
116
220
  for (const done of [...waiters]) {
221
+ if (key && done.key && done.key !== key) continue;
117
222
  waiters.delete(done);
118
223
  clearTimeout(done.timer);
119
224
  done.resolve(value);
120
225
  }
121
226
  }
122
227
 
123
- /** 排一个等待者,超时自己摘掉(助手没答就回 null,别把内存漏下去)。 */
124
- function await_(resolve) {
228
+ /**
229
+ * 排一个等待者,超时自己摘掉(助手没答就回 null,别把内存漏下去)。
230
+ *
231
+ * `key` 是这台手机的公钥(不挑就留空)。超时那一支还要顺手把这一格的「正在发起」清掉 ——
232
+ * 不清的话,那一格永远是 dialing,下一轮同候选的会合会被判成「已经在起,复用」,
233
+ * 于是这台就再也不会重新发起(看起来像「隧道卡住了、怎么等都不动」)。
234
+ */
235
+ function await_(resolve, key = '') {
125
236
  const done = {
237
+ key,
126
238
  resolve,
127
239
  timer: setTimeout(() => {
128
240
  waiters.delete(done);
241
+ const entry = key ? peers.get(key) : null;
242
+ if (entry) entry.dialing = false;
129
243
  resolve(null);
130
244
  }, P2P_TIMEOUT_MS),
131
245
  };
@@ -135,33 +249,31 @@ function createP2PSession(options) {
135
249
 
136
250
  function handleEvent(event) {
137
251
  switch (event.event) {
138
- case 'p2pReady':
252
+ case 'p2pReady': {
139
253
  lastError = '';
140
- tunnel = {
141
- ...tunnel,
142
- running: true,
143
- url: typeof event.url === 'string' ? event.url : '',
144
- candidates: candidateList(event.candidates),
145
- };
254
+ const key = routeKey(event);
255
+ const entry = ensurePeer(key);
256
+ entry.running = true;
257
+ entry.dialing = false;
258
+ entry.url = typeof event.url === 'string' ? event.url : '';
259
+ entry.candidates = candidateList(event.candidates);
146
260
  // 候选必须带出去:会合那一发要把它还给手机,否则手机不知道往哪打洞。
147
- settle({ ok: true, url: tunnel.url, candidates: candidateList(event.candidates) });
261
+ settle({ ok: true, url: entry.url, candidates: entry.candidates }, key);
148
262
  break;
149
- case 'p2pStatus':
150
- tunnel = {
151
- running: Boolean(event.running),
152
- url: typeof event.url === 'string' ? event.url : '',
153
- // 助手报的 path 有四种:direct / failed / punching(在打)/ waiting(等人来打)。
154
- // 后两种都不是终态,但对界面来说「在打」与「在等」必须分得开。
155
- path: event.path === 'direct' || event.path === 'failed'
156
- || event.path === 'punching' || event.path === 'waiting'
157
- ? event.path
158
- : 'unknown',
159
- active: event.active === true,
160
- detail: typeof event.detail === 'string' ? event.detail : '',
161
- candidates: candidateList(event.candidates),
162
- };
163
- settle({ ok: true, ...tunnel, candidates: candidateList(event.candidates) });
263
+ }
264
+ case 'p2pStatus': {
265
+ const key = routeKey(event);
266
+ const entry = ensurePeer(key);
267
+ entry.running = Boolean(event.running);
268
+ entry.dialing = false;
269
+ entry.url = typeof event.url === 'string' ? event.url : '';
270
+ entry.path = normalizePath(event.path);
271
+ entry.active = event.active === true;
272
+ entry.detail = typeof event.detail === 'string' ? event.detail : '';
273
+ entry.candidates = candidateList(event.candidates);
274
+ settle({ ok: true, ...pick(entry), candidates: entry.candidates }, key);
164
275
  break;
276
+ }
165
277
  case 'p2pKeys':
166
278
  // 助手生成了密钥对:私钥存插件、公钥留给配对与状态用(公钥可以明着给出去)。
167
279
  if (typeof event.private === 'string' && typeof event.public === 'string') {
@@ -169,13 +281,23 @@ function createP2PSession(options) {
169
281
  }
170
282
  settle({ ok: true, publicKey: typeof event.public === 'string' ? event.public : '' });
171
283
  break;
172
- case 'p2pStopped':
173
- tunnel = { running: false, url: '', path: 'unknown', active: false, detail: '', candidates: [] };
174
- runningPeer = '';
284
+ case 'p2pStopped': {
285
+ const announced = typeof event.peerKey === 'string' ? event.peerKey.trim() : '';
286
+ if (announced) {
287
+ // 新助手:只收掉这一台那条,别人的照跑。
288
+ peers.delete(announced);
289
+ } else {
290
+ // 老助手:它一条进程只有一条隧道,回的就是「全停了」。
291
+ peers.clear();
292
+ }
175
293
  settle({ ok: true, running: false });
176
294
  break;
295
+ }
177
296
  case 'error':
178
297
  lastError = event.code;
298
+ // 助手报错意味着这一轮「正在发起」已经落空:不清这一位的话,那一格永远是 dialing,
299
+ // 下一轮同候选的会合会被判成「已经在起,复用」,这台就再也重新发不起来了。
300
+ for (const entry of peers.values()) entry.dialing = false;
179
301
  settle({ ok: false, code: event.code });
180
302
  break;
181
303
  default:
@@ -183,6 +305,18 @@ function createP2PSession(options) {
183
305
  }
184
306
  }
185
307
 
308
+ /** 一条隧道里属于 `status()` 老字段的那几格(不带头里的记账字段)。 */
309
+ function pick(entry) {
310
+ return {
311
+ running: entry.running,
312
+ url: entry.url,
313
+ path: entry.path,
314
+ active: entry.active,
315
+ detail: entry.detail,
316
+ candidates: entry.candidates,
317
+ };
318
+ }
319
+
186
320
  /** 只在入口上重探一次(与直连隧道同一条规矩:不做定时扫描)。 */
187
321
  function locate() {
188
322
  if (binary) return binary;
@@ -223,10 +357,10 @@ function createP2PSession(options) {
223
357
  function stop() {
224
358
  unwatch();
225
359
  everOpened = false;
360
+ lastPeer = '';
361
+ peers.clear();
226
362
  const current = host;
227
363
  host = null;
228
- tunnel = { running: false, url: '', path: 'unknown', active: false, detail: '', candidates: [] };
229
- runningPeer = '';
230
364
  if (current) return current.close();
231
365
  return Promise.resolve();
232
366
  }
@@ -249,20 +383,26 @@ function createP2PSession(options) {
249
383
  }
250
384
  if (typeof peerPublicKey === 'string' && peerPublicKey) {
251
385
  persist({ [STATE.peerKey]: peerPublicKey });
252
- currentPeer = peerPublicKey;
386
+ lastPeer = peerPublicKey;
253
387
  }
254
388
  // 候选也要带出去:二维码里那一段是手机打洞的起点,空着就只能靠「只听不打」等握手。
389
+ const entry = (lastPeer && peers.get(lastPeer)) || recentPeer();
255
390
  return {
256
391
  ok: true,
257
392
  publicKey: state.publicKey,
258
393
  port: P2P_PORT,
259
394
  peerKey: peerPublicKey,
260
- candidates: tunnel.candidates || [],
395
+ candidates: entry ? entry.candidates || [] : [],
261
396
  localCandidates: [],
262
397
  };
263
398
  }
264
399
 
265
- /** 面板与路由看到的现状。这里不重探,理由同直连隧道那条。 */
400
+ /**
401
+ * 面板与路由看到的现状。这里不重探,理由同直连隧道那条。
402
+ *
403
+ * **字段名一个都没动**(手机 `/status` 心跳读的就是它们)。多台手机之后语义是:
404
+ * 老字段取「最近活跃的那一条」,另外追加一份 `peers` 按手机公钥的详单给面板用。
405
+ */
266
406
  function status() {
267
407
  const state = snapshot();
268
408
  if (!binary) {
@@ -275,38 +415,45 @@ function createP2PSession(options) {
275
415
  path: 'unknown',
276
416
  active: false,
277
417
  error: 'helper-missing',
418
+ peers: [],
278
419
  };
279
420
  }
421
+ const entry = recentPeer();
422
+ const current = entry ? pick(entry) : emptyTunnel();
423
+ // 详单:最近活跃的排前面。匿名槽(空串)不进这份 —— 它只是老助手事件的落点。
424
+ const roster = [...peers.entries()]
425
+ .filter(([key]) => key)
426
+ .sort((left, right) => right[1].stamp - left[1].stamp)
427
+ .map(([key, item]) => ({ peerKey: key, ...pick(item) }));
280
428
  return {
281
429
  available: true,
282
430
  enabled: state.enabled,
283
- ready: tunnel.running,
431
+ ready: current.running,
284
432
  // 有没有被手机叫起来过。`ready:false` + `started:false` 说的不是「正在启动」,
285
433
  // 而是「还没有手机来会合」—— 电脑这半边本来就是等人来打(「只听不打」)。
286
434
  started: everOpened,
287
- url: tunnel.url,
288
- path: tunnel.path,
289
- active: tunnel.active,
290
- detail: tunnel.detail,
435
+ url: current.url,
436
+ path: current.path,
437
+ active: current.active,
438
+ detail: current.detail,
291
439
  // 我自己的打洞候选也要给出去:手机那半边靠它知道往哪打。
292
- candidates: tunnel.candidates || [],
440
+ candidates: current.candidates || [],
293
441
  publicKey: state.publicKey,
294
442
  error: lastError,
443
+ // 追加字段(不是老字段的改名):每台手机那条隧道各是什么样,面板要能分开看。
444
+ peers: roster,
295
445
  };
296
446
  }
297
447
 
298
448
  /**
299
- * 按手机给的公钥与候选把电脑侧那一半拉起来。
449
+ * 按手机给的公钥与候选把电脑侧那一半拉起来,并回手机要的那份(我自己这边的候选)。
300
450
  *
301
- * 每次都先问一次现状:手机可能已经打通了(进程刚重启过、助手还在跑),那就直接复用,
302
- * 不要重新起一条 —— 同一条通道起两条会让两边都拿不到稳定路径。
303
- */
304
- /**
305
- * 按手机给的候选把电脑侧那一半拉起来,并回手机要的那份(我自己这边的候选)。
451
+ * **幂等**:会合是手机每 60 秒会再来一次的心跳。同一把手机公钥、同一份候选、**它那条**
452
+ * 隧道又在跑,就直接把现状回过去,**绝不重开** —— 重开等于把已经打通的洞每分钟踢掉一次,
453
+ * 用户感受到的就是「网一会儿通一会儿断」。候选**真的变了**才按新的重开。
306
454
  *
307
- * **幂等**:会合是手机每 60 秒会再来一次的心跳。候选没变、隧道又在跑,就直接把现状
308
- * 回过去,**绝不重开** —— 重开等于把已经打通的洞每分钟踢掉一次,用户感受到的就是
309
- * 「网一会儿通一会儿断」。
455
+ * 判据从「全局唯一那个对端」变成「**这台手机自己那一份**」:多台共存之后,两台手机各判
456
+ * 各的,一台候选变了不会连累另一台。
310
457
  */
311
458
  function open(options = {}) {
312
459
  everOpened = true;
@@ -325,56 +472,46 @@ function createP2PSession(options) {
325
472
  // `path` 会报 `direct`、面板一片绿),但数据面一个包也过不去,手机那条回环端口连上去
326
473
  // 永远没有响应。这一格的名字与语义错位了一年,别再把它当对端用。
327
474
  const phonePublicKey = typeof options.phonePublicKey === 'string' ? options.phonePublicKey.trim() : '';
328
- if (phonePublicKey) {
329
- if (phonePublicKey !== currentPeer) persist({ [STATE.peerKey]: phonePublicKey });
330
- currentPeer = phonePublicKey;
331
- }
332
- const peerKey = String(phonePublicKey || currentPeer || snapshot().peerKey || '');
475
+ // 先只**算**出这次该用哪把对端公钥,**不落盘**:落盘要在复用判定之后 —— 顺序反过来的话,
476
+ // 一次会被判成复用的会合也会去改写盘上那份,而盘上只有一格(最后一台会合的手机)。
477
+ const peerKey = String(phonePublicKey || lastPeer || snapshot().peerKey || '');
333
478
  // 打一行现场:两端公钥对不对得上,是这条通道唯一真正致命的判据 ——
334
479
  // 错了洞照样开、`path` 照样报 `direct`,但数据面一个包都过不去,从现象上看不出区别。
480
+ const seen = peers.get(peerKey);
335
481
  log(`P2P 隧道:会合 phonePublicKey=${phonePublicKey ? `${phonePublicKey.slice(0, 6)}…` : '(空)'}`
336
482
  + ` 采用的对端=${peerKey ? `${peerKey.slice(0, 6)}…` : '(空)'}`
337
- + ` 自己=${state.publicKey.slice(0, 6)}… 在跑=${tunnel.running} 在跑的对端=${runningPeer ? `${runningPeer.slice(0, 6)}…` : '(无)'}`);
483
+ + ` 自己=${state.publicKey.slice(0, 6)}… 在跑=${seen ? seen.running : false}`);
338
484
  const candidates = Array.isArray(options.candidates) ? options.candidates : [];
339
485
  const localCandidates = Array.isArray(options.localCandidates) ? options.localCandidates : [];
486
+ const wantedCandidates = { public: candidateSet(candidates), local: candidateSet(localCandidates) };
340
487
 
341
- // **已经按这把手机公钥起来的那条,才复用。**
488
+ // **同一把手机公钥 + 同一份候选,才复用该手机自己那条。**
489
+ //
490
+ // 「在跑」不足以说明「跑的是你」:这条通道早先一次只服务一个对端,多台手机同时来会合
491
+ // 时,谁先来隧道就归谁 —— 后来那台每次会合都只拿到别人的现状。现在每把公钥各一套状态,
492
+ // 判据天然落在「它自己那份」上。
342
493
  //
343
- // 早先写的是「隧道只要在跑就复用」,理由是候选变化不该踢掉已经打通的洞 —— 那一条仍然
344
- // 成立(候选变化由分流器从来包自己学)。但「在跑」不足以说明「跑的是你」:这条通道一次
345
- // 只服务一个对端,多台手机同时来会合时,谁先来隧道就归谁,后来那台每次会合都只拿到别人的
346
- // 现状 —— 它自己的握手一个包都进不来,而电脑面板显示得好好地在跑。
347
- // 所以判据收窄成「正在跑 + 跑的就是你这把公钥 + 你这把是真有」。
348
- if (tunnel.running && peerKey && peerKey === runningPeer) {
494
+ // **候选也得一起判**:电脑第一次往往是在手机还没报出候选时以「只听不打」起来的,之后
495
+ // 手机把真实候选报上来;只判公钥就一直复用那个没有对端地址的隧道,电脑永远不往手机打。
496
+ // 局域网还好(手机摸得到内网地址、从那边打过来了),一到公网就只剩下「等待」。
497
+ if (peerKey && seen && (seen.running || seen.dialing)
498
+ && sameCandidates(wantedCandidates, seen.runningCandidates)) {
349
499
  return Promise.resolve({
350
500
  ok: true,
351
501
  running: true,
352
- url: tunnel.url,
353
- path: tunnel.path,
354
- active: tunnel.active,
355
- candidates: tunnel.candidates || [],
502
+ url: seen.url,
503
+ path: seen.path,
504
+ active: seen.active,
505
+ candidates: seen.candidates || [],
356
506
  // 我这一把公钥:会合回包要把它带给手机(手机拿它配对端)。
357
507
  publicKey: state.publicKey,
358
508
  });
359
509
  }
360
- // **另一台手机正在用,就如实说「被占用」,绝不去抢。**
361
- //
362
- // 这条通道一次只服务一个对端。早先这里是「换设备就重开」,本意是别让后来者永远排在旧
363
- // 的后头 —— 但那条判据在多设备同时在线时变成互相抢:A 起来 → B 起来把 A 踢掉 → 下一轮
364
- // A 又把 B 踢掉。每抢一次 socket 端口都换一个,两端手里的候选永远对不上,打洞永远打不穿,
365
- // 面板上却老老实实报 `punching` / `failed`,看起来像 NAT 太严。真机上一台平板、一台手机
366
- // 同时开着就是这个场面。
367
- //
368
- // 所以退回「谁先来归谁」,后来者拿到一句明确的不可用 + 原因,过一会儿再问(它那条定时
369
- // 会合本来就在跑)。换手机的正确入口是让原来那台停下来(关 App / 换设备),不是抢。
370
- if (tunnel.running && peerKey && peerKey !== runningPeer) {
371
- log(`P2P 隧道:已有另一台手机在用(在跑的对端=${runningPeer.slice(0, 6)}…),这次不抢`);
372
- return Promise.resolve({
373
- ok: false,
374
- code: 'busy',
375
- publicKey: state.publicKey,
376
- });
377
- }
510
+
511
+ // 复用的门过了,才把对端公钥落盘。
512
+ if (peerKey && peerKey !== lastPeer) persist({ [STATE.peerKey]: peerKey });
513
+ if (peerKey) lastPeer = peerKey;
514
+ const entry = ensurePeer(peerKey);
378
515
 
379
516
  return new Promise((resolve) => {
380
517
  // 助手回的那几格(url / path / candidates)里没有公钥,在这里补上 —— 会合回包要用。
@@ -382,9 +519,12 @@ function createP2PSession(options) {
382
519
  await_((reply) => {
383
520
  watch();
384
521
  resolve(reply && reply.ok ? { ...reply, publicKey: state.publicKey } : reply);
385
- });
386
- // 记下「这条隧道是按谁的公钥起来的」:下一次会合据此判「是不是同一台手机」。
387
- runningPeer = peerKey;
522
+ }, peerKey);
523
+ // 记下「这条隧道是按哪份候选起来的」:下一次会合据此判要不要重开。在这里记而不是等
524
+ // `p2pReady`:请求已经发给助手了,这中间再来一发会合必须认同一份,否则会连发两条
525
+ // `p2pStart`,两条各自开一次 socket、洞白开。
526
+ entry.dialing = true;
527
+ entry.runningCandidates = wantedCandidates;
388
528
  current.startP2P({
389
529
  privateKey: state.privateKey,
390
530
  peerKey,
@@ -409,7 +549,7 @@ function createP2PSession(options) {
409
549
  /**
410
550
  * 定时问一次现状。
411
551
  *
412
- * **面板与 `/api/relay/p2p` 读的都是 `tunnel` 那一格,而它只会被助手的事件更新** ——
552
+ * **面板与 `/api/relay/p2p` 读的都是事件更新出来的那张表,而它只会被助手的事件更新** ——
413
553
  * 没人问,助手就不报;没人报,面板就永远停在「正在启动」。早先 `refresh()` 写好了却
414
554
  * 没有任何调用点(路由与面板都没接),于是这条通道在界面上永远是「正在启动」,
415
555
  * 哪怕它其实起得挺好。
@@ -7,12 +7,30 @@
7
7
  * 局域网端口;这条的连接是**从隧道里进来的**(WireGuard 的虚拟网卡),助手自己转给
8
8
  * `127.0.0.1:<插件端口>`,所以插件这边不需要任何监听。
9
9
  *
10
+ * 隧道里进来的连接最终转给谁由 `chooseP2PTarget` 决定(局域网开着转局域网端口、关着转宿主
11
+ * web 端口),与会合那一发拿到的 `targetPort` 是同一个答案。
12
+ *
10
13
  * 任何一步起不来都只意味着「这台机器没有这条通道」:`mount` 兜住异常并留一行日志,别的
11
14
  * 通道一行不受影响。
12
15
  */
13
16
 
14
17
  const { createP2PSession, P2P_PORT } = require('./session.cjs');
15
18
 
19
+ /**
20
+ * 隧道里进来的连接该转到哪个端口。
21
+ *
22
+ * 与直连隧道(`tailcat/wiring.cjs` 的 `chooseTailcatTarget`)同一个形状、同一个理由:
23
+ * 局域网开着就照旧转给「当前的局域网端口」,和一台 LAN 手机走完全同一条路;局域网关着
24
+ * 那个端口根本没人监听(`resolveLanPort` 直接早返回、sidecar 也不起),隧道会握手成功、
25
+ * 数据一到就断 —— 手机那边看到的是「连上了却毫无反应」。此时改转宿主自己的 web 端口,
26
+ * 手机 API 就在那里,局域网 sidecar 平时也只是把连接原样转给它。
27
+ *
28
+ * 端口是取值器而不是一个数:局域网开关与端口都会漂,隧道却未必每次会合都重建。
29
+ */
30
+ function chooseP2PTarget({ lanEnabled, lanPort, webPort }) {
31
+ return () => (lanEnabled() ? lanPort() : webPort());
32
+ }
33
+
16
34
  /**
17
35
  * @param {object} options
18
36
  * @param {(label: string, fn: Function) => any} options.mount 装配层的兜底挂载
@@ -20,13 +38,16 @@ const { createP2PSession, P2P_PORT } = require('./session.cjs');
20
38
  * @param {object} options.state relay.json 那份内存状态(唯一真值)
21
39
  * @param {(state: object) => void} options.saveState 落盘
22
40
  * @param {() => number} options.localPort 插件本机端口(隧道里唯一的去处)
41
+ * @param {() => number} [options.targetPort] 隧道里进来的连接该转给谁;给了就用它,否则退回
42
+ * localPort(见 chooseP2PTarget)
23
43
  * @param {object} options.ctx 插件 ctx(只为 `ctx.effect`)
24
44
  * @param {string} options.name 插件名,用于 effect 标签
25
45
  * @param {() => (string|null)} [options.resolveBinary] 测试缝:钉住「本机有没有助手」,
26
46
  * 不让用例的结果取决于开发机上刚好有没有编译好的助手。
27
47
  * @returns {object|undefined} 会话;起不来就是 undefined(路由与面板按「不可用」处理)
28
48
  */
29
- function mountP2P({ mount, warn, state, saveState, localPort, ctx, name, resolveBinary }) {
49
+ function mountP2P({ mount, warn, state, saveState, localPort, targetPort, ctx, name, resolveBinary }) {
50
+ const resolvedTargetPort = typeof targetPort === 'function' ? targetPort : localPort;
30
51
  /**
31
52
  * 端口是异步拿到的(`lanPortInUse()` 依赖 sidebar 起来),所以会话也是异步建好的。
32
53
  * 与直连隧道同一个做法:对外交**门面**,里面那格可能还是空,调用方问到的就是
@@ -44,6 +65,9 @@ function mountP2P({ mount, warn, state, saveState, localPort, ctx, name, resolve
44
65
  watch: () => (session ? session.watch() : undefined),
45
66
  start: () => (session ? session.start() : null),
46
67
  stop: () => (session ? session.stop() : Promise.resolve()),
68
+ // 会合那一发要的「本机落点端口」:**每次现取**,不缓存。局域网开关与端口都会漂,
69
+ // 缓存一个数就会在关掉局域网之后继续往一个没人听的口转。
70
+ targetPort: () => Number(resolvedTargetPort()) || 0,
47
71
  // 会合回包要报「手机在隧道里拨哪个口」。会话还没建好时也得给得出来 —— 它是固定常量,
48
72
  // 与会话在不在跑无关;漏了这一格,手机拿到空端口就判「这次没有这条通道」。
49
73
  constants: { P2P_PORT },
@@ -90,4 +114,4 @@ function mountP2P({ mount, warn, state, saveState, localPort, ctx, name, resolve
90
114
  return door;
91
115
  }
92
116
 
93
- module.exports = { mountP2P };
117
+ module.exports = { mountP2P, chooseP2PTarget };
@@ -50,6 +50,18 @@ const EVENT_KEYS = {
50
50
  error: ['code'],
51
51
  };
52
52
 
53
+ /**
54
+ * 这些事件**允许**多带一个 `peerKey`(手机公钥),其余键一个不多。
55
+ *
56
+ * 一台电脑同时接多台手机之后,助手报的 `p2pReady` / `p2pStatus` 是「哪一台的」必须能分开,
57
+ * 否则插件没法把状态路由到对应那台。但这条 IPC 的严格白名单照旧:只给这两个事件开这一个
58
+ * 可选键,其余键、其余事件多一个字节都还是脏数据。老助手不发这一格,所以是可选而不是必填。
59
+ */
60
+ const OPTIONAL_EVENT_KEYS = {
61
+ p2pReady: ['peerKey'],
62
+ p2pStatus: ['peerKey'],
63
+ };
64
+
53
65
  const ADDRESS = /^tc[\x21-\x7e]{0,4095}$/;
54
66
  const PRIVATE_KEY = /^privkey:[0-9a-f]{64}$/;
55
67
  const CLIENT_KEY = /^nodekey:[0-9a-f]{64}$/;
@@ -138,7 +150,11 @@ function createTailcatHost(options) {
138
150
  if (typeof kind !== 'string' || !EVENT_KEYS[kind]) return;
139
151
  const keys = Object.keys(value);
140
152
  const fields = EVENT_KEYS[kind];
141
- if (keys.length !== fields.length + 1 || fields.some(field => !(field in value))) return;
153
+ // 必填键一个不许少;可选键(目前只有 `peerKey`)可以没有,但有了也必须是这一格。
154
+ if (fields.some(field => !(field in value))) return;
155
+ const optional = OPTIONAL_EVENT_KEYS[kind] || [];
156
+ const extra = keys.filter(key => key !== 'event' && !fields.includes(key) && !optional.includes(key));
157
+ if (extra.length > 0) return;
142
158
 
143
159
  if (kind === 'ready' && typeof value.address === 'string' && (value.address === '' || ADDRESS.test(value.address))) {
144
160
  ready = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.26",
3
+ "version": "0.5.27",
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
6
  "icon": "./icon.png",