dsh-mobile-plugin 0.5.20 → 0.5.22
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 +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/client.js +318 -27
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/local-images.js +10 -5
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +12 -11
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +74 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/session-cleanup.cjs +22 -7
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +266 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/package.json +10 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 悬浮宠物的包目录(见 docs/pet-overlay.md §1):素材存在跑对话的这台电脑上,手机连着哪台
|
|
5
|
+
* 就看哪台的;换手机、清缓存都不丢。
|
|
6
|
+
*
|
|
7
|
+
* ## 两个根
|
|
8
|
+
*
|
|
9
|
+
* · 用户包:`<DSH_HOME>/storages/mobile-plugin/pets/<id>/`,可写、可删;
|
|
10
|
+
* · 内置包:插件包自己的 `assets/pets/<id>/`,只读、随发版走,删不掉。
|
|
11
|
+
*
|
|
12
|
+
* 用户目录里同 id 的包覆盖内置那份(`builtin` 回 false)—— 这就是「整体覆盖内置」的方式。
|
|
13
|
+
* 内置目录不存在是常态(素材由发版那边放),那时列表就是少几个包,不报错。
|
|
14
|
+
*
|
|
15
|
+
* ## 坏包静默跳过
|
|
16
|
+
*
|
|
17
|
+
* 少一个目录、pack.json 读不出、schema 不是 1、id 不合规矩、图片超限、没有能用的 idle,
|
|
18
|
+
* 一律当作「这个包不存在」,不抛、不让整张列表变红。取文件那条才逐个回错误码。
|
|
19
|
+
*
|
|
20
|
+
* ## 能力面
|
|
21
|
+
*
|
|
22
|
+
* 只认包目录内**单层**的 `.gif` / `.png`:id 与文件名都卡成不含斜杠的形状,取文件时再按
|
|
23
|
+
* 列表里的那一份核对,路径全由宿主拼,客户端给不了路径。没有「写任意文件」这条口子。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const fsp = require('node:fs/promises');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
31
|
+
const { dshHome } = require('../shared/paths.js');
|
|
32
|
+
|
|
33
|
+
/** 包 id 也是目录名:只认这套形状,顺带把路径穿越挡在门外。 */
|
|
34
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
35
|
+
/** 认得的状态键。表外的键原样忽略,以后加状态时老 App 还能用这份包。 */
|
|
36
|
+
const KNOWN_STATES = Object.freeze(['idle', 'working', 'attention', 'done', 'error', 'sleeping']);
|
|
37
|
+
/** 默认循环播的那几档(与 §1.1 一致)。 */
|
|
38
|
+
const DEFAULT_LOOP = Object.freeze(['idle', 'working', 'attention']);
|
|
39
|
+
/** 单张图上限:一张 GIF 一般几十到几百 KB,四 MB 是「明显不对劲」的那条线。 */
|
|
40
|
+
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
41
|
+
/** 整包上限:手机要整份拉下来,超了整包拒收。 */
|
|
42
|
+
const MAX_PACK_BYTES = 20 * 1024 * 1024;
|
|
43
|
+
const MAX_PACK_FILES = 60;
|
|
44
|
+
/** 档案文件名。 */
|
|
45
|
+
const PACK_FILE = 'pack.json';
|
|
46
|
+
/** 图片扩展名:GIF 自带帧就是一段动画,PNG 是一张静帧。 */
|
|
47
|
+
const IMAGE_EXT_RE = /\.(?:gif|png)$/i;
|
|
48
|
+
|
|
49
|
+
/** 用户包根。`DSH_MOBILE_PETS` 只给测试用。 */
|
|
50
|
+
function rootDir() {
|
|
51
|
+
return process.env.DSH_MOBILE_PETS || path.join(dshHome(), 'storages', 'mobile-plugin', 'pets');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 内置包根:从 lib/mobile/ 往上两级就是包根。不依赖 cwd、不建目录(随 npm 包发布)。
|
|
56
|
+
* `DSH_MOBILE_PETS_BUILTIN` 只给测试用。
|
|
57
|
+
*/
|
|
58
|
+
function builtinRoot() {
|
|
59
|
+
return process.env.DSH_MOBILE_PETS_BUILTIN || path.join(__dirname, '..', '..', 'assets', 'pets');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
|
|
63
|
+
function fail(res, status, code, message) {
|
|
64
|
+
return sendJson(res, status, { ok: false, error: { code, message } });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value) {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function textOf(value, max) {
|
|
72
|
+
if (typeof value !== 'string') return '';
|
|
73
|
+
const trimmed = value.trim();
|
|
74
|
+
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 可选文案:非空字符串才收,其余一律 null(页脚按 null 决定不画这一行)。 */
|
|
78
|
+
function textOrNull(value) {
|
|
79
|
+
const text = textOf(value, 200);
|
|
80
|
+
return text ? text : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function finiteOf(value) {
|
|
84
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 包内图片名:单层、不带斜杠、不以点开头、扩展名只认 `.gif` / `.png`。
|
|
89
|
+
*
|
|
90
|
+
* 这一条同时是「路径穿越」的判据:`..` 段与绝对路径都拼不出这个形状。
|
|
91
|
+
*/
|
|
92
|
+
function isImageName(name) {
|
|
93
|
+
if (typeof name !== 'string') return false;
|
|
94
|
+
if (name.length === 0 || name.length > 120) return false;
|
|
95
|
+
if (name.startsWith('.')) return false;
|
|
96
|
+
if (name.includes('/') || name.includes('\\')) return false;
|
|
97
|
+
return IMAGE_EXT_RE.test(name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 按扩展名给媒体类型:只认 gif / png 两种,其余在上一步已经被挡掉了。 */
|
|
101
|
+
function mediaTypeOf(name) {
|
|
102
|
+
return /\.gif$/i.test(name) ? 'image/gif' : 'image/png';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `{w,h}` / `{x,baseline}` 这类小坐标:字段齐且都是有限数才收,缺一个就当没填。 */
|
|
106
|
+
function pairOf(value, keys) {
|
|
107
|
+
if (!isRecord(value)) return undefined;
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
const number = finiteOf(value[key]);
|
|
111
|
+
if (number === null) return undefined;
|
|
112
|
+
out[key] = number;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 扫包目录里的图片:单层、名字合规矩、不点开头;超限当场返回 null(整包拒收)。
|
|
119
|
+
*
|
|
120
|
+
* 只认普通文件:子目录一律不看(§1 的形状就是一层)。
|
|
121
|
+
*/
|
|
122
|
+
function scanImages(directory) {
|
|
123
|
+
let entries;
|
|
124
|
+
try {
|
|
125
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const files = [];
|
|
130
|
+
let total = 0;
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.isFile() || entry.name.startsWith('.') || !isImageName(entry.name)) continue;
|
|
133
|
+
let size;
|
|
134
|
+
try {
|
|
135
|
+
size = fs.statSync(path.join(directory, entry.name)).size;
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (size > MAX_FILE_BYTES) return null;
|
|
140
|
+
total += size;
|
|
141
|
+
files.push({ path: entry.name, size });
|
|
142
|
+
if (files.length > MAX_PACK_FILES || total > MAX_PACK_BYTES) return null;
|
|
143
|
+
}
|
|
144
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
145
|
+
return files;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 把一份 pack.json 收成内部形状。`entries` 是包里实际有的图片(`[{path, size}]`)。
|
|
150
|
+
*
|
|
151
|
+
* 返回 `{ pack, problems }`:列表那条路只认 `pack`(拿不到就是坏包,静默跳过),写那条路
|
|
152
|
+
* 把 `problems` 原样说给模型听 —— 同一套判据,两种说法。
|
|
153
|
+
*/
|
|
154
|
+
function buildPack(raw, id, entries) {
|
|
155
|
+
const problems = [];
|
|
156
|
+
if (!isRecord(raw)) return { pack: null, problems: ['pack.json 不是一个对象。'] };
|
|
157
|
+
|
|
158
|
+
if (raw.schema !== 1) problems.push('schema 必须是 1。');
|
|
159
|
+
if (typeof raw.id !== 'string' || !ID_RE.test(raw.id.trim())) {
|
|
160
|
+
problems.push('id 不合规矩:只认 1-64 位小写字母、数字与连字符,且不以连字符开头。');
|
|
161
|
+
} else if (raw.id.trim() !== id) {
|
|
162
|
+
// id 同时是目录名:写的和所在的目录对不上,这份包到底叫哪个名字就说不清了。
|
|
163
|
+
problems.push(`pack.json 里的 id("${raw.id.trim()}")和目录名("${id}")对不上。`);
|
|
164
|
+
}
|
|
165
|
+
const name = textOf(raw.name, 80);
|
|
166
|
+
if (!name) problems.push('name 必填(界面上显示的就是它)。');
|
|
167
|
+
|
|
168
|
+
const present = new Set(entries.map((entry) => entry.path));
|
|
169
|
+
|
|
170
|
+
// states:认识的键才收,文件得真在包里;其余键(以后加的)原样忽略,不是错误。
|
|
171
|
+
const states = {};
|
|
172
|
+
if (!isRecord(raw.states)) {
|
|
173
|
+
problems.push('states 必填,而且至少要有一个 idle。');
|
|
174
|
+
} else {
|
|
175
|
+
for (const key of KNOWN_STATES) {
|
|
176
|
+
const entry = raw.states[key];
|
|
177
|
+
if (!isRecord(entry)) continue;
|
|
178
|
+
const file = textOf(entry.file, 120);
|
|
179
|
+
if (!isImageName(file) || !present.has(file)) continue;
|
|
180
|
+
states[key] = { file };
|
|
181
|
+
}
|
|
182
|
+
if (!states.idle) problems.push('states 里至少要有一个能用的 idle(file 得是包里真有的 .gif / .png)。');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// tiers.working:按「同时在跑几条」换图,min 从大到小匹配。
|
|
186
|
+
const tiers = {};
|
|
187
|
+
if (isRecord(raw.tiers) && Array.isArray(raw.tiers.working)) {
|
|
188
|
+
const rows = [];
|
|
189
|
+
for (const item of raw.tiers.working) {
|
|
190
|
+
if (!isRecord(item)) continue;
|
|
191
|
+
const min = finiteOf(item.min);
|
|
192
|
+
const file = textOf(item.file, 120);
|
|
193
|
+
if (min === null || min < 1) continue;
|
|
194
|
+
if (!isImageName(file) || !present.has(file)) continue;
|
|
195
|
+
rows.push({ min: Math.floor(min), file });
|
|
196
|
+
}
|
|
197
|
+
rows.sort((a, b) => b.min - a.min);
|
|
198
|
+
if (rows.length > 0) tiers.working = rows;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const loop = [];
|
|
202
|
+
if (Array.isArray(raw.loop)) {
|
|
203
|
+
for (const item of raw.loop) {
|
|
204
|
+
if (typeof item !== 'string' || !KNOWN_STATES.includes(item) || loop.includes(item)) continue;
|
|
205
|
+
loop.push(item);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const sleepAfterMs = finiteOf(raw.sleepAfterMs);
|
|
209
|
+
|
|
210
|
+
if (problems.length > 0) return { pack: null, problems };
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
pack: {
|
|
214
|
+
id,
|
|
215
|
+
name,
|
|
216
|
+
author: textOrNull(raw.author),
|
|
217
|
+
license: textOrNull(raw.license),
|
|
218
|
+
canvas: pairOf(raw.canvas, ['w', 'h']),
|
|
219
|
+
anchor: pairOf(raw.anchor, ['x', 'baseline']),
|
|
220
|
+
states,
|
|
221
|
+
tiers,
|
|
222
|
+
loop: loop.length > 0 ? loop : [...DEFAULT_LOOP],
|
|
223
|
+
sleepAfterMs: sleepAfterMs !== null && sleepAfterMs > 0 ? sleepAfterMs : undefined,
|
|
224
|
+
size: entries.reduce((sum, entry) => sum + entry.size, 0),
|
|
225
|
+
files: entries,
|
|
226
|
+
},
|
|
227
|
+
problems: [],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 读一个包目录。不成立就 null(坏包静默跳过这条路的入口)。 */
|
|
232
|
+
function readPackAt(root, id, builtin) {
|
|
233
|
+
if (!ID_RE.test(id)) return null;
|
|
234
|
+
const directory = path.join(root, id);
|
|
235
|
+
let stats;
|
|
236
|
+
try {
|
|
237
|
+
stats = fs.statSync(directory);
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (!stats.isDirectory()) return null;
|
|
242
|
+
let raw;
|
|
243
|
+
try {
|
|
244
|
+
raw = JSON.parse(fs.readFileSync(path.join(directory, PACK_FILE), 'utf8'));
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const entries = scanImages(directory);
|
|
249
|
+
if (!entries || entries.length === 0) return null;
|
|
250
|
+
const { pack } = buildPack(raw, id, entries);
|
|
251
|
+
if (!pack) return null;
|
|
252
|
+
return { ...pack, builtin, dir: directory };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 列包:内置在前、用户在后,同 id 的话用户那份顶掉内置那份(`builtin` 变 false)。
|
|
257
|
+
*
|
|
258
|
+
* 哪个目录不在都不算错:用户还没做过宠物的机器上,用户目录根本不存在;素材还没放进去的
|
|
259
|
+
* 构建里,内置目录也不存在 —— 两种都只是「列表里少几个包」。
|
|
260
|
+
*/
|
|
261
|
+
function listPacks() {
|
|
262
|
+
const byId = new Map();
|
|
263
|
+
for (const id of dirNames(builtinRoot())) {
|
|
264
|
+
const pack = readPackAt(builtinRoot(), id, true);
|
|
265
|
+
if (pack) byId.set(pack.id, pack);
|
|
266
|
+
}
|
|
267
|
+
for (const id of dirNames(rootDir())) {
|
|
268
|
+
const pack = readPackAt(rootDir(), id, false);
|
|
269
|
+
if (pack) byId.set(pack.id, pack);
|
|
270
|
+
}
|
|
271
|
+
const packs = [...byId.values()];
|
|
272
|
+
packs.sort((a, b) => {
|
|
273
|
+
if (a.builtin !== b.builtin) return a.builtin ? -1 : 1;
|
|
274
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
275
|
+
});
|
|
276
|
+
return packs;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 目录下的一层子目录名。读不到就是空表。 */
|
|
280
|
+
function dirNames(root) {
|
|
281
|
+
let entries;
|
|
282
|
+
try {
|
|
283
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
return entries
|
|
288
|
+
.filter((entry) => entry.isDirectory() && ID_RE.test(entry.name))
|
|
289
|
+
.map((entry) => entry.name);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 只留列表要的形状:内部的 `dir` 不出去。 */
|
|
293
|
+
function publicPack(pack) {
|
|
294
|
+
return {
|
|
295
|
+
id: pack.id,
|
|
296
|
+
name: pack.name,
|
|
297
|
+
builtin: pack.builtin,
|
|
298
|
+
author: pack.author,
|
|
299
|
+
license: pack.license,
|
|
300
|
+
canvas: pack.canvas,
|
|
301
|
+
anchor: pack.anchor,
|
|
302
|
+
states: pack.states,
|
|
303
|
+
tiers: pack.tiers,
|
|
304
|
+
loop: pack.loop,
|
|
305
|
+
sleepAfterMs: pack.sleepAfterMs,
|
|
306
|
+
size: pack.size,
|
|
307
|
+
files: pack.files,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 找一个包:用户那份优先,其次内置。 */
|
|
312
|
+
function findPack(id) {
|
|
313
|
+
if (!ID_RE.test(id)) return null;
|
|
314
|
+
return readPackAt(rootDir(), id, false) || readPackAt(builtinRoot(), id, true);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 取一个文件。
|
|
319
|
+
*
|
|
320
|
+
* 判据是「这份包列出来的文件里有没有它」而不是拼路径 —— id 与文件名都已经是不含斜杠的
|
|
321
|
+
* 形状了,再加上这一步,读到的永远是列表里那一张。
|
|
322
|
+
*/
|
|
323
|
+
function readPackFile(id, name) {
|
|
324
|
+
if (!ID_RE.test(id) || !isImageName(name)) return null;
|
|
325
|
+
const pack = findPack(id);
|
|
326
|
+
if (!pack) return null;
|
|
327
|
+
if (!pack.files.some((file) => file.path === name)) return null;
|
|
328
|
+
let bytes;
|
|
329
|
+
try {
|
|
330
|
+
bytes = fs.readFileSync(path.join(pack.dir, name));
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (bytes.length === 0 || bytes.length > MAX_FILE_BYTES) return null;
|
|
335
|
+
return { bytes, mediaType: mediaTypeOf(name) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 删一个**用户**包。内置包删不掉:它是随发版走的只读素材。
|
|
340
|
+
*
|
|
341
|
+
* 用户目录里没有同 id 的包、而内置里有,回 `forbidden` 而不是 not-found —— App 那边听到的是
|
|
342
|
+
* 「这是内置的」,不是一个看起来像出错的「不在了」。
|
|
343
|
+
*/
|
|
344
|
+
async function deletePack(id) {
|
|
345
|
+
if (!ID_RE.test(id)) {
|
|
346
|
+
const error = new Error('宠物包 id 不合规矩。');
|
|
347
|
+
error.code = 'bad-request';
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
const isDir = (directory) => {
|
|
351
|
+
try {
|
|
352
|
+
return fs.statSync(directory).isDirectory();
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
if (isDir(path.join(rootDir(), id))) {
|
|
358
|
+
await fsp.rm(path.join(rootDir(), id), { recursive: true, force: true });
|
|
359
|
+
return { builtin: false };
|
|
360
|
+
}
|
|
361
|
+
if (isDir(path.join(builtinRoot(), id))) {
|
|
362
|
+
const error = new Error('这是内置的宠物,删不掉;想换成自己的一份就用同一个 id 写一个用户包。');
|
|
363
|
+
error.code = 'forbidden';
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
const error = new Error('没有这个宠物包。');
|
|
367
|
+
error.code = 'not-found';
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 本机路径统一收一遍:绝对路径、`~/`、`file://`,相对地址按当前目录解析。 */
|
|
372
|
+
function resolveLocalPath(value) {
|
|
373
|
+
let text = String(value || '').trim();
|
|
374
|
+
if (!text) return null;
|
|
375
|
+
if (text.startsWith('<') && text.endsWith('>')) text = text.slice(1, -1).trim();
|
|
376
|
+
if (/^file:\/\//i.test(text)) {
|
|
377
|
+
try {
|
|
378
|
+
text = decodeURIComponent(text.slice(7));
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
} else if (text === '~' || text.startsWith('~/')) {
|
|
383
|
+
text = path.join(require('node:os').homedir(), text.slice(1));
|
|
384
|
+
}
|
|
385
|
+
return path.isAbsolute(text) ? path.normalize(text) : path.resolve(process.cwd(), text);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 一项文件的字节:优先读电脑上的那个文件,其次才认 base64。
|
|
390
|
+
*
|
|
391
|
+
* 以 `filePath` 为主:让模型把几 MB 的 base64 写进工具参数里走不通(与公众号那条同一个
|
|
392
|
+
* 理由),它手里真有的是那张图落在盘上的位置。
|
|
393
|
+
*/
|
|
394
|
+
async function bytesOfItem(item) {
|
|
395
|
+
const fromPath = textOf(item.filePath, 1024);
|
|
396
|
+
if (fromPath) {
|
|
397
|
+
const filePath = resolveLocalPath(fromPath);
|
|
398
|
+
if (!filePath) return null;
|
|
399
|
+
try {
|
|
400
|
+
const stat = await fsp.stat(filePath);
|
|
401
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_FILE_BYTES) return null;
|
|
402
|
+
return await fsp.readFile(filePath);
|
|
403
|
+
} catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const data = typeof item.base64 === 'string' ? item.base64.trim() : '';
|
|
408
|
+
if (!data) return null;
|
|
409
|
+
let bytes;
|
|
410
|
+
try {
|
|
411
|
+
bytes = Buffer.from(data, 'base64');
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
if (bytes.length === 0 || bytes.length > MAX_FILE_BYTES) return null;
|
|
416
|
+
return bytes;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 写一个用户包。
|
|
421
|
+
*
|
|
422
|
+
* 判据与列表那条是同一套(`buildPack`):先按「盘上已有的图 + 这次给的图」拼出完整的一份,
|
|
423
|
+
* 成立才落盘 —— 不成立就整包不动,不会把原来那份写坏。
|
|
424
|
+
*
|
|
425
|
+
* 覆盖内置 id 是允许的:那是用户明说要改,`overriddenBuiltin` 让工具那条路能告警一句。
|
|
426
|
+
*/
|
|
427
|
+
async function writePack(input) {
|
|
428
|
+
const id = textOf(input && input.id, 64);
|
|
429
|
+
if (!ID_RE.test(id)) {
|
|
430
|
+
const error = new Error(`宠物包 id 不合规矩(收到 "${id || ''}"):只认 1-64 位小写字母、数字与连字符,且不以连字符开头。`);
|
|
431
|
+
error.code = 'bad-request';
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
if (!isRecord(input.pack)) {
|
|
435
|
+
const error = new Error('pack 必填:整份 pack.json 的对象(至少要有 name 与 states.idle)。');
|
|
436
|
+
error.code = 'bad-request';
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const directory = path.join(rootDir(), id);
|
|
441
|
+
const existing = scanImages(directory) || [];
|
|
442
|
+
const entries = new Map(existing.map((entry) => [entry.path, entry.size]));
|
|
443
|
+
const incoming = [];
|
|
444
|
+
const rejected = [];
|
|
445
|
+
const items = Array.isArray(input.files) ? input.files : [];
|
|
446
|
+
for (const item of items) {
|
|
447
|
+
if (!isRecord(item)) continue;
|
|
448
|
+
const name = textOf(item.path, 120);
|
|
449
|
+
if (!isImageName(name)) {
|
|
450
|
+
rejected.push(name || '(没有文件名)');
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const bytes = await bytesOfItem(item);
|
|
454
|
+
if (!bytes) {
|
|
455
|
+
rejected.push(name);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
entries.set(name, bytes.length);
|
|
459
|
+
incoming.push({ path: name, bytes });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const scan = [...entries.entries()].map(([file, size]) => ({ path: file, size }));
|
|
463
|
+
if (scan.length > MAX_PACK_FILES) {
|
|
464
|
+
const error = new Error(`一个包最多 ${MAX_PACK_FILES} 个文件,现在会到 ${scan.length} 个。`);
|
|
465
|
+
error.code = 'too-large';
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
const total = scan.reduce((sum, entry) => sum + entry.size, 0);
|
|
469
|
+
if (total > MAX_PACK_BYTES) {
|
|
470
|
+
const error = new Error(`整包上限 ${Math.round(MAX_PACK_BYTES / 1024 / 1024)}MB,现在会到 ${(total / 1024 / 1024).toFixed(1)}MB。`);
|
|
471
|
+
error.code = 'too-large';
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const manifest = { ...input.pack, schema: 1, id };
|
|
476
|
+
const built = buildPack(manifest, id, scan);
|
|
477
|
+
if (!built.pack) {
|
|
478
|
+
const error = new Error(`这份 pack.json 还不成立:${built.problems.join(' ')}`);
|
|
479
|
+
error.code = 'bad-pack';
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
await fsp.mkdir(directory, { recursive: true });
|
|
484
|
+
for (const file of incoming) {
|
|
485
|
+
await fsp.writeFile(path.join(directory, file.path), file.bytes);
|
|
486
|
+
}
|
|
487
|
+
// 档案最后写、并且先写临时文件再改名:宿主随时可能被杀,半个 JSON 会让这个包从此不出现。
|
|
488
|
+
const tmp = path.join(directory, `${PACK_FILE}.tmp`);
|
|
489
|
+
await fsp.writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf8');
|
|
490
|
+
await fsp.rename(tmp, path.join(directory, PACK_FILE));
|
|
491
|
+
|
|
492
|
+
const pack = readPackAt(rootDir(), id, false) || readPackAt(builtinRoot(), id, true);
|
|
493
|
+
return {
|
|
494
|
+
pack,
|
|
495
|
+
rejected,
|
|
496
|
+
written: incoming.map((file) => file.path),
|
|
497
|
+
// 内置里有同 id 的那份:覆盖是允许的(用户明说要改),只是工具那条路该说一句。
|
|
498
|
+
overriddenBuiltin: Boolean(readPackAt(builtinRoot(), id, true)),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── 路由 ─────────────────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
/** `GET /pet/packs` —— 这台电脑上现有的宠物包(内置 + 用户)。 */
|
|
505
|
+
async function handlePackList(_ctx, _req, res) {
|
|
506
|
+
sendJson(res, 200, { ok: true, packs: listPacks().map(publicPack) });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* `GET /pet/pack/file?id=&path=` —— 取一张图(base64 JSON,与 `wechat/media` 同形)。
|
|
511
|
+
*
|
|
512
|
+
* 走 JSON 而不是直接回字节:App 取回来之后要交给原生落地播放,中间还得带鉴权头,形状与
|
|
513
|
+
* 公众号那条一致,也能复用同一套解包。
|
|
514
|
+
*/
|
|
515
|
+
async function handlePackFile(_ctx, req, res) {
|
|
516
|
+
const url = new URL(req.url, 'http://x');
|
|
517
|
+
const id = (url.searchParams.get('id') || '').trim();
|
|
518
|
+
const name = (url.searchParams.get('path') || '').trim();
|
|
519
|
+
if (!ID_RE.test(id) || !isImageName(name)) return fail(res, 400, 'bad-request', '参数不对。');
|
|
520
|
+
const file = readPackFile(id, name);
|
|
521
|
+
if (!file) return fail(res, 404, 'file-not-found', '这个包里没有这张图。');
|
|
522
|
+
sendJson(res, 200, { ok: true, base64: file.bytes.toString('base64'), mediaType: file.mediaType });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** `POST /pet/pack/delete` —— 删一个用户包;内置的回 forbidden。 */
|
|
526
|
+
async function handlePackDelete(_ctx, req, res) {
|
|
527
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
528
|
+
if (!isRecord(body)) return fail(res, 400, 'bad-request', '请求体不是对象。');
|
|
529
|
+
const id = textOf(body.id, 64);
|
|
530
|
+
try {
|
|
531
|
+
await deletePack(id);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
const code = error && error.code;
|
|
534
|
+
if (code === 'bad-request') return fail(res, 400, 'bad-request', error.message);
|
|
535
|
+
if (code === 'forbidden') return fail(res, 403, 'forbidden', error.message);
|
|
536
|
+
if (code === 'not-found') return fail(res, 404, 'pack-not-found', error.message);
|
|
537
|
+
return fail(res, 500, 'delete-failed', '没能删掉,稍后重试。');
|
|
538
|
+
}
|
|
539
|
+
sendJson(res, 200, { ok: true, id });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
module.exports = {
|
|
543
|
+
handlePackList,
|
|
544
|
+
handlePackFile,
|
|
545
|
+
handlePackDelete,
|
|
546
|
+
// 工具那一侧直接用这几个(不经过 HTTP)
|
|
547
|
+
listPacks,
|
|
548
|
+
readPackFile,
|
|
549
|
+
writePack,
|
|
550
|
+
deletePack,
|
|
551
|
+
rootDir,
|
|
552
|
+
builtinRoot,
|
|
553
|
+
isImageName,
|
|
554
|
+
ID_RE,
|
|
555
|
+
KNOWN_STATES,
|
|
556
|
+
MAX_FILE_BYTES,
|
|
557
|
+
MAX_PACK_BYTES,
|
|
558
|
+
MAX_PACK_FILES,
|
|
559
|
+
};
|
|
@@ -24,22 +24,69 @@ const questions = require('./questions.js');
|
|
|
24
24
|
* - `pinnedSessionIds`:已置顶的会话。摘要里没有置顶字段,不读这份快照就画不出置顶标记、
|
|
25
25
|
* 也排不到工作区最前。
|
|
26
26
|
*
|
|
27
|
-
* 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10
|
|
28
|
-
*
|
|
27
|
+
* 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10 秒重建一次。
|
|
28
|
+
*
|
|
29
|
+
* 取不到时两个集合都回空,另外带一个 `known: false`。调用方据此分两种口径:
|
|
30
|
+
*
|
|
31
|
+
* - **列表**(`/sessions`)按空集合画:宁可多显示几行,也不能因为一次 RPC 抖动让整个侧栏变空。
|
|
32
|
+
* - **提醒**(`/monitor`)不许把「不知道」当成「没有归档」:那样一次抖动就会让已归档的会话
|
|
33
|
+
* 重新进监控视图,被当成刚跑完再响一遍。所以那条路由另外用一本账([archivedSessionIds]),
|
|
34
|
+
* 记着最近一次真读到的归档集合,读不到就沿用它。
|
|
29
35
|
*/
|
|
30
36
|
async function workspaceSessionFlags(ctx) {
|
|
31
37
|
try {
|
|
32
38
|
const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
33
39
|
const value = unwrap(resp).value;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
40
|
+
// 集合被人当成 Set 传过(宿主与本仓库两个来源形状不一样,dsh-devmap 专门归一化过),
|
|
41
|
+
// 所以数组与 Set 都收:只认 `Array.isArray` 会让那一类宿主上的归档过滤恒失效。
|
|
42
|
+
const idSetOf = (field) => {
|
|
43
|
+
const raw = value && value[field];
|
|
44
|
+
const list = Array.isArray(raw) ? raw : (raw instanceof Set ? [...raw] : null);
|
|
45
|
+
return list ? new Set(list.filter(id => typeof id === 'string')) : null;
|
|
46
|
+
};
|
|
47
|
+
const archived = idSetOf('archivedSessionIds');
|
|
48
|
+
// 判据只看归档那一格:置顶是画图用的,缺了顶多个图钉,不值得让它把归档判断一起拖死。
|
|
49
|
+
const pinned = idSetOf('pinnedSessionIds') ?? new Set();
|
|
50
|
+
if (!archived) return { archived: new Set(), pinned, known: false };
|
|
51
|
+
return { archived, pinned, known: true };
|
|
38
52
|
} catch {
|
|
39
|
-
return { archived: new Set(), pinned: new Set() };
|
|
53
|
+
return { archived: new Set(), pinned: new Set(), known: false };
|
|
40
54
|
}
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
/**
|
|
58
|
+
* `/monitor` 用的归档集合:**只认读到的**,读不到就沿用上一次读到的那一份。
|
|
59
|
+
*
|
|
60
|
+
* 为什么不跟列表一样 fail-open:这条视图的作用是决定「要不要在后台叫醒人」,而它的判据是
|
|
61
|
+
* 「这个会话还在不在清单里」。一次读不到就按空集合画,已归档的会话会重新进清单 —— 原生那边
|
|
62
|
+
* 刚因为它消失而清了基线,于是它成了「第一次见到」,只要那一轮结束在十分钟内就再响一遍
|
|
63
|
+
* (延迟多久取决于宿主的 RPC 超时,反复抖动就会反复响)。列表那边空一下只是多显示几行,
|
|
64
|
+
* 这边空一下是半夜多叮一声,两种代价不是一个量级。
|
|
65
|
+
*
|
|
66
|
+
* 也刻意**不**回 5xx:README 那条规矩是「一拍什么都没取到就退回完整清单」,而这条 RPC
|
|
67
|
+
* 在 0.2.0 宿主要现开 `workspace.follow` 取首帧(8 秒预算),失败率不低 —— 每失败一拍就
|
|
68
|
+
* 让整个后台提醒哑掉,代价比偶尔多滤错一条大得多。沿用旧账的最坏结果是「刚归档的会话还在
|
|
69
|
+
* 清单里挂一会儿」,而它挂着的那些拍,这条会话的 `completed` 还会按已读水位判,不会凭空响。
|
|
70
|
+
*
|
|
71
|
+
* 一本账都没有(进程刚起来就问不到)时退回空集合 —— 那时候原生手上也没有基线,
|
|
72
|
+
* 首拍见到什么都只建基线、不提醒,所以空集合不会凭空造出一次提醒。
|
|
73
|
+
*/
|
|
74
|
+
const archivedSessionIds = { ids: null };
|
|
75
|
+
|
|
76
|
+
async function archivedSessionIdsForMonitor(ctx) {
|
|
77
|
+
const { archived, known } = await workspaceSessionFlags(ctx);
|
|
78
|
+
if (known) {
|
|
79
|
+
archivedSessionIds.ids = archived;
|
|
80
|
+
return archived;
|
|
81
|
+
}
|
|
82
|
+
return archivedSessionIds.ids ?? archived;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 仅供守卫重置进程内那本归档账。 */
|
|
86
|
+
function resetArchivedSessionIdsForTest() {
|
|
87
|
+
archivedSessionIds.ids = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
/**
|
|
44
91
|
* 「这条会话是不是在等人回话」以及等的是什么。
|
|
45
92
|
*
|
|
@@ -72,4 +119,10 @@ function pendingCardOf(sessionId, summary) {
|
|
|
72
119
|
return approvals.pendingCardOf(sessionId) || questions.pendingCardOf(sessionId);
|
|
73
120
|
}
|
|
74
121
|
|
|
75
|
-
module.exports = {
|
|
122
|
+
module.exports = {
|
|
123
|
+
workspaceSessionFlags,
|
|
124
|
+
archivedSessionIdsForMonitor,
|
|
125
|
+
resetArchivedSessionIdsForTest,
|
|
126
|
+
pendingInteractionOf,
|
|
127
|
+
pendingCardOf,
|
|
128
|
+
};
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('node:fs/promises');
|
|
18
18
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
19
|
-
const { localImageOf } = require('./local-images.js');
|
|
19
|
+
const { localImageOf, MAX_IMAGE_BYTES } = require('./local-images.js');
|
|
20
20
|
|
|
21
21
|
async function handleSessionLocalImage(ctx, req, res) {
|
|
22
22
|
const body = await readJsonBody(req);
|
|
@@ -28,6 +28,14 @@ async function handleSessionLocalImage(ctx, req, res) {
|
|
|
28
28
|
if (!entry) {
|
|
29
29
|
return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这张图没有登记,或登记已失效' } });
|
|
30
30
|
}
|
|
31
|
+
// 超过单张上限的那张:登记过(编号在正文里),但字节不往回搬。回一个能读的 413,
|
|
32
|
+
// App 据此画「图片太大」的占位 —— 比在正文里留一串 markdown 源码有用得多。
|
|
33
|
+
if (entry.tooLarge) {
|
|
34
|
+
return sendJson(res, 413, {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: { code: 'too-large', message: '这张图超过了单张上限,手机这边取不回来', details: { maxBytes: MAX_IMAGE_BYTES } },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
31
39
|
let data;
|
|
32
40
|
try {
|
|
33
41
|
data = await fs.readFile(entry.filePath);
|