dsh-mobile-plugin 0.5.4 → 0.5.5
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/mobile/file-read.js
CHANGED
|
@@ -23,7 +23,7 @@ const { pipeline } = require('node:stream/promises');
|
|
|
23
23
|
|
|
24
24
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
25
25
|
const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
|
|
26
|
-
const { writeEncryptedFile, SESSION_FILE_MAX_BYTES } = require('./session-file.js');
|
|
26
|
+
const { writeEncryptedFile, SESSION_FILE_MAX_BYTES, encryptedLengthOf } = require('./session-file.js');
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* 扩展名 → 内容类型。与 `session-file.js` 那张表逐条相同:两条路给同一个文件必须给出同一个
|
|
@@ -137,14 +137,19 @@ async function handleFileRead(ctx, req, res) {
|
|
|
137
137
|
'cache-control': 'no-store',
|
|
138
138
|
};
|
|
139
139
|
|
|
140
|
-
// 协议 v2:与 `/session/file` 逐字节同形(`x-dsh-content-type`
|
|
141
|
-
//
|
|
140
|
+
// 协议 v2:与 `/session/file` 逐字节同形(`x-dsh-content-type` 带明文类型、**带
|
|
141
|
+
// `content-length` 而不发分块**,真实类型单独给一份让 App 解密后决定怎么预览)。
|
|
142
|
+
// 长度是算出来的(`encryptedLengthOf`),为什么要给见那条路由的注释:Android 原生下载器
|
|
143
|
+
// 按「实收字节数 == content-length」判完整,分块响应会被它判成 `Download interrupted.`。
|
|
142
144
|
const opened = req && req.__dshE2ee;
|
|
143
145
|
if (opened && opened.keys && opened.req) {
|
|
144
146
|
res.writeHead(200, {
|
|
145
147
|
...header,
|
|
146
148
|
'content-type': 'application/x-dsh-e2ee-chunks',
|
|
147
|
-
'
|
|
149
|
+
'content-length': String(encryptedLengthOf(info.size)),
|
|
150
|
+
// 原文件的字节数:中转有时会把 `content-length` 吞掉(改成分块),而客户端要靠它
|
|
151
|
+
// 判「这份到底收全没有」—— 分块信封自己的认证判不出「正好在块边界被截断」那种。
|
|
152
|
+
'x-dsh-file-size': String(info.size),
|
|
148
153
|
'x-dsh-proto': '2',
|
|
149
154
|
'x-dsh-content-type': header['content-type'],
|
|
150
155
|
});
|
|
@@ -178,15 +178,22 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
178
178
|
// 协议 v2:分块信封(§8.3(6))。
|
|
179
179
|
//
|
|
180
180
|
// 套一次性 AEAD 意味着宿主得先把整份读进内存 —— 单文件上限 512MB,那条路不能走。
|
|
181
|
-
// 所以改成固定分块流式写:宿主仍然边读边发,内存占用还是一块的大小;App
|
|
182
|
-
//
|
|
181
|
+
// 所以改成固定分块流式写:宿主仍然边读边发,内存占用还是一块的大小;App 那边按长度字段
|
|
182
|
+
// 切块、逐块解即可,不需要流式解密栈。
|
|
183
183
|
const opened = req && req.__dshE2ee;
|
|
184
184
|
if (opened && opened.keys && opened.req) {
|
|
185
185
|
res.writeHead(200, {
|
|
186
186
|
...header,
|
|
187
187
|
'content-type': 'application/x-dsh-e2ee-chunks',
|
|
188
|
-
//
|
|
189
|
-
|
|
188
|
+
// **给长度,不发分块**(2026-10-30):加密后这份响应有多长是算得出来的(见
|
|
189
|
+
// `encryptedLengthOf`),而不给长度的代价很具体 —— 客户端只能「流到哪儿算哪儿」地判
|
|
190
|
+
// 收没收完,Android 那个原生下载器更是按「实收字节数 == content-length」判,
|
|
191
|
+
// 分块响应在它那儿一律判成 `Download interrupted.`,手机上就是「点开文件说网络断了」。
|
|
192
|
+
// 长度顺带还是这份分块信封自己的完整性凭据:被截断的字节数一定对不上。
|
|
193
|
+
'content-length': String(encryptedLengthOf(info.size)),
|
|
194
|
+
// 原文件的字节数:中转有时会把 `content-length` 吞掉(改成分块),而客户端要靠它
|
|
195
|
+
// 判「这份到底收全没有」—— 分块信封自己的认证判不出「正好在块边界被截断」那种。
|
|
196
|
+
'x-dsh-file-size': String(info.size),
|
|
190
197
|
'x-dsh-proto': '2',
|
|
191
198
|
// 真实类型单独给:App 解密之后要拿它决定怎么预览。
|
|
192
199
|
'x-dsh-content-type': header['content-type'],
|
|
@@ -264,4 +271,26 @@ function writeBlock(res, e2ee, key, block, aad) {
|
|
|
264
271
|
/** 交付文件的分块大小。64KB:一块一次 AEAD,太小会让每块的 16+12 字节固定开销占比变高。 */
|
|
265
272
|
const FILE_CHUNK_BYTES = 64 * 1024;
|
|
266
273
|
|
|
267
|
-
|
|
274
|
+
/** 一块除明文字节之外还带多少:`[4 字节长度][12 字节 nonce][16 字节 tag]`。 */
|
|
275
|
+
const FILE_BLOCK_OVERHEAD = 4 + 12 + 16;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 一份文件按块加密之后在网上会有多少字节。
|
|
279
|
+
*
|
|
280
|
+
* 块数 = `floor(size / FILE_CHUNK_BYTES)` 个满块 + **永远存在的那一块尾块**(空文件也要一块)。
|
|
281
|
+
* 这个数在写之前就得用(响应头的 `content-length`),所以它必须与 `writeEncryptedFile` 的
|
|
282
|
+
* 写法逐字对得上 —— 后果不对称:算少了对端读不满、算多了对端一直等,两种都是「文件打不开」。
|
|
283
|
+
* 守卫拿几个跨块边界的长度钉住它。
|
|
284
|
+
*/
|
|
285
|
+
function encryptedLengthOf(size) {
|
|
286
|
+
const plain = Math.max(0, Number(size) || 0);
|
|
287
|
+
return plain + (Math.floor(plain / FILE_CHUNK_BYTES) + 1) * FILE_BLOCK_OVERHEAD;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = {
|
|
291
|
+
handleSessionFile,
|
|
292
|
+
SESSION_FILE_MAX_BYTES,
|
|
293
|
+
FILE_CHUNK_BYTES,
|
|
294
|
+
writeEncryptedFile,
|
|
295
|
+
encryptedLengthOf,
|
|
296
|
+
};
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -328,7 +328,12 @@ function createLanProxy(opts) {
|
|
|
328
328
|
(ures) => {
|
|
329
329
|
const out = {};
|
|
330
330
|
for (const [k, v] of Object.entries(ures.headers)) {
|
|
331
|
-
|
|
331
|
+
// `content-length` 照原样转出去(**它不是逐跳头**,见 `HOP_BY_HOP` 上面那条说明):
|
|
332
|
+
// 丢了它,每一发响应都退化成 chunked,客户端拿不到长度 —— 小到进度条、大到
|
|
333
|
+
// 「这份文件收完了没有」都无从判断。中转那条路早就这么处理了
|
|
334
|
+
//(`dsh-mobile-server` 的 `headers.rs` 里那个 `forwardable_response_header`),
|
|
335
|
+
// 两条路对同一个宿主必须给同一份头,否则同一个文件在 Wi-Fi 下和在外面表现不一样。
|
|
336
|
+
if (!HOP_BY_HOP.has(k) || k === 'content-length') out[k] = v;
|
|
332
337
|
}
|
|
333
338
|
res.writeHead(ures.statusCode, out);
|
|
334
339
|
// SSE 的第一段(`: connected` 与响应头)要立刻到手机,别等内核攒够一个段。
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -30,7 +30,6 @@ const workspaces = require('../mobile/workspaces.js');
|
|
|
30
30
|
const directoryListing = require('../mobile/directory-listing.js');
|
|
31
31
|
const archiveClean = require('../mobile/archive-clean.js');
|
|
32
32
|
const sessionLog = require('../mobile/session-log.js');
|
|
33
|
-
const debugTrace = require('../mobile/debug-trace.js');
|
|
34
33
|
const hostSettings = require('../mobile/host-settings.js');
|
|
35
34
|
const channels = require('../mobile/channels.js');
|
|
36
35
|
const deepSeekAccount = require('../mobile/deepseek-account.js');
|
|
@@ -333,10 +332,6 @@ function registerMobileRoutes(deps) {
|
|
|
333
332
|
// 的 127.0.0.1 上手机直连不到 —— 所以由插件代取(mobile/session-log.js),App 侧
|
|
334
333
|
// 再落盘。
|
|
335
334
|
route('/api/mobile/v1/session-log', sessionLog.handleSessionLog)
|
|
336
|
-
// 手机端调试轨迹(开发包专用,见 mobile/debug-trace.js):App 录滑动过程,边录边把 JSONL
|
|
337
|
-
// 打到这里,插件写进 <DSH_HOME>/mobile-debug/<capture>.jsonl,维护者在电脑上直接读。
|
|
338
|
-
// 只追加、轨迹名走白名单、单份 8MB 封顶 —— 不是「任意文件写入」。
|
|
339
|
-
route('/api/mobile/v1/debug/trace', debugTrace.handleDebugTrace)
|
|
340
335
|
// 宿主设置与模型提供方(宿主 settings.describe / llm.providers 的移动端只读代理)。
|
|
341
336
|
// 桌面端「设置」里有默认模型、提供方配置、凭据是否已设;手机端此前完全没有这一块,
|
|
342
337
|
// 只能看到 App 自己的偏好项。两个宿主接口本身已脱敏(secrets 只给 path 与 set),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 手机端调试轨迹的落盘口(`POST /api/mobile/v1/debug/trace`)。
|
|
5
|
-
*
|
|
6
|
-
* 为什么要有它:滑动手感出问题时,现场在手机上(偏移轨迹、每一行的实际高度、图片什么时候
|
|
7
|
-
* 才到),而能看这些东西的人在这台电脑上。让人把几万行日志抄进聊天不现实,所以 App 边录边把
|
|
8
|
-
* 一行行 JSONL 打到这里,插件落进 `<DSH_HOME>/mobile-debug/<capture>.jsonl`,维护者在这台
|
|
9
|
-
* 电脑上直接读文件。
|
|
10
|
-
*
|
|
11
|
-
* 三条边界,别放宽:
|
|
12
|
-
* - **不是任意文件写入**:文件名只由 `capture` 生成,且只认 `[A-Za-z0-9._-]`。构造一个别的
|
|
13
|
-
* capture 只会在这一个目录里多一份空文件,碰不到用户的任何东西;
|
|
14
|
-
* - **只追加**:一次请求往 `<capture>.jsonl` 末尾续一段,从不覆盖、从不删别的文件;
|
|
15
|
-
* - **封顶**:一份轨迹 8MB 之后停写(回 `capped: true`,App 收到就停手),目录里最多留 10 份,
|
|
16
|
-
* 第 11 份进来时删掉最旧那份。录音不会把盘写满。
|
|
17
|
-
*
|
|
18
|
-
* 与 `session/file`、`session/attachment` 那些读口方向相反:这条只写、写的位置固定,回给手机
|
|
19
|
-
* 的只有文件名与字节数,不回路径。
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
const fs = require('node:fs');
|
|
23
|
-
const fsp = require('node:fs/promises');
|
|
24
|
-
const path = require('node:path');
|
|
25
|
-
|
|
26
|
-
const { dshHome } = require('../shared/paths');
|
|
27
|
-
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
28
|
-
|
|
29
|
-
/** 一份轨迹的字节上限。真到这一步说明录得太久,该停了。 */
|
|
30
|
-
const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
|
|
31
|
-
/** 目录里最多留几份(按 mtime,新的来、旧的走)。 */
|
|
32
|
-
const MAX_CAPTURES = 10;
|
|
33
|
-
/** 一次请求最多带多少行;App 那边按 40KB 切批,正常远小于这个数。 */
|
|
34
|
-
const MAX_LINES_PER_BATCH = 4000;
|
|
35
|
-
/** 一次请求最多多少字节(`readJsonBody` 本身还有 344KB 的封顶,这里再收一道)。 */
|
|
36
|
-
const MAX_BATCH_BYTES = 512 * 1024;
|
|
37
|
-
/** 轨迹名(也就是文件名):只认这几类字符,别的一律 400。 */
|
|
38
|
-
const CAPTURE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
39
|
-
|
|
40
|
-
/** 轨迹目录。按调用时解析,测试可注入 `DSH_HOME`。 */
|
|
41
|
-
function traceDir() {
|
|
42
|
-
return path.join(dshHome(), 'mobile-debug');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 某一份轨迹的落盘路径(`capture` 已经过白名单校验)。 */
|
|
46
|
-
function capturePath(capture) {
|
|
47
|
-
return path.join(traceDir(), `${capture}.jsonl`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 目录里留最近 `MAX_CAPTURES` 份。
|
|
52
|
-
*
|
|
53
|
-
* 按 mtime 而不是文件名:轨迹名由 App 带时间戳生成,但同一秒的两份、或者用户手改过的名字都
|
|
54
|
-
* 不该让清理算错。读不动 stat 的(正在被删)直接跳过,清理失败不该让这次上传失败。
|
|
55
|
-
*/
|
|
56
|
-
async function pruneCaptures() {
|
|
57
|
-
let names;
|
|
58
|
-
try {
|
|
59
|
-
names = await fsp.readdir(traceDir());
|
|
60
|
-
} catch {
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const files = [];
|
|
64
|
-
for (const name of names) {
|
|
65
|
-
if (!name.endsWith('.jsonl')) continue;
|
|
66
|
-
const full = path.join(traceDir(), name);
|
|
67
|
-
try {
|
|
68
|
-
const info = await fsp.stat(full);
|
|
69
|
-
if (info.isFile()) files.push({ name, full, mtime: info.mtimeMs });
|
|
70
|
-
} catch { /* 刚被删掉 / 读不动:跳过 */ }
|
|
71
|
-
}
|
|
72
|
-
if (files.length <= MAX_CAPTURES) return;
|
|
73
|
-
files.sort((a, b) => a.mtime - b.mtime);
|
|
74
|
-
for (const file of files.slice(0, files.length - MAX_CAPTURES)) {
|
|
75
|
-
try {
|
|
76
|
-
await fsp.unlink(file.full);
|
|
77
|
-
} catch { /* 删不掉就留着,下次再说 */ }
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* 续写一段轨迹。
|
|
83
|
-
*
|
|
84
|
-
* `lines` 是已经序列化好的 JSON 行(App 侧拼的),这里只做「拼成一段文本 + 追加」。一行都
|
|
85
|
-
* 不合法的批次按空处理:回 ok、写 0 字节,别让手机那头因为一次形状不对就整段录不上。
|
|
86
|
-
*/
|
|
87
|
-
async function handleDebugTrace(_ctx, req, res) {
|
|
88
|
-
const body = await readJsonBody(req);
|
|
89
|
-
const capture = body && typeof body.capture === 'string' ? body.capture : '';
|
|
90
|
-
if (!CAPTURE_RE.test(capture)) {
|
|
91
|
-
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { capture },且只认字母数字与 ._-' } });
|
|
92
|
-
}
|
|
93
|
-
const raw = body && Array.isArray(body.lines) ? body.lines : null;
|
|
94
|
-
if (!raw) {
|
|
95
|
-
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { lines: string[] }' } });
|
|
96
|
-
}
|
|
97
|
-
if (raw.length > MAX_LINES_PER_BATCH) {
|
|
98
|
-
return sendJson(res, 413, { ok: false, error: { code: 'too-many-lines', message: `一次最多 ${MAX_LINES_PER_BATCH} 行` } });
|
|
99
|
-
}
|
|
100
|
-
const lines = raw.filter((line) => typeof line === 'string' && line.length > 0);
|
|
101
|
-
if (lines.length === 0) {
|
|
102
|
-
return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: 0, lines: 0, capped: false });
|
|
103
|
-
}
|
|
104
|
-
const text = `${lines.join('\n')}\n`;
|
|
105
|
-
const bytes = Buffer.byteLength(text, 'utf8');
|
|
106
|
-
if (bytes > MAX_BATCH_BYTES) {
|
|
107
|
-
return sendJson(res, 413, { ok: false, error: { code: 'too-large', message: `一次最多 ${MAX_BATCH_BYTES} 字节` } });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const file = capturePath(capture);
|
|
111
|
-
try {
|
|
112
|
-
await fsp.mkdir(traceDir(), { recursive: true });
|
|
113
|
-
} catch (error) {
|
|
114
|
-
return sendJson(res, 500, { ok: false, error: { code: 'io', message: `建目录失败:${error && error.message ? error.message : ''}` } });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let existing = 0;
|
|
118
|
-
let created = false;
|
|
119
|
-
try {
|
|
120
|
-
existing = (await fsp.stat(file)).size;
|
|
121
|
-
} catch {
|
|
122
|
-
created = true;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (existing >= MAX_CAPTURE_BYTES) {
|
|
126
|
-
// 已经写满:回 capped 让 App 停手。文件留着,人要看的正是前面那一段。
|
|
127
|
-
return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: existing, lines: 0, capped: true });
|
|
128
|
-
}
|
|
129
|
-
// 快写满时**按整行留**:切在字节中间会让最后一行变成半截 JSON,读的人只会看到一行坏数据。
|
|
130
|
-
const room = MAX_CAPTURE_BYTES - existing;
|
|
131
|
-
let payload = text;
|
|
132
|
-
if (bytes > room) {
|
|
133
|
-
const kept = [];
|
|
134
|
-
let used = 0;
|
|
135
|
-
for (const line of lines) {
|
|
136
|
-
const size = Buffer.byteLength(line, 'utf8') + 1;
|
|
137
|
-
if (used + size > room) break;
|
|
138
|
-
kept.push(line);
|
|
139
|
-
used += size;
|
|
140
|
-
}
|
|
141
|
-
payload = kept.length > 0 ? `${kept.join('\n')}\n` : '';
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
await fsp.appendFile(file, payload);
|
|
145
|
-
} catch (error) {
|
|
146
|
-
return sendJson(res, 500, { ok: false, error: { code: 'io', message: `写盘失败:${error && error.message ? error.message : ''}` } });
|
|
147
|
-
}
|
|
148
|
-
// 清理放在写完之后:这一份刚建出来的文件按 mtime 是最新的,留下来的正是它。
|
|
149
|
-
if (created) await pruneCaptures();
|
|
150
|
-
let total = existing;
|
|
151
|
-
try {
|
|
152
|
-
total = (await fsp.stat(file)).size;
|
|
153
|
-
} catch { /* 写成功但 stat 失败:回一个估计值,不影响 App */ }
|
|
154
|
-
sendJson(res, 200, {
|
|
155
|
-
ok: true,
|
|
156
|
-
file: `${capture}.jsonl`,
|
|
157
|
-
bytes: total,
|
|
158
|
-
lines: payload === text ? lines.length : (payload ? payload.split('\n').length - 1 : 0),
|
|
159
|
-
// 「这一段被切了」或「已经顶到上限」都算满:App 收到就停手,别继续往一条死路上发。
|
|
160
|
-
capped: payload !== text || total >= MAX_CAPTURE_BYTES,
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** 同步读一份轨迹(维护者在电脑上直接看文件;这里只给测试与将来的面板用)。 */
|
|
165
|
-
function readCapture(capture) {
|
|
166
|
-
if (!CAPTURE_RE.test(capture)) return null;
|
|
167
|
-
try {
|
|
168
|
-
return fs.readFileSync(capturePath(capture), 'utf8');
|
|
169
|
-
} catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
module.exports = {
|
|
175
|
-
handleDebugTrace,
|
|
176
|
-
readCapture,
|
|
177
|
-
traceDir,
|
|
178
|
-
MAX_CAPTURE_BYTES,
|
|
179
|
-
MAX_CAPTURES,
|
|
180
|
-
};
|