dsh-mobile-plugin 0.1.74 → 0.1.76
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/README.md +15 -1
- package/lib/index.js +15 -15
- package/lib/mobile/archive-clean.js +24 -6
- package/lib/mobile/attachments.js +15 -2
- package/lib/mobile/deepseek-account.js +125 -0
- package/lib/mobile/host-bridge/endpoints.js +5 -0
- package/lib/mobile/session-cache.js +36 -0
- package/lib/mobile/session-send.js +30 -1
- package/lib/mobile/sessions.js +10 -10
- package/lib/mobile/uploads.js +220 -0
- package/lib/mobile/workspaces.js +3 -2
- package/lib/panel/index.html +8 -8
- package/lib/routes/control-plane.js +26 -0
- package/lib/routes/mobile-api.js +14 -0
- package/lib/shell/device-ota.js +126 -0
- package/lib/shell/manager.js +31 -32
- package/lib/shell/runtime.js +23 -11
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端文件分片上传:把手机分享进来的文件落到宿主机固定收件目录。
|
|
5
|
+
*
|
|
6
|
+
* 四步:begin(登记文件名,拿 uploadId 与片大小)→ chunk(按 index 顺序追加)→
|
|
7
|
+
* finish(落盘成 `<收件根>/<日期>/<文件名>`,重名自动加后缀)→ abort(放弃)。
|
|
8
|
+
*
|
|
9
|
+
* 为什么分片,而不是一次 POST 传完:
|
|
10
|
+
* - 一次传完会把「能传多大的文件」变成一条运维配置 —— 中转的
|
|
11
|
+
* `MAX_BODY_BYTES`(dsh-mobile-server/lib/config.js)默认 50MB,插件自己的
|
|
12
|
+
* readJsonBody 默认只有 256KB。分片后单请求只有 1MB,与这些上限无关。
|
|
13
|
+
* - 手机侧网络不稳,分片能只重传坏掉的那一片,而不是整个文件。
|
|
14
|
+
*
|
|
15
|
+
* 为什么片内容走 base64 而不是原始字节:隧道协议本身支持原始字节
|
|
16
|
+
* (lib/relay/protocol.js 的 httpBody 帧),但 App 侧没有任何「发二进制 body」的
|
|
17
|
+
* 先例,RN 的 fetch 对 ArrayBuffer body 的支持也随版本变;图片通道本来就以 base64
|
|
18
|
+
* 走 JSON,这里沿用同一条确定能用的路。代价是 33% 膨胀(单片 1MB → 约 1.37MB),
|
|
19
|
+
* 仍远低于任何一层上限。
|
|
20
|
+
*
|
|
21
|
+
* 顺序与幂等:chunk 只接受「下一片」,重复送达同一片按重传处理(回当前 received,
|
|
22
|
+
* 不再追加),跳号直接 409 —— 客户端因此不需要在重试前先问进度。
|
|
23
|
+
*
|
|
24
|
+
* 落盘根目录固定 `~/.dsh/mobile-inbox`(收件专目录,与工作区解耦,工作区被删也不
|
|
25
|
+
* 影响已收到的文件)。`DSH_MOBILE_INBOX` 可覆盖,只给测试用。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fsp = require('node:fs/promises');
|
|
29
|
+
const os = require('node:os');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const crypto = require('node:crypto');
|
|
32
|
+
|
|
33
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
34
|
+
|
|
35
|
+
// 片大小 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s(`app/src/api/core.ts`
|
|
36
|
+
// 的 REQUEST_TIMEOUT_MS),1MB 片在慢速上行网络上会撞超时;片越小越稳,代价只是多几次往返。
|
|
37
|
+
const CHUNK_BYTES = 256 * 1024;
|
|
38
|
+
/** 单片请求体上限:1MB base64 后约 1.37MB,留出信封与转义余量。 */
|
|
39
|
+
const CHUNK_MAX_BODY = 4 * 1024 * 1024;
|
|
40
|
+
/** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
|
|
41
|
+
const NAME_MAX_CHARS = 120;
|
|
42
|
+
/** 暂存残片的最长寿命:手机上传中断后没人来 abort 的清理依据。 */
|
|
43
|
+
const STAGING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
44
|
+
/** 分片必须是标准 base64:Node 的 Buffer.from 会静默丢弃非法字符,光靠解码判不出坏片。 */
|
|
45
|
+
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
46
|
+
|
|
47
|
+
/** 进行中的上传:uploadId → { name, stagingPath, received, bytes }。 */
|
|
48
|
+
const uploads = new Map();
|
|
49
|
+
|
|
50
|
+
/** 收件根目录。每次取用都重算,好让测试用环境变量分派。 */
|
|
51
|
+
function inboxRoot() {
|
|
52
|
+
const override = process.env.DSH_MOBILE_INBOX;
|
|
53
|
+
return override ? path.resolve(override) : path.join(os.homedir(), '.dsh', 'mobile-inbox');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 清成一个能安全当作文件名的名字:去掉路径成分、文件系统保留字符与控制字符,
|
|
58
|
+
* 压掉连续空白,掐掉前导点(避免生成隐藏文件),并限长(保住扩展名)。
|
|
59
|
+
*/
|
|
60
|
+
function sanitizeName(raw) {
|
|
61
|
+
const base = path.basename(String(raw == null ? '' : raw).replace(/\\/g, '/'));
|
|
62
|
+
const cleaned = base
|
|
63
|
+
.replace(/[/\\:*?"<>|\u0000-\u001f\u007f]/g, '_')
|
|
64
|
+
.replace(/\s+/g, ' ')
|
|
65
|
+
.trim()
|
|
66
|
+
.replace(/^\.+/, '')
|
|
67
|
+
.trim();
|
|
68
|
+
if (!cleaned) return '未命名文件';
|
|
69
|
+
if (cleaned.length <= NAME_MAX_CHARS) return cleaned;
|
|
70
|
+
const ext = path.extname(cleaned).slice(0, 16);
|
|
71
|
+
return cleaned.slice(0, NAME_MAX_CHARS - ext.length) + ext;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 本地日期 yyyy-mm-dd(收件目录按天分堆,`os` 本地时区更贴近「今天收到的」)。 */
|
|
75
|
+
function today() {
|
|
76
|
+
const now = new Date();
|
|
77
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
78
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 在 dir 下给 name 找一个不冲突的落点(`名字-1.ext`、`名字-2.ext`…)。 */
|
|
82
|
+
async function uniquePath(dir, name) {
|
|
83
|
+
const ext = path.extname(name);
|
|
84
|
+
const stem = name.slice(0, name.length - ext.length) || '未命名文件';
|
|
85
|
+
for (let i = 0; i < 200; i += 1) {
|
|
86
|
+
const candidate = path.join(dir, i === 0 ? name : `${stem}-${i}${ext}`);
|
|
87
|
+
try {
|
|
88
|
+
await fsp.access(candidate);
|
|
89
|
+
} catch {
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return path.join(dir, `${stem}-${Date.now()}${ext}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 清掉过期残片;尽力而为,失败不影响本次上传。 */
|
|
97
|
+
async function sweepStaging(stagingDir) {
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = await fsp.readdir(stagingDir, { withFileTypes: true });
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const cutoff = Date.now() - STAGING_TTL_MS;
|
|
105
|
+
await Promise.all(entries.map(async (entry) => {
|
|
106
|
+
if (!entry.isFile() || !entry.name.endsWith('.part')) return;
|
|
107
|
+
const target = path.join(stagingDir, entry.name);
|
|
108
|
+
try {
|
|
109
|
+
const stat = await fsp.stat(target);
|
|
110
|
+
if (stat.mtimeMs < cutoff) await fsp.unlink(target);
|
|
111
|
+
} catch { /* 已被别人清掉 */ }
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fail(res, status, code, message) {
|
|
116
|
+
return sendJson(res, status, { ok: false, error: { code, message } });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** POST /api/mobile/v1/upload/begin → { ok, uploadId, chunkSize }。 */
|
|
120
|
+
async function handleUploadBegin(_ctx, req, res) {
|
|
121
|
+
const body = await readJsonBody(req);
|
|
122
|
+
if (!body || typeof body.name !== 'string' || !body.name.trim()) {
|
|
123
|
+
return fail(res, 400, 'bad-request', '需要 { name }');
|
|
124
|
+
}
|
|
125
|
+
const stagingDir = path.join(inboxRoot(), '.staging');
|
|
126
|
+
try {
|
|
127
|
+
await fsp.mkdir(stagingDir, { recursive: true });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return fail(res, 500, 'inbox-unavailable', `收件目录不可用:${error.message}`);
|
|
130
|
+
}
|
|
131
|
+
void sweepStaging(stagingDir);
|
|
132
|
+
|
|
133
|
+
const uploadId = crypto.randomUUID();
|
|
134
|
+
const stagingPath = path.join(stagingDir, `${uploadId}.part`);
|
|
135
|
+
try {
|
|
136
|
+
await fsp.writeFile(stagingPath, Buffer.alloc(0));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return fail(res, 500, 'inbox-unavailable', `无法建立暂存文件:${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
uploads.set(uploadId, {
|
|
141
|
+
name: sanitizeName(body.name),
|
|
142
|
+
stagingPath,
|
|
143
|
+
received: 0,
|
|
144
|
+
bytes: 0,
|
|
145
|
+
});
|
|
146
|
+
sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** POST /api/mobile/v1/upload/chunk { uploadId, index, data } → { ok, received }。 */
|
|
150
|
+
async function handleUploadChunk(_ctx, req, res) {
|
|
151
|
+
const body = await readJsonBody(req, CHUNK_MAX_BODY);
|
|
152
|
+
if (!body) return fail(res, 413, 'too-large', '分片过大或不是合法 JSON');
|
|
153
|
+
|
|
154
|
+
const job = uploads.get(String(body.uploadId == null ? '' : body.uploadId));
|
|
155
|
+
if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
156
|
+
|
|
157
|
+
const index = Number(body.index);
|
|
158
|
+
if (!Number.isInteger(index) || index < 0) return fail(res, 400, 'bad-request', 'index 必须是非负整数');
|
|
159
|
+
if (typeof body.data !== 'string') return fail(res, 400, 'bad-request', 'data 必须是 base64 字符串');
|
|
160
|
+
if (index < job.received) return sendJson(res, 200, { ok: true, received: job.received });
|
|
161
|
+
if (index > job.received) {
|
|
162
|
+
return fail(res, 409, 'out-of-order', `期望分片 ${job.received},收到 ${index}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const chunk = Buffer.from(body.data, 'base64');
|
|
166
|
+
if (!BASE64_RE.test(body.data) || body.data.length % 4 !== 0) {
|
|
167
|
+
return fail(res, 400, 'bad-request', 'data 不是合法 base64');
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await fsp.appendFile(job.stagingPath, chunk);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
uploads.delete(String(body.uploadId));
|
|
173
|
+
return fail(res, 500, 'inbox-unavailable', `写入暂存文件失败:${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
job.received += 1;
|
|
176
|
+
job.bytes += chunk.length;
|
|
177
|
+
sendJson(res, 200, { ok: true, received: job.received });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** POST /api/mobile/v1/upload/finish { uploadId } → { ok, path, name, bytes }。 */
|
|
181
|
+
async function handleUploadFinish(_ctx, req, res) {
|
|
182
|
+
const body = await readJsonBody(req);
|
|
183
|
+
const uploadId = String((body && body.uploadId) || '');
|
|
184
|
+
const job = uploads.get(uploadId);
|
|
185
|
+
if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
186
|
+
uploads.delete(uploadId);
|
|
187
|
+
|
|
188
|
+
const dir = path.join(inboxRoot(), today());
|
|
189
|
+
try {
|
|
190
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
191
|
+
const target = await uniquePath(dir, job.name);
|
|
192
|
+
await fsp.rename(job.stagingPath, target);
|
|
193
|
+
sendJson(res, 200, { ok: true, path: target, name: path.basename(target), bytes: job.bytes });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
|
|
196
|
+
return fail(res, 500, 'inbox-unavailable', `落盘失败:${error.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** POST /api/mobile/v1/upload/abort { uploadId } → { ok }。 */
|
|
201
|
+
async function handleUploadAbort(_ctx, req, res) {
|
|
202
|
+
const body = await readJsonBody(req);
|
|
203
|
+
const uploadId = String((body && body.uploadId) || '');
|
|
204
|
+
const job = uploads.get(uploadId);
|
|
205
|
+
if (job) {
|
|
206
|
+
uploads.delete(uploadId);
|
|
207
|
+
try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
|
|
208
|
+
}
|
|
209
|
+
sendJson(res, 200, { ok: true });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = {
|
|
213
|
+
handleUploadBegin,
|
|
214
|
+
handleUploadChunk,
|
|
215
|
+
handleUploadFinish,
|
|
216
|
+
handleUploadAbort,
|
|
217
|
+
sanitizeName,
|
|
218
|
+
inboxRoot,
|
|
219
|
+
CHUNK_BYTES,
|
|
220
|
+
};
|
package/lib/mobile/workspaces.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
18
|
-
const {
|
|
18
|
+
const { stableRevision } = require('../shared/list-revision.js');
|
|
19
19
|
// 会话列表缓存归 sessions.js 管。归档/移动会话会改变列表行本身,必须让那边立刻失效,
|
|
20
20
|
// 否则手机端按完「归档会话」,那一行最长 10 秒还留在侧栏里,看着像没归档。
|
|
21
21
|
const { invalidateSessionList } = require('./sessions.js');
|
|
@@ -74,7 +74,8 @@ async function buildWorkspacesBody(ctx) {
|
|
|
74
74
|
// App 把 generatedAt + revision 存进快照用于展示「数据时间」,emptyConfirmed
|
|
75
75
|
// 让「用户真的删光了工作区」不必再靠连续两次空去猜。
|
|
76
76
|
generatedAt: Date.now(),
|
|
77
|
-
|
|
77
|
+
// 归档集合也是工作区快照语义的一部分;仅它变化时也必须让镜像推送判为新版本。
|
|
78
|
+
revision: stableRevision({ items, archivedSessionIds }),
|
|
78
79
|
emptyConfirmed: items.length === 0,
|
|
79
80
|
};
|
|
80
81
|
}
|