dsh-session-curator 0.1.0
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/LICENSE +21 -0
- package/README.md +216 -0
- package/README.zh-CN.md +171 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/lib/client.js +1011 -0
- package/lib/index.js +556 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +118 -0
- package/scripts/activation.mjs +358 -0
- package/scripts/client.mjs +418 -0
- package/scripts/smoke.mjs +181 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主半区**激活**自检 —— 对着真实 DSH 运行时的 cordis 跑一遍(不需要 DSH 在跑)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么除了 `smoke.mjs` 还要这个:`smoke.mjs` 用的是**假 ctx**(服务直接挂在根上),
|
|
5
|
+
* 它测得出"登记了什么路由",但复现不了这次的 405 事故 —— 那个事故要求服务**各自挂在
|
|
6
|
+
* 自己的兄弟 fiber 上**(Loader 挂 profile 条目就是这样),此时 cordis 的服务调用带
|
|
7
|
+
* shadow(`Context[symbols.shadow]` 指向提供者作用域),于是
|
|
8
|
+
* `ctx.connection.rpc.handle()` 会拿 **Connection 自己的 ctx** 去
|
|
9
|
+
* `owner.webServer.register(...)`,抛
|
|
10
|
+
*
|
|
11
|
+
* cannot get property "webServer" without inject
|
|
12
|
+
* (@deepseek-ai/dsh-client-connection/lib/index.js:656)
|
|
13
|
+
*
|
|
14
|
+
* 插件 fiber 直接 `failed`、一条路由都没挂上,客户端每个 POST 落到 SPA 静态兜底,
|
|
15
|
+
* 被回 **HTTP 405** —— 就是 `transport failure for /session-manager/trash: HTTP 405`。
|
|
16
|
+
*
|
|
17
|
+
* 本脚本从 `app.asar` 里抽出真实的 `@deepseek-ai/cordis` + `@deepseek-ai/cosmokit`,
|
|
18
|
+
* 用**逐字照抄**的 Connection(`rpc` / `fetch` / `createSharedFetchHandler` /
|
|
19
|
+
* `endpointFromPath` / `assertChannel` / `assertFetchRoute`)搭出同样的兄弟拓扑,然后:
|
|
20
|
+
* 1. 载入本插件的宿主半区,断言 fiber 变 **ACTIVE**(FAILED 直接判失败);
|
|
21
|
+
* 2. 断言 8 条 `/api/session-curator/*` 精确路由都登记上了;
|
|
22
|
+
* 3. 用真 `Request` 走一遍共享处理器(等价于宿主 `/api` 栅栏之后的路径),要 200 + 合法信封;
|
|
23
|
+
* 4. 登记一条"永远不该被碰到"的 webServer —— 谁把实现改回 `rpc.handle()` 就会撞上它;
|
|
24
|
+
* 5. dispose 插件,断言 8 条路由全部释放。
|
|
25
|
+
*
|
|
26
|
+
* 用法:`node scripts/activation.mjs`
|
|
27
|
+
* 找不到 `app.asar` 时打印 SKIP 并以 0 退出;可用 `$env:DSH_APP_ASAR` 指定路径。
|
|
28
|
+
*/
|
|
29
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
31
|
+
import { createRequire } from "node:module";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
34
|
+
import { pathToFileURL } from "node:url";
|
|
35
|
+
|
|
36
|
+
// ── 找 app.asar ──────────────────────────────────────────────────────────
|
|
37
|
+
function findArchive() {
|
|
38
|
+
const configured = process.env.DSH_APP_ASAR;
|
|
39
|
+
if (typeof configured === "string" && configured.trim() !== "") {
|
|
40
|
+
const path = resolve(configured.trim());
|
|
41
|
+
if (!existsSync(path)) throw new Error(`$DSH_APP_ASAR 指向的文件不存在:${path}`);
|
|
42
|
+
return path;
|
|
43
|
+
}
|
|
44
|
+
const candidates = [
|
|
45
|
+
"C:/Soft/DeepSeek Harness/resources/app.asar",
|
|
46
|
+
join(process.env.LOCALAPPDATA ?? "", "Programs/DeepSeek Harness/resources/app.asar"),
|
|
47
|
+
join(process.env.PROGRAMFILES ?? "", "DeepSeek Harness/resources/app.asar")
|
|
48
|
+
];
|
|
49
|
+
return candidates.find((path) => existsSync(path));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 最小 asar 读取器:Chromium pickle 头 + JSON 目录 + 顺序拼接的文件体。 */
|
|
53
|
+
async function readArchive(path) {
|
|
54
|
+
const buffer = await readFile(path);
|
|
55
|
+
const headerSize = buffer.readUInt32LE(4);
|
|
56
|
+
const jsonLength = buffer.readUInt32LE(12);
|
|
57
|
+
const header = JSON.parse(buffer.subarray(16, 16 + jsonLength).toString("utf8"));
|
|
58
|
+
const dataStart = 8 + headerSize;
|
|
59
|
+
const files = [];
|
|
60
|
+
(function walk(node, prefix) {
|
|
61
|
+
for (const [name, entry] of Object.entries(node.files ?? {})) {
|
|
62
|
+
const target = prefix === "" ? name : `${prefix}/${name}`;
|
|
63
|
+
if (entry.files) walk(entry, target);
|
|
64
|
+
else files.push({ path: target, offset: Number(entry.offset), size: entry.size });
|
|
65
|
+
}
|
|
66
|
+
})(header, "");
|
|
67
|
+
return {
|
|
68
|
+
files,
|
|
69
|
+
read(file) {
|
|
70
|
+
return buffer.subarray(dataStart + file.offset, dataStart + file.offset + file.size);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 把运行时的 cordis + cosmokit 抽到一个临时 node_modules 里(每次覆盖,避免陈旧)。 */
|
|
76
|
+
async function extractRuntime(archivePath) {
|
|
77
|
+
const archive = await readArchive(archivePath);
|
|
78
|
+
const root = join(tmpdir(), "dsh-session-curator-activation");
|
|
79
|
+
const modules = join(root, "node_modules");
|
|
80
|
+
await rm(modules, { recursive: true, force: true });
|
|
81
|
+
let extracted = 0;
|
|
82
|
+
for (const file of archive.files) {
|
|
83
|
+
const match = /^dsh\/node_modules\/(@deepseek-ai\/(?:cordis|cosmokit))(\/.*)?$/.exec(file.path);
|
|
84
|
+
if (match === null) continue;
|
|
85
|
+
const [, packageName, rest = ""] = match;
|
|
86
|
+
const wanted = rest === "" || rest === "/package.json" || rest.startsWith("/lib/");
|
|
87
|
+
if (!wanted) continue;
|
|
88
|
+
const target = join(modules, packageName, rest.replace(/^\//, ""));
|
|
89
|
+
await mkdir(dirname(target), { recursive: true });
|
|
90
|
+
await writeFile(target, archive.read(file));
|
|
91
|
+
extracted += 1;
|
|
92
|
+
}
|
|
93
|
+
if (extracted === 0) throw new Error("app.asar 里没有找到 @deepseek-ai/cordis —— 布局变了?");
|
|
94
|
+
return join(modules, "@deepseek-ai/cordis/lib/index.js");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── 逐字照抄 @deepseek-ai/dsh-client-connection 的判定与注册代码 ─────────
|
|
98
|
+
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/;
|
|
99
|
+
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
|
100
|
+
|
|
101
|
+
function endpointFromPath(channel, pathname) {
|
|
102
|
+
if (!pathname.startsWith(`${channel}/`)) return undefined;
|
|
103
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
104
|
+
if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return undefined;
|
|
105
|
+
return endpoint;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertChannel(channel) {
|
|
109
|
+
if (!CHANNEL_PATTERN.test(channel) || channel === "/api") throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertFetchRoute(route) {
|
|
113
|
+
if (endpointFromPath("/api", route.path) === undefined) throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`);
|
|
114
|
+
if (route.methods.length === 0) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`);
|
|
115
|
+
if (new Set(route.methods).size !== route.methods.length) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge"];
|
|
119
|
+
const CHANNEL = "/api";
|
|
120
|
+
const PREFIX = "session-curator";
|
|
121
|
+
const ROUTE_PREFIX = `${CHANNEL}/${PREFIX}`;
|
|
122
|
+
|
|
123
|
+
const checks = [];
|
|
124
|
+
function check(label, condition, detail) {
|
|
125
|
+
checks.push({ label, ok: Boolean(condition) });
|
|
126
|
+
console.log(`${condition ? " ok " : " FAIL "} ${label}${detail === undefined ? "" : ` — ${detail}`}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 跑 ───────────────────────────────────────────────────────────────────
|
|
130
|
+
const archivePath = findArchive();
|
|
131
|
+
if (archivePath === undefined) {
|
|
132
|
+
console.log("SKIP: 找不到 app.asar(用 $env:DSH_APP_ASAR 指定 DSH 的 resources/app.asar 后重跑)");
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(`runtime: ${archivePath}`);
|
|
137
|
+
const cordisEntry = await extractRuntime(archivePath);
|
|
138
|
+
const { Context, Service } = await import(pathToFileURL(cordisEntry).href);
|
|
139
|
+
|
|
140
|
+
/** 故意抛错:这个插件不该再碰 webServer(老实现才会)。 */
|
|
141
|
+
class WebServer extends Service {
|
|
142
|
+
constructor(ctx) {
|
|
143
|
+
super(ctx, "webServer");
|
|
144
|
+
}
|
|
145
|
+
register(route) {
|
|
146
|
+
throw new Error(`webserver.register 不该被本插件调用:${route.path}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** verbatim: dsh-client-connection/lib/index.js `HostConnectionService`。 */
|
|
151
|
+
class Connection extends Service {
|
|
152
|
+
fetchRoutes = new Map();
|
|
153
|
+
constructor(ctx) {
|
|
154
|
+
super(ctx, "connection");
|
|
155
|
+
}
|
|
156
|
+
get rpc() {
|
|
157
|
+
const owner = this.ctx;
|
|
158
|
+
return {
|
|
159
|
+
handle: (channel, handler) => this.register(owner, channel, handler),
|
|
160
|
+
intercept: (channel, matches, handler) => this.registerInterceptor(owner, channel, matches, handler)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
get fetch() {
|
|
164
|
+
const owner = this.ctx;
|
|
165
|
+
return { register: (route) => this.registerFetchRoute(owner, route) };
|
|
166
|
+
}
|
|
167
|
+
register(owner, channel) {
|
|
168
|
+
assertChannel(channel);
|
|
169
|
+
const route = { kind: "prefix", path: channel, handler: async () => {} };
|
|
170
|
+
return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
|
|
171
|
+
}
|
|
172
|
+
registerInterceptor() {
|
|
173
|
+
throw new Error("intercept 未实现(本插件不用)");
|
|
174
|
+
}
|
|
175
|
+
registerFetchRoute(owner, route) {
|
|
176
|
+
assertFetchRoute(route);
|
|
177
|
+
const registered = { methods: new Set(route.methods), requestBody: route.requestBody, fetch: route.fetch };
|
|
178
|
+
return owner.effect(() => {
|
|
179
|
+
if (this.fetchRoutes.has(route.path)) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`);
|
|
180
|
+
this.fetchRoutes.set(route.path, registered);
|
|
181
|
+
return () => {
|
|
182
|
+
this.fetchRoutes.delete(route.path);
|
|
183
|
+
};
|
|
184
|
+
}, `client-connection: ${route.path} Fetch route`);
|
|
185
|
+
}
|
|
186
|
+
createSharedFetchHandler(channel) {
|
|
187
|
+
return {
|
|
188
|
+
requestBodyMode: ({ method, url }) => {
|
|
189
|
+
const route = this.fetchRoutes.get(url.pathname);
|
|
190
|
+
return route?.methods.has(method) === true ? route.requestBody : "buffered";
|
|
191
|
+
},
|
|
192
|
+
fetch: (request) => {
|
|
193
|
+
const pathname = new URL(request.url).pathname;
|
|
194
|
+
const route = this.fetchRoutes.get(pathname);
|
|
195
|
+
if (route?.methods.has(request.method) === true) return route.fetch(request);
|
|
196
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
class WorkspaceRegistry extends Service {
|
|
203
|
+
constructor(ctx) {
|
|
204
|
+
super(ctx, "workspaceRegistry");
|
|
205
|
+
}
|
|
206
|
+
list() {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
get archivedSessionIds() {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
get pinnedSessionIds() {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 兄弟拓扑:Loader 挂 profile 条目就是每个服务一条自己的 fiber。
|
|
218
|
+
const root = new Context();
|
|
219
|
+
let connection;
|
|
220
|
+
root.plugin({ name: "webserver", apply: (ctx) => void new WebServer(ctx) });
|
|
221
|
+
root.plugin({
|
|
222
|
+
name: "connection",
|
|
223
|
+
apply: (ctx) => {
|
|
224
|
+
connection = new Connection(ctx);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
root.plugin({ name: "workspace-registry", apply: (ctx) => void new WorkspaceRegistry(ctx) });
|
|
228
|
+
|
|
229
|
+
const plugin = await import(pathToFileURL(process.argv[2] === undefined ? join(import.meta.dirname, "../lib/index.js") : resolve(process.argv[2])).href);
|
|
230
|
+
const fiber = root.plugin(plugin);
|
|
231
|
+
let failure;
|
|
232
|
+
try {
|
|
233
|
+
await fiber.await();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
failure = error;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
check("host half activates (fiber ACTIVE)", fiber.state === 2, failure === undefined ? `state=${fiber.state}` : `state=${fiber.state}: ${failure.message}`);
|
|
239
|
+
check(
|
|
240
|
+
"registers one exact /api route per endpoint",
|
|
241
|
+
ENDPOINTS.every((endpoint) => connection.fetchRoutes.has(`${ROUTE_PREFIX}/${endpoint}`)),
|
|
242
|
+
[...connection.fetchRoutes.keys()].join(", ")
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const shared = connection.createSharedFetchHandler("/api");
|
|
246
|
+
const rpcId = "activation-probe";
|
|
247
|
+
const response = await shared.fetch(new Request(`http://127.0.0.1:19387${ROUTE_PREFIX}/snapshot`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
body: JSON.stringify({ type: "client-request", rpcId, method: `${PREFIX}/snapshot`, payload: {} })
|
|
251
|
+
}));
|
|
252
|
+
const text = await response.text();
|
|
253
|
+
let envelope;
|
|
254
|
+
try {
|
|
255
|
+
envelope = JSON.parse(text);
|
|
256
|
+
} catch {
|
|
257
|
+
// 路由没挂上时这里拿到的是 Connection 的纯文本 404,别让它盖掉上面的 FAIL 列表。
|
|
258
|
+
envelope = { type: "unparsable", result: { ok: false, error: { message: text.slice(0, 80) } } };
|
|
259
|
+
}
|
|
260
|
+
check("POST /api/session-curator/snapshot answers 200 + Connection envelope", response.status === 200 && envelope.type === "server-response" && envelope.rpcId === rpcId, `status=${response.status} type=${envelope.type} ok=${envelope.result?.ok}${envelope.result?.ok === false ? ` error=${JSON.stringify(envelope.result.error)}` : ""}`);
|
|
261
|
+
check("snapshot value carries the documented shape", envelope.result?.ok === true && ["dshHome", "trashDir", "sessionsDir", "archived", "pinned", "trashed", "totals"].every((key) => key in (envelope.result.value ?? {})));
|
|
262
|
+
|
|
263
|
+
const unregistered = await shared.fetch(new Request("http://127.0.0.1:19387/api/not-ours", { method: "POST" }));
|
|
264
|
+
check("an unregistered /api path is left to Connection (404)", unregistered.status === 404, `status=${unregistered.status}`);
|
|
265
|
+
|
|
266
|
+
await fiber.dispose();
|
|
267
|
+
await fiber.await().catch(() => {});
|
|
268
|
+
check("disposing the host half releases every route", connection.fetchRoutes.size === 0, `${connection.fetchRoutes.size} left`);
|
|
269
|
+
|
|
270
|
+
// ── manifest 元数据:插件管理页卡片上的图标与标题/描述 ───────────────────
|
|
271
|
+
// 逐条复刻 @deepseek-ai/dsh-app-boot 的 `lib/types/package-meta.js`(readPluginMeta /
|
|
272
|
+
// iconOf):locale 文件**必须能被 Node 解析器按 exports 找到**,图标是 manifest 相对路径、
|
|
273
|
+
// 只能是 SVG/PNG/JPEG/WebP、realpath 之后仍在 manifest 目录内、且 ≤ 256 KiB。
|
|
274
|
+
const ICON_MEDIA_TYPES = new Map([[".svg", "image/svg+xml"], [".png", "image/png"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".webp", "image/webp"]]);
|
|
275
|
+
const MAX_ICON_BYTES = 256 * 1024;
|
|
276
|
+
|
|
277
|
+
/** 极简标签配对:够验证我们自己的手写 SVG(跳过注释、自闭合、XML 声明)。 */
|
|
278
|
+
function tagsBalanced(source) {
|
|
279
|
+
const text = source.replace(/<!--[\s\S]*?-->/gu, "");
|
|
280
|
+
const stack = [];
|
|
281
|
+
const pattern = /<\s*(\/?)\s*([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)\s*>/gu;
|
|
282
|
+
let match;
|
|
283
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
284
|
+
const [, closing, name] = match;
|
|
285
|
+
// 自闭合看原始匹配的结尾,别信那个可选捕获组:属性组会把 `/` 一起吃掉。
|
|
286
|
+
if (/\/\s*>$/u.test(match[0]) || name.startsWith("?")) continue;
|
|
287
|
+
if (closing === "/") {
|
|
288
|
+
if (stack.pop() !== name) return false;
|
|
289
|
+
} else {
|
|
290
|
+
stack.push(name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return stack.length === 0;
|
|
294
|
+
}
|
|
295
|
+
check("the SVG tag checker itself detects imbalance", tagsBalanced("<svg><path/></svg>") === true && tagsBalanced("<svg><path></svg>") === false && tagsBalanced("<svg><path>x</svg>") === false);
|
|
296
|
+
|
|
297
|
+
const pluginDir = resolve(import.meta.dirname, "..");
|
|
298
|
+
// 用 self-reference 解析:不需要插件已经装进 profile,走的是同一张 exports 表。
|
|
299
|
+
const pluginRequire = createRequire(join(pluginDir, "package.json"));
|
|
300
|
+
// 包名从自己的 manifest 读:将来改名(发布到 npm 前一定要改)自检不会跟着烂。
|
|
301
|
+
const PACKAGE_NAME = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8")).name;
|
|
302
|
+
let manifest;
|
|
303
|
+
let manifestPath;
|
|
304
|
+
try {
|
|
305
|
+
manifestPath = pluginRequire.resolve(`${PACKAGE_NAME}/package.json`);
|
|
306
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
manifestPath = undefined;
|
|
309
|
+
check("package.json is reachable through its own exports map", false, String(error?.code ?? error));
|
|
310
|
+
}
|
|
311
|
+
if (manifestPath !== undefined) {
|
|
312
|
+
check("package.json is reachable through its own exports map", true);
|
|
313
|
+
check("the manifest exports locale JSON for the metadata reader", (() => {
|
|
314
|
+
try {
|
|
315
|
+
return pluginRequire.resolve(`${PACKAGE_NAME}/locale/en.json`).endsWith("en.json");
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
})());
|
|
320
|
+
|
|
321
|
+
// 本地化 title / description(app-boot 读取 locale/ 目录下每个 *.json 的 meta)。
|
|
322
|
+
const englishPath = pluginRequire.resolve(`${PACKAGE_NAME}/locale/en.json`);
|
|
323
|
+
const dictionaries = new Map();
|
|
324
|
+
for (const name of readdirSync(dirname(englishPath))) {
|
|
325
|
+
if (!name.endsWith(".json")) continue;
|
|
326
|
+
const id = name.slice(0, -5).toLowerCase();
|
|
327
|
+
const parsed = JSON.parse(readFileSync(pluginRequire.resolve(`${PACKAGE_NAME}/locale/${name}`), "utf8"));
|
|
328
|
+
dictionaries.set(id, parsed.meta ?? {});
|
|
329
|
+
}
|
|
330
|
+
check("every locale file carries meta.title and meta.description", [...dictionaries].every(([, meta]) => typeof meta.title === "string" && meta.title !== "" && typeof meta.description === "string" && meta.description !== ""), [...dictionaries].map(([id, meta]) => `${id}:${meta.title}`).join(", "));
|
|
331
|
+
const title = { en: manifest.name, ...Object.fromEntries([...dictionaries].filter(([, meta]) => meta.title !== undefined).map(([id, meta]) => [id, meta.title])) };
|
|
332
|
+
const description = { en: manifest.description ?? "", ...Object.fromEntries([...dictionaries].filter(([, meta]) => meta.description !== undefined).map(([id, meta]) => [id, meta.description])) };
|
|
333
|
+
check("the plugin card gets a real (non-package-name) title in both languages", title.en !== manifest.name && typeof title.zh === "string" && title.zh !== "", JSON.stringify(title));
|
|
334
|
+
check("the plugin card gets a description in both languages", typeof description.en === "string" && description.en !== "" && typeof description.zh === "string" && description.zh !== "");
|
|
335
|
+
|
|
336
|
+
// 图标:路径规则 + 体积 + 内容。
|
|
337
|
+
const icon = manifest.icon;
|
|
338
|
+
check("the manifest declares a manifest-relative SVG icon", typeof icon === "string" && !isAbsolute(icon) && ICON_MEDIA_TYPES.get(extname(icon).toLowerCase()) === "image/svg+xml", JSON.stringify(icon));
|
|
339
|
+
if (typeof icon === "string") {
|
|
340
|
+
const iconFile = realpathSync(resolve(dirname(manifestPath), icon));
|
|
341
|
+
const local = relative(realpathSync(dirname(manifestPath)), iconFile);
|
|
342
|
+
const size = statSync(iconFile).size;
|
|
343
|
+
check("the icon stays inside the manifest directory, is a regular file and fits 256 KiB", !local.startsWith("..") && !isAbsolute(local) && statSync(iconFile).isFile() && size <= MAX_ICON_BYTES, `${size} B`);
|
|
344
|
+
const svg = readFileSync(iconFile, "utf8");
|
|
345
|
+
// 注释不算数:只有真正会被渲染的部分才需要自带着色。
|
|
346
|
+
const shapes = svg.replace(/<!--[\s\S]*?-->/gu, "");
|
|
347
|
+
check("the icon is well-formed SVG with its own colours (it renders through <img>)", svg.trimStart().startsWith("<svg") && svg.trimEnd().endsWith("</svg>") && tagsBalanced(svg) && !shapes.includes("currentColor"), `${svg.length} chars`);
|
|
348
|
+
check("the icon uses the same square canvas as DSH's own plugin artwork", /viewBox="0 0 36 36"/u.test(svg) && /width="36"/u.test(svg) && /height="36"/u.test(svg));
|
|
349
|
+
// 只量几何属性(别去扫 xmlns 里的 2000、也别把 #324DE2 读成 324)。
|
|
350
|
+
const geometryAttributes = [...shapes.matchAll(/\b(?:d|viewBox|x|y|x1|y1|x2|y2|width|height|rx)="([^"]*)"/gu)];
|
|
351
|
+
const coordinates = geometryAttributes.flatMap((match) => [...match[1].matchAll(/-?\d+(?:\.\d+)?/gu)].map((number) => Number(number[0])));
|
|
352
|
+
check("every coordinate stays inside the 36×36 canvas", coordinates.length > 0 && coordinates.every((value) => value >= 0 && value <= 36), `${coordinates.length} numbers, max=${Math.max(...coordinates)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const failed = checks.filter((entry) => !entry.ok);
|
|
357
|
+
console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`);
|
|
358
|
+
process.exit(failed.length === 0 ? 0 : 1);
|