dsh-mobile-plugin 0.1.77 → 0.1.82

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 CHANGED
@@ -74,7 +74,16 @@ pnpm add dsh-mobile-plugin@<版本>
74
74
 
75
75
  新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
76
76
 
77
- 发布 OTA 完整包时,在插件仓库根目录执行 `npm pack`,再按中转服务器的部署说明上传到 `dsh-hub.wnluo.com`。
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', summary?, shadowedItemCount?, shadowedTokenCount? };
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
- const existing = compactionAt.get(item.id);
336
- if (existing !== undefined) out[existing] = item;
337
- else {
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
- if (!compactionAt.has(e.data.compactionId)) {
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、compaction/end 等其余类型照旧忽略(compaction 行已由 start 发出)。
556
+ // tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
490
557
  }
491
558
  // 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
492
559
  out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
@@ -128,11 +128,41 @@ function dropUnexpectedArgs(args, message) {
128
128
  return removed ? next : null;
129
129
  }
130
130
 
131
+ /**
132
+ * 调用宿主时一并交出「本机操作者」身份。
133
+ *
134
+ * 0.2.1 起网关在派发前先 `assertPeerLive(request.peer)`:Peer 缺了就对 undefined 读
135
+ * `.signal`,抛 `Cannot read properties of undefined (reading 'signal')`,这里把它收敛成
136
+ * `host-call-failed` —— 手机上表现为 /sessions、/workspaces 一律 400,会话镜像永远
137
+ * 「未推送」(只有本机自算的 /status 还活着)。0.2.0 及更早没有这道检查(网关自己回落
138
+ * operatorPeer),也没有 `browserPeer` 这个成员,所以取不到就**不带**,行为与从前一致。
139
+ *
140
+ * 用 `ctx.get` 而不是 inject:旧宿主没这个成员、极简 profile 也可能没有这个服务,
141
+ * 注入一个不存在的服务会让整行永远 pending(见 impl.js 里 apiProxy 那段教训)。
142
+ */
143
+ function peerOf(ctx) {
144
+ try {
145
+ const connection = ctx.get && ctx.get('connection');
146
+ const peer = connection && connection.browserPeer;
147
+ return peer && typeof peer === 'object' ? peer : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /** 补齐到「当前宿主认得」的请求形状:新旧宿主的差别只在这一个函数里判定。 */
154
+ function hostRequest(ctx, namespace, method, args, signal) {
155
+ const request = { namespace, method, args };
156
+ if (signal) request.signal = signal;
157
+ const peer = peerOf(ctx);
158
+ if (peer) request.peer = peer;
159
+ return request;
160
+ }
161
+
131
162
  async function invoke(ctx, namespace, method, args, signal) {
132
163
  const gateway = gatewayOf(ctx);
133
164
  if (!gateway) return { error: { ...UNAVAILABLE } };
134
- const request = { namespace, method, args };
135
- if (signal) request.signal = signal;
165
+ const request = hostRequest(ctx, namespace, method, args, signal);
136
166
  try {
137
167
  return { value: await gateway.invoke(request) };
138
168
  } catch (error) {
@@ -154,9 +184,7 @@ async function invoke(ctx, namespace, method, args, signal) {
154
184
  async function openStream(ctx, namespace, method, args, signal) {
155
185
  const gateway = gatewayOf(ctx);
156
186
  if (!gateway) throw Object.assign(new Error(UNAVAILABLE.message), { code: UNAVAILABLE.code });
157
- const request = { namespace, method, args };
158
- if (signal) request.signal = signal;
159
- return gateway.stream(request);
187
+ return gateway.stream(hostRequest(ctx, namespace, method, args, signal));
160
188
  }
161
189
 
162
190
  async function invokeEnvelope(ctx, namespace, method, args, signal) {
@@ -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
+ };
@@ -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
- uploads.set(uploadId, {
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
- uploads.delete(String(body.uploadId));
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
- uploads.delete(uploadId);
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
- uploads.delete(uploadId);
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
  };