dsh-mobile-plugin 0.5.14 → 0.5.15
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/approvals.js +15 -0
- package/lib/mobile/canvas-store.js +365 -0
- package/lib/mobile/monitor.js +23 -4
- package/lib/mobile/questions.js +15 -0
- package/lib/mobile/session-flags.js +19 -1
- package/lib/mobile/uploads.js +36 -4
- package/lib/routes/mobile-api.js +9 -0
- package/package.json +1 -1
package/lib/mobile/approvals.js
CHANGED
|
@@ -156,6 +156,20 @@ function hasPending(sessionId) {
|
|
|
156
156
|
return openEntries(sessionId).length > 0;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* 这条会话此刻挂着的审批卡(形状与推送帧一字不差),没有就 undefined。
|
|
161
|
+
*
|
|
162
|
+
* 与提问桥的 `pendingCardOf` 同一用途:手机回前台时先问原生要卡,不必等流重新接上
|
|
163
|
+
* (见 `questions.js` 那一处的说明)。只认已绑定的项 —— 与 `attachSession` 的补发口径
|
|
164
|
+
* 一致:没绑上钩子的卡答不动,送上去只会让人点出一个 409。
|
|
165
|
+
*/
|
|
166
|
+
function pendingCardOf(sessionId) {
|
|
167
|
+
for (const entry of openEntries(sessionId)) {
|
|
168
|
+
if (entry.bound) return cardFrame(entry);
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
159
173
|
/** 测试用:清空全部进程内状态(真实运行时由插件卸载时的 disposer 收尾)。 */
|
|
160
174
|
function resetForTest() {
|
|
161
175
|
watchers.clear();
|
|
@@ -367,5 +381,6 @@ module.exports = {
|
|
|
367
381
|
handleApprovalRespond,
|
|
368
382
|
attachSession,
|
|
369
383
|
hasPending,
|
|
384
|
+
pendingCardOf,
|
|
370
385
|
resetForTest,
|
|
371
386
|
};
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 无限画布的库(`/api/mobile/v1/canvas/*`)。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要有它:画布整个活在 App 的 WebView 里,项目和素材全在页面的 IndexedDB 中 ——
|
|
7
|
+
* 清一次缓存、换一台手机就没了,桌面端也看不见。这个模块把「库」搬到宿主机上,App 负责
|
|
8
|
+
* 把本机那份推上来、把远端那份拉下去,桌面端以后读的是同一份。
|
|
9
|
+
*
|
|
10
|
+
* 存两样东西,各管一件事:
|
|
11
|
+
* · **清单**(每个域一份 JSON):项目图、资产行、工作台记录这些「结构」,加上一份
|
|
12
|
+
* `files: [{storageKey, hash, mimeType, bytes}]` —— 结构里引用了哪些字节、各是哪一份。
|
|
13
|
+
* · **字节**(`objects/<哈希前两位>/<其余>`):按内容哈希落盘,只增不改。同一个 storageKey
|
|
14
|
+
* 在手机上是不可变的(键是随机串),所以判「要不要传」只看远端清单里有没有这个键。
|
|
15
|
+
*
|
|
16
|
+
* 清单永远在字节之后写:先传缺的文件、再写清单。写清单这一步会逐个 stat 文件,缺一个就
|
|
17
|
+
* 整份拒绝(`missing-file`)—— 一份指向不存在字节的清单在手机上表现为「画布能打开、图全是
|
|
18
|
+
* 坏的」,而且它会把下一次同步判成「已同步」,坏得无声无息。
|
|
19
|
+
*
|
|
20
|
+
* 能力面:只有一个固定目录,**客户端给不了任何路径**,也只会拿到哈希。文件名是 64 位十六
|
|
21
|
+
* 进制(不是哈希形状的一律不认,顺带挡住路径穿越),与分片上传那条通道同一口径。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const crypto = require('node:crypto');
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const fsp = require('node:fs/promises');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { pipeline } = require('node:stream/promises');
|
|
29
|
+
|
|
30
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
31
|
+
const { dshHome } = require('../shared/paths.js');
|
|
32
|
+
const { writeEncryptedFile, encryptedLengthOf } = require('./session-file.js');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 四个域与画布自己的同步引擎一一对应(`app/vendor/canvas-src` 的 app-sync)。
|
|
36
|
+
*
|
|
37
|
+
* 域是清单的分片,不是权限:每个域一份文件,谁变了推谁,别把整库塞进一份 JSON。
|
|
38
|
+
*/
|
|
39
|
+
const DOMAINS = new Set(['canvas', 'assets', 'image-workbench', 'video-workbench']);
|
|
40
|
+
|
|
41
|
+
/** 清单请求体上限(**明文**字节):与分片上传同一个 8MB 预算,够装几万个节点。 */
|
|
42
|
+
const MAX_MANIFEST_BYTES = Math.ceil(8 * 1024 * 1024 * 4 / 3);
|
|
43
|
+
/** 一份清单最多认多少个文件条目:再多也传不完,早报错比传到一半失败好。 */
|
|
44
|
+
const MAX_FILES = 20000;
|
|
45
|
+
/** 单份字节的上限:与「会话文件」同一条口径,超过的整份传不回来。 */
|
|
46
|
+
const MAX_OBJECT_BYTES = 512 * 1024 * 1024;
|
|
47
|
+
/** 内容哈希:64 位小写十六进制。别的形状一律不认(这一条同时挡住路径穿越)。 */
|
|
48
|
+
const HASH_RE = /^[0-9a-f]{64}$/;
|
|
49
|
+
/** storageKey 只当清单里的键用、不拼路径,但仍然要求是干净的字符串。 */
|
|
50
|
+
const STORAGE_KEY_MAX = 300;
|
|
51
|
+
/** 清单版本号取内容哈希的前 16 位:同一份内容算出同一个号,不需要另存一个自增计数。 */
|
|
52
|
+
const REVISION_CHARS = 16;
|
|
53
|
+
|
|
54
|
+
/** 库根:插件自己的数据目录,`DSH_MOBILE_CANVAS` 只给测试用。 */
|
|
55
|
+
function rootDir() {
|
|
56
|
+
return process.env.DSH_MOBILE_CANVAS || path.join(dshHome(), 'storages', 'mobile-plugin', 'canvas');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 某个域的清单文件。 */
|
|
60
|
+
function manifestPath(domain) {
|
|
61
|
+
return path.join(rootDir(), `manifest-${domain}.json`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 字节区根。 */
|
|
65
|
+
function objectsRoot() {
|
|
66
|
+
return path.join(rootDir(), 'objects');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 哈希 → 盘上路径。形状不对回 null:这个值要拼进文件路径,校验放在这里,不靠调用方记得。
|
|
71
|
+
*/
|
|
72
|
+
function objectPathOf(hash) {
|
|
73
|
+
if (typeof hash !== 'string' || !HASH_RE.test(hash)) return null;
|
|
74
|
+
return path.join(objectsRoot(), hash.slice(0, 2), hash.slice(2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 域 → 规范值;不认识回 null。 */
|
|
78
|
+
function domainOf(value) {
|
|
79
|
+
const domain = typeof value === 'string' ? value.trim() : '';
|
|
80
|
+
return DOMAINS.has(domain) ? domain : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 内容 → 版本号(内容变了才对不上,所以不需要另记时间)。 */
|
|
84
|
+
function revisionOf(payload) {
|
|
85
|
+
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, REVISION_CHARS);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 读某个域的清单。没有回 null;盘上有但读不懂**如实抛**,不当成「没有」——
|
|
90
|
+
* 当成没有会让手机把本机那份推上来,把远端那份直接盖掉(那是最不该发生的静默失败)。
|
|
91
|
+
*/
|
|
92
|
+
async function readManifest(domain) {
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = await fsp.readFile(manifestPath(domain), 'utf8');
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error && error.code === 'ENOENT') return null;
|
|
98
|
+
throw Object.assign(new Error(`读画布清单失败:${error.message}`), { code: 'canvas-store-unavailable' });
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(text);
|
|
102
|
+
if (!parsed || typeof parsed !== 'object' || parsed.domain !== domain) throw new Error('形状不对');
|
|
103
|
+
return parsed;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw Object.assign(new Error(`画布清单已损坏(${domain}):${error.message}`), { code: 'manifest-corrupt' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 哈希对应的字节在不在盘上(清单落盘之前要逐个确认)。 */
|
|
110
|
+
async function objectExists(hash) {
|
|
111
|
+
const target = objectPathOf(hash);
|
|
112
|
+
if (!target) return false;
|
|
113
|
+
try {
|
|
114
|
+
const info = await fsp.stat(target);
|
|
115
|
+
return info.isFile();
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 校验并规范化一份要写进来的清单。
|
|
123
|
+
*
|
|
124
|
+
* @returns {{ status: number, code: string, message: string, missing?: string[] } | { files: Array<object>, data: unknown }}
|
|
125
|
+
*/
|
|
126
|
+
function normalizePayload(body) {
|
|
127
|
+
const domain = domainOf(body.domain);
|
|
128
|
+
if (!domain) return { status: 400, code: 'bad-domain', message: 'domain 只能是 canvas / assets / image-workbench / video-workbench' };
|
|
129
|
+
if (!body.data || typeof body.data !== 'object' || Array.isArray(body.data)) {
|
|
130
|
+
return { status: 400, code: 'bad-data', message: '需要 { data }(一个对象)' };
|
|
131
|
+
}
|
|
132
|
+
const rawFiles = body.files === undefined ? [] : body.files;
|
|
133
|
+
if (!Array.isArray(rawFiles)) return { status: 400, code: 'bad-files', message: 'files 必须是数组' };
|
|
134
|
+
if (rawFiles.length > MAX_FILES) {
|
|
135
|
+
return { status: 413, code: 'too-many-files', message: `一份清单最多 ${MAX_FILES} 个文件` };
|
|
136
|
+
}
|
|
137
|
+
// 同一个 storageKey 只留最后一条:清单是整份替换的,重复条目只会在下发时变成两行。
|
|
138
|
+
const byKey = new Map();
|
|
139
|
+
for (const item of rawFiles) {
|
|
140
|
+
if (!item || typeof item !== 'object') return { status: 400, code: 'bad-files', message: 'files 里每一项都得是对象' };
|
|
141
|
+
const storageKey = typeof item.storageKey === 'string' ? item.storageKey : '';
|
|
142
|
+
if (!storageKey || storageKey.length > STORAGE_KEY_MAX || /[\u0000-\u001f\u007f]/.test(storageKey)) {
|
|
143
|
+
return { status: 400, code: 'bad-files', message: 'storageKey 必须是 1..300 个可见字符' };
|
|
144
|
+
}
|
|
145
|
+
if (!HASH_RE.test(String(item.hash || ''))) {
|
|
146
|
+
return { status: 400, code: 'bad-files', message: `文件 ${storageKey} 的 hash 不是 64 位十六进制` };
|
|
147
|
+
}
|
|
148
|
+
const bytes = Number(item.bytes);
|
|
149
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_OBJECT_BYTES) {
|
|
150
|
+
return { status: 400, code: 'bad-files', message: `文件 ${storageKey} 的字节数不合法` };
|
|
151
|
+
}
|
|
152
|
+
const mimeType = typeof item.mimeType === 'string' ? item.mimeType.slice(0, 120) : 'application/octet-stream';
|
|
153
|
+
byKey.set(storageKey, { storageKey, hash: String(item.hash), mimeType, bytes });
|
|
154
|
+
}
|
|
155
|
+
return { files: [...byKey.values()], data: body.data };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 写清单:先确认每个哈希都在盘上,再整份原子替换。
|
|
160
|
+
*
|
|
161
|
+
* 为什么逐个 stat:清单是「库里有哪些字节」的账,账上有一笔盘上没有的单子,手机下次同步会
|
|
162
|
+
* 因为「清单里有」而不再上传,那张图标就一直坏着。
|
|
163
|
+
*/
|
|
164
|
+
async function writeManifest(domain, data, files) {
|
|
165
|
+
const missing = [];
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
if (!(await objectExists(file.hash))) missing.push(file.storageKey);
|
|
168
|
+
}
|
|
169
|
+
if (missing.length) {
|
|
170
|
+
const shown = missing.slice(0, 5).join('、');
|
|
171
|
+
return {
|
|
172
|
+
status: 409,
|
|
173
|
+
code: 'missing-file',
|
|
174
|
+
message: `这些文件还没传上来:${shown}${missing.length > 5 ? ` 等 ${missing.length} 个` : ''}`,
|
|
175
|
+
missing,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const revision = revisionOf({ data, files });
|
|
179
|
+
const manifest = {
|
|
180
|
+
app: 'infinite-canvas',
|
|
181
|
+
version: 1,
|
|
182
|
+
domain,
|
|
183
|
+
revision,
|
|
184
|
+
updatedAt: new Date().toISOString(),
|
|
185
|
+
data,
|
|
186
|
+
files,
|
|
187
|
+
};
|
|
188
|
+
const target = manifestPath(domain);
|
|
189
|
+
const tmp = `${target}.tmp-${crypto.randomBytes(6).toString('hex')}`;
|
|
190
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
191
|
+
try {
|
|
192
|
+
await fsp.writeFile(tmp, JSON.stringify(manifest), 'utf8');
|
|
193
|
+
// 原子替换:手机随时可能来读,读到的要么是上一份、要么是这一份。
|
|
194
|
+
await fsp.rename(tmp, target);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
try { await fsp.unlink(tmp); } catch { /* 已经不在了 */ }
|
|
197
|
+
throw Object.assign(new Error(`写画布清单失败:${error.message}`), { code: 'canvas-store-unavailable' });
|
|
198
|
+
}
|
|
199
|
+
return { revision, updatedAt: manifest.updatedAt, files: files.length };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 一份文件的 sha256(流式,不把整份读进内存)。 */
|
|
203
|
+
function sha256OfFile(filePath) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const hash = crypto.createHash('sha256');
|
|
206
|
+
const stream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 });
|
|
207
|
+
stream.on('error', reject);
|
|
208
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
209
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 把一份刚落进暂存区的字节收进库里,回它的哈希。
|
|
215
|
+
*
|
|
216
|
+
* 分片上传(`uploads.js`)传完一份画布素材就交给这里:算哈希 → 落进 objects。同内容的文件
|
|
217
|
+
* 已经在库里就直接丢暂存的那份 —— 画布里的图经常是同一张被引用了好几次,哈希天然去重。
|
|
218
|
+
*
|
|
219
|
+
* @returns {Promise<{ hash: string, bytes: number }>}
|
|
220
|
+
*/
|
|
221
|
+
async function storeUploadedFile(stagingPath) {
|
|
222
|
+
const info = await fsp.stat(stagingPath);
|
|
223
|
+
if (info.size > MAX_OBJECT_BYTES) {
|
|
224
|
+
const error = Object.assign(new Error('这份文件太大,画布库装不下'), { code: 'too-large', status: 413 });
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
const hash = await sha256OfFile(stagingPath);
|
|
228
|
+
const target = objectPathOf(hash);
|
|
229
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
230
|
+
if (await objectExists(hash)) {
|
|
231
|
+
try { await fsp.unlink(stagingPath); } catch { /* 已经不在了 */ }
|
|
232
|
+
return { hash, bytes: info.size };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
await fsp.rename(stagingPath, target);
|
|
236
|
+
} catch {
|
|
237
|
+
// 跨设备(暂存根被 `DSH_MOBILE_INBOX` 挪到别的盘)时 rename 会 EXDEV:复制一份再删。
|
|
238
|
+
await fsp.copyFile(stagingPath, target);
|
|
239
|
+
try { await fsp.unlink(stagingPath); } catch { /* 已经不在了 */ }
|
|
240
|
+
}
|
|
241
|
+
return { hash, bytes: info.size };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* GET /api/mobile/v1/canvas/manifest?domain=canvas&rev=<版本号>
|
|
246
|
+
*
|
|
247
|
+
* 带 `rev` 且与盘上那份一致时只回一句 `unchanged` —— 画布每开一次就同步一次,稳态那一发
|
|
248
|
+
* 不该把整份项目图再传一遍(跟会话列表那条条件刷同一个口径)。
|
|
249
|
+
*/
|
|
250
|
+
async function handleCanvasManifestGet(_ctx, req, res) {
|
|
251
|
+
const query = new URL(req.url || '/', 'http://x').searchParams;
|
|
252
|
+
const domain = domainOf(query.get('domain'));
|
|
253
|
+
if (!domain) return sendJson(res, 400, { ok: false, error: { code: 'bad-domain', message: 'domain 只能是 canvas / assets / image-workbench / video-workbench' } });
|
|
254
|
+
let manifest;
|
|
255
|
+
try {
|
|
256
|
+
manifest = await readManifest(domain);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return sendJson(res, 500, { ok: false, error: { code: (error && error.code) || 'canvas-store-unavailable', message: String(error && error.message) } });
|
|
259
|
+
}
|
|
260
|
+
if (!manifest) return sendJson(res, 200, { ok: true, domain, revision: null, empty: true, files: [] });
|
|
261
|
+
const rev = String(query.get('rev') || '');
|
|
262
|
+
if (rev && rev === manifest.revision) {
|
|
263
|
+
return sendJson(res, 200, { ok: true, domain, revision: manifest.revision, updatedAt: manifest.updatedAt, unchanged: true });
|
|
264
|
+
}
|
|
265
|
+
sendJson(res, 200, {
|
|
266
|
+
ok: true,
|
|
267
|
+
domain,
|
|
268
|
+
revision: manifest.revision,
|
|
269
|
+
updatedAt: manifest.updatedAt,
|
|
270
|
+
data: manifest.data,
|
|
271
|
+
files: Array.isArray(manifest.files) ? manifest.files : [],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* POST /api/mobile/v1/canvas/manifest { domain, data, files }
|
|
277
|
+
*
|
|
278
|
+
* 整份替换、不做字段级合并:合并是客户端的事(它手上有本机那份,也有刚拉下去的远端那份)。
|
|
279
|
+
* 宿主只负责「存下来、版本号算清楚、缺字节就拒绝」。
|
|
280
|
+
*/
|
|
281
|
+
async function handleCanvasManifestSave(_ctx, req, res) {
|
|
282
|
+
const body = await readJsonBody(req, MAX_MANIFEST_BYTES);
|
|
283
|
+
if (!body || typeof body !== 'object') {
|
|
284
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '请求体不是合法 JSON,或超过体积上限' } });
|
|
285
|
+
}
|
|
286
|
+
const normalized = normalizePayload(body);
|
|
287
|
+
if (normalized.status) {
|
|
288
|
+
return sendJson(res, normalized.status, { ok: false, error: { code: normalized.code, message: normalized.message, ...(normalized.missing ? { missing: normalized.missing } : {}) } });
|
|
289
|
+
}
|
|
290
|
+
let result;
|
|
291
|
+
try {
|
|
292
|
+
result = await writeManifest(domainOf(body.domain), normalized.data, normalized.files);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return sendJson(res, 500, { ok: false, error: { code: (error && error.code) || 'canvas-store-unavailable', message: String(error && error.message) } });
|
|
295
|
+
}
|
|
296
|
+
if (result.status) {
|
|
297
|
+
return sendJson(res, result.status, { ok: false, error: { code: result.code, message: result.message, ...(result.missing ? { missing: result.missing } : {}) } });
|
|
298
|
+
}
|
|
299
|
+
sendJson(res, 200, { ok: true, domain: domainOf(body.domain), revision: result.revision, updatedAt: result.updatedAt, files: result.files });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* POST /api/mobile/v1/canvas/file { hash } → 那份字节。
|
|
304
|
+
*
|
|
305
|
+
* 按哈希取、不按路径:客户端构造不出一个能读到别的东西的请求。响应形状与 `/session/file`
|
|
306
|
+
* 逐字节同形(含协议 v2 的分块信封与 `content-length`)—— 手机那边用的是同一个原生下载器,
|
|
307
|
+
* 换一个形状就要在 App 里再写一条下载路。
|
|
308
|
+
*/
|
|
309
|
+
async function handleCanvasFile(_ctx, req, res) {
|
|
310
|
+
const body = await readJsonBody(req);
|
|
311
|
+
const hash = body && typeof body.hash === 'string' ? body.hash.trim().toLowerCase() : '';
|
|
312
|
+
const target = objectPathOf(hash);
|
|
313
|
+
if (!target) {
|
|
314
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { hash }(64 位十六进制)' } });
|
|
315
|
+
}
|
|
316
|
+
let info;
|
|
317
|
+
try {
|
|
318
|
+
info = await fsp.stat(target);
|
|
319
|
+
} catch {
|
|
320
|
+
return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
|
|
321
|
+
}
|
|
322
|
+
if (!info.isFile()) {
|
|
323
|
+
return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const header = { 'content-type': 'application/octet-stream', 'cache-control': 'no-store' };
|
|
327
|
+
const opened = req && req.__dshE2ee;
|
|
328
|
+
if (opened && opened.keys && opened.req) {
|
|
329
|
+
res.writeHead(200, {
|
|
330
|
+
...header,
|
|
331
|
+
'content-type': 'application/x-dsh-e2ee-chunks',
|
|
332
|
+
'content-length': String(encryptedLengthOf(info.size)),
|
|
333
|
+
'x-dsh-file-size': String(info.size),
|
|
334
|
+
'x-dsh-proto': '2',
|
|
335
|
+
'x-dsh-content-type': 'application/octet-stream',
|
|
336
|
+
});
|
|
337
|
+
await writeEncryptedFile(req, res, target, opened);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
res.writeHead(200, { ...header, 'content-length': String(info.size) });
|
|
342
|
+
if (info.size === 0) return res.end();
|
|
343
|
+
const stream = fs.createReadStream(target);
|
|
344
|
+
res.on('close', () => stream.destroy());
|
|
345
|
+
try {
|
|
346
|
+
await pipeline(stream, res);
|
|
347
|
+
} catch {
|
|
348
|
+
// 头已经发出去了,改不了状态码,只能断开让对端按截断处理(与会话文件那条一致)。
|
|
349
|
+
try { res.destroy(); } catch { /* 连接已关闭 */ }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = {
|
|
354
|
+
DOMAINS,
|
|
355
|
+
MAX_OBJECT_BYTES,
|
|
356
|
+
rootDir,
|
|
357
|
+
objectPathOf,
|
|
358
|
+
objectExists,
|
|
359
|
+
readManifest,
|
|
360
|
+
writeManifest,
|
|
361
|
+
storeUploadedFile,
|
|
362
|
+
handleCanvasManifestGet,
|
|
363
|
+
handleCanvasManifestSave,
|
|
364
|
+
handleCanvasFile,
|
|
365
|
+
};
|
package/lib/mobile/monitor.js
CHANGED
|
@@ -26,7 +26,7 @@ const {
|
|
|
26
26
|
const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
|
|
27
27
|
const { lastTurnOf } = require('./session-last-turn.js');
|
|
28
28
|
const { sessionPreviewFor } = require('./session-preview.js');
|
|
29
|
-
const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
|
|
29
|
+
const { workspaceSessionFlags, pendingInteractionOf, pendingCardOf } = require('./session-flags.js');
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* 一条会话在监控视图里的那几格。
|
|
@@ -35,8 +35,10 @@ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags
|
|
|
35
35
|
* lastTurnError),因为原生那边解析的是同一套字段:换名字就得同步改 Kotlin,而那段代码
|
|
36
36
|
* 在 App 之外,改错一次是「后台永远不提醒」这种看不见的故障。
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* 另有两个可选字段:`lastMessage` 只给可能要提醒的那几条带上一句「这一轮跑出了什么」,
|
|
39
|
+
* 供原生完成提醒的横幅正文用;`pendingCard` 是**等你处理的那张卡本身**(与实时流推的帧
|
|
40
|
+
* 一字不差),只在那几条待处理的条目上带 —— 手机回前台时先问原生要它,就不必等流重新
|
|
41
|
+
* 接上才有卡(见 `session-flags.js` 的 pendingCardOf)。拿不到都不出字段。
|
|
40
42
|
*/
|
|
41
43
|
function monitorItem(summary, state, fact) {
|
|
42
44
|
const running = summary.running === true;
|
|
@@ -60,7 +62,19 @@ function monitorItem(summary, state, fact) {
|
|
|
60
62
|
completed,
|
|
61
63
|
};
|
|
62
64
|
const pending = pendingInteractionOf(summary.sessionId, summary);
|
|
63
|
-
if (pending)
|
|
65
|
+
if (pending) {
|
|
66
|
+
item.pendingInteraction = pending;
|
|
67
|
+
// 卡的内容跟着一起走:它在上面这个判据之外没有第二条路能到手机上(推送只在流接入时
|
|
68
|
+
// 补发一次)。进指纹是名正言顺的 —— 换一道新题就是真的变了,原生那一拍该被唤醒。
|
|
69
|
+
const card = pendingCardOf(summary.sessionId, summary);
|
|
70
|
+
if (card) item.pendingCard = card;
|
|
71
|
+
} else {
|
|
72
|
+
// **显式写 null**:原生那头靠「这一格在不在」判「这台宿主认不认识它」,并据此决定
|
|
73
|
+
// 要不要把「看过了、没有挂起」记成账。不在就永远是「不知道」,回前台也就永远撤不掉
|
|
74
|
+
// 那张已经在电脑上答掉的旧卡。待处理但没有帧可给(0.1.x 摘要自带口径)时仍然不写,
|
|
75
|
+
// 那格含义是「不知道」,不是「没有」。
|
|
76
|
+
item.pendingCard = null;
|
|
77
|
+
}
|
|
64
78
|
const failure = fact
|
|
65
79
|
? (fact.status === 'failed' && settledForThisDevice
|
|
66
80
|
? { turn: fact.turn, code: fact.code ?? null, message: fact.message ?? null }
|
|
@@ -74,6 +88,11 @@ function monitorItem(summary, state, fact) {
|
|
|
74
88
|
turn: fact.turn,
|
|
75
89
|
endedAt: fact.endedAt,
|
|
76
90
|
status: fact.status,
|
|
91
|
+
// 这一轮是不是**已经有人读过了**(桌面 / 另一台手机 / 这台都算)。与设备无关的
|
|
92
|
+
// 事实,跟着事实走;上面那个 `completed` 用的是按设备的 `clearedAt`,而桌面点开
|
|
93
|
+
// 只看 `/api/relay/desktop-read`,它不动那个水位 —— 后台把「已经看过的这一轮」
|
|
94
|
+
// 弹出来之后,只有这个水位能让它自己消失(见原生那条撤销)。
|
|
95
|
+
...(typeof fact.readAt === 'number' && fact.readAt > 0 ? { readAt: fact.readAt } : {}),
|
|
77
96
|
...(fact.status === 'failed' ? { code: fact.code ?? null, message: fact.message ?? null } : {}),
|
|
78
97
|
};
|
|
79
98
|
}
|
package/lib/mobile/questions.js
CHANGED
|
@@ -157,6 +157,20 @@ function attachSession(sessionId, sink) {
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* 这条会话此刻挂着的提问卡(形状与推送帧一字不差),没有就 undefined。
|
|
162
|
+
*
|
|
163
|
+
* 给 `/monitor` 用:手机回前台时可以**先问原生要这张卡**,不必等流重新接上。挂起项本来
|
|
164
|
+
* 只在进程内、推送只有「接入补发」那一次,回前台那条重连一旦落空卡就缺到下次重进对话;
|
|
165
|
+
* 让后台轮询顺手把卡的内容带回去,这条缺口才补得上。
|
|
166
|
+
*/
|
|
167
|
+
function pendingCardOf(sessionId) {
|
|
168
|
+
for (const entry of openEntries(sessionId)) {
|
|
169
|
+
if (entry.bound) return cardFrame(entry);
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
/** 会话摘要里的待处理标记(0.2.x 的宿主不会再下发这个字段,见 sessions.js 的用法)。 */
|
|
161
175
|
function pendingKind(sessionId) {
|
|
162
176
|
const open = openEntries(sessionId);
|
|
@@ -371,5 +385,6 @@ module.exports = {
|
|
|
371
385
|
handleQuestionRespond,
|
|
372
386
|
attachSession,
|
|
373
387
|
pendingKind,
|
|
388
|
+
pendingCardOf,
|
|
374
389
|
resetForTest,
|
|
375
390
|
};
|
|
@@ -54,4 +54,22 @@ function pendingInteractionOf(sessionId, summary) {
|
|
|
54
54
|
return questions.pendingKind(sessionId);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* 这条会话挂着的卡**本身**(推送帧原样),拿不到就 undefined。
|
|
59
|
+
*
|
|
60
|
+
* 给 `/monitor` 用:手机上那张「等你回答 / 等你审批」的卡只有一条来路 —— 实时流重新
|
|
61
|
+
* 接上时插件的补发。回前台那条重连一旦落空(退避中、宿主把流收了、网络还没被采纳),
|
|
62
|
+
* 卡就缺到用户切一次会话为止。后台轮询顺手把卡的内容一起带回去,回前台问原生就能取到,
|
|
63
|
+
* 不必等网络。审批优先,与 `pendingInteractionOf` 同序 —— 两处判据分家会让「黄点写着
|
|
64
|
+
* 审批、卡片却是提问」。
|
|
65
|
+
*
|
|
66
|
+
* 摘要自己带了 `pendingInteraction`(0.1.x / 桌面口径)时没帧可给:那时候的卡是宿主
|
|
67
|
+
* 直接推的,本桥手上没有。
|
|
68
|
+
*/
|
|
69
|
+
function pendingCardOf(sessionId, summary) {
|
|
70
|
+
const reported = summary && summary.pendingInteraction;
|
|
71
|
+
if (reported === 'approval' || reported === 'plan-review' || reported === 'question') return undefined;
|
|
72
|
+
return approvals.pendingCardOf(sessionId) || questions.pendingCardOf(sessionId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { workspaceSessionFlags, pendingInteractionOf, pendingCardOf };
|
package/lib/mobile/uploads.js
CHANGED
|
@@ -39,6 +39,7 @@ const { sendJson, readJsonBody, readRawBody } = require('../shared/http-util');
|
|
|
39
39
|
// 头名的大小写不保证(中转会压成小写,Node 版本之间也有差异),复用信封那一份读法。
|
|
40
40
|
const { headerOf } = require('../shared/e2ee-request');
|
|
41
41
|
const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
|
|
42
|
+
const canvasStore = require('./canvas-store.js');
|
|
42
43
|
|
|
43
44
|
// 片大小的**起步值** 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s
|
|
44
45
|
// (`app/src/api/core.ts` 的 REQUEST_TIMEOUT_MS),一上来就给大片的机器会在慢速上行
|
|
@@ -108,7 +109,10 @@ function rememberFinishedUpload(uploadId, record) {
|
|
|
108
109
|
/**
|
|
109
110
|
* 按 uploadId 取刚落盘的上传。过期 / 不存在返回 null。
|
|
110
111
|
*
|
|
111
|
-
*
|
|
112
|
+
* 记录有两种:收件箱那份带 `path`(图片内联要用它),画布素材那份带 `hash`(没有路径 ——
|
|
113
|
+
* 画布库只按内容哈希认文件)。两种都要能原样回放给重收一次口的客户端。
|
|
114
|
+
*
|
|
115
|
+
* @returns {{ path?: string, hash?: string, purpose?: string, name: string, bytes: number } | null}
|
|
112
116
|
*/
|
|
113
117
|
function resolveFinishedUpload(uploadId) {
|
|
114
118
|
const id = String(uploadId || '');
|
|
@@ -118,7 +122,8 @@ function resolveFinishedUpload(uploadId) {
|
|
|
118
122
|
finishedUploads.delete(id);
|
|
119
123
|
return null;
|
|
120
124
|
}
|
|
121
|
-
|
|
125
|
+
const { at, ...rest } = record;
|
|
126
|
+
return rest;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/**
|
|
@@ -345,8 +350,16 @@ async function handleUploadBegin(ctx, req, res) {
|
|
|
345
350
|
}
|
|
346
351
|
const caps = resolveCaps(body);
|
|
347
352
|
if (caps.status) return fail(res, caps.status, caps.code, caps.message);
|
|
353
|
+
// 画布库的素材也走这条通道(`purpose: 'canvas'`):传完不落收件箱,而是按内容哈希收进
|
|
354
|
+
// 画布库(见 `canvas-store.js`)。它与 `destPath` 互斥:一个是「收件箱里的一份文件」,
|
|
355
|
+
// 一个是「库里的一份字节」,同时给说明调用方把两件事搞混了。
|
|
356
|
+
const purpose = body.purpose === undefined || body.purpose === null ? '' : String(body.purpose);
|
|
357
|
+
if (purpose && purpose !== 'canvas') return fail(res, 400, 'bad-purpose', 'purpose 只认 canvas');
|
|
358
|
+
if (purpose && body.destPath !== undefined && body.destPath !== null) {
|
|
359
|
+
return fail(res, 400, 'bad-request', 'purpose 与 destPath 不能同时给');
|
|
360
|
+
}
|
|
348
361
|
// 落点先判:越界 / 不是目录在这一步就回,不先建暂存再报错 —— 残片要躺满 7 天才清。
|
|
349
|
-
const destination = await resolveDestDir(ctx, body);
|
|
362
|
+
const destination = purpose ? { dir: '' } : await resolveDestDir(ctx, body);
|
|
350
363
|
if (destination.status) return fail(res, destination.status, destination.code, destination.message);
|
|
351
364
|
const stagingDir = path.join(inboxRoot(), '.staging');
|
|
352
365
|
try {
|
|
@@ -366,6 +379,8 @@ async function handleUploadBegin(ctx, req, res) {
|
|
|
366
379
|
}
|
|
367
380
|
const job = {
|
|
368
381
|
name: sanitizeName(body.name),
|
|
382
|
+
// 画布素材:finish 时按内容哈希收进画布库,不进收件箱(见 `canvas-store.js`)。
|
|
383
|
+
purpose,
|
|
369
384
|
// 指定落点就记在这里,finish 才用得上(中途不再判一次范围:begin 已经认过)。
|
|
370
385
|
destDir: destination.dir,
|
|
371
386
|
stagingPath,
|
|
@@ -560,6 +575,9 @@ async function handleUploadFinish(_ctx, req, res) {
|
|
|
560
575
|
const job = uploads.get(uploadId);
|
|
561
576
|
if (!job) {
|
|
562
577
|
const replay = resolveFinishedUpload(uploadId);
|
|
578
|
+
if (replay && replay.purpose === 'canvas') {
|
|
579
|
+
return sendJson(res, 200, { ok: true, hash: replay.hash, bytes: replay.bytes, name: replay.name, uploadId });
|
|
580
|
+
}
|
|
563
581
|
if (replay) return sendJson(res, 200, { ok: true, ...replay, uploadId });
|
|
564
582
|
return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
|
|
565
583
|
}
|
|
@@ -574,6 +592,19 @@ async function handleUploadFinish(_ctx, req, res) {
|
|
|
574
592
|
}
|
|
575
593
|
removeUpload(uploadId, job);
|
|
576
594
|
|
|
595
|
+
// 画布素材:不进收件箱,按内容哈希收进画布库,回哈希而不是路径(客户端拿不到也拼不出路径)。
|
|
596
|
+
if (job.purpose === 'canvas') {
|
|
597
|
+
try {
|
|
598
|
+
const stored = await canvasStore.storeUploadedFile(job.stagingPath);
|
|
599
|
+
// 与收件箱那条同一个理由:回包丢了,手机只会拿同一个 uploadId 再收一次口。
|
|
600
|
+
rememberFinishedUpload(uploadId, { purpose: 'canvas', hash: stored.hash, bytes: stored.bytes, name: job.name });
|
|
601
|
+
return sendJson(res, 200, { ok: true, hash: stored.hash, bytes: stored.bytes, name: job.name, uploadId });
|
|
602
|
+
} catch (error) {
|
|
603
|
+
try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
|
|
604
|
+
return fail(res, (error && error.status) || 500, (error && error.code) || 'canvas-store-unavailable', `收进画布库失败:${error.message}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
577
608
|
const dir = job.destDir || path.join(inboxRoot(), today());
|
|
578
609
|
try {
|
|
579
610
|
// 指定落点时目录在 begin 已确认存在:中途被删了就该按落盘失败报,不能悄悄重建一个
|
|
@@ -621,7 +652,8 @@ async function inlineUploadedImageBlocks(content) {
|
|
|
621
652
|
if (!block || block.type !== 'image') continue;
|
|
622
653
|
if (typeof block.data === 'string' && block.data) continue;
|
|
623
654
|
const record = resolveFinishedUpload(block.uploadId);
|
|
624
|
-
|
|
655
|
+
// 画布素材那份没有 `path`(只按哈希躺在画布库里),不能拿来内联:照旧报「过期」。
|
|
656
|
+
if (!record || !record.path) return UPLOAD_EXPIRED_MESSAGE;
|
|
625
657
|
try {
|
|
626
658
|
const bytes = await fsp.readFile(record.path);
|
|
627
659
|
if (!bytes.length) return UPLOAD_EXPIRED_MESSAGE;
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -46,6 +46,7 @@ const pluginSelf = require('../mobile/plugin-self.js');
|
|
|
46
46
|
const pluginMarket = require('../mobile/plugin-market.js');
|
|
47
47
|
const { createRelayControlHandlers } = require('../mobile/relay-control.js');
|
|
48
48
|
const uploads = require('../mobile/uploads.js');
|
|
49
|
+
const canvasStore = require('../mobile/canvas-store.js');
|
|
49
50
|
const inboxClean = require('../mobile/inbox-clean.js');
|
|
50
51
|
const git = require('../mobile/git.js');
|
|
51
52
|
const pairing = require('../mobile/pairing.js');
|
|
@@ -482,6 +483,14 @@ function registerMobileRoutes(deps) {
|
|
|
482
483
|
route('/api/mobile/v1/upload/chunk', uploads.handleUploadChunk)
|
|
483
484
|
route('/api/mobile/v1/upload/finish', uploads.handleUploadFinish)
|
|
484
485
|
route('/api/mobile/v1/upload/abort', uploads.handleUploadAbort)
|
|
486
|
+
|
|
487
|
+
// 无限画布的库(App 那页 WebView 里的项目与素材,见 mobile/canvas-store.js)。
|
|
488
|
+
// 每个域一份清单、字节按内容哈希落在插件自己的目录里;手机推上来、拉下去都走这三条。
|
|
489
|
+
// 素材字节不另开通道,走上面的分片上传(`purpose: 'canvas'`)—— 断点续传与片大小
|
|
490
|
+
// 自适应那一套已经在那条路上,再写一份只是多一条会漂的实现。
|
|
491
|
+
route('/api/mobile/v1/canvas/manifest', canvasStore.handleCanvasManifestGet)
|
|
492
|
+
route('/api/mobile/v1/canvas/manifest/save', canvasStore.handleCanvasManifestSave)
|
|
493
|
+
route('/api/mobile/v1/canvas/file', canvasStore.handleCanvasFile)
|
|
485
494
|
// 同一个收件目录的出口:上面四条只往里放,这两条报占用、按龄清掉(见 mobile/inbox-clean.js)。
|
|
486
495
|
// 落盘的文件不认识会话,所以没有「删掉这条对话就顺带清掉它传上来的文件」这条路 ——
|
|
487
496
|
// 唯一的出口只能按龄,口径写在那个文件头。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.15",
|
|
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",
|