dsh-mobile-plugin 0.5.22 → 0.5.23

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.
@@ -48,6 +48,8 @@ function turnOf(ctx, sessionId) {
48
48
  /** 还没发出去的那几步(`drainLiveSteps` 取走)。 */
49
49
  pending: [],
50
50
  ended: false,
51
+ /** 这份副本属于哪一轮(宿主 turn 号):换轮就整份作废,见 `turnForEvent`。 */
52
+ turnNo: null,
51
53
  };
52
54
  bucket.delete(sessionId);
53
55
  bucket.set(sessionId, turn);
@@ -57,6 +59,28 @@ function turnOf(ctx, sessionId) {
57
59
  return turn;
58
60
  }
59
61
 
62
+ /**
63
+ * 把一条事件归到它所属的那一轮;**轮号一变就整份作废**。
64
+ *
65
+ * 为什么要靠事件自带的 `data.turn` 兜这一手:`noteLiveEvent` 只在这条流上收到 `turn/start`
66
+ * 时清桶,可断线重连后那一轮的 `turn/start` 已经在重连之前发生过、被当作「回放已覆盖」丢掉,
67
+ * 于是新的一轮的步骤会接着上一轮往后长,越积越多、最后全挂在最新那条消息下。每一帧增量
68
+ * 都带着它属于哪个 turn,用它来判就不会漏:turn 号变了就把整轮副本换掉(下一轮的步骤从空起)。
69
+ * 同一轮内(turn 号相同)原样沿用。
70
+ */
71
+ function turnForEvent(ctx, sessionId, data) {
72
+ const bucket = bucketOf(ctx);
73
+ const explicitTurn = data && typeof data.turn === 'number' ? data.turn : null;
74
+ let turn = bucket.get(sessionId);
75
+ if (turn && explicitTurn !== null && turn.turnNo !== null && turn.turnNo !== explicitTurn) {
76
+ bucket.delete(sessionId);
77
+ turn = undefined;
78
+ }
79
+ if (!turn) turn = turnOf(ctx, sessionId);
80
+ if (turn.turnNo === null && explicitTurn !== null) turn.turnNo = explicitTurn;
81
+ return turn;
82
+ }
83
+
60
84
  /** 把一段文本接到已有的那段后面,超过上限就只留尾巴。 */
61
85
  function appendText(prev, next) {
62
86
  if (typeof next !== 'string' || next === '') return prev || '';
@@ -160,11 +184,12 @@ function noteLiveEvent(ctx, sessionId, event) {
160
184
  if (event.type === 'turn/start') {
161
185
  // 新的一轮开始:上一轮的过程副本作废(换掉了,就别再让人点开旧内容)。
162
186
  bucketOf(ctx).delete(sessionId);
163
- turnOf(ctx, sessionId);
187
+ const started = turnOf(ctx, sessionId);
188
+ if (data && typeof data.turn === 'number') started.turnNo = data.turn;
164
189
  return;
165
190
  }
166
191
  if (!data) return;
167
- const turn = turnOf(ctx, sessionId);
192
+ const turn = turnForEvent(ctx, sessionId, data);
168
193
  if (event.type === 'assistant/message') {
169
194
  // 一步落定:这一步的思考按块序号收尾(后面那一步的块序号从 0 重新数)。
170
195
  closeThoughtsOfStep(turn);
@@ -303,6 +328,13 @@ function liveStepsOf(ctx, sessionId) {
303
328
  return turn.steps.map((entry) => ({ key: entry.key, step: { ...entry.step } }));
304
329
  }
305
330
 
331
+ /** 这份直播清单属于哪一轮(宿主 turn 号)——随 `session/live-steps` 帧下发,App 据此按轮隔离。 */
332
+ function liveStepsTurnOf(ctx, sessionId) {
333
+ if (!ctx || typeof sessionId !== 'string' || !sessionId) return null;
334
+ const turn = liveStepTurnOf(ctx).get(sessionId);
335
+ return turn && typeof turn.turnNo === 'number' ? turn.turnNo : null;
336
+ }
337
+
306
338
  /**
307
339
  * 取走「还没告诉过手机」的那几步。
308
340
  *
@@ -354,4 +386,4 @@ function clearLiveSteps(ctx, sessionId) {
354
386
  liveStepTurnOf(ctx).delete(sessionId);
355
387
  }
356
388
 
357
- module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, drainLiveSteps, clearLiveSteps };
389
+ module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, liveStepsTurnOf, drainLiveSteps, clearLiveSteps };
@@ -18,6 +18,8 @@
18
18
  * 保守取舍——宁可保留字面 markdown,也不给半截东西:
19
19
  * - 本机路径认三种:绝对(`/x/y.png`、`file:///x/y.png`)、`~/y.png`、相对(`deliverables/a.png`,
20
20
  * 按调用方给的工作区目录解析,与桌面端同口径;没给目录就当成不是本机图);http(s)、`data:` 不碰;
21
+ * - 双分隔符打头的网页地址(协议相对 `//host/…`)与 Windows 网络 / 扩展路径(`\\host\…`、`\\?\…`)
22
+ * 不碰:它们不是本机文件,在 Windows 上会被当成 UNC 去 stat、一读就卡几十秒;
21
23
  * - 只认 png/jpg/jpeg/webp/gif,并核对魔术字节,不按扩展名撒谎;
22
24
  * - 宽高只读文件头(最多 64KB);读不出来就不带宽高,App 那边照旧现量;
23
25
  * - 围栏代码块与行内代码里的图片语法不碰,那是写法示例。
@@ -85,7 +87,9 @@ function localImagePathOf(url, cwd) {
85
87
  let value = url.trim();
86
88
  if (!value) return null;
87
89
  if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
88
- if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
90
+ // 盘符路径(`C:\…`、`C:/…`)不是 URL 协议:单字母后跟分隔符的别当成 scheme 拦掉。
91
+ const hasUrlScheme = /^[a-z][a-z0-9+.-]*:/i.test(value) && !/^[a-z]:[\\/]/i.test(value);
92
+ if (hasUrlScheme) {
89
93
  if (value.slice(0, 7).toLowerCase() !== 'file://') return null;
90
94
  try {
91
95
  value = decodeURIComponent(value.slice(7));
@@ -96,6 +100,10 @@ function localImagePathOf(url, cwd) {
96
100
  } else if (value === '~' || value.startsWith('~/')) {
97
101
  value = path.join(os.homedir(), value.slice(1));
98
102
  }
103
+ // 双分隔符打头的是网页地址(协议相对 `//host/…`)或 Windows 网络 / 扩展路径(`\\host\…`、
104
+ // `\\?\…`)。它们不是本机文件,在 Windows 上会被当成 UNC 去 stat、一读就卡几十秒;
105
+ // `file://` 的远程主机经上面剥完也可能落到这里,同样不认。
106
+ if (/^[\\/]{2}/.test(value)) return null;
99
107
  if (!path.isAbsolute(value)) {
100
108
  if (!cwd) return null;
101
109
  value = path.resolve(cwd, value);
@@ -273,19 +281,154 @@ async function registerLocalImage(filePath) {
273
281
  const size = sane ? parsed : {};
274
282
  const entry = { filePath, mediaType, ...size, ...(over ? { tooLarge: true } : {}) };
275
283
  registry.set(id, entry);
276
- while (registry.size > MAX_REGISTRY_ENTRIES) registry.delete(registry.keys().next().value);
284
+ trimRegistry();
277
285
  return { id, ...entry };
278
286
  }
279
287
 
288
+ /** 登记表满了就淘汰最老的一条;取回来的临时文件一并删掉,别在地上留垃圾。 */
289
+ function trimRegistry() {
290
+ while (registry.size > MAX_REGISTRY_ENTRIES) {
291
+ const oldestId = registry.keys().next().value;
292
+ const entry = registry.get(oldestId);
293
+ registry.delete(oldestId);
294
+ if (entry && entry.fetched && entry.filePath) fs.unlink(entry.filePath).catch(() => {});
295
+ }
296
+ }
297
+
280
298
  /** 取图口用:只认登记表里的编号(构造不出来的编号一律 null → 404)。 */
281
299
  function localImageOf(id) {
282
300
  if (typeof id !== 'string' || !id) return null;
283
301
  return registry.get(id) || null;
284
302
  }
285
303
 
286
- /** 清空登记表(测试用)。 */
304
+ /** 清空登记表(测试用)。取回来的临时文件一并删掉。 */
287
305
  function clearLocalImages() {
306
+ for (const entry of registry.values()) {
307
+ if (entry && entry.fetched && entry.filePath) fs.unlink(entry.filePath).catch(() => {});
308
+ }
288
309
  registry.clear();
310
+ loopbackUrlCache.clear();
311
+ }
312
+
313
+ /**
314
+ * 回环地址的主机名:出现在正文里时指的是「跑插件这台电脑自己」,手机拿着同一个地址
315
+ * 只会指回手机自己。生图插件常把成品图挂在自己电脑上的一个小服务上,回的就是这种地址。
316
+ */
317
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1', '::']);
318
+
319
+ /** 取回回环图片的超时:只在投影一条消息时占这一小会儿,取不到就保持字面 markdown。 */
320
+ const LOOPBACK_FETCH_TIMEOUT_MS = 4000;
321
+ /** 失败后多久内不再重试同一地址:翻历史碰上已经关掉的服务,不该每次都干等一次超时。 */
322
+ const LOOPBACK_FAIL_TTL_MS = 30 * 1000;
323
+ /** 同一地址取到过的图片编号;翻历史时直接复用,不必重复取。 */
324
+ const loopbackUrlCache = new Map();
325
+
326
+ /**
327
+ * `![](…)` 里的地址若是**回环地址的 http(s) 图片**,回那条地址;否则 null。
328
+ *
329
+ * 只认回环:别的远程地址一律不碰 —— 那会把「本机图片」变成「替手机随便抓任意网址」,
330
+ * 能力面就从「读这台电脑上的文件」扩成了代理一切出站请求。回环地址连不到外网,
331
+ * 只是这台电脑自己起的服务,与「读本机文件」同一类。
332
+ */
333
+ function loopbackImageUrlOf(url) {
334
+ if (typeof url !== 'string') return null;
335
+ let value = url.trim();
336
+ if (!value) return null;
337
+ if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
338
+ if (!/^https?:\/\//i.test(value)) return null;
339
+ let parsed;
340
+ try {
341
+ parsed = new URL(value);
342
+ } catch {
343
+ return null;
344
+ }
345
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
346
+ return LOOPBACK_HOSTNAMES.has(hostname) ? value : null;
347
+ }
348
+
349
+ /**
350
+ * 取回的图片字节落成临时文件再登记:取图口按编号读 `filePath`,与磁盘上的图同一条路。
351
+ *
352
+ * 编号取内容哈希:同一张图反复出现只落一份。超过单张上限的照样登记、但标 `tooLarge`
353
+ * (取图口回 413,App 画「图片太大」占位),与磁盘图片那条口径一致。
354
+ */
355
+ async function registerLocalImageBytes(buffer, mediaType) {
356
+ if (!buffer || buffer.length === 0) return null;
357
+ const id = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 16);
358
+ const known = registry.get(id);
359
+ if (known) return { id, ...known };
360
+ const over = buffer.length > MAX_IMAGE_BYTES;
361
+ const parsed = over ? null : imageSizeOf(buffer);
362
+ const sane = parsed && parsed.width > 0 && parsed.height > 0
363
+ && parsed.width <= 30000 && parsed.height <= 30000;
364
+ const size = sane ? parsed : {};
365
+ if (over) {
366
+ const entry = { filePath: '', mediaType, ...size, tooLarge: true, fetched: true };
367
+ registry.set(id, entry);
368
+ trimRegistry();
369
+ return { id, ...entry };
370
+ }
371
+ const ext = mediaType === 'image/jpeg' ? '.jpg'
372
+ : mediaType === 'image/gif' ? '.gif'
373
+ : mediaType === 'image/webp' ? '.webp' : '.png';
374
+ let filePath;
375
+ try {
376
+ const dir = path.join(os.tmpdir(), 'dsh-local-images');
377
+ await fs.mkdir(dir, { recursive: true });
378
+ filePath = path.join(dir, `${id}${ext}`);
379
+ await fs.writeFile(filePath, buffer);
380
+ } catch {
381
+ return null;
382
+ }
383
+ const entry = { filePath, mediaType, ...size, fetched: true };
384
+ registry.set(id, entry);
385
+ trimRegistry();
386
+ return { id, ...entry };
387
+ }
388
+
389
+ /**
390
+ * 从回环地址取回一张图片:有超时、有大小上限、必须过魔术字节。
391
+ *
392
+ * 取不到 / 不是图 / 太大 / 重定向去别处,一律返回 null,调用方保持字面 markdown ——
393
+ * 宁可显示成字面文本,也不能让一段历史或一帧 SSE 因为一张图取不到而挂掉(与现有口径一致)。
394
+ * 只走一次 GET、不跟随重定向:跟去别处就不再是「回环图片」了。
395
+ */
396
+ async function fetchLoopbackImage(url) {
397
+ if (typeof fetch !== 'function') return null;
398
+ const hit = loopbackUrlCache.get(url);
399
+ if (hit) {
400
+ if (hit.id && registry.has(hit.id)) return { id: hit.id, ...registry.get(hit.id) };
401
+ if (!hit.id && hit.failedAt && Date.now() - hit.failedAt < LOOPBACK_FAIL_TTL_MS) return null;
402
+ }
403
+ const controller = new AbortController();
404
+ const timer = setTimeout(() => controller.abort(), LOOPBACK_FETCH_TIMEOUT_MS);
405
+ let buffer;
406
+ try {
407
+ const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
408
+ if (!res.ok) throw new Error(`http ${res.status}`);
409
+ const declared = Number(res.headers.get('content-length'));
410
+ if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) throw new Error('too large');
411
+ buffer = Buffer.from(await res.arrayBuffer());
412
+ } catch {
413
+ loopbackUrlCache.set(url, { failedAt: Date.now() });
414
+ return null;
415
+ } finally {
416
+ clearTimeout(timer);
417
+ }
418
+ const mediaType = buffer && buffer.length > 0 && buffer.length <= MAX_IMAGE_BYTES
419
+ ? sniffMediaType(buffer)
420
+ : null;
421
+ if (!mediaType) {
422
+ loopbackUrlCache.set(url, { failedAt: Date.now() });
423
+ return null;
424
+ }
425
+ const entry = await registerLocalImageBytes(buffer, mediaType);
426
+ if (!entry) {
427
+ loopbackUrlCache.set(url, { failedAt: Date.now() });
428
+ return null;
429
+ }
430
+ loopbackUrlCache.set(url, { id: entry.id });
431
+ return entry;
289
432
  }
290
433
  /**
291
434
  * Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
@@ -382,9 +525,17 @@ async function inlineTextImages(text, budget) {
382
525
  let cursor = 0;
383
526
  for (const m of matches) {
384
527
  if (count <= 0) break;
528
+ let entry = null;
385
529
  const filePath = localImagePathOf(m[2], pool.cwd);
386
- if (!filePath) continue;
387
- const entry = await registerLocalImage(filePath);
530
+ if (filePath) {
531
+ entry = await registerLocalImage(filePath);
532
+ } else {
533
+ // 第二种「本机图片」:生图插件把成品图挂在自己电脑的一个回环小服务上,回的是
534
+ // `http://127.0.0.1:<端口>/…`。手机拿着它只会指回手机自己,所以由电脑去取回来、
535
+ // 按编号给手机(与磁盘图片同一条路,宽高/缓存/重试全现成)。
536
+ const loopbackUrl = loopbackImageUrlOf(m[2]);
537
+ if (loopbackUrl) entry = await fetchLoopbackImage(loopbackUrl);
538
+ }
388
539
  if (!entry) continue;
389
540
  count -= 1;
390
541
  pool.remaining = count;
@@ -394,82 +545,5 @@ async function inlineTextImages(text, budget) {
394
545
  }
395
546
  return cursor === 0 ? text : out + text.slice(cursor);
396
547
  }
397
- /**
398
- * Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
399
- *
400
- * 代码块里的 `![](/tmp/a.png)` 是**写法示例**而不是要画的图。旧实现整段正则替换,
401
- * 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
402
- * 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
403
- * `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
404
- */
405
- function literalRanges(text) {
406
- const ranges = [];
407
- const lines = text.split('\n');
408
- const lineStarts = [];
409
- let offset = 0;
410
- for (const line of lines) {
411
- lineStarts.push(offset);
412
- offset += line.length + 1;
413
- }
414
- let fence = null;
415
- let fenceStart = -1;
416
- for (let index = 0; index < lines.length; index += 1) {
417
- const line = lines[index];
418
- if (fence) {
419
- const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
420
- if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
421
- ranges.push([fenceStart, lineStarts[index] + line.length]);
422
- fence = null;
423
- }
424
- continue;
425
- }
426
- const open = /^(`{3,}|~{3,})/.exec(line.trim());
427
- if (!open) continue;
428
- fence = { char: open[1][0], length: open[1].length };
429
- fenceStart = lineStarts[index];
430
- }
431
- // 没闭合的围栏按 App 的解析口径一直吃到文末。
432
- if (fence) ranges.push([fenceStart, text.length]);
433
-
434
- // 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
435
- let cursor = 0;
436
- while (cursor < text.length) {
437
- if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
438
- cursor += 1;
439
- continue;
440
- }
441
- let ticks = 1;
442
- while (text[cursor + ticks] === '`') ticks += 1;
443
- let scan = cursor + ticks;
444
- let closed = -1;
445
- while (scan < text.length) {
446
- if (text[scan] !== '`' || inRanges(scan, ranges)) {
447
- scan += 1;
448
- continue;
449
- }
450
- let run = 1;
451
- while (text[scan + run] === '`') run += 1;
452
- if (run === ticks) {
453
- closed = scan + run;
454
- break;
455
- }
456
- scan += run;
457
- }
458
- if (closed < 0) {
459
- cursor += ticks;
460
- continue;
461
- }
462
- ranges.push([cursor, closed]);
463
- cursor = closed;
464
- }
465
- return ranges;
466
- }
467
-
468
- function inRanges(index, ranges) {
469
- for (const [start, end] of ranges) {
470
- if (index >= start && index < end) return true;
471
- }
472
- return false;
473
- }
474
548
 
475
549
  module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages, MAX_IMAGE_BYTES };
@@ -37,7 +37,7 @@ const { callSessions } = require('./rpc.js');
37
37
  const { unwrap } = require('./envelope.js');
38
38
  const { normalizeJobs } = require('./jobs.js');
39
39
  const { cutEvent, createToolNameIndex } = require('./wire-cut.js');
40
- const { noteLiveEvent, drainLiveSteps, liveStepsOf } = require('./live-steps.js');
40
+ const { noteLiveEvent, drainLiveSteps, liveStepsOf, liveStepsTurnOf } = require('./live-steps.js');
41
41
  const approvals = require('./approvals.js');
42
42
  const questions = require('./questions.js');
43
43
  const streamRegistry = require('./stream-registry.js');
@@ -491,9 +491,9 @@ async function handleStream(ctx, req, res) {
491
491
  * 按 `key` 替换。**不带 seq**:它不是会话事件,不参与序门与水位(口径同 `session/queue`)。
492
492
  * 一行摘要由电脑算好 —— 推理全文与工具参数都不上线路(见 wire-cut.js)。
493
493
  */
494
- const writeLiveSteps = (steps, full, frameArg) => {
494
+ const writeLiveSteps = (steps, full, frameArg, turn) => {
495
495
  if (!Array.isArray(steps)) return;
496
- writeSse(res, { type: 'session/live-steps', sessionId, full, steps }, frameArg, { sealFrame });
496
+ writeSse(res, { type: 'session/live-steps', sessionId, full, turn: typeof turn === 'number' ? turn : null, steps }, frameArg, { sealFrame });
497
497
  };
498
498
 
499
499
  try {
@@ -521,8 +521,9 @@ async function handleStream(ctx, req, res) {
521
521
  // 开流先补一份过程清单:这条流从此刻起只发「变化的那几步」,之前跑过的那些靠这份快照
522
522
  // 铺出来(中途进会话 / 重连都走这里)。**一步都没有时就一个字都不发** —— 空快照只在
523
523
  // 告诉手机「你手上那份作废」,而那种情况由新的一轮开始时清(App 侧 `resetLiveSteps`)。
524
+ const openingTurn = liveStepsTurnOf(ctx, sessionId);
524
525
  const opening = liveStepsOf(ctx, sessionId);
525
- if (!clientGone && opening.length > 0) writeLiveSteps(opening, true, null);
526
+ if (!clientGone && opening.length > 0) writeLiveSteps(opening, true, null, openingTurn);
526
527
 
527
528
  if (!clientGone) {
528
529
  // 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
@@ -566,8 +567,10 @@ async function handleStream(ctx, req, res) {
566
567
  noteLiveEvent(ctx, sessionId, event);
567
568
  // 过程清单的增量:这一步刚开、或某一步的状态变了,把变化的那几行发出去。
568
569
  // 排在事件本身之前发顺序不影响判断(清单是「到此刻为止」的旁白,不是内容)。
570
+ // 带上这一轮的行号:App 按轮隔离,换轮就整份换掉(漏掉 turn/start 也不会串轮)。
571
+ const liveTurn = liveStepsTurnOf(ctx, sessionId);
569
572
  for (const changed of drainLiveSteps(ctx, sessionId)) {
570
- const value = { type: 'session/live-steps', sessionId, full: false, steps: [changed] };
573
+ const value = { type: 'session/live-steps', sessionId, full: false, turn: liveTurn, steps: [changed] };
571
574
  if (await pushOrdered(value, null) === 'closed') { clientGone = true; break; }
572
575
  }
573
576
  if (clientGone) break;
@@ -151,7 +151,11 @@ function createTailcatHost(options) {
151
151
  if (typeof value.id !== 'string' || value.id.length > 32) return;
152
152
  if (!Array.isArray(value.allowedClients)) return;
153
153
  } else if (kind === 'error') {
154
- if (typeof value.code !== 'string' || value.code.length > 64) return;
154
+ if (typeof value.code !== 'string') return;
155
+ // 助手的失败原因可能是底层网络 / 证书报错这类长串(例如取 DERP 地图时 x509 证书校验失败,
156
+ // 见 A3-87 的坑)。早先按「超过 64 字就丢」直接吞掉,界面于是永远停在「正在启动」、
157
+ // 看不到真正原因。这里只去掉控制字符、截断到上限,然后如实交给上层展示。
158
+ value.code = value.code.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 512) || 'error';
155
159
  ready = false;
156
160
  }
157
161
  options.onEvent({ ...value });
@@ -207,6 +207,41 @@ function installedVersion(profileNodeModules, name = DEFAULT_NAME) {
207
207
  }
208
208
  }
209
209
 
210
+ /**
211
+ * 这次装完后,插件「按当前平台装上的」可选依赖(例如 tailcat 平台包)各自的实际版本。
212
+ *
213
+ * 它们和插件本体一样可能刚发布就被装进来,登记排除清单时要一并写 —— 否则这些包自己的
214
+ * 锁条目会把后续的安装挡在 24 小时冷却窗口里(漏的正是刚发的那几个平台包)。只统计真的
215
+ * 落在 node_modules 里的那些:os / cpu 不匹配被包管理器跳过的平台包不登记。
216
+ *
217
+ * 读不到插件清单或读不到某个依赖都只跳过、不抛 —— 登记是善后,不该把装好的版本说成没装上。
218
+ */
219
+ function installedOptionalDeps(profileNodeModules, name = DEFAULT_NAME) {
220
+ const readVersion = (dir) => {
221
+ try {
222
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
223
+ return typeof raw.version === 'string' && raw.version ? raw.version : null;
224
+ } catch {
225
+ return null;
226
+ }
227
+ };
228
+ const out = [];
229
+ let optional;
230
+ try {
231
+ optional = JSON.parse(fs.readFileSync(path.join(profileNodeModules, name, 'package.json'), 'utf8')).optionalDependencies;
232
+ } catch {
233
+ return out;
234
+ }
235
+ if (!optional || typeof optional !== 'object') return out;
236
+ for (const depName of Object.keys(optional)) {
237
+ // hoisted 布局在 profile/node_modules 顶层;严格布局下在插件自己的 node_modules 里。
238
+ const version = readVersion(path.join(profileNodeModules, depName))
239
+ || readVersion(path.join(profileNodeModules, name, 'node_modules', depName));
240
+ if (version) out.push({ name: depName, version });
241
+ }
242
+ return out;
243
+ }
244
+
210
245
  /**
211
246
  * profile 的依赖声明里这个包是怎么装的(`profileDir` 是 profile 根,不是它的 node_modules)。
212
247
  *
@@ -719,6 +754,13 @@ async function installLatest({
719
754
  const changed = after !== null && after !== beforeVersion;
720
755
  // 这一版是我们绕开晾置期装进去的,登记进排除清单,免得它反过来把别的安装挡 24 小时。
721
756
  const releaseAgeRecorded = changed ? recordReleaseAgeExclusion(profileDir, name, after) : false;
757
+ // 插件按平台带出来的可选依赖(如 tailcat 平台包)也是刚发就可能刚装上的,同批登记,
758
+ // 否则这些包自己的锁条目会把后续安装挡在冷却窗口里。逐条尽力写,失败不影响主结果。
759
+ if (changed) {
760
+ for (const dep of installedOptionalDeps(profileNodeModules, name)) {
761
+ recordReleaseAgeExclusion(profileDir, dep.name, dep.version);
762
+ }
763
+ }
722
764
  return {
723
765
  installed: changed,
724
766
  skipped: false,
@@ -845,6 +887,7 @@ module.exports = {
845
887
  installLatest,
846
888
  installLatestIfNeeded,
847
889
  installedVersion,
890
+ installedOptionalDeps,
848
891
  declaredSource,
849
892
  // 「Windows 上怎么起包管理器」在本机跑不起来,守卫只能验拼好的那一行,所以这两样露出来。
850
893
  cmdInvocation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.22",
3
+ "version": "0.5.23",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",