dsh-mobile-plugin 0.1.65 → 0.1.67
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 +10 -3
- package/lib/panel/index.html +5 -5
- package/lib/relay/core.js +8 -0
- package/lib/relay/lan-network.cjs +33 -0
- package/lib/relay/lan-proxy.js +7 -0
- package/lib/relay/port-allocator.cjs +18 -3
- package/lib/routes/control-plane.js +71 -75
- package/lib/routes/mobile-api.js +22 -1
- package/lib/sidecar-lifecycle.cjs +19 -1
- package/package.json +1 -1
package/lib/relay/core.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* relay.json 的状态形状与读写,外加实例 ID / 配对码 / 隧道地址这几个纯函数。
|
|
3
|
+
*
|
|
4
|
+
* 内存里的 `state` 是本进程唯一真值,这里只负责落盘与还原(见 A3-20 的坑)。
|
|
5
|
+
*/
|
|
1
6
|
'use strict';
|
|
2
7
|
|
|
3
8
|
const fs = require('node:fs');
|
|
@@ -29,6 +34,9 @@ function defaultState() {
|
|
|
29
34
|
// 中转侧 TUNNEL_REGISTER_SECRET 的对偶值。为空时隧道注册会被中转以
|
|
30
35
|
// close(1008, 'bad registration secret') 拒掉(除非中转根本没开校验)。
|
|
31
36
|
registerSecret: '',
|
|
37
|
+
// 手机优先用哪条地址(空 = 不指定)。它是 `RemoteControlSettingsSchema` 里的一项,
|
|
38
|
+
// 写它不碰 sidecar(那份配置是 `impl.js` 的 `buildConfig` 逐字段拼的,不带它)。
|
|
39
|
+
preferredLanAddress: '',
|
|
32
40
|
// 首次接入引导走完没有。**刻意不进 RemoteControlSettingsSchema**:它不是一项
|
|
33
41
|
// 「远程控制设置」,改它不该触发 sidecar 重配(那条链上每加一个字段都要同步改
|
|
34
42
|
// schema、worker 的 allowed 数组与 buildConfig,漏一处就抛 unknown field)。
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网卡枚举与候选地址:私网 / 公网两份清单,外加「用户选中的那条排最前」。
|
|
3
|
+
*
|
|
4
|
+
* 收口在这一个文件里,是因为 App 只认「按顺序挨个试」这一种语义 —— 顺序就是优先级。
|
|
5
|
+
*/
|
|
1
6
|
'use strict'
|
|
2
7
|
|
|
3
8
|
const os = require('node:os')
|
|
@@ -158,6 +163,33 @@ function advertisedCandidates(port, interfaces = os.networkInterfaces()) {
|
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
|
|
166
|
+
/**
|
|
167
|
+
* 把用户选中的那条排到最前面(面板上的「手机优先用这条」,存在设置项 `preferredLanAddress`)。
|
|
168
|
+
*
|
|
169
|
+
* 为什么只能是「排前面」:App 拿到候选之后是**按顺序挨个试**的,顺序就是唯一的表达方式
|
|
170
|
+
* (见 `impl.js` 的 `pairingPayload` 与 `routes/mobile-api.js` 的 `/connection-info`)。
|
|
171
|
+
* 所以这里刻意只挪位置:
|
|
172
|
+
*
|
|
173
|
+
* - **不删任何东西**:其余的照旧跟在后面当兜底。用户选错了(比如选了一块手机根本到不了的
|
|
174
|
+
* 网卡)、或者那条以后不通了,手机还能顺着试下去,不会因为一次选择就把整条局域网路走死;
|
|
175
|
+
* - **匹配不上就原样返回**:换了网段、拔了网卡之后,用户当初选的那个 IP 就不在清单里了,
|
|
176
|
+
* 这时必须退回宿主自己的网卡优先级排序。绝不能因为「选过一条现在没有的地址」把候选清空;
|
|
177
|
+
* - 比对的是**地址**而不是整条 URL —— URL 里带着端口,用户改端口不该让这次选择失效。
|
|
178
|
+
*/
|
|
179
|
+
function orderCandidates(list, preferred) {
|
|
180
|
+
if (!Array.isArray(list)) return []
|
|
181
|
+
if (list.length < 2) return list
|
|
182
|
+
const want = String(preferred || '').trim()
|
|
183
|
+
if (!want) return list
|
|
184
|
+
const index = list.findIndex(item => item && item.address === want)
|
|
185
|
+
// 已经在首位(宿主的排序正好就是它),或者压根没有这条:原样返回。
|
|
186
|
+
if (index <= 0) return list
|
|
187
|
+
const ordered = list.slice()
|
|
188
|
+
const [picked] = ordered.splice(index, 1)
|
|
189
|
+
ordered.unshift(picked)
|
|
190
|
+
return ordered
|
|
191
|
+
}
|
|
192
|
+
|
|
161
193
|
module.exports = {
|
|
162
194
|
ipv4Number,
|
|
163
195
|
isPrivateLanAddress,
|
|
@@ -172,4 +204,5 @@ module.exports = {
|
|
|
172
204
|
publicUrls,
|
|
173
205
|
publicCandidates,
|
|
174
206
|
advertisedCandidates,
|
|
207
|
+
orderCandidates,
|
|
175
208
|
}
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -503,6 +503,13 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
503
503
|
res.end('ok');
|
|
504
504
|
return;
|
|
505
505
|
}
|
|
506
|
+
// 地址体检要和 /healthz 一样在鉴权之前放行:手机在配对之前就要问「这个地址上
|
|
507
|
+
// 是宿主还是中转」,那时候它没有任何 token(上游那一条也没有 guarded)。
|
|
508
|
+
// 它只回服务名与版本,不是能力面。
|
|
509
|
+
if (url.pathname === '/api/mobile/v1/health') {
|
|
510
|
+
proxyToUpstream(req, res);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
506
513
|
if (!authorized(req)) {
|
|
507
514
|
// API 请求必须回 JSON 401(见 isApiRequest 注释);只有浏览器导航才给配对页。
|
|
508
515
|
if (isApiRequest(req, url)) sendUnauthorized(res);
|
|
@@ -47,11 +47,26 @@ function freePort(host) {
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* 解析出本次真正要监听的端口。
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
*
|
|
51
|
+
* @param {number} requested 首选端口(不合法就回落 18787)。
|
|
52
|
+
* @param {string} [host] 试绑地址。
|
|
53
|
+
* @param {object} [options]
|
|
54
|
+
* @param {number} [options.ours] 我们**自己占着**的端口(= 最近一次下发给局域网代理的那个号),
|
|
55
|
+
* 没有给 0。试绑一个自己占着的端口必然失败,但那不是「被别人占了」——调用方给这个值时,
|
|
56
|
+
* 等于告诉我们「这个号是我的」,于是原样保留、连试绑都不做。
|
|
57
|
+
* **这一条不认,只读动作也会改落盘状态**:插件面板上「看一眼二维码」那条路由也会解析
|
|
58
|
+
* 一次端口,代理已经在那个号上之后每次解析都会判它「被占」、换一个内核随手给的空闲端口
|
|
59
|
+
* 回传,而调用方会把它写回 relay.json——于是状态里记的端口与真实监听分叉,平时看不出来,
|
|
60
|
+
* **下次重启按漂过的值去绑,手机里存的地址就废了**。判据怎么来的见 sidecar-lifecycle 的
|
|
61
|
+
* getOwnLanPort:**不能**用「快照说它在 listening」,那个在重绑窗口里还是上一个号。
|
|
62
|
+
* @returns {Promise<{port: number, adjusted: boolean, requested: number, ours?: boolean}>}
|
|
63
|
+
* `adjusted=true` 表示首选端口被占、换了一个;`ours=true` 表示这个号本来就是自己的。
|
|
52
64
|
*/
|
|
53
|
-
async function resolvePort(requested, host = '0.0.0.0') {
|
|
65
|
+
async function resolvePort(requested, host = '0.0.0.0', options = {}) {
|
|
54
66
|
const wanted = Number.isInteger(requested) && requested >= 1024 && requested <= 65535 ? requested : 18787
|
|
67
|
+
if (Number.isInteger(options.ours) && options.ours === wanted) {
|
|
68
|
+
return { port: wanted, adjusted: false, requested: wanted, ours: true }
|
|
69
|
+
}
|
|
55
70
|
if (await canListen(wanted, host)) return { port: wanted, adjusted: false, requested: wanted }
|
|
56
71
|
const port = await freePort(host)
|
|
57
72
|
return { port: port > 0 ? port : wanted, adjusted: true, requested: wanted }
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* 取值器/函数注入,不捕获对象——闭包里那些 `let`(sidecarSnapshot、lanPortAdjusted、
|
|
14
14
|
* sidecarState…)会被整体替换或重新赋值。`getSidecar` 尤其重要:sidecar 会被自重启换掉,
|
|
15
15
|
* 拿到实例本身就等于拿到一份指向旧 facade 的快照。
|
|
16
|
+
*
|
|
17
|
+
* 插件更新那两条:`check` 自己问 npm 注册表(不要求外壳在场),`apply` 只委托外壳的
|
|
18
|
+
* `apply`(判定 / 装 / 换代 / 落账同一层)——各自的取舍写在路由头上。
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
const fs = require('node:fs');
|
|
@@ -36,7 +39,7 @@ const desktopHalf = require('../desktop-half-stats.cjs');
|
|
|
36
39
|
// 「查有没有新版」与「装新版」这两格都归插件自己:查直接问 npm 注册表,装在 profile 里跑
|
|
37
40
|
// 它自己的包管理器(见 `../update-registry.cjs` / `../update-install.cjs`)。不再借道市场。
|
|
38
41
|
const { checkForUpdate } = require('../update-registry.cjs');
|
|
39
|
-
const {
|
|
42
|
+
const { declaredSource } = require('../update-install.cjs');
|
|
40
43
|
const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
|
|
41
44
|
|
|
42
45
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
@@ -216,11 +219,15 @@ function registerControlPlaneRoutes(deps) {
|
|
|
216
219
|
// 面板的「手机该填哪个地址」:把检测到的网卡地址按**局域网 / 公网**分成两份报出来,
|
|
217
220
|
// 用户不用自己去 `ipconfig`,界面也能一眼看出该填哪个(引导第三、四页用它)。
|
|
218
221
|
//
|
|
219
|
-
// 只给面板看:移动端那份候选契约(二维码里带的 `lanUrls
|
|
222
|
+
// 只给面板看:移动端那份候选契约(二维码里带的 `lanUrls`)的**取舍**一个字都没动,那边的
|
|
220
223
|
// 私网/公网取舍见 `../relay/lan-network.cjs` 的 `advertisedCandidates`。
|
|
224
|
+
//
|
|
225
|
+
// `preferred` 是用户在这两份地址里选中的那条(设置项 `preferredLanAddress`):界面靠它回显
|
|
226
|
+
// 「现在选的是这条」。选中的地址要真生效在二维码与 `/connection-info` 里,不在这一条上。
|
|
221
227
|
registerRoute({ kind: 'exact', path: '/api/relay/addresses', handler: localOnly('GET', async (_req, res) => {
|
|
222
228
|
const port = lanPortInUse()
|
|
223
|
-
|
|
229
|
+
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
|
|
230
|
+
sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port) })
|
|
224
231
|
}) })
|
|
225
232
|
registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), ...mobileActivity() })) })
|
|
226
233
|
// 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
|
|
@@ -391,6 +398,11 @@ function registerControlPlaneRoutes(deps) {
|
|
|
391
398
|
if (body?.pairingPolicy === 'code' || body?.pairingPolicy === 'deny') patch.pairingPolicy = body.pairingPolicy
|
|
392
399
|
// 注册密钥:只接受字符串,过长拒绝;写空串等于关掉(回到"中转开了校验就连不上")。
|
|
393
400
|
if (typeof body?.registerSecret === 'string' && body.registerSecret.length <= 512) patch.registerSecret = body.registerSecret
|
|
401
|
+
// 手机优先用哪条地址。**空串是合法值**(= 不指定,回到宿主的网卡优先级),所以这里
|
|
402
|
+
// 只挡类型和长度,不像 name 那样要求非空——那是「清掉这次选择」的唯一方式。
|
|
403
|
+
if (typeof body?.preferredLanAddress === 'string' && body.preferredLanAddress.length <= 64) {
|
|
404
|
+
patch.preferredLanAddress = body.preferredLanAddress.trim()
|
|
405
|
+
}
|
|
394
406
|
await settings.update(patch)
|
|
395
407
|
await syncSidecar().catch(() => undefined)
|
|
396
408
|
sendJson(res, 200, status())
|
|
@@ -440,20 +452,6 @@ function registerControlPlaneRoutes(deps) {
|
|
|
440
452
|
return null
|
|
441
453
|
}
|
|
442
454
|
}
|
|
443
|
-
/**
|
|
444
|
-
* 取一个宿主服务,拿不到就当没有。
|
|
445
|
-
*
|
|
446
|
-
* 更新这条路要用两个:`profileContext`(宿主发布的包管理器调用方式,装的时候必须优先用它)
|
|
447
|
-
* 与 `agents`(「有对话在跑就别换插件文件」那道闸的判据)。宿主没提供某个服务时如实降级,
|
|
448
|
-
* **不许让路由因此抛错** —— 服务不存在时 `ctx.get` 本身可能就不在。
|
|
449
|
-
*/
|
|
450
|
-
const hostService = key => {
|
|
451
|
-
try {
|
|
452
|
-
return ctx && typeof ctx.get === 'function' ? ctx.get(key) ?? null : null
|
|
453
|
-
} catch {
|
|
454
|
-
return null
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
455
|
const runtimeUnavailable = { ok: false, error: { code: 'update_unavailable', message: '这一版没有可换代的外壳(插件是被直接挂载的),装完没法就地生效,所以这里不装:无头部署请 rsync 源码后重启' } }
|
|
458
456
|
registerRoute({ kind: 'exact', path: '/api/relay/update/status', handler: localOnly('GET', async (_req, res) => {
|
|
459
457
|
const runtime = pluginRuntime()
|
|
@@ -489,15 +487,21 @@ function registerControlPlaneRoutes(deps) {
|
|
|
489
487
|
})
|
|
490
488
|
}
|
|
491
489
|
}) })
|
|
492
|
-
//
|
|
493
|
-
//
|
|
490
|
+
// 「更新并立即生效」:**交给外壳去做**(`runtime.apply`)。它在 profile 里跑插件自己的包
|
|
491
|
+
// 装器装新版、装完就地换代,并把这次结果写进它那本 `lastApply` 账 —— 面板「最近一次更新」
|
|
492
|
+
// 与那颗「强制更新」按钮的「上次没装成」判据读的都是这本账。
|
|
493
|
+
//
|
|
494
|
+
// 为什么这条路由不再自己去装(2026-10-12 改):从前它自己调 `installLatestIfNeeded` 再
|
|
495
|
+
// `reload`,判定确实和自动更新是同一套(外壳那条路最后也走盘上那一代的同一个函数),
|
|
496
|
+
// **但结果没人落账** —— 手动(含强制)更新装完,外壳的账一动不动,面板那一行永远停在上一次
|
|
497
|
+
// 自动更新的结果上。判定与记账必须是同一个动作,所以这里只负责传参、把外壳记完账的状态
|
|
498
|
+
// 原样回给面板(`status` 必须是 apply **之后**的快照,拿 apply 之前那份等于没落账)。
|
|
494
499
|
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// 出了意料之外的错才回 500。
|
|
500
|
+
// 只在有外壳时才装(没外壳回 409):盘上换了版本、运行中的实现换不掉,等于白装。装没装成
|
|
501
|
+
// 一律回 200 + 如实的结果(面板照着 `failure` 说人话);只有外壳**抛错**才是意料之外,回 500。
|
|
498
502
|
registerRoute({ kind: 'exact', path: '/api/relay/update/apply', handler: localOnly('POST', async (req, res) => {
|
|
499
503
|
const runtime = pluginRuntime()
|
|
500
|
-
if (!runtime) return sendJson(res, 409, runtimeUnavailable)
|
|
504
|
+
if (!runtime || typeof runtime.apply !== 'function') return sendJson(res, 409, runtimeUnavailable)
|
|
501
505
|
let body = {}
|
|
502
506
|
try {
|
|
503
507
|
body = (await readJson(req)) || {}
|
|
@@ -507,64 +511,56 @@ function registerControlPlaneRoutes(deps) {
|
|
|
507
511
|
const force = body.force === true
|
|
508
512
|
const snapshot = runtime.status()
|
|
509
513
|
const beforeVersion = snapshot.installedVersion ?? snapshot.version ?? generationVersion()
|
|
510
|
-
const
|
|
511
|
-
installed: false,
|
|
512
|
-
skipped: false,
|
|
513
|
-
state: 'failed',
|
|
514
|
-
beforeVersion,
|
|
515
|
-
installedVersion: beforeVersion,
|
|
516
|
-
swapped: false,
|
|
517
|
-
// 更新后绝不重启 DSH(外壳就地换代),这两格只为如实上报。
|
|
518
|
-
restartRequired: false,
|
|
519
|
-
refreshRequired: false,
|
|
520
|
-
failure: null,
|
|
521
|
-
}
|
|
522
|
-
const answer = (extra = {}) => Object.assign(outcome, extra)
|
|
523
|
-
|
|
524
|
-
const profileNodeModules = snapshot.profileNodeModules ?? null
|
|
525
|
-
if (!profileNodeModules) {
|
|
526
|
-
answer({ failure: { code: 'profile-unknown', message: '宿主没给出 profile 的 node_modules 路径,装不了' } })
|
|
527
|
-
return sendJson(res, 500, { ok: false, error: { code: 'update_apply_failed', message: outcome.failure.message }, data: { ...outcome, status: snapshot } })
|
|
528
|
-
}
|
|
514
|
+
const beforeOnDisk = snapshot.installedVersion ?? null
|
|
529
515
|
|
|
530
|
-
|
|
531
|
-
// 里一起判:面板那颗按钮与外壳的自动更新必须走同一套判定,否则会出现「手动说有新版、
|
|
532
|
-
// 自动说没有」这种最难查的偏差。
|
|
533
|
-
let result
|
|
516
|
+
let applied
|
|
534
517
|
try {
|
|
535
|
-
|
|
536
|
-
profileNodeModules,
|
|
537
|
-
force,
|
|
538
|
-
// 宿主发布的包管理器调用方式(`{command, args, env?}`)。**必须优先用它**:PATH 上
|
|
539
|
-
// 那个 pnpm 的版本可能和 profile 的 store 对不上(实测 10.x 装 11.x 的 profile 直接
|
|
540
|
-
// `ERR_PNPM_UNEXPECTED_STORE`)。市场也是这么取的(`profileContext.packageManager`)。
|
|
541
|
-
packageManager: hostService('profileContext')?.packageManager ?? null,
|
|
542
|
-
// 「有对话在跑就先别换插件文件」那道闸的判据。force 由用户点按钮时显式带上。
|
|
543
|
-
agents: hostService('agents'),
|
|
544
|
-
})
|
|
518
|
+
applied = await runtime.apply({ force })
|
|
545
519
|
} catch (error) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
520
|
+
// 外壳抛错只有两类:装的那步就炸了(网络 / 权限 / 注册表),或者装上了、换代码那步炸了。
|
|
521
|
+
// 必须分开说 —— 后者盘上的版本已经变了,回一句「装上了但没换过来」比笼统报失败有用得多。
|
|
522
|
+
const after = runtime.status()
|
|
523
|
+
const landed = beforeOnDisk !== null && (after.installedVersion ?? null) !== beforeOnDisk
|
|
524
|
+
const failure = landed
|
|
525
|
+
? { code: 'swap_failed', message: `新版已经装到盘上(${after.installedVersion}),但换代码没成:${String(error?.message || error)}` }
|
|
526
|
+
: { code: 'install_failed', message: String(error?.message || error) }
|
|
527
|
+
return sendJson(res, 500, {
|
|
528
|
+
ok: false,
|
|
529
|
+
error: { code: 'update_apply_failed', message: failure.message },
|
|
530
|
+
data: {
|
|
531
|
+
installed: landed,
|
|
532
|
+
skipped: false,
|
|
533
|
+
state: landed ? 'installed-not-swapped' : 'failed',
|
|
534
|
+
beforeVersion,
|
|
535
|
+
installedVersion: after.installedVersion ?? beforeVersion,
|
|
536
|
+
swapped: false,
|
|
537
|
+
// 更新后绝不重启 DSH(外壳就地换代),这两格只为如实上报。
|
|
538
|
+
restartRequired: false,
|
|
539
|
+
refreshRequired: false,
|
|
540
|
+
failure,
|
|
541
|
+
status: after,
|
|
542
|
+
},
|
|
543
|
+
})
|
|
558
544
|
}
|
|
559
545
|
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
546
|
+
// `ok` 只有「装上了」与「已经最新、什么都没动」算真;其余(含「有对话在跑」)都是没成。
|
|
547
|
+
const outcome = {
|
|
548
|
+
installed: applied.installed === true,
|
|
549
|
+
skipped: applied.skipped === true,
|
|
550
|
+
state: applied.state ?? (applied.installed || applied.skipped ? 'succeeded' : 'failed'),
|
|
551
|
+
beforeVersion: applied.beforeVersion ?? beforeVersion,
|
|
552
|
+
installedVersion: applied.installedVersion ?? beforeVersion,
|
|
553
|
+
swapped: applied.swapped === true,
|
|
554
|
+
restartRequired: applied.restartRequired === true,
|
|
555
|
+
refreshRequired: applied.refreshRequired === true,
|
|
556
|
+
failure: applied.failure ?? null,
|
|
557
|
+
retryable: applied.retryable === true,
|
|
558
|
+
command: applied.command ?? null,
|
|
559
|
+
check: applied.check ?? null,
|
|
560
|
+
// 外壳自己记完账后的状态(`lastApply` / `appliedAt` 就在这里面)。
|
|
561
|
+
status: applied.status ?? runtime.status(),
|
|
566
562
|
}
|
|
567
|
-
return sendJson(res, 200, { ok:
|
|
563
|
+
return sendJson(res, 200, { ok: outcome.installed || outcome.skipped, data: outcome })
|
|
568
564
|
}) })
|
|
569
565
|
// 自动更新开关(默认关)。开=外壳每 10 分钟问一次有没有新版,有就自己装并就地切换
|
|
570
566
|
// (轮询住在外壳里,因为「没人看着也要发生」;装与换代用的就是上面这两条同一套实现)。
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const { sendJson } = require('../http/routing.js');
|
|
23
|
+
const { orderCandidates } = require('../relay/lan-network.cjs');
|
|
23
24
|
|
|
24
25
|
const sessions = require('../mobile/sessions.js');
|
|
25
26
|
const agentPresets = require('../mobile/agent-presets.js');
|
|
@@ -34,6 +35,8 @@ const mobileCredentials = require('../mobile/credentials.js');
|
|
|
34
35
|
const subagents = require('../mobile/subagents.js');
|
|
35
36
|
const references = require('../mobile/references.js');
|
|
36
37
|
const plugins = require('../mobile/plugins.js');
|
|
38
|
+
// 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
|
|
39
|
+
const pkg = require('../../package.json');
|
|
37
40
|
const desktopView = require('../desktop-view.cjs');
|
|
38
41
|
|
|
39
42
|
const API_VERSION = '1';
|
|
@@ -78,6 +81,20 @@ function registerMobileRoutes(deps) {
|
|
|
78
81
|
...hostMetadata(),
|
|
79
82
|
})
|
|
80
83
|
}) })
|
|
84
|
+
// 地址体检:手机在**配对之前**就要问「这个地址上到底是谁」,那时它手上没有任何
|
|
85
|
+
// 凭据,所以这条刻意不包 guarded(豁免与理由见 test/mobile-routes.test.cjs 的
|
|
86
|
+
// PUBLIC_ROUTES)。它能说的话只有服务名与版本——没有实例信息、没有任何能力面。
|
|
87
|
+
// 中转侧在 server.js 的同名路径上回 'dsh-mobile-relay',App 靠这个字段分辨
|
|
88
|
+
// 「填的是宿主还是中转」(消费方 app/src/services/addressProbe.ts)。
|
|
89
|
+
//
|
|
90
|
+
// ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的,
|
|
91
|
+
// 上面那条 `guarded(...)` 之所以能收 `(ctx, req, res)`,是因为那三个参数由 guarded 自己补。
|
|
92
|
+
// 这里曾写成 `(_ctx, _req, res)`,于是 `res` 是 undefined,而 `sendJson` 的 catch 会把
|
|
93
|
+
// TypeError 当成「连接已关闭」吞掉——既没报错也没回包,请求**永久挂住**;手机 5s 超时后
|
|
94
|
+
// 退回探活接口拿到 ok,报「能连上但不是 DSH 移动端服务」。纯文本的路由表测试抓不到这类错。
|
|
95
|
+
registerRoute({ kind: 'exact', path: '/api/mobile/v1/health', handler: async (_req, res) => {
|
|
96
|
+
sendJson(res, 200, { ok: true, service: 'dsh-mobile-plugin', apiVersion: API_VERSION, version: pkg.version })
|
|
97
|
+
} })
|
|
81
98
|
// 局域网/中转连接信息。公网配对时由中转在 `/api/mobile/v1/connection-info`
|
|
82
99
|
// 代答宿主上报的镜像数据;但纯局域网配对(App 只扫到局域网二维码、完全不经
|
|
83
100
|
// 中转)需要宿主自己回答同一个地址,否则 App 拿不到 lanCandidates,换网卡后
|
|
@@ -86,7 +103,11 @@ function registerMobileRoutes(deps) {
|
|
|
86
103
|
// 端口取「手机该拨的那个」(sidecar 真 bind 成的),不是配置里的首选端口:
|
|
87
104
|
// 两者不一致时这里报出去的候选是死地址,手机只能退回公网中转。
|
|
88
105
|
const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort
|
|
89
|
-
|
|
106
|
+
// 用户在面板上选过的「手机优先用这条」(设置项 `preferredLanAddress`)在这里同样兑现:
|
|
107
|
+
// 手机是按顺序挨个试的,所以只挪顺序、不删候选。选的那条不在了就退回宿主排序。
|
|
108
|
+
const candidates = state.lanEnabled
|
|
109
|
+
? orderCandidates(safeLanCandidates(port), state.preferredLanAddress)
|
|
110
|
+
: []
|
|
90
111
|
const lanUpdatedAt = getSidecarSnapshot()?.lan?.updatedAt || Date.now()
|
|
91
112
|
sendJson(res, 200, {
|
|
92
113
|
ok: true,
|
|
@@ -100,9 +100,13 @@ function createSidecarLifecycle(deps) {
|
|
|
100
100
|
// 请求复用同一个在飞 Promise,并记录一次待重跑,确保最终下发的是最新配置。
|
|
101
101
|
let syncInFlight = null
|
|
102
102
|
let syncQueued = false
|
|
103
|
+
// 我们最近一次**下发给 worker** 的局域网端口(0 = 还没下发过)。它是 getOwnLanPort 的判据。
|
|
104
|
+
let configuredLanPort = 0
|
|
103
105
|
const applySidecarConfig = async () => {
|
|
104
106
|
await resolveLanPort()
|
|
105
|
-
|
|
107
|
+
const config = buildConfig(lanPortInUse())
|
|
108
|
+
configuredLanPort = Number.isInteger(config.lanPort) ? config.lanPort : 0
|
|
109
|
+
sidecarSnapshot = await sidecar.configure(config)
|
|
106
110
|
sidecarState = 'healthy'
|
|
107
111
|
return sidecarSnapshot
|
|
108
112
|
}
|
|
@@ -239,6 +243,20 @@ function createSidecarLifecycle(deps) {
|
|
|
239
243
|
if (Number.isInteger(reported) && reported > 0) return reported
|
|
240
244
|
return lanPortInUse()
|
|
241
245
|
},
|
|
246
|
+
/**
|
|
247
|
+
* 「这个局域网端口是我们自己占着的」——就是**最近一次下发给 worker 的那个号**;没下发过是 0。
|
|
248
|
+
*
|
|
249
|
+
* 为什么不用快照来判:`lan.port` 走的是 worker 上报的状态帧,是**另一条消息**。刚
|
|
250
|
+
* `configure` 完的那个窗口里快照还是上一个号(或还没绑完),此刻去试绑刚下发的号必然
|
|
251
|
+
* 失败——那是自己占着自己,不是被别人占了。只认快照的话,紧接着的第二次同步就会把这个
|
|
252
|
+
* 号判成「被占」、换个内核随手给的端口写回 relay.json,监听也跟着挪走(`/api/relay/config`
|
|
253
|
+
* 改端口那条路由**一次会同步两遍**,第二遍正好落在这个窗口里;2026-10-11 真机实测,
|
|
254
|
+
* 落盘端口 18787→22779→33091→17079→28223 一路漂,手机里存的地址就是这么废的)。
|
|
255
|
+
*
|
|
256
|
+
* 代价照实说:真的被别的程序占着时,这里也认这个号。那没关系——worker 自己 bind 失败会
|
|
257
|
+
* 退到内核给的空闲端口并按**实绑值**上报,对外报的地址(getAdvertisedLanPort)跟着它走。
|
|
258
|
+
*/
|
|
259
|
+
getOwnLanPort: () => configuredLanPort,
|
|
242
260
|
markSidecarDown,
|
|
243
261
|
}
|
|
244
262
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
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",
|