dsh-mobile-plugin 0.4.1 → 0.4.4
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 +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +39 -16
- package/lib/impl.js +0 -4
- package/lib/index.js +1 -1
- package/lib/mobile/allowed-zone.js +100 -0
- package/lib/mobile/archive-clean.js +4 -3
- package/lib/mobile/attachments.js +5 -1
- package/lib/mobile/file-read.js +168 -0
- package/lib/mobile/history-derive.js +67 -98
- package/lib/mobile/local-images.js +38 -7
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +1 -1
- package/lib/mobile/plugin-update.js +1 -1
- package/lib/mobile/relay-control.js +131 -7
- package/lib/mobile/session-cwd.js +28 -0
- package/lib/mobile/session-file.js +1 -12
- package/lib/mobile/stats-collect.cjs +146 -15
- package/lib/mobile/stats-ledger.cjs +249 -59
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +30 -110
- package/lib/mobile/uploads.js +68 -5
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/panel/index.html +2 -2
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/protocol.js +4 -0
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +12 -3
- package/lib/routes/mobile-api.js +28 -2
- package/lib/session-cleanup.cjs +10 -0
- package/lib/shared/device-headers.js +12 -4
- package/lib/shell/updater.js +2 -2
- package/lib/sidecar-facade-worker.cjs +55 -8
- package/lib/sidecar-facade.cjs +11 -1
- package/lib/sidecar-lifecycle.cjs +14 -1
- package/lib/update-install.cjs +13 -6
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「按新配置重建隧道失败」的台账:最近一次失败的错误、时间与当时要切过去的地址。
|
|
5
|
+
*
|
|
6
|
+
* 以前装配层写的是 `void syncSidecar().catch(() => undefined)`:换地址失败被整个吞掉,
|
|
7
|
+
* 手机上看是「保存成功」然后永久离线,宿主日志里也一个字都没有 —— 用户唯一的线索是「连不上
|
|
8
|
+
* 了」,还得自己猜是不是地址填错了。台账只留最近一条,由
|
|
9
|
+
* `/api/mobile/v1/relay/diagnostics` 原样报给手机(「运行状态」那一页读它)。
|
|
10
|
+
*
|
|
11
|
+
* 只留一条是刻意的:这里回答的是「刚才那次为什么没成」,不是故障统计(故障统计归
|
|
12
|
+
* `host-guard` 的 faults)。成功一次记下 `okAt`(那一次 configure 被 worker 接下了),好让
|
|
13
|
+
* 界面能说清「失败过,后来又好了」;隧道到底连上没有,看同一份诊断里的 `tunnel`。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 台账里 message 的上限:与 faults 同一条口径,手机上只看一句话。 */
|
|
17
|
+
const MESSAGE_MAX = 300;
|
|
18
|
+
|
|
19
|
+
let lastFailure = null;
|
|
20
|
+
let lastOkAt = 0;
|
|
21
|
+
|
|
22
|
+
/** 记一次失败。`target` 是当时配置里那份要切过去的中转地址(没有就不带)。 */
|
|
23
|
+
function recordFailure(error, target) {
|
|
24
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
25
|
+
lastFailure = {
|
|
26
|
+
at: Date.now(),
|
|
27
|
+
// 只留第一行:facade 拼出来的错误后面可能跟着整段堆栈首行,手机上没用。
|
|
28
|
+
message: String(raw || '').split('\n')[0].slice(0, MESSAGE_MAX),
|
|
29
|
+
...(target ? { target: String(target) } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 记一次成功下发:失败记录不删(它确实发生过),只补上「之后又通了」的时间。 */
|
|
34
|
+
function recordSuccess() {
|
|
35
|
+
lastOkAt = Date.now();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 只读投影:诊断接口原样回这一份。 */
|
|
39
|
+
function view() {
|
|
40
|
+
return {
|
|
41
|
+
lastFailure: lastFailure ? { ...lastFailure } : null,
|
|
42
|
+
okAt: lastOkAt || null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 清空(只有测试用得到;生产里失败记录由下一次失败覆盖)。 */
|
|
47
|
+
function reset() {
|
|
48
|
+
lastFailure = null;
|
|
49
|
+
lastOkAt = 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { recordFailure, recordSuccess, view, reset, MESSAGE_MAX };
|
package/lib/relay/core.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* relay.json 的状态形状与读写,外加实例 ID / 配对码 / 隧道地址这几个纯函数。
|
|
3
3
|
*
|
|
4
|
-
* 内存里的 `state` 是本进程唯一真值,这里只负责落盘与还原(见 A3-20
|
|
4
|
+
* 内存里的 `state` 是本进程唯一真值,这里只负责落盘与还原(见 A3-20 的坑)。中转地址的
|
|
5
|
+
* 规范形态(`normalizeRelayUrl`)也在这里:配置里、设备手上、`MIGRATE` 帧里必须是同一份
|
|
6
|
+
* 字符串,否则「地址变了」会被判错,迁移登记白跑一次。
|
|
5
7
|
*/
|
|
6
8
|
'use strict';
|
|
7
9
|
|
|
@@ -87,6 +89,15 @@ function saveState(state) {
|
|
|
87
89
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
88
90
|
fs.renameSync(tmp, STATE_FILE);
|
|
89
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* 中转地址的规范形态:去首尾空白、去尾斜杠。
|
|
94
|
+
*
|
|
95
|
+
* 设备手上存的那份、`MIGRATE` 帧里报出去的 `to`、配置里落盘的那份,必须是同一个字符串 ——
|
|
96
|
+
* 差一个尾斜杠就是「地址变了」,换地址那套迁移登记会白跑一次。
|
|
97
|
+
*/
|
|
98
|
+
function normalizeRelayUrl(relayUrl) {
|
|
99
|
+
return String(relayUrl || '').trim().replace(/\/+$/, '');
|
|
100
|
+
}
|
|
90
101
|
/**
|
|
91
102
|
* 中转地址 → 隧道 WebSocket 地址。
|
|
92
103
|
*
|
|
@@ -96,7 +107,7 @@ function saveState(state) {
|
|
|
96
107
|
* `/api/relay/config` 能在保存时就给出明确反馈。
|
|
97
108
|
*/
|
|
98
109
|
function tunnelWsUrl(relayUrl) {
|
|
99
|
-
const raw =
|
|
110
|
+
const raw = normalizeRelayUrl(relayUrl);
|
|
100
111
|
if (!/^https?:\/\/[^\s/]+/i.test(raw)) {
|
|
101
112
|
throw new Error(`无效的中转地址「${relayUrl || ''}」:必须以 http:// 或 https:// 开头`);
|
|
102
113
|
}
|
|
@@ -107,5 +118,5 @@ function verifyLanToken(secret, token, instanceId) { return sharedToken.verifyLa
|
|
|
107
118
|
|
|
108
119
|
module.exports = {
|
|
109
120
|
STATE_FILE, MOBILE_TOKEN_TTL_SEC, MOBILE_BEARER_TTL_SEC, loadState, saveState, rotatePairing, genInstanceId, genPairingCode, genPairingTicket,
|
|
110
|
-
tunnelWsUrl, signLanToken, verifyLanToken,
|
|
121
|
+
tunnelWsUrl, normalizeRelayUrl, signLanToken, verifyLanToken,
|
|
111
122
|
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 换中转地址前的「搬迁登记」:在**旧隧道**上告诉中转「这台搬家到 to」,给帧留出真的离开
|
|
5
|
+
* 本机的时间,再建新隧道;新隧道注册不上就把那条登记收回来(`to:""`)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么非得在旧隧道上说:设备手上握的是旧地址,它只会在旧中转上找这台电脑。不先登记,
|
|
8
|
+
* 换完地址那一刻,所有不在同一个局域网的设备就永远找不到它了 —— 「换服务器之后拿旧地址的
|
|
9
|
+
* 设备永远学不到新地址」就是缺这一帧。
|
|
10
|
+
*
|
|
11
|
+
* 旧隧道**不立刻关**(两条路都一样):新隧道注册期间它还在服务在途请求;失败时更是只有它
|
|
12
|
+
* 还能发撤销。注册完成后再留 `OLD_TUNNEL_LINGER_MS`,让「已受理」或「失败」那一发响应
|
|
13
|
+
* 沿着老路回去 —— 这一发的结果是用户填完地址之后唯一的反馈。
|
|
14
|
+
*
|
|
15
|
+
* 顺序只有这一份实现:worker 的 `configure()` 在检测到中转地址变了之后调 `switchRelay`,
|
|
16
|
+
* 手机端与控制面两个入口都走同一条下发路径,不各写一套。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { T } = require('./protocol.js');
|
|
20
|
+
const core = require('./core.js');
|
|
21
|
+
|
|
22
|
+
/** MIGRATE 帧发出后留给它离开发送队列的时间:一次 `ws.send` 就进内核,200ms 足够。 */
|
|
23
|
+
const MIGRATE_FLUSH_MS = 200;
|
|
24
|
+
|
|
25
|
+
/** 新隧道注册成功后旧隧道还留多久:够一发 HTTP 响应(含中转那段)沿着旧路回完。 */
|
|
26
|
+
const OLD_TUNNEL_LINGER_MS = 1500;
|
|
27
|
+
|
|
28
|
+
const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
29
|
+
|
|
30
|
+
/** 登记用的目标地址:与配置、设备手上那份同一个规范形态。 */
|
|
31
|
+
function migrateTarget(relayUrl) {
|
|
32
|
+
return core.normalizeRelayUrl(relayUrl);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 在旧隧道上登记迁移;没连着就回 false(那就没什么可登记的)。 */
|
|
36
|
+
function announce(tunnel, relayUrl) {
|
|
37
|
+
if (!tunnel || !tunnel.connected) return false;
|
|
38
|
+
return tunnel.sendJson(T.migrate, 0, { to: migrateTarget(relayUrl), at: Date.now() }) === true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 撤销登记(`to` 空串 = 这台没搬走)。 */
|
|
42
|
+
function revoke(tunnel) {
|
|
43
|
+
if (!tunnel || !tunnel.connected) return false;
|
|
44
|
+
return tunnel.sendJson(T.migrate, 0, { to: '', at: Date.now() }) === true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 换地址:登记 → 等帧出去 → 起新隧道 → 成功则稍后收旧隧道 / 失败则撤销并抛错。
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {object|null} opts.oldTunnel 旧隧道(可能没连上,那就只是重建)
|
|
52
|
+
* @param {string} opts.relayUrl 要切过去的中转地址
|
|
53
|
+
* @param {() => Promise<unknown>} opts.open 建一条**新**隧道并返回它的注册 Promise;
|
|
54
|
+
* 不许先停旧的(旧的还要发撤销、还要把响应送回去)
|
|
55
|
+
* @param {(tunnel: object) => void} opts.drop 停掉一条隧道
|
|
56
|
+
* @returns {Promise<boolean>} 旧隧道上到底登记出去没有
|
|
57
|
+
*/
|
|
58
|
+
async function switchRelay({ oldTunnel, relayUrl, open, drop }) {
|
|
59
|
+
/** 留一段再收旧隧道:那一发「已受理 / 失败原因」的响应还在它上面往回走。 */
|
|
60
|
+
const dropLater = (tunnel) => {
|
|
61
|
+
if (!tunnel) return;
|
|
62
|
+
const linger = setTimeout(() => { try { drop(tunnel); } catch { /* 已经断开 */ } }, OLD_TUNNEL_LINGER_MS);
|
|
63
|
+
if (typeof linger.unref === 'function') linger.unref();
|
|
64
|
+
};
|
|
65
|
+
const announced = announce(oldTunnel, relayUrl);
|
|
66
|
+
// 帧必须先真的离开发送队列:紧接着建新隧道(以及失败后的撤销)都和这一帧的到达顺序有关。
|
|
67
|
+
if (announced) await wait(MIGRATE_FLUSH_MS);
|
|
68
|
+
let registered;
|
|
69
|
+
try {
|
|
70
|
+
registered = await open();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// 新地址没通:把登记收回去,旧中转别再把设备往一个连不上的地址送。
|
|
73
|
+
revoke(oldTunnel);
|
|
74
|
+
// 撤销之后也别立刻关:错误要沿旧隧道回到发起这次切换的那一发请求上 ——「如实回给
|
|
75
|
+
// 调用方」只有这一条物理路径。
|
|
76
|
+
dropLater(oldTunnel);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
dropLater(oldTunnel);
|
|
80
|
+
return announced && Boolean(registered);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
switchRelay,
|
|
85
|
+
announce,
|
|
86
|
+
revoke,
|
|
87
|
+
migrateTarget,
|
|
88
|
+
MIGRATE_FLUSH_MS,
|
|
89
|
+
OLD_TUNNEL_LINGER_MS,
|
|
90
|
+
};
|
package/lib/relay/protocol.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* 数据通道的 payload 是原始字节(不再 base64),控制消息是 UTF-8 JSON,ping/pong 为应用层
|
|
8
8
|
* 心跳(经代理/负载均衡时比 WS 原生 ping 可靠)。
|
|
9
9
|
*
|
|
10
|
+
* 控制帧 `migrate`(0x1C)是「这台搬家了」的登记:宿主在**旧隧道**上发出,中转据此把还
|
|
11
|
+
* 握着旧地址的设备导向新地址,`to` 空串表示撤销。发送与撤销的时序见 relay/migrate.js。
|
|
12
|
+
*
|
|
10
13
|
* 本文件是自包含副本(插件包不能跨目录引用中转仓库):完整内联了共用类型表与编解码实现。
|
|
11
14
|
* 事实源在中转仓库的 relay-core,本仓的 protocol-parity 测试钉住线格式;改动帧编号后必须
|
|
12
15
|
* 同步修改本文件。
|
|
@@ -40,6 +43,7 @@ const T = {
|
|
|
40
43
|
revokeAllAck: 0x19, // id=0 JSON {ok, revoked};中转确认已清空
|
|
41
44
|
pushRegister: 0x1A, // id=0 JSON {deviceId, platform, token};宿主代 App 上报 iOS 推送令牌(本实例)
|
|
42
45
|
pushSend: 0x1B, // id=0 JSON {deviceId, title, body, sessionId?, kind};宿主请求中转发一条 APNs 推送
|
|
46
|
+
migrate: 0x1C, // id=0 JSON {to, at};宿主在旧隧道上登记「这台搬去哪个中转」,to 空串 = 撤销
|
|
43
47
|
};
|
|
44
48
|
|
|
45
49
|
const TYPE_NAME = {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 试连中转:拿**新地址**真注册一次,注册上了才算这个地址能用,随后立刻断开。
|
|
5
|
+
*
|
|
6
|
+
* 手机和电脑面板上改中转地址,最坏的结果是这台电脑从网上消失 —— 而改地址的入口本身就挂在
|
|
7
|
+
* 那条隧道上。先落盘再重建等于让用户在一次手滑之后自己走回电脑前,所以改之前必须先验证:
|
|
8
|
+
* 这个动作就是那个验证。
|
|
9
|
+
*
|
|
10
|
+
* **临时实例 id**:中转按实例 id 认人,拿正式 id 去试连会把正在服务的那条顶掉(真机上表现
|
|
11
|
+
* 为「点了一下验证,手机立刻掉线」)。所以用正式 id 加 `~probe~` 加随机段,连完马上断,
|
|
12
|
+
* 不在中转上留实例。随机段不是防御性加盐:同一个实例连第二次探测时,中转那条旧记录还没被
|
|
13
|
+
* 心跳清掉,撞上就会互相取代。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { randomBytes } = require('node:crypto');
|
|
17
|
+
|
|
18
|
+
const { RelayTunnel } = require('./tunnel.js');
|
|
19
|
+
const core = require('./core.js');
|
|
20
|
+
|
|
21
|
+
/** 临时实例 id 里的标记段:出错时一眼能看出这不是一台真的宿主。 */
|
|
22
|
+
const PROBE_MARK = '~probe~';
|
|
23
|
+
|
|
24
|
+
/** 试连的实例 id:正式 id + 标记 + 随机段,必然与正式 id 不同。 */
|
|
25
|
+
function probeInstanceId(instanceId) {
|
|
26
|
+
return `${String(instanceId || '')}${PROBE_MARK}${randomBytes(4).toString('hex')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 用 `instanceId`(调用方给的是临时 id)连 `relayUrl` 并等到 `registered` 帧。
|
|
31
|
+
*
|
|
32
|
+
* resolve = 这个地址登录得了;reject = 地址形状不对、连不上、注册被拒或超时(消息原样往上
|
|
33
|
+
* 回,手机上看的就是这句)。无论成败都断开:留一个实例在中转上,下次真连接会被它顶一下。
|
|
34
|
+
*/
|
|
35
|
+
async function verifyRelayRegistration({ relayUrl, instanceId, upstreamPort, name, registerSecret }) {
|
|
36
|
+
// 地址形状不对在这里当场抛(与保存配置同一个判据),别等到 WebSocket 才炸。
|
|
37
|
+
const tunnel = new RelayTunnel({
|
|
38
|
+
relayUrl: core.tunnelWsUrl(relayUrl),
|
|
39
|
+
instanceId,
|
|
40
|
+
name: name || '',
|
|
41
|
+
upstream: `http://127.0.0.1:${upstreamPort}`,
|
|
42
|
+
registerSecret: typeof registerSecret === 'string' ? registerSecret : '',
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
await tunnel.start();
|
|
46
|
+
return { ok: true };
|
|
47
|
+
} finally {
|
|
48
|
+
try { tunnel.stop(); } catch { /* 已经断开 */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { probeInstanceId, verifyRelayRegistration, PROBE_MARK };
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* 依赖以取值器 / 函数注入,不捕获对象:闭包里那些 `let`(sidecarSnapshot、sidecarState…)
|
|
11
11
|
* 会被整体替换。`getSidecar` 尤其重要:sidecar 会被自重启换掉,拿到实例就等于拿到旧 facade
|
|
12
12
|
* 的快照。
|
|
13
|
+
*
|
|
14
|
+
* 改中转地址(`POST /api/relay/config`)与手机端写的是同一份配置、走同一条下发路径;「先登记
|
|
15
|
+
* 迁移、再重建隧道」的顺序在 worker 的 configure 里(见 ../relay/migrate.js),这里只把那次
|
|
16
|
+
* 下发的失败原因如实带回给面板。
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
const fs = require('node:fs');
|
|
@@ -653,8 +657,13 @@ function registerControlPlaneRoutes(deps) {
|
|
|
653
657
|
patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '')
|
|
654
658
|
}
|
|
655
659
|
await settings.update(patch)
|
|
656
|
-
|
|
657
|
-
|
|
660
|
+
// 换中转地址时宿主会**先在旧隧道上登记迁移、再重建**(顺序在 worker 的 configure 里,
|
|
661
|
+
// 见 lib/relay/migrate.js)—— 面板与手机走的是同一条下发路径,不需要各自实现一遍。
|
|
662
|
+
// 失败不能再像以前那样 `.catch(() => undefined)` 吞掉:面板上这一发是用户填完地址之后
|
|
663
|
+
// 唯一的反馈,如实报出来才谈得上「改坏了能看见」。
|
|
664
|
+
let applyError = null
|
|
665
|
+
try { await syncSidecar() } catch (error) { applyError = String(error?.message || error) }
|
|
666
|
+
sendJson(res, 200, { ...status(), ...(applyError ? { applyError } : {}) })
|
|
658
667
|
}) })
|
|
659
668
|
// 端口自检:面板上那颗「检查端口」用的。真实地试绑一次,回答「本机现在能不能监听它」,
|
|
660
669
|
// 探针就是局域网代理挑端口用的那一把,所以它给的答案和真正 listen 的结果是同一件事。
|
|
@@ -811,7 +820,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
811
820
|
}
|
|
812
821
|
return sendJson(res, 200, { ok: outcome.installed || outcome.skipped, data: outcome })
|
|
813
822
|
}) })
|
|
814
|
-
// 自动更新开关(默认关)。开=外壳每
|
|
823
|
+
// 自动更新开关(默认关)。开=外壳每 2 分钟问一次有没有新版,有就自己装并就地切换
|
|
815
824
|
// (轮询住在外壳里,因为「没人看着也要发生」;装与换代用的就是上面这两条同一套实现)。
|
|
816
825
|
registerRoute({ kind: 'exact', path: '/api/relay/update/auto', handler: localOnly('POST', async (req, res) => {
|
|
817
826
|
const runtime = pluginRuntime()
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -17,6 +17,7 @@ const { orderCandidates, withManualCandidate } = require('../relay/lan-network.c
|
|
|
17
17
|
const sessions = require('../mobile/sessions.js');
|
|
18
18
|
const sessionModelOptions = require('../mobile/session-model-options.js');
|
|
19
19
|
const sessionFile = require('../mobile/session-file.js');
|
|
20
|
+
const fileRead = require('../mobile/file-read.js');
|
|
20
21
|
const stats = require('../mobile/stats.js');
|
|
21
22
|
const agentPresets = require('../mobile/agent-presets.js');
|
|
22
23
|
const stream = require('../mobile/stream.js');
|
|
@@ -39,6 +40,7 @@ const schedule = require('../mobile/schedule.js');
|
|
|
39
40
|
const pushNotifyRoute = require('../mobile/push-notify-route.js');const plugins = require('../mobile/plugins.js');
|
|
40
41
|
const pluginUpdate = require('../mobile/plugin-update.js');
|
|
41
42
|
const pluginSelf = require('../mobile/plugin-self.js');
|
|
43
|
+
const pluginMarket = require('../mobile/plugin-market.js');
|
|
42
44
|
const { createRelayControlHandlers } = require('../mobile/relay-control.js');
|
|
43
45
|
const uploads = require('../mobile/uploads.js');
|
|
44
46
|
const inboxClean = require('../mobile/inbox-clean.js');
|
|
@@ -219,6 +221,10 @@ function registerMobileRoutes(deps) {
|
|
|
219
221
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
220
222
|
// 交付文件卡要的字节:只放行本会话 present 声明过的路径(见 mobile/session-file.js)。
|
|
221
223
|
route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
|
|
224
|
+
// 右栏文件浏览器「打开文件」:按**绝对路径**取字节,范围是宿主已登记的工作区 ∪ 当前会话
|
|
225
|
+
// 的项目目录(见 mobile/file-read.js 与 mobile/allowed-zone.js)。与上一条并存:那条服务
|
|
226
|
+
// 交付(卡片、正文链接),放行面跟着一次交付走;这条跟着「我登记过的目录」走。
|
|
227
|
+
route('/api/mobile/v1/file/read', fileRead.handleFileRead)
|
|
222
228
|
route('/api/mobile/v1/session/send', sessions.handleSend)
|
|
223
229
|
// 正文水位探针:App 进对话前先问一句「这条会话的正文到哪个水位了」,跟本地缓存水位
|
|
224
230
|
// 一比就知道要不要重拉全量历史(几十字节换几兆)。**只读信号快照**:信号里没有就如实
|
|
@@ -397,6 +403,25 @@ function registerMobileRoutes(deps) {
|
|
|
397
403
|
route('/api/mobile/v1/plugin/self', pluginSelf.handlePluginSelf)
|
|
398
404
|
route('/api/mobile/v1/plugin/self/auto', pluginSelf.handlePluginAuto)
|
|
399
405
|
|
|
406
|
+
// 这台电脑上**别的插件**:装了哪些、装 / 启用 / 升级(见 mobile/plugin-market.js)。
|
|
407
|
+
//
|
|
408
|
+
// 不自己跑包管理器:宿主里本来就有一套插件管理器(桌面端 设置 → 插件 那个页面就是它的
|
|
409
|
+
// 界面),装包、版本兼容检查、依赖脚本授权、装失败回滚、要不要重启才生效,全由它判断。
|
|
410
|
+
// 这里只搬结果。**没有卸载入口**:卸载要重启电脑上的 DSH,手机上没有在电脑前的那双手;
|
|
411
|
+
// 承载手机连接的这个包另外不给停用,只留原有的自更新那条路。
|
|
412
|
+
//
|
|
413
|
+
// 装一次可能几十秒到几分钟,所以 install / update 只回 202 + requestId,进度另开一条
|
|
414
|
+
// 轮询 —— 手机切后台、断线重连都不该把一次安装拖断。安装时的 npm 源由 registries 那条
|
|
415
|
+
// 给选择器:主源 / 备用源 / pnpm 实际读到的那个都原样报上来。
|
|
416
|
+
route('/api/mobile/v1/plugin/market', pluginMarket.handlePluginMarket)
|
|
417
|
+
route('/api/mobile/v1/plugin/market/registries', pluginMarket.handlePluginMarketRegistries)
|
|
418
|
+
route('/api/mobile/v1/plugin/market/inspect', pluginMarket.handlePluginMarketInspect)
|
|
419
|
+
route('/api/mobile/v1/plugin/market/install', pluginMarket.handlePluginMarketInstall)
|
|
420
|
+
route('/api/mobile/v1/plugin/market/update', pluginMarket.handlePluginMarketUpdate)
|
|
421
|
+
route('/api/mobile/v1/plugin/market/progress', pluginMarket.handlePluginMarketProgress)
|
|
422
|
+
route('/api/mobile/v1/plugin/market/cancel', pluginMarket.handlePluginMarketCancel)
|
|
423
|
+
route('/api/mobile/v1/plugin/market/enabled', pluginMarket.handlePluginMarketEnabled)
|
|
424
|
+
|
|
400
425
|
// 插件自更新:APP 的「应用更新」页给这台宿主升级插件版本(见 mobile/plugin-update.js)。
|
|
401
426
|
// 与桌面控制面 `/api/relay/update/*` 同一套实现,差别只在入口:那三条 localOnly,手机
|
|
402
427
|
// 永远打不到,而手机上聊天时「有对话在跑」那道闸几乎总关着 —— 自动更新等于不跑。
|
|
@@ -419,8 +444,9 @@ function registerMobileRoutes(deps) {
|
|
|
419
444
|
|
|
420
445
|
// 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
|
|
421
446
|
// 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
|
|
422
|
-
// 一律进不去。四条端点只做「收字节 →
|
|
423
|
-
// 落盘路径由 App 写成 @提及
|
|
447
|
+
// 一律进不去。四条端点只做「收字节 → 落盘」:默认落到 `~/.dsh/mobile-inbox/<日期>/<文件名>`,
|
|
448
|
+
// 落盘路径由 App 写成 @提及 再随消息发给宿主;带 `destPath` 时落到那个目录(右栏的
|
|
449
|
+
// 「传到这里」),它必须在 mobile/allowed-zone.js 的允许区内。
|
|
424
450
|
route('/api/mobile/v1/upload/begin', uploads.handleUploadBegin)
|
|
425
451
|
route('/api/mobile/v1/upload/chunk', uploads.handleUploadChunk)
|
|
426
452
|
route('/api/mobile/v1/upload/finish', uploads.handleUploadFinish)
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -35,6 +35,8 @@ const HEADER_MAX_BYTES = 64 * 1024
|
|
|
35
35
|
|
|
36
36
|
const { envelopeValue, unwrap } = require('./mobile/envelope.js')
|
|
37
37
|
const { rememberDeletedSessionIds } = require('./mobile/session-cache.js')
|
|
38
|
+
// 删之前那一刀统计补采:与手机那个「清理已归档」按钮共用同一份预算(见 mobile/stats-collect.cjs)。
|
|
39
|
+
const { collectStats, DELETING_COLLECT_BUDGET_MS } = require('./mobile/stats-collect.cjs')
|
|
38
40
|
// 收件目录的启动清理与这里共用同一个「宿主刚起来」的窗口,实现见 mobile/inbox-clean.js。
|
|
39
41
|
const { clearInboxOnce } = require('./mobile/inbox-clean.js')
|
|
40
42
|
|
|
@@ -408,6 +410,14 @@ async function cleanupSessions(ctx, args) {
|
|
|
408
410
|
}
|
|
409
411
|
if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
|
|
410
412
|
const ids = selected.map(item => item.sessionId)
|
|
413
|
+
// 删之前先把这批会话的用量采进统计账本:日志一没,那部分用量就再也读不出来了。手机那个
|
|
414
|
+
// 「清理已归档」按钮一直有这一刀,这条工具路以前没有 —— 走它删掉的会话,上次采集之后的
|
|
415
|
+
// 那一截增量就白丢。采不上不挡删除:统计偏旧好过会话删不掉。
|
|
416
|
+
try {
|
|
417
|
+
await collectStats(ctx, { sessionIds: ids, force: true, skipPreview: true, budgetMs: DELETING_COLLECT_BUDGET_MS })
|
|
418
|
+
} catch {
|
|
419
|
+
/* 统计是旁路 */
|
|
420
|
+
}
|
|
411
421
|
// 与移动端那条路由同一口径:进程里只排队,**日志留到宿主下次启动时删**。进程活着时
|
|
412
422
|
// 删日志,宿主随时可能去开一条已经不存在的文件,那是未捕获异常(会带走整个宿主)。
|
|
413
423
|
queueArchivedClearIds(ids)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 手机每次请求顺带上来的身份:设备名、机型、平台、系统版本、App 版本。
|
|
5
5
|
*
|
|
6
6
|
* ## 为什么要有这几个头
|
|
7
7
|
*
|
|
@@ -13,18 +13,20 @@
|
|
|
13
13
|
* 3. 面板把「加密」读成「已是最新」,其实那只说明这枚凭据带设备密钥。
|
|
14
14
|
*
|
|
15
15
|
* 身份属于「每一次请求都顺手知道」的信息,不该是一张一次性的照片。现在手机在统一的鉴权头
|
|
16
|
-
*
|
|
16
|
+
* 里带上这几样,宿主在鉴权通过那一刻(`lib/impl.js` 的 `onAuthorized`)把它们写回凭据记录。
|
|
17
17
|
*
|
|
18
18
|
* ## 口径
|
|
19
19
|
*
|
|
20
20
|
* - **缺一个就少写一个**:老版本 App 根本不带这些头,那时必须保持记录里原来的值,
|
|
21
21
|
* 绝不能把已经有的名字冲成空串(那会把一个好好的设备变成「未知设备」)。
|
|
22
22
|
* - 头的值是手机自报的,长度交给凭据库的 `truncate` 截断,这里不重复做校验。
|
|
23
|
-
* -
|
|
23
|
+
* - 名字是**用户自己起的**,不参与任何判断,只用于显示;机型是手机自己报的,同理只用于显示。
|
|
24
|
+
* 认一台手机靠的是它自带的 `deviceId`(`X-Dsh-Device` 那条),不是这几格里的任何一样。
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/** 手机自报身份用的五个头(App 侧在 `app/src/services/viewmodels/authHeaders.ts` 里写)。 */
|
|
27
28
|
const DEVICE_NAME_HEADER = 'x-dsh-device-name';
|
|
29
|
+
const DEVICE_MODEL_HEADER = 'x-dsh-device-model';
|
|
28
30
|
const PLATFORM_HEADER = 'x-dsh-platform';
|
|
29
31
|
const OS_VERSION_HEADER = 'x-dsh-os-version';
|
|
30
32
|
const APP_VERSION_HEADER = 'x-dsh-app-version';
|
|
@@ -46,6 +48,11 @@ function deviceMetaFromHeaders(req) {
|
|
|
46
48
|
const meta = {};
|
|
47
49
|
const name = headerText(headers, DEVICE_NAME_HEADER);
|
|
48
50
|
if (name) meta.name = name;
|
|
51
|
+
// 机型号只在这个位置补得回来:它跟名字不一样,不随用户改,所以只在**配对之后第一次
|
|
52
|
+
// 请求**时真正写进去 —— 但老记录(这一格出生前配的对)不会有第二次配对,全靠这条头
|
|
53
|
+
// 自愈。缺了就不写,别把已经记下的机型冲成空。
|
|
54
|
+
const model = headerText(headers, DEVICE_MODEL_HEADER);
|
|
55
|
+
if (model) meta.model = model;
|
|
49
56
|
const platform = headerText(headers, PLATFORM_HEADER);
|
|
50
57
|
if (platform) meta.platform = platform;
|
|
51
58
|
const osVersion = headerText(headers, OS_VERSION_HEADER);
|
|
@@ -57,6 +64,7 @@ function deviceMetaFromHeaders(req) {
|
|
|
57
64
|
|
|
58
65
|
module.exports = {
|
|
59
66
|
APP_VERSION_HEADER,
|
|
67
|
+
DEVICE_MODEL_HEADER,
|
|
60
68
|
DEVICE_NAME_HEADER,
|
|
61
69
|
OS_VERSION_HEADER,
|
|
62
70
|
PLATFORM_HEADER,
|
package/lib/shell/updater.js
CHANGED
|
@@ -87,7 +87,7 @@ export function shellFingerprint(pkgDir) {
|
|
|
87
87
|
export const PACKAGE_NAME = 'dsh-mobile-plugin'
|
|
88
88
|
|
|
89
89
|
/** 自动检查的间隔与「检查一次」的超时。 */
|
|
90
|
-
export const AUTO_INTERVAL_MS =
|
|
90
|
+
export const AUTO_INTERVAL_MS = 2 * 60 * 1000
|
|
91
91
|
const REQUEST_TIMEOUT_MS = 15000
|
|
92
92
|
/** 等一次安装走完的上限:pnpm 装一个包 + 校验,给足但别无限等。 */
|
|
93
93
|
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000
|
|
@@ -305,7 +305,7 @@ export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false
|
|
|
305
305
|
/**
|
|
306
306
|
* 换代尝试的记账:同一个版本自动只试一次。
|
|
307
307
|
*
|
|
308
|
-
*
|
|
308
|
+
* 一个起不来的新版每一轮把手机抖一下没有任何意义 —— 第一次失败之后就该停下,等人按
|
|
309
309
|
* 那颗按钮(手动不受这个限制:那是「我知道,再来一次」)。进程重启后这份记录清零,
|
|
310
310
|
* 而重启本来就会走一遍正常装载,不需要它。
|
|
311
311
|
*/
|
|
@@ -13,11 +13,14 @@
|
|
|
13
13
|
* 2. `/tunnel/send` 先把快照留在本地再尝试真发:局域网直连的手机读本地这份,隧道断了也不
|
|
14
14
|
* 至于读到过期镜像。
|
|
15
15
|
* 3. 请求体一律先量后读:`/configure` 与镜像帧都有字节上限,超限立刻 413。
|
|
16
|
+
* 4. 换中转地址不是「拆了重建」:先在旧隧道上登记迁移,再建新隧道,注册不上要撤销
|
|
17
|
+
* (见 lib/relay/migrate.js)。这条顺序是「填错地址就把自己锁在外面」的唯一解。
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
const http = require('node:http')
|
|
19
21
|
const { RelayTunnel } = require('./relay/tunnel.js')
|
|
20
22
|
const { T } = require('./relay/protocol.js')
|
|
23
|
+
const { switchRelay } = require('./relay/migrate.js')
|
|
21
24
|
const { createLanProxy } = require('./relay/lan-proxy.js')
|
|
22
25
|
const { lanUrls } = require('./relay/lan-network.cjs')
|
|
23
26
|
const core = require('./relay/core.js')
|
|
@@ -271,10 +274,14 @@ function stopLan() {
|
|
|
271
274
|
publishState()
|
|
272
275
|
}
|
|
273
276
|
async function stopServices() { stopNetworkMonitor(); stopTunnel(); stopLan() }
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
277
|
+
/**
|
|
278
|
+
* 建一条隧道并把它设成当前实例,返回它的注册 Promise(resolve = 已 registered)。
|
|
279
|
+
*
|
|
280
|
+
* **不在这里吞错**:换中转地址那条路要拿到真实的失败原因去撤销迁移登记、并把错误回给调用方
|
|
281
|
+
* (`configure` 的 503 会一路回到手机)。普通重建由 `startTunnel` 那一侧兜底 —— 状态置错 +
|
|
282
|
+
* 排重连,与旧实现一字不差。
|
|
283
|
+
*/
|
|
284
|
+
function openTunnel() {
|
|
278
285
|
const current = new RelayTunnel({
|
|
279
286
|
relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId,
|
|
280
287
|
name: config.name, upstream: upstream(),
|
|
@@ -305,7 +312,17 @@ function startTunnel() {
|
|
|
305
312
|
onLog: (...args) => process.stderr.write(`[dsh-ai-hub sidecar] ${args.join(' ')}\n`),
|
|
306
313
|
})
|
|
307
314
|
tunnel = current
|
|
308
|
-
|
|
315
|
+
const ready = current.start()
|
|
316
|
+
// 挂了就如实报,并按 5s 起退避重连(旧实现在 startTunnel 的 catch 里做同一件事)。
|
|
317
|
+
// 换地址失败时这里也会排一次:配置里已经是新地址,值得继续试它,而不是悄悄退回旧地址。
|
|
318
|
+
ready.catch(error => { if (tunnel === current) { tunnelState = 'error'; tunnelError = error instanceof Error ? error.message : String(error); publishState(); scheduleReconnect() } })
|
|
319
|
+
return ready
|
|
320
|
+
}
|
|
321
|
+
function startTunnel() {
|
|
322
|
+
if (!config.enabled || !config.relayUrl || !config.upstreamPort) return
|
|
323
|
+
stopTunnel(); tunnelState = 'connecting'
|
|
324
|
+
publishState()
|
|
325
|
+
void openTunnel()
|
|
309
326
|
}
|
|
310
327
|
function startLan() {
|
|
311
328
|
// 总开关关闭时不能只停隧道:旧实现漏了 `config.enabled`,于是 /api/relay/toggle
|
|
@@ -371,13 +388,35 @@ async function configure(next) {
|
|
|
371
388
|
// - 其余(配对码 / 名字 / 局域网候选)在现有连接上重报一次注册信息即可。
|
|
372
389
|
// 无条件全拆全建就是「每隔几分钟断一次 + 内存一直涨」的根因,别改回去。
|
|
373
390
|
const previous = config
|
|
391
|
+
// 中转地址变了要**先登记迁移再重建**:还握着旧地址的设备只会在旧中转上找这台电脑。
|
|
392
|
+
// 按规范形态比,避免只差一个尾斜杠就白跑一次迁移(那只是同一台中转)。
|
|
393
|
+
// 「第一次配地址」不算换地址:旧中转上没有谁握着这台电脑的旧地址,没什么要登记的,
|
|
394
|
+
// 更不该为了等注册把启动那次下发拖住。
|
|
395
|
+
const previousRelayUrl = core.normalizeRelayUrl(previous.relayUrl)
|
|
396
|
+
const relayUrlChanged = Boolean(previousRelayUrl) && previousRelayUrl !== core.normalizeRelayUrl(next.relayUrl)
|
|
374
397
|
const tunnelNeedsRebuild = tunnelSignature(previous) !== tunnelSignature(next)
|
|
375
398
|
const lanNeedsRebuild = lanSignature(previous) !== lanSignature(next)
|
|
376
399
|
const monitorNeedsRestart = monitorSignature(previous) !== monitorSignature(next)
|
|
377
400
|
config = { ...next }
|
|
378
401
|
if (tunnelNeedsRebuild) {
|
|
379
|
-
|
|
380
|
-
|
|
402
|
+
if (relayUrlChanged && config.enabled && config.relayUrl && config.upstreamPort) {
|
|
403
|
+
// 换地址这条路**等新隧道真的注册上**再返回:注册不上要把错误一路回给调用方,
|
|
404
|
+
// 并在旧隧道上撤销迁移登记(顺序与理由见 lib/relay/migrate.js 文件头)。
|
|
405
|
+
await switchRelay({
|
|
406
|
+
oldTunnel: tunnel,
|
|
407
|
+
relayUrl: config.relayUrl,
|
|
408
|
+
open: () => {
|
|
409
|
+
// 旧隧道还在,但重连定时器得先撤掉:否则它到点会把刚起来的新隧道又拆一遍。
|
|
410
|
+
reconnect.cancel()
|
|
411
|
+
tunnelState = 'connecting'; tunnelError = null; publishState()
|
|
412
|
+
return openTunnel()
|
|
413
|
+
},
|
|
414
|
+
drop: (old) => { if (old) { try { old.stop() } catch { /* 已经断开 */ } } },
|
|
415
|
+
})
|
|
416
|
+
} else {
|
|
417
|
+
stopTunnel()
|
|
418
|
+
startTunnel()
|
|
419
|
+
}
|
|
381
420
|
}
|
|
382
421
|
if (lanNeedsRebuild) {
|
|
383
422
|
stopLan()
|
|
@@ -412,7 +451,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
412
451
|
if (!authorized(req)) return send(res, 401, { ok: false, error: 'unauthorized' })
|
|
413
452
|
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { ok: true, service: 'dsh-ai-hub-relay-sidecar', capabilities: ['relay-tunnel', 'lan-proxy', 'pairing-approval-rpc'] })
|
|
414
453
|
if (req.method === 'GET' && req.url === '/status') return send(res, 200, status())
|
|
415
|
-
if (req.method === 'POST' && req.url === '/configure') {
|
|
454
|
+
if (req.method === 'POST' && req.url === '/configure') {
|
|
455
|
+
const next = await readBody(req, CONFIGURE_MAX_BODY)
|
|
456
|
+
const run = configuring.then(() => configure(next))
|
|
457
|
+
// 串行链本身**不许被一次失败卡死**:`configuring` 一旦是 rejected,后续每一个
|
|
458
|
+
// `.then(() => configure(...))` 都会跳过 configure 直接跟着失败 —— 一次地址填错
|
|
459
|
+
// 就能让这台宿主从此不再接受任何配置下发(隧道永远起不来,只能重启 worker)。
|
|
460
|
+
configuring = run.catch(() => undefined)
|
|
461
|
+
return send(res, 200, await run)
|
|
462
|
+
}
|
|
416
463
|
if (req.method === 'POST' && req.url === '/tunnel/send') {
|
|
417
464
|
const body = await readBody(req, TUNNEL_SEND_MAX_BODY);
|
|
418
465
|
// 桌面→relay 主动推送(移动端镜像缓存):复用隧道立即 sendJson;同时在本地保留快照供读取隔离。
|
package/lib/sidecar-facade.cjs
CHANGED
|
@@ -19,6 +19,15 @@ const { join } = require('node:path')
|
|
|
19
19
|
|
|
20
20
|
const worker = join(__dirname, 'sidecar-facade-worker.cjs')
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* `/configure` 的等待上限。
|
|
24
|
+
*
|
|
25
|
+
* 换中转地址时 worker 会**等新隧道真的注册上**再回包(顺序见 lib/relay/migrate.js):注册
|
|
26
|
+
* 失败要撤销迁移、还要把错误一路回给手机上那一发请求。隧道自己的注册超时是 8s,所以这里
|
|
27
|
+
* 比它宽一截;超时了宿主拿到的是「facade 超时」而不是真正的原因,那句话在手机上没法用。
|
|
28
|
+
*/
|
|
29
|
+
const CONFIGURE_TIMEOUT_MS = 12_000
|
|
30
|
+
|
|
22
31
|
class SidecarFacade {
|
|
23
32
|
constructor(options) {
|
|
24
33
|
this.parentPort = options.parentPort
|
|
@@ -120,7 +129,8 @@ class SidecarFacade {
|
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
async configure(config) {
|
|
123
|
-
|
|
132
|
+
// 换地址那一发要等新隧道注册,比默认 5s 久(见 CONFIGURE_TIMEOUT_MS)。
|
|
133
|
+
return this.request('/configure', 'POST', config, CONFIGURE_TIMEOUT_MS)
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
async stop() {
|
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* `sidecar` 是会被整体替换的 let(自重启换掉 facade 实例),所以对外一律给取值器,绝不能把
|
|
12
12
|
* 实例本身传出去。
|
|
13
|
+
*
|
|
14
|
+
* 每一次配置下发(`applySidecarConfig`)的成功失败都记进 `relay/apply-ledger.cjs` 的台账:
|
|
15
|
+
* 换中转地址失败时,手机端「运行状态」读的就是那一份(错误、时间与当时要切过去的地址)。
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
const path = require('node:path');
|
|
16
19
|
const { randomBytes } = require('node:crypto');
|
|
17
20
|
|
|
18
21
|
const { SidecarFacade } = require('./sidecar-facade.cjs');
|
|
22
|
+
const applyLedger = require('./relay/apply-ledger.cjs');
|
|
19
23
|
|
|
20
24
|
const HEALTH_POLL_INTERVAL_MS = 10000;
|
|
21
25
|
|
|
@@ -104,7 +108,16 @@ function createSidecarLifecycle(deps) {
|
|
|
104
108
|
await resolveLanPort()
|
|
105
109
|
const config = buildConfig(lanPortInUse())
|
|
106
110
|
configuredLanPort = Number.isInteger(config.lanPort) ? config.lanPort : 0
|
|
107
|
-
|
|
111
|
+
// 失败必须落账(`/api/mobile/v1/relay/diagnostics` 的 `apply` 那一格):所有下发都经过
|
|
112
|
+
// 这里 —— 手机改地址、电脑面板改地址、启动时那次 —— 而调用方几乎都 `.catch(() => …)`
|
|
113
|
+
// 掉。换地址失败的现场是「保存成功、然后永久离线」,台账是唯一能说清原因的地方。
|
|
114
|
+
try {
|
|
115
|
+
sidecarSnapshot = await sidecar.configure(config)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
applyLedger.recordFailure(error, config.relayUrl)
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
applyLedger.recordSuccess()
|
|
108
121
|
sidecarState = 'healthy'
|
|
109
122
|
return sidecarSnapshot
|
|
110
123
|
}
|