dsh-mobile-plugin 0.1.99 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +79 -5
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +129 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +135 -9
- package/lib/routes/mobile-api.js +87 -7
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/package.json +3 -2
package/lib/mobile/questions.js
CHANGED
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
* 1. 听 `user-questions/request` 钩子(**prepend**,理由与审批桥同一条:宿主的转发器
|
|
17
17
|
* `dsh-api-remotes` 在桌面窗口开着时会一直等桌面回答、不调用 `next()`)。铸一个自己的
|
|
18
18
|
* rpcId,把卡片推给正在看这个会话的手机,然后把请求挂住。
|
|
19
|
-
* 2.
|
|
20
|
-
*
|
|
19
|
+
* 2. 下游**总是**拿到请求(桌面那张卡照旧出现,`dsh-plan-mode` 也一样),与手机那张
|
|
20
|
+
* 卡同时存在:手机答得早就用手机的,桌面答得早就用桌面的,落定后广播
|
|
21
|
+
* `question/resolved`,另一边那张卡被收掉。手机在看的时候也照转,否则桌面根本
|
|
22
|
+
* 没有卡可显示 —— 桌面那张卡只由「被转发的请求」长出来,切回去也补不回来
|
|
23
|
+
* (2026-10-24 用户报「APP 有卡、桌面什么都没有」的就是这条)。
|
|
21
24
|
* 3. `/question/respond` 拿 rpcId 找到挂起项,把回答交回钩子 —— 这才是真正让 agent
|
|
22
|
-
*
|
|
25
|
+
* 继续往下跑的那一步。
|
|
23
26
|
*
|
|
24
27
|
* 「计划评审」不走另一条通道:`dsh-plan-mode` 的 `exit_plan_mode` 就是拿
|
|
25
28
|
* `ctx.userQuestions.ask()` 问的(问题带 `intent.kind='plan-review'`、`detail` 是计划正文、
|
|
@@ -237,16 +240,31 @@ function bindRequest(entry, request) {
|
|
|
237
240
|
};
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
/** 宿主「这一趟没有任何答案方」的失败码(`dsh-user-questions` 的 `noAnswerer`)。 */
|
|
244
|
+
function noAnswerer(error) {
|
|
245
|
+
return Boolean(error) && typeof error === 'object' && error.code === 'NO_PROVIDER';
|
|
246
|
+
}
|
|
247
|
+
|
|
240
248
|
/**
|
|
241
|
-
*
|
|
249
|
+
* 挂起这条请求直到有人回答:桌面的转发与手机的卡**同时进行**,谁先答就用谁的答案。
|
|
242
250
|
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
251
|
+
* 桌面先答:下游给出答案,结算挂起项(手机那张卡由结算帧收掉)。
|
|
252
|
+
* 手机先答:把手机的回答交回钩子。桌面那张卡由宿主自己收尾 —— 这一问落定后宿主的限时
|
|
253
|
+
* 等待随之结束,桌面客户端把「被转发的请求」delegate 回来,卡跟着撤掉,会话投影也不再
|
|
254
|
+
* 列这个 call。那一下会让 `downstream` 再落一次;上面的 race 已经挂着它的处理函数,迟到
|
|
255
|
+
* 的那次不会冒成未处理异常,`settle` 本身也是幂等的,收卡与回答都不受影响。
|
|
256
|
+
*
|
|
257
|
+
* `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate 回来)不是结论:手机正在看这个会话时
|
|
258
|
+
* 卡还在手机上等着,继续等手机答;没人看时照旧把失败交回去,纯桌面表现一字不改。
|
|
245
259
|
*/
|
|
246
260
|
function holdForPhone(entry, next) {
|
|
247
261
|
const downstream = Promise.resolve().then(() => next()).then(
|
|
248
262
|
(answer) => { settle(entry, null); return answer; },
|
|
249
|
-
(error) => {
|
|
263
|
+
(error) => {
|
|
264
|
+
if (noAnswerer(error) && watcherCount(entry.sessionId) > 0) return new Promise(() => {});
|
|
265
|
+
settle(entry, null);
|
|
266
|
+
throw error;
|
|
267
|
+
},
|
|
250
268
|
);
|
|
251
269
|
const fromPhone = entry.wait.then((result) => {
|
|
252
270
|
if (result.answers) return { answers: result.answers };
|
|
@@ -263,6 +281,8 @@ function onQuestionRequest(request, next) {
|
|
|
263
281
|
if (!sessionId) return next();
|
|
264
282
|
const entry = entryFor(sessionId, request);
|
|
265
283
|
bindRequest(entry, request);
|
|
284
|
+
// 旁路转发:桌面和手机同时出卡。少转这一下,手机看着的时候桌面从源头就没有卡 ——
|
|
285
|
+
// 而桌面那张卡只由「被转发的请求」长出来,之后切过去也补不回来。
|
|
266
286
|
return holdForPhone(entry, next);
|
|
267
287
|
}
|
|
268
288
|
|
|
@@ -280,6 +300,15 @@ function installQuestionBridge(ctx) {
|
|
|
280
300
|
*
|
|
281
301
|
* 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 宿主那份真 apiProxy 才是
|
|
282
302
|
* 它的归宿;0.2.x 的兼容桥会如实回 `host-unsupported`,于是落到下面那条 409)。
|
|
303
|
+
*
|
|
304
|
+
* ⚠️ `respond` 在两条通道上的返回形态**不一样**,这里必须两种都受得住:
|
|
305
|
+
* - 0.1.x 的真 apiProxy → `Promise<{accepted}>`;
|
|
306
|
+
* - 0.2.x 的兼容桥(`mobile/host-bridge.js`)→ **同步对象**
|
|
307
|
+
* `{result:{ok:false,error:{code:'host-unsupported'}}}`,它压根不是 Promise。
|
|
308
|
+
* 早先这里直接 `.catch()`,于是在 0.2.x 上表达缺席的那一发会抛
|
|
309
|
+
* `TypeError: ctx.apiProxy.respond(...).catch is not a function`,被路由守卫兜成
|
|
310
|
+
* **500 internal** 而不是下面那句 409。手机端因此只看到「提交失败」的原始异常,
|
|
311
|
+
* 而桌面那张卡还挂着 —— 用户看到的就是「APP 提交没到 DSH,还得回桌面重答一遍」。
|
|
283
312
|
*/
|
|
284
313
|
async function handleQuestionRespond(ctx, req, res) {
|
|
285
314
|
const body = await readJsonBody(req);
|
|
@@ -295,17 +324,53 @@ async function handleQuestionRespond(ctx, req, res) {
|
|
|
295
324
|
settle(entry, answers);
|
|
296
325
|
return sendJson(res, 200, { ok: true, accepted: true });
|
|
297
326
|
}
|
|
298
|
-
const receipt = await ctx
|
|
327
|
+
const receipt = await respondViaLegacyProxy(ctx, {
|
|
299
328
|
type: 'client-response',
|
|
300
329
|
rpcId: body.rpcId,
|
|
301
330
|
result: { ok: true, value: { sessionId: body.sessionId, answer: { answers: (body.answer && body.answer.answers) || [] } } },
|
|
302
|
-
})
|
|
331
|
+
});
|
|
303
332
|
if (!receipt || receipt.accepted !== true) {
|
|
304
|
-
|
|
333
|
+
// 本桥不认识这个 rpcId,旧通道也不认:这条提问在**我们这一侧**已经死了
|
|
334
|
+
// (挂起项只活在进程内,插件重载 / 宿主重启就没了)。
|
|
335
|
+
//
|
|
336
|
+
// 这里**故意不推 question/resolved 收卡**:手机端要靠这张卡把「为什么没答成」
|
|
337
|
+
// 显示出来(`UserQuestionDock` 的 error 行),卡一收掉就跟着卸载,那句话没地方落。
|
|
338
|
+
// 留着卡也没有副作用:这条 rpcId 在本桥表里根本不存在,再点一次同样回 409、刷新
|
|
339
|
+
// 同一句说明。真正该收卡的是「别人已经答掉了」,那条路在 settle 里。
|
|
340
|
+
return sendJson(res, 409, {
|
|
341
|
+
ok: false,
|
|
342
|
+
error: {
|
|
343
|
+
code: 'not-pending',
|
|
344
|
+
message: '这条提问已经不在等待回答(插件重载或宿主重启后挂起的提问会失效),请回桌面端作答。',
|
|
345
|
+
},
|
|
346
|
+
});
|
|
305
347
|
}
|
|
306
348
|
sendJson(res, 200, { ok: true, accepted: true });
|
|
307
349
|
}
|
|
308
350
|
|
|
351
|
+
/**
|
|
352
|
+
* 把回答交回旧通道的 `ctx.apiProxy.respond`,**Promise 与同步对象两种返回都收**。
|
|
353
|
+
*
|
|
354
|
+
* 两条通道的形态不同(见 `handleQuestionRespond` 的注释):0.1.x 是 Promise,0.2.x 的兼容桥
|
|
355
|
+
* 是同步的 `{result:{ok:false,...}}`。同步对象上没有 `.catch`,所以不能在这里链 Promise。
|
|
356
|
+
* 抛错与「通道说没有」一律折算成 null —— 调用方据此如实回 409,而不是把 500 抛给手机。
|
|
357
|
+
*
|
|
358
|
+
* 兼容桥那种 `{result:{ok:false,error:{code:'host-unsupported'}}}` 里没有任何 `accepted`
|
|
359
|
+
* 字段,`accepted !== true` 自然落到 409,不必单独判 code。
|
|
360
|
+
*/
|
|
361
|
+
async function respondViaLegacyProxy(ctx, message) {
|
|
362
|
+
const proxy = ctx && ctx.apiProxy;
|
|
363
|
+
if (!proxy || typeof proxy.respond !== 'function') return null;
|
|
364
|
+
try {
|
|
365
|
+
const reply = proxy.respond(message);
|
|
366
|
+
// 同步对象与 Promise 都要:先 await(对普通对象是恒等),拿到的仍可能是信封。
|
|
367
|
+
const settled = await reply;
|
|
368
|
+
return settled && typeof settled === 'object' ? settled : null;
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
309
374
|
module.exports = {
|
|
310
375
|
installQuestionBridge,
|
|
311
376
|
handleQuestionRespond,
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「这台设备的中转与连接配置」:读写、配对码、吊销、重连、端口自检、地址候选。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么手机要能改这些
|
|
7
|
+
*
|
|
8
|
+
* 这七八项(中转地址、注册密钥、设备名、局域网开关与端口、手机优先走哪条、配对策略、
|
|
9
|
+
* 遥控总开关)从前只有一个入口:电脑上当机面板(`/api/relay/*`,全部 `localOnly`)。
|
|
10
|
+
* 于是「手机上发现连不上、想改个中转地址」这件事必须回电脑前坐下才能做,而那往往正是
|
|
11
|
+
* 人不在电脑前的时候。
|
|
12
|
+
*
|
|
13
|
+
* 这些项与「装/卸插件」不是一类东西:它们**不会让插件代码变**,最坏结果只是这台电脑从
|
|
14
|
+
* 网上找不到 —— 而重连的主动权在手机这一侧(换地址、重新配对、同网走局域网),不会像
|
|
15
|
+
* 装坏插件那样死锁。所以这一层的能力面是**有意放开给已配对手机**的。
|
|
16
|
+
*
|
|
17
|
+
* ## 直接调内部实现,不绕本机 HTTP
|
|
18
|
+
*
|
|
19
|
+
* 控制面那几条 `localOnly` 路由是为了防「中转进来的人」而存在的:它们能读进程级错误账本
|
|
20
|
+
* (含堆栈)、能吊销全部授权。手机这边不能复用那道口子,所以这里**直接持有同一份
|
|
21
|
+
* `settings`(relay.json 的 config store)**,走同一套 schema 校验 —— 两个入口写的是同一
|
|
22
|
+
* 份真值,不会出现「面板说改了、手机说没改」。
|
|
23
|
+
*
|
|
24
|
+
* ## 会掐断自己那条隧道的动作
|
|
25
|
+
*
|
|
26
|
+
* 改中转地址 / 关遥控 / 吊销全部 / 重连 / 重启后台服务:这几条一动手,承载**这一发请求**
|
|
27
|
+
* 的那条隧道就可能立刻断。所以它们一律「先把响应吐完再动手」(`AFTER_RESPONSE_MS`),
|
|
28
|
+
* 手机看到 200 只代表「已经受理」,结果靠断线重连之后读状态判 —— 与插件自更新那条
|
|
29
|
+
* `apply` 同一个手感,理由也一样:响应发不出去的时候,任何「返回结果」的设计都是假的。
|
|
30
|
+
*
|
|
31
|
+
* ## 注册密钥只进不出
|
|
32
|
+
*
|
|
33
|
+
* `status()` 与二维码都不回显它(宿主那边本来就这么写),这里也照做:只接受写入,
|
|
34
|
+
* 状态里只报「设没设」。手机上看不到它,也就没有「读出来再抄走」这条路。
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const { randomBytes } = require('node:crypto');
|
|
38
|
+
|
|
39
|
+
const core = require('../relay/core.js');
|
|
40
|
+
const pairingApproval = require('../relay/pairing-policy.js');
|
|
41
|
+
const { canListen } = require('../relay/port-allocator.cjs');
|
|
42
|
+
const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
|
|
43
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
44
|
+
|
|
45
|
+
/** 改配置的请求体上限:几个短字符串,4KB 很宽。 */
|
|
46
|
+
const CONFIG_BODY_MAX = 4 * 1024;
|
|
47
|
+
|
|
48
|
+
/** 「先回包、再动手」的延迟:装/换一次隧道要几百毫秒,留一点让响应真正写完。 */
|
|
49
|
+
const AFTER_RESPONSE_MS = 120;
|
|
50
|
+
|
|
51
|
+
/** 测速的单条上限,与控制面 `/probe-relays` 同一档(6 秒)。 */
|
|
52
|
+
const PROBE_TIMEOUT_MS = 6000;
|
|
53
|
+
|
|
54
|
+
/** 一次最多测几条:手机只会问它自己存着的那几条,8 条已经很宽。 */
|
|
55
|
+
const PROBE_MAX_URLS = 8;
|
|
56
|
+
|
|
57
|
+
/** 离线故障台账只搬最近几条,且**不搬堆栈**:那是宿主内部路径,手机上没用。 */
|
|
58
|
+
const FAULT_TAIL = 5;
|
|
59
|
+
|
|
60
|
+
/** 名字最长多少字:与 `RemoteControlSettingsSchema` 的 `name` 同一条口径(不限长,但挡个糊话)。 */
|
|
61
|
+
const NAME_MAX_CHARS = 200;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 配置投影:手机要看到的每一项。
|
|
65
|
+
*
|
|
66
|
+
* `registerSecret` **只报设没设**,不回值 —— 理由见文件头。
|
|
67
|
+
* `pairingTicket` 同理不出:它是配对票据,只该活在二维码与配对流程里。
|
|
68
|
+
*/
|
|
69
|
+
function configView(state) {
|
|
70
|
+
return {
|
|
71
|
+
enabled: state.enabled === true,
|
|
72
|
+
relayUrl: typeof state.relayUrl === 'string' ? state.relayUrl : '',
|
|
73
|
+
name: typeof state.name === 'string' ? state.name : '',
|
|
74
|
+
lanEnabled: state.lanEnabled === true,
|
|
75
|
+
lanPort: Number.isInteger(state.lanPort) ? state.lanPort : 18787,
|
|
76
|
+
pairingPolicy: pairingApproval.normalize(state.pairingPolicy),
|
|
77
|
+
preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
|
|
78
|
+
/** 注册密钥设没设(值本身不回)。 */
|
|
79
|
+
registerSecretSet: typeof state.registerSecret === 'string' && state.registerSecret !== '',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 把请求体收敛成一个可以交给 `settings.update` 的 patch。
|
|
85
|
+
*
|
|
86
|
+
* 逐项都按控制面 `/api/relay/config` 的判据来:**空串对 `preferredLanAddress` 是合法值**
|
|
87
|
+
* (那是「清掉这次选择」的唯一方式),对 `name` 不是(改成一个没名字的宿主没有意义)。
|
|
88
|
+
* 任何一项形状不对就整次拒绝 —— 部分写入会让用户以为全都改好了。
|
|
89
|
+
*/
|
|
90
|
+
function configPatch(body) {
|
|
91
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, message: '请求体必须是一个对象' };
|
|
92
|
+
const patch = {};
|
|
93
|
+
if (body.relayUrl !== undefined) {
|
|
94
|
+
if (typeof body.relayUrl !== 'string') return { ok: false, message: 'relayUrl 必须是字符串' };
|
|
95
|
+
patch.relayUrl = body.relayUrl.trim();
|
|
96
|
+
}
|
|
97
|
+
if (body.lanEnabled !== undefined) {
|
|
98
|
+
if (typeof body.lanEnabled !== 'boolean') return { ok: false, message: 'lanEnabled 必须是布尔值' };
|
|
99
|
+
patch.lanEnabled = body.lanEnabled;
|
|
100
|
+
}
|
|
101
|
+
if (body.lanPort !== undefined) {
|
|
102
|
+
if (!Number.isInteger(body.lanPort) || body.lanPort < 1024 || body.lanPort > 65535) {
|
|
103
|
+
return { ok: false, message: 'lanPort 必须是 1024–65535 之间的整数' };
|
|
104
|
+
}
|
|
105
|
+
patch.lanPort = body.lanPort;
|
|
106
|
+
}
|
|
107
|
+
if (body.name !== undefined) {
|
|
108
|
+
if (typeof body.name !== 'string') return { ok: false, message: 'name 必须是字符串' };
|
|
109
|
+
const name = body.name.trim();
|
|
110
|
+
if (!name) return { ok: false, message: '这台电脑的名字不能为空' };
|
|
111
|
+
if (name.length > NAME_MAX_CHARS) return { ok: false, message: `名字最多 ${NAME_MAX_CHARS} 个字` };
|
|
112
|
+
patch.name = name;
|
|
113
|
+
}
|
|
114
|
+
if (body.pairingPolicy !== undefined) {
|
|
115
|
+
if (!pairingApproval.POLICIES.includes(body.pairingPolicy)) {
|
|
116
|
+
return { ok: false, message: `配对策略只能是 ${pairingApproval.POLICIES.join(' / ')}` };
|
|
117
|
+
}
|
|
118
|
+
patch.pairingPolicy = body.pairingPolicy;
|
|
119
|
+
}
|
|
120
|
+
if (body.registerSecret !== undefined) {
|
|
121
|
+
if (typeof body.registerSecret !== 'string' || body.registerSecret.length > 512) {
|
|
122
|
+
return { ok: false, message: '注册密钥必须是 512 字以内的字符串' };
|
|
123
|
+
}
|
|
124
|
+
patch.registerSecret = body.registerSecret;
|
|
125
|
+
}
|
|
126
|
+
if (body.preferredLanAddress !== undefined) {
|
|
127
|
+
if (typeof body.preferredLanAddress !== 'string' || body.preferredLanAddress.length > 64) {
|
|
128
|
+
return { ok: false, message: '优先地址必须是 64 字以内的字符串(空串表示不指定)' };
|
|
129
|
+
}
|
|
130
|
+
patch.preferredLanAddress = body.preferredLanAddress.trim();
|
|
131
|
+
}
|
|
132
|
+
if (Object.keys(patch).length === 0) return { ok: false, message: '没有任何可改的字段' };
|
|
133
|
+
return { ok: true, patch };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 响应写完再动手:这几条都会掐掉承载本请求的那条隧道。 */
|
|
137
|
+
function afterResponse(fn) {
|
|
138
|
+
setTimeout(() => { void fn(); }, AFTER_RESPONSE_MS);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 构造这组 handler。依赖由装配层注入,**只用取值器**:
|
|
143
|
+
* `settings` 是 relay.json 的 config store(会随 watch 整体替换),
|
|
144
|
+
* `state` 是内存真值(就地改),`syncSidecar` 让旁路重读配置。
|
|
145
|
+
*/
|
|
146
|
+
function createRelayControlHandlers(deps) {
|
|
147
|
+
const {
|
|
148
|
+
state,
|
|
149
|
+
settings,
|
|
150
|
+
syncSidecar,
|
|
151
|
+
status,
|
|
152
|
+
resolveLanPort,
|
|
153
|
+
lanPortInUse,
|
|
154
|
+
getLanPortAdjusted,
|
|
155
|
+
restartSidecar = null,
|
|
156
|
+
getSidecar = null,
|
|
157
|
+
credentials = null,
|
|
158
|
+
activeGuard = () => null,
|
|
159
|
+
probeUrls = null,
|
|
160
|
+
} = deps || {};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 装配层没把真值接进来时(单测里只挂路由表、或某一版装配漏了),统一回 503 而不是让
|
|
164
|
+
* handler 去读 undefined —— 那种错会被守卫兜成 500 `internal`,界面上看不出是「这台没
|
|
165
|
+
* 这个能力」还是「代码坏了」,而这两件事的处置完全不同。
|
|
166
|
+
*/
|
|
167
|
+
const unavailable = (res) => sendJson(res, 503, {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: { code: 'relay-control-unavailable', message: '这台宿主的插件没有把中转配置接进来,改不了配置' },
|
|
170
|
+
});
|
|
171
|
+
const wired = Boolean(state && settings);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 把「配置变了」这件事真正推下去。
|
|
175
|
+
*
|
|
176
|
+
* 优先走 config store 的通知:装配层那唯一一个订阅者负责 `saveState` **与**重配 sidecar,
|
|
177
|
+
* 走它才和桌面面板改配置是同一条路。没有 notify(测试里的假 store)才退回收 `syncSidecar`。
|
|
178
|
+
*/
|
|
179
|
+
function applyNow() {
|
|
180
|
+
if (typeof settings.notify === 'function') {
|
|
181
|
+
try { settings.notify(); return; } catch { /* 退回收 syncSidecar */ }
|
|
182
|
+
}
|
|
183
|
+
return syncSidecar().catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 地址候选:与控制面 `/addresses` 同一份来源,三份分开报(理由见 lan-network.cjs)。 */
|
|
187
|
+
function addressView() {
|
|
188
|
+
const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort;
|
|
189
|
+
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '';
|
|
190
|
+
return {
|
|
191
|
+
port,
|
|
192
|
+
preferred,
|
|
193
|
+
adjusted: typeof getLanPortAdjusted === 'function' ? getLanPortAdjusted() === true : false,
|
|
194
|
+
lan: lanCandidates(port),
|
|
195
|
+
public: publicCandidates(port),
|
|
196
|
+
ipv6: ipv6Candidates(port),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 故障台账摘要:只给最近几条的 kind / 时间 / 一句话,**不带堆栈**。 */
|
|
201
|
+
function faultsView() {
|
|
202
|
+
const guard = typeof activeGuard === 'function' ? activeGuard() : null;
|
|
203
|
+
if (!guard || typeof guard.faults !== 'function') {
|
|
204
|
+
return { installed: false, faults: [], stats: null };
|
|
205
|
+
}
|
|
206
|
+
const all = guard.faults();
|
|
207
|
+
const tail = all.slice(-FAULT_TAIL).map((fault) => ({
|
|
208
|
+
at: typeof fault.at === 'number' ? fault.at : 0,
|
|
209
|
+
kind: typeof fault.kind === 'string' ? fault.kind : 'unknown',
|
|
210
|
+
// 台账里 message 是 `describeError()` 的产物,可能带堆栈首行;只取第一行。
|
|
211
|
+
message: String(fault.message || '').split('\n')[0].slice(0, 300),
|
|
212
|
+
fatal: fault.fatal === true,
|
|
213
|
+
}));
|
|
214
|
+
const stats = typeof guard.stats === 'function' ? guard.stats() : null;
|
|
215
|
+
return {
|
|
216
|
+
installed: true,
|
|
217
|
+
faults: tail,
|
|
218
|
+
total: all.length,
|
|
219
|
+
stats: stats ? {
|
|
220
|
+
total: stats.total,
|
|
221
|
+
consecutive: stats.consecutive,
|
|
222
|
+
lastAt: stats.lastAt,
|
|
223
|
+
fatal: stats.fatal === true,
|
|
224
|
+
} : null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 把一条探测做得有界:打 `/healthz`,2xx/3xx 算通,耗时从发出到读完响应体。 */
|
|
229
|
+
async function probeOne(url) {
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
try {
|
|
232
|
+
const controller = new AbortController();
|
|
233
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
234
|
+
const response = await fetch(`${url}/healthz`, { signal: controller.signal, redirect: 'follow' });
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
await response.text().catch(() => '');
|
|
237
|
+
return { url, ms: response.ok ? Date.now() - started : null };
|
|
238
|
+
} catch {
|
|
239
|
+
return { url, ms: null };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* `GET /api/mobile/v1/relay/config` —— 当前配置 + 运行状态 + 地址候选。
|
|
245
|
+
*
|
|
246
|
+
* 一次给全:手机进这一页只要一发就能把「配了什么、现在通不通、能填哪条地址」都拿到,
|
|
247
|
+
* 不需要连打四五条再自己拼。
|
|
248
|
+
*/
|
|
249
|
+
async function handleRelayConfig(_ctx, _req, res) {
|
|
250
|
+
if (!wired) return unavailable(res);
|
|
251
|
+
sendJson(res, 200, {
|
|
252
|
+
ok: true,
|
|
253
|
+
config: configView(state),
|
|
254
|
+
addresses: addressView(),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `POST /api/mobile/v1/relay/config` —— 改配置。
|
|
260
|
+
*
|
|
261
|
+
* 两档结果:
|
|
262
|
+
* · 只改了不进 sidecar 的项(`preferredLanAddress` / `name`)→ 当场改完、回新配置,
|
|
263
|
+
* 手机不用重连;
|
|
264
|
+
* · 改了会让旁路重配的项(`relayUrl` / `enabled` 相关的其余项)→ 先回 200 再说,
|
|
265
|
+
* 随后 `syncSidecar` 把新配置推下去,隧道可能断一下。
|
|
266
|
+
*/
|
|
267
|
+
async function handleRelayConfigSave(_ctx, req, res) {
|
|
268
|
+
if (!wired) return unavailable(res);
|
|
269
|
+
let body = {};
|
|
270
|
+
try {
|
|
271
|
+
body = (await readJsonBody(req, CONFIG_BODY_MAX)) || {};
|
|
272
|
+
} catch {
|
|
273
|
+
body = {};
|
|
274
|
+
}
|
|
275
|
+
const parsed = configPatch(body);
|
|
276
|
+
if (!parsed.ok) {
|
|
277
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: parsed.message } });
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
// `notify: false` 是关键:订阅者(装配层那个 watch)一跑就会重配隧道,而那一发响应
|
|
281
|
+
// 还没吐完。所以先只落盘,回完包再通知。
|
|
282
|
+
await settings.update(parsed.patch, { notify: false });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return sendJson(res, 400, {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: { code: 'config-rejected', message: String(error?.message || error) },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// 改了端口:下一次同步前必须重新探一次(首选端口被占时要让内核换一个),
|
|
290
|
+
// 否则二维码与状态里报的仍是一个没人监听的端口。
|
|
291
|
+
if (parsed.patch.lanPort !== undefined && typeof resolveLanPort === 'function') {
|
|
292
|
+
await resolveLanPort().catch(() => undefined);
|
|
293
|
+
}
|
|
294
|
+
const view = { ok: true, config: configView(state), addresses: addressView() };
|
|
295
|
+
sendJson(res, 200, view);
|
|
296
|
+
// 推给旁路:改地址 / 端口 / 局域网开关都会重配隧道与直连,这一下多半会断。
|
|
297
|
+
afterResponse(() => applyNow());
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `POST /api/mobile/v1/relay/enabled` —— 远程控制总开关。
|
|
302
|
+
*
|
|
303
|
+
* 单独一条而不是并进 config:它在控制面上也是单独一条(`/toggle`),而且**关掉它等于
|
|
304
|
+
* 手机立刻失去这台电脑**(只能同网用局域网)。所以口径上与其余配置分开,界面上也要
|
|
305
|
+
* 单独确认。
|
|
306
|
+
*/
|
|
307
|
+
async function handleRelayToggle(_ctx, req, res) {
|
|
308
|
+
if (!wired) return unavailable(res);
|
|
309
|
+
let body = {};
|
|
310
|
+
try {
|
|
311
|
+
body = (await readJsonBody(req, CONFIG_BODY_MAX)) || {};
|
|
312
|
+
} catch {
|
|
313
|
+
body = {};
|
|
314
|
+
}
|
|
315
|
+
if (typeof body.enabled !== 'boolean') {
|
|
316
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } });
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
await settings.update({ enabled: body.enabled }, { notify: false });
|
|
320
|
+
} catch (error) {
|
|
321
|
+
return sendJson(res, 400, {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: { code: 'config-rejected', message: String(error?.message || error) },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
sendJson(res, 200, { ok: true, enabled: state.enabled === true, config: configView(state) });
|
|
327
|
+
afterResponse(() => applyNow());
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* `POST /api/mobile/v1/relay/pairing-code` —— 重新生成配对码。
|
|
332
|
+
*
|
|
333
|
+
* 换码**同时也换票据**(`core.rotatePairing`):只换码不换票据,截图里的票据还能再配
|
|
334
|
+
* 一次;只换票据不换码,旧码在旧形状那条路上还能用。两件必须在同一个动作里。
|
|
335
|
+
*/
|
|
336
|
+
async function handleRelayRegenerate(_ctx, _req, res) {
|
|
337
|
+
if (!wired) return unavailable(res);
|
|
338
|
+
try {
|
|
339
|
+
core.rotatePairing(state);
|
|
340
|
+
core.saveState(state);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return sendJson(res, 500, {
|
|
343
|
+
ok: false,
|
|
344
|
+
error: { code: 'rotate-failed', message: String(error?.message || error) },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
sendJson(res, 200, { ok: true, pairingCode: state.pairingCode });
|
|
348
|
+
afterResponse(() => syncSidecar().catch(() => undefined));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* `POST /api/mobile/v1/relay/revoke-all` —— 吊销全部设备授权。
|
|
353
|
+
*
|
|
354
|
+
* **这一发会把这台手机自己也踢掉**(它就在「全部设备」里),所以必须先回包:回包之后
|
|
355
|
+
* 隧道被拆,手机看到的是断线,重连时会发现自己已经没凭据了 —— 那正是用户要的结果。
|
|
356
|
+
*
|
|
357
|
+
* 三处都要做(与 `/api/relay/revoke-all` 同一条口径):中转侧清授权、本机吊销局域网
|
|
358
|
+
* `dshc_` 凭据、轮换 `lanSecret` 与配对码。只做其中一处都会留下还能用的旧凭据。
|
|
359
|
+
*/
|
|
360
|
+
async function handleRelayRevokeAll(_ctx, req, res) {
|
|
361
|
+
if (!wired) return unavailable(res);
|
|
362
|
+
let body = {};
|
|
363
|
+
try {
|
|
364
|
+
body = (await readJsonBody(req, CONFIG_BODY_MAX)) || {};
|
|
365
|
+
} catch {
|
|
366
|
+
body = {};
|
|
367
|
+
}
|
|
368
|
+
if (body.confirm !== true) {
|
|
369
|
+
return sendJson(res, 400, {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: { code: 'confirm-required', message: '吊销全部授权不可撤销,请求需要带 confirm: true' },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
sendJson(res, 200, { ok: true, started: true });
|
|
375
|
+
afterResponse(async () => {
|
|
376
|
+
// 三处都要做(与控制面 `/api/relay/revoke-all` 同一条口径):中转清空本实例全部设备
|
|
377
|
+
// 授权、本机吊销局域网 `dshc_` 凭据、轮换 `lanSecret` 与配对码。只做其中一处都会留下
|
|
378
|
+
// 还能用的旧凭据 —— 而「撤销全部授权」正是在要求它们**全部**失效。
|
|
379
|
+
let revoked = 0;
|
|
380
|
+
try {
|
|
381
|
+
const sidecar = typeof getSidecar === 'function' ? getSidecar() : null;
|
|
382
|
+
if (sidecar && typeof sidecar.request === 'function') {
|
|
383
|
+
const result = await sidecar.request('/revoke-all', 'POST', undefined, 20_000);
|
|
384
|
+
revoked = Number(result && result.revoked) || 0;
|
|
385
|
+
}
|
|
386
|
+
} catch { /* 中转不可达时仍继续吊销本机凭据与密钥 */ }
|
|
387
|
+
try {
|
|
388
|
+
const localRevoked = credentials && typeof credentials.revokeAllByInstance === 'function'
|
|
389
|
+
? credentials.revokeAllByInstance(state.instanceId)
|
|
390
|
+
: 0;
|
|
391
|
+
state.lanSecret = randomBytes(32).toString('hex');
|
|
392
|
+
state.pairingCode = core.genPairingCode();
|
|
393
|
+
core.saveState(state);
|
|
394
|
+
console.warn(`[dsh-mobile-plugin] [relay-control] 已吊销全部授权:中转 ${revoked}、本机 ${localRevoked}`);
|
|
395
|
+
await syncSidecar().catch(() => undefined);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
console.warn('[dsh-mobile-plugin] [relay-control] 吊销后收尾失败:', String(error?.message || error));
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** `POST /api/mobile/v1/relay/reconnect` —— 让旁路重读配置并重建隧道。 */
|
|
403
|
+
async function handleRelayReconnect(_ctx, _req, res) {
|
|
404
|
+
if (!wired) return unavailable(res);
|
|
405
|
+
sendJson(res, 200, { ok: true, started: true });
|
|
406
|
+
afterResponse(() => syncSidecar().catch(() => undefined));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* `POST /api/mobile/v1/relay/restart-sidecar` —— 重启后台服务(worker + facade)。
|
|
411
|
+
*
|
|
412
|
+
* 与 `/api/relay/restart-sidecar` 是同一个动作:换掉 worker 子进程与 facade 模块,
|
|
413
|
+
* 宿主本身不重启。隧道会断一两秒,这也是「先回包再动手」。
|
|
414
|
+
*/
|
|
415
|
+
async function handleRelayRestartSidecar(_ctx, _req, res) {
|
|
416
|
+
if (!wired) return unavailable(res);
|
|
417
|
+
if (typeof restartSidecar !== 'function') {
|
|
418
|
+
return sendJson(res, 409, {
|
|
419
|
+
ok: false,
|
|
420
|
+
error: { code: 'restart-unavailable', message: '这台宿主的插件版本不支持重启后台服务' },
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
sendJson(res, 200, { ok: true, started: true });
|
|
424
|
+
afterResponse(() => restartSidecar().catch(() => undefined));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* `POST /api/mobile/v1/relay/check-port` —— 端口自检。
|
|
429
|
+
*
|
|
430
|
+
* **只读,不写配置**(用户点着玩的动作不该改落盘状态)。用的是局域网代理同一把探针,
|
|
431
|
+
* 所以这里说「可用」、真正 listen 时就不会 EADDRINUSE。
|
|
432
|
+
*
|
|
433
|
+
* 两个必须单独认出来的现场(与控制面同一条经验):这台宿主自己的网页端口不能当直连
|
|
434
|
+
* 端口;而它**自己正在监听**的那个端口,`canListen` 当然会失败,但那不是「被别的程序
|
|
435
|
+
* 占用」。
|
|
436
|
+
*/
|
|
437
|
+
async function handleRelayCheckPort(_ctx, req, res) {
|
|
438
|
+
let body = {};
|
|
439
|
+
try {
|
|
440
|
+
body = (await readJsonBody(req, CONFIG_BODY_MAX)) || {};
|
|
441
|
+
} catch {
|
|
442
|
+
body = {};
|
|
443
|
+
}
|
|
444
|
+
const port = Number(body.port);
|
|
445
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
446
|
+
return sendJson(res, 200, { ok: true, port: null, available: false, reason: 'out-of-range' });
|
|
447
|
+
}
|
|
448
|
+
const snapshot = typeof status === 'function' ? status() : {};
|
|
449
|
+
const listening = snapshot?.lan?.state === 'listening';
|
|
450
|
+
const currentPort = snapshot?.lan?.port;
|
|
451
|
+
if (snapshot?.localPort === port) {
|
|
452
|
+
return sendJson(res, 200, { ok: true, port, available: false, ours: false, listening, currentPort, reason: 'host-web-port' });
|
|
453
|
+
}
|
|
454
|
+
if (listening && currentPort === port) {
|
|
455
|
+
return sendJson(res, 200, { ok: true, port, available: true, ours: true, listening, currentPort });
|
|
456
|
+
}
|
|
457
|
+
sendJson(res, 200, { ok: true, port, available: await canListen(port, '0.0.0.0'), ours: false, listening, currentPort });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* `POST /api/mobile/v1/relay/probe` —— 测中转候选的连通与耗时。
|
|
462
|
+
*
|
|
463
|
+
* **必须由宿主代测**:手机在蜂窝网络下直连那些域名可能被运营商挡、也可能被 CORS 挡住
|
|
464
|
+
* (读到的是浏览器拦截而不是真实耗时)。宿主是出站无 CORS 的本机进程。
|
|
465
|
+
*
|
|
466
|
+
* 只认 http(s)、最多 8 条:这条不接受「任意地址探测」那种用法。
|
|
467
|
+
*/
|
|
468
|
+
async function handleRelayProbe(_ctx, req, res) {
|
|
469
|
+
let body = {};
|
|
470
|
+
try {
|
|
471
|
+
body = (await readJsonBody(req, CONFIG_BODY_MAX)) || {};
|
|
472
|
+
} catch {
|
|
473
|
+
body = {};
|
|
474
|
+
}
|
|
475
|
+
const list = Array.isArray(body.urls) ? body.urls : [];
|
|
476
|
+
const targets = list
|
|
477
|
+
.filter((url) => typeof url === 'string' && /^https?:\/\//i.test(url.trim()))
|
|
478
|
+
.slice(0, PROBE_MAX_URLS)
|
|
479
|
+
.map((url) => url.trim().replace(/\/+$/, ''));
|
|
480
|
+
const runner = typeof probeUrls === 'function' ? probeUrls : (urls) => Promise.all(urls.map(probeOne));
|
|
481
|
+
const results = await runner(targets);
|
|
482
|
+
sendJson(res, 200, { ok: true, results });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* `GET /api/mobile/v1/relay/diagnostics` —— 只读诊断:故障台账 + 地址候选的端口细节。
|
|
487
|
+
*
|
|
488
|
+
* 隧道为什么断、局域网为什么没起来,答案在故障台账与端口自检这两处;两条一起给,
|
|
489
|
+
* 手机上一页就能回答「这台到底怎么了」。
|
|
490
|
+
*
|
|
491
|
+
* **不搬堆栈**:台账的 message 只取第一行,截断 300 字。
|
|
492
|
+
*/
|
|
493
|
+
async function handleRelayDiagnostics(_ctx, _req, res) {
|
|
494
|
+
if (!wired) return unavailable(res);
|
|
495
|
+
const snapshot = typeof status === 'function' ? status() : {};
|
|
496
|
+
sendJson(res, 200, {
|
|
497
|
+
ok: true,
|
|
498
|
+
faults: faultsView(),
|
|
499
|
+
addresses: addressView(),
|
|
500
|
+
/** 端口那两格的来历:「配置里写的」与「真的在监听的」可能不同。 */
|
|
501
|
+
port: {
|
|
502
|
+
requested: Number.isInteger(state.lanPort) ? state.lanPort : null,
|
|
503
|
+
inUse: typeof lanPortInUse === 'function' ? lanPortInUse() : null,
|
|
504
|
+
adjusted: typeof getLanPortAdjusted === 'function' ? getLanPortAdjusted() === true : false,
|
|
505
|
+
},
|
|
506
|
+
/** 隧道为什么被拒那几条判断(宿主那边按中转错误文案推出来的)。 */
|
|
507
|
+
tunnel: snapshot?.tunnel ?? null,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
handleRelayConfig,
|
|
513
|
+
handleRelayConfigSave,
|
|
514
|
+
handleRelayToggle,
|
|
515
|
+
handleRelayRegenerate,
|
|
516
|
+
handleRelayRevokeAll,
|
|
517
|
+
handleRelayReconnect,
|
|
518
|
+
handleRelayRestartSidecar,
|
|
519
|
+
handleRelayCheckPort,
|
|
520
|
+
handleRelayProbe,
|
|
521
|
+
handleRelayDiagnostics,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
module.exports = {
|
|
526
|
+
createRelayControlHandlers,
|
|
527
|
+
configView,
|
|
528
|
+
configPatch,
|
|
529
|
+
AFTER_RESPONSE_MS,
|
|
530
|
+
CONFIG_BODY_MAX,
|
|
531
|
+
PROBE_MAX_URLS,
|
|
532
|
+
FAULT_TAIL,
|
|
533
|
+
NAME_MAX_CHARS,
|
|
534
|
+
};
|