dsh-mobile-plugin 0.5.21 → 0.5.23
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/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/live-steps.js +35 -3
- package/lib/mobile/local-images.js +166 -87
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- 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/stream.js +8 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +10 -10
- 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 +65 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +270 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/lib/update-install.cjs +43 -0
- package/package.json +10 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 已审核扩展清单:随 dsh-mobile-plugin 的 npm 包一起发布的第三方扩展(包内 `extensions/`)。
|
|
5
|
+
*
|
|
6
|
+
* 与开发包那条路的分界:开发包读作者电脑上的 `$DSH_HOME/extension-dev`(边写边看),这条读
|
|
7
|
+
* 插件包自带的只读目录 —— 只有审过、登记进 `extensions/catalog.json` 的包才下发,目录里有但
|
|
8
|
+
* 没登记的一律当不存在。审核闸门在清单,不在目录。
|
|
9
|
+
*
|
|
10
|
+
* 能力面只多「读这一个固定目录」:根由 `__dirname` 定位,不依赖进程 cwd,也不建目录;每个
|
|
11
|
+
* 文件的 size 与 sha256 由宿主扫目录现算,不信清单里写的数字。取文件先挡绝对路径与 `..` 段,
|
|
12
|
+
* 再 resolve 之后判前缀(带分隔符),不接受任意路径。仍然不暴露 shell、系统设置、任意文件读写。
|
|
13
|
+
*
|
|
14
|
+
* 坏包一律静默跳过:没登记、目录不在、manifest 读不出、超限(20MB / 200 个文件)都不该让整张
|
|
15
|
+
* 列表变红;catalog.json 不在或坏了就是一份空清单,不抛。取文件那条才逐个回错误码。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('node:crypto');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const { sendJson } = require('../shared/http-util');
|
|
23
|
+
|
|
24
|
+
/** 审核清单文件名。只有列进来的 slug 才下发。 */
|
|
25
|
+
const CATALOG_FILE = 'catalog.json';
|
|
26
|
+
/** slug 直接当包目录名,所以只认这套字符;别的名字连读都不读。 */
|
|
27
|
+
const SLUG_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
28
|
+
/** 整包上限:与开发包同一档,太重的包手机也不该整份拉。 */
|
|
29
|
+
const MAX_PACKAGE_BYTES = 20 * 1024 * 1024;
|
|
30
|
+
const MAX_PACKAGE_FILES = 200;
|
|
31
|
+
/** 单个文件上限:一条 base64 响应要塞进手机内存,再大就不该走这条路。 */
|
|
32
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** 包内固定目录:从 lib/mobile/ 往上两级就是包根。不依赖 cwd、不建目录(随 npm 包发布)。 */
|
|
35
|
+
function catalogRoot() {
|
|
36
|
+
return path.join(__dirname, '..', '..', 'extensions');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
|
|
40
|
+
function fail(res, status, code, message) {
|
|
41
|
+
return sendJson(res, status, { ok: false, error: { code, message } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** manifest 里取一个非空字符串,缺了 / 类型不对 / 空串都退回默认值。 */
|
|
45
|
+
function textField(value, fallback) {
|
|
46
|
+
return typeof value === 'string' && value ? value : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 清单里的可选文案:非空字符串才收,其余一律 null。 */
|
|
50
|
+
function textOrNull(value) {
|
|
51
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 能力声明收窄成字符串数组:表外的类型与空串丢掉,重复的不给第二份。 */
|
|
55
|
+
function capabilitiesOf(value) {
|
|
56
|
+
if (!Array.isArray(value)) return [];
|
|
57
|
+
const out = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const item of value) {
|
|
60
|
+
if (typeof item !== 'string' || !item.trim() || seen.has(item)) continue;
|
|
61
|
+
seen.add(item);
|
|
62
|
+
out.push(item);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sha256Hex(raw) {
|
|
68
|
+
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 读审核清单。文件不在、读坏、形状不对都当空清单 —— 空的插件包本来就没有第三方扩展。
|
|
73
|
+
* 只收 slug / description / author / releaseNotes 四样,其余字段(包括任何 size / sha256)
|
|
74
|
+
* 一概不认:清单只负责「谁审过了」,文件事实由扫目录算。
|
|
75
|
+
*/
|
|
76
|
+
function readCatalog(root) {
|
|
77
|
+
let text;
|
|
78
|
+
try { text = fs.readFileSync(path.join(root, CATALOG_FILE), 'utf8'); } catch { return []; }
|
|
79
|
+
let parsed;
|
|
80
|
+
try { parsed = JSON.parse(text); } catch { return []; }
|
|
81
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.packages)) return [];
|
|
82
|
+
const rows = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const raw of parsed.packages) {
|
|
85
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
86
|
+
const slug = typeof raw.slug === 'string' ? raw.slug.trim() : '';
|
|
87
|
+
if (!SLUG_PATTERN.test(slug) || seen.has(slug)) continue;
|
|
88
|
+
seen.add(slug);
|
|
89
|
+
rows.push({
|
|
90
|
+
slug,
|
|
91
|
+
description: textOrNull(raw.description),
|
|
92
|
+
author: textOrNull(raw.author),
|
|
93
|
+
releaseNotes: textOrNull(raw.releaseNotes),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 递归收集包内的普通文件,`path` 是相对包目录的 POSIX 路径。与 extension-dev.js 同一套口径:
|
|
101
|
+
* 点开头的文件与目录不看,符号链接不是普通文件,超限当场收手返回 false(先收完再判等于把
|
|
102
|
+
* 500MB 的目录整个哈希一遍)。
|
|
103
|
+
*/
|
|
104
|
+
function collectFiles(directory, acc) {
|
|
105
|
+
let entries;
|
|
106
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return false; }
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (entry.name.startsWith('.')) continue;
|
|
109
|
+
const full = path.join(directory, entry.name);
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
if (!collectFiles(full, acc)) return false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!entry.isFile()) continue;
|
|
115
|
+
let raw;
|
|
116
|
+
try { raw = fs.readFileSync(full); } catch { continue; }
|
|
117
|
+
acc.bytes += raw.length;
|
|
118
|
+
acc.files.push({
|
|
119
|
+
path: path.relative(acc.root, full).split(path.sep).join('/'),
|
|
120
|
+
size: raw.length,
|
|
121
|
+
sha256: sha256Hex(raw),
|
|
122
|
+
});
|
|
123
|
+
if (acc.bytes > MAX_PACKAGE_BYTES || acc.files.length > MAX_PACKAGE_FILES) return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 扫审核清单,把每个登记过、且目录与 manifest 都在的包读成一份 Package。
|
|
130
|
+
*
|
|
131
|
+
* 入口页固定是包目录下那层 `index.html`:App 侧 `extensionEntryPath` 找的就是这个名字,
|
|
132
|
+
* `manifest.json` 里写 `main` 不生效。所以这里在列包时先确认 `index.html` 真的在文件清单里,
|
|
133
|
+
* 缺了就把整包跳过 —— 装到手机上再报「需要重新安装」,比根本不出现在清单里难查得多。
|
|
134
|
+
*
|
|
135
|
+
* 纯函数、root 由调用方给:守卫拿假目录跑,线上 handler 给包内那个固定目录。
|
|
136
|
+
*/
|
|
137
|
+
function listPackages(root) {
|
|
138
|
+
const packages = [];
|
|
139
|
+
for (const entry of readCatalog(root)) {
|
|
140
|
+
const directory = path.join(root, entry.slug);
|
|
141
|
+
let stats;
|
|
142
|
+
try { stats = fs.statSync(directory); } catch { continue; }
|
|
143
|
+
if (!stats.isDirectory()) continue;
|
|
144
|
+
let manifest;
|
|
145
|
+
try {
|
|
146
|
+
manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
|
|
147
|
+
} catch { continue; }
|
|
148
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) continue;
|
|
149
|
+
const acc = { root: directory, files: [], bytes: 0 };
|
|
150
|
+
if (!collectFiles(directory, acc) || acc.files.length === 0) continue;
|
|
151
|
+
if (!acc.files.some((file) => file.path === 'index.html')) continue;
|
|
152
|
+
packages.push({
|
|
153
|
+
slug: entry.slug,
|
|
154
|
+
name: textField(manifest.name, entry.slug),
|
|
155
|
+
description: entry.description,
|
|
156
|
+
author: entry.author,
|
|
157
|
+
versionName: textField(manifest.versionName, textField(manifest.version, '0.0.0')),
|
|
158
|
+
apiVersion: textField(manifest.apiVersion, textField(manifest.api_version, '1')),
|
|
159
|
+
releaseNotes: entry.releaseNotes,
|
|
160
|
+
capabilities: capabilitiesOf(manifest.capabilities),
|
|
161
|
+
size: acc.bytes,
|
|
162
|
+
fileCount: acc.files.length,
|
|
163
|
+
files: acc.files,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return packages;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* GET /api/mobile/v1/extension/catalog(token)
|
|
171
|
+
*
|
|
172
|
+
* 回审核过的第三方扩展清单。`root` 只是给守卫传假目录的可选参数,线上由 route() 调用时
|
|
173
|
+
* 不传,永远走包内那个固定目录。
|
|
174
|
+
*/
|
|
175
|
+
async function handleCatalog(_ctx, _req, res, root = catalogRoot()) {
|
|
176
|
+
sendJson(res, 200, { ok: true, packages: listPackages(root) });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* GET /api/mobile/v1/extension/catalog/file?slug=…&path=…(token)
|
|
181
|
+
*
|
|
182
|
+
* 取一个已审核包里的一个文件,回 base64 与 sha256(与列表里那条同一份算法)。审核闸门对取文件
|
|
183
|
+
* 同样生效:没登记进 catalog.json 的 slug 一律 404,免得绕过清单直接把目录里的字节读走。
|
|
184
|
+
*
|
|
185
|
+
* 路径检查两道:先挡明显不该出现的(绝对路径、`..` 段),再 resolve 之后判前缀 —— 后者才是
|
|
186
|
+
* 判据,前缀比较必须带分隔符,否则 `/x/extensions/foobar` 会被 `/x/extensions/foo` 认成子路径。
|
|
187
|
+
*/
|
|
188
|
+
async function handleCatalogFile(_ctx, req, res, root = catalogRoot()) {
|
|
189
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
190
|
+
const slug = (url.searchParams.get('slug') || '').trim();
|
|
191
|
+
const requested = (url.searchParams.get('path') || '').trim();
|
|
192
|
+
if (!slug || !requested || !SLUG_PATTERN.test(slug)) {
|
|
193
|
+
return fail(res, 400, 'bad-request', '需要合法的 slug 与 path(path 是相对包目录的路径)');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!readCatalog(root).some((entry) => entry.slug === slug)) {
|
|
197
|
+
return fail(res, 404, 'package-missing', `审核清单里没有 ${slug} 这个包`);
|
|
198
|
+
}
|
|
199
|
+
const directory = path.join(root, slug);
|
|
200
|
+
if (!fs.existsSync(path.join(directory, 'manifest.json'))) {
|
|
201
|
+
return fail(res, 404, 'package-missing', `${slug} 没有可下发的包(缺 manifest.json)`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (path.isAbsolute(requested) || requested.split(/[\\/]/).includes('..')) {
|
|
205
|
+
return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
|
|
206
|
+
}
|
|
207
|
+
const target = path.resolve(directory, requested);
|
|
208
|
+
if (!target.startsWith(`${directory}${path.sep}`)) {
|
|
209
|
+
return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let stats;
|
|
213
|
+
try { stats = fs.statSync(target); } catch { stats = null; }
|
|
214
|
+
if (!stats || !stats.isFile()) {
|
|
215
|
+
return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
|
|
216
|
+
}
|
|
217
|
+
if (stats.size > MAX_FILE_BYTES) {
|
|
218
|
+
return fail(res, 413, 'file-too-large', `单个文件上限 ${MAX_FILE_BYTES} 字节,这个超了`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let raw;
|
|
222
|
+
try { raw = fs.readFileSync(target); } catch {
|
|
223
|
+
return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const relative = path.relative(directory, target).split(path.sep).join('/');
|
|
227
|
+
sendJson(res, 200, {
|
|
228
|
+
ok: true,
|
|
229
|
+
path: relative,
|
|
230
|
+
size: raw.length,
|
|
231
|
+
sha256: sha256Hex(raw),
|
|
232
|
+
content: raw.toString('base64'),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
catalogRoot,
|
|
238
|
+
listPackages,
|
|
239
|
+
handleCatalog,
|
|
240
|
+
handleCatalogFile,
|
|
241
|
+
MAX_PACKAGE_BYTES,
|
|
242
|
+
MAX_PACKAGE_FILES,
|
|
243
|
+
MAX_FILE_BYTES,
|
|
244
|
+
};
|
package/lib/mobile/live-steps.js
CHANGED
|
@@ -48,6 +48,8 @@ function turnOf(ctx, sessionId) {
|
|
|
48
48
|
/** 还没发出去的那几步(`drainLiveSteps` 取走)。 */
|
|
49
49
|
pending: [],
|
|
50
50
|
ended: false,
|
|
51
|
+
/** 这份副本属于哪一轮(宿主 turn 号):换轮就整份作废,见 `turnForEvent`。 */
|
|
52
|
+
turnNo: null,
|
|
51
53
|
};
|
|
52
54
|
bucket.delete(sessionId);
|
|
53
55
|
bucket.set(sessionId, turn);
|
|
@@ -57,6 +59,28 @@ function turnOf(ctx, sessionId) {
|
|
|
57
59
|
return turn;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 把一条事件归到它所属的那一轮;**轮号一变就整份作废**。
|
|
64
|
+
*
|
|
65
|
+
* 为什么要靠事件自带的 `data.turn` 兜这一手:`noteLiveEvent` 只在这条流上收到 `turn/start`
|
|
66
|
+
* 时清桶,可断线重连后那一轮的 `turn/start` 已经在重连之前发生过、被当作「回放已覆盖」丢掉,
|
|
67
|
+
* 于是新的一轮的步骤会接着上一轮往后长,越积越多、最后全挂在最新那条消息下。每一帧增量
|
|
68
|
+
* 都带着它属于哪个 turn,用它来判就不会漏:turn 号变了就把整轮副本换掉(下一轮的步骤从空起)。
|
|
69
|
+
* 同一轮内(turn 号相同)原样沿用。
|
|
70
|
+
*/
|
|
71
|
+
function turnForEvent(ctx, sessionId, data) {
|
|
72
|
+
const bucket = bucketOf(ctx);
|
|
73
|
+
const explicitTurn = data && typeof data.turn === 'number' ? data.turn : null;
|
|
74
|
+
let turn = bucket.get(sessionId);
|
|
75
|
+
if (turn && explicitTurn !== null && turn.turnNo !== null && turn.turnNo !== explicitTurn) {
|
|
76
|
+
bucket.delete(sessionId);
|
|
77
|
+
turn = undefined;
|
|
78
|
+
}
|
|
79
|
+
if (!turn) turn = turnOf(ctx, sessionId);
|
|
80
|
+
if (turn.turnNo === null && explicitTurn !== null) turn.turnNo = explicitTurn;
|
|
81
|
+
return turn;
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
/** 把一段文本接到已有的那段后面,超过上限就只留尾巴。 */
|
|
61
85
|
function appendText(prev, next) {
|
|
62
86
|
if (typeof next !== 'string' || next === '') return prev || '';
|
|
@@ -160,11 +184,12 @@ function noteLiveEvent(ctx, sessionId, event) {
|
|
|
160
184
|
if (event.type === 'turn/start') {
|
|
161
185
|
// 新的一轮开始:上一轮的过程副本作废(换掉了,就别再让人点开旧内容)。
|
|
162
186
|
bucketOf(ctx).delete(sessionId);
|
|
163
|
-
turnOf(ctx, sessionId);
|
|
187
|
+
const started = turnOf(ctx, sessionId);
|
|
188
|
+
if (data && typeof data.turn === 'number') started.turnNo = data.turn;
|
|
164
189
|
return;
|
|
165
190
|
}
|
|
166
191
|
if (!data) return;
|
|
167
|
-
const turn =
|
|
192
|
+
const turn = turnForEvent(ctx, sessionId, data);
|
|
168
193
|
if (event.type === 'assistant/message') {
|
|
169
194
|
// 一步落定:这一步的思考按块序号收尾(后面那一步的块序号从 0 重新数)。
|
|
170
195
|
closeThoughtsOfStep(turn);
|
|
@@ -303,6 +328,13 @@ function liveStepsOf(ctx, sessionId) {
|
|
|
303
328
|
return turn.steps.map((entry) => ({ key: entry.key, step: { ...entry.step } }));
|
|
304
329
|
}
|
|
305
330
|
|
|
331
|
+
/** 这份直播清单属于哪一轮(宿主 turn 号)——随 `session/live-steps` 帧下发,App 据此按轮隔离。 */
|
|
332
|
+
function liveStepsTurnOf(ctx, sessionId) {
|
|
333
|
+
if (!ctx || typeof sessionId !== 'string' || !sessionId) return null;
|
|
334
|
+
const turn = liveStepTurnOf(ctx).get(sessionId);
|
|
335
|
+
return turn && typeof turn.turnNo === 'number' ? turn.turnNo : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
306
338
|
/**
|
|
307
339
|
* 取走「还没告诉过手机」的那几步。
|
|
308
340
|
*
|
|
@@ -354,4 +386,4 @@ function clearLiveSteps(ctx, sessionId) {
|
|
|
354
386
|
liveStepTurnOf(ctx).delete(sessionId);
|
|
355
387
|
}
|
|
356
388
|
|
|
357
|
-
module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, drainLiveSteps, clearLiveSteps };
|
|
389
|
+
module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, liveStepsTurnOf, drainLiveSteps, clearLiveSteps };
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* 保守取舍——宁可保留字面 markdown,也不给半截东西:
|
|
19
19
|
* - 本机路径认三种:绝对(`/x/y.png`、`file:///x/y.png`)、`~/y.png`、相对(`deliverables/a.png`,
|
|
20
20
|
* 按调用方给的工作区目录解析,与桌面端同口径;没给目录就当成不是本机图);http(s)、`data:` 不碰;
|
|
21
|
+
* - 双分隔符打头的网页地址(协议相对 `//host/…`)与 Windows 网络 / 扩展路径(`\\host\…`、`\\?\…`)
|
|
22
|
+
* 不碰:它们不是本机文件,在 Windows 上会被当成 UNC 去 stat、一读就卡几十秒;
|
|
21
23
|
* - 只认 png/jpg/jpeg/webp/gif,并核对魔术字节,不按扩展名撒谎;
|
|
22
24
|
* - 宽高只读文件头(最多 64KB);读不出来就不带宽高,App 那边照旧现量;
|
|
23
25
|
* - 围栏代码块与行内代码里的图片语法不碰,那是写法示例。
|
|
@@ -44,7 +46,7 @@ const MEDIA_TYPES = {
|
|
|
44
46
|
'.gif': 'image/gif',
|
|
45
47
|
};
|
|
46
48
|
|
|
47
|
-
/**
|
|
49
|
+
/** 单张图上限:再大的东西不该整包回给手机。 */
|
|
48
50
|
const MAX_IMAGE_BYTES = 2.5 * 1024 * 1024;
|
|
49
51
|
/** 读宽高只需要文件头:JPEG 的 SOF 段可能在几百字节之后,64KB 足够覆盖绝大多实拍图。 */
|
|
50
52
|
const HEAD_BYTES = 64 * 1024;
|
|
@@ -85,7 +87,9 @@ function localImagePathOf(url, cwd) {
|
|
|
85
87
|
let value = url.trim();
|
|
86
88
|
if (!value) return null;
|
|
87
89
|
if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
|
|
88
|
-
|
|
90
|
+
// 盘符路径(`C:\…`、`C:/…`)不是 URL 协议:单字母后跟分隔符的别当成 scheme 拦掉。
|
|
91
|
+
const hasUrlScheme = /^[a-z][a-z0-9+.-]*:/i.test(value) && !/^[a-z]:[\\/]/i.test(value);
|
|
92
|
+
if (hasUrlScheme) {
|
|
89
93
|
if (value.slice(0, 7).toLowerCase() !== 'file://') return null;
|
|
90
94
|
try {
|
|
91
95
|
value = decodeURIComponent(value.slice(7));
|
|
@@ -96,6 +100,10 @@ function localImagePathOf(url, cwd) {
|
|
|
96
100
|
} else if (value === '~' || value.startsWith('~/')) {
|
|
97
101
|
value = path.join(os.homedir(), value.slice(1));
|
|
98
102
|
}
|
|
103
|
+
// 双分隔符打头的是网页地址(协议相对 `//host/…`)或 Windows 网络 / 扩展路径(`\\host\…`、
|
|
104
|
+
// `\\?\…`)。它们不是本机文件,在 Windows 上会被当成 UNC 去 stat、一读就卡几十秒;
|
|
105
|
+
// `file://` 的远程主机经上面剥完也可能落到这里,同样不认。
|
|
106
|
+
if (/^[\\/]{2}/.test(value)) return null;
|
|
99
107
|
if (!path.isAbsolute(value)) {
|
|
100
108
|
if (!cwd) return null;
|
|
101
109
|
value = path.resolve(cwd, value);
|
|
@@ -246,6 +254,10 @@ async function readHead(filePath, size) {
|
|
|
246
254
|
*
|
|
247
255
|
* 编号取「路径 + 大小 + 修改时间」的短哈希:同一张图在同一轮历史里会被反复解析,
|
|
248
256
|
* 这个组合稳定,且改过之后自然换号(图片内容变了本来就该换)。
|
|
257
|
+
*
|
|
258
|
+
* **超过单张上限的图也登记,只是标一句 `tooLarge`**:不登记的话正文里那一段就退回字面
|
|
259
|
+
* markdown,用户在手机上看到的是一串 `` 源码 —— 既不知道发生过什么,也没有任何
|
|
260
|
+
* 入口。标了之后取图口如实回 413,App 画成「图片太大」的占位块,这才是用户能读懂的一句话。
|
|
249
261
|
*/
|
|
250
262
|
async function registerLocalImage(filePath) {
|
|
251
263
|
let stat;
|
|
@@ -254,7 +266,7 @@ async function registerLocalImage(filePath) {
|
|
|
254
266
|
} catch {
|
|
255
267
|
return null;
|
|
256
268
|
}
|
|
257
|
-
if (!stat.isFile() || stat.size <= 0
|
|
269
|
+
if (!stat.isFile() || stat.size <= 0) return null;
|
|
258
270
|
const id = crypto.createHash('sha1').update(`${filePath}:${stat.size}:${stat.mtimeMs}`).digest('hex').slice(0, 16);
|
|
259
271
|
const known = registry.get(id);
|
|
260
272
|
if (known) return { id, ...known };
|
|
@@ -262,25 +274,161 @@ async function registerLocalImage(filePath) {
|
|
|
262
274
|
if (!head) return null;
|
|
263
275
|
const mediaType = sniffMediaType(head);
|
|
264
276
|
if (!mediaType) return null;
|
|
265
|
-
const
|
|
277
|
+
const over = stat.size > MAX_IMAGE_BYTES;
|
|
278
|
+
const parsed = over ? null : imageSizeOf(head);
|
|
266
279
|
const sane = parsed && parsed.width > 0 && parsed.height > 0
|
|
267
280
|
&& parsed.width <= 30000 && parsed.height <= 30000;
|
|
268
281
|
const size = sane ? parsed : {};
|
|
269
|
-
const entry = { filePath, mediaType, ...size };
|
|
282
|
+
const entry = { filePath, mediaType, ...size, ...(over ? { tooLarge: true } : {}) };
|
|
270
283
|
registry.set(id, entry);
|
|
271
|
-
|
|
284
|
+
trimRegistry();
|
|
272
285
|
return { id, ...entry };
|
|
273
286
|
}
|
|
274
287
|
|
|
288
|
+
/** 登记表满了就淘汰最老的一条;取回来的临时文件一并删掉,别在地上留垃圾。 */
|
|
289
|
+
function trimRegistry() {
|
|
290
|
+
while (registry.size > MAX_REGISTRY_ENTRIES) {
|
|
291
|
+
const oldestId = registry.keys().next().value;
|
|
292
|
+
const entry = registry.get(oldestId);
|
|
293
|
+
registry.delete(oldestId);
|
|
294
|
+
if (entry && entry.fetched && entry.filePath) fs.unlink(entry.filePath).catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
275
298
|
/** 取图口用:只认登记表里的编号(构造不出来的编号一律 null → 404)。 */
|
|
276
299
|
function localImageOf(id) {
|
|
277
300
|
if (typeof id !== 'string' || !id) return null;
|
|
278
301
|
return registry.get(id) || null;
|
|
279
302
|
}
|
|
280
303
|
|
|
281
|
-
/**
|
|
304
|
+
/** 清空登记表(测试用)。取回来的临时文件一并删掉。 */
|
|
282
305
|
function clearLocalImages() {
|
|
306
|
+
for (const entry of registry.values()) {
|
|
307
|
+
if (entry && entry.fetched && entry.filePath) fs.unlink(entry.filePath).catch(() => {});
|
|
308
|
+
}
|
|
283
309
|
registry.clear();
|
|
310
|
+
loopbackUrlCache.clear();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 回环地址的主机名:出现在正文里时指的是「跑插件这台电脑自己」,手机拿着同一个地址
|
|
315
|
+
* 只会指回手机自己。生图插件常把成品图挂在自己电脑上的一个小服务上,回的就是这种地址。
|
|
316
|
+
*/
|
|
317
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1', '::']);
|
|
318
|
+
|
|
319
|
+
/** 取回回环图片的超时:只在投影一条消息时占这一小会儿,取不到就保持字面 markdown。 */
|
|
320
|
+
const LOOPBACK_FETCH_TIMEOUT_MS = 4000;
|
|
321
|
+
/** 失败后多久内不再重试同一地址:翻历史碰上已经关掉的服务,不该每次都干等一次超时。 */
|
|
322
|
+
const LOOPBACK_FAIL_TTL_MS = 30 * 1000;
|
|
323
|
+
/** 同一地址取到过的图片编号;翻历史时直接复用,不必重复取。 */
|
|
324
|
+
const loopbackUrlCache = new Map();
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* `` 里的地址若是**回环地址的 http(s) 图片**,回那条地址;否则 null。
|
|
328
|
+
*
|
|
329
|
+
* 只认回环:别的远程地址一律不碰 —— 那会把「本机图片」变成「替手机随便抓任意网址」,
|
|
330
|
+
* 能力面就从「读这台电脑上的文件」扩成了代理一切出站请求。回环地址连不到外网,
|
|
331
|
+
* 只是这台电脑自己起的服务,与「读本机文件」同一类。
|
|
332
|
+
*/
|
|
333
|
+
function loopbackImageUrlOf(url) {
|
|
334
|
+
if (typeof url !== 'string') return null;
|
|
335
|
+
let value = url.trim();
|
|
336
|
+
if (!value) return null;
|
|
337
|
+
if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
|
|
338
|
+
if (!/^https?:\/\//i.test(value)) return null;
|
|
339
|
+
let parsed;
|
|
340
|
+
try {
|
|
341
|
+
parsed = new URL(value);
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
346
|
+
return LOOPBACK_HOSTNAMES.has(hostname) ? value : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 取回的图片字节落成临时文件再登记:取图口按编号读 `filePath`,与磁盘上的图同一条路。
|
|
351
|
+
*
|
|
352
|
+
* 编号取内容哈希:同一张图反复出现只落一份。超过单张上限的照样登记、但标 `tooLarge`
|
|
353
|
+
* (取图口回 413,App 画「图片太大」占位),与磁盘图片那条口径一致。
|
|
354
|
+
*/
|
|
355
|
+
async function registerLocalImageBytes(buffer, mediaType) {
|
|
356
|
+
if (!buffer || buffer.length === 0) return null;
|
|
357
|
+
const id = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 16);
|
|
358
|
+
const known = registry.get(id);
|
|
359
|
+
if (known) return { id, ...known };
|
|
360
|
+
const over = buffer.length > MAX_IMAGE_BYTES;
|
|
361
|
+
const parsed = over ? null : imageSizeOf(buffer);
|
|
362
|
+
const sane = parsed && parsed.width > 0 && parsed.height > 0
|
|
363
|
+
&& parsed.width <= 30000 && parsed.height <= 30000;
|
|
364
|
+
const size = sane ? parsed : {};
|
|
365
|
+
if (over) {
|
|
366
|
+
const entry = { filePath: '', mediaType, ...size, tooLarge: true, fetched: true };
|
|
367
|
+
registry.set(id, entry);
|
|
368
|
+
trimRegistry();
|
|
369
|
+
return { id, ...entry };
|
|
370
|
+
}
|
|
371
|
+
const ext = mediaType === 'image/jpeg' ? '.jpg'
|
|
372
|
+
: mediaType === 'image/gif' ? '.gif'
|
|
373
|
+
: mediaType === 'image/webp' ? '.webp' : '.png';
|
|
374
|
+
let filePath;
|
|
375
|
+
try {
|
|
376
|
+
const dir = path.join(os.tmpdir(), 'dsh-local-images');
|
|
377
|
+
await fs.mkdir(dir, { recursive: true });
|
|
378
|
+
filePath = path.join(dir, `${id}${ext}`);
|
|
379
|
+
await fs.writeFile(filePath, buffer);
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
const entry = { filePath, mediaType, ...size, fetched: true };
|
|
384
|
+
registry.set(id, entry);
|
|
385
|
+
trimRegistry();
|
|
386
|
+
return { id, ...entry };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 从回环地址取回一张图片:有超时、有大小上限、必须过魔术字节。
|
|
391
|
+
*
|
|
392
|
+
* 取不到 / 不是图 / 太大 / 重定向去别处,一律返回 null,调用方保持字面 markdown ——
|
|
393
|
+
* 宁可显示成字面文本,也不能让一段历史或一帧 SSE 因为一张图取不到而挂掉(与现有口径一致)。
|
|
394
|
+
* 只走一次 GET、不跟随重定向:跟去别处就不再是「回环图片」了。
|
|
395
|
+
*/
|
|
396
|
+
async function fetchLoopbackImage(url) {
|
|
397
|
+
if (typeof fetch !== 'function') return null;
|
|
398
|
+
const hit = loopbackUrlCache.get(url);
|
|
399
|
+
if (hit) {
|
|
400
|
+
if (hit.id && registry.has(hit.id)) return { id: hit.id, ...registry.get(hit.id) };
|
|
401
|
+
if (!hit.id && hit.failedAt && Date.now() - hit.failedAt < LOOPBACK_FAIL_TTL_MS) return null;
|
|
402
|
+
}
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
const timer = setTimeout(() => controller.abort(), LOOPBACK_FETCH_TIMEOUT_MS);
|
|
405
|
+
let buffer;
|
|
406
|
+
try {
|
|
407
|
+
const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
408
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
409
|
+
const declared = Number(res.headers.get('content-length'));
|
|
410
|
+
if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) throw new Error('too large');
|
|
411
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
412
|
+
} catch {
|
|
413
|
+
loopbackUrlCache.set(url, { failedAt: Date.now() });
|
|
414
|
+
return null;
|
|
415
|
+
} finally {
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
}
|
|
418
|
+
const mediaType = buffer && buffer.length > 0 && buffer.length <= MAX_IMAGE_BYTES
|
|
419
|
+
? sniffMediaType(buffer)
|
|
420
|
+
: null;
|
|
421
|
+
if (!mediaType) {
|
|
422
|
+
loopbackUrlCache.set(url, { failedAt: Date.now() });
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const entry = await registerLocalImageBytes(buffer, mediaType);
|
|
426
|
+
if (!entry) {
|
|
427
|
+
loopbackUrlCache.set(url, { failedAt: Date.now() });
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
loopbackUrlCache.set(url, { id: entry.id });
|
|
431
|
+
return entry;
|
|
284
432
|
}
|
|
285
433
|
/**
|
|
286
434
|
* Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
|
|
@@ -377,9 +525,17 @@ async function inlineTextImages(text, budget) {
|
|
|
377
525
|
let cursor = 0;
|
|
378
526
|
for (const m of matches) {
|
|
379
527
|
if (count <= 0) break;
|
|
528
|
+
let entry = null;
|
|
380
529
|
const filePath = localImagePathOf(m[2], pool.cwd);
|
|
381
|
-
if (
|
|
382
|
-
|
|
530
|
+
if (filePath) {
|
|
531
|
+
entry = await registerLocalImage(filePath);
|
|
532
|
+
} else {
|
|
533
|
+
// 第二种「本机图片」:生图插件把成品图挂在自己电脑的一个回环小服务上,回的是
|
|
534
|
+
// `http://127.0.0.1:<端口>/…`。手机拿着它只会指回手机自己,所以由电脑去取回来、
|
|
535
|
+
// 按编号给手机(与磁盘图片同一条路,宽高/缓存/重试全现成)。
|
|
536
|
+
const loopbackUrl = loopbackImageUrlOf(m[2]);
|
|
537
|
+
if (loopbackUrl) entry = await fetchLoopbackImage(loopbackUrl);
|
|
538
|
+
}
|
|
383
539
|
if (!entry) continue;
|
|
384
540
|
count -= 1;
|
|
385
541
|
pool.remaining = count;
|
|
@@ -389,82 +545,5 @@ async function inlineTextImages(text, budget) {
|
|
|
389
545
|
}
|
|
390
546
|
return cursor === 0 ? text : out + text.slice(cursor);
|
|
391
547
|
}
|
|
392
|
-
/**
|
|
393
|
-
* Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
|
|
394
|
-
*
|
|
395
|
-
* 代码块里的 `` 是**写法示例**而不是要画的图。旧实现整段正则替换,
|
|
396
|
-
* 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
|
|
397
|
-
* 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
|
|
398
|
-
* `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
|
|
399
|
-
*/
|
|
400
|
-
function literalRanges(text) {
|
|
401
|
-
const ranges = [];
|
|
402
|
-
const lines = text.split('\n');
|
|
403
|
-
const lineStarts = [];
|
|
404
|
-
let offset = 0;
|
|
405
|
-
for (const line of lines) {
|
|
406
|
-
lineStarts.push(offset);
|
|
407
|
-
offset += line.length + 1;
|
|
408
|
-
}
|
|
409
|
-
let fence = null;
|
|
410
|
-
let fenceStart = -1;
|
|
411
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
412
|
-
const line = lines[index];
|
|
413
|
-
if (fence) {
|
|
414
|
-
const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
|
|
415
|
-
if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
|
|
416
|
-
ranges.push([fenceStart, lineStarts[index] + line.length]);
|
|
417
|
-
fence = null;
|
|
418
|
-
}
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
const open = /^(`{3,}|~{3,})/.exec(line.trim());
|
|
422
|
-
if (!open) continue;
|
|
423
|
-
fence = { char: open[1][0], length: open[1].length };
|
|
424
|
-
fenceStart = lineStarts[index];
|
|
425
|
-
}
|
|
426
|
-
// 没闭合的围栏按 App 的解析口径一直吃到文末。
|
|
427
|
-
if (fence) ranges.push([fenceStart, text.length]);
|
|
428
|
-
|
|
429
|
-
// 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
|
|
430
|
-
let cursor = 0;
|
|
431
|
-
while (cursor < text.length) {
|
|
432
|
-
if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
|
|
433
|
-
cursor += 1;
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
let ticks = 1;
|
|
437
|
-
while (text[cursor + ticks] === '`') ticks += 1;
|
|
438
|
-
let scan = cursor + ticks;
|
|
439
|
-
let closed = -1;
|
|
440
|
-
while (scan < text.length) {
|
|
441
|
-
if (text[scan] !== '`' || inRanges(scan, ranges)) {
|
|
442
|
-
scan += 1;
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
let run = 1;
|
|
446
|
-
while (text[scan + run] === '`') run += 1;
|
|
447
|
-
if (run === ticks) {
|
|
448
|
-
closed = scan + run;
|
|
449
|
-
break;
|
|
450
|
-
}
|
|
451
|
-
scan += run;
|
|
452
|
-
}
|
|
453
|
-
if (closed < 0) {
|
|
454
|
-
cursor += ticks;
|
|
455
|
-
continue;
|
|
456
|
-
}
|
|
457
|
-
ranges.push([cursor, closed]);
|
|
458
|
-
cursor = closed;
|
|
459
|
-
}
|
|
460
|
-
return ranges;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function inRanges(index, ranges) {
|
|
464
|
-
for (const [start, end] of ranges) {
|
|
465
|
-
if (index >= start && index < end) return true;
|
|
466
|
-
}
|
|
467
|
-
return false;
|
|
468
|
-
}
|
|
469
548
|
|
|
470
|
-
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages };
|
|
549
|
+
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages, MAX_IMAGE_BYTES };
|