dsh-mobile-plugin 0.5.9 → 0.5.11

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.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * 在终端里把配对二维码打出来(`dsh-mobile-plugin-pair`)。
6
+ *
7
+ * 为什么需要它:二维码那条路由只在本机屏幕或者两小时窗口里给码,而**服务器部署**上
8
+ * 「本机屏幕」这个概念是空的 —— 那台机器的设置页往往开不了,人也够不到它的回环地址。
9
+ * 能在服务器终端里敲命令的人本来就有这台机器的权限,把码打给他,信任量级与「在本机屏幕
10
+ * 上看码」相同,也不新增任何对外的口子。
11
+ *
12
+ * 载荷与面板同一份(`../relay/pairing-payload.cjs`),所以打出来的码一定和面板里那张能
13
+ * 配上同一台机器;这里只负责画图与说人话,不碰配对流程。
14
+ *
15
+ * 用法:
16
+ * dsh-mobile-plugin-pair 在终端里打出二维码
17
+ * dsh-mobile-plugin-pair --svg qr.svg 另外存一份 SVG(终端太窄、想发给别人时用)
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const QRCode = require('qrcode');
23
+ const core = require('../relay/core.js');
24
+ const { pairingPayload } = require('../relay/pairing-payload.cjs');
25
+
26
+ const USAGE = `用法:
27
+ dsh-mobile-plugin-pair 在终端里打印配对二维码
28
+ dsh-mobile-plugin-pair --svg <路径> 同时把二维码存成 SVG 图片`;
29
+
30
+ function fail(message) {
31
+ process.stderr.write(`${message}\n`);
32
+ process.exitCode = 1;
33
+ }
34
+
35
+ function parseArgs(argv) {
36
+ const out = { svg: '' };
37
+ for (let index = 0; index < argv.length; index += 1) {
38
+ const arg = argv[index];
39
+ if (arg === '--svg') {
40
+ const next = argv[index + 1];
41
+ if (!next || next.startsWith('-')) return { error: '--svg 后面要跟一个文件路径' };
42
+ out.svg = next;
43
+ index += 1;
44
+ continue;
45
+ }
46
+ if (arg === '--help' || arg === '-h') return { help: true };
47
+ return { error: `不认识的参数:${arg}` };
48
+ }
49
+ return out;
50
+ }
51
+
52
+ async function main(argv) {
53
+ const args = parseArgs(argv);
54
+ if (args.help) {
55
+ process.stdout.write(`${USAGE}\n`);
56
+ return;
57
+ }
58
+ if (args.error) {
59
+ fail(`${args.error}\n\n${USAGE}`);
60
+ return;
61
+ }
62
+
63
+ // 状态文件是宿主与这里唯一的交接口:宿主每次换码换票据都会落盘,所以读到的那份就是
64
+ // 跑着的宿主手上那份。文件不存在时**不替它建**(`loadState()` 会建),直接说清楚。
65
+ if (!fs.existsSync(core.STATE_FILE)) {
66
+ fail(`这台机器上还没跑过这个插件(找不到 ${core.STATE_FILE})。先启动 DSH,并在移动端插件里启用远程控制。`);
67
+ return;
68
+ }
69
+ const state = core.loadState();
70
+ if (!state.enabled) {
71
+ fail('远程控制还没启用:先在 DSH 的移动端设置页(或面板)里打开远程控制并保存配置。');
72
+ return;
73
+ }
74
+ const { payload, text } = pairingPayload(state, state.lanPort);
75
+ if (!payload.relayUrl && payload.lanUrls.length === 0) {
76
+ fail('没有可用的中转或直连地址:先在面板上配好中转地址,或打开局域网直连,再回来打码。');
77
+ return;
78
+ }
79
+
80
+ const lines = [];
81
+ lines.push(`宿主:${payload.name}`);
82
+ lines.push(`配对码:${payload.code}`);
83
+ lines.push(`中转地址:${payload.relayUrl || '(没配)'}`);
84
+ if (payload.lanUrls.length > 0) {
85
+ lines.push(`直连地址:${payload.lanUrls.join(' ')}`);
86
+ lines.push(`局域网端口:${state.lanPort}`);
87
+ }
88
+ process.stdout.write(`${lines.join('\n')}\n\n`);
89
+
90
+ if (args.svg) {
91
+ const target = path.resolve(args.svg);
92
+ fs.mkdirSync(path.dirname(target), { recursive: true });
93
+ fs.writeFileSync(target, await QRCode.toString(text, { type: 'svg', errorCorrectionLevel: 'M', margin: 2, width: 480 }));
94
+ process.stdout.write(`二维码已存成图片:${target}\n如果终端太窄扫不动,把这个文件拿下来用看图软件打开再扫。\n\n`);
95
+ }
96
+
97
+ // small: true 让一个字符带两行模块,绝大多数终端一行装得下;扫不动就加 --svg。
98
+ process.stdout.write(`${await QRCode.toString(text, { type: 'terminal', small: true, errorCorrectionLevel: 'M' })}\n`);
99
+ process.stdout.write('用 DSH App 扫这张码即可配对。它等于一次配对权限,别截图发给别人;配完可以在面板上点「重新生成配对码」。\n');
100
+ }
101
+
102
+ main(process.argv.slice(2)).catch((error) => {
103
+ fail(`打码失败:${error && error.message ? error.message : String(error)}`);
104
+ });
package/lib/client.js CHANGED
@@ -482,6 +482,16 @@ window.__ModuleLoader__.load({
482
482
  hostHalfRetrying: '它还在后台自己重试,过几分钟刷新这一页就能看到结果;也可以点下面的按钮立刻再试一次。',
483
483
  hostHalfReload: '重新装载',
484
484
  hostHalfReloadFailed: '重新装载没成:',
485
+ hostHalfUnreachable: '宿主侧的自检口也没有回应:这一版插件可能还没带它(较老的版本)——先更新插件,再完整重启一次 DSH。',
486
+ hostHalfNoError: '宿主侧自检没报错,控制面路由却确实不在:把 DSH 完整重启一次;还不行请把这句连同插件版本发给我们。',
487
+ hostHalfVersion: '自检版本:',
488
+ diagHostReturned: '宿主返回:',
489
+ diagSelfCheck: '自检:',
490
+ diagSelfCheckNoAnswer: '没有回应(这一版可能还没带它)',
491
+ diagSelfCheckNoError: '没有报错',
492
+ diagSelfCheckRetry: '后台重试 ',
493
+ diagOnDisk: '碟上版本 ',
494
+ diagShellRestartAtStartup: '外壳改过,要重启一次 DSH',
485
495
  webAccessOn: '公网网页管理已开放,到期时间',
486
496
  webAccessOff: '公网网页管理已关闭',
487
497
  webAccessOpen: '开放网页管理 2 小时',
@@ -504,6 +514,16 @@ window.__ModuleLoader__.load({
504
514
  hostHalfRetrying: 'It is still retrying on its own in the background — reopen this item in a few minutes, or press the button below to retry right now.',
505
515
  hostHalfReload: 'Reload now',
506
516
  hostHalfReloadFailed: 'Reload failed: ',
517
+ hostHalfUnreachable: 'The host-side self-check did not answer either: this version of the plugin may not ship it yet (older build) — update the plugin, then restart DSH completely.',
518
+ hostHalfNoError: 'The host self-check reports no error, yet the control-plane routes really are missing: restart DSH completely; if that does not help, send us this line together with the plugin version.',
519
+ hostHalfVersion: 'self-check version: ',
520
+ diagHostReturned: 'Host returned: ',
521
+ diagSelfCheck: 'Self-check: ',
522
+ diagSelfCheckNoAnswer: 'no answer (this build may not ship it)',
523
+ diagSelfCheckNoError: 'no error reported',
524
+ diagSelfCheckRetry: 'background retries: ',
525
+ diagOnDisk: 'on disk: ',
526
+ diagShellRestartAtStartup: 'shell changed — DSH needs one restart',
507
527
  webAccessOn: 'Public web management is open until',
508
528
  webAccessOff: 'Public web management is closed',
509
529
  webAccessOpen: 'Open web management for 2 hours',
@@ -573,15 +593,17 @@ window.__ModuleLoader__.load({
573
593
  refreshTimer = null;
574
594
  }
575
595
  // 404 = 实现那一半没装载。它为什么没装载,只有外壳答得出来(这两条路由挂在
576
- // 外壳上,实现挂了它们还在);老版本宿主没有这条口,问不到就当没有,别报错。
596
+ // 外壳上,实现挂了它们还在);老版本宿主没有这条口,问不到也得把这个事实留住
597
+ // (`unavailable`)——「自检口都没有回应」和「自检说没事」是两种不同的现场,
598
+ // 都缩成一句「重启/重装」的话,用户只能对着空白猜(2026-11-01 现场)。
577
599
  if (problem?.status === 404) {
578
600
  try {
579
601
  const picture = await call(HOST_HALF_ENDPOINT);
580
602
  if (!alive) return;
581
- setHalf(picture && typeof picture.half === 'object' ? picture.half : null);
603
+ setHalf(picture && typeof picture.half === 'object' ? picture.half : { unavailable: true });
582
604
  } catch {
583
605
  if (!alive) return;
584
- setHalf(null);
606
+ setHalf({ unavailable: true });
585
607
  }
586
608
  } else {
587
609
  setHalf(null);
@@ -648,16 +670,59 @@ window.__ModuleLoader__.load({
648
670
 
649
671
  const missing = errorStatus === 404;
650
672
 
673
+ /**
674
+ * 诊断读数:宿主回了什么 + 外壳那份自检说了什么。
675
+ *
676
+ * 只拼进这一屏、不落盘也不上报:它的用途是让人截图发给我们(2026-11-01 用户原话:
677
+ * 「以后用户发过来 我也不好去排查」)。字段名用 `version=` / `source=` 这种记号而不是
678
+ * 翻成中文,是因为它要能贴进日志里对得上宿主那一侧。
679
+ */
680
+ const diagLines = [];
681
+ if (missing) {
682
+ diagLines.push(`${t('diagHostReturned')}${error}`);
683
+ if (half && half.unavailable === true) {
684
+ diagLines.push(`${t('diagSelfCheck')}${t('diagSelfCheckNoAnswer')}`);
685
+ } else if (half) {
686
+ const bits = [];
687
+ if (half.version) bits.push(`version=${half.version}`);
688
+ if (half.source) bits.push(`source=${half.source}`);
689
+ if (half.activatedAt) bits.push(`at=${half.activatedAt}`);
690
+ if (half.retrying) {
691
+ const attempts = Number.isFinite(half.retryAttempts) ? half.retryAttempts : 0;
692
+ bits.push(`${t('diagSelfCheckRetry')}${attempts}${half.nextRetryAt ? `→${half.nextRetryAt}` : ''}`);
693
+ }
694
+ if (half.installedVersion && half.installedVersion !== half.version) {
695
+ bits.push(`${t('diagOnDisk')}${half.installedVersion}`);
696
+ }
697
+ if (half.shellChanged) bits.push(t('diagShellRestartAtStartup'));
698
+ diagLines.push(`${t('diagSelfCheck')}${bits.length ? bits.join(' · ') : t('diagSelfCheckNoError')}`);
699
+ }
700
+ }
701
+
651
702
  return h('div', { className: 'dshm_root' }, [
652
703
  h('h2', { className: 'dshm_heading' }, t('heading')),
653
704
  error
654
705
  ? h('p', { className: 'dshm_error' }, missing ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
655
706
  : null,
707
+ // 404 那屏必须自带「能拿去排查」的读数(2026-11-01 用户要求):只写一句「宿主侧没起来」
708
+ // 的话,用户截图发过来我们还是不知道坏在哪一层,只能让他一次次试。
709
+ // 两行足够定位:宿主到底回了什么、外壳那份自检说了什么。
710
+ ...diagLines.map((line) => h('p', { className: 'dshm_note', key: line.slice(0, 24) }, line)),
656
711
  // 404 时把宿主那半边自己的说法贴出来:重启 / 重装这两句提示在「装载超时」那种
657
712
  // 现场是帮倒忙的,真正该做的是等它后台补齐、或者点一下重新装载。
658
713
  missing && half && typeof half.lastError === 'string' && half.lastError
659
714
  ? h('p', { className: 'dshm_note' }, `${t('hostHalfReason')}${half.lastError}`)
660
715
  : null,
716
+ // 问不到自检口:说明这一版插件压根没带它(比 0.5.11 老),用户这时最该做的是更新
717
+ // 插件,而不是继续重启 —— 原先这一支是一片空白,只留一句「重启/重装」。
718
+ missing && half && half.unavailable === true
719
+ ? h('p', { className: 'dshm_note' }, t('hostHalfUnreachable'))
720
+ : null,
721
+ // 自检说没事、路由却不在:这是「同一份 DSH 里跑着两份插件」那种现场,报出版本
722
+ // 才查得下去。
723
+ missing && half && half.unavailable !== true && !(typeof half.lastError === 'string' && half.lastError)
724
+ ? h('p', { className: 'dshm_note' }, `${t('hostHalfNoError')}${typeof half.version === 'string' && half.version ? `(${t('hostHalfVersion')}${half.version})` : ''}`)
725
+ : null,
661
726
  missing && half && half.retrying === true
662
727
  ? h('p', { className: 'dshm_note' }, t('hostHalfRetrying'))
663
728
  : null,
package/lib/impl.js CHANGED
@@ -28,7 +28,9 @@ const { deviceMetaFromHeaders } = require('./shared/device-headers.js')
28
28
  const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
29
29
  const { createMirrorPusher } = require('./mirror/pusher.cjs')
30
30
  const { randomBytes } = require('node:crypto')
31
- const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
31
+ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManualIpv6Candidate } = require('./relay/lan-network.cjs')
32
+ // 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
33
+ const { pairingPayload } = require('./relay/pairing-payload.cjs')
32
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
33
35
  const { createPreviewService } = require('./relay/preview.js')
34
36
  // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
@@ -93,6 +95,9 @@ export const RemoteControlSettingsSchema = z.object({
93
95
  preferredLanAddress: z.string().max(64).default(''),
94
96
  // 用户手填的直连地址(`manualLanUrl`,内网穿透域名)也随二维码一起走,见 lan-network.cjs。
95
97
  manualLanUrl: z.string().max(200).default(''),
98
+ // 用户手填的 IPv6 直连地址(`manualIpv6Url`,形如 `http://[240e::1]:18787`):与上一条并列,
99
+ // 只是拨的是 IPv6;网卡扫不到的那条固定地址靠它兜底,候选口径见 lan-network.cjs。
100
+ manualIpv6Url: z.string().max(200).default(''),
96
101
  })
97
102
 
98
103
 
@@ -111,6 +116,7 @@ function configOf(state) {
111
116
  registerSecret: typeof state.registerSecret === 'string' ? state.registerSecret : '',
112
117
  preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
113
118
  manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
119
+ manualIpv6Url: typeof state.manualIpv6Url === 'string' ? state.manualIpv6Url : '',
114
120
  }
115
121
  }
116
122
 
@@ -166,44 +172,6 @@ function createConfigStore(state) {
166
172
  }
167
173
  }
168
174
 
169
- function pairingPayload(state, lanPort) {
170
- const port = Number.isInteger(lanPort) ? lanPort : state.lanPort
171
- // 面板上选过的「手机优先用这条」在这里兑现:只挪到最前,其余继续当兜底(App 按顺序挨个试)。
172
- const candidates = state.lanEnabled
173
- ? phoneCandidates(advertisedCandidates(port), { preferred: state.preferredLanAddress, manual: state.manualLanUrl })
174
- : []
175
- const urls = candidates.map(candidate => candidate.url).filter(Boolean)
176
- const relayUrl = state.relayUrl || ''
177
- const payload = {
178
- type: 'dsh-pair',
179
- version: 1,
180
- name: state.name || os.hostname(),
181
- instanceId: state.instanceId,
182
- code: state.pairingCode,
183
- // 协议版本与一次性票据(§8.3(1))。
184
- //
185
- // `proto: 2` 由**宿主**写、也由宿主生成,中转没有任何机会改写它 —— 所以「新 App 被
186
- // 中转骗回明文」这个问题从一开始就不存在。`ticket` 是 32 字节随机数,手机拿它派生
187
- // 配对密钥;二维码是屏幕对摄像头,这条通道完全绕开中转。
188
- //
189
- // 老 App 只取自己认识的那几个字段,多出来的两个会被忽略 —— 一张二维码同时服务新旧两种。
190
- proto: 2,
191
- ticket: state.pairingTicket || '',
192
- relayUrl,
193
- lanUrls: urls,
194
- // 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
195
- // 依次重试而不用先回到公网中转去问。
196
- lanCandidates: candidates.map(candidate => candidate.url).filter(Boolean),
197
- // IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
198
- // 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
199
- ipv6Candidates: state.lanEnabled ? ipv6Candidates(port).map(candidate => candidate.url).filter(Boolean) : [],
200
- }
201
- return {
202
- payload,
203
- text: `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`,
204
- }
205
- }
206
-
207
175
  // 上报给 App 的候选地址(私网优先、没有私网才退公网)住在 `./relay/lan-network.cjs`
208
176
  // 的 `advertisedCandidates`:那是地址策略,不是装配。这里只把它转交给三处消费点。
209
177
  // 「手机上最近来过没有」同理,住在 `./mobile/activity.cjs`(守卫记、控制面状态读)。
@@ -490,7 +458,8 @@ function apply(ctx) {
490
458
  // 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
491
459
  safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
492
460
  // IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
493
- safeIpv6Candidates: ipv6Candidates,
461
+ // 手填的那条同样缀在最后(只追加、不删网卡候选),手机每次核对连接信息都重新拿到它。
462
+ safeIpv6Candidates: (port) => withManualIpv6Candidate(ipv6Candidates(port), state.manualIpv6Url),
494
463
  lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
495
464
  // 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
496
465
  // 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 宿主附件库的直读 —— 手机取图那条路少绕一圈。
5
+ *
6
+ * 为什么要有它:手机要一张图时,插件今天只能调宿主的取附件接口,拿回**整份 base64** 再
7
+ * 解成字节、分块写给手机。那一发要等宿主把整张读出来、编码完才返回,宿主主线程正忙
8
+ * (比如在派生一条几千条的会话)时,手机那边就是一个字节都没有 —— 2026-10-31 中转日志里
9
+ * 有 109 次请求整整 15 秒收不到任何字节,全部是这一条路上来的。
10
+ *
11
+ * 附件在宿主本机本来就是**按内容哈希分两级目录存的文件**(`<DSH_HOME>/attachments/v1/
12
+ * objects/<前两位>/<其余>`),插件和宿主同机同进程,直接从盘上流进隧道就行:首字节不等
13
+ * 宿主做主活,内存里也不再摊一份两兆七的 base64 字符串。
14
+ *
15
+ * **只认「我们确实告诉过手机的那几个 id」**(`remember` 在引用下发时记账)。这条不是多余的
16
+ * 防御:附着在会话里的图片 id 是我们自己下发的,反过来「拿任意一个 64 位十六进制串来读」
17
+ * 就不该成立 —— 能力面与 `session/file`、`session/local-image` 同一条口径:只读登记过的。
18
+ * 记账没命中、文件不在、不是加密会话,一律退回原来那条宿主接口,行为与今天完全一致。
19
+ *
20
+ * 记账只在内存里:插件重启后手机重连会重新拉一遍消息、重新登记;过期与上限见下面两个常量。
21
+ */
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const { dshHome } = require('../shared/paths.js');
27
+ const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
28
+
29
+ /** 一个会话最多记多少个附件 id(会话里几千张图也不该无限涨)。 */
30
+ const MAX_IDS_PER_SESSION = 4096;
31
+ /** 最多记多少个会话(滚动淘汰最久没登记的)。 */
32
+ const MAX_SESSIONS = 64;
33
+ /** 记账多久算新鲜:超过这个时间没再被引用过,就当手机手里那份已经过期了。 */
34
+ const TTL_MS = 30 * 60 * 1000;
35
+
36
+ /** sessionId -> { at, entries: Map<attachmentId, { mediaType, at }> } */
37
+ const SESSIONS = new Map();
38
+
39
+ /** 内容哈希是 64 位小写十六进制;别的形状一律不认(顺带挡住路径穿越)。 */
40
+ const OBJECT_ID = /^[0-9a-f]{64}$/;
41
+
42
+ function nowMs() {
43
+ return Date.now();
44
+ }
45
+
46
+ /**
47
+ * 附件 id → 宿主附件库里的绝对路径。
48
+ *
49
+ * 形状不对(不是 64 位小写十六进制)直接回 null:这条路径要拼进 `createReadStream`,
50
+ * 校验放在这里、而不是靠调用方记得先校验。
51
+ */
52
+ function objectPathOf(attachmentId) {
53
+ if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return null;
54
+ const root = path.join(dshHome(), 'attachments', 'v1', 'objects');
55
+ return path.join(root, attachmentId.slice(0, 2), attachmentId.slice(2));
56
+ }
57
+
58
+ /** 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。 */
59
+ function remember(sessionId, attachmentId, mediaType) {
60
+ if (typeof sessionId !== 'string' || !sessionId) return;
61
+ if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return;
62
+ const at = nowMs();
63
+ let slot = SESSIONS.get(sessionId);
64
+ if (!slot) {
65
+ slot = { at, entries: new Map() };
66
+ SESSIONS.set(sessionId, slot);
67
+ // 会话数到顶:淘汰最久没动静的那一个(Map 保持插入顺序,第一条就是最旧的)。
68
+ while (SESSIONS.size > MAX_SESSIONS) {
69
+ const oldest = SESSIONS.keys().next().value;
70
+ if (oldest === undefined) break;
71
+ SESSIONS.delete(oldest);
72
+ }
73
+ }
74
+ slot.at = at;
75
+ slot.entries.delete(attachmentId);
76
+ slot.entries.set(attachmentId, {
77
+ at,
78
+ mediaType: typeof mediaType === 'string' && mediaType ? mediaType : 'application/octet-stream',
79
+ });
80
+ while (slot.entries.size > MAX_IDS_PER_SESSION) {
81
+ const oldest = slot.entries.keys().next().value;
82
+ if (oldest === undefined) break;
83
+ slot.entries.delete(oldest);
84
+ }
85
+ }
86
+
87
+ /** 这个 id 是不是我们为这条会话下发过的;是就带上当时的媒体类型。 */
88
+ function lookup(sessionId, attachmentId, now = nowMs()) {
89
+ if (typeof sessionId !== 'string' || !sessionId) return null;
90
+ if (typeof attachmentId !== 'string' || !attachmentId) return null;
91
+ const slot = SESSIONS.get(sessionId);
92
+ if (!slot) return null;
93
+ if (now - slot.at > TTL_MS) {
94
+ SESSIONS.delete(sessionId);
95
+ return null;
96
+ }
97
+ const hit = slot.entries.get(attachmentId);
98
+ if (!hit) return null;
99
+ if (now - hit.at > TTL_MS) {
100
+ slot.entries.delete(attachmentId);
101
+ return null;
102
+ }
103
+ return { mediaType: hit.mediaType };
104
+ }
105
+
106
+ /** 打开一条只读流(调用方自己负责在连接关掉时 destroy)。 */
107
+ function openObject(attachmentId) {
108
+ const target = objectPathOf(attachmentId);
109
+ if (!target) return null;
110
+ try {
111
+ const stat = fs.statSync(target);
112
+ if (!stat.isFile() || stat.size <= 0) return null;
113
+ return { path: target, size: stat.size, stream: fs.createReadStream(target) };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 试一次「直接从盘上流给手机」:写成功返回 true,不该走或走不了返回 false。
121
+ *
122
+ * 三个前提缺一不可:这条请求带信封(协议 v2 才走分块)、这个 id 是我们为这条会话下发过的、
123
+ * 对象文件确实还在。任一不满足就交给调用方走原来那条宿主接口 —— 老插件、老会话、明文会话
124
+ * 的行为因此一个字都不变。
125
+ */
126
+ async function streamIfRemembered(req, res, sessionId, attachmentId) {
127
+ const opened = req && req.__dshE2ee;
128
+ if (!opened || !opened.keys || !opened.req) return false;
129
+ const known = lookup(sessionId, attachmentId);
130
+ if (!known) return false;
131
+ const object = openObject(attachmentId);
132
+ if (!object) return false;
133
+ res.writeHead(200, chunksHeaders({
134
+ 'x-dsh-content-type': known.mediaType,
135
+ 'x-dsh-file-size': String(object.size),
136
+ }));
137
+ res.on('close', () => object.stream.destroy());
138
+ await pipeEncrypted(req, res, object.stream);
139
+ return true;
140
+ }
141
+
142
+ /** 测试用:清空记账。 */
143
+ function reset() {
144
+ SESSIONS.clear();
145
+ }
146
+
147
+ module.exports = {
148
+ MAX_IDS_PER_SESSION,
149
+ MAX_SESSIONS,
150
+ TTL_MS,
151
+ objectPathOf,
152
+ remember,
153
+ lookup,
154
+ openObject,
155
+ streamIfRemembered,
156
+ reset,
157
+ };
@@ -19,6 +19,7 @@ const { unwrap } = require('./envelope.js');
19
19
  const { callSessions } = require('./rpc.js');
20
20
  const { cwdOf } = require('./session-cwd.js');
21
21
  const { inlineTextImages, createTextImageBudget, hasRelativeImage } = require('./local-images.js');
22
+ const attachmentStore = require('./attachment-store.js');
22
23
 
23
24
  /**
24
25
  * 取不到字节的图片块:原样留着,只加一个标记。
@@ -54,7 +55,12 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
54
55
  const attachmentId = block.attachment.attachmentId;
55
56
  if (typeof attachmentId !== 'string' || !attachmentId) return block;
56
57
  // 引用模式到此为止:块原样返回,字节由手机端按 attachmentId 单张去取。
57
- if (referencesOnly) return block;
58
+ if (referencesOnly) {
59
+ // 顺便记一笔:手机接下来就可能拿着这个 id 来取字节。记下来之后那条路可以直接从
60
+ // 宿主附件库流给它,不必再等宿主把整张图读成 base64(见 attachment-store.js)。
61
+ attachmentStore.remember(sessionId, attachmentId, block.attachment.mediaType || block.mediaType);
62
+ return block;
63
+ }
58
64
  let result;
59
65
  try {
60
66
  result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
@@ -17,6 +17,7 @@
17
17
  const { sendJson, readJsonBody } = require('../shared/http-util');
18
18
  const { unwrap } = require('./envelope.js');
19
19
  const { dropSessionModelsForDevice } = require('./session-cache.js');
20
+ const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
20
21
 
21
22
  /** 自建渠道写在这个命名空间里;桌面端 `src/custom-channels.js` 的 PI_AI_NS 同值。 */
22
23
  const PI_AI_NS = 'llm-pi-ai';
@@ -280,7 +281,8 @@ async function handleChannels(ctx, req, res) {
280
281
  });
281
282
  }
282
283
 
283
- sendJson(res, 200, { ok: true, channels });
284
+ const body = { ok: true, channels, revision: stableRevision(channels) };
285
+ sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
284
286
  }
285
287
 
286
288
  /**