dsh-mobile-plugin 0.5.24 → 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.
Files changed (42) hide show
  1. package/README.md +6 -6
  2. package/knowledge/apps/alipay.md +30 -0
  3. package/knowledge/apps/android-settings.md +30 -0
  4. package/knowledge/apps/bilibili.md +30 -0
  5. package/knowledge/apps/boss-zhipin.md +32 -0
  6. package/knowledge/apps/douyin.md +32 -0
  7. package/knowledge/apps/taobao.md +33 -0
  8. package/knowledge/apps/wechat.md +33 -0
  9. package/knowledge/apps/weibo.md +31 -0
  10. package/knowledge/apps/xiaohongshu.md +29 -0
  11. package/knowledge/apps/zhihu.md +30 -0
  12. package/knowledge/sites/bilibili.com.md +27 -0
  13. package/knowledge/sites/github.com.md +27 -0
  14. package/knowledge/sites/x.com.md +28 -0
  15. package/knowledge/sites/xiaohongshu.com.md +27 -0
  16. package/knowledge/sites/youtube.com.md +28 -0
  17. package/knowledge/sites/zhihu.com.md +27 -0
  18. package/lib/impl.js +9 -0
  19. package/lib/mobile/cli/acp.js +3 -2
  20. package/lib/mobile/phone/confirm.js +189 -0
  21. package/lib/mobile/phone/index.js +21 -1
  22. package/lib/mobile/phone/irreversible.js +146 -0
  23. package/lib/mobile/phone/pacing.js +173 -0
  24. package/lib/mobile/phone/route-hint.js +164 -0
  25. package/lib/mobile/phone/routes-memory.js +133 -0
  26. package/lib/mobile/phone/routes.js +84 -0
  27. package/lib/mobile/phone/service.js +90 -5
  28. package/lib/mobile/phone/tools.js +1190 -64
  29. package/lib/mobile/phone-knowledge.js +301 -0
  30. package/lib/mobile/schedule.js +64 -7
  31. package/lib/mobile/sessions.js +40 -5
  32. package/lib/mobile/wechat.js +26 -1
  33. package/lib/p2p/rendezvous.cjs +118 -0
  34. package/lib/p2p/session.cjs +595 -0
  35. package/lib/p2p/wiring.cjs +117 -0
  36. package/lib/panel/index.html +10 -10
  37. package/lib/relay/status.js +2 -0
  38. package/lib/routes/control-plane.js +24 -1
  39. package/lib/routes/mobile-api.js +20 -1
  40. package/lib/shared/e2ee-request.js +27 -7
  41. package/lib/tailcat/host-process.cjs +54 -1
  42. package/package.json +4 -2
@@ -0,0 +1,595 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 点对点隧道(P2P)的宿主侧会话:电脑这一半的身份、配对时换公钥、以及把它拉起来。
5
+ *
6
+ * 与直连隧道(`session.cjs`)刻意分成两个模块:那条要开会合窗口、要白名单、要区分
7
+ * 「中继会合 / 直连」两种路径;这条**没有中继、也没有窗口** —— 两端的公钥在配对时换一次,
8
+ * 之后就固定了,需要知道的只有「对方通到哪」(打洞候选)。混进同一个模块会让两边各有一半
9
+ * 用不上的字段,也容易把「打不通报 nat-blocked」和「中继没会合」说成同一件事。
10
+ *
11
+ * **一台电脑同时服务多台手机**:每把手机公钥各有一套状态(`peers` 那张表),助手那边每条
12
+ * 隧道也各是独立的 netstack,同一个隧道内端口在各自栈里互不冲突。所以这里不再有「谁先来
13
+ * 归谁、后来者回 busy」—— 那种退让是单条隧道时的权宜,多台共存之后它只会把第二台永远挡在
14
+ * 外面。
15
+ *
16
+ * 三件必须一直成立:
17
+ * 1. 私钥落在插件自己的存储里(`relay.json`),助手不落盘 —— 插件永远是唯一真相。
18
+ * 2. 公钥可以明着给出去:配对二维码里那份、以及 `/connection-info` 报的都是公钥。
19
+ * 3. 打洞失败如实报,绝不改口成「已连通」:这条通道没有中继兜底,装不出来。
20
+ *
21
+ * 助手是可选依赖,插件加载时可能还没有。所以与直连隧道一样,只在入口上重探一次,
22
+ * 不做定时扫描。
23
+ */
24
+
25
+ const { createTailcatHost, resolveBinary } = require('../tailcat/host-process.cjs');
26
+
27
+ /** 存进 relay.json 的四个键(与直连隧道那一组并列,互不影响)。 */
28
+ const STATE = {
29
+ enabled: 'p2pEnabled',
30
+ privateKey: 'p2pPrivateKey',
31
+ publicKey: 'p2pPublicKey',
32
+ /** 对端(手机)那把公钥:配对或会合时换进来的,拉起隧道时要拿它对握手。 */
33
+ peerKey: 'p2pPeerKey',
34
+ };
35
+
36
+ /**
37
+ * 电脑在**隧道里**听的那个端口(手机拨它)。与手机侧 `p2p.port` 同一个数。
38
+ *
39
+ * 它与「本机插件端口」是两件事:那个口是本机的,隧道里根本看不见。多台手机也是同一个
40
+ * 数 —— 每条隧道各有独立的用户态网络栈,同一个端口号在各自栈里互不冲突。
41
+ */
42
+ const P2P_PORT = 48950;
43
+
44
+ /** 把助手报上来的候选收成字符串数组(对端给的任何东西都当不可信数据)。 */
45
+ function candidateList(value) {
46
+ if (!Array.isArray(value)) return [];
47
+ return value.filter((item) => typeof item === 'string' && item.length > 0 && item.length <= 64).slice(0, 16);
48
+ }
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
+
78
+ /** 等助手把隧道起来 / 报一次状态的上限。 */
79
+ const P2P_TIMEOUT_MS = 10_000;
80
+
81
+ /**
82
+ * @param {object} options
83
+ * @param {() => object} options.readState 读盘上的配置
84
+ * @param {(patch: object) => void} options.writeState 写回
85
+ * @param {number} options.localPort 插件本机端口(隧道里唯一的去处)
86
+ * @param {(message: string) => void} [options.log]
87
+ * @param {object} [options.host] 测试注入的助手句柄
88
+ * @param {() => (string|null)} [options.resolveBinary] 测试注入的查找函数
89
+ */
90
+ function createP2PSession(options) {
91
+ const log = options.log || (() => {});
92
+ const findBinary = typeof options.resolveBinary === 'function' ? options.resolveBinary : resolveBinary;
93
+ let binary = options.host && !options.resolveBinary ? 'injected' : findBinary();
94
+ let host = null;
95
+ let lastError = '';
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
+
165
+ /**
166
+ * 正在等助手回执的那些请求(**一组,不是一个**)。
167
+ *
168
+ * 早先这里只放得下「一个」,而会话起好之后状态轮询每 2 秒就要问一次现状、它也会占这个位。
169
+ * 后果很致命:手机的会合请求刚把等待位放进去,下一拍轮询就把它顶掉,于是助手回的那条
170
+ * `p2pReady` 交给了轮询、会合这一发永远等不到回执 —— 表现是「会合必超时、这条路永远接不上」,
171
+ * 而电脑那头一切正常。轮询与会合本来就会同时等着,两个都得能收到同一条事件。
172
+ */
173
+ const waiters = new Set();
174
+ /** 最近一次配对 / 会合换进来的手机公钥(老助手事件没有 peerKey 时靠它兜底路由)。 */
175
+ let lastPeer = '';
176
+ /** 状态轮询定时器与开关(见 `watch`)。 */
177
+ let timer = null;
178
+ let polling = false;
179
+ /** 这一进程里有没有真的被会合拉起过一条隧道(用来区分「还没人接」与「正在起」)。 */
180
+ let everOpened = false;
181
+
182
+ function snapshot() {
183
+ try {
184
+ const state = options.readState() || {};
185
+ return {
186
+ enabled: state[STATE.enabled] !== false,
187
+ privateKey: typeof state[STATE.privateKey] === 'string' ? state[STATE.privateKey] : '',
188
+ publicKey: typeof state[STATE.publicKey] === 'string' ? state[STATE.publicKey] : '',
189
+ // 对端公钥:进程重启之后内存表是空的,得从盘上那份接回来 —— 否则电脑会用空的对端
190
+ // 公钥去配设备,起不来,表现成「这条通道一直在启动中」。盘上只存得下**最后一台**
191
+ // 会合过的手机,其余那几台等它们下一轮会合(60 秒)自然会补齐。
192
+ peerKey: typeof state[STATE.peerKey] === 'string' ? state[STATE.peerKey] : '',
193
+ };
194
+ } catch {
195
+ return { enabled: true, privateKey: '', publicKey: '', peerKey: '' };
196
+ }
197
+ }
198
+
199
+ function persist(patch) {
200
+ try {
201
+ options.writeState(patch);
202
+ return true;
203
+ } catch (error) {
204
+ log(`P2P 隧道:写配置失败 ${error && error.message ? error.message : error}`);
205
+ return false;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * 唤醒等待者。
211
+ *
212
+ * 多台手机之后这里要按公钥分道:两台同时会合时,A 那条 `p2pReady` 绝不能拿去回 B ——
213
+ * 拿着别人的地址当自己的,B 会往一个自己够不到的 URL 上走。所以等待者按公钥认领,
214
+ * 只唤醒「等这一台的」与「不挑的」(轮询)。
215
+ *
216
+ * `key` 为空串是**认不出是哪台**的情况(老助手的事件里没有公钥):这时一律广播 ——
217
+ * 老助手一条进程只有一条隧道,它报的就是「有隧道起来了」,分道反而会把等着的那台饿死。
218
+ */
219
+ function settle(value, key = '') {
220
+ for (const done of [...waiters]) {
221
+ if (key && done.key && done.key !== key) continue;
222
+ waiters.delete(done);
223
+ clearTimeout(done.timer);
224
+ done.resolve(value);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 排一个等待者,超时自己摘掉(助手没答就回 null,别把内存漏下去)。
230
+ *
231
+ * `key` 是这台手机的公钥(不挑就留空)。超时那一支还要顺手把这一格的「正在发起」清掉 ——
232
+ * 不清的话,那一格永远是 dialing,下一轮同候选的会合会被判成「已经在起,复用」,
233
+ * 于是这台就再也不会重新发起(看起来像「隧道卡住了、怎么等都不动」)。
234
+ */
235
+ function await_(resolve, key = '') {
236
+ const done = {
237
+ key,
238
+ resolve,
239
+ timer: setTimeout(() => {
240
+ waiters.delete(done);
241
+ const entry = key ? peers.get(key) : null;
242
+ if (entry) entry.dialing = false;
243
+ resolve(null);
244
+ }, P2P_TIMEOUT_MS),
245
+ };
246
+ done.timer.unref?.();
247
+ waiters.add(done);
248
+ }
249
+
250
+ function handleEvent(event) {
251
+ switch (event.event) {
252
+ case 'p2pReady': {
253
+ lastError = '';
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);
260
+ // 候选必须带出去:会合那一发要把它还给手机,否则手机不知道往哪打洞。
261
+ settle({ ok: true, url: entry.url, candidates: entry.candidates }, key);
262
+ break;
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);
275
+ break;
276
+ }
277
+ case 'p2pKeys':
278
+ // 助手生成了密钥对:私钥存插件、公钥留给配对与状态用(公钥可以明着给出去)。
279
+ if (typeof event.private === 'string' && typeof event.public === 'string') {
280
+ persist({ [STATE.privateKey]: event.private, [STATE.publicKey]: event.public });
281
+ }
282
+ settle({ ok: true, publicKey: typeof event.public === 'string' ? event.public : '' });
283
+ break;
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
+ }
293
+ settle({ ok: true, running: false });
294
+ break;
295
+ }
296
+ case 'error':
297
+ lastError = event.code;
298
+ // 助手报错意味着这一轮「正在发起」已经落空:不清这一位的话,那一格永远是 dialing,
299
+ // 下一轮同候选的会合会被判成「已经在起,复用」,这台就再也重新发不起来了。
300
+ for (const entry of peers.values()) entry.dialing = false;
301
+ settle({ ok: false, code: event.code });
302
+ break;
303
+ default:
304
+ break;
305
+ }
306
+ }
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
+
320
+ /** 只在入口上重探一次(与直连隧道同一条规矩:不做定时扫描)。 */
321
+ function locate() {
322
+ if (binary) return binary;
323
+ binary = findBinary();
324
+ if (binary) log(`P2P 隧道:找到助手 ${binary}`);
325
+ return binary;
326
+ }
327
+
328
+ function start() {
329
+ if (host) return host;
330
+ if (!locate()) {
331
+ lastError = 'helper-missing';
332
+ return null;
333
+ }
334
+ if (lastError === 'helper-missing') lastError = '';
335
+ const state = snapshot();
336
+ if (!state.enabled) return null;
337
+ host = options.host || createTailcatHost({
338
+ localPort: options.localPort,
339
+ state: null,
340
+ // P2P 没有白名单:能连上的前提是握手时用的那把私钥对得上,比白名单更直接。
341
+ allowedClients: [],
342
+ onEvent: handleEvent,
343
+ });
344
+ host.start();
345
+ // 还没生成过密钥对就先催一次。
346
+ //
347
+ // **不能只在配对那一刻催**:面板要显示「我这边的公钥」,手机要拿它配对端,而这两件事
348
+ // 都不该等到有人点开二维码才发生。更关键的是——不催就不会有 `p2pKeys` 事件,
349
+ // 也就没有公钥;面板上那格永远是空、状态永远是「正在启动」。早先只有 `exchange()`
350
+ // 里那一处催法,于是没开过码的机器上这条通道一直是死的。
351
+ if (!state.privateKey || !state.publicKey) {
352
+ if (typeof host.newKeysP2P === 'function') host.newKeysP2P();
353
+ }
354
+ return host;
355
+ }
356
+
357
+ function stop() {
358
+ unwatch();
359
+ everOpened = false;
360
+ lastPeer = '';
361
+ peers.clear();
362
+ const current = host;
363
+ host = null;
364
+ if (current) return current.close();
365
+ return Promise.resolve();
366
+ }
367
+
368
+ /**
369
+ * 配对时换一次公钥:手机把自己的公钥报上来,同时拿走电脑这把。
370
+ *
371
+ * 电脑的密钥对在第一次被问到时生成一次,之后一直用同一对 —— 换一对等于所有已配对
372
+ * 手机都得重新配,那没有理由。
373
+ */
374
+ function exchange(peerPublicKey) {
375
+ const state = snapshot();
376
+ if (!state.privateKey || !state.publicKey) {
377
+ // 还没生成过密钥对:这一步要等助手(Go 那边生成,它才懂 WireGuard 的密钥格式)。
378
+ // 入口上先**把助手拉起来**再催一次 —— 只 `if (host)` 的话,助手还没起来时这一催就是空的,
379
+ // 于是下次开码还是拿不到身份,用户看到的是「扫了码但没有这条通道」。
380
+ const live = start();
381
+ if (live && typeof live.newKeysP2P === 'function') live.newKeysP2P();
382
+ return { ok: false, error: 'no-identity', port: P2P_PORT };
383
+ }
384
+ if (typeof peerPublicKey === 'string' && peerPublicKey) {
385
+ persist({ [STATE.peerKey]: peerPublicKey });
386
+ lastPeer = peerPublicKey;
387
+ }
388
+ // 候选也要带出去:二维码里那一段是手机打洞的起点,空着就只能靠「只听不打」等握手。
389
+ const entry = (lastPeer && peers.get(lastPeer)) || recentPeer();
390
+ return {
391
+ ok: true,
392
+ publicKey: state.publicKey,
393
+ port: P2P_PORT,
394
+ peerKey: peerPublicKey,
395
+ candidates: entry ? entry.candidates || [] : [],
396
+ localCandidates: [],
397
+ };
398
+ }
399
+
400
+ /**
401
+ * 面板与路由看到的现状。这里不重探,理由同直连隧道那条。
402
+ *
403
+ * **字段名一个都没动**(手机 `/status` 心跳读的就是它们)。多台手机之后语义是:
404
+ * 老字段取「最近活跃的那一条」,另外追加一份 `peers` 按手机公钥的详单给面板用。
405
+ */
406
+ function status() {
407
+ const state = snapshot();
408
+ if (!binary) {
409
+ return {
410
+ available: false,
411
+ enabled: state.enabled,
412
+ ready: false,
413
+ started: false,
414
+ url: '',
415
+ path: 'unknown',
416
+ active: false,
417
+ error: 'helper-missing',
418
+ peers: [],
419
+ };
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) }));
428
+ return {
429
+ available: true,
430
+ enabled: state.enabled,
431
+ ready: current.running,
432
+ // 有没有被手机叫起来过。`ready:false` + `started:false` 说的不是「正在启动」,
433
+ // 而是「还没有手机来会合」—— 电脑这半边本来就是等人来打(「只听不打」)。
434
+ started: everOpened,
435
+ url: current.url,
436
+ path: current.path,
437
+ active: current.active,
438
+ detail: current.detail,
439
+ // 我自己的打洞候选也要给出去:手机那半边靠它知道往哪打。
440
+ candidates: current.candidates || [],
441
+ publicKey: state.publicKey,
442
+ error: lastError,
443
+ // 追加字段(不是老字段的改名):每台手机那条隧道各是什么样,面板要能分开看。
444
+ peers: roster,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * 按手机给的公钥与候选把电脑侧那一半拉起来,并回手机要的那份(我自己这边的候选)。
450
+ *
451
+ * **幂等**:会合是手机每 60 秒会再来一次的心跳。同一把手机公钥、同一份候选、**它那条**
452
+ * 隧道又在跑,就直接把现状回过去,**绝不重开** —— 重开等于把已经打通的洞每分钟踢掉一次,
453
+ * 用户感受到的就是「网一会儿通一会儿断」。候选**真的变了**才按新的重开。
454
+ *
455
+ * 判据从「全局唯一那个对端」变成「**这台手机自己那一份**」:多台共存之后,两台手机各判
456
+ * 各的,一台候选变了不会连累另一台。
457
+ */
458
+ function open(options = {}) {
459
+ everOpened = true;
460
+ const current = start();
461
+ if (!current) return Promise.resolve(null);
462
+ const state = snapshot();
463
+ if (!state.privateKey || !state.publicKey) return Promise.resolve(null);
464
+
465
+ // 手机把自己的公钥带过来了:存下来、当场用上。只有两端公钥互相对得上,WireGuard 才肯
466
+ // 完成握手 —— 拿不到手机那把,电脑配给设备的对端公钥要么是空的(起不来)、要么是上一台
467
+ // 手机的(握手永远失败,看着像「打洞打不通」)。
468
+ //
469
+ // **对端只认这一格**。手机发来的 `peerKey` 里装的是**电脑自己的公钥**(它拿它确认「对面
470
+ // 是那台电脑」),早先这里 `options.peerKey || currentPeer` 直接取前者,于是把电脑自己的
471
+ // 公钥配成了 WireGuard 对端 —— 电脑在跟自己握手。洞照样能开(UDP 打洞与密钥无关,所以
472
+ // `path` 会报 `direct`、面板一片绿),但数据面一个包也过不去,手机那条回环端口连上去
473
+ // 永远没有响应。这一格的名字与语义错位了一年,别再把它当对端用。
474
+ const phonePublicKey = typeof options.phonePublicKey === 'string' ? options.phonePublicKey.trim() : '';
475
+ // 先只**算**出这次该用哪把对端公钥,**不落盘**:落盘要在复用判定之后 —— 顺序反过来的话,
476
+ // 一次会被判成复用的会合也会去改写盘上那份,而盘上只有一格(最后一台会合的手机)。
477
+ const peerKey = String(phonePublicKey || lastPeer || snapshot().peerKey || '');
478
+ // 打一行现场:两端公钥对不对得上,是这条通道唯一真正致命的判据 ——
479
+ // 错了洞照样开、`path` 照样报 `direct`,但数据面一个包都过不去,从现象上看不出区别。
480
+ const seen = peers.get(peerKey);
481
+ log(`P2P 隧道:会合 phonePublicKey=${phonePublicKey ? `${phonePublicKey.slice(0, 6)}…` : '(空)'}`
482
+ + ` 采用的对端=${peerKey ? `${peerKey.slice(0, 6)}…` : '(空)'}`
483
+ + ` 自己=${state.publicKey.slice(0, 6)}… 在跑=${seen ? seen.running : false}`);
484
+ const candidates = Array.isArray(options.candidates) ? options.candidates : [];
485
+ const localCandidates = Array.isArray(options.localCandidates) ? options.localCandidates : [];
486
+ const wantedCandidates = { public: candidateSet(candidates), local: candidateSet(localCandidates) };
487
+
488
+ // **同一把手机公钥 + 同一份候选,才复用该手机自己那条。**
489
+ //
490
+ // 「在跑」不足以说明「跑的是你」:这条通道早先一次只服务一个对端,多台手机同时来会合
491
+ // 时,谁先来隧道就归谁 —— 后来那台每次会合都只拿到别人的现状。现在每把公钥各一套状态,
492
+ // 判据天然落在「它自己那份」上。
493
+ //
494
+ // **候选也得一起判**:电脑第一次往往是在手机还没报出候选时以「只听不打」起来的,之后
495
+ // 手机把真实候选报上来;只判公钥就一直复用那个没有对端地址的隧道,电脑永远不往手机打。
496
+ // 局域网还好(手机摸得到内网地址、从那边打过来了),一到公网就只剩下「等待」。
497
+ if (peerKey && seen && (seen.running || seen.dialing)
498
+ && sameCandidates(wantedCandidates, seen.runningCandidates)) {
499
+ return Promise.resolve({
500
+ ok: true,
501
+ running: true,
502
+ url: seen.url,
503
+ path: seen.path,
504
+ active: seen.active,
505
+ candidates: seen.candidates || [],
506
+ // 我这一把公钥:会合回包要把它带给手机(手机拿它配对端)。
507
+ publicKey: state.publicKey,
508
+ });
509
+ }
510
+
511
+ // 复用的门过了,才把对端公钥落盘。
512
+ if (peerKey && peerKey !== lastPeer) persist({ [STATE.peerKey]: peerKey });
513
+ if (peerKey) lastPeer = peerKey;
514
+ const entry = ensurePeer(peerKey);
515
+
516
+ return new Promise((resolve) => {
517
+ // 助手回的那几格(url / path / candidates)里没有公钥,在这里补上 —— 会合回包要用。
518
+ // **回调里顺手开轮询**:会合只等到「本机这半边起来了」,打洞结果要再问几次才有。
519
+ await_((reply) => {
520
+ watch();
521
+ resolve(reply && reply.ok ? { ...reply, publicKey: state.publicKey } : reply);
522
+ }, peerKey);
523
+ // 记下「这条隧道是按哪份候选起来的」:下一次会合据此判要不要重开。在这里记而不是等
524
+ // `p2pReady`:请求已经发给助手了,这中间再来一发会合必须认同一份,否则会连发两条
525
+ // `p2pStart`,两条各自开一次 socket、洞白开。
526
+ entry.dialing = true;
527
+ entry.runningCandidates = wantedCandidates;
528
+ current.startP2P({
529
+ privateKey: state.privateKey,
530
+ peerKey,
531
+ peerPublic: candidates,
532
+ peerLocal: localCandidates,
533
+ // 两个端口各司其职:listen 是手机在隧道里拨的,target 是本机插件在听的那个。
534
+ listenPort: P2P_PORT,
535
+ targetPort: Number(options.targetPort) || 0,
536
+ });
537
+ });
538
+ }
539
+
540
+ /** 问一次「打通了没有」。打洞是异步的,界面轮询这条。 */
541
+ function refresh() {
542
+ if (!host) return Promise.resolve(null);
543
+ return new Promise((resolve) => {
544
+ await_(resolve);
545
+ host.statusP2P();
546
+ });
547
+ }
548
+
549
+ /**
550
+ * 定时问一次现状。
551
+ *
552
+ * **面板与 `/api/relay/p2p` 读的都是事件更新出来的那张表,而它只会被助手的事件更新** ——
553
+ * 没人问,助手就不报;没人报,面板就永远停在「正在启动」。早先 `refresh()` 写好了却
554
+ * 没有任何调用点(路由与面板都没接),于是这条通道在界面上永远是「正在启动」,
555
+ * 哪怕它其实起得挺好。
556
+ *
557
+ * 只在**已经拉起过隧道**之后才问:没起过时不发,免得给一个必然超时的空转。
558
+ * `unref` 掉,别让这个定时器把进程钉住。
559
+ */
560
+ function watch(intervalMs = 2000) {
561
+ if (polling) return;
562
+ polling = true;
563
+ const tick = () => {
564
+ if (!host) return;
565
+ void refresh();
566
+ };
567
+ timer = setInterval(tick, intervalMs);
568
+ timer.unref?.();
569
+ }
570
+
571
+ function unwatch() {
572
+ polling = false;
573
+ if (timer) {
574
+ clearInterval(timer);
575
+ timer = null;
576
+ }
577
+ }
578
+
579
+ return {
580
+ start,
581
+ stop,
582
+ status,
583
+ open,
584
+ refresh,
585
+ watch,
586
+ unwatch,
587
+ exchange,
588
+ /** 测试缝:直接喂一条助手事件(真实路径是子进程 stdout)。 */
589
+ _event: handleEvent,
590
+ constants: { P2P_PORT },
591
+ stateKeys: STATE,
592
+ };
593
+ }
594
+
595
+ module.exports = { createP2PSession, STATE, P2P_PORT };