dsh-mobile-plugin 0.5.6 → 0.5.9

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.
@@ -58,7 +58,21 @@ const remoteRelayRequest = (req) => Boolean(
58
58
  // Rejecting cross-site fetches and pinning each route to one method closes
59
59
  // that: a control-plane call now has to be a same-origin top-level/`same-origin`
60
60
  // request, and it has to use the documented verb.
61
- const crossSiteRequest = (req) => {
61
+ //
62
+ // 只读页面(面板页)是唯一的例外,而且只放开「人点开的一页」这一种形态:社区桌面壳
63
+ // 会把自己接住的链接原地打开,从自定义协议页跳到 `http://127.0.0.1:<端口>/api/relay/panel`
64
+ // 时 Chromium 一定给 `sec-fetch-site: cross-site`(自定义协议与 http 不同源),
65
+ // 按攻击挡掉,用户看到的就是整屏一句 403 JSON。判据换成 Fetch 元数据里的
66
+ // `mode: navigate` + `dest: document`:别的网页最多能把用户带到这一页(只读 HTML),
67
+ // 页面里偷偷发的 fetch/XHR 仍然 403。
68
+ const documentNavigation = (req) => {
69
+ const headers = req?.headers && typeof req.headers === 'object' ? req.headers : {}
70
+ return String(headers['sec-fetch-mode'] || '').toLowerCase() === 'navigate'
71
+ && String(headers['sec-fetch-dest'] || '').toLowerCase() === 'document'
72
+ }
73
+
74
+ const crossSiteRequest = (req, allowDocumentNavigation = false) => {
75
+ if (allowDocumentNavigation === true && documentNavigation(req)) return false
62
76
  if (String(req.headers['sec-fetch-site'] || '').toLowerCase() === 'cross-site') return true
63
77
  const origin = req.headers.origin
64
78
  if (typeof origin !== 'string' || !origin) return false
@@ -103,9 +117,12 @@ const trustedLocalUi = (req) => {
103
117
 
104
118
  // `method` 可以是单个方法名,也可以是方法名数组(同一个路径上挂 GET+POST,
105
119
  // 例如 /api/relay/desktop-view 既读快照又收回传)。单方法时的 405 文案保持不变。
106
- const localOnly = (method, handler) => async (req, res) => {
120
+ //
121
+ // `options.allowDocumentNavigation` 只给那几条「本来就是给人点开看」的只读页面用,
122
+ // 其余路由不传,跨站照旧一律拒。
123
+ const localOnly = (method, handler, options = {}) => async (req, res) => {
107
124
  if (remoteRelayRequest(req)) return sendJson(res, 403, { ok: false, error: { code: 'local_only', message: '控制面仅允许本机访问' } })
108
- if (crossSiteRequest(req)) return sendJson(res, 403, { ok: false, error: { code: 'cross_site', message: '控制面拒绝跨站请求' } })
125
+ if (crossSiteRequest(req, options.allowDocumentNavigation === true)) return sendJson(res, 403, { ok: false, error: { code: 'cross_site', message: '控制面拒绝跨站请求' } })
109
126
  const allowed = Array.isArray(method) ? method : [method]
110
127
  const actual = String(req.method || 'GET').toUpperCase()
111
128
  if (!allowed.includes(actual)) {
@@ -311,11 +311,41 @@ function lookupService(ctx, name) {
311
311
  }
312
312
 
313
313
  /**
314
- * POST /api/mobile/v1/channels/discover(token){ protocol, baseUrl, apiKey? }
314
+ * 宿主 `llm.discoverModels` 的失败 → 中文一句(宿主的英文原话留在 `details.hostMessage`)。
315
+ *
316
+ * 宿主的原话是英文(`could not reach …`、`… answered 401; check the API key`),而 App 那边
317
+ * 规定英文原话不上界面(`services/http` 的 `unwrapResult` 会整句换成「电脑那边出了错,请重试」),
318
+ * 「连不上地址」与「Key 不对」在手机上就分不出来。所以在这一层按已经见到的几种形状换成中文:
319
+ * App 拿到的原话是中文就照原样显示,用户读得到下一步该干什么。
320
+ */
321
+ function discoverFailureMessage(caught) {
322
+ const raw = caught && typeof caught.message === 'string' ? caught.message : String(caught == null ? '' : caught);
323
+ const code = caught && typeof caught.code === 'string' ? caught.code : '';
324
+ if (/could not reach/i.test(raw)) return '连不上这个地址:确认地址没写错,也确认这台电脑访问得到它';
325
+ const answered = /answered (\d{3})/i.exec(raw);
326
+ if (answered) {
327
+ const status = answered[1];
328
+ if (status === '401' || status === '403') return '这个地址要求鉴权:Key 不对或者没填';
329
+ return `这个地址没有模型清单这个接口(HTTP ${status}),检查地址有没有多写路径`;
330
+ }
331
+ if (/did not answer with JSON/i.test(raw)) return '这个地址回的不是模型清单,可能填成了网页地址';
332
+ if (/no model listing this build can read/i.test(raw)) return '这条协议取不到模型清单,手动填模型 id 吧';
333
+ if (/no credential for provider route/i.test(raw) || /API key is blank/i.test(raw)) {
334
+ return '这条渠道在这台电脑上没有可用的 Key,填一次 Key 再获取';
335
+ }
336
+ return `取模型失败${code ? `(${code})` : ''}`;
337
+ }
338
+
339
+ /**
340
+ * POST /api/mobile/v1/channels/discover(token){ id?, protocol, baseUrl, apiKey? }
315
341
  * → { ok, models: [{id,name,contextWindow?,maxTokens?,vision?}], baseUrl }
316
342
  *
317
343
  * `baseUrl` 回的是**问通的那个**:`/v1` 回退成功时保存要写它,否则聊天请求还会打到
318
344
  * 刚才失败的那个地址上。只读不落盘,用户确认清单之后再调 `/channels/save`。
345
+ *
346
+ * `id` 是编辑中的那条渠道:宿主按路由名就能在「请求没带 Key」时自己去凭据库取原来那把
347
+ * (`discoverModels` 的 `provider`),所以表单里 Key 留空也能取模型。新建时没有 id,
348
+ * 沿用当场填的那把。
319
349
  */
320
350
  async function handleChannelDiscover(ctx, req, res) {
321
351
  const body = await readJsonBody(req, 64 * 1024);
@@ -325,6 +355,10 @@ async function handleChannelDiscover(ctx, req, res) {
325
355
  }
326
356
  const base = normalizeBaseUrl(body ? body.baseUrl : undefined);
327
357
  if (base.error) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: base.error } });
358
+ const editingId = body && typeof body.id === 'string' ? body.id.trim() : '';
359
+ if (editingId !== '' && !CHANNEL_ID_PATTERN.test(editingId)) {
360
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '渠道 id 不合法' } });
361
+ }
328
362
  const apiKey = body && typeof body.apiKey === 'string' ? body.apiKey.trim() : '';
329
363
  if (apiKey.length > MAX_KEY_CHARS) {
330
364
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'API Key 太长' } });
@@ -342,6 +376,7 @@ async function handleChannelDiscover(ctx, req, res) {
342
376
  for (const baseURL of discoverCandidates(base.url, protocol)) {
343
377
  try {
344
378
  const found = await llm.discoverModels(PI_AI_NS, {
379
+ ...(editingId === '' ? {} : { provider: editingId }),
345
380
  baseURL,
346
381
  api: protocol,
347
382
  ...(apiKey === '' ? {} : { apiKey }),
@@ -368,10 +403,16 @@ async function handleChannelDiscover(ctx, req, res) {
368
403
  lastError = caught;
369
404
  }
370
405
  }
371
- // 协议层 / 渠道包的话本身就是给用户看的(「answered 401; check the API key」),原样透出。
406
+ // 失败说人话:宿主那几句英文摆到手机的英文过滤后面会变成「电脑那边出了错」,原文留在 details。
407
+ const hostMessage = String((lastError && lastError.message) || lastError).slice(0, 300);
408
+ const hostCode = lastError && typeof lastError.code === 'string' ? lastError.code : '';
372
409
  sendJson(res, 422, {
373
410
  ok: false,
374
- error: { code: 'discover-failed', message: String((lastError && lastError.message) || lastError).slice(0, 300) },
411
+ error: {
412
+ code: 'discover-failed',
413
+ message: discoverFailureMessage(lastError),
414
+ details: { hostMessage, ...(hostCode === '' ? {} : { hostCode }) },
415
+ },
375
416
  });
376
417
  }
377
418
 
@@ -16,7 +16,7 @@ const { createFrameSealer } = require('./stream.js');
16
16
  const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
17
17
  const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
18
18
  const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
19
- const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
19
+ const { writeSse: sharedWriteSse, stableEventId: sharedEventId, waitForDrain } = require('../shared/sse-write.js');
20
20
 
21
21
  const DEDUP_WINDOW = 2048;
22
22
  /** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
@@ -153,10 +153,35 @@ async function handleDeviceStream(ctx, req, res) {
153
153
  // shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
154
154
  if (result.shed) return false;
155
155
  framesSent += 1;
156
- // 所有调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
157
- // 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。
156
+ // 这几处调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
157
+ // 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。开场那一批补发
158
+ // 不走这里,走下面的 `writeFrameAwaiting`:那批是一口气几十上百帧,必然会撞上写缓冲。
158
159
  return !result.needDrain;
159
160
  };
161
+ /**
162
+ * 补发快照专用:写一帧,写不动就等缓冲吐出去再接着写。
163
+ *
164
+ * 与 `writeFrame` 只差一件事:`needDrain` 不是失败,是「慢」。开场补发是每台被跟踪的
165
+ * 会话各一帧的水位快照,会话多的机器一上来就超出一条连接的写缓冲(Node 的高水位默认
166
+ * 16KB),把它当失败就等于**每次连上补完就自己掐掉**,手机重连再补一遍,永远建立不起来;
167
+ * 顺带连「积压到软水位才 shed」那道闸都轮不到。会话流(`stream.js`)的 live 循环就是
168
+ * 等 drain 再继续,这里照同一套来。
169
+ *
170
+ * @returns {Promise<boolean>} 这一帧写成功(或等到了 drain)→ true;该收尾 → false。
171
+ */
172
+ const writeFrameAwaiting = async (devicePayload) => {
173
+ const result = writeSse(res, {
174
+ type: 'device/event',
175
+ version: 1,
176
+ payload: devicePayload,
177
+ }, null, sealFrame);
178
+ if (!result.ok) return false;
179
+ // shed 不是成功:不能继续读,否则这一帧代表的变更永远补不回来。
180
+ if (result.shed) return false;
181
+ framesSent += 1;
182
+ if (result.needDrain && !(await waitForDrain(res, ac.signal))) return false;
183
+ return true;
184
+ };
160
185
  /**
161
186
  * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就推给手机。
162
187
  *
@@ -208,26 +233,27 @@ async function handleDeviceStream(ctx, req, res) {
208
233
  * 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
209
234
  * App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
210
235
  */
211
- const replayKnownSignals = () => {
236
+ const replayKnownSignals = async () => {
212
237
  const list = sharedListSignal.snapshot();
213
238
  if (list && list.revision) {
214
- if (!writeFrame({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
239
+ if (!await writeFrameAwaiting({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
215
240
  }
216
241
  // 桌面侧栏折叠态:手机从此只在变化时拉一次。老 App 不认识这一帧(走默认分支忽略),
217
242
  // 所以补发它不会改变老客户端的任何行为。
218
243
  const view = sharedDesktopViewSignal.snapshot();
219
244
  if (view && view.updatedAt > 0) {
220
- if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
245
+ if (!await writeFrameAwaiting({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
221
246
  }
222
247
  for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
223
248
  if (!(seq > 0)) continue;
224
- if (!writeFrame({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
249
+ if (!await writeFrameAwaiting({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
225
250
  }
226
251
  return true;
227
252
  };
228
- // 补发失败说明连接已经不可写(writeSse 只在 res.write 抛错时返回 false),此时
229
- // 继续跑 mux 循环只会一路失败;abort 掉让它走正常收尾。
230
- if (!replayKnownSignals()) ac.abort();
253
+ // 补发失败说明连接真的已经不可写(writeSse 只在 res.write 抛错时返回 false),此时
254
+ // 继续跑 mux 循环只会一路失败;abort 掉让它走正常收尾。写缓冲满了不算失败,见
255
+ // `writeFrameAwaiting`。
256
+ if (!await replayKnownSignals()) ac.abort();
231
257
  const keepAlive = setInterval(() => {
232
258
  try { res.write(': keepalive\n\n'); } catch { ac.abort(); return; }
233
259
  // 心跳必须是**数据帧**:注释帧 App 看不见,而 App 只有「最近真的收到过帧」才敢把
@@ -273,7 +299,11 @@ async function handleDeviceStream(ctx, req, res) {
273
299
  // Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
274
300
  const sent = writeSse(res, envelope, frame, sealFrame);
275
301
  if (sent.shed) { clientGone = true; break; }
276
- if (!sent.ok || sent.needDrain) { clientGone = true; break; }
302
+ if (!sent.ok) { clientGone = true; break; }
303
+ // 写不动就等一次 drain 再接着读:这条循环本来就是 async,等一等比断掉重连便宜得多
304
+ // (重连要补发整份快照,手机那边还要重拉一遍清单与正文)。等不到(超时或流被中断)
305
+ // 才收尾。
306
+ if (sent.needDrain && !(await waitForDrain(res, ac.signal))) { clientGone = true; break; }
277
307
  framesSent += 1;
278
308
  }
279
309
  if (!clientGone) loopEnded = true;
@@ -159,6 +159,33 @@ function hostRequest(ctx, namespace, method, args, signal) {
159
159
  return request;
160
160
  }
161
161
 
162
+ /**
163
+ * 撤掉网关还挂着的那一发远程转发(桌面窗口那张卡挂在它上面)。
164
+ *
165
+ * 网关按**请求对象本身**登记挂起的转发,所以按 `requestRef` 认回那一发、调它自己的撤回接口:
166
+ * 窗口收到 `cancel` 帧后按 abort 处理,卡跟着撤,不会冒成错误提示。认不到就什么都不做 ——
167
+ * 老宿主没有 `pendingRemoteEvents` / `cancelRemoteEvent`,「桌面那边没卡可收」本来就是对的。
168
+ *
169
+ * 放在这里而不是调用方:调用方(提问桥)只知道「这一问的那一发撤掉」,两个成员名与迭代方式
170
+ * 跟 invoke / stream 一样属于传输层 —— 别在别处再摸一次 `typertGateway`。
171
+ *
172
+ * @returns {number} 真的撤掉了几发
173
+ */
174
+ function cancelForwardedRequests(ctx, requestRef, reason) {
175
+ const gateway = gatewayOf(ctx);
176
+ const pending = gateway && gateway.pendingRemoteEvents;
177
+ if (!pending || typeof pending.values !== 'function' || typeof gateway.cancelRemoteEvent !== 'function') return 0;
178
+ let cancelled = 0;
179
+ for (const item of [...pending.values()]) {
180
+ if (!item || !item.source || item.source.request !== requestRef) continue;
181
+ try {
182
+ gateway.cancelRemoteEvent(item, reason);
183
+ cancelled += 1;
184
+ } catch { /* 那一发已经走了(别人先收掉):没什么可撤的 */ }
185
+ }
186
+ return cancelled;
187
+ }
188
+
162
189
  async function invoke(ctx, namespace, method, args, signal) {
163
190
  const gateway = gatewayOf(ctx);
164
191
  if (!gateway) return { error: { ...UNAVAILABLE } };
@@ -222,4 +249,4 @@ async function invokeEnvelope(ctx, namespace, method, args, signal) {
222
249
  return ok(result.value);
223
250
  }
224
251
 
225
- module.exports = { GATEWAY_MISSING, UNAVAILABLE, unsupported, deadline, gatewayOf, warn, pick, ok, errorOf, fail, requestId, dropUnexpectedArgs, invoke, openStream, invokeEnvelope, SYNTHETIC_DEADLINE_MS };
252
+ module.exports = { GATEWAY_MISSING, UNAVAILABLE, unsupported, deadline, gatewayOf, warn, pick, ok, errorOf, fail, requestId, dropUnexpectedArgs, invoke, cancelForwardedRequests, openStream, invokeEnvelope, SYNTHETIC_DEADLINE_MS };
@@ -16,6 +16,7 @@
16
16
 
17
17
  const { randomUUID } = require('node:crypto');
18
18
  const { sendJson, readJsonBody } = require('../shared/http-util');
19
+ const { cancelForwardedRequests } = require('./host-bridge/transport.js');
19
20
 
20
21
  /** sessionId → Set<sink>:正在看这个会话的手机连接(每条 SSE 一个 sink)。 */
21
22
  const watchers = new Map();
@@ -208,6 +209,8 @@ function normalizeAnswers(questions, answers) {
208
209
  /** 绑上钩子请求:从此这条挂起项「答得动」,并接上宿主的取消信号。 */
209
210
  function bindRequest(entry, request) {
210
211
  entry.bound = true;
212
+ // 留一个身份引用:手机答完之后要按它找回宿主转发给桌面窗口的那一发(见 cancelForwardedCards)。
213
+ entry.requestRef = request;
211
214
  pushCard(entry);
212
215
  const signal = request.signal;
213
216
  if (!signal || typeof signal.addEventListener !== 'function') return;
@@ -233,10 +236,10 @@ function noAnswerer(error) {
233
236
  * 挂起这条请求直到有人回答:桌面的转发与手机的卡**同时进行**,谁先答就用谁的答案。
234
237
  *
235
238
  * 桌面先答:下游给出答案,结算挂起项(手机那张卡由结算帧收掉)。
236
- * 手机先答:把手机的回答交回钩子。桌面那张卡由宿主自己收尾 —— 这一问落定后宿主的限时
237
- * 等待随之结束,桌面客户端把「被转发的请求」delegate 回来,卡跟着撤掉,会话投影也不再
238
- * 列这个 call。那一下会让 `downstream` 再落一次;上面的 race 已经挂着它的处理函数,迟到
239
- * 的那次不会冒成未处理异常,`settle` 本身也是幂等的,收卡与回答都不受影响。
239
+ * 手机先答:把手机的回答交回钩子,桌面那张卡由 `cancelForwardedCards` 主动收掉 —— 宿主
240
+ * 只在「限时提问」里给这条转发做了收尾,本桥跑的这版宿主是阻塞式提问,没人替桌面窗口
241
+ * 撤回那一发,卡会一直悬着直到人手动点。那一发被撤时 `downstream` 会再落一次;上面的
242
+ * race 已经挂着它的处理函数,迟到的那次不会冒成未处理异常,`settle` 本身也是幂等的。
240
243
  *
241
244
  * `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate 回来)不是结论:手机正在看这个会话时
242
245
  * 卡还在手机上等着,继续等手机答;没人看时照旧把失败交回去,纯桌面表现一字不改。
@@ -260,6 +263,22 @@ function holdForPhone(entry, next) {
260
263
  return Promise.race([fromPhone, downstream]);
261
264
  }
262
265
 
266
+ /**
267
+ * 收掉桌面那张卡:把宿主已经发给桌面窗口、还挂着的那一发转发撤掉。
268
+ *
269
+ * 桌面那张卡不是本桥画的:宿主把提问转发给桌面窗口之后,窗口自己按那一发长出一张卡,
270
+ * 只有三种情况会让它消失 —— 桌面自己答、这一轮被取消、宿主主动撤回那一发。「两边同时
271
+ * 出卡」靠的就是转发,而宿主只在「限时提问」路径上做了落定后的撤回;这版宿主的
272
+ * `ask_user_question` 跑的是阻塞式,手机先答之后没人去收,卡就一直悬着。
273
+ *
274
+ * 认哪一发、怎么撤都由传输层做(网关按请求对象本身登记这些挂起的转发):这里只把「这一问
275
+ * 的请求对象」交出去。认不到(老宿主没这道网关)就什么都不做 —— 「桌面没卡可收」本来就是对的。
276
+ */
277
+ function cancelForwardedCards(ctx, entry) {
278
+ if (!entry.requestRef) return;
279
+ cancelForwardedRequests(ctx, entry.requestRef, new Error('手机已经回答了这条提问'));
280
+ }
281
+
263
282
  function onQuestionRequest(request, next) {
264
283
  const sessionId = sessionIdOfRequest(request);
265
284
  if (!sessionId) return next();
@@ -301,6 +320,7 @@ async function handleQuestionRespond(ctx, req, res) {
301
320
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'answer.answers 必须每题恰好一条、含合法 selected' } });
302
321
  }
303
322
  settle(entry, answers);
323
+ cancelForwardedCards(ctx, entry);
304
324
  return sendJson(res, 200, { ok: true, accepted: true });
305
325
  }
306
326
  const receipt = await respondViaLegacyProxy(ctx, {
@@ -21,6 +21,7 @@ const path = require('node:path');
21
21
  const { pipeline } = require('node:stream/promises');
22
22
  const { sendJson, readJsonBody } = require('../shared/http-util');
23
23
  const { signedPath } = require('../shared/e2ee-request');
24
+ const { FILE_CHUNK_BYTES, writeChunk } = require('../shared/e2ee-chunks.js');
24
25
  const { unwrap } = require('./envelope.js');
25
26
  const { callSessions } = require('./rpc.js');
26
27
  const { cwdOf } = require('./session-cwd.js');
@@ -244,13 +245,13 @@ async function writeEncryptedFile(req, res, target, opened) {
244
245
  const block = pending.subarray(0, FILE_CHUNK_BYTES);
245
246
  pending = pending.subarray(FILE_CHUNK_BYTES);
246
247
  seq += 1;
247
- writeBlock(res, e2ee, opened.keys.down, block, e2ee.fileAad(path0, requestNonce, seq));
248
+ writeChunk(res, opened.keys.down, block, e2ee.fileAad(path0, requestNonce, seq));
248
249
  }
249
250
  }
250
251
  // 尾块(可能不足一块);空文件也给一块,否则 App 拿不到「这是个空文件」的确认。
251
252
  if (!closed) {
252
253
  seq += 1;
253
- writeBlock(res, e2ee, opened.keys.down, pending, e2ee.fileAad(path0, requestNonce, seq));
254
+ writeChunk(res, opened.keys.down, pending, e2ee.fileAad(path0, requestNonce, seq));
254
255
  }
255
256
  if (!closed) res.end();
256
257
  } catch {
@@ -258,29 +259,15 @@ async function writeEncryptedFile(req, res, target, opened) {
258
259
  }
259
260
  }
260
261
 
261
- /** 写一块:`[4 字节长度][nonce][密文+tag]`,长度写的是后两段之外的密文长度。 */
262
- function writeBlock(res, e2ee, key, block, aad) {
263
- const sealed = e2ee.seal(key, block, aad);
264
- const buf = Buffer.from(sealed);
265
- const cipherLen = buf.length - e2ee.NONCE_BYTES;
266
- const head = Buffer.alloc(4);
267
- head.writeUInt32BE(cipherLen, 0);
268
- res.write(Buffer.concat([head, buf]));
269
- }
270
-
271
- /** 交付文件的分块大小。64KB:一块一次 AEAD,太小会让每块的 16+12 字节固定开销占比变高。 */
272
- const FILE_CHUNK_BYTES = 64 * 1024;
273
-
274
262
  /** 一块除明文字节之外还带多少:`[4 字节长度][12 字节 nonce][16 字节 tag]`。 */
275
263
  const FILE_BLOCK_OVERHEAD = 4 + 12 + 16;
276
264
 
277
265
  /**
278
266
  * 一份文件按块加密之后在网上会有多少字节。
279
267
  *
280
- * 块数 = `floor(size / FILE_CHUNK_BYTES)` 个满块 + **永远存在的那一块尾块**(空文件也要一块)。
281
- * 这个数在写之前就得用(响应头的 `content-length`),所以它必须与 `writeEncryptedFile` 的
282
- * 写法逐字对得上 —— 后果不对称:算少了对端读不满、算多了对端一直等,两种都是「文件打不开」。
283
- * 守卫拿几个跨块边界的长度钉住它。
268
+ * 块数 = `floor(size / FILE_CHUNK_BYTES)` 个满块 + **永远存在的那一块尾块**(空文件也要一块):
269
+ * 这个数在写之前就得用(响应头的 `content-length`),所以它必须与写块那份逐字对得上 —— 后果
270
+ * 不对称:算少了对端读不满、算多了对端一直等,两种都是「文件打不开」。
284
271
  */
285
272
  function encryptedLengthOf(size) {
286
273
  const plain = Math.max(0, Number(size) || 0);
@@ -14,11 +14,21 @@
14
14
  *
15
15
  * 与桌面客户端保持同一口径:`includeDescendants` 缺省即 true —— 导出的是「当前会话
16
16
  * + 子会话 + 附件」的整棵会话树,而不是单个会话。
17
+ *
18
+ * **封过包的请求回分块信封,明文请求照旧回 zip**(2026-10-30)。判据是 `req.__dshE2ee`:
19
+ * 它由移动端守卫在解封请求时挂上,只有带 `X-Dsh-Proto: 2` 的请求才有 —— 也就是说这一档
20
+ * 只对新 App 生效,老 App 一个字都不用改。为什么要加这一档:守卫对「v2 凭据 + 明文请求」
21
+ * 一律回 `e2ee-required`,而这条导出一直是绕开传输内核直发的明文请求,于是它在按 v2 配对的
22
+ * 手机上从来就没走到下载这一步。封了包之后,**响应也得跟着封** —— 会话树里是整段对话与附件,
23
+ * 明文 zip 等于把这条链路唯一的加密出口又打开一次。分块格式与 `session-file.js` 共用一套
24
+ * (`[4 字节长度][nonce][密文+tag]`,AAD 绑路径 / 请求 nonce / 块序号),App 那边用同一个
25
+ * 原生解密口读,两处口径因此只有一份。
17
26
  */
18
27
 
19
28
  const { Readable } = require('node:stream');
20
29
  const { pipeline } = require('node:stream/promises');
21
30
  const { sendJson } = require('../shared/http-util.js');
31
+ const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
22
32
 
23
33
  /**
24
34
  * 与桌面 sessionLogZipFilename 同口径的下载文件名。
@@ -82,8 +92,19 @@ async function handleSessionLog(ctx, req, res) {
82
92
  };
83
93
  const length = upstream.headers.get('content-length');
84
94
  if (length) headers['content-length'] = length;
85
- res.writeHead(200, headers);
86
95
 
96
+ const opened = req && req.__dshE2ee;
97
+ if (opened && opened.keys && opened.req && upstream.body) {
98
+ // 真类型单独给一条头:解密之后按它认「这是一份 zip」。
99
+ res.writeHead(200, chunksHeaders({
100
+ 'content-disposition': headers['content-disposition'],
101
+ 'x-dsh-content-type': headers['content-type'],
102
+ }));
103
+ await pipeEncrypted(req, res, Readable.fromWeb(upstream.body));
104
+ return;
105
+ }
106
+
107
+ res.writeHead(200, headers);
87
108
  if (!upstream.body) return res.end();
88
109
  try {
89
110
  // 边收边写,不在插件进程里整包缓冲:会话树的附件可能很大,缓冲等于把宿主的
@@ -13,7 +13,9 @@
13
13
  * 未知命令或网关未组合时回退普通消息,与桌面 miss 落入默认 sink 的行为一致。
14
14
  */
15
15
 
16
+ const { Readable } = require('node:stream');
16
17
  const { sendJson, readJsonBody } = require('../shared/http-util');
18
+ const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
17
19
  const { inlineUploadedImageBlocks } = require('./uploads.js');
18
20
  const { unwrap } = require('./envelope.js');
19
21
  const { callSessions, mobilePromptRpcId } = require('./rpc.js');
@@ -290,6 +292,19 @@ async function handleSessionAttachment(ctx, req, res) {
290
292
  // `dims: true` = 只要原始宽高,不要字节。老会话里宿主没存附件的宽高,正文下发时就带不上,
291
293
  // 手机侧只能按 240 方盒先占位、等字节到了再变高(那一行一变高,屏幕上就是「跳」)。这一档
292
294
  // 让手机在挂载那一帧就能按真比例占好:回给它的只有两格数字,整张图的 base64 不出这台电脑。
295
+ // `raw: true` = 要原始字节,不要 base64(2026-10-30)。宿主给的仍是 base64,这里解成
296
+ // Buffer 后按分块信封流出去:手机那边落盘的是密文,靠原生解盘口边解边写,整张图的
297
+ // base64 不进 JS 堆 —— 一张两兆的截图在手机上原本要摊着两兆七的字符串加一份解析副本。
298
+ // **不封包不许走这一档**:那就成了明文传输,正好把这条路本来的意义抹掉,如实回 400。
299
+ if (body.raw === true) {
300
+ const opened = req && req.__dshE2ee;
301
+ if (!opened || !opened.keys || !opened.req) {
302
+ return sendJson(res, 400, { ok: false, error: { code: 'e2ee-required', message: '这条会话是加密的,请用信封重发' } });
303
+ }
304
+ res.writeHead(200, chunksHeaders({ 'x-dsh-content-type': mediaType }));
305
+ await pipeEncrypted(req, res, Readable.from([Buffer.from(data, 'base64')]));
306
+ return;
307
+ }
293
308
  if (body.dims === true) {
294
309
  const size = imageSizeOf(Buffer.from(data, 'base64'));
295
310
  if (!size || !(size.width > 0) || !(size.height > 0)) {
@@ -352,15 +352,21 @@ function manualCandidate(value) {
352
352
  }
353
353
 
354
354
  /**
355
- * 候选 = 用户填的那条(有的话)在前 + 其余照旧。按 url 去重,**不删**任何一条 NIC 候选:
356
- * 穿透域名不通时手机还得能顺着网卡地址摸回这台机器。值不合法时原样返回 NIC 候选,
357
- * 不让一条坏配置把整份候选清空(那正是彻底失联的形状)。
355
+ * 候选 = 网卡那几条照旧 + 用户填的那条**排在最后**当兜底。按 url 去重,**不删**任何一条
356
+ * NIC 候选:穿透域名不通时手机还得能顺着网卡地址摸回这台机器。值不合法时原样返回 NIC
357
+ * 候选,不让一条坏配置把整份候选清空(那正是彻底失联的形状)。
358
+ *
359
+ * **为什么排在最后而不是最前**(2026-10-30 改):这两类地址的快慢本来不可比 —— 同一个
360
+ * WiFi 里内网是几毫秒,穿透要绕公网,起步就是几十到几百毫秒。排在最前等于让手机按「先
361
+ * 回话的赢」这条规矩永远选中穿透域名,家里明明有内网地址却一次都不去试;现场表现就是
362
+ * 「以前在局域网下直连地址会自动变成内网 IP,现在不会了」。顺序是这条上唯一的表达方式,
363
+ * 所以内网那条必须排前面,穿透域名只当离开这个网络之后的兜底。
358
364
  */
359
365
  function withManualCandidate(list, value) {
360
366
  const manual = manualCandidate(value)
361
367
  const rest = Array.isArray(list) ? list : []
362
368
  if (!manual) return rest
363
- return [manual, ...rest.filter(item => item && item.url !== manual.url)]
369
+ return [...rest.filter(item => item && item.url !== manual.url), manual]
364
370
  }
365
371
 
366
372
  /**
@@ -393,8 +399,8 @@ function orderCandidates(list, preferred) {
393
399
  /**
394
400
  * 报给手机的直连候选 —— 二维码、配对响应与 `/connection-info` 三处**只许走这一份**。
395
401
  *
396
- * 两步顺序不能反:先让「手机优先用这条」调网卡候选的顺序,再把用户手填的地址盖到最前
397
- * (那条是用户在手机上手打的,`preferredLanAddress` 只是面板上对网卡地址的选择,压不过它)。
402
+ * 两步顺序不能反:先让「手机优先用这条」调网卡候选的顺序,再把用户手填的地址**缀到末尾**
403
+ * 当兜底(`preferredLanAddress` 调的是同一批网卡地址之间的次序,手填的那条不属于这一批)。
398
404
  * 三处各写一遍的结果就是「画面上说这条、手机去试另一条」。
399
405
  */
400
406
  function phoneCandidates(list, options = {}) {
@@ -87,7 +87,7 @@ function createWebAccessWindow({ now = Date.now, monotonicNow = () => performanc
87
87
  }
88
88
 
89
89
  function createWebAccessLocalOnly({ localOnly, trustedLocalUi, webAccessWindow, sendJson }) {
90
- return (method, handler) => localOnly(method, async (req, res) => {
90
+ return (method, handler, options) => localOnly(method, async (req, res) => {
91
91
  const route = String(req?.url || '').split('?', 1)[0];
92
92
  if (WEB_ACCESS_ROUTES.has(route) && !trustedLocalUi(req) && !webAccessWindow.isOpen()) {
93
93
  return sendJson(res, 423, {
@@ -96,7 +96,7 @@ function createWebAccessLocalOnly({ localOnly, trustedLocalUi, webAccessWindow,
96
96
  });
97
97
  }
98
98
  return handler(req, res);
99
- });
99
+ }, options);
100
100
  }
101
101
 
102
102
  /**
@@ -445,9 +445,12 @@ function registerControlPlaneRoutes(deps) {
445
445
  process.stderr.write(`[dsh-mobile-plugin] panel unavailable (${diagnosis.reason}) ${PANEL_FILE}: ${diagnosis.message}\n`);
446
446
  return sendJson(res, 500, { ok: false, error: { code: 'panel_missing', reason: diagnosis.reason, message: diagnosis.message, path: PANEL_FILE } });
447
447
  };
448
- registerRoute({ kind: 'exact', path: '/api/relay/panel', handler: localOnly('GET', async (_req, res) => servePanel(res)) })
449
- registerRoute({ kind: 'exact', path: '/api/relay/panel/setup', handler: localOnly('GET', async (_req, res) => servePanel(res)) })
450
- registerRoute({ kind: 'exact', path: '/api/relay/panel/settings', handler: localOnly('GET', async (_req, res) => servePanel(res)) })
448
+ // 三条面板页是「人点开的一页」:社区桌面壳会把自己接住的链接原地打开,从自定义协议
449
+ // 页跳过来必带 `sec-fetch-site: cross-site`,不放开这一种形态,用户看到的就是整屏
450
+ // 一句 403 JSON(见 `lib/http/routing.js` 的 `documentNavigation`)。
451
+ registerRoute({ kind: 'exact', path: '/api/relay/panel', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
452
+ registerRoute({ kind: 'exact', path: '/api/relay/panel/setup', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
453
+ registerRoute({ kind: 'exact', path: '/api/relay/panel/settings', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
451
454
  // 桌面端工作区视图(侧栏展开态)的回传/读取。桌面端只把展开态记在浏览器
452
455
  // localStorage 里,宿主进程读不到,只能由页面内的客户端半边(lib/client.js)
453
456
  // 每 2s 变了就 POST 到这里;App 再经 /api/mobile/v1/desktop-view 取走。
@@ -189,8 +189,8 @@ function registerMobileRoutes(deps) {
189
189
  const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort
190
190
  // 用户在面板上选过的「手机优先用这条」(设置项 `preferredLanAddress`)在这里同样兑现:
191
191
  // 手机是按顺序挨个试的,所以只挪顺序、不删候选。选的那条不在了就退回宿主排序。
192
- // 手填的直连地址再盖到最前面:那是用户明确指定的那条,`preferredLanAddress` 只调网卡候选
193
- // 的顺序,不该把它压到后面。
192
+ // 手填的直连地址(穿透域名)缀在**最后**当兜底:它绕公网、比内网慢一个数量级,排在前面
193
+ // 就等于让手机永远选中它、家里那条内网地址一次都不试(见 `withManualCandidate` 的注释)。
194
194
  const candidates = state.lanEnabled
195
195
  ? withManualCandidate(orderCandidates(safeLanCandidates(port), state.preferredLanAddress), state.manualLanUrl)
196
196
  : []
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 分块信封的写出口(协议 v2,§8.3(6))—— 会话文件、会话日志 ZIP、会话图片附件共用这一份。
5
+ *
6
+ * 块形状:`[4 字节大端长度][12 字节 nonce][密文 + 16 字节 tag]`,长度字段写的是「密文 + tag」
7
+ * 这一段(不含 nonce 与长度字段本身)。AAD 绑这条请求的路径、请求 nonce 与块序号 —— 块序号
8
+ * 让中转既不能把别的文件的字节搬进来,也不能重排或截断而不被察觉。
9
+ *
10
+ * 为什么要一个共用模块:这是跨端**逐字对齐**的那一层,手机那边是同一个原生解盘口在读
11
+ * (`DSHCipher.openFile`)。三条下载各写一遍,等于给自己留三个漂移点,而漂了不会有报错,
12
+ * 只会换来「文件打不开」。
13
+ */
14
+
15
+ const e2ee = require('./e2ee');
16
+ const { signedPath } = require('./e2ee-request');
17
+
18
+ /** 一块的明文字节数。与 App 侧 `FILE_CHUNK_BYTES` 同值。 */
19
+ const FILE_CHUNK_BYTES = 64 * 1024;
20
+
21
+ /** 分块信封的 content-type:App 靠它判「这份字节要解,别当普通文件存」。 */
22
+ const E2EE_CHUNKS_TYPE = 'application/x-dsh-e2ee-chunks';
23
+
24
+ /**
25
+ * 分块信封的响应头。
26
+ *
27
+ * **不给 content-length**:这一份是边收边加密的流,总长要等上游收完才知道;而 App 那条原生
28
+ * 下载器只认 EOF、不靠长度判完成(要看长度的是 blob-util 那条老路,已经不用了)。
29
+ */
30
+ function chunksHeaders(extra = {}) {
31
+ return {
32
+ 'content-type': E2EE_CHUNKS_TYPE,
33
+ 'cache-control': 'no-store',
34
+ 'x-dsh-proto': '2',
35
+ ...extra,
36
+ };
37
+ }
38
+
39
+ /** 写一块:`[4 字节长度][nonce][密文+tag]`。 */
40
+ function writeChunk(res, key, block, aad) {
41
+ const sealed = Buffer.from(e2ee.seal(key, block, aad));
42
+ const head = Buffer.alloc(4);
43
+ head.writeUInt32BE(sealed.length - e2ee.NONCE_BYTES, 0);
44
+ res.write(Buffer.concat([head, sealed]));
45
+ }
46
+
47
+ /**
48
+ * 把一个可读流按块加密写进 `res`(调用方已经把状态码与头写完了)。
49
+ *
50
+ * 块边界要自己攒(`pending` 不到一块就等下一段)。**空内容也要写一块**,否则 App 分不清
51
+ * 「这份是空的」与「一个字节都没收到」。出错时只能断开连接 —— 头已经发出去了,改不了状态码。
52
+ */
53
+ async function pipeEncrypted(req, res, source) {
54
+ const opened = req && req.__dshE2ee;
55
+ if (!opened || !opened.keys || !opened.req) throw new Error('这条请求没有信封');
56
+ const path = signedPath(req);
57
+ const requestNonce = opened.req.nonce;
58
+ let seq = 0;
59
+ let pending = Buffer.alloc(0);
60
+ let closed = false;
61
+ res.on('close', () => { closed = true; });
62
+ try {
63
+ for await (const chunk of source) {
64
+ if (closed) break;
65
+ pending = pending.length ? Buffer.concat([pending, chunk]) : Buffer.from(chunk);
66
+ while (pending.length >= FILE_CHUNK_BYTES) {
67
+ const block = pending.subarray(0, FILE_CHUNK_BYTES);
68
+ pending = pending.subarray(FILE_CHUNK_BYTES);
69
+ seq += 1;
70
+ writeChunk(res, opened.keys.down, block, e2ee.fileAad(path, requestNonce, seq));
71
+ }
72
+ }
73
+ if (!closed) {
74
+ seq += 1;
75
+ writeChunk(res, opened.keys.down, pending, e2ee.fileAad(path, requestNonce, seq));
76
+ res.end();
77
+ }
78
+ } catch {
79
+ try { res.destroy(); } catch { /* 连接已关闭 */ }
80
+ }
81
+ }
82
+
83
+ module.exports = { FILE_CHUNK_BYTES, E2EE_CHUNKS_TYPE, chunksHeaders, writeChunk, pipeEncrypted };
@@ -8,10 +8,17 @@
8
8
  *
9
9
  * 不自己实现包管理,只驱动它:profile 的 pnpm-workspace.yaml 已经写好 node-linker、peer 等
10
10
  * 关键设置,这里只「cd 到 profile 跑一句 pnpm add <包名>@<版本>」,不传布局类开关。必要的
11
- * 开关有两层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
11
+ * 开关有三层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
12
12
  * (一检测到新版就立刻装,不等「新发布先晾一会儿」;那道策略查的是整份
13
- * lockfile,别的插件刚发也会连累我们),再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
14
- * pnpm 12 的原生 CLI 会静默忽略这两个 --config 参数,环境变量那一份还认。
13
+ * lockfile,别的插件刚发也会连累我们)、ignore-workspace-root-check=true(profile 自己带
14
+ * `pnpm-workspace.yaml`,在 pnpm 眼里就是 workspace 根;pnpm 10.4 及以前会拿
15
+ * `ERR_PNPM_ADDING_TO_ROOT` 把这一句 add 直接拒掉,机械地退回 PATH 上那个老 pnpm 的机器全中),
16
+ * 再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
17
+ * pnpm 12 的原生 CLI 会静默忽略这几个 --config 参数,环境变量那一份还认。
18
+ *
19
+ * 失败要**说出原因**:`failure.message` 是手机、面板与日志唯一显示的那句话,曾经只写「跑过的
20
+ * 命令 + 退出码」—— 排错的人拿着一张只有命令的截图,包管理器自己的报错(ERR_PNPM_* 那一行)
21
+ * 被丢在了 `output` 里没人看。现在报错正文跟着附上。
15
22
  *
16
23
  * 装完要**登记**:这个版本是被我们绕过晾置期装进去的,不写进 profile 的 minimumReleaseAgeExclude,
17
24
  * 它就会留在 lockfile 里,之后 pnpm 校验整份 lockfile 时把这台机器上所有安装都拒掉
@@ -102,7 +109,7 @@ function spawnEnv(dirs, env = process.env) {
102
109
  }
103
110
 
104
111
  /**
105
- * 每次调 pnpm 都带上的那两格策略开关,**环境变量这一份不是备份,是另一半**。
112
+ * 每次调 pnpm 都带上的那几格开关,**环境变量这一份不是备份,是另一半**。
106
113
  *
107
114
  * pnpm 11 两种写法都认;pnpm 12 的原生 CLI 会静默忽略部分 `--config.*`(市场在 #600 上实测
108
115
  * 过,一次装失败一次白等),但同一个键走 `PNPM_CONFIG_*` 它照读。命令行与环境变量各带一份,
@@ -111,6 +118,7 @@ function spawnEnv(dirs, env = process.env) {
111
118
  const POLICY_ENV = {
112
119
  PNPM_CONFIG_AUTO_INSTALL_PEERS: 'false',
113
120
  PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0',
121
+ PNPM_CONFIG_IGNORE_WORKSPACE_ROOT_CHECK: 'true',
114
122
  };
115
123
 
116
124
  /** 「配置里这个包用的是哪个注册表」缓存多久:查更新两分钟一轮,源不常换。 */
@@ -428,6 +436,26 @@ function runningAgentIds(agents) {
428
436
  return ids;
429
437
  }
430
438
 
439
+ /**
440
+ * 给一句失败结论附上包管理器自己说的原因。
441
+ *
442
+ * `failure.message` 是手机、面板与日志**唯一**会显示的那句话,所以它必须自带原因:只写
443
+ * 「跑过的命令 + 退出码」时,排错的人拿着一张只有命令的截图,一点办法都没有。pnpm 的诊断
444
+ * 都在 `ERR_PNPM_*` / `ERROR` 那几行里,优先挑它;一行都没有(被 kill、旧版 pnpm 换了措辞)
445
+ * 就退回最后一行有内容的输出 —— 进度条那种噪声先滤掉。挑不满就不硬凑,也别把整份输出塞进
446
+ * 一句话里:完整的那份一直留在 `output` 里。
447
+ */
448
+ function withReason(message, output) {
449
+ const lines = String(output || '')
450
+ .split('\n')
451
+ .map(line => line.trimEnd())
452
+ .filter(line => line.trim() !== '' && !/^Progress:/.test(line.trim()));
453
+ if (lines.length === 0) return message;
454
+ const marked = lines.filter(line => /ERR_PNPM|ERROR/.test(line));
455
+ const reason = (marked.length > 0 ? marked : lines.slice(-1)).slice(0, 3).join('\n').slice(0, 600);
456
+ return reason === '' ? message : `${message}\n${reason}`;
457
+ }
458
+
431
459
  /** 跑一条命令,收 stdout+stderr 的尾巴。超时或非零退出码都如实带出来。 */
432
460
  function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
433
461
  return new Promise(resolve => {
@@ -558,7 +586,7 @@ async function installLatest({
558
586
  const spec = `${name}@${version}`;
559
587
  // 一律带上:自动更新是「一检测到就装」,手动点的那一下同样是「现在就要」,没理由先让这道
560
588
  // 策略挡一次、再靠失败重试绕过来。命令行与环境变量各带一份,pnpm 12 只认后者(见 POLICY_ENV)。
561
- const policyArgs = ['--config.auto-install-peers=false', '--config.minimum-release-age=0'];
589
+ const policyArgs = ['--config.auto-install-peers=false', '--config.minimum-release-age=0', '--config.ignore-workspace-root-check=true'];
562
590
  const args = picked.manager === 'pnpm'
563
591
  ? ['add', spec, ...policyArgs]
564
592
  : ['install', spec, '--no-audit', '--no-fund'];
@@ -622,7 +650,10 @@ async function installLatest({
622
650
  // 超时与「真的失败了」分开说:前者可以再试,后者多半要看输出。
623
651
  failure: {
624
652
  code: result.code === null ? 'INSTALL_ABORTED' : 'INSTALL_FAILED',
625
- message: `${command} ${result.code === null ? '没能跑完' : `退出码 ${result.code}`}${result.error ? `:${result.error}` : ''}`,
653
+ message: withReason(
654
+ `${command} ${result.code === null ? '没能跑完' : `退出码 ${result.code}`}${result.error ? `:${result.error}` : ''}`,
655
+ output,
656
+ ),
626
657
  },
627
658
  };
628
659
  }
@@ -643,7 +674,10 @@ async function installLatest({
643
674
  output,
644
675
  failure: changed
645
676
  ? null
646
- : { code: 'VERSION_UNCHANGED', message: `${command} 跑完了,但盘上还是 ${after ?? '读不到版本'},这一次不算数` },
677
+ : {
678
+ code: 'VERSION_UNCHANGED',
679
+ message: withReason(`${command} 跑完了,但盘上还是 ${after ?? '读不到版本'},这一次不算数`, output),
680
+ },
647
681
  };
648
682
  }
649
683
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.6",
3
+ "version": "0.5.9",
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",