dsh-mobile-plugin 0.1.92 → 0.1.93
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/README.md +2 -0
- package/config/agent-presets/standard/agent.cordis.yml +7 -4
- package/lib/impl.js +6 -1
- package/lib/mobile/attachments.js +23 -6
- package/lib/mobile/history-derive.js +53 -7
- package/lib/mobile/host-bridge/shims.js +3 -2
- package/lib/mobile/session-models.js +6 -0
- package/lib/mobile/session-preview.js +70 -0
- package/lib/mobile/session-send.js +8 -2
- package/lib/mobile/session-snapshot.js +4 -2
- package/lib/mobile/sessions.js +50 -58
- package/lib/mobile/stream.js +44 -5
- package/lib/mobile/uploads.js +14 -7
- package/lib/mobile/workspaces.js +17 -3
- package/lib/panel/index.html +7 -7
- package/lib/relay/lan-network.cjs +156 -12
- package/lib/relay/lan-proxy.js +75 -5
- package/lib/routes/control-plane.js +11 -5
- package/lib/routes/mobile-api.js +4 -0
- package/lib/shared/list-revision.js +20 -1
- package/lib/update-install.cjs +71 -13
- package/package.json +2 -2
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 网卡枚举与候选地址:私网 /
|
|
2
|
+
* 网卡枚举与候选地址:私网 / 公网 / IPv6 全球单播三份清单,外加「用户选中的那条排最前」。
|
|
3
3
|
*
|
|
4
4
|
* 收口在这一个文件里,是因为 App 只认「按顺序挨个试」这一种语义 —— 顺序就是优先级。
|
|
5
|
+
*
|
|
6
|
+
* 三份的区别在**手机在哪**:私网那份是「同一个 Wi-Fi 里能直连」,公网那份是「这台机器
|
|
7
|
+
* 自己就挂在公网上」,IPv6 那份是「手机在别的网络里、从公网拨过来」——最后这份是 2026-10
|
|
8
|
+
* 新加的,它**不并进前两份**,因为 App 对它的用法完全不同(要和公网中转比速度,见
|
|
9
|
+
* `ipv6Candidates` 的注释)。
|
|
5
10
|
*/
|
|
6
11
|
'use strict'
|
|
7
12
|
|
|
@@ -94,13 +99,19 @@ function interfaceRank(name) {
|
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
/**
|
|
97
|
-
* 扫一遍网卡,把 `accept`
|
|
98
|
-
*
|
|
102
|
+
* 扫一遍网卡,把 `accept` 认的、属于 `family` 的地址收成候选(剔除虚拟/VPN 网卡、按物理
|
|
103
|
+
* 网卡优先级排序、去重、最多 16 条)。
|
|
104
|
+
*
|
|
105
|
+
* 三份清单共用这一段:差别**只在 accept 与 family**——一个挑私网 v4、一个挑公网 v4、
|
|
106
|
+
* 一个挑全球单播 v6。网卡黑名单与排序规则必须一模一样,否则同一台机器上几份候选会给出
|
|
107
|
+
* 互相矛盾的顺序。
|
|
99
108
|
*
|
|
100
|
-
* `
|
|
101
|
-
*
|
|
109
|
+
* 去重按 `key`:IPv4 是地址本身,IPv6 是解出来的数值 —— 同一个地址的压缩写法、展开写法
|
|
110
|
+
* 与大小写(`240e::1` / `240E:0:0:0:0:0:0:1`)必须算成一条,否则 App 会为同一个地址
|
|
111
|
+
* 各探一次。
|
|
102
112
|
*/
|
|
103
|
-
function collectEndpoints(interfaces, accept) {
|
|
113
|
+
function collectEndpoints(interfaces, accept, family = 'IPv4') {
|
|
114
|
+
const wantV6 = family === 'IPv6'
|
|
104
115
|
const found = []
|
|
105
116
|
let order = 0
|
|
106
117
|
for (const [name, entries] of Object.entries(interfaces || {})) {
|
|
@@ -108,24 +119,46 @@ function collectEndpoints(interfaces, accept) {
|
|
|
108
119
|
if (isVpnInterface(name) || isVirtualInterface(name)) continue
|
|
109
120
|
for (const entry of entries || []) {
|
|
110
121
|
if (!entry || entry.internal) continue
|
|
111
|
-
|
|
122
|
+
// family 必须**显式**对上:缺字段的畸形条目不参与任何一份清单
|
|
123
|
+
// (`test/lan-network.test.cjs` 的「空 / 异常入参不抛错」钉着这一条)。
|
|
124
|
+
const entryV6 = entry.family === 'IPv6' || entry.family === 6
|
|
125
|
+
const entryV4 = entry.family === 'IPv4' || entry.family === 4
|
|
126
|
+
if (wantV6 ? !entryV6 : !entryV4) continue
|
|
112
127
|
if (!accept(entry.address)) continue
|
|
113
|
-
found.push({
|
|
128
|
+
found.push({
|
|
129
|
+
name,
|
|
130
|
+
address: entry.address,
|
|
131
|
+
order: current,
|
|
132
|
+
key: wantV6 ? String(ipv6Number(entry.address)) : entry.address,
|
|
133
|
+
})
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
const seen = new Set()
|
|
117
137
|
return found
|
|
118
138
|
.sort((a, b) => interfaceRank(a.name) - interfaceRank(b.name) || a.order - b.order)
|
|
119
139
|
.filter((item) => {
|
|
120
|
-
if (seen.has(item.
|
|
121
|
-
seen.add(item.
|
|
140
|
+
if (seen.has(item.key)) return false
|
|
141
|
+
seen.add(item.key)
|
|
122
142
|
return true
|
|
123
143
|
})
|
|
124
144
|
.slice(0, 16)
|
|
125
145
|
}
|
|
126
146
|
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
/**
|
|
148
|
+
* 产候选。IPv6 在 URL 里**必须带方括号**(`http://[240e::1]:18787`),裸着写出来是
|
|
149
|
+
* `http://240e::1:18787` —— 一个解析不出来的坏地址。
|
|
150
|
+
*
|
|
151
|
+
* `address` 字段刻意**不带**方括号:面板的「手机优先用这条」是按地址匹配的
|
|
152
|
+
* (`orderCandidates`),用户看到与填进去的都是裸地址。
|
|
153
|
+
*/
|
|
154
|
+
function toCandidates(list, port, family = 'IPv4') {
|
|
155
|
+
const v6 = family === 'IPv6'
|
|
156
|
+
return list.map((item) => ({
|
|
157
|
+
url: `http://${v6 ? `[${item.address}]` : item.address}:${port}`,
|
|
158
|
+
interface: item.name,
|
|
159
|
+
family,
|
|
160
|
+
address: item.address,
|
|
161
|
+
}))
|
|
129
162
|
}
|
|
130
163
|
|
|
131
164
|
// Structured view of the usable LAN endpoints. The relay's `normalizeLanMetadata`
|
|
@@ -185,6 +218,112 @@ function publicCandidates(port, interfaces = os.networkInterfaces()) {
|
|
|
185
218
|
return publicInterfaces(port, interfaces)
|
|
186
219
|
}
|
|
187
220
|
|
|
221
|
+
/**
|
|
222
|
+
* 把 IPv6 文本解成 8 段 16 位十六进制,处理内嵌 IPv4 尾巴、压缩写法与 zone id。
|
|
223
|
+
* 解不出来一律回 null —— 判据函数宁可不认,也不能把一串看不懂的东西当地址发出去。
|
|
224
|
+
*/
|
|
225
|
+
function ipv6Groups(text) {
|
|
226
|
+
const [head, tail, ...rest] = text.split('::')
|
|
227
|
+
if (rest.length) return null
|
|
228
|
+
const toGroups = (part) => (part === '' ? [] : part.split(':'))
|
|
229
|
+
const headGroups = toGroups(head)
|
|
230
|
+
const tailGroups = tail === undefined ? [] : toGroups(tail)
|
|
231
|
+
// 内嵌 IPv4 尾巴(`::ffff:192.168.1.1`、`64:ff9b::1.2.3.4`)折成两段。
|
|
232
|
+
const last = tailGroups.length ? tailGroups : headGroups
|
|
233
|
+
const embedded = last.length ? last[last.length - 1] : ''
|
|
234
|
+
if (embedded.includes('.')) {
|
|
235
|
+
const value = ipv4Number(embedded)
|
|
236
|
+
if (value === null) return null
|
|
237
|
+
last.splice(last.length - 1, 1, ((value >>> 16) & 0xffff).toString(16), (value & 0xffff).toString(16))
|
|
238
|
+
}
|
|
239
|
+
const isHexGroup = (group) => /^[0-9a-f]{1,4}$/.test(group)
|
|
240
|
+
if (![...headGroups, ...tailGroups].every(isHexGroup)) return null
|
|
241
|
+
if (tail === undefined) return headGroups.length === 8 ? headGroups : null
|
|
242
|
+
const fill = 8 - headGroups.length - tailGroups.length
|
|
243
|
+
if (fill < 1) return null
|
|
244
|
+
return [...headGroups, ...Array(fill).fill('0'), ...tailGroups]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** IPv6 文本 → 128 位数值;非法回 null。方括号与 `%en0` 这类 zone id 都先剥掉。 */
|
|
248
|
+
function ipv6Number(address) {
|
|
249
|
+
const text = String(address === null || address === undefined ? '' : address)
|
|
250
|
+
.trim()
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
.replace(/^\[/, '')
|
|
253
|
+
.replace(/\]$/, '')
|
|
254
|
+
.split('%')[0]
|
|
255
|
+
if (!text.includes(':')) return null
|
|
256
|
+
const groups = ipv6Groups(text)
|
|
257
|
+
if (!groups) return null
|
|
258
|
+
let value = 0n
|
|
259
|
+
for (const group of groups) value = (value << 16n) | BigInt(parseInt(group, 16))
|
|
260
|
+
return value
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function inV6Prefix(value, prefix, length) {
|
|
264
|
+
const shift = BigInt(128 - length)
|
|
265
|
+
return value >> shift === prefix >> shift
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 值得上报的 IPv6 地址:**全球单播**(`2000::/3`)且不是下面这三种假公网。
|
|
270
|
+
*
|
|
271
|
+
* - `2002::/16`(6to4):地址里的那截 IPv4 要靠一台公网中继转,今天基本没人还在跑中继,
|
|
272
|
+
* 报出去就是一个拨不通的地址;
|
|
273
|
+
* - `2001::/32`(Teredo):隧道端点,本身不是能直接连的服务地址;
|
|
274
|
+
* - `2001:db8::/32`:文档示例段。
|
|
275
|
+
*
|
|
276
|
+
* 其余非全球单播的一律不收:链路本地(`fe80::/10`)只在本链路内通,手机拿不到;
|
|
277
|
+
* ULA(`fc00::/7`)要同一个内网前缀,手机同样拿不到 —— 报了都是死地址;已废弃的站点本地
|
|
278
|
+
* `fec0::/10`、组播、回环、未指定、IPv4-mapped 同理。
|
|
279
|
+
*
|
|
280
|
+
* **不做**的事:不猜哪条是 RFC 4941 临时地址(`os.networkInterfaces()` 根本不暴露这个标志,
|
|
281
|
+
* 硬造的判据只会误伤稳定地址)。同一块网卡上并存的一条稳定地址与两条临时地址会一起报出去
|
|
282
|
+
* ——临时地址也是全球单播、当下也真的能连,等它轮换掉时 App 早就在下一轮发现里拿到新清单了。
|
|
283
|
+
*/
|
|
284
|
+
const V6_GLOBAL_UNICAST = [ipv6Number('2000::'), 3]
|
|
285
|
+
const V6_NOT_GLOBAL = [
|
|
286
|
+
['2002::', 16],
|
|
287
|
+
['2001::', 32],
|
|
288
|
+
['2001:db8::', 32],
|
|
289
|
+
].map(([spec, length]) => [ipv6Number(spec), length])
|
|
290
|
+
|
|
291
|
+
function isGlobalUnicastV6(address) {
|
|
292
|
+
const value = ipv6Number(address)
|
|
293
|
+
if (value === null) return false
|
|
294
|
+
if (!inV6Prefix(value, V6_GLOBAL_UNICAST[0], V6_GLOBAL_UNICAST[1])) return false
|
|
295
|
+
return !V6_NOT_GLOBAL.some(([prefix, length]) => inV6Prefix(value, prefix, length))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* IPv6 全球单播候选:手机**不在同一个 Wi-Fi 里**时唯一有可能直连进来的地址。
|
|
300
|
+
*
|
|
301
|
+
* 为什么单独一份、不并进 `lanInterfaces`:那份的语义是「手机在同一 Wi-Fi 里能直连」,
|
|
302
|
+
* App 对它的处理是「在网就在、不在就连不上」——共用退避窗口的话,手机刚离开家里 Wi-Fi
|
|
303
|
+
* 的那一刻(正是最该试 IPv6 的时刻)局域网一进退避就把 IPv6 一起压住了。App 侧对应一条
|
|
304
|
+
* 独立通道,还要拿它和公网中转**比速度**,所以这里只负责如实产出、不去猜哪条更快。
|
|
305
|
+
*
|
|
306
|
+
* 最多 4 条:这台机器上通常是一到三条(一条稳定地址 + 一两条临时地址)。上限是为了二维码
|
|
307
|
+
* ——候选是随二维码一起带走的,条数一多二维码就密到扫不动。
|
|
308
|
+
*/
|
|
309
|
+
const IPV6_CANDIDATE_MAX = 4
|
|
310
|
+
function ipv6Interfaces(port, interfaces = os.networkInterfaces()) {
|
|
311
|
+
return toCandidates(collectEndpoints(interfaces, isGlobalUnicastV6, 'IPv6'), port, 'IPv6')
|
|
312
|
+
.slice(0, IPV6_CANDIDATE_MAX)
|
|
313
|
+
}
|
|
314
|
+
function ipv6Urls(port, interfaces = os.networkInterfaces()) {
|
|
315
|
+
return ipv6Interfaces(port, interfaces).map((candidate) => candidate.url)
|
|
316
|
+
}
|
|
317
|
+
function ipv6Candidates(port, interfaces = os.networkInterfaces()) {
|
|
318
|
+
// 与 `advertisedCandidates` 同一条口径:取不到网卡(异常)时回空清单、绝不抛 —— 它喂的
|
|
319
|
+
// 也是二维码与 `/connection-info`,把它整条控制面拖下水不值得。
|
|
320
|
+
try {
|
|
321
|
+
return ipv6Interfaces(port, interfaces)
|
|
322
|
+
} catch {
|
|
323
|
+
return []
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
188
327
|
/**
|
|
189
328
|
* 对外公布的候选地址:**先私网,一个都没有时才退到公网**。
|
|
190
329
|
*
|
|
@@ -234,9 +373,11 @@ function orderCandidates(list, preferred) {
|
|
|
234
373
|
|
|
235
374
|
module.exports = {
|
|
236
375
|
ipv4Number,
|
|
376
|
+
ipv6Number,
|
|
237
377
|
isPrivateLanAddress,
|
|
238
378
|
isLanCandidateAddress,
|
|
239
379
|
isPublicAddress,
|
|
380
|
+
isGlobalUnicastV6,
|
|
240
381
|
isVpnInterface,
|
|
241
382
|
isVirtualInterface,
|
|
242
383
|
interfaceRank,
|
|
@@ -246,6 +387,9 @@ module.exports = {
|
|
|
246
387
|
publicInterfaces,
|
|
247
388
|
publicUrls,
|
|
248
389
|
publicCandidates,
|
|
390
|
+
ipv6Interfaces,
|
|
391
|
+
ipv6Urls,
|
|
392
|
+
ipv6Candidates,
|
|
249
393
|
advertisedCandidates,
|
|
250
394
|
orderCandidates,
|
|
251
395
|
}
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
const http = require('node:http');
|
|
15
15
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
16
16
|
const core = require('./core.js');
|
|
17
|
+
const { isGlobalUnicastV6 } = require('./lan-network.cjs');
|
|
17
18
|
const { safeEqual } = require('../shared/dshtoken');
|
|
18
19
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
19
20
|
const credentials = require('../shared/credentials-entry.js').credentialsEntry();
|
|
@@ -62,6 +63,8 @@ function createLanProxy(opts) {
|
|
|
62
63
|
} = opts;
|
|
63
64
|
|
|
64
65
|
let server = null;
|
|
66
|
+
// IPv6 那条监听(`::`,ipv6Only)。它可能压根没建立(机器没有 IPv6),所以单独存一份。
|
|
67
|
+
let v6Server = null;
|
|
65
68
|
let wss = null;
|
|
66
69
|
// 活着的上游请求计数(自检台账与失控闸消费):见 proxyToUpstream 的拆除注释。
|
|
67
70
|
let upstreamOpen = 0;
|
|
@@ -76,6 +79,11 @@ function createLanProxy(opts) {
|
|
|
76
79
|
function log(...args) {
|
|
77
80
|
onLog('[lan-proxy]', ...args);
|
|
78
81
|
}
|
|
82
|
+
/** 日志里的监听地址:IPv6 按 URL 习惯套方括号(`[::]`),IPv4 原样。 */
|
|
83
|
+
function formatHost(host) {
|
|
84
|
+
const text = String(host);
|
|
85
|
+
return text.includes(':') ? `[${text}]` : text;
|
|
86
|
+
}
|
|
79
87
|
|
|
80
88
|
function isPairLocked(ip) {
|
|
81
89
|
const l = pairLocks.get(ip);
|
|
@@ -425,11 +433,31 @@ function createLanProxy(opts) {
|
|
|
425
433
|
|
|
426
434
|
function start() {
|
|
427
435
|
return new Promise((resolve, reject) => {
|
|
428
|
-
|
|
436
|
+
/**
|
|
437
|
+
* v4 / v6 两条监听共用的请求处理器。协议族只影响 `req.socket.remoteAddress` 的
|
|
438
|
+
* 形状(IPv4 是 `::ffff:a.b.c.d` 或 `a.b.c.d`,IPv6 是原生地址),路由与鉴权完全一致。
|
|
439
|
+
*/
|
|
440
|
+
const handleRequest = (req, res) => {
|
|
429
441
|
const url = new URL(req.url, 'http://lan.local');
|
|
430
442
|
try {
|
|
431
443
|
if (url.pathname === '/__lan_pair' && req.method === 'POST') {
|
|
432
444
|
const ip = req.socket.remoteAddress || '';
|
|
445
|
+
// 双栈之后这个**不需要凭据**的接口在公网 IPv6 上也够得着,而它本来是「同一个
|
|
446
|
+
// Wi-Fi 内的手机直接配对」用的。原生全球单播 IPv6 来源一律按「配对码错误」回
|
|
447
|
+
// (`pairDenied`:JSON、401,与码错完全同形,不承认这个接口为谁而开)——刚好
|
|
448
|
+
// 也是地图 A3-12 那条刻意设计要的形状:未认证请求绝不能回 2xx,否则 App 会把
|
|
449
|
+
// 连接通道钉在直连并重置熔断,随后 json 解析失败,既不回退中转也不提示重配。
|
|
450
|
+
//
|
|
451
|
+
// 不记限流计数:按源地址计数在 IPv6 上等于按 /128 计数,换个源地址就是新桶,
|
|
452
|
+
// 记了没用、白留痕迹。
|
|
453
|
+
//
|
|
454
|
+
// 只收这一格:只有公网 IPv4 的机器上,手机经公网 IPv4 打进来的配对是**现有受
|
|
455
|
+
// 支持流程**(配对二维码里就带公网 IPv4 候选),判据必须是「原生全球 IPv6」
|
|
456
|
+
// 而不是「公网地址」——后者会把那条正常用法一起拦掉。判据在 lan-network.cjs。
|
|
457
|
+
if (isGlobalUnicastV6(ip)) {
|
|
458
|
+
pairDenied(res);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
433
461
|
let b = '';
|
|
434
462
|
req.on('data', (c) => (b += c));
|
|
435
463
|
req.on('end', () => {
|
|
@@ -541,7 +569,9 @@ function createLanProxy(opts) {
|
|
|
541
569
|
res.end('internal error');
|
|
542
570
|
} catch { /* ignore */ }
|
|
543
571
|
}
|
|
544
|
-
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
server = http.createServer(handleRequest);
|
|
545
575
|
|
|
546
576
|
wss = new WebSocketServer({ noServer: true });
|
|
547
577
|
server.on('upgrade', handleUpgrade);
|
|
@@ -565,7 +595,8 @@ function createLanProxy(opts) {
|
|
|
565
595
|
server.on('error', (err) => log('server error:', err.message))
|
|
566
596
|
const address = server.address()
|
|
567
597
|
actualPort = address && typeof address.port === 'number' ? address.port : port
|
|
568
|
-
|
|
598
|
+
const boundHost = address && address.address ? address.address : '0.0.0.0'
|
|
599
|
+
log(`listening on ${formatHost(boundHost)}:${actualPort}`)
|
|
569
600
|
onBound()
|
|
570
601
|
}
|
|
571
602
|
server.once('error', onError)
|
|
@@ -573,6 +604,40 @@ function createLanProxy(opts) {
|
|
|
573
604
|
server.listen(port, '0.0.0.0')
|
|
574
605
|
})
|
|
575
606
|
|
|
607
|
+
/**
|
|
608
|
+
* IPv6 那条监听:拿 IPv4 实绑的端口,`::` + **显式 `ipv6Only: true`**。
|
|
609
|
+
*
|
|
610
|
+
* 为什么不合成一条双栈监听:`impl.js` 的端口占用探测真去 `listen(port, '0.0.0.0')`
|
|
611
|
+
* 试探,一条双栈的 `::` 会把首选端口一律探成「被占」,于是落到内核随机端口并写盘,
|
|
612
|
+
* 端口每次启动都可能漂;反过来把探测也改成 `::`,关掉 IPv6 的机器上探测会因
|
|
613
|
+
* EAFNOSUPPORT 永远判「被占」,同样漂。各管一个协议族就没这个问题:`ipv6Only: true`
|
|
614
|
+
* 之后 v6 那个 socket 不碰 IPv4(不设这个标志在 Linux 上才会撞),同端口可以共存。
|
|
615
|
+
*
|
|
616
|
+
* 它是**软的**:只有「手机不在同一 Wi-Fi、只有 IPv6」时才用得上,绑不上就记一行、
|
|
617
|
+
* 只留 IPv4 直连——绝不影响 IPv4,也绝不往上抛。
|
|
618
|
+
*/
|
|
619
|
+
const startV6 = () => new Promise((onDone) => {
|
|
620
|
+
const v6 = http.createServer(handleRequest);
|
|
621
|
+
v6.on('upgrade', handleUpgrade);
|
|
622
|
+
v6.once('error', (err) => {
|
|
623
|
+
log(`IPv6 监听未建立(${err.code || err.message}),只保留 IPv4 直连`);
|
|
624
|
+
onDone();
|
|
625
|
+
});
|
|
626
|
+
v6.once('listening', () => {
|
|
627
|
+
v6.on('error', (err) => log('IPv6 server error:', err.message));
|
|
628
|
+
v6Server = v6;
|
|
629
|
+
const address = v6.address();
|
|
630
|
+
log(`listening on ${formatHost(address.address)}:${address.port}`);
|
|
631
|
+
onDone();
|
|
632
|
+
});
|
|
633
|
+
try {
|
|
634
|
+
v6.listen(actualPort, '::', { ipv6Only: true });
|
|
635
|
+
} catch (err) {
|
|
636
|
+
log(`IPv6 监听未建立(${err.code || err.message}),只保留 IPv4 直连`);
|
|
637
|
+
onDone();
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
576
641
|
// 端口自适应:首选端口在探测之后、真正 listen 之前被别的进程抢走时(另一个
|
|
577
642
|
// 实例刚起、或者上一轮 stop() 还没释放干净),退到内核分配的空闲端口,而不是
|
|
578
643
|
// 让一次 EADDRINUSE 把整条 LAN 直连带走。
|
|
@@ -580,10 +645,10 @@ function createLanProxy(opts) {
|
|
|
580
645
|
// catch 分支根本不会执行,外层那个 Promise 就永远 pending——服务器确实在监听,
|
|
581
646
|
// 但调用方拿到的 start() 永不落定,lan.state 永远停在 connecting、error 还是
|
|
582
647
|
// null。端口回退是兜底,不是主路径。
|
|
583
|
-
bind(lanPort).then(() => resolve(), async (error) => {
|
|
648
|
+
bind(lanPort).then(async () => { await startV6(); resolve() }, async (error) => {
|
|
584
649
|
if (!error || error.code !== 'EADDRINUSE') return reject(error);
|
|
585
650
|
log(`port ${lanPort} busy (${error.code}), falling back to an OS-assigned port`);
|
|
586
|
-
try { await bind(0); resolve() } catch (retryError) { reject(retryError) }
|
|
651
|
+
try { await bind(0); await startV6(); resolve() } catch (retryError) { reject(retryError) }
|
|
587
652
|
});
|
|
588
653
|
});
|
|
589
654
|
}
|
|
@@ -604,6 +669,11 @@ function createLanProxy(opts) {
|
|
|
604
669
|
try { server.closeAllConnections?.(); } catch { /* ignore */ }
|
|
605
670
|
server = null;
|
|
606
671
|
}
|
|
672
|
+
if (v6Server) {
|
|
673
|
+
try { v6Server.close(); } catch { /* ignore */ }
|
|
674
|
+
try { v6Server.closeAllConnections?.(); } catch { /* ignore */ }
|
|
675
|
+
v6Server = null;
|
|
676
|
+
}
|
|
607
677
|
}
|
|
608
678
|
|
|
609
679
|
// 对外只暴露「实际在监听的端口」。配置端口仍是 `lanPort`(那只是首选值)。
|
|
@@ -28,9 +28,13 @@ const pairingApproval = require('../relay/pairing-policy.js');
|
|
|
28
28
|
// 端口自检用的是局域网代理**同一把探针**(见 `../relay/port-allocator.cjs` 的文件头):
|
|
29
29
|
// 这里说「可用」,真正 listen 时就不会 EADDRINUSE,两边不会各说各话。
|
|
30
30
|
const { canListen } = require('../relay/port-allocator.cjs');
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
31
|
+
// 面板的「手机该填哪个地址」要三份:私网(lanCandidates)、公网(publicCandidates)与
|
|
32
|
+
// IPv6 全球单播(ipv6Candidates)。三者刻意分开,理由写在 lan-network.cjs 里那三个函数头上。
|
|
33
|
+
//
|
|
34
|
+
// ⚠️ 面板里**能被选中的**那份仍然只能是 `lan`(没有私网时才退 `public`)——它与
|
|
35
|
+
// `advertisedCandidates` 同口径。IPv6 那份只**显示**、不进那个选择:它由手机侧实测择优
|
|
36
|
+
// (和公网中转比速度),做成可选中等于骗人(同 A3-40 的刻意设计)。
|
|
37
|
+
const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
|
|
34
38
|
const desktopView = require('../desktop-view.cjs');
|
|
35
39
|
const readReceipt = require('../read-receipt.cjs');
|
|
36
40
|
// 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
|
|
@@ -289,7 +293,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
289
293
|
// 控制面状态里额外带上面板页的绝对地址:客户端半边(lib/client.js)那一项现在只是
|
|
290
294
|
// 一个「打开配置页面」的入口,链接必须是绝对的(原因见 panelUrlOf)。地址只在这里
|
|
291
295
|
// 补,不动 createStatusView —— 那份 JSON 手机端也在读,127.0.0.1 的地址对它没意义。
|
|
292
|
-
// 面板的「手机该填哪个地址」:把检测到的网卡地址按**局域网 /
|
|
296
|
+
// 面板的「手机该填哪个地址」:把检测到的网卡地址按**局域网 / 公网 / IPv6**分成三份报出来,
|
|
293
297
|
// 用户不用自己去 `ipconfig`,界面也能一眼看出该填哪个(引导第三、四页用它)。
|
|
294
298
|
//
|
|
295
299
|
// 只给面板看:移动端那份候选契约(二维码里带的 `lanUrls`)的**取舍**一个字都没动,那边的
|
|
@@ -297,10 +301,12 @@ function registerControlPlaneRoutes(deps) {
|
|
|
297
301
|
//
|
|
298
302
|
// `preferred` 是用户在这两份地址里选中的那条(设置项 `preferredLanAddress`):界面靠它回显
|
|
299
303
|
// 「现在选的是这条」。选中的地址要真生效在二维码与 `/connection-info` 里,不在这一条上。
|
|
304
|
+
// `ipv6` 是 2026-10 加的第三份:它**不参与** `preferred`,只用来显示「这台机器在公网
|
|
305
|
+
// IPv6 上是哪个地址」—— 真机验证时人要拿这个地址去拨,没有它就只能敲命令行。
|
|
300
306
|
registerRoute({ kind: 'exact', path: '/api/relay/addresses', handler: localOnly('GET', async (_req, res) => {
|
|
301
307
|
const port = lanPortInUse()
|
|
302
308
|
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
|
|
303
|
-
sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port) })
|
|
309
|
+
sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port), ipv6: ipv6Candidates(port) })
|
|
304
310
|
}) })
|
|
305
311
|
/**
|
|
306
312
|
* 公用中转候选的测速(`POST /api/relay/probe-relays`,body `{ urls: string[] }`)。
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -153,6 +153,10 @@ function registerMobileRoutes(deps) {
|
|
|
153
153
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
154
154
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
155
155
|
route('/api/mobile/v1/session/send', sessions.handleSend)
|
|
156
|
+
// 正文水位探针:App 进对话前先问一句「这条会话的正文到哪个水位了」,跟本地缓存水位
|
|
157
|
+
// 一比就知道要不要重拉全量历史(几十字节换几兆)。**只读信号快照**:信号里没有就如实
|
|
158
|
+
// 回 known:false 让 App 退回全量,绝不现读一次历史——否则这探针就变成了它要省掉的那次请求。
|
|
159
|
+
route('/api/mobile/v1/session/head', sessions.handleSessionHead)
|
|
156
160
|
route('/api/mobile/v1/commands', sessions.handleCommands)
|
|
157
161
|
route('/api/mobile/v1/session/read', sessions.handleSessionRead)
|
|
158
162
|
route('/api/mobile/v1/session/stop', sessions.handleStop)
|
|
@@ -48,4 +48,23 @@ function listRevision(items) {
|
|
|
48
48
|
return stableRevision(Array.isArray(items) ? items : []);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* 条件刷新的极简响应(sessions / workspaces 共用)。
|
|
53
|
+
*
|
|
54
|
+
* 客户端带上上次拿到的 `revision` 来问「还是那一份吗」:**恰好相等**时只回一句
|
|
55
|
+
* `{ok, unchanged:true, revision, generatedAt}`,不带 items —— 几十字节换几十 KB。
|
|
56
|
+
*
|
|
57
|
+
* 调用方负责决定「这次请求该不该走这条」(`?fresh=1` 的写后刷新、归档子集、子代理等
|
|
58
|
+
* 一律不走);这里只管形状与相等判定。不相等返回 null,调用方回完整 body。
|
|
59
|
+
*
|
|
60
|
+
* @param {object} body 最终要回的那份完整清单体
|
|
61
|
+
* @param {string} rev 客户端带来的 revision
|
|
62
|
+
*/
|
|
63
|
+
function unchangedListBody(body, rev) {
|
|
64
|
+
if (!body || body.ok !== true) return null;
|
|
65
|
+
if (typeof rev !== 'string' || rev === '') return null;
|
|
66
|
+
if (typeof body.revision !== 'string' || body.revision !== rev) return null;
|
|
67
|
+
return { ok: true, unchanged: true, revision: body.revision, generatedAt: body.generatedAt };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { stableRevision, listRevision, unchangedListBody };
|
package/lib/update-install.cjs
CHANGED
|
@@ -138,8 +138,6 @@ function installedVersion(profileNodeModules, name = DEFAULT_NAME) {
|
|
|
138
138
|
/**
|
|
139
139
|
* profile 的依赖声明里这个包是怎么装的(`profileDir` 是 profile 根,不是它的 node_modules)。
|
|
140
140
|
*
|
|
141
|
-
* 只有 `link:` 要拦:那是**故意**把插件指向某个本地目录(开发时),装一次 npm 版会把这个
|
|
142
|
-
* 指向悄悄改掉。`file:` 的 tgz 与普通版号都照装(前者会一并换成注册表来源,这是想要的)。
|
|
143
141
|
* 读不到就说不知道(null),不要瞎猜。
|
|
144
142
|
*/
|
|
145
143
|
function declaredSource(profileDir, name = DEFAULT_NAME) {
|
|
@@ -156,6 +154,43 @@ function declaredSource(profileDir, name = DEFAULT_NAME) {
|
|
|
156
154
|
}
|
|
157
155
|
}
|
|
158
156
|
|
|
157
|
+
/** 「只有从注册表装的,才由注册表来更新」——其余装法一律不碰盘。 */
|
|
158
|
+
const REGISTRY_SOURCES = new Set(['npm']);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 一次安装请求能不能动盘。
|
|
162
|
+
*
|
|
163
|
+
* 判据是 profile 里**声明的来源**,不是「装没装得上」:`link:` 指向本地目录(开发时故意
|
|
164
|
+
* 指过去的)、`file:` 指向本机打出来的 tgz(开发版)、`workspace:` 指向本仓库的包——
|
|
165
|
+
* 这三种都是有人**明确安排**的装法,装一次注册表版会把那个指向悄悄改掉,而这正是开发机
|
|
166
|
+
* 上最不想要的一次覆盖:改完一轮代码,后台自己把 npm 版装回来,然后所有本地改动「明明
|
|
167
|
+
* 改了却没生效」。
|
|
168
|
+
*
|
|
169
|
+
* 所以「能不能点更新」这一格由来源决定,不由外部命令决定:非注册表来源**连试都不试**,
|
|
170
|
+
* 如实回一个稳定错误码,让人知道这台是怎么更新的(插件自己的改动只能由改代码的人来推)。
|
|
171
|
+
*
|
|
172
|
+
* 读不到来源(profile 里没有这条依赖,或文件坏了)不算已知来源:那种现场通常意味着
|
|
173
|
+
* 插件压根不是从 profile 装的,装一次反而是「修复」,所以放行。
|
|
174
|
+
*
|
|
175
|
+
* @param {string|null} source - `declaredSource` 的结果。
|
|
176
|
+
* @returns {{ ok: true } | { ok: false, code: string, message: string }}
|
|
177
|
+
*/
|
|
178
|
+
function installAllowedFor(source) {
|
|
179
|
+
if (source === null || REGISTRY_SOURCES.has(source)) return { ok: true };
|
|
180
|
+
const how = source === 'linked' ? 'link: 一个本地目录'
|
|
181
|
+
: source === 'file' ? 'file: 一个本机打出来的包'
|
|
182
|
+
: source === 'workspace' ? 'workspace: 本仓库里的包'
|
|
183
|
+
: `来源 ${source}`;
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
code: 'LOCAL_SOURCE',
|
|
187
|
+
message:
|
|
188
|
+
`这个插件在 profile 里是 ${how}(${source})装的,是有意为之:改成注册表版会把那个指向换掉,`
|
|
189
|
+
+ '本地的改动就再也不生效了。所以这里既不检查也不安装 —— 这一台的插件更新由改代码的人来做'
|
|
190
|
+
+ '(同步文件后让宿主就地重载);要换回按版号安装,先在插件市场里按包名重装一次。',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
159
194
|
/**
|
|
160
195
|
* 宿主**发布出来**的包管理器调用方式(DSH 桌面版把它挂在 `profileContext.packageManager`
|
|
161
196
|
* 上,形状 `{command, args, env?}`)。
|
|
@@ -280,6 +315,14 @@ async function installLatest({
|
|
|
280
315
|
failure: { code, message },
|
|
281
316
|
});
|
|
282
317
|
|
|
318
|
+
/**
|
|
319
|
+
* 来源那道门排在**挑包管理器之前**:非注册表来源连 `pnpm --version` 都不该跑一次 ——
|
|
320
|
+
* 那一步只是探活,但排在前面会让「这台根本不该装」和「这台装不了」混成同一种失败。
|
|
321
|
+
*/
|
|
322
|
+
const source = declaredSource(profileDir, name);
|
|
323
|
+
const gate = installAllowedFor(source);
|
|
324
|
+
if (!gate.ok) return fail(gate.code, gate.message);
|
|
325
|
+
|
|
283
326
|
/**
|
|
284
327
|
* 挑一个能用的包管理器:**宿主给的那个先试**(探一次 `--version`,跑不起来就当没有),
|
|
285
328
|
* 之后才是 PATH 上的 pnpm / npm。返回 `{command, prefix, manager, kind}`。
|
|
@@ -313,15 +356,6 @@ async function installLatest({
|
|
|
313
356
|
);
|
|
314
357
|
}
|
|
315
358
|
|
|
316
|
-
const source = declaredSource(profileDir, name);
|
|
317
|
-
if (source === 'linked') {
|
|
318
|
-
return fail(
|
|
319
|
-
'LINKED_SOURCE',
|
|
320
|
-
'这个插件在 profile 里指向一个本地目录(link:),是有意为之。自己装会把那个指向改掉,' +
|
|
321
|
-
'所以这里不动它:要去掉这个指向,先在插件市场里按包名重装一次。',
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
359
|
const spec = `${name}@${version}`;
|
|
326
360
|
const releaseAgeFlag = '--config.minimum-release-age=0';
|
|
327
361
|
const args = picked.manager === 'pnpm'
|
|
@@ -394,12 +428,19 @@ async function installLatest({
|
|
|
394
428
|
}
|
|
395
429
|
|
|
396
430
|
/**
|
|
397
|
-
*
|
|
431
|
+
* 「让我变成最新」:先看这台是不是注册表来源,再看有没有人在干活,最后决定装不装。
|
|
398
432
|
*
|
|
399
433
|
* 面板那颗按钮**与外壳的自动更新**都打这个入口 —— 两处走同一套判定,免得出现「手动说有
|
|
400
434
|
* 新版、自动说没有」这种最难查的偏差。已经是最新(`force` 为假)就什么都不做、如实报
|
|
401
435
|
* `skipped`。
|
|
402
436
|
*
|
|
437
|
+
* ## 为什么来源排在第一位
|
|
438
|
+
*
|
|
439
|
+
* 只有从注册表装的包才该由注册表来更新。`link:` / `file:` / `workspace:` 是有人**明确
|
|
440
|
+
* 安排**的装法(开发机就是这么跑的),装一次注册表版会把那个指向悄悄换掉 —— 表现为「明明
|
|
441
|
+
* 改了代码却没生效」,而改代码的人还以为是缓存。`force` 也过不去这一关:这不是「现在不行」,
|
|
442
|
+
* 是「这台不该这么装」。
|
|
443
|
+
*
|
|
403
444
|
* ## 为什么装之前先看有没有 agent 在跑
|
|
404
445
|
*
|
|
405
446
|
* 装会**就地换掉插件文件**(profile 里那一份),而正在干活的对话可能正好在装配/读取插件
|
|
@@ -412,7 +453,24 @@ async function installLatest({
|
|
|
412
453
|
async function installLatestIfNeeded(options = {}) {
|
|
413
454
|
const { profileNodeModules, force = false, name = DEFAULT_NAME, agents = null } = options;
|
|
414
455
|
const beforeVersion = installedVersion(profileNodeModules, name);
|
|
415
|
-
|
|
456
|
+
// 来源那道门排在最前:非注册表来源连「线上最新是几」都不用问 —— 答案再新也不该装,
|
|
457
|
+
// 而且这一条同时管着手动与自动两条路(自动更新永远不带 force,照样过不去)。
|
|
458
|
+
const source = declaredSource(path.dirname(profileNodeModules), name);
|
|
459
|
+
const gate = installAllowedFor(source);
|
|
460
|
+
if (!gate.ok) {
|
|
461
|
+
return {
|
|
462
|
+
installed: false,
|
|
463
|
+
skipped: true,
|
|
464
|
+
beforeVersion,
|
|
465
|
+
installedVersion: beforeVersion,
|
|
466
|
+
source,
|
|
467
|
+
command: null,
|
|
468
|
+
output: '',
|
|
469
|
+
failure: { code: gate.code, message: gate.message },
|
|
470
|
+
check: null,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
const check = await checkForUpdate({ installedVersion: beforeVersion, source });
|
|
416
474
|
// 「有没有新版」排在「有没有人在跑」**前面**:没东西可装的时候,拿一句「有对话在跑」挡住
|
|
417
475
|
// 真正的答案(已经是最新)是误导 —— 2026-10-11 真机上就是这么被绊了一下。
|
|
418
476
|
if (!force && !check.updateAvailable) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.93",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"test": "node --test test/*.test.cjs && node test/lan-direct-e2e.cjs && node test/sidecar-facade.cjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@deepseek-ai/dsh-tool-ask-user": "0.
|
|
48
|
+
"@deepseek-ai/dsh-tool-ask-user": "0.2.0-rc.2",
|
|
49
49
|
"qrcode": "1.5.4",
|
|
50
50
|
"ws": "8.18.0"
|
|
51
51
|
},
|