@dshfly/remote-connector 0.2.1
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/LICENSE +21 -0
- package/README.md +44 -0
- package/config.js +69 -0
- package/cordis.patch.yml +6 -0
- package/core/cloud.js +55 -0
- package/core/connector-core.js +910 -0
- package/core/dsh-web.js +556 -0
- package/core/keys.js +68 -0
- package/core/path-guard.js +60 -0
- package/core/pending-approvals.js +80 -0
- package/core/push-notifier.js +79 -0
- package/core/session-titles.js +85 -0
- package/core/tunnel.js +607 -0
- package/dist/client.js +556 -0
- package/dist/client.js.map +7 -0
- package/http-api.js +531 -0
- package/index.js +173 -0
- package/mobile-bridge/README.md +33 -0
- package/mobile-bridge/core/bridge-core.js +367 -0
- package/mobile-bridge/core/enumerate.js +44 -0
- package/mobile-bridge/core/files.js +431 -0
- package/mobile-bridge/core/roots.js +50 -0
- package/mobile-bridge/index.js +59 -0
- package/package.json +87 -0
package/core/tunnel.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// packages/remote-connector/core/tunnel.js
|
|
2
|
+
// connector 的控制通道(WSS 客户端)与 E2EE 终点 + dsh web 事件转发:
|
|
3
|
+
// 自 services/connector/tunnel.js 迁入(单一代码源)。
|
|
4
|
+
// - 收到 REQ(E2EE 信封)→ 解密 → 明文重放到 dsh web → 加密响应 → RES
|
|
5
|
+
// - 连上 dsh web 的 /api/events.mux + /api/events.host(WS),把每条 server-request
|
|
6
|
+
// 帧 E2EE 加密后作为 EVENT 帧推给中继(中继再路由给手机)。
|
|
7
|
+
|
|
8
|
+
import WebSocket from 'ws';
|
|
9
|
+
import zlib from 'node:zlib';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { TYPE, encode, decodeFrame, toJson, jsonFrame } from '@dshfly/tunnel-protocol';
|
|
12
|
+
import { sealEnvelope, openEnvelope, deriveDirectionalKeys, ReplayGuard, utf8Decode, utf8, verifyUnpair } from '@dshfly/crypto';
|
|
13
|
+
import { claimOutSeq, saveKeys } from './keys.js';
|
|
14
|
+
|
|
15
|
+
// C2(P2-8):异步 deflate(大响应压缩不阻塞宿主主线程)
|
|
16
|
+
const zlibDeflate = promisify(zlib.deflate);
|
|
17
|
+
|
|
18
|
+
// 协议 v2 方向密钥:PC 封 PC→手机 用 down、解 手机→PC 用 up(手机侧恰好相反)。
|
|
19
|
+
// 96-bit nonce 的碰撞域要求按方向隔离,不能直接用根会话密钥。
|
|
20
|
+
const dirOf = (sessionKey) => deriveDirectionalKeys(sessionKey);
|
|
21
|
+
|
|
22
|
+
const HOP = new Set([
|
|
23
|
+
'host', 'connection', 'content-length', 'transfer-encoding',
|
|
24
|
+
'keep-alive', 'upgrade', 'te', 'trailer',
|
|
25
|
+
]);
|
|
26
|
+
// B8(P1-10):入站 REQ 帧 payload 上限(信封 JSON,业务 RPC 载荷远小于此)。
|
|
27
|
+
// 2026-08 调至 8MB 与中继 MAX_TUNNEL_BODY 对齐:多模态图片的 E2EE 信封(≈1.78×图片 base64)
|
|
28
|
+
// 会突破原 4MB,而中继本来就允许到 8MB(docs/mobile-image-upload-plan.md §4.1)——原 4MB 会把
|
|
29
|
+
// 合法图片帧静默丢弃(tunnel.js `> MAX_REQ_FRAME return`),导致手机端 30s 后 504 的"假死"。
|
|
30
|
+
const MAX_REQ_FRAME = 8 * 1024 * 1024;
|
|
31
|
+
function stripHop(h) {
|
|
32
|
+
const o = {};
|
|
33
|
+
for (const [k, v] of Object.entries(h || {})) if (!HOP.has(k.toLowerCase())) o[k] = v;
|
|
34
|
+
return o;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ws readyState: CONNECTING=0 OPEN=1 CLOSING=2 CLOSED=3
|
|
38
|
+
const WS_OPEN = 1;
|
|
39
|
+
|
|
40
|
+
// 安全出站:ws.send 在非 OPEN 状态(CONNECTING/CLOSING/CLOSED)会抛
|
|
41
|
+
// 'WebSocket is not open: readyState ...'。定时器/事件回调里的未捕获异常会
|
|
42
|
+
// 崩掉宿主进程(relay 重启重连窗口的 flushBatch 曾因此杀死 dsh web)——
|
|
43
|
+
// 统一走本函数,只对 OPEN 连接发送且吞掉一切发送错误。
|
|
44
|
+
export function safeSend(ws, data) {
|
|
45
|
+
try {
|
|
46
|
+
if (ws && !ws.destroyed && ws.readyState === WS_OPEN) ws.send(data);
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 大响应体 deflate 压缩(手机端 pako 解压),减少隧道传输量
|
|
51
|
+
const COMPRESS_THRESHOLD = 4096;
|
|
52
|
+
|
|
53
|
+
/** session.list 响应瘦身(2026-08 性能):剥除 App 无消费的投影字段(goal/
|
|
54
|
+
* subagentTiming/subagent/plan/todos)——列表刷新体量与解析随会话数线性增长。
|
|
55
|
+
* permissions 2026-08 起保留:会话详情页"权限设定"卡片在读(写走 commands/execute,
|
|
56
|
+
* 与 session.list 无关)。非目标结构/解析失败 → 原样返回(绝不破坏响应)。纯函数,可单测。 */
|
|
57
|
+
export function stripSessionListJson(jsonStr) {
|
|
58
|
+
try {
|
|
59
|
+
const full = JSON.parse(jsonStr);
|
|
60
|
+
if (full?.result?.ok && Array.isArray(full.result.value?.items)) {
|
|
61
|
+
for (const it of full.result.value.items) {
|
|
62
|
+
const v = it?.projections?.values;
|
|
63
|
+
if (v && typeof v === 'object') {
|
|
64
|
+
delete v.goal;
|
|
65
|
+
delete v.subagentTiming;
|
|
66
|
+
delete v.subagent;
|
|
67
|
+
delete v.plan;
|
|
68
|
+
delete v.todos;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return JSON.stringify(full);
|
|
72
|
+
}
|
|
73
|
+
} catch { /* 解析失败原样返回 */ }
|
|
74
|
+
return jsonStr;
|
|
75
|
+
}
|
|
76
|
+
async function replayInner(target, inner, sessionTitles = null) {
|
|
77
|
+
// A5(P0-4,SSRF 修复):反代目标必须与 target 同源。
|
|
78
|
+
// inner.url 是手机 E2EE 明文,可被恶意手机/被劫持会话设置为任意绝对 URL——
|
|
79
|
+
// 不做校验时 dsh web 进程会替手机请求任意内网/云 metadata 地址并把响应密封回传。
|
|
80
|
+
// 只允许相对 /api/* 路径解析到 target 同源;绝对 URL/跨源/带 userinfo 一律拒绝。
|
|
81
|
+
const u = new URL(inner.url || '/', target);
|
|
82
|
+
const t = new URL(target);
|
|
83
|
+
if (u.protocol !== t.protocol || u.host !== t.host || u.username || u.password) {
|
|
84
|
+
throw new Error('proxy target must be same-origin');
|
|
85
|
+
}
|
|
86
|
+
const res = await fetch(u, {
|
|
87
|
+
method: inner.method || 'GET',
|
|
88
|
+
headers: stripHop(inner.headers),
|
|
89
|
+
body: inner.body ? Buffer.from(inner.body, 'base64') : undefined,
|
|
90
|
+
});
|
|
91
|
+
let buf = Buffer.from(await res.arrayBuffer());
|
|
92
|
+
// D 项 history 响应瘦身(docs/bug-report-phone-duplicate-stream.md §9.4-D):
|
|
93
|
+
// session.history 响应含"该区间全部事件"(含每条消息的全部 chunk——实测 10 条消息
|
|
94
|
+
// ≈2.3 万事件 ≈4.3MB,密集会话尾页可达 10MB+),其中 chunk 对手机端历史重建零消费
|
|
95
|
+
// (消息级事件才是权威内容)却贡献 99% 字节与 JSON.parse 时间——进入会话加载变长的
|
|
96
|
+
// 主因。代理层剥掉 assistant/chunk(保留 hasMore/projections 等全部协议字段)。
|
|
97
|
+
if (inner.body) {
|
|
98
|
+
try {
|
|
99
|
+
const req = JSON.parse(Buffer.from(inner.body, 'base64').toString('utf8'));
|
|
100
|
+
if (req?.method === 'session.history') {
|
|
101
|
+
const full = JSON.parse(buf.toString('utf8'));
|
|
102
|
+
if (full?.result?.ok && Array.isArray(full.result.value?.events)) {
|
|
103
|
+
full.result.value.events = full.result.value.events.filter((e) => e?.event?.type !== 'assistant/chunk');
|
|
104
|
+
buf = Buffer.from(JSON.stringify(full));
|
|
105
|
+
}
|
|
106
|
+
} else if (req?.method === 'session.list') {
|
|
107
|
+
// 2026-08 性能:剥除 App 无消费的投影字段(goal/subagentTiming/subagent/plan/
|
|
108
|
+
// todos,实测合计约 21.5% 字节中的大头)——列表刷新体量与解析随会话数线性增长。
|
|
109
|
+
// 保留:title/sessionStats/tokenUsage/contextPressure/contextBreakdown/
|
|
110
|
+
// sessionListMetadata/imageLimits/permissions(详情页/聊天页/权限设定在用)
|
|
111
|
+
// + 全部顶层字段(cwd/running 等)。
|
|
112
|
+
const stripped = stripSessionListJson(buf.toString('utf8'));
|
|
113
|
+
buf = Buffer.from(stripped);
|
|
114
|
+
sessionTitles?.ingestSessionList(stripped); // 推送:顺手落 sessionId→title(title 未被剥除)
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
/* 剥取失败按原样透传(绝不破坏响应) */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
let body = buf.length ? buf.toString('base64') : null;
|
|
121
|
+
let enc = null;
|
|
122
|
+
if (buf.length >= COMPRESS_THRESHOLD) {
|
|
123
|
+
// C2(P2-8):同步 deflateSync 会阻塞 dsh web 主线程(10MB+ history 响应数百 ms)——
|
|
124
|
+
// 改异步 deflate(promisify),大响应压缩不冻结宿主
|
|
125
|
+
body = (await zlibDeflate(buf)).toString('base64');
|
|
126
|
+
enc = 'deflate';
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
status: res.status,
|
|
130
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
131
|
+
body,
|
|
132
|
+
enc,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function connectConnector({ relayWsUrl, deviceToken, keys, keysFile, sessionKeys, target, localHandler, onRevoke, onConnection, onActivity, pendingApprovals, pushNotifier, sessionTitles, onPhoneOnlineChange, onAuthFailure, eventsDisabled = false, onFocus = null }) {
|
|
137
|
+
const guard = new ReplayGuard();
|
|
138
|
+
let backoff = 1000;
|
|
139
|
+
let dshStreams = [];
|
|
140
|
+
let stopped = false;
|
|
141
|
+
let currentWs = null;
|
|
142
|
+
let reconnectTimer = null;
|
|
143
|
+
// 2026-08 token 自愈:HELLO 认证被拒(token 过期/失效)标记,close 时触发 onAuthFailure
|
|
144
|
+
// 刷新 token,下次重连不再用过期 token 死循环
|
|
145
|
+
let authFailed = false;
|
|
146
|
+
|
|
147
|
+
// outSeq 持久化:启动时从 keys 领取(含 1000 预留窗口),崩溃/重启后新 seq 必高于
|
|
148
|
+
// 手机端记住的旧水位 → ReplayGuard 不会静默丢弃(Kimi Blocker 修复)。
|
|
149
|
+
// 运行中每次出站 seq = keys.outSeq++,500ms 防抖落盘。
|
|
150
|
+
const outSeqTimer = { current: null };
|
|
151
|
+
claimOutSeq(keys, keysFile, 1000);
|
|
152
|
+
const nextSeq = () => {
|
|
153
|
+
const seq = keys.outSeq++;
|
|
154
|
+
if (!outSeqTimer.current) {
|
|
155
|
+
outSeqTimer.current = setTimeout(() => {
|
|
156
|
+
outSeqTimer.current = null;
|
|
157
|
+
try { saveKeys(keys, keysFile); } catch {}
|
|
158
|
+
}, 500);
|
|
159
|
+
}
|
|
160
|
+
return seq;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// 每台手机的合批缓冲:phonePk -> { frames: [{text, sid, isChunk}], timer }
|
|
164
|
+
// 250ms 时间兜底 + 16 帧立即发(帧数优先):Hermes 验签 4-10ms/帧,
|
|
165
|
+
// 峰值 54fps 时 60ms 窗口只攒 3-4 帧(降幅 3-4 倍,远低于"降 10-20 倍"目标)→
|
|
166
|
+
// 调大窗口 + 帧数优先(docs/bug-report-phone-duplicate-stream.md §2.1)。
|
|
167
|
+
// 2026-08 再调优(§9.4-F):16 帧/批使多会话并行时批数减半(验签次数减半);
|
|
168
|
+
// 250ms 兜底与手机端 flush 节奏对齐。手机端零改动(格式不变)。
|
|
169
|
+
// 连接级状态(按 phonePk 键控):dsh web 事件流与 M4.x-c 本地事件(injectEvent)共用。
|
|
170
|
+
const batchBufs = new Map();
|
|
171
|
+
const BATCH_MS = 250;
|
|
172
|
+
const BATCH_MAX_FRAMES = 16;
|
|
173
|
+
|
|
174
|
+
// 方案C(省流):手机 /events 在线门禁。默认"全在线"(安全方向:旧 relay 不发
|
|
175
|
+
// PHONE_STATE 帧时退化为现状;新手机配对后立即推送,等 relay 通知校正)。
|
|
176
|
+
// relay 在手机订阅建立/关闭及 connector HELLO 时下发 {phonePk, online} 快照,
|
|
177
|
+
// 离线手机不再合批推送——手机全离线时 connector 完全不上传事件(白省上行 + 加解密 CPU)。
|
|
178
|
+
const phoneOffline = new Set();
|
|
179
|
+
|
|
180
|
+
function setPhoneOnline(phonePk, online) {
|
|
181
|
+
if (!phonePk) return;
|
|
182
|
+
// C2(P2-5):PHONE_STATE 仅接受已配对手机——恶意中继用随机 phonePk 刷状态帧
|
|
183
|
+
// 会导致 phoneOffline Set / phoneOnline Map 无界增长(内存 DoS)并静默停推
|
|
184
|
+
if (!sessionKeys.has(phonePk)) return;
|
|
185
|
+
if (online) {
|
|
186
|
+
phoneOffline.delete(phonePk);
|
|
187
|
+
} else {
|
|
188
|
+
phoneOffline.add(phonePk);
|
|
189
|
+
// 清掉该手机的积压缓冲:离线期间的帧已无意义(relay 无缓冲,恢复后由手机补拉兜底)
|
|
190
|
+
const b = batchBufs.get(phonePk);
|
|
191
|
+
if (b) {
|
|
192
|
+
if (b.timer) clearTimeout(b.timer);
|
|
193
|
+
batchBufs.delete(phonePk);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// 状态上抛(ConnectorCore 镜像 → 设置页设备列表"连接状态"提示)
|
|
197
|
+
onPhoneOnlineChange?.(phonePk, online);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function flushBatch(phonePk) {
|
|
201
|
+
// 定时器回调:任何异常都不能逃出(否则崩宿主进程);缓冲清理必须先行
|
|
202
|
+
try {
|
|
203
|
+
const b = batchBufs.get(phonePk);
|
|
204
|
+
if (!b) return;
|
|
205
|
+
batchBufs.delete(phonePk);
|
|
206
|
+
if (b.timer) clearTimeout(b.timer);
|
|
207
|
+
const sessionKey = sessionKeys.get(phonePk);
|
|
208
|
+
if (!sessionKey) return;
|
|
209
|
+
const frames = b.frames.map((f) => f.text);
|
|
210
|
+
// 方案A(省流):EVENT 合批包整体 deflate(明文侧压缩,封进密文后中继下行字节
|
|
211
|
+
// 同比例缩小)。reasoning/text delta 高度重复,deflate 通常 5-15×;
|
|
212
|
+
// 与 RES ≥4KB 同阈值同标记(enc:'deflate'),手机端 pako inflate(两端同步升级)。
|
|
213
|
+
// 格式:压缩 → {batch:true, enc:'deflate', data:<b64>};未达阈值 → 原 {batch:true, frames}。
|
|
214
|
+
let batchInner;
|
|
215
|
+
const framesJson = JSON.stringify(frames);
|
|
216
|
+
if (framesJson.length >= COMPRESS_THRESHOLD) {
|
|
217
|
+
batchInner = JSON.stringify({ batch: true, enc: 'deflate', data: zlib.deflateSync(framesJson).toString('base64') });
|
|
218
|
+
} else {
|
|
219
|
+
batchInner = JSON.stringify({ batch: true, frames });
|
|
220
|
+
}
|
|
221
|
+
const env = sealEnvelope(
|
|
222
|
+
dirOf(sessionKey).down,
|
|
223
|
+
{
|
|
224
|
+
fromPublicKey: keys.identityPublicKey,
|
|
225
|
+
toPublicKey: phonePk,
|
|
226
|
+
seq: nextSeq(),
|
|
227
|
+
identitySecretKey: keys.identitySecretKey,
|
|
228
|
+
},
|
|
229
|
+
utf8(batchInner),
|
|
230
|
+
);
|
|
231
|
+
safeSend(currentWs, encode(TYPE.EVENT, 0, JSON.stringify(env)));
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 全量推送(H1 根治,docs/bug-report-phone-duplicate-stream.md §8.3-A):
|
|
236
|
+
// 原按会话焦点过滤 chunk——"进入进行中的会话"焦点上报生效前、上报失败静默、
|
|
237
|
+
// connector 重启焦点内存丢失,都会静默丢弃该会话 chunk(思考前半/尾部永久缺失,
|
|
238
|
+
// 且进行中轮次不在 history、无法补拉)。移除过滤后:
|
|
239
|
+
// · 内容完整性 > 省流:合批后批数不增(多会话帧打包进同一批)、解密次数不变;
|
|
240
|
+
// · 手机端按 sessionId 分发天然隔离(非当前会话 chunk 解包即弃,不渲染);
|
|
241
|
+
// · 中继下行 EVENT 只计字节不计次数(M5.7),KB 级增量无计量影响;
|
|
242
|
+
// · `_focus` 保留 ack(兼容旧版 App),但不再参与过滤。
|
|
243
|
+
function pushToBatch(phonePk, text) {
|
|
244
|
+
onActivity?.(phonePk);
|
|
245
|
+
let b = batchBufs.get(phonePk);
|
|
246
|
+
if (!b) {
|
|
247
|
+
b = { frames: [], timer: null, bytes: 0 };
|
|
248
|
+
batchBufs.set(phonePk, b);
|
|
249
|
+
}
|
|
250
|
+
b.frames.push({ text, sid: null, isChunk: false });
|
|
251
|
+
b.bytes += text.length;
|
|
252
|
+
if (!b.timer) {
|
|
253
|
+
b.timer = setTimeout(() => flushBatch(phonePk), BATCH_MS);
|
|
254
|
+
}
|
|
255
|
+
// 帧数优先:攒满 BATCH_MAX_FRAMES 立即发(峰值输出下延迟封顶 ~148ms,
|
|
256
|
+
// 不必等满 150ms 时间窗);字节上限兜底:突发积压(如重连回放)时提前 flush,
|
|
257
|
+
// 避免超大信封
|
|
258
|
+
if (b.frames.length >= BATCH_MAX_FRAMES || b.bytes > 256 * 1024) {
|
|
259
|
+
if (b.timer) clearTimeout(b.timer);
|
|
260
|
+
flushBatch(phonePk);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function openDshStream(controlWs, url) {
|
|
265
|
+
let s;
|
|
266
|
+
try {
|
|
267
|
+
s = new WebSocket(url);
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
dshStreams.push(s);
|
|
272
|
+
// 主动关闭标记(缺陷 B 根治,见 docs/bug-report-phone-duplicate-stream.md §3):
|
|
273
|
+
// startDshEvents 换流时置位再 close;close 处理器据此区分"主动换流"与"意外断开"——
|
|
274
|
+
// 否则每次控制面重连都会把旧流误判为意外断开而 +1s 重开,事件流 2→4→6…翻倍,
|
|
275
|
+
// 同一批事件从 N 条流双份/多份转发(碎片级 ×N 重复、N× 卡顿)。
|
|
276
|
+
s.intentional = false;
|
|
277
|
+
let b = 1000;
|
|
278
|
+
|
|
279
|
+
s.on('open', () => {
|
|
280
|
+
b = 1000;
|
|
281
|
+
// 方案 A(pending 审批缓存):mux 重开时 DSH 会把全部 pending 审批/提问重放进
|
|
282
|
+
// 新队列(mux-open replay)——清空旧缓存等重放重建,避免 resolved 帧在断线窗口
|
|
283
|
+
// 丢失导致的陈旧条目残留
|
|
284
|
+
if (url.includes('events.mux')) pendingApprovals?.clear();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
s.on('message', (data) => {
|
|
288
|
+
// ws 包可能以 Buffer 投递,统一转成字符串
|
|
289
|
+
const text = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
|
|
290
|
+
// 方案 A:镜像 pending 审批/提问(手机离线期间的事件被 relay 丢弃,重连后经
|
|
291
|
+
// mobile.approvals.list 拉取,与实时帧同路径分发);非审批帧零成本跳过
|
|
292
|
+
pendingApprovals?.ingest(text);
|
|
293
|
+
// 推送:喂 sessionId→title 缓存(事件帧)+ 触发检测(只对离线手机,见 pushNotifier)
|
|
294
|
+
sessionTitles?.ingest(text);
|
|
295
|
+
pushNotifier?.ingest(text, phoneOffline);
|
|
296
|
+
// 全量推送(H1 根治,§8.3-A):不做会话焦点过滤——进入会话瞬间/上报失败/
|
|
297
|
+
// connector 重启的焦点黑洞都会造成 chunk 永久缺失;手机端按 sessionId 分发隔离
|
|
298
|
+
// 方案C(省流):跳过在线门禁判定的离线手机(relay 通知,见 setPhoneOnline)
|
|
299
|
+
for (const phonePk of sessionKeys.keys()) {
|
|
300
|
+
if (phoneOffline.has(phonePk)) continue;
|
|
301
|
+
pushToBatch(phonePk, text);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
s.on('error', () => {});
|
|
305
|
+
s.on('close', () => {
|
|
306
|
+
// 连接关闭:清空合批缓冲
|
|
307
|
+
for (const b of batchBufs.values()) if (b.timer) clearTimeout(b.timer);
|
|
308
|
+
batchBufs.clear();
|
|
309
|
+
// 仅"意外断开"自动重开;主动换流(startDshEvents)不重开(缺陷 B 根治)
|
|
310
|
+
if (!s.intentional && !stopped && !controlWs.destroyed) {
|
|
311
|
+
setTimeout(() => openDshStream(controlWs, url), b);
|
|
312
|
+
b = Math.min(b * 2, 30000);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function startDshEvents(controlWs) {
|
|
318
|
+
for (const s of dshStreams) {
|
|
319
|
+
// 主动换流:先置标记再关闭,避免 close 处理器把它当意外断开重开(缺陷 B 根治)
|
|
320
|
+
try {
|
|
321
|
+
s.intentional = true;
|
|
322
|
+
s.close();
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
dshStreams = [];
|
|
326
|
+
// 2026-09:DSH 0.1.2 起 /api/events.mux|.host 已移除(改 /api/remote.mux)。connector 用
|
|
327
|
+
// 进程内 cordis 订阅注入事件(见 connector-core),不再连已死端点。
|
|
328
|
+
if (eventsDisabled) return;
|
|
329
|
+
const wsBase = target.replace(/^http/, 'ws');
|
|
330
|
+
for (const p of ['/api/events.mux', '/api/events.host']) openDshStream(controlWs, wsBase + p);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const connect = () => {
|
|
334
|
+
const ws = new WebSocket(relayWsUrl);
|
|
335
|
+
currentWs = ws;
|
|
336
|
+
|
|
337
|
+
// 心跳保活:检测半开连接(PC 断网时 close/error 可能不触发,靠 ping/pong 超时探测)
|
|
338
|
+
let alive = true;
|
|
339
|
+
let heartbeatTimer = null;
|
|
340
|
+
const stopHeartbeat = () => {
|
|
341
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
342
|
+
heartbeatTimer = null;
|
|
343
|
+
};
|
|
344
|
+
const startHeartbeat = () => {
|
|
345
|
+
alive = true;
|
|
346
|
+
stopHeartbeat();
|
|
347
|
+
heartbeatTimer = setInterval(() => {
|
|
348
|
+
if (!alive) {
|
|
349
|
+
// 上次 pong 未回来:连接已死(半开),强制断开以触发重连
|
|
350
|
+
console.warn('[connector] heartbeat timeout, terminating dead connection');
|
|
351
|
+
try { ws.terminate(); } catch {}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
alive = false;
|
|
355
|
+
try { ws.ping(); } catch {}
|
|
356
|
+
}, 30000); // 每 30s ping,60s 内无 pong 判死
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
ws.on('open', () => {
|
|
360
|
+
onConnection?.('connecting');
|
|
361
|
+
startHeartbeat();
|
|
362
|
+
// B3/D2:HELLO 用 keys 里最新的 deviceToken(短 TTL 在线续期后重连用新 token)
|
|
363
|
+
safeSend(ws, jsonFrame(TYPE.HELLO, 0, { deviceToken: keys.deviceToken || deviceToken }));
|
|
364
|
+
});
|
|
365
|
+
ws.on('pong', () => { alive = true; });
|
|
366
|
+
ws.on('error', (e) => {
|
|
367
|
+
stopHeartbeat();
|
|
368
|
+
console.error('[connector] ws error:', e.message);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
ws.on('message', async (data) => {
|
|
372
|
+
let f;
|
|
373
|
+
try {
|
|
374
|
+
f = decodeFrame(data);
|
|
375
|
+
} catch {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (f.type === TYPE.HELLO_OK) {
|
|
380
|
+
// B9(P1-11):畸形 HELLO_OK 不得逃逸异常崩宿主(async 回调 rejection → Node 默认退出)
|
|
381
|
+
try {
|
|
382
|
+
backoff = 1000;
|
|
383
|
+
onConnection?.('connected');
|
|
384
|
+
console.log('[connector] registered as', toJson(f.payload).deviceId, 'sessionKeys=', sessionKeys.size);
|
|
385
|
+
startDshEvents(ws);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
console.error('[connector] bad HELLO_OK:', e?.message);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (f.type === TYPE.ERROR) {
|
|
392
|
+
try {
|
|
393
|
+
const msg = f.payload.toString('utf8');
|
|
394
|
+
console.error('[connector] relay error:', msg);
|
|
395
|
+
// 认证被拒(HELLO 用的 deviceToken 过期/失效)→ 标记,close 时刷新 token
|
|
396
|
+
if (/unauthorized/i.test(msg) || /not device/i.test(msg)) authFailed = true;
|
|
397
|
+
} catch {}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (f.type === TYPE.REVOKE) {
|
|
402
|
+
try {
|
|
403
|
+
const msg = toJson(f.payload);
|
|
404
|
+
const phonePk = msg.phonePk;
|
|
405
|
+
// A3/P0-2:REVOKE 必须携带手机身份私钥签名(relay 转发时已验签;本地再兜底验一次)。
|
|
406
|
+
// 无签名/验签失败 → 拒绝删除(中继/他人无法伪造 REVOKE)。
|
|
407
|
+
if (!phonePk || typeof msg.sig !== 'string' || !verifyUnpair(phonePk, msg.sig, { deviceId: keys.deviceId, phonePk, ts: String(msg.ts ?? '') })) {
|
|
408
|
+
throw new Error('bad revoke signature');
|
|
409
|
+
}
|
|
410
|
+
sessionKeys.delete(phonePk);
|
|
411
|
+
phoneOffline.delete(phonePk); // 会话密钥已删,门禁状态一并清理
|
|
412
|
+
onRevoke?.(phonePk);
|
|
413
|
+
console.log('[connector] revoked session key for', String(phonePk).slice(0, 8));
|
|
414
|
+
} catch {}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 方案C(省流):手机 /events 上下线通知——离线手机停止合批推送。默认全在线
|
|
419
|
+
// (旧 relay 不发本帧时行为与现状一致);HELLO 快照补齐 relay 重启后的陈旧状态。
|
|
420
|
+
if (f.type === TYPE.PHONE_STATE) {
|
|
421
|
+
try {
|
|
422
|
+
const msg = toJson(f.payload);
|
|
423
|
+
setPhoneOnline(msg.phonePk, msg.online === true);
|
|
424
|
+
} catch {}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (f.type === TYPE.REQ) {
|
|
429
|
+
// B8(P1-10):入站 REQ 帧 payload 上限(恶意/失控中继超大帧 → OOM 宿主)
|
|
430
|
+
if (f.payload.length > MAX_REQ_FRAME) return;
|
|
431
|
+
try {
|
|
432
|
+
const env = toJson(f.payload);
|
|
433
|
+
const sessionKey = sessionKeys.get(env.from);
|
|
434
|
+
if (!sessionKey) {
|
|
435
|
+
// 无会话密钥(PC 端已解除配对 / 该手机从未配对过):此前直接 throw,而 catch 里
|
|
436
|
+
// 也因无密钥封不了错误信封 → **静默丢弃** → relay 等 REQ_TIMEOUT(30s) → 手机端
|
|
437
|
+
// 得到不透明 504「电脑响应超时」。改为**立即**回一个可识别的明文错误
|
|
438
|
+
// (与下方 replay detected 同风格:错误本身不携带任何载荷/密文,中继只能看到
|
|
439
|
+
// "该手机未配对"),手机端 tunnelFetch 映射 'no session key' → UNPAIRED →
|
|
440
|
+
// 友好提示"请重新扫码配对",无漫长等待。
|
|
441
|
+
safeSend(ws, encode(TYPE.RES, f.streamId, JSON.stringify({ __error: 'no session key' })));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
onActivity?.(env.from);
|
|
445
|
+
// A7(P0-6):先验签解密成功,再推进防重放水位——
|
|
446
|
+
// 未认证帧(中继注入的垃圾 REQ)不能污染水位(否则受害手机后续合法帧全被拒)
|
|
447
|
+
const innerBytes = openEnvelope(dirOf(sessionKey).up, env);
|
|
448
|
+
if (!guard.check(env.from, env.seq)) {
|
|
449
|
+
// seq 回退(手机 App 重启后 5s 持久化窗口未落盘):回包当前水位,
|
|
450
|
+
// 手机端据此快进 seq 并自动重试一次,用户无感
|
|
451
|
+
const errRes = { __error: 'replay detected', replayWatermark: guard.watermarkOf(env.from) };
|
|
452
|
+
safeSend(ws, encode(TYPE.RES, f.streamId, JSON.stringify(errRes)));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const innerStr = utf8Decode(innerBytes);
|
|
456
|
+
let inner;
|
|
457
|
+
try {
|
|
458
|
+
inner = JSON.parse(innerStr);
|
|
459
|
+
} catch {
|
|
460
|
+
// A8:错误消息绝不携带解密内容(中继可见)
|
|
461
|
+
throw new Error('bad inner json');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 控制帧:手机会话焦点上报(H1 根治后不再参与过滤,仅保留 ack 兼容旧版 App)
|
|
465
|
+
if (inner?.method === '_focus') {
|
|
466
|
+
const ack = sealEnvelope(
|
|
467
|
+
dirOf(sessionKey).down,
|
|
468
|
+
{
|
|
469
|
+
fromPublicKey: keys.identityPublicKey,
|
|
470
|
+
toPublicKey: env.from,
|
|
471
|
+
seq: nextSeq(),
|
|
472
|
+
identitySecretKey: keys.identitySecretKey,
|
|
473
|
+
},
|
|
474
|
+
utf8(JSON.stringify({ status: 200, body: null })),
|
|
475
|
+
);
|
|
476
|
+
safeSend(ws, encode(TYPE.RES, f.streamId, JSON.stringify(ack)));
|
|
477
|
+
// 2026-09:焦点上报 → connector 开 session/follow 流重构消息级实时(Phase 3)
|
|
478
|
+
try { onFocus?.(env.from, inner?.sessionId ?? null); } catch {}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// M4.x-c:本地处理面(mobile.* RPC)。localHandler 返回 null 表示不接管,
|
|
483
|
+
// 回落通用反代(如 dsh web 自己的 /api 方法)。返回值与 replayInner 同构。
|
|
484
|
+
// phonePk(env.from)随请求传入:供权限门禁(fullDisk)按手机判定。
|
|
485
|
+
const local = localHandler ? await localHandler(inner, env.from) : null;
|
|
486
|
+
// raw-bytes 响应(mobile.files.download,方案 file-image-download-plan.md §4.2):
|
|
487
|
+
// localHandler/bridge 以 {__raw: Uint8Array} 标记——以原始字节作信封明文展开
|
|
488
|
+
// (越过 utf8(JSON.stringify(...)),避开业务层 base64)。错误仍走下方 catch。
|
|
489
|
+
if (local && typeof local === 'object' && local.__raw instanceof Uint8Array) {
|
|
490
|
+
const rawEnv = sealEnvelope(
|
|
491
|
+
dirOf(sessionKey).down,
|
|
492
|
+
{
|
|
493
|
+
fromPublicKey: keys.identityPublicKey,
|
|
494
|
+
toPublicKey: env.from,
|
|
495
|
+
seq: nextSeq(),
|
|
496
|
+
identitySecretKey: keys.identitySecretKey,
|
|
497
|
+
},
|
|
498
|
+
local.__raw,
|
|
499
|
+
);
|
|
500
|
+
// 性能:raw-bytes 分块的信封对 ≥4KB deflate(base64 密文是 ASCII,可压缩;文本/二进制均省网 25-33%)。
|
|
501
|
+
// 与 replayInner 的 RES 压缩同标记(enc:'deflate');手机端 callBinary 先解压再 openEnvelope。
|
|
502
|
+
const envStr = JSON.stringify(rawEnv);
|
|
503
|
+
let out;
|
|
504
|
+
if (envStr.length >= COMPRESS_THRESHOLD) {
|
|
505
|
+
out = JSON.stringify({ enc: 'deflate', data: zlib.deflateSync(envStr).toString('base64') });
|
|
506
|
+
} else {
|
|
507
|
+
out = envStr;
|
|
508
|
+
}
|
|
509
|
+
safeSend(ws, encode(TYPE.RES, f.streamId, out));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const innerRes = local ?? (await replayInner(target, inner, sessionTitles));
|
|
513
|
+
const resEnv = sealEnvelope(
|
|
514
|
+
dirOf(sessionKey).down,
|
|
515
|
+
{
|
|
516
|
+
fromPublicKey: keys.identityPublicKey,
|
|
517
|
+
toPublicKey: env.from,
|
|
518
|
+
seq: nextSeq(),
|
|
519
|
+
identitySecretKey: keys.identitySecretKey,
|
|
520
|
+
},
|
|
521
|
+
utf8(JSON.stringify(innerRes)),
|
|
522
|
+
);
|
|
523
|
+
safeSend(ws, encode(TYPE.RES, f.streamId, JSON.stringify(resEnv)));
|
|
524
|
+
} catch (e) {
|
|
525
|
+
// A8(P1-4):错误响应密封为信封(down 方向密钥)且不回显任何载荷/明文片段——
|
|
526
|
+
// 中继只能看到密文,不能注入/读取错误通道内容;无会话密钥时静默(零泄露)。
|
|
527
|
+
let errEnv = null;
|
|
528
|
+
try {
|
|
529
|
+
const parsed = JSON.parse(f.payload.toString('utf8'));
|
|
530
|
+
const sk = sessionKeys.get(parsed.from);
|
|
531
|
+
if (sk) {
|
|
532
|
+
errEnv = sealEnvelope(
|
|
533
|
+
dirOf(sk).down,
|
|
534
|
+
{
|
|
535
|
+
fromPublicKey: keys.identityPublicKey,
|
|
536
|
+
toPublicKey: parsed.from,
|
|
537
|
+
seq: nextSeq(),
|
|
538
|
+
identitySecretKey: keys.identitySecretKey,
|
|
539
|
+
},
|
|
540
|
+
utf8(JSON.stringify({ __error: String(e?.message || 'internal') })),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
} catch {}
|
|
544
|
+
if (errEnv) safeSend(ws, encode(TYPE.RES, f.streamId, JSON.stringify(errEnv)));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
ws.on('close', () => {
|
|
550
|
+
stopHeartbeat();
|
|
551
|
+
onConnection?.('disconnected');
|
|
552
|
+
for (const s of dshStreams) {
|
|
553
|
+
try {
|
|
554
|
+
s.close();
|
|
555
|
+
} catch {}
|
|
556
|
+
}
|
|
557
|
+
dshStreams = [];
|
|
558
|
+
console.log('[connector] control closed, reconnect in', backoff, 'ms');
|
|
559
|
+
if (!stopped) {
|
|
560
|
+
// 认证失败:触发 token 自愈刷新(异步,更新 keys.deviceToken),并以较快退避重连
|
|
561
|
+
if (authFailed) {
|
|
562
|
+
authFailed = false;
|
|
563
|
+
backoff = 1000;
|
|
564
|
+
try {
|
|
565
|
+
Promise.resolve(onAuthFailure?.()).catch((e) =>
|
|
566
|
+
console.warn('[connector] auth-failure token refresh failed:', e?.message),
|
|
567
|
+
);
|
|
568
|
+
} catch {}
|
|
569
|
+
}
|
|
570
|
+
reconnectTimer = setTimeout(connect, backoff);
|
|
571
|
+
backoff = Math.min(backoff * 2, 30000);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// M4.x-c:本地事件注入面——bridge 的 mobile/event 帧从这里进入合批队列
|
|
577
|
+
// (与 dsh web 事件同一加密 EVENT 通道;广播给所有已配对手机,App 按 pluginId 过滤)。
|
|
578
|
+
// 方案C:离线手机同样跳过(在线门禁与 dsh 事件共用)。
|
|
579
|
+
function injectEvent(text) {
|
|
580
|
+
for (const phonePk of sessionKeys.keys()) {
|
|
581
|
+
if (phoneOffline.has(phonePk)) continue;
|
|
582
|
+
pushToBatch(phonePk, text);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
connect();
|
|
587
|
+
|
|
588
|
+
const stop = () => {
|
|
589
|
+
stopped = true;
|
|
590
|
+
clearTimeout(reconnectTimer);
|
|
591
|
+
if (outSeqTimer.current) {
|
|
592
|
+
clearTimeout(outSeqTimer.current);
|
|
593
|
+
outSeqTimer.current = null;
|
|
594
|
+
}
|
|
595
|
+
for (const s of dshStreams) {
|
|
596
|
+
try {
|
|
597
|
+
s.close();
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
currentWs?.close();
|
|
602
|
+
} catch {}
|
|
603
|
+
};
|
|
604
|
+
// 可调用 + 携带方法(connector-core 用 this._stopConnector?.() 停止,injectEvent 供 bridge 事件接线)
|
|
605
|
+
stop.injectEvent = injectEvent;
|
|
606
|
+
return stop;
|
|
607
|
+
}
|