dsh-mobile-plugin 0.1.77 → 0.1.78
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 +10 -1
- package/lib/impl.js +12 -1
- package/lib/mobile/history-derive.js +75 -8
- package/lib/mobile/pairing.js +211 -0
- package/lib/mobile/uploads.js +48 -6
- package/lib/panel/index.html +8 -6
- package/lib/relay/lan-network.cjs +44 -1
- package/lib/relay/lan-proxy.js +43 -11
- package/lib/relay/protocol.js +5 -4
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +28 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,7 +74,16 @@ pnpm add dsh-mobile-plugin@<版本>
|
|
|
74
74
|
|
|
75
75
|
新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
发布一个紧急完整包:在插件仓库根目录 `npm pack`,把打出来的 tgz 原样 POST 给中转(包本身就是 tar.gz,不用再解包重打):
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
curl -X POST -H "authorization: Bearer <中转的 OTA 发布 token>" --data-binary @dsh-mobile-plugin-0.1.61.tgz \
|
|
81
|
+
"https://你的中转/api/desktop-plugins/ota/publish?platform=darwin&channel=stable&version=0.1.61"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`platform` 填 `process.platform`(`darwin` / `linux` / `win32`),`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
|
|
85
|
+
|
|
86
|
+
打包时**别用 macOS 默认的 tar 参数**——带 PAX 扩展头,宿主那边的解析器不认,会报「OTA 包结构不完整」。要 `COPYFILE_DISABLE=1 tar --no-xattrs --no-mac-metadata -czf …`,且条目以 `package/` 打头。
|
|
78
87
|
|
|
79
88
|
## 开发
|
|
80
89
|
|
package/lib/impl.js
CHANGED
|
@@ -62,9 +62,16 @@ export const name = 'dsh-mobile-plugin'
|
|
|
62
62
|
// 有,get trap 第一站就命中)。
|
|
63
63
|
// 漏了这两个分支里的任何一个,读 `ctx.apiProxy` 都会抛
|
|
64
64
|
// `cannot get property "apiProxy" without inject`:服务器宿主上 mirror 推送曾整条挂掉。
|
|
65
|
+
//
|
|
66
|
+
// `typertGateway` 必须出现在 inject 里(0.2.0 那一支):宿主 RPC 全部经它派发,
|
|
67
|
+
// 而它是**后于本插件**装载的服务。只 `ctx.get('typertGateway')` 而不注入,取到的是
|
|
68
|
+
// 装载那一刻的空值,于是每一条事件流都抛「当前宿主版本没有提供该能力」——服务器宿主
|
|
69
|
+
// 上实时推送整条挂掉,而收发消息、会话列表这些走普通请求的功能一切正常,看着像只有
|
|
70
|
+
// 「刷新不实时」的小毛病。注入之后 cordis 会等服务就绪再激活本插件。
|
|
71
|
+
// 旧运行时那一支不加:那版没有这个服务,注入只会让这一行永远 pending。
|
|
65
72
|
export const inject = runtimeProvidesApiProxy()
|
|
66
73
|
? ['webServer', 'apiProxy', 'sessions']
|
|
67
|
-
: ['webServer', 'sessions']
|
|
74
|
+
: ['webServer', 'sessions', 'typertGateway']
|
|
68
75
|
|
|
69
76
|
export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
|
|
70
77
|
export const RemoteControlSettingsSchema = z.object({
|
|
@@ -393,6 +400,10 @@ function apply(ctx) {
|
|
|
393
400
|
// 移动端路由表那边叫它「局域网候选」,取的是同一份私网优先的清单。
|
|
394
401
|
safeLanCandidates: advertisedCandidates,
|
|
395
402
|
lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
|
|
403
|
+
// 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
|
|
404
|
+
// 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
|
|
405
|
+
core,
|
|
406
|
+
pairingApproval,
|
|
396
407
|
})
|
|
397
408
|
ctx.effect(() => {
|
|
398
409
|
const stopWatching = settings.watch(next => {
|
|
@@ -209,9 +209,13 @@ function lastMessageOf(messages) {
|
|
|
209
209
|
/**
|
|
210
210
|
* 从 history 事件折叠出消息与「结构化行」(wire v1 扩展,向后兼容):
|
|
211
211
|
* - 普通消息维持现状:{ id, seq, time, role, text, blocks?, images?, clientMessageId? }(user/assistant);
|
|
212
|
-
* - compaction:{ kind:'compaction', id, seq, time, state:'running'|'done',
|
|
212
|
+
* - compaction:{ kind:'compaction', id, seq, time, state:'running'|'done'|'failed', error?, turn?,
|
|
213
|
+
* summary?, shadowedItemCount?, shadowedTokenCount? };
|
|
213
214
|
* 压缩检查点 user/message(source.kind==='plugin' && source.plugin==='compact')绝不渲染成用户气泡,
|
|
214
215
|
* 渲染成 compaction 行(id=compactionId,state='done'),并用同 compactionId 的 compaction/summary 补 counts;
|
|
216
|
+
* compaction/end 给这次尝试落定(带 error → state='failed' + error 原文,否则 'done');
|
|
217
|
+
* **同一轮里的多次尝试只占一行**(新尝试原地顶替还没落定的那条)—— 宿主一步重试一次,
|
|
218
|
+
* 不并就是几十条「正在压缩…」;
|
|
215
219
|
* - retry:{ kind:'retry', id: retryId, seq, time, status:'scheduled'|'started', retry, maxRetries, delayMs?, failure? };
|
|
216
220
|
* - turn-error / max-tokens:{ kind:'turn-error', id:'turn-<turn>', message, code? } / { kind:'max-tokens', id:'turn-<turn>' };
|
|
217
221
|
* - command:{ kind:'command', id:commandId, name, status:'running'|'done', resultKind?, text? }
|
|
@@ -291,6 +295,8 @@ function deriveMessages(events) {
|
|
|
291
295
|
};
|
|
292
296
|
// 结构化行按 id 唯一:compactionId → 行索引(检查点行原地替换 start 行)、retryId → 行索引(原地升级)。
|
|
293
297
|
const compactionAt = new Map();
|
|
298
|
+
// 一轮里的压缩尝试行(turn → 行索引表):同一轮的多次尝试共用一行,见 pendingCompactionAt。
|
|
299
|
+
const compactionTurns = new Map();
|
|
294
300
|
const retryAt = new Map();
|
|
295
301
|
// 命令行按 commandId 唯一:command/run 发出行,command/done 原地升级(保 run 的 name,位置随 done 事件 seq 归位)。
|
|
296
302
|
const commandAt = new Map();
|
|
@@ -332,12 +338,31 @@ function deriveMessages(events) {
|
|
|
332
338
|
|
|
333
339
|
// 放入一行 compaction:同一 compactionId 只保留一行,检查点行原地替换 start 行(最终按 seq 排序归位)。
|
|
334
340
|
const placeCompaction = (item) => {
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
compactionAt.set(item.id, out.length);
|
|
341
|
+
let index = compactionAt.get(item.id);
|
|
342
|
+
if (index === undefined) {
|
|
343
|
+
index = out.length;
|
|
339
344
|
out.push(item);
|
|
345
|
+
} else out[index] = item;
|
|
346
|
+
compactionAt.set(item.id, index);
|
|
347
|
+
if (typeof item.turn === 'number') {
|
|
348
|
+
const list = compactionTurns.get(item.turn);
|
|
349
|
+
if (list === undefined) compactionTurns.set(item.turn, [index]);
|
|
350
|
+
else if (!list.includes(index)) list.push(index);
|
|
340
351
|
}
|
|
352
|
+
return index;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// 同一轮里的多次压缩尝试只占一行:宿主一步重试一次、每次一个新 compactionId,按 id 找必然
|
|
356
|
+
// 「每次都新增一条」——一次跑几十步就是一个回合几十条「正在压缩…」。新尝试顶替这一轮里还
|
|
357
|
+
// 没落定的那条(running / failed),已经落地(done)的不顶替:那是真压过一次的记录。
|
|
358
|
+
const pendingCompactionAt = (turn, id) => {
|
|
359
|
+
if (compactionAt.has(id)) return compactionAt.get(id);
|
|
360
|
+
if (typeof turn !== 'number') return -1;
|
|
361
|
+
for (const index of compactionTurns.get(turn) || []) {
|
|
362
|
+
const row = out[index];
|
|
363
|
+
if (row && row.kind === 'compaction' && (row.state === 'running' || row.state === 'failed')) return index;
|
|
364
|
+
}
|
|
365
|
+
return -1;
|
|
341
366
|
};
|
|
342
367
|
|
|
343
368
|
for (const entry of events) {
|
|
@@ -376,8 +401,10 @@ function deriveMessages(events) {
|
|
|
376
401
|
} else if (e.type === 'assistant/message' && e.data && e.data.message && e.data.message.content) {
|
|
377
402
|
pushMessage(e.data.message, e);
|
|
378
403
|
} else if (e.type === 'compaction/start' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
|
|
379
|
-
// 行已由检查点发出(done
|
|
380
|
-
|
|
404
|
+
// 行已由检查点发出(done)时跳过,避免重复行。同一轮里已有没落定的行就**原地顶替**
|
|
405
|
+
// (换 id/状态,位置留在第一次尝试那一处)—— 否则一次跑几十步就是几十条行。
|
|
406
|
+
const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
|
|
407
|
+
if (idx < 0) {
|
|
381
408
|
placeCompaction({
|
|
382
409
|
id: e.data.compactionId,
|
|
383
410
|
kind: 'compaction',
|
|
@@ -387,7 +414,46 @@ function deriveMessages(events) {
|
|
|
387
414
|
shadowedTokenCount: null,
|
|
388
415
|
seq,
|
|
389
416
|
time,
|
|
417
|
+
...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
|
|
418
|
+
});
|
|
419
|
+
} else {
|
|
420
|
+
const existing = out[idx];
|
|
421
|
+
out[idx] = {
|
|
422
|
+
...existing,
|
|
423
|
+
id: e.data.compactionId,
|
|
424
|
+
state: 'running',
|
|
425
|
+
error: null,
|
|
426
|
+
...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
|
|
427
|
+
};
|
|
428
|
+
compactionAt.set(e.data.compactionId, idx);
|
|
429
|
+
}
|
|
430
|
+
} else if (e.type === 'compaction/end' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
|
|
431
|
+
// 这次尝试的落定:无 error → done(summary / 检查点随后补齐 counts)、有 error → failed
|
|
432
|
+
// 带原因。这条以前整条丢掉,于是被拒绝的压缩永远停在「正在压缩…」,失败一次都看不见。
|
|
433
|
+
const error = typeof e.data.error === 'string' && e.data.error !== '' ? e.data.error : null;
|
|
434
|
+
const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
|
|
435
|
+
if (idx < 0) {
|
|
436
|
+
placeCompaction({
|
|
437
|
+
id: e.data.compactionId,
|
|
438
|
+
kind: 'compaction',
|
|
439
|
+
state: error ? 'failed' : 'done',
|
|
440
|
+
error,
|
|
441
|
+
summary: null,
|
|
442
|
+
shadowedItemCount: null,
|
|
443
|
+
shadowedTokenCount: null,
|
|
444
|
+
seq,
|
|
445
|
+
time,
|
|
446
|
+
...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
|
|
390
447
|
});
|
|
448
|
+
} else {
|
|
449
|
+
const existing = out[idx];
|
|
450
|
+
out[idx] = {
|
|
451
|
+
...existing,
|
|
452
|
+
id: e.data.compactionId,
|
|
453
|
+
state: error ? 'failed' : 'done',
|
|
454
|
+
error,
|
|
455
|
+
};
|
|
456
|
+
compactionAt.set(e.data.compactionId, idx);
|
|
391
457
|
}
|
|
392
458
|
} else if (e.type === 'compaction/summary' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
|
|
393
459
|
// 已有同 compactionId 的行(start 或检查点发出)→ 升级为 done 并补 counts;否则新增 done 行。
|
|
@@ -395,6 +461,7 @@ function deriveMessages(events) {
|
|
|
395
461
|
const existing = compactionAt.has(id) ? out[compactionAt.get(id)] : null;
|
|
396
462
|
if (existing) {
|
|
397
463
|
existing.state = 'done';
|
|
464
|
+
existing.error = null;
|
|
398
465
|
existing.summary = compactionSummaryText(e.data.summary);
|
|
399
466
|
existing.shadowedItemCount = shadowedCount(e.data.shadowedSeqs);
|
|
400
467
|
existing.shadowedTokenCount = shadowedTokens(e.data.shadowedTokenCount);
|
|
@@ -486,7 +553,7 @@ function deriveMessages(events) {
|
|
|
486
553
|
out.push({ id: `turn-${e.data.turn}`, kind: 'max-tokens', seq, time });
|
|
487
554
|
}
|
|
488
555
|
}
|
|
489
|
-
// tool/result
|
|
556
|
+
// tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
|
|
490
557
|
}
|
|
491
558
|
// 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
|
|
492
559
|
out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端配对:手机在中转上用自己的配对码换到 token 与设备凭据。
|
|
5
|
+
*
|
|
6
|
+
* **这条路由归宿主,不归中转。**「谁可以进这台机器」是宿主自己的判断,配对码本来就存在
|
|
7
|
+
* 宿主的 `state` 里;中转只按实例标识把请求转过来。早先这套判断长在独立的中转进程里
|
|
8
|
+
* (`dsh-mobile-server/server.js` 的 `/pair`),换成 Rust 中转时整块没搬过去,于是手机
|
|
9
|
+
* 按老地址 POST `/pair`、请求被中转原样转给宿主、宿主没有这条路由,只能回 405。
|
|
10
|
+
*
|
|
11
|
+
* 与局域网那条(`lib/relay/lan-proxy.js` 的 `/__lan_pair`)是**同一件事的两个入口**:
|
|
12
|
+
* 都是「配对码 → 签发凭据」,差别只在网络路径。凭据本身两条路签发的是同一种 HMAC
|
|
13
|
+
* (都用宿主 lanSecret 签名),所以经中转配对拿到的 token 可以直接用于局域网请求。
|
|
14
|
+
*
|
|
15
|
+
* 故意**不**在这里做的事:
|
|
16
|
+
* - 不问宿主再确认一次。宿主的配对策略(pairingPolicy)已经决定了它接不接受新设备,
|
|
17
|
+
* `deny` 时中转根本连不上;而这条路由本身就跑在宿主进程里。
|
|
18
|
+
* - 不做「多台机器撞同一个配对码」的处理。那是中转的职责(它才看得见全部实例),
|
|
19
|
+
* 在这台宿主上,配对码要么对上要么对不上。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { sendJson } = require('../shared/http-util');
|
|
23
|
+
const { safeEqual } = require('../shared/dshtoken');
|
|
24
|
+
|
|
25
|
+
/** 与局域网那条同口径:连错 5 次锁 60 秒。 */
|
|
26
|
+
const PAIR_MAX_FAILS = 5;
|
|
27
|
+
const PAIR_LOCK_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
/** ip -> { fails, lockedUntil }。放在模块作用域,两种入口共用同一份额度。 */
|
|
30
|
+
const pairLocks = new Map();
|
|
31
|
+
|
|
32
|
+
function isPairLocked(ip) {
|
|
33
|
+
const entry = pairLocks.get(ip);
|
|
34
|
+
return !!entry && entry.lockedUntil > Date.now();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function recordPairFail(ip) {
|
|
38
|
+
const entry = pairLocks.get(ip) || { fails: 0, lockedUntil: 0 };
|
|
39
|
+
entry.fails += 1;
|
|
40
|
+
if (entry.fails >= PAIR_MAX_FAILS) {
|
|
41
|
+
entry.lockedUntil = Date.now() + PAIR_LOCK_MS;
|
|
42
|
+
entry.fails = 0;
|
|
43
|
+
}
|
|
44
|
+
pairLocks.set(ip, entry);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clearPairFails(ip) {
|
|
48
|
+
pairLocks.delete(ip);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 测试用:清空限流计数。 */
|
|
52
|
+
function resetPairLocks() {
|
|
53
|
+
pairLocks.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 请求方 IP。中转会把真实来源放进 x-real-ip(与 nginx 同一约定)。 */
|
|
57
|
+
function clientIp(req) {
|
|
58
|
+
const headers = (req && req.headers) || {};
|
|
59
|
+
const raw = headers['x-real-ip'];
|
|
60
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
61
|
+
if (typeof header === 'string' && header.trim()) return header.trim();
|
|
62
|
+
return (req && req.socket && req.socket.remoteAddress) || '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 配对码错误 / 策略拒绝统一回同一响应。
|
|
67
|
+
*
|
|
68
|
+
* 分开回等于告诉对方「这个码是真的,只是被拒了」,也就是把一个免费的存在性查询送出去。
|
|
69
|
+
*/
|
|
70
|
+
function pairDenied(res) {
|
|
71
|
+
sendJson(res, 401, { ok: false, error: '配对码错误' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 读请求体。配对体很小(配对码 + 设备元信息),上限给 16KB。
|
|
76
|
+
*/
|
|
77
|
+
function readBody(req) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
if (!req || typeof req.on !== 'function') {
|
|
80
|
+
resolve({});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let raw = '';
|
|
84
|
+
let done = false;
|
|
85
|
+
const finish = () => {
|
|
86
|
+
if (done) return;
|
|
87
|
+
done = true;
|
|
88
|
+
try {
|
|
89
|
+
resolve(JSON.parse(raw || '{}'));
|
|
90
|
+
} catch {
|
|
91
|
+
resolve(null);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
req.setEncoding('utf8');
|
|
95
|
+
req.on('data', (chunk) => {
|
|
96
|
+
raw += chunk;
|
|
97
|
+
if (raw.length > 16 * 1024) {
|
|
98
|
+
raw = raw.slice(0, 16 * 1024);
|
|
99
|
+
req.destroy();
|
|
100
|
+
finish();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
req.on('end', finish);
|
|
104
|
+
req.on('error', finish);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 构造 `/pair` 的 handler。依赖由 impl.js 注入,与移动端其它路由同一形状。
|
|
110
|
+
*
|
|
111
|
+
* @param {object} deps
|
|
112
|
+
* @param {object} deps.state 宿主状态(instanceId / pairingCode / lanSecret)
|
|
113
|
+
* @param {Function} deps.readJsonBody
|
|
114
|
+
* @param {object} deps.core relay/core.js(签 token 用)
|
|
115
|
+
* @param {object} deps.credentials 设备凭据库(issue 接口)
|
|
116
|
+
* @param {object} deps.pairingApproval pairing-policy.js(决定这台机器收不收新设备)
|
|
117
|
+
* @param {(message: string) => void} deps.log
|
|
118
|
+
*/
|
|
119
|
+
function createPairHandler(deps) {
|
|
120
|
+
const { state, core, credentials, pairingApproval, log } = deps;
|
|
121
|
+
|
|
122
|
+
// ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的。
|
|
123
|
+
// 写成 `(_ctx, req, res)` 会把 res 挤成 undefined,而 sendJson 的 catch 会把那个
|
|
124
|
+
// TypeError 当成「连接已关闭」吞掉——既没报错也没回包,请求永久挂住(/health 踩过)。
|
|
125
|
+
return async function handlePair(req, res) {
|
|
126
|
+
const ip = clientIp(req);
|
|
127
|
+
|
|
128
|
+
if (isPairLocked(ip)) {
|
|
129
|
+
sendJson(res, 429, { ok: false, error: '尝试过于频繁,请稍后再试' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const body = await readBody(req);
|
|
134
|
+
if (!body || typeof body !== 'object') {
|
|
135
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
|
140
|
+
if (!/^\d{6}$/.test(code)) {
|
|
141
|
+
sendJson(res, 400, { ok: false, error: '配对码需为 6 位数字' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const expected = String(state.pairingCode || '');
|
|
146
|
+
if (!expected || !safeEqual(code, expected)) {
|
|
147
|
+
recordPairFail(ip);
|
|
148
|
+
pairDenied(res);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 配对码对了,还要宿主自己的策略点头:'deny' 就是明确不收新设备。
|
|
153
|
+
const decision = pairingApproval.evaluate(state.pairingPolicy, body.device);
|
|
154
|
+
if (!decision.approved) {
|
|
155
|
+
recordPairFail(ip);
|
|
156
|
+
pairDenied(res);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// **配对码正确时不清限流计数**:被拒的尝试同样要记数,否则猜对码但被策略拒的一方
|
|
161
|
+
// 可以无限重试。只有真正放行才清零。
|
|
162
|
+
clearPairFails(ip);
|
|
163
|
+
|
|
164
|
+
const instanceId = String(state.instanceId || '');
|
|
165
|
+
const supplied = body.device && typeof body.device === 'object' ? body.device : {};
|
|
166
|
+
const device = {
|
|
167
|
+
...Object.fromEntries(Object.entries(supplied).filter(([, value]) => value !== undefined)),
|
|
168
|
+
ip,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let issued;
|
|
172
|
+
try {
|
|
173
|
+
issued = credentials.issue(instanceId, device, '');
|
|
174
|
+
} catch (error) {
|
|
175
|
+
log('pair issue failed:', error && error.message);
|
|
176
|
+
sendJson(res, 500, { ok: false, error: 'internal error' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 两样都要给,缺一样 App 就停在「配对响应缺少 token/cookie」:
|
|
181
|
+
// - `token` 是 **bearer**(`Authorization: Bearer …`),365 天;
|
|
182
|
+
// - `cookie` 是**中转路由**用的那张,30 天。
|
|
183
|
+
// 两者的 TTL 刻意不同,别合成一个常量(见 relay/core.js 的 MOBILE_BEARER_TTL_SEC 注释)。
|
|
184
|
+
const token = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_BEARER_TTL_SEC);
|
|
185
|
+
const cookieValue = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC);
|
|
186
|
+
const hostPlatform = pairingApproval.hostPlatform().platform;
|
|
187
|
+
sendJson(res, 200, {
|
|
188
|
+
ok: true,
|
|
189
|
+
instanceId,
|
|
190
|
+
token,
|
|
191
|
+
// cookie 同时放进 body:RN 的 fetch 读不到 Set-Cookie,App 得自己带上。
|
|
192
|
+
cookie: `dsh_lan=${cookieValue}`,
|
|
193
|
+
deviceId: issued.deviceId,
|
|
194
|
+
deviceCredential: issued.credential,
|
|
195
|
+
credentialExpiresAt: issued.expiresAt,
|
|
196
|
+
// 宿主自己的系统标识,不是手机的。键名**必须是 `hostPlatform`**:App 落盘的就是它
|
|
197
|
+
//(app/src/services/pairApi.ts 读 `body.hostPlatform` 存进 `MobileSession.hostPlatform`),
|
|
198
|
+
// 局域网那条(lan-proxy.js)给的也是它。`hostPlatform()` 回的是 `{platform}` 对象,
|
|
199
|
+
// 整块展开就变成 `platform` —— App 收不到,设备行退回通用图标(真机上少的就是企鹅)。
|
|
200
|
+
...(hostPlatform ? { hostPlatform } : {}),
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
createPairHandler,
|
|
207
|
+
resetPairLocks,
|
|
208
|
+
clientIp,
|
|
209
|
+
PAIR_MAX_FAILS,
|
|
210
|
+
PAIR_LOCK_MS,
|
|
211
|
+
};
|
package/lib/mobile/uploads.js
CHANGED
|
@@ -44,9 +44,43 @@ const STAGING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
44
44
|
/** 分片必须是标准 base64:Node 的 Buffer.from 会静默丢弃非法字符,光靠解码判不出坏片。 */
|
|
45
45
|
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
46
46
|
|
|
47
|
-
/** 进行中的上传:uploadId → { name, stagingPath, received, bytes }。 */
|
|
47
|
+
/** 进行中的上传:uploadId → { name, stagingPath, received, bytes, expiresAt, expiryTimer }。 */
|
|
48
48
|
const uploads = new Map();
|
|
49
49
|
|
|
50
|
+
function removeUpload(uploadId, job) {
|
|
51
|
+
if (uploads.get(uploadId) !== job) return false;
|
|
52
|
+
uploads.delete(uploadId);
|
|
53
|
+
if (job.expiryTimer) clearTimeout(job.expiryTimer);
|
|
54
|
+
job.expiryTimer = null;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function expireUpload(uploadId, job) {
|
|
59
|
+
if (uploads.get(uploadId) !== job) return;
|
|
60
|
+
const remaining = job.expiresAt - Date.now();
|
|
61
|
+
if (remaining > 0) {
|
|
62
|
+
job.expiryTimer = setTimeout(() => { void expireUpload(uploadId, job); }, remaining);
|
|
63
|
+
job.expiryTimer.unref?.();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!removeUpload(uploadId, job)) return;
|
|
67
|
+
try { await fsp.unlink(job.stagingPath); } catch { /* 暂存文件可能已由磁盘清扫移除 */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function scheduleUploadExpiry(uploadId, job) {
|
|
71
|
+
if (job.expiryTimer) clearTimeout(job.expiryTimer);
|
|
72
|
+
job.expiryTimer = setTimeout(() => { void expireUpload(uploadId, job); }, Math.max(0, job.expiresAt - Date.now()));
|
|
73
|
+
job.expiryTimer.unref?.();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 清理过期上传状态及其暂存文件;传入 now 便于测试 TTL 边界。 */
|
|
77
|
+
async function sweepExpiredUploads(now = Date.now()) {
|
|
78
|
+
await Promise.all([...uploads].map(async ([uploadId, job]) => {
|
|
79
|
+
if (job.expiresAt > now || !removeUpload(uploadId, job)) return;
|
|
80
|
+
try { await fsp.unlink(job.stagingPath); } catch { /* 暂存文件可能已由磁盘清扫移除 */ }
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
50
84
|
/** 收件根目录。每次取用都重算,好让测试用环境变量分派。 */
|
|
51
85
|
function inboxRoot() {
|
|
52
86
|
const override = process.env.DSH_MOBILE_INBOX;
|
|
@@ -129,6 +163,7 @@ async function handleUploadBegin(_ctx, req, res) {
|
|
|
129
163
|
return fail(res, 500, 'inbox-unavailable', `收件目录不可用:${error.message}`);
|
|
130
164
|
}
|
|
131
165
|
void sweepStaging(stagingDir);
|
|
166
|
+
void sweepExpiredUploads();
|
|
132
167
|
|
|
133
168
|
const uploadId = crypto.randomUUID();
|
|
134
169
|
const stagingPath = path.join(stagingDir, `${uploadId}.part`);
|
|
@@ -137,12 +172,16 @@ async function handleUploadBegin(_ctx, req, res) {
|
|
|
137
172
|
} catch (error) {
|
|
138
173
|
return fail(res, 500, 'inbox-unavailable', `无法建立暂存文件:${error.message}`);
|
|
139
174
|
}
|
|
140
|
-
|
|
175
|
+
const job = {
|
|
141
176
|
name: sanitizeName(body.name),
|
|
142
177
|
stagingPath,
|
|
143
178
|
received: 0,
|
|
144
179
|
bytes: 0,
|
|
145
|
-
|
|
180
|
+
expiresAt: Date.now() + STAGING_TTL_MS,
|
|
181
|
+
expiryTimer: null,
|
|
182
|
+
};
|
|
183
|
+
uploads.set(uploadId, job);
|
|
184
|
+
scheduleUploadExpiry(uploadId, job);
|
|
146
185
|
sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES });
|
|
147
186
|
}
|
|
148
187
|
|
|
@@ -169,11 +208,13 @@ async function handleUploadChunk(_ctx, req, res) {
|
|
|
169
208
|
try {
|
|
170
209
|
await fsp.appendFile(job.stagingPath, chunk);
|
|
171
210
|
} catch (error) {
|
|
172
|
-
|
|
211
|
+
removeUpload(String(body.uploadId), job);
|
|
173
212
|
return fail(res, 500, 'inbox-unavailable', `写入暂存文件失败:${error.message}`);
|
|
174
213
|
}
|
|
175
214
|
job.received += 1;
|
|
176
215
|
job.bytes += chunk.length;
|
|
216
|
+
job.expiresAt = Date.now() + STAGING_TTL_MS;
|
|
217
|
+
scheduleUploadExpiry(String(body.uploadId), job);
|
|
177
218
|
sendJson(res, 200, { ok: true, received: job.received });
|
|
178
219
|
}
|
|
179
220
|
|
|
@@ -183,7 +224,7 @@ async function handleUploadFinish(_ctx, req, res) {
|
|
|
183
224
|
const uploadId = String((body && body.uploadId) || '');
|
|
184
225
|
const job = uploads.get(uploadId);
|
|
185
226
|
if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
186
|
-
|
|
227
|
+
removeUpload(uploadId, job);
|
|
187
228
|
|
|
188
229
|
const dir = path.join(inboxRoot(), today());
|
|
189
230
|
try {
|
|
@@ -203,7 +244,7 @@ async function handleUploadAbort(_ctx, req, res) {
|
|
|
203
244
|
const uploadId = String((body && body.uploadId) || '');
|
|
204
245
|
const job = uploads.get(uploadId);
|
|
205
246
|
if (job) {
|
|
206
|
-
|
|
247
|
+
removeUpload(uploadId, job);
|
|
207
248
|
try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
|
|
208
249
|
}
|
|
209
250
|
sendJson(res, 200, { ok: true });
|
|
@@ -216,5 +257,6 @@ module.exports = {
|
|
|
216
257
|
handleUploadAbort,
|
|
217
258
|
sanitizeName,
|
|
218
259
|
inboxRoot,
|
|
260
|
+
sweepExpiredUploads,
|
|
219
261
|
CHUNK_BYTES,
|
|
220
262
|
};
|