dsh-mobile-plugin 0.5.5 → 0.5.8
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/lib/http/routing.js +20 -3
- package/lib/mobile/history-derive.js +3 -1
- package/lib/mobile/live-steps.js +4 -3
- package/lib/mobile/session-file.js +6 -19
- package/lib/mobile/session-log.js +22 -1
- package/lib/mobile/session-send.js +15 -0
- package/lib/mobile/session-step.js +4 -2
- package/lib/mobile/step-index.js +27 -1
- package/lib/relay/lan-network.cjs +12 -6
- package/lib/routes/control-plane.js +8 -5
- package/lib/routes/mobile-api.js +2 -2
- package/lib/shared/e2ee-chunks.js +83 -0
- package/package.json +1 -1
package/lib/http/routing.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)) {
|
|
@@ -37,6 +37,7 @@ function collectTurnImages(entries, lookbackEntries) {
|
|
|
37
37
|
return byTurn;
|
|
38
38
|
}
|
|
39
39
|
const {
|
|
40
|
+
reasoningText,
|
|
40
41
|
reasoningStepSummary,
|
|
41
42
|
toolStepSummary,
|
|
42
43
|
toolStepState,
|
|
@@ -516,7 +517,8 @@ function deriveMessages(events, sessionId, lookbackEvents) {
|
|
|
516
517
|
content.forEach((raw, index) => {
|
|
517
518
|
if (!raw || typeof raw !== 'object') return;
|
|
518
519
|
if (raw.type === 'reasoning') {
|
|
519
|
-
|
|
520
|
+
// 签名块先剥掉再判空:只剩签名的那一段等于没有推理,不算一步(桌面那边是整行不显示)。
|
|
521
|
+
const text = reasoningText(raw.text);
|
|
520
522
|
// 空推理块(只有零宽字符那种)不算一步:App 点开一条什么都没有的行没有意义。
|
|
521
523
|
if (text.trim() === '') return;
|
|
522
524
|
list.push({
|
package/lib/mobile/live-steps.js
CHANGED
|
@@ -23,7 +23,7 @@ const MAX_SESSIONS = 4;
|
|
|
23
23
|
const TEXT_MAX = 200_000;
|
|
24
24
|
|
|
25
25
|
const { liveStepTurnOf } = require('./session-cache.js');
|
|
26
|
-
const { reasoningStepSummary, toolStepSummary } = require('./step-index.js');
|
|
26
|
+
const { reasoningText, reasoningStepSummary, toolStepSummary } = require('./step-index.js');
|
|
27
27
|
|
|
28
28
|
function bucketOf(ctx) {
|
|
29
29
|
return liveStepTurnOf(ctx);
|
|
@@ -328,8 +328,9 @@ function liveStepOf(ctx, sessionId, query) {
|
|
|
328
328
|
if (query && query.kind === 'think') {
|
|
329
329
|
const index = Number.isSafeInteger(query.index) ? query.index : null;
|
|
330
330
|
if (index === null) return null;
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
// 签名块在展示前剥掉(与桌面同口径);剥完没有正文,这一段思考就当还没有内容。
|
|
332
|
+
const text = reasoningText(turn.thoughts.get(index));
|
|
333
|
+
if (text === '') return null;
|
|
333
334
|
return { kind: 'think', text, live: true, state: turn.ended ? 'ok' : 'running' };
|
|
334
335
|
}
|
|
335
336
|
const callId = query && typeof query.callId === 'string' ? query.callId : '';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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)) {
|
|
@@ -20,7 +20,7 @@ const { sendJson } = require('../shared/http-util');
|
|
|
20
20
|
const { unwrap } = require('./envelope.js');
|
|
21
21
|
const { callSessions } = require('./rpc.js');
|
|
22
22
|
const { liveStepOf } = require('./live-steps.js');
|
|
23
|
-
const { textOfParts } = require('./step-index.js');
|
|
23
|
+
const { reasoningText, textOfParts } = require('./step-index.js');
|
|
24
24
|
|
|
25
25
|
/** 详情单段文本的上限(参数 / 输出 / 思考全文各自适用)。 */
|
|
26
26
|
const DETAIL_MAX_CHARS = 200_000;
|
|
@@ -106,7 +106,9 @@ async function historyStep(ctx, { sessionId, kind, callId, seq, index, resultSeq
|
|
|
106
106
|
const message = target && target.data && target.data.message;
|
|
107
107
|
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
108
108
|
const block = content[index];
|
|
109
|
-
|
|
109
|
+
// 签名块(`<signature>…</signature>`)先剥掉再判断有没有正文:宿主数据里那段还在,
|
|
110
|
+
// 桌面的剥法是渲染层的,这里不跟着剥就会把一整段签名原文摊给用户看。
|
|
111
|
+
const text = block && block.type === 'reasoning' ? reasoningText(block.text) : '';
|
|
110
112
|
if (!text) return null;
|
|
111
113
|
const capped = capText(text);
|
|
112
114
|
return { kind: 'think', state: 'ok', text: capped.text, ...(capped.truncated ? { truncated: true } : {}) };
|
package/lib/mobile/step-index.js
CHANGED
|
@@ -24,9 +24,34 @@ function clampSummary(text, max = STEP_SUMMARY_MAX) {
|
|
|
24
24
|
return `${value.slice(0, max - 1)}…`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 推理正文里模型带的签名块(`<signature>…</signature>`)在展示前剥掉。
|
|
29
|
+
*
|
|
30
|
+
* 桌面从 0.2.1-rc.5 起在渲染层做同一件事,但宿主存的会话数据里那段还在 —— 手机不从数据里
|
|
31
|
+
* 剥一遍,清单摘要那第一行就是一串 base64、展开详情整段噪声。流式期间只写到半截签名
|
|
32
|
+
* (`<sig`)时返回空串,不让它先闪一下再消失;剥完什么都不剩的块等于没有推理,调用方按空处理。
|
|
33
|
+
*/
|
|
34
|
+
function reasoningText(text) {
|
|
35
|
+
const value = typeof text === 'string' ? text : '';
|
|
36
|
+
if (value[0] !== '<') return value;
|
|
37
|
+
const opening = '<signature>';
|
|
38
|
+
if (opening.startsWith(value)) return '';
|
|
39
|
+
if (!value.startsWith(opening)) return value;
|
|
40
|
+
const closing = '</signature>';
|
|
41
|
+
const end = value.indexOf(closing, opening.length);
|
|
42
|
+
if (end === -1) return '';
|
|
43
|
+
let start = end + closing.length;
|
|
44
|
+
while (start < value.length) {
|
|
45
|
+
if (value[start] === '\n') start += 1;
|
|
46
|
+
else if (value[start] === '\r' && value[start + 1] === '\n') start += 2;
|
|
47
|
+
else break;
|
|
48
|
+
}
|
|
49
|
+
return value.slice(start);
|
|
50
|
+
}
|
|
51
|
+
|
|
27
52
|
/** 推理正文的一行摘要。 */
|
|
28
53
|
function reasoningStepSummary(text) {
|
|
29
|
-
return clampSummary(firstLine(text));
|
|
54
|
+
return clampSummary(firstLine(reasoningText(text)));
|
|
30
55
|
}
|
|
31
56
|
|
|
32
57
|
/** 参数 JSON → 对象;解析不了就返回空对象(畸形参数不该让清单少一条)。 */
|
|
@@ -100,6 +125,7 @@ module.exports = {
|
|
|
100
125
|
STEP_SUMMARY_MAX,
|
|
101
126
|
firstLine,
|
|
102
127
|
clampSummary,
|
|
128
|
+
reasoningText,
|
|
103
129
|
reasoningStepSummary,
|
|
104
130
|
parseArgs,
|
|
105
131
|
toolStepSummary,
|
|
@@ -352,15 +352,21 @@ function manualCandidate(value) {
|
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
/**
|
|
355
|
-
* 候选 =
|
|
356
|
-
*
|
|
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 [
|
|
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
|
-
*
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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 取走。
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -189,8 +189,8 @@ function registerMobileRoutes(deps) {
|
|
|
189
189
|
const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort
|
|
190
190
|
// 用户在面板上选过的「手机优先用这条」(设置项 `preferredLanAddress`)在这里同样兑现:
|
|
191
191
|
// 手机是按顺序挨个试的,所以只挪顺序、不删候选。选的那条不在了就退回宿主排序。
|
|
192
|
-
//
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
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",
|