dsh-folia 3.7.0 → 3.8.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.
@@ -1,1032 +1,1074 @@
1
- /**
2
- * routes/skins.js — 「皮肤包」数据源:把**自建站点**上的皮肤包列出来、下下来、装上、切换。
3
- *
4
- * 与 `routes/inventory.js` 的站点数据源是**同一套思路**(自建站 = 唯一真源,宿主端代取,
5
- * 浏览器不直连),但落地对象不同:那边是壁纸条目,这边是**整套皮肤包**。
6
- *
7
- * 为什么这件事可行而不是"另造一套皮肤引擎":
8
- * · 站点已经按市场同一格式产出索引(`<site>/skins/manifest.json`)与 zip
9
- * (`<site>/skins/<id>.zip`),zip 内就是 `skin.json` + `skin.css` + `assets/`
10
- * + `dsh-market.provenance.json`;
11
- * · 本插件**不负责渲染** —— 皮肤中心(dsh-skin-center)读 `harness/skins/<id>/`
12
- * 自己应用样式与 hooks。我们只做"取、验、落、切"四件事。
13
- * · 所以皮肤中心的完整性校验仍会照常跑(provenance 在 zip 内原样带着,哈希没被动过)。
14
- *
15
- * 四个路由(全部 `${base}/skins` 前缀下,一条 prefix 注册):
16
- * GET /skins 索引 + 本机已装/在用状态(合并后下发,客户端一次拿全)
17
- * POST /skins/install {id} 下载 → sha256 校验 → 解压到 harness/skins/<id>/
18
- * POST /skins/activate {id} 写 harness/skin-center-active.json 的 `active`
19
- * POST /skins/remove {id} 删目录(在用 / 不存在的拒绝)
20
- *
21
- * 纪律(与库存族同口径):
22
- * · 站点不可达**绝不影响**插件其他功能:索引失败返回 503 + 结构化错误,客户端显示"不可达";
23
- * · 每次下载都有上限(`SKIN_ZIP_MAX_BYTES`)与超时;
24
- * · zip 解压是**纯 JS**(node:zlib)—— 不引入新依赖,也不 shell 出去调 Expand-Archive;
25
- * · 解压路径做 zip-slip 防护:绝对路径、`..`、反斜杠、盘符一律拒绝;
26
- * · 只认 `^[a-z0-9][a-z0-9-]*$` 的 id(索引来自远端,不能拿它当路径片段直接拼)。
27
- */
28
- import { createHash } from 'node:crypto';
29
- import { inflateRawSync } from 'node:zlib';
30
- import {
31
- cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
32
- } from 'node:fs';
33
- import { homedir } from 'node:os';
34
- import { join, resolve, sep } from 'node:path';
35
- import { sendJson } from '../json-response.js';
36
- import { bodyReader } from '../http-body.js';
37
-
38
- /* ── 站点 ────────────────────────────────────────────────────────────────── */
39
-
40
- const SKINS_SITE = 'https://folia-major-3t6.pages.dev';
41
- const SKINS_INDEX_PATH = '/skins/manifest.json';
42
- const SKINS_TTL_MS = 60000;
43
- const SKINS_TIMEOUT_MS = 8000;
44
- /** 单个 zip 上限:站点上最大的是 8 MB 那套(whalechan-harness),留足余量。 */
45
- const SKIN_ZIP_MAX_BYTES = 32 * 1024 * 1024;
46
- /** 分卷拼接后的总上限(DIY 手账本 3 段合计约 67 MB,留余量)。 */
47
- const SKIN_TOTAL_MAX_BYTES = 128 * 1024 * 1024;
48
- const CONTROL_JSON_MAX_BYTES = 64 * 1024;
49
- const ACTIVE_FILE = 'skin-center-active.json';
50
- const SKINS_DIR_NAME = 'skins';
51
- const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
52
-
53
- /** `DSH_FOLIA_SKINS_SITE` → 归一化基址;`off`/空串表示停用整条通道。 */
54
- function skinsSiteBase() {
55
- const raw = process.env.DSH_FOLIA_SKINS_SITE;
56
- if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
57
- const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : SKINS_SITE;
58
- return base.replace(/\/+$/, '');
59
- }
60
-
61
- /**
62
- * 皮肤中心是否已挂载(60s 缓存)。
63
- *
64
- * 判据只有它的路由:`/api/skin-center/v2/active` 存在 ⇒ 挂载(200 / 401 / 403 都算,
65
- * 网关会先挡一层,401 恰恰证明路由注册过);404 ⇒ 没装 / 没挂。
66
- *
67
- * 为什么要探测:我们不再假装能"完整应用皮肤"。皮肤中心在台时,应用动作应当交回它的
68
- * 设置页(原生路径,100% 完整);它不在时我们才用自建兜底,并把"可能不全"说出来。
69
- */
70
- let skinCenterCache = { at: 0, mounted: false };
71
- async function skinCenterMounted(origin, fwdHeaders) {
72
- if (Date.now() - skinCenterCache.at < SKINS_TTL_MS) return skinCenterCache.mounted;
73
- let mounted = false;
74
- try {
75
- const r = await fetch(origin + '/api/skin-center/v2/active', {
76
- headers: fwdHeaders,
77
- signal: AbortSignal.timeout(3000),
78
- });
79
- mounted = r.status !== 404;
80
- } catch { mounted = false; }
81
- skinCenterCache = { at: Date.now(), mounted };
82
- return mounted;
83
- }
84
-
85
- /** @type {{ at: number, payload: any[] | null, error: string | null }} */
86
- let indexCache = { at: 0, payload: null, error: null };
87
-
88
- /**
89
- * harness 根目录(`skins/` 与 `skin-center-active.json` 所在的那一层)。
90
- *
91
- * 桌面版是 `<userData>/harness`(Electron 的 `--user-data-dir` 是 `%APPDATA%\dsh-desktop`);
92
- * CLI 版是 `~/.dsh`。两边都没有权威 API 可问,所以按候选逐个试,**以 `skins/` 或
93
- * `skin-center-active.json` 是否存在为准**。`DSH_FOLIA_HARNESS` 可强制指定(自检/异形安装)。
94
- * @returns {string | null}
95
- */
96
- export function harnessRoot() {
97
- const forced = process.env.DSH_FOLIA_HARNESS;
98
- if (typeof forced === 'string' && forced.trim()) return resolve(forced.trim());
99
- const home = homedir();
100
- /** @type {string[]} */
101
- const candidates = [];
102
- const appData = process.env.APPDATA;
103
- const localAppData = process.env.LOCALAPPDATA;
104
- if (appData) candidates.push(join(appData, 'dsh-desktop', 'harness'));
105
- if (localAppData) candidates.push(join(localAppData, 'dsh-desktop', 'harness'));
106
- candidates.push(join(home, '.dsh', 'harness'), join(home, '.dsh'), join(home, '.dsh-desktop', 'harness'));
107
- for (const c of candidates) {
108
- try {
109
- if (existsSync(join(c, SKINS_DIR_NAME)) || existsSync(join(c, ACTIVE_FILE))) return c;
110
- } catch { /* 单个候选读不到 ⇒ 试下一个 */ }
111
- }
112
- return null;
113
- }
114
-
115
- /* ── 索引 ────────────────────────────────────────────────────────────────── */
116
-
117
- /**
118
- * 列出**所有**提供了皮肤中心行的 bundle(跨 profile 扫 patch 文件)。
119
- *
120
- * 为什么必须报出来:我们在自己的 patch 里加了一行内嵌皮肤中心(folia-skin-center)。
121
- * 如果用户同时还装着 @linxin666/dsh-web-all,它的 patch 里也有一行(web-ui-skin-center)
122
- * 指向同一个包 ⇒ 同一个插件被挂两次。这种事要显式告诉用户去关掉一处,不能静默。
123
- */
124
- function skinCenterProviders() {
125
- const out = [];
126
- try {
127
- const harness = harnessRoot();
128
- if (!harness) return out;
129
- const profilesDir = join(harness, 'profiles');
130
- if (!existsSync(profilesDir)) return out;
131
- const SKIN_CENTER = '@linxin666/dsh-client-ui-skin-center';
132
- for (const prof of readdirSync(profilesDir)) {
133
- if (prof.startsWith('.')) continue;
134
- const pkgPath = join(profilesDir, prof, 'package.json');
135
- if (!existsSync(pkgPath)) continue;
136
- let doc;
137
- try { doc = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { continue; }
138
- const bundles = (doc && doc.dsh && doc.dsh.profile && doc.dsh.profile.bundles) || [];
139
- for (const b of bundles) {
140
- const patchPath = join(profilesDir, prof, 'node_modules', b, 'cordis.patch.yml');
141
- if (!existsSync(patchPath)) continue;
142
- let text;
143
- try { text = readFileSync(patchPath, 'utf8'); } catch { continue; }
144
- if (!text.includes(SKIN_CENTER)) continue;
145
- const ids = [];
146
- const re = /-\s*id:\s*([A-Za-z0-9_-]+)\s*\n\s*name:\s*'([^']+)'/g;
147
- let m;
148
- while ((m = re.exec(text)) !== null) {
149
- if (m[2].includes('dsh-client-ui-skin-center')) ids.push(m[1]);
150
- }
151
- if (ids.length) out.push({ profile: prof, bundle: b, rows: ids });
152
- }
153
- }
154
- } catch { /* 扫描失败只是少一条提示,不影响功能 */ }
155
- return out;
156
- }
157
-
158
- /**
159
- * 拉站点索引并**精简**成客户端要的形状。永不抛;失败返回 `{ items: [], error }`。
160
- *
161
- * 为什么精简:原始 manifest 62 KB / 65 条,每条还带完整 description 与 parts 清单;
162
- * 客户端一次拿全没有意义,只留卡片需要的字段 + `zipRelative`(安装时回传给宿主)。
163
- */
164
- async function fetchIndex(force = false) {
165
- const base = skinsSiteBase();
166
- if (!base) return { items: [], error: 'disabled' };
167
- if (!force && Date.now() - indexCache.at < SKINS_TTL_MS) {
168
- return { items: indexCache.payload || [], error: indexCache.error };
169
- }
170
-
171
- let items = [];
172
- let error = null;
173
- try {
174
- const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
175
- ? AbortSignal.timeout(SKINS_TIMEOUT_MS)
176
- : undefined;
177
- const res = await fetch(base + SKINS_INDEX_PATH, { headers: { accept: 'application/json' }, signal });
178
- if (!res.ok) {
179
- error = 'http-' + res.status;
180
- } else {
181
- let doc;
182
- try {
183
- doc = await res.json();
184
- } catch {
185
- error = 'json';
186
- }
187
- if (doc) {
188
- const list = Array.isArray(doc.skins) ? doc.skins : [];
189
- for (const s of list) {
190
- const id = String((s && s.id) || '');
191
- if (!ID_RE.test(id)) continue;
192
- // 分卷包(DIY 手账本那种 67 MB 三段):`parts` 是 **对象数组** `{name, bytes}`,
193
- // 也兼容纯字符串形态 —— 早期实现只认字符串,把 diy-journal 整条静默丢了
194
- //(manifest 65 条、列表只出 64 条,就是它)。
195
- const parts = (Array.isArray(s.parts) ? s.parts : [])
196
- .map((p) => (typeof p === 'string' ? p : (p && typeof p.name === 'string' ? p.name : '')))
197
- .filter(Boolean);
198
- const zip = typeof s.zip === 'string' ? s.zip : '';
199
- if (!zip && parts.length === 0) continue;
200
- items.push({
201
- id,
202
- name: String(s.name || id),
203
- nameEn: String(s.nameEn || ''),
204
- version: String(s.version || ''),
205
- author: String(s.author || ''),
206
- accent: typeof s.accent === 'string' ? s.accent : '',
207
- tagline: String(s.tagline || ''),
208
- tags: Array.isArray(s.tags) ? s.tags.slice(0, 8).map(String) : [],
209
- zipBytes: Number(s.zipBytes) || 0,
210
- sha256: typeof s.sha256 === 'string' ? s.sha256 : '',
211
- zipRelative: zip,
212
- parts: parts.slice(0, 16),
213
- previewLight: (s.preview && typeof s.preview.light === 'string') ? s.preview.light : '',
214
- previewDark: (s.preview && typeof s.preview.dark === 'string') ? s.preview.dark : '',
215
- });
216
- }
217
- }
218
- }
219
- } catch (e) {
220
- const code = String((e && (e.name || e.code)) || '');
221
- const msg = String((e && e.message) || '');
222
- if (/timeout|abort/i.test(code) || /timeout|aborted/i.test(msg)) error = 'timeout';
223
- else if (/cert|tls|ssl/i.test(msg)) error = 'tls';
224
- else error = 'network';
225
- }
226
-
227
- indexCache = { at: Date.now(), payload: items, error };
228
- return { items, error };
229
- }
230
-
231
- /* ── 本机状态 ─────────────────────────────────────────────────────────────── */
232
-
233
- /** 本机已装的皮肤 id 集合(`skins/` 下的目录名)。 */
234
- function installedIds(harness) {
235
- const dir = join(harness, SKINS_DIR_NAME);
236
- try {
237
- return new Set(readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
238
- } catch {
239
- return new Set();
240
- }
241
- }
242
-
243
- /** 皮肤中心当前的 `active`(读不到就当没有 —— fail-open,不影响列表)。 */
244
- function activeSkinId(harness) {
245
- try {
246
- const doc = JSON.parse(readFileSync(join(harness, ACTIVE_FILE), 'utf8'));
247
- return typeof doc.active === 'string' ? doc.active : null;
248
- } catch {
249
- return null;
250
- }
251
- }
252
-
253
- /* ── zip 解压(纯 JS,零依赖)────────────────────────────────────────────── */
254
-
255
- /** 从中央目录读条目表。只支持 store(0) / deflate(8),够用且不引依赖。 */
256
- function zipEntries(buf) {
257
- // 找 EOCD(0x06054b50),从尾部往前扫(注释区最多 65535 字节)
258
- let eocd = -1;
259
- for (let i = buf.length - 22; i >= 0 && i >= buf.length - 65558; i--) {
260
- if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
261
- }
262
- if (eocd < 0) throw new Error('不是有效的 zip(找不到中央目录)');
263
- const count = buf.readUInt16LE(eocd + 10);
264
- let off = buf.readUInt32LE(eocd + 16);
265
- const out = [];
266
- for (let i = 0; i < count; i++) {
267
- if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error('中央目录条目损坏');
268
- const method = buf.readUInt16LE(off + 10);
269
- const compSize = buf.readUInt32LE(off + 20);
270
- const nameLen = buf.readUInt16LE(off + 28);
271
- const extraLen = buf.readUInt16LE(off + 30);
272
- const commentLen = buf.readUInt16LE(off + 32);
273
- const localOff = buf.readUInt32LE(off + 42);
274
- const name = buf.toString('utf8', off + 46, off + 46 + nameLen);
275
- out.push({ name, method, compSize, localOff });
276
- off += 46 + nameLen + extraLen + commentLen;
277
- }
278
- return out;
279
- }
280
-
281
- /** 校验条目名是安全的相对路径,并返回归一化后的相对段。 */
282
- function safeRelPath(name) {
283
- const norm = String(name).replace(/\\/g, '/');
284
- if (!norm || norm.startsWith('/') || /^[a-zA-Z]:/.test(norm)) return null;
285
- const segs = norm.split('/').filter((s) => s && s !== '.');
286
- if (segs.length === 0) return null;
287
- for (const s of segs) {
288
- if (s === '..') return null;
289
- if (s.includes('\0')) return null;
290
- }
291
- return segs;
292
- }
293
-
294
- /**
295
- * 解压到 `destDir`。**先解到临时目录再整体改名**,中途失败不留半个皮肤。
296
- * @returns {{ files: number }}
297
- */
298
- function extractZipTo(buf, destDir, tmpDir) {
299
- const entries = zipEntries(buf);
300
- let files = 0;
301
- for (const e of entries) {
302
- const segs = safeRelPath(e.name);
303
- if (!segs) throw new Error('zip 内含不安全路径:' + e.name);
304
- // 本地文件头长度可变(name/extra 可能与中央目录不同),必须重读
305
- const lo = e.localOff;
306
- if (buf.readUInt32LE(lo) !== 0x04034b50) throw new Error('本地文件头损坏:' + e.name);
307
- const lNameLen = buf.readUInt16LE(lo + 26);
308
- const lExtraLen = buf.readUInt16LE(lo + 28);
309
- const dataStart = lo + 30 + lNameLen + lExtraLen;
310
- const raw = buf.subarray(dataStart, dataStart + e.compSize);
311
- const isDir = e.name.endsWith('/');
312
- const target = join(tmpDir, ...segs);
313
- // 兜底:拼接后必须仍在 tmpDir 里(防 zip-slip 的任何漏网)
314
- if (!resolve(target).startsWith(resolve(tmpDir) + sep)) throw new Error('zip 路径越界:' + e.name);
315
- if (isDir) {
316
- mkdirSync(target, { recursive: true });
317
- continue;
318
- }
319
- let data;
320
- if (e.method === 0) data = raw;
321
- else if (e.method === 8) data = inflateRawSync(raw);
322
- else throw new Error('不支持的 zip 压缩方式 ' + e.method + ':' + e.name);
323
- mkdirSync(join(target, '..'), { recursive: true });
324
- writeFileSync(target, data);
325
- files++;
326
- }
327
- if (files === 0) throw new Error('zip 里没有任何文件');
328
- return { files };
329
- }
330
-
331
- /* ── 下载 ────────────────────────────────────────────────────────────────── */
332
-
333
- async function fetchBytes(url, maxBytes) {
334
- const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
335
- ? AbortSignal.timeout(SKINS_TIMEOUT_MS * 4) // 8 MB 的包在跨境链路上要给足时间
336
- : undefined;
337
- const res = await fetch(url, { headers: { accept: '*/*' }, signal });
338
- if (!res.ok) throw new Error('下载失败:HTTP ' + res.status);
339
- const len = Number(res.headers.get('content-length'));
340
- if (Number.isFinite(len) && len > maxBytes) throw new Error('皮肤包过大:' + len + ' 字节');
341
- const ab = await res.arrayBuffer();
342
- const buf = Buffer.from(ab);
343
- if (buf.length > maxBytes) throw new Error('皮肤包过大:' + buf.length + ' 字节');
344
- if (buf.length === 0) throw new Error('下载到 0 字节');
345
- return buf;
346
- }
347
-
348
- function sha256Hex(buf) {
349
- return createHash('sha256').update(buf).digest('hex');
350
- }
351
-
352
- /* ── 路由 ────────────────────────────────────────────────────────────────── */
353
-
354
- /**
355
- * @param {any} webServer
356
- * @param {{ disposers: Function[], base: string, log?: Function }} c
357
- */
358
- export function registerSkinRoutes(webServer, c) {
359
- const { disposers, base, log } = c;
360
- const note = typeof log === 'function' ? log : () => {};
361
-
362
- /**
363
- * 读一个小 JSON body({id})并校验成合法皮肤 id。解析失败 / 缺 id / 越界一律 null ⇒ 调用方回 400。
364
- *
365
- * ⚠️ `bodyReader` 是「处理器 + 取值器」模式(见 lib/http-body.js 文件头的用法),**没有**
366
- * `await reader.text()` 这种异步形态 —— 监听器由调用点挂,这里包一层 Promise 收口。
367
- */
368
- function readIdBody(req) {
369
- return new Promise((resolveId) => {
370
- let settled = false;
371
- const done = (v) => { if (!settled) { settled = true; resolveId(v); } };
372
- const reader = bodyReader(req, {
373
- maxBytes: CONTROL_JSON_MAX_BYTES,
374
- // 越限当场判失败:body 不该继续占内存,也不该等到 end 才应 400。
375
- onOverflow: () => done(null),
376
- });
377
- req.on('data', reader.onData);
378
- req.on('end', () => {
379
- if (reader.overflowed) { done(null); return; }
380
- let doc;
381
- try {
382
- doc = JSON.parse(reader.text());
383
- } catch {
384
- done(null);
385
- return;
386
- }
387
- const id = doc && typeof doc.id === 'string' ? doc.id : '';
388
- done(ID_RE.test(id) ? id : null);
389
- });
390
- req.on('error', () => done(null));
391
- });
392
- }
393
-
394
- disposers.push(webServer.register({
395
- kind: 'exact',
396
- path: `${base}/skins`,
397
- handler: async (req, res) => {
398
- const url = new URL(req.url || '/', 'http://x');
399
- const force = url.searchParams.get('refresh') === '1';
400
- const harness = harnessRoot();
401
- const { items, error } = await fetchIndex(force);
402
- const installed = harness ? installedIds(harness) : new Set();
403
- const active = harness ? activeSkinId(harness) : null;
404
- const merged = items.map((it) => ({
405
- ...it,
406
- installed: installed.has(it.id),
407
- active: it.id === active,
408
- }));
409
- sendJson(res, 200, {
410
- ok: error === null,
411
- error,
412
- site: skinsSiteBase(),
413
- harness,
414
- harnessFound: Boolean(harness),
415
- skinCenterProviders: skinCenterProviders(),
416
- skinCenter: await skinCenterMounted(
417
- 'http://' + ((req && req.headers && req.headers.host) || '127.0.0.1'),
418
- (() => {
419
- const h = { accept: 'application/json', 'sec-fetch-site': 'same-origin' };
420
- const host = (req && req.headers && req.headers.host) || '';
421
- if (host) { h.origin = 'http://' + host; h.referer = 'http://' + host + '/'; }
422
- if (req && req.headers && req.headers.cookie) h.cookie = req.headers.cookie;
423
- return h;
424
- })(),
425
- ),
426
- active,
427
- count: merged.length,
428
- installedCount: [...installed].length,
429
- skins: merged,
430
- });
431
- },
432
- }));
433
-
434
- disposers.push(webServer.register({
435
- kind: 'exact',
436
- path: `${base}/skins/install`,
437
- handler: async (req, res) => {
438
- const id = await readIdBody(req);
439
- if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
440
- const harness = harnessRoot();
441
- if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
442
- const site = skinsSiteBase();
443
- if (!site) { sendJson(res, 503, { ok: false, error: 'disabled' }); return; }
444
-
445
- const { items } = await fetchIndex();
446
- const entry = items.find((x) => x.id === id);
447
- if (!entry) { sendJson(res, 404, { ok: false, error: 'unknown-skin' }); return; }
448
-
449
- const skinsDir = join(harness, SKINS_DIR_NAME);
450
- const destDir = join(skinsDir, id);
451
- const tmpDir = join(skinsDir, '.' + id + '.tmp-' + Date.now().toString(36));
452
- try {
453
- // 分卷包:按顺序拼接成完整 zip
454
- let buf;
455
- if (entry.parts.length > 0) {
456
- const chunks = [];
457
- for (const p of entry.parts) {
458
- chunks.push(await fetchBytes(site + '/' + String(p).replace(/^\/+/, ''), SKIN_ZIP_MAX_BYTES));
459
- }
460
- buf = Buffer.concat(chunks);
461
- // 拼接后总量另设一道闸:单包 32 MB 的上限管不住 3 段拼起来的 67 MB。
462
- if (buf.length > SKIN_TOTAL_MAX_BYTES) {
463
- throw new Error('分卷拼接后过大:' + buf.length + ' 字节');
464
- }
465
- } else {
466
- buf = await fetchBytes(site + '/' + String(entry.zipRelative).replace(/^\/+/, ''), SKIN_ZIP_MAX_BYTES);
467
- }
468
-
469
- // 哈希校验:manifest 里给的是**截断**哈希(16 位十六进制),按前缀比
470
- if (entry.sha256 && /^[0-9a-f]+$/i.test(entry.sha256)) {
471
- const actual = sha256Hex(buf);
472
- if (!actual.toLowerCase().startsWith(entry.sha256.toLowerCase())) {
473
- throw new Error('校验失败:sha256 不匹配(下载可能被截断)');
474
- }
475
- }
476
-
477
- mkdirSync(skinsDir, { recursive: true });
478
- rmSync(tmpDir, { recursive: true, force: true });
479
- mkdirSync(tmpDir, { recursive: true });
480
- const { files } = extractZipTo(buf, destDir, tmpDir);
481
-
482
- // zip 顶层就是 `<id>/`:把它里面的内容搬到 destDir
483
- const inner = join(tmpDir, id);
484
- const innerExists = existsSync(inner) && statSync(inner).isDirectory();
485
- const payload = innerExists ? inner : tmpDir;
486
- rmSync(destDir, { recursive: true, force: true });
487
- mkdirSync(destDir, { recursive: true });
488
- for (const name of readdirSync(payload)) {
489
- const from = join(payload, name);
490
- const to = join(destDir, name);
491
- // 同一分区:rename 最快;跨设备时退化为复制
492
- try {
493
- renameSyncSafe(from, to);
494
- } catch {
495
- cpSyncSafe(from, to);
496
- }
497
- }
498
- rmSync(tmpDir, { recursive: true, force: true });
499
-
500
- if (!existsSync(join(destDir, 'skin.json'))) {
501
- throw new Error('装完没有 skin.json —— zip 结构不对');
502
- }
503
- note('[skins] installed ' + id + ' (' + files + ' files)');
504
- sendJson(res, 200, { ok: true, id, files, bytes: buf.length, installed: true });
505
- } catch (err) {
506
- try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 清理失败不影响应答 */ }
507
- const msg = String((err && err.message) || err);
508
- note('[skins] install ' + id + ' failed: ' + msg);
509
- sendJson(res, 502, { ok: false, error: 'install-failed', detail: msg });
510
- }
511
- },
512
- }));
513
-
514
- disposers.push(webServer.register({
515
- kind: 'exact',
516
- path: `${base}/skins/activate`,
517
- handler: async (req, res) => {
518
- const id = await readIdBody(req);
519
- if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
520
- const harness = harnessRoot();
521
- if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
522
- if (!existsSync(join(harness, SKINS_DIR_NAME, id, 'skin.json'))) {
523
- sendJson(res, 404, { ok: false, error: 'not-installed' });
524
- return;
525
- }
526
- const file = join(harness, ACTIVE_FILE);
527
- let doc = {};
528
- try {
529
- doc = JSON.parse(readFileSync(file, 'utf8')) || {};
530
- } catch { /* 文件不存在 / 坏掉 ⇒ 用最小骨架重建 */ }
531
- // 只改 `active`:background 那一段是皮肤中心自己的设置,一个字都不能丢。
532
- doc.active = id;
533
- if (typeof doc.initialized !== 'boolean') doc.initialized = true;
534
- try {
535
- writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
536
- note('[skins] activated ' + id);
537
- sendJson(res, 200, { ok: true, id, active: id, restartRequired: true });
538
- } catch (err) {
539
- sendJson(res, 500, { ok: false, error: 'write-failed', detail: String(err && err.message || err) });
540
- }
541
- },
542
- }));
543
-
544
- disposers.push(webServer.register({
545
- kind: 'exact',
546
- path: `${base}/skins/remove`,
547
- handler: async (req, res) => {
548
- const id = await readIdBody(req);
549
- if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
550
- const harness = harnessRoot();
551
- if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
552
- if (activeSkinId(harness) === id) {
553
- sendJson(res, 409, { ok: false, error: 'skin-active' });
554
- return;
555
- }
556
- const dir = join(harness, SKINS_DIR_NAME, id);
557
- if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
558
- try {
559
- rmSync(dir, { recursive: true, force: true });
560
- note('[skins] removed ' + id);
561
- sendJson(res, 200, { ok: true, id, removed: true });
562
- } catch (err) {
563
- sendJson(res, 500, { ok: false, error: 'remove-failed', detail: String(err && err.message || err) });
564
- }
565
- },
566
- }));
567
-
568
- /* ── 热切换:把皮肤样式自己喂给浏览器 ──────────────────────────────────────
569
- * 皮肤中心只在**页面加载时**应用皮肤(它的 host 侧把样式塞进文档、client 侧启动时拉
570
- * `/api/skin-center/v2/active`),桌面壳里 `location.reload()` 又常常被吞掉 ⇒
571
- * 用户点「使用」后界面永远不变。
572
- *
573
- * 所以这里补两条只读路由,把 `harness/skins/<id>/` 里的样式与素材直接发给页面:
574
- * GET /skins/css?id=<id> → skin.css(`url()` 已重写成下面那条素材路由)
575
- * GET /skins/asset?id=<id>&p=<rel> → 皮肤目录里的任意素材(图片/字体)
576
- * 客户端把 CSS 挂成一个 `<link id="folia-skin-hot">` 追加在 `<head>` 末尾 ——
577
- * 后加载的规则覆盖先前的,于是**不刷新也能换肤**;hooks(粒子等)不会跑,那部分
578
- * 仍归皮肤中心管。
579
- */
580
-
581
- /** 读一套皮肤的 manifest,拿 `contributes.stylesheet`。 */
582
- function skinStylesheetOf(dir) {
583
- try {
584
- const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
585
- const rel = doc && doc.contributes && doc.contributes.stylesheet;
586
- if (typeof rel !== 'string') return null;
587
- const segs = safeRelPath(rel);
588
- if (!segs || segs.includes('..')) return null;
589
- return { rel: segs.join('/'), doc };
590
- } catch {
591
- return null;
592
- }
593
- }
594
-
595
- /**
596
- * 取 manifests 里某个可选的样式类字段(当前用于 `patches`)。
597
- * 与 stylesheet 同样的安全校验:只接受皮肤目录内的相对路径。
598
- */
599
- function skinAuxFileOf(dir, key) {
600
- try {
601
- const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
602
- const rel = doc && doc.contributes && doc.contributes[key];
603
- if (typeof rel !== 'string' || !rel) return null;
604
- const segs = safeRelPath(rel);
605
- if (!segs || segs.includes('..')) return null;
606
- return segs.join('/');
607
- } catch {
608
- return null;
609
- }
610
- }
611
-
612
- const ASSET_TYPES = {
613
- '.css': 'text/css; charset=utf-8',
614
- '.js': 'text/javascript; charset=utf-8',
615
- '.json': 'application/json; charset=utf-8',
616
- '.svg': 'image/svg+xml',
617
- '.png': 'image/png',
618
- '.jpg': 'image/jpeg',
619
- '.jpeg': 'image/jpeg',
620
- '.webp': 'image/webp',
621
- '.gif': 'image/gif',
622
- '.avif': 'image/avif',
623
- '.mp4': 'video/mp4',
624
- '.webm': 'video/webm',
625
- '.woff': 'font/woff',
626
- '.woff2': 'font/woff2',
627
- '.ttf': 'font/ttf',
628
- '.otf': 'font/otf',
629
- };
630
-
631
- /* ── 背景层数据 ────────────────────────────────────────────────────────────
632
- * 一套皮肤的"背景"不在 CSS 里,而是 `contributes.backgroundMedia`:
633
- * { light: { type: 'video'|'image', src: 'assets/x.mp4', scrim: '<css 渐变>' }, dark: {...} }
634
- * 皮肤中心负责把这层画出来;它的 DOM 契约(从各皮肤 CSS 反推)是
635
- * `body [data-dsh-skin-layer='background']`,z-index -2,视频/图片作为子元素
636
- *(`future-window` 直接写 `> img`,`frutiger-aero` 用 `::after` 叠气泡)。
637
- * 热切换时没人建这层 ⇒ 背景全空("水墨丹青没有背景动画"就是这个原因)。
638
- */
639
- disposers.push(webServer.register({
640
- kind: 'exact',
641
- path: `${base}/skins/manifest`,
642
- handler: async (req, res) => {
643
- const url = new URL(req.url || '/', 'http://x');
644
- const id = url.searchParams.get('id') || '';
645
- if (!ID_RE.test(id)) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
646
- const harness = harnessRoot();
647
- if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
648
- const dir = join(harness, SKINS_DIR_NAME, id);
649
- let doc;
650
- try {
651
- doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
652
- } catch {
653
- sendJson(res, 404, { ok: false, error: 'no-manifest' });
654
- return;
655
- }
656
- const c = (doc && doc.contributes) || {};
657
- const media = c.backgroundMedia && typeof c.backgroundMedia === 'object' ? c.backgroundMedia : null;
658
- /** `src` 相对路径 → 我们的素材路由(浏览器只认这条)。 */
659
- const abs = (rel) => {
660
- if (typeof rel !== 'string' || !rel) return '';
661
- if (/^(?:[a-z]+:|\/)/i.test(rel)) return rel;
662
- return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
663
- };
664
- const shape = (m) => (m && typeof m === 'object' ? {
665
- type: m.type === 'video' ? 'video' : 'image',
666
- url: abs(m.src),
667
- scrim: typeof m.scrim === 'string' ? m.scrim : '',
668
- } : null);
669
- sendJson(res, 200, {
670
- ok: true,
671
- id,
672
- name: typeof doc.name === 'string' ? doc.name : id,
673
- backgroundMedia: media ? { light: shape(media.light), dark: shape(media.dark) } : null,
674
- // ⚠️ hooks 的声明位置是 **`facets.client.entry`**,不是 `contributes.hooks`
675
- //(契约见皮肤中心 contracts/hooks-api.d.ts:`facets.client.entry (hooks.mjs)`)。
676
- // 之前读 contributes.hooks ⇒ 28 套带动效的皮肤全部"无动效",而那正是它们
677
- // 看起来"显示不全"的部分(粒子/漂浮/交互)。这里两个位置都认,以 facets 为准。
678
- hooks: (() => {
679
- const facets = (doc && doc.facets) || null;
680
- const client = facets && facets.client;
681
- if (client && typeof client.entry === 'string' && client.entry) return client.entry;
682
- return typeof c.hooks === 'string' ? c.hooks : '';
683
- })(),
684
- });
685
- },
686
- }));
687
-
688
- disposers.push(webServer.register({
689
- kind: 'exact',
690
- path: `${base}/skins/css`,
691
- handler: async (req, res) => {
692
- const url = new URL(req.url || '/', 'http://x');
693
- const id = url.searchParams.get('id') || '';
694
- if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
695
- const harness = harnessRoot();
696
- if (!harness) { res.statusCode = 503; res.end(); return; }
697
- const dir = join(harness, SKINS_DIR_NAME, id);
698
- const sheet = skinStylesheetOf(dir);
699
- if (!sheet) { res.statusCode = 404; res.end(); return; }
700
- let css;
701
- try {
702
- // ⚠️ 一套皮肤通常有**两份样式**:`contributes.stylesheet`(token 与结构)与
703
- // `contributes.patches`(覆盖补丁)。**很多皮肤把全部改动写在 patches 里,
704
- // stylesheet 故意留空**(例如 black-gold-vip 的 skin.css 只有一行注释)。
705
- // 只下发 stylesheet ⇒ 换肤"看起来毫无变化"。两份都读,patches 放在后面覆盖。
706
- const files = [];
707
- files.push(sheet.rel);
708
- const patchesRel = skinAuxFileOf(dir, 'patches');
709
- if (patchesRel) files.push(patchesRel);
710
- css = files.map((rel) => {
711
- try { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); } catch { return ''; }
712
- }).join('\n/* ---- patches ---- */\n');
713
- } catch {
714
- res.statusCode = 404;
715
- res.end();
716
- return;
717
- }
718
- // `url(...)` 全部改写成素材路由:CSS 是从 `/folia/skins/css` 下发的,相对路径会
719
- // 解析到错误的前缀;改成绝对路径后,图片/字体才找得到。
720
- const baseDir = sheet.rel.includes('/') ? sheet.rel.slice(0, sheet.rel.lastIndexOf('/')) : '';
721
- const abs = (p) => {
722
- if (/^(?:[a-z]+:|\/|#|data:)/i.test(p)) return p;
723
- const rel = (baseDir ? baseDir + '/' : '') + p;
724
- return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
725
- };
726
- css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, p) => `url("${abs(p.trim())}")`);
727
- res.setHeader('Content-Type', 'text/css; charset=utf-8');
728
- // 不缓存:换皮肤后必须立刻拿到新的一份,否则用户会看到上一套的样式。
729
- res.setHeader('Cache-Control', 'no-store');
730
- res.end(css);
731
- },
732
- }));
733
-
734
- /* ── 路径式素材路由 ────────────────────────────────────────────────────────
735
- * 皮肤的 hooks 契约(`contracts/hooks-api.d.ts`)给的是 `assetBase`:一个**同源 URL
736
- * 前缀**,hooks 里自己拼 `assetBase + 'assets/x.webp'`。`?id=&p=` 这种查询式前缀拼不出
737
- * 合法地址,所以这里再提供一条路径式:`/skins/files/<id>/<相对路径>`。
738
- * 与 asset 完全同一套安全校验(id 白名单 + 路径段 + 越界检查)。
739
- */
740
- disposers.push(webServer.register({
741
- kind: 'prefix',
742
- path: `${base}/skins/files`,
743
- handler: async (req, res) => {
744
- let pathname = '/';
745
- try { pathname = new URL(req.url || '/', 'http://x').pathname; } catch { res.statusCode = 400; res.end(); return; }
746
- const prefix = `${base}/skins/files/`;
747
- if (!pathname.startsWith(prefix)) { res.statusCode = 404; res.end(); return; }
748
- const rest = decodeURIComponent(pathname.slice(prefix.length));
749
- const slash = rest.indexOf('/');
750
- const id = slash < 0 ? rest : rest.slice(0, slash);
751
- const rel = slash < 0 ? '' : rest.slice(slash + 1);
752
- if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
753
- const segs = safeRelPath(rel);
754
- if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
755
- const harness = harnessRoot();
756
- if (!harness) { res.statusCode = 503; res.end(); return; }
757
- const root = join(harness, SKINS_DIR_NAME, id);
758
- const file = join(root, ...segs);
759
- if (!resolve(file).startsWith(resolve(root) + sep)) { res.statusCode = 400; res.end(); return; }
760
- if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end(); return; }
761
- const ext = (file.slice(file.lastIndexOf('.')).toLowerCase() || '');
762
- res.setHeader('Content-Type', ASSET_TYPES[ext] || 'application/octet-stream');
763
- res.setHeader('Cache-Control', 'public, max-age=300');
764
- res.end(readFileSync(file));
765
- },
766
- }));
767
-
768
- /* ── 自己实现「作用域化」,不再依赖皮肤中心的接口 ────────────────────────────
769
- *
770
- * 皮肤中心的 `/api/skin-center/v2/skins/<id>/stylesheet` 会走 `transformSkinCss()`:
771
- * 把**每条选择器**包进 `html[data-dsh-skin="<id>"]`。这一步决定皮肤能不能压住应用
772
- * 自身的面板/令牌规则 —— 作用域化之后特异性更高。
773
- *
774
- * 但那条路由在网关后面:浏览器直连 401,宿主代发(哪怕透传全部请求头)也可能 401。
775
- * 而"让每个用户把自己的会话凭证交给插件"不是可交付的方案。所以这里**自己算**:
776
- * 读皮肤目录里的 CSS,做同一件事,输出等价结果。
777
- *
778
- * 转换规则:
779
- * · 普通规则:`a, b` → `html[data-dsh-skin="x"] a, html[data-dsh-skin="x"] b`
780
- * · 以 `:root` 开头的:开头换成作用域本身
781
- * · 以 `html` 开头的:原样保留(作者自己已作用域化,别套两层)
782
- * · `@media/@supports/@layer/@container`:**递归进去**处理内部规则
783
- * · `@keyframes/@font-face/@page/@property/@import`:原样保留
784
- * (关键帧内部是 `0% {}` 这种选择器,套作用域会直接毁掉动画)
785
- */
786
-
787
- /** 从 `openIdx`(指向 `{`)找配对的 `}`,跳过注释与字符串。找不到返回 -1。 */
788
- function matchBrace(css, openIdx) {
789
- let depth = 0;
790
- for (let i = openIdx; i < css.length; i++) {
791
- const ch = css[i];
792
- if (ch === '/' && css[i + 1] === '*') {
793
- const end = css.indexOf('*/', i + 2);
794
- if (end < 0) return -1;
795
- i = end + 1;
796
- continue;
797
- }
798
- if (ch === '"' || ch === "'") {
799
- const quote = ch;
800
- for (i++; i < css.length; i++) {
801
- if (css[i] === '\\') { i++; continue; }
802
- if (css[i] === quote) break;
803
- }
804
- continue;
805
- }
806
- if (ch === '{') depth++;
807
- else if (ch === '}') {
808
- depth--;
809
- if (depth === 0) return i;
810
- }
811
- }
812
- return -1;
813
- }
814
-
815
- /** 单个选择器 → 作用域化后的选择器。 */
816
- function scopeSelector(sel, scope) {
817
- const t = sel.trim();
818
- if (!t) return t;
819
- if (/^:root\b/.test(t)) return t.replace(/^:root/, scope);
820
- if (/^html\b/i.test(t)) return t; // 作者已作用域化,别套两层
821
- return scope + ' ' + t;
822
- }
823
-
824
- /** 选择器列表 → 逐项作用域化。**不切括号内的逗号**(:is()/:not()/[a="b,c"])。 */
825
- function scopeSelectorList(list, scope) {
826
- const out = [];
827
- let depth = 0;
828
- let cur = '';
829
- for (const ch of list) {
830
- if (ch === '(' || ch === '[') depth++;
831
- else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
832
- if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
833
- cur += ch;
834
- }
835
- out.push(cur);
836
- return out.map((s) => scopeSelector(s, scope)).join(', ');
837
- }
838
-
839
- const GROUP_AT = /^@(media|supports|layer|container|scope)\b/i;
840
- const KEEP_AT = /^@(keyframes|-webkit-keyframes|font-face|page|property|counter-style|import|charset|namespace)\b/i;
841
-
842
- /** 把整段 CSS 作用域化。看不懂的结构就原样跳过该段(宁可少转,不可转坏)。 */
843
- function scopeCss(css, id) {
844
- const scope = `html[data-dsh-skin="${id}"]`;
845
- let out = '';
846
- let i = 0;
847
- while (i < css.length) {
848
- if (css[i] === '/' && css[i + 1] === '*') { // 注释原样保留
849
- const end = css.indexOf('*/', i + 2);
850
- if (end < 0) { out += css.slice(i); break; }
851
- out += css.slice(i, end + 2);
852
- i = end + 2;
853
- continue;
854
- }
855
- if (/\s/.test(css[i])) { out += css[i++]; continue; }
856
-
857
- if (css[i] === '@') {
858
- const headEnd = css.indexOf('{', i);
859
- const semi = css.indexOf(';', i);
860
- if (semi >= 0 && (headEnd < 0 || semi < headEnd)) { // 无块 at-rule
861
- out += css.slice(i, semi + 1);
862
- i = semi + 1;
863
- continue;
864
- }
865
- if (headEnd < 0) { out += css.slice(i); break; }
866
- const head = css.slice(i, headEnd).trim();
867
- const close = matchBrace(css, headEnd);
868
- if (close < 0) { out += css.slice(i); break; }
869
- const inner = css.slice(headEnd + 1, close);
870
- // 分组 at-rule 递归进去;其余(关键帧/字体面/未知)原样保留
871
- out += head + '{' + (GROUP_AT.test(head) ? scopeCss(inner, id) : inner) + '}';
872
- i = close + 1;
873
- continue;
874
- }
875
-
876
- const braceIdx = css.indexOf('{', i); // 普通规则
877
- if (braceIdx < 0) { out += css.slice(i); break; }
878
- const selector = css.slice(i, braceIdx);
879
- const close = matchBrace(css, braceIdx);
880
- if (close < 0) { out += css.slice(i); break; }
881
- out += scopeSelectorList(selector, scope) + '{' + css.slice(braceIdx + 1, close) + '}';
882
- i = close + 1;
883
- }
884
- return out;
885
- }
886
-
887
- /* ── 皮肤中心「加工后 CSS」的宿主端代理 ──────────────────────────────────────
888
- * 皮肤中心的 `/skins/<id>/stylesheet|patches` 是 `transformSkinCss()` 的产物:
889
- * **每条规则被强制包进 `html[data-dsh-skin="<id>"]`**,特异性高于应用自身规则 ——
890
- * 只有那份 CSS 才能真正把面板压透。皮肤目录里的原始 CSS 压不住,于是"显示不全"。
891
- *
892
- * 为什么需要代理:浏览器直接请求 `/api/skin-center/v2/...` 被 DSH 网关挡掉
893
- *(实测前端一直落到"未作用域化"的降级分支)。宿主端在本机回环上再发一次、并**带上
894
- * 浏览器这次请求的 Cookie 与同源头**,就能拿到那份产物。
895
- *
896
- * 拿不到(皮肤中心没装 / 该皮肤不在它的 catalog 里)一律 404,客户端据此回退到原始 CSS。
897
- */
898
- disposers.push(webServer.register({
899
- kind: 'exact',
900
- path: `${base}/skins/scoped-css`,
901
- handler: async (req, res) => {
902
- const url = new URL(req.url || '/', 'http://x');
903
- const id = url.searchParams.get('id') || '';
904
- if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
905
- const host = req && req.headers && req.headers.host;
906
- if (!host) { res.statusCode = 500; res.end(); return; }
907
- const origin = 'http://' + host;
908
- // 透传**全部**入站头(网关认的是这次会话的凭证,可能是 cookie,也可能是某个自定义
909
- // 头;只挑几个字段必漏),再去掉逐跳头并补上同源语义。
910
- // 实测:裸请求与"只带 sec-fetch-*"都回 401 ⇒ 凭证不在这几个头里。
911
- const HOP_BY_HOP = new Set([
912
- 'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'te', 'trailer',
913
- 'proxy-authorization', 'proxy-authenticate', 'content-length', 'accept-encoding',
914
- ]);
915
- const fwdHeaders = {};
916
- for (const [k, v] of Object.entries(req.headers || {})) {
917
- const key = k.toLowerCase();
918
- if (HOP_BY_HOP.has(key)) continue;
919
- if (typeof v === 'string') fwdHeaders[key] = v;
920
- else if (Array.isArray(v)) fwdHeaders[key] = v.join(', ');
921
- }
922
- fwdHeaders.origin = origin;
923
- fwdHeaders.referer = origin + '/';
924
- fwdHeaders['sec-fetch-site'] = 'same-origin';
925
- fwdHeaders['sec-fetch-mode'] = 'cors';
926
- fwdHeaders['sec-fetch-dest'] = 'empty';
927
- fwdHeaders.accept = 'text/css,*/*;q=0.1';
928
-
929
- for (const part of ['stylesheet', 'patches']) {
930
- try {
931
- const r = await fetch(origin + '/api/skin-center/v2/skins/' + encodeURIComponent(id) + '/' + part, {
932
- headers: fwdHeaders,
933
- signal: AbortSignal.timeout(SKINS_TIMEOUT_MS),
934
- });
935
- if (!r.ok) continue;
936
- const text = await r.text();
937
- if (!text || !text.trim()) continue;
938
- res.setHeader('Content-Type', 'text/css; charset=utf-8');
939
- res.setHeader('Cache-Control', 'no-store');
940
- res.setHeader('X-Folia-Scoped', 'skin-center');
941
- res.end(text);
942
- return;
943
- } catch { /* 这一段失败就试下一段 */ }
944
- }
945
-
946
- // 代理拿不到(网关拦截 / 没装皮肤中心)⇒ **自己作用域化**。
947
- // 这条路不依赖任何凭证,也不依赖对方的进程,对所有用户一致成立。
948
- const harness = harnessRoot();
949
- if (!harness) { res.statusCode = 503; res.end(); return; }
950
- const dir = join(harness, SKINS_DIR_NAME, id);
951
- const sheet = skinStylesheetOf(dir);
952
- if (!sheet) { res.statusCode = 404; res.end(); return; }
953
- const raw = [];
954
- try { raw.push(readFileSync(join(dir, ...sheet.rel.split('/')), 'utf8')); } catch { /* 主样式读不到就不能算成功 */ }
955
- if (raw.length === 0) { res.statusCode = 404; res.end(); return; }
956
- const patches = skinAuxFileOf(dir, 'patches');
957
- if (patches) {
958
- try { raw.push(readFileSync(join(dir, ...patches.split('/')), 'utf8')); } catch { /* 补丁缺失可接受 */ }
959
- }
960
- // url() 先改写成我们的素材路由(作用域化只动选择器,不动声明)。
961
- const baseDir = sheet.rel.includes('/') ? sheet.rel.slice(0, sheet.rel.lastIndexOf('/')) : '';
962
- const absUrl = (p) => {
963
- if (/^(?:[a-z]+:|\/|#|data:)/i.test(p)) return p;
964
- const rel = (baseDir ? baseDir + '/' : '') + p;
965
- return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
966
- };
967
- const merged = raw.join('\n/* ---- patches ---- */\n')
968
- .replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, p) => `url("${absUrl(p.trim())}")`);
969
- const scoped = scopeCss(merged, id);
970
- note('[skins] scoped locally: ' + id + ' (' + scoped.length + ' bytes)');
971
- res.setHeader('Content-Type', 'text/css; charset=utf-8');
972
- res.setHeader('Cache-Control', 'no-store');
973
- res.setHeader('X-Folia-Scoped', 'local');
974
- res.end(scoped);
975
- },
976
- }));
977
-
978
- disposers.push(webServer.register({
979
- kind: 'exact',
980
- path: `${base}/skins/asset`,
981
- handler: async (req, res) => {
982
- const url = new URL(req.url || '/', 'http://x');
983
- const id = url.searchParams.get('id') || '';
984
- const rel = url.searchParams.get('p') || '';
985
- if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
986
- const segs = safeRelPath(rel);
987
- if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
988
- const harness = harnessRoot();
989
- if (!harness) { res.statusCode = 503; res.end(); return; }
990
- const root = join(harness, SKINS_DIR_NAME, id);
991
- const file = join(root, ...segs);
992
- if (!resolve(file).startsWith(resolve(root) + sep)) { res.statusCode = 400; res.end(); return; }
993
- if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end(); return; }
994
- const ext = (file.slice(file.lastIndexOf('.')).toLowerCase() || '');
995
- res.setHeader('Content-Type', ASSET_TYPES[ext] || 'application/octet-stream');
996
- res.setHeader('Cache-Control', 'public, max-age=300');
997
- res.end(readFileSync(file));
998
- },
999
- }));
1000
-
1001
- disposers.push(webServer.register({
1002
- kind: 'exact',
1003
- path: `${base}/skins/preview`,
1004
- handler: async (req, res) => {
1005
- // 预览图代理:浏览器不能直连站点(跨境),也避免把站点 URL 散到客户端各处。
1006
- const url = new URL(req.url || '/', 'http://x');
1007
- const rel = url.searchParams.get('p') || '';
1008
- const site = skinsSiteBase();
1009
- if (!site) { res.statusCode = 503; res.end(); return; }
1010
- const segs = safeRelPath(rel);
1011
- if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
1012
- try {
1013
- const r = await fetch(site + '/' + segs.join('/'), {
1014
- headers: { accept: 'image/*' },
1015
- signal: AbortSignal.timeout(SKINS_TIMEOUT_MS),
1016
- });
1017
- if (!r.ok) { res.statusCode = 404; res.end(); return; }
1018
- const buf = Buffer.from(await r.arrayBuffer());
1019
- res.setHeader('Content-Type', r.headers.get('content-type') || 'image/jpeg');
1020
- res.setHeader('Cache-Control', 'public, max-age=3600');
1021
- res.end(buf);
1022
- } catch {
1023
- res.statusCode = 502;
1024
- res.end();
1025
- }
1026
- },
1027
- }));
1028
- }
1029
-
1030
- /* renameSync / cpSync 的薄封装:统一「同分区 rename、跨设备退化复制」这一处。 */
1031
- function renameSyncSafe(from, to) { renameSync(from, to); }
1032
- function cpSyncSafe(from, to) { cpSync(from, to, { recursive: true }); }
1
+ /**
2
+ * routes/skins.js — 「皮肤包」数据源:把**自建站点**上的皮肤包列出来、下下来、装上、切换。
3
+ *
4
+ * 与 `routes/inventory.js` 的站点数据源是**同一套思路**(自建站 = 唯一真源,宿主端代取,
5
+ * 浏览器不直连),但落地对象不同:那边是壁纸条目,这边是**整套皮肤包**。
6
+ *
7
+ * 为什么这件事可行而不是"另造一套皮肤引擎":
8
+ * · 站点已经按市场同一格式产出索引(`<site>/skins/manifest.json`)与 zip
9
+ * (`<site>/skins/<id>.zip`),zip 内就是 `skin.json` + `skin.css` + `assets/`
10
+ * + `dsh-market.provenance.json`;
11
+ * · 本插件**不负责渲染** —— 皮肤中心(dsh-skin-center)读 `harness/skins/<id>/`
12
+ * 自己应用样式与 hooks。我们只做"取、验、落、切"四件事。
13
+ * · 所以皮肤中心的完整性校验仍会照常跑(provenance 在 zip 内原样带着,哈希没被动过)。
14
+ *
15
+ * 四个路由(全部 `${base}/skins` 前缀下,一条 prefix 注册):
16
+ * GET /skins 索引 + 本机已装/在用状态(合并后下发,客户端一次拿全)
17
+ * POST /skins/install {id} 下载 → sha256 校验 → 解压到 harness/skins/<id>/
18
+ * POST /skins/activate {id} 写 harness/skin-center-active.json 的 `active`
19
+ * POST /skins/remove {id} 删目录(在用 / 不存在的拒绝)
20
+ *
21
+ * 纪律(与库存族同口径):
22
+ * · 站点不可达**绝不影响**插件其他功能:索引失败返回 503 + 结构化错误,客户端显示"不可达";
23
+ * · 每次下载都有上限(`SKIN_ZIP_MAX_BYTES`)与超时;
24
+ * · zip 解压是**纯 JS**(node:zlib)—— 不引入新依赖,也不 shell 出去调 Expand-Archive;
25
+ * · 解压路径做 zip-slip 防护:绝对路径、`..`、反斜杠、盘符一律拒绝;
26
+ * · 只认 `^[a-z0-9][a-z0-9-]*$` 的 id(索引来自远端,不能拿它当路径片段直接拼)。
27
+ */
28
+ import { createHash } from 'node:crypto';
29
+ import { inflateRawSync } from 'node:zlib';
30
+ import {
31
+ cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
32
+ } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { join, resolve, sep } from 'node:path';
35
+ import { sendJson } from '../json-response.js';
36
+ import { bodyReader } from '../http-body.js';
37
+
38
+ /* ── 站点 ────────────────────────────────────────────────────────────────── */
39
+
40
+ const SKINS_SITE = 'https://folia-major-3t6.pages.dev';
41
+ const SKINS_INDEX_PATH = '/skins/manifest.json';
42
+ const SKINS_TTL_MS = 60000;
43
+ const SKINS_TIMEOUT_MS = 8000;
44
+ /** 单个 zip 上限:站点上最大的是 8 MB 那套(whalechan-harness),留足余量。 */
45
+ const SKIN_ZIP_MAX_BYTES = 32 * 1024 * 1024;
46
+ /** 分卷拼接后的总上限(DIY 手账本 3 段合计约 67 MB,留余量)。 */
47
+ const SKIN_TOTAL_MAX_BYTES = 128 * 1024 * 1024;
48
+ const CONTROL_JSON_MAX_BYTES = 64 * 1024;
49
+ const ACTIVE_FILE = 'skin-center-active.json';
50
+ const SKINS_DIR_NAME = 'skins';
51
+ const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
52
+
53
+ /** `DSH_FOLIA_SKINS_SITE` → 归一化基址;`off`/空串表示停用整条通道。 */
54
+ function skinsSiteBase() {
55
+ const raw = process.env.DSH_FOLIA_SKINS_SITE;
56
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
57
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : SKINS_SITE;
58
+ return base.replace(/\/+$/, '');
59
+ }
60
+
61
+ /**
62
+ * 皮肤中心是否已挂载(60s 缓存)。
63
+ *
64
+ * 判据只有它的路由:`/api/skin-center/v2/active` 存在 ⇒ 挂载(200 / 401 / 403 都算,
65
+ * 网关会先挡一层,401 恰恰证明路由注册过);404 ⇒ 没装 / 没挂。
66
+ *
67
+ * 为什么要探测:我们不再假装能"完整应用皮肤"。皮肤中心在台时,应用动作应当交回它的
68
+ * 设置页(原生路径,100% 完整);它不在时我们才用自建兜底,并把"可能不全"说出来。
69
+ */
70
+ let skinCenterCache = { at: 0, mounted: false };
71
+ async function skinCenterMounted(origin, fwdHeaders) {
72
+ if (Date.now() - skinCenterCache.at < SKINS_TTL_MS) return skinCenterCache.mounted;
73
+ // 多个候选 origin:请求自身的 host 最可能是对的,但反向代理/多端口下会落空,
74
+ // 所以再试一次 localhost 与裸 127.0.0.1 的同端口写法。任一返回非 404 就算在台。
75
+ const candidates = [];
76
+ const push = (u) => { if (u && candidates.indexOf(u) === -1) candidates.push(u); };
77
+ push(origin);
78
+ try {
79
+ const u = new URL(origin);
80
+ push("http://127.0.0.1:" + u.port);
81
+ push("http://localhost:" + u.port);
82
+ } catch { /* origin 不合规就只用它自己 */ }
83
+
84
+ let mounted = false;
85
+ const tried = [];
86
+ for (const base of candidates) {
87
+ try {
88
+ const r = await fetch(base + "/api/skin-center/v2/active", {
89
+ headers: fwdHeaders,
90
+ signal: AbortSignal.timeout(2500),
91
+ });
92
+ tried.push(base + "=" + r.status);
93
+ if (r.status !== 404) { mounted = true; break; }
94
+ } catch (err) {
95
+ tried.push(base + "=err");
96
+ }
97
+ }
98
+ skinCenterCache = { at: Date.now(), mounted };
99
+ try {
100
+ // 只在"没探到"时留一行记录,方便下次排查(探测失败不该是静默的)。
101
+ if (!mounted) skinCenterCache.lastTried = tried.join(" ");
102
+ } catch { /* ignore */ }
103
+ return mounted;
104
+ }
105
+
106
+ /** @type {{ at: number, payload: any[] | null, error: string | null }} */
107
+ let indexCache = { at: 0, payload: null, error: null };
108
+
109
+ /**
110
+ * harness 根目录(`skins/` 与 `skin-center-active.json` 所在的那一层)。
111
+ *
112
+ * 桌面版是 `<userData>/harness`(Electron 的 `--user-data-dir` 是 `%APPDATA%\dsh-desktop`);
113
+ * CLI 版是 `~/.dsh`。两边都没有权威 API 可问,所以按候选逐个试,**以 `skins/` 或
114
+ * `skin-center-active.json` 是否存在为准**。`DSH_FOLIA_HARNESS` 可强制指定(自检/异形安装)。
115
+ * @returns {string | null}
116
+ */
117
+ export function harnessRoot() {
118
+ const forced = process.env.DSH_FOLIA_HARNESS;
119
+ if (typeof forced === 'string' && forced.trim()) return resolve(forced.trim());
120
+ const home = homedir();
121
+ /** @type {string[]} */
122
+ const candidates = [];
123
+ const appData = process.env.APPDATA;
124
+ const localAppData = process.env.LOCALAPPDATA;
125
+ if (appData) candidates.push(join(appData, 'dsh-desktop', 'harness'));
126
+ if (localAppData) candidates.push(join(localAppData, 'dsh-desktop', 'harness'));
127
+ candidates.push(join(home, '.dsh', 'harness'), join(home, '.dsh'), join(home, '.dsh-desktop', 'harness'));
128
+ for (const c of candidates) {
129
+ try {
130
+ if (existsSync(join(c, SKINS_DIR_NAME)) || existsSync(join(c, ACTIVE_FILE))) return c;
131
+ } catch { /* 单个候选读不到 ⇒ 试下一个 */ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /* ── 索引 ────────────────────────────────────────────────────────────────── */
137
+
138
+ /**
139
+ * 列出**所有**提供了皮肤中心行的 bundle(跨 profile 扫 patch 文件)。
140
+ *
141
+ * 为什么必须报出来:我们在自己的 patch 里加了一行内嵌皮肤中心(folia-skin-center)。
142
+ * 如果用户同时还装着 @linxin666/dsh-web-all,它的 patch 里也有一行(web-ui-skin-center)
143
+ * 指向同一个包 ⇒ 同一个插件被挂两次。这种事要显式告诉用户去关掉一处,不能静默。
144
+ */
145
+ function skinCenterProviders() {
146
+ const out = [];
147
+ try {
148
+ const harness = harnessRoot();
149
+ if (!harness) return out;
150
+ const profilesDir = join(harness, 'profiles');
151
+ if (!existsSync(profilesDir)) return out;
152
+ const SKIN_CENTER = '@linxin666/dsh-client-ui-skin-center';
153
+ for (const prof of readdirSync(profilesDir)) {
154
+ if (prof.startsWith('.')) continue;
155
+ const pkgPath = join(profilesDir, prof, 'package.json');
156
+ if (!existsSync(pkgPath)) continue;
157
+ let doc;
158
+ try { doc = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { continue; }
159
+ const bundles = (doc && doc.dsh && doc.dsh.profile && doc.dsh.profile.bundles) || [];
160
+ for (const b of bundles) {
161
+ const patchPath = join(profilesDir, prof, 'node_modules', b, 'cordis.patch.yml');
162
+ if (!existsSync(patchPath)) continue;
163
+ let text;
164
+ try { text = readFileSync(patchPath, 'utf8'); } catch { continue; }
165
+ if (!text.includes(SKIN_CENTER)) continue;
166
+ const ids = [];
167
+ const re = /-\s*id:\s*([A-Za-z0-9_-]+)\s*\n\s*name:\s*'([^']+)'/g;
168
+ let m;
169
+ while ((m = re.exec(text)) !== null) {
170
+ if (m[2].includes('dsh-client-ui-skin-center')) ids.push(m[1]);
171
+ }
172
+ if (ids.length) out.push({ profile: prof, bundle: b, rows: ids });
173
+ }
174
+ }
175
+ } catch { /* 扫描失败只是少一条提示,不影响功能 */ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * 拉站点索引并**精简**成客户端要的形状。永不抛;失败返回 `{ items: [], error }`。
181
+ *
182
+ * 为什么精简:原始 manifest 62 KB / 65 条,每条还带完整 description 与 parts 清单;
183
+ * 客户端一次拿全没有意义,只留卡片需要的字段 + `zipRelative`(安装时回传给宿主)。
184
+ */
185
+ async function fetchIndex(force = false) {
186
+ const base = skinsSiteBase();
187
+ if (!base) return { items: [], error: 'disabled' };
188
+ if (!force && Date.now() - indexCache.at < SKINS_TTL_MS) {
189
+ return { items: indexCache.payload || [], error: indexCache.error };
190
+ }
191
+
192
+ let items = [];
193
+ let error = null;
194
+ try {
195
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
196
+ ? AbortSignal.timeout(SKINS_TIMEOUT_MS)
197
+ : undefined;
198
+ const res = await fetch(base + SKINS_INDEX_PATH, { headers: { accept: 'application/json' }, signal });
199
+ if (!res.ok) {
200
+ error = 'http-' + res.status;
201
+ } else {
202
+ let doc;
203
+ try {
204
+ doc = await res.json();
205
+ } catch {
206
+ error = 'json';
207
+ }
208
+ if (doc) {
209
+ const list = Array.isArray(doc.skins) ? doc.skins : [];
210
+ for (const s of list) {
211
+ const id = String((s && s.id) || '');
212
+ if (!ID_RE.test(id)) continue;
213
+ // 分卷包(DIY 手账本那种 67 MB 三段):`parts` 是 **对象数组** `{name, bytes}`,
214
+ // 也兼容纯字符串形态 —— 早期实现只认字符串,把 diy-journal 整条静默丢了
215
+ //(manifest 65 条、列表只出 64 条,就是它)。
216
+ const parts = (Array.isArray(s.parts) ? s.parts : [])
217
+ .map((p) => (typeof p === 'string' ? p : (p && typeof p.name === 'string' ? p.name : '')))
218
+ .filter(Boolean);
219
+ const zip = typeof s.zip === 'string' ? s.zip : '';
220
+ if (!zip && parts.length === 0) continue;
221
+ items.push({
222
+ id,
223
+ name: String(s.name || id),
224
+ nameEn: String(s.nameEn || ''),
225
+ version: String(s.version || ''),
226
+ author: String(s.author || ''),
227
+ accent: typeof s.accent === 'string' ? s.accent : '',
228
+ tagline: String(s.tagline || ''),
229
+ tags: Array.isArray(s.tags) ? s.tags.slice(0, 8).map(String) : [],
230
+ zipBytes: Number(s.zipBytes) || 0,
231
+ sha256: typeof s.sha256 === 'string' ? s.sha256 : '',
232
+ zipRelative: zip,
233
+ parts: parts.slice(0, 16),
234
+ previewLight: (s.preview && typeof s.preview.light === 'string') ? s.preview.light : '',
235
+ previewDark: (s.preview && typeof s.preview.dark === 'string') ? s.preview.dark : '',
236
+ });
237
+ }
238
+ }
239
+ }
240
+ } catch (e) {
241
+ const code = String((e && (e.name || e.code)) || '');
242
+ const msg = String((e && e.message) || '');
243
+ if (/timeout|abort/i.test(code) || /timeout|aborted/i.test(msg)) error = 'timeout';
244
+ else if (/cert|tls|ssl/i.test(msg)) error = 'tls';
245
+ else error = 'network';
246
+ }
247
+
248
+ indexCache = { at: Date.now(), payload: items, error };
249
+ return { items, error };
250
+ }
251
+
252
+ /* ── 本机状态 ─────────────────────────────────────────────────────────────── */
253
+
254
+ /** 本机已装的皮肤 id 集合(`skins/` 下的目录名)。 */
255
+ function installedIds(harness) {
256
+ const dir = join(harness, SKINS_DIR_NAME);
257
+ try {
258
+ return new Set(readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
259
+ } catch {
260
+ return new Set();
261
+ }
262
+ }
263
+
264
+ /** 皮肤中心当前的 `active`(读不到就当没有 —— fail-open,不影响列表)。 */
265
+ function activeSkinId(harness) {
266
+ try {
267
+ const doc = JSON.parse(readFileSync(join(harness, ACTIVE_FILE), 'utf8'));
268
+ return typeof doc.active === 'string' ? doc.active : null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /* ── zip 解压(纯 JS,零依赖)────────────────────────────────────────────── */
275
+
276
+ /** 从中央目录读条目表。只支持 store(0) / deflate(8),够用且不引依赖。 */
277
+ function zipEntries(buf) {
278
+ // 找 EOCD(0x06054b50),从尾部往前扫(注释区最多 65535 字节)
279
+ let eocd = -1;
280
+ for (let i = buf.length - 22; i >= 0 && i >= buf.length - 65558; i--) {
281
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
282
+ }
283
+ if (eocd < 0) throw new Error('不是有效的 zip(找不到中央目录)');
284
+ const count = buf.readUInt16LE(eocd + 10);
285
+ let off = buf.readUInt32LE(eocd + 16);
286
+ const out = [];
287
+ for (let i = 0; i < count; i++) {
288
+ if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error('中央目录条目损坏');
289
+ const method = buf.readUInt16LE(off + 10);
290
+ const compSize = buf.readUInt32LE(off + 20);
291
+ const nameLen = buf.readUInt16LE(off + 28);
292
+ const extraLen = buf.readUInt16LE(off + 30);
293
+ const commentLen = buf.readUInt16LE(off + 32);
294
+ const localOff = buf.readUInt32LE(off + 42);
295
+ const name = buf.toString('utf8', off + 46, off + 46 + nameLen);
296
+ out.push({ name, method, compSize, localOff });
297
+ off += 46 + nameLen + extraLen + commentLen;
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /** 校验条目名是安全的相对路径,并返回归一化后的相对段。 */
303
+ function safeRelPath(name) {
304
+ const norm = String(name).replace(/\\/g, '/');
305
+ if (!norm || norm.startsWith('/') || /^[a-zA-Z]:/.test(norm)) return null;
306
+ const segs = norm.split('/').filter((s) => s && s !== '.');
307
+ if (segs.length === 0) return null;
308
+ for (const s of segs) {
309
+ if (s === '..') return null;
310
+ if (s.includes('\0')) return null;
311
+ }
312
+ return segs;
313
+ }
314
+
315
+ /**
316
+ * 解压到 `destDir`。**先解到临时目录再整体改名**,中途失败不留半个皮肤。
317
+ * @returns {{ files: number }}
318
+ */
319
+ function extractZipTo(buf, destDir, tmpDir) {
320
+ const entries = zipEntries(buf);
321
+ let files = 0;
322
+ for (const e of entries) {
323
+ const segs = safeRelPath(e.name);
324
+ if (!segs) throw new Error('zip 内含不安全路径:' + e.name);
325
+ // 本地文件头长度可变(name/extra 可能与中央目录不同),必须重读
326
+ const lo = e.localOff;
327
+ if (buf.readUInt32LE(lo) !== 0x04034b50) throw new Error('本地文件头损坏:' + e.name);
328
+ const lNameLen = buf.readUInt16LE(lo + 26);
329
+ const lExtraLen = buf.readUInt16LE(lo + 28);
330
+ const dataStart = lo + 30 + lNameLen + lExtraLen;
331
+ const raw = buf.subarray(dataStart, dataStart + e.compSize);
332
+ const isDir = e.name.endsWith('/');
333
+ const target = join(tmpDir, ...segs);
334
+ // 兜底:拼接后必须仍在 tmpDir 里(防 zip-slip 的任何漏网)
335
+ if (!resolve(target).startsWith(resolve(tmpDir) + sep)) throw new Error('zip 路径越界:' + e.name);
336
+ if (isDir) {
337
+ mkdirSync(target, { recursive: true });
338
+ continue;
339
+ }
340
+ let data;
341
+ if (e.method === 0) data = raw;
342
+ else if (e.method === 8) data = inflateRawSync(raw);
343
+ else throw new Error('不支持的 zip 压缩方式 ' + e.method + ':' + e.name);
344
+ mkdirSync(join(target, '..'), { recursive: true });
345
+ writeFileSync(target, data);
346
+ files++;
347
+ }
348
+ if (files === 0) throw new Error('zip 里没有任何文件');
349
+ return { files };
350
+ }
351
+
352
+ /* ── 下载 ────────────────────────────────────────────────────────────────── */
353
+
354
+ async function fetchBytes(url, maxBytes) {
355
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
356
+ ? AbortSignal.timeout(SKINS_TIMEOUT_MS * 4) // 8 MB 的包在跨境链路上要给足时间
357
+ : undefined;
358
+ const res = await fetch(url, { headers: { accept: '*/*' }, signal });
359
+ if (!res.ok) throw new Error('下载失败:HTTP ' + res.status);
360
+ const len = Number(res.headers.get('content-length'));
361
+ if (Number.isFinite(len) && len > maxBytes) throw new Error('皮肤包过大:' + len + ' 字节');
362
+ const ab = await res.arrayBuffer();
363
+ const buf = Buffer.from(ab);
364
+ if (buf.length > maxBytes) throw new Error('皮肤包过大:' + buf.length + ' 字节');
365
+ if (buf.length === 0) throw new Error('下载到 0 字节');
366
+ return buf;
367
+ }
368
+
369
+ function sha256Hex(buf) {
370
+ return createHash('sha256').update(buf).digest('hex');
371
+ }
372
+
373
+ /* ── 路由 ────────────────────────────────────────────────────────────────── */
374
+
375
+ /**
376
+ * @param {any} webServer
377
+ * @param {{ disposers: Function[], base: string, log?: Function }} c
378
+ */
379
+ export function registerSkinRoutes(webServer, c) {
380
+ const { disposers, base, log } = c;
381
+ const note = typeof log === 'function' ? log : () => {};
382
+
383
+ /**
384
+ * 读一个小 JSON body({id})并校验成合法皮肤 id。解析失败 / 缺 id / 越界一律 null ⇒ 调用方回 400。
385
+ *
386
+ * ⚠️ `bodyReader` 是「处理器 + 取值器」模式(见 lib/http-body.js 文件头的用法),**没有**
387
+ * `await reader.text()` 这种异步形态 —— 监听器由调用点挂,这里包一层 Promise 收口。
388
+ */
389
+ function readIdBody(req) {
390
+ return new Promise((resolveId) => {
391
+ let settled = false;
392
+ const done = (v) => { if (!settled) { settled = true; resolveId(v); } };
393
+ const reader = bodyReader(req, {
394
+ maxBytes: CONTROL_JSON_MAX_BYTES,
395
+ // 越限当场判失败:body 不该继续占内存,也不该等到 end 才应 400。
396
+ onOverflow: () => done(null),
397
+ });
398
+ req.on('data', reader.onData);
399
+ req.on('end', () => {
400
+ if (reader.overflowed) { done(null); return; }
401
+ let doc;
402
+ try {
403
+ doc = JSON.parse(reader.text());
404
+ } catch {
405
+ done(null);
406
+ return;
407
+ }
408
+ const id = doc && typeof doc.id === 'string' ? doc.id : '';
409
+ done(ID_RE.test(id) ? id : null);
410
+ });
411
+ req.on('error', () => done(null));
412
+ });
413
+ }
414
+
415
+ disposers.push(webServer.register({
416
+ kind: 'exact',
417
+ path: `${base}/skins`,
418
+ handler: async (req, res) => {
419
+ const url = new URL(req.url || '/', 'http://x');
420
+ const force = url.searchParams.get('refresh') === '1';
421
+ const harness = harnessRoot();
422
+ const { items, error } = await fetchIndex(force);
423
+ const installed = harness ? installedIds(harness) : new Set();
424
+ const active = harness ? activeSkinId(harness) : null;
425
+ const merged = items.map((it) => ({
426
+ ...it,
427
+ installed: installed.has(it.id),
428
+ active: it.id === active,
429
+ }));
430
+ sendJson(res, 200, {
431
+ ok: error === null,
432
+ error,
433
+ site: skinsSiteBase(),
434
+ harness,
435
+ harnessFound: Boolean(harness),
436
+ skinCenterProviders: skinCenterProviders(),
437
+ // 判据分两层:先探它的路由(最准),探不到再退回"有 bundle 提供了那一行"
438
+ // (用户可能正好在多端口/反代下,探测会落空,但行存在就说明引擎装了)。
439
+ skinCenter: (await skinCenterMounted(
440
+ 'http://' + ((req && req.headers && req.headers.host) || '127.0.0.1'),
441
+ (() => {
442
+ const h = { accept: 'application/json', 'sec-fetch-site': 'same-origin' };
443
+ const host = (req && req.headers && req.headers.host) || '';
444
+ if (host) { h.origin = 'http://' + host; h.referer = 'http://' + host + '/'; }
445
+ if (req && req.headers && req.headers.cookie) h.cookie = req.headers.cookie;
446
+ return h;
447
+ })(),
448
+ )) || skinCenterProviders().length > 0,
449
+ active,
450
+ count: merged.length,
451
+ installedCount: [...installed].length,
452
+ skins: merged,
453
+ });
454
+ },
455
+ }));
456
+
457
+ disposers.push(webServer.register({
458
+ kind: 'exact',
459
+ path: `${base}/skins/install`,
460
+ handler: async (req, res) => {
461
+ const id = await readIdBody(req);
462
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
463
+ const harness = harnessRoot();
464
+ if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
465
+ const site = skinsSiteBase();
466
+ if (!site) { sendJson(res, 503, { ok: false, error: 'disabled' }); return; }
467
+
468
+ const { items } = await fetchIndex();
469
+ const entry = items.find((x) => x.id === id);
470
+ if (!entry) { sendJson(res, 404, { ok: false, error: 'unknown-skin' }); return; }
471
+
472
+ const skinsDir = join(harness, SKINS_DIR_NAME);
473
+ const destDir = join(skinsDir, id);
474
+ const tmpDir = join(skinsDir, '.' + id + '.tmp-' + Date.now().toString(36));
475
+ try {
476
+ // 分卷包:按顺序拼接成完整 zip
477
+ let buf;
478
+ if (entry.parts.length > 0) {
479
+ const chunks = [];
480
+ for (const p of entry.parts) {
481
+ chunks.push(await fetchBytes(site + '/' + String(p).replace(/^\/+/, ''), SKIN_ZIP_MAX_BYTES));
482
+ }
483
+ buf = Buffer.concat(chunks);
484
+ // 拼接后总量另设一道闸:单包 32 MB 的上限管不住 3 段拼起来的 67 MB。
485
+ if (buf.length > SKIN_TOTAL_MAX_BYTES) {
486
+ throw new Error('分卷拼接后过大:' + buf.length + ' 字节');
487
+ }
488
+ } else {
489
+ buf = await fetchBytes(site + '/' + String(entry.zipRelative).replace(/^\/+/, ''), SKIN_ZIP_MAX_BYTES);
490
+ }
491
+
492
+ // 哈希校验:manifest 里给的是**截断**哈希(16 位十六进制),按前缀比
493
+ if (entry.sha256 && /^[0-9a-f]+$/i.test(entry.sha256)) {
494
+ const actual = sha256Hex(buf);
495
+ if (!actual.toLowerCase().startsWith(entry.sha256.toLowerCase())) {
496
+ throw new Error('校验失败:sha256 不匹配(下载可能被截断)');
497
+ }
498
+ }
499
+
500
+ mkdirSync(skinsDir, { recursive: true });
501
+ rmSync(tmpDir, { recursive: true, force: true });
502
+ mkdirSync(tmpDir, { recursive: true });
503
+ const { files } = extractZipTo(buf, destDir, tmpDir);
504
+
505
+ // zip 顶层就是 `<id>/`:把它里面的内容搬到 destDir
506
+ const inner = join(tmpDir, id);
507
+ const innerExists = existsSync(inner) && statSync(inner).isDirectory();
508
+ const payload = innerExists ? inner : tmpDir;
509
+ rmSync(destDir, { recursive: true, force: true });
510
+ mkdirSync(destDir, { recursive: true });
511
+ for (const name of readdirSync(payload)) {
512
+ const from = join(payload, name);
513
+ const to = join(destDir, name);
514
+ // 同一分区:rename 最快;跨设备时退化为复制
515
+ try {
516
+ renameSyncSafe(from, to);
517
+ } catch {
518
+ cpSyncSafe(from, to);
519
+ }
520
+ }
521
+ rmSync(tmpDir, { recursive: true, force: true });
522
+
523
+ if (!existsSync(join(destDir, 'skin.json'))) {
524
+ throw new Error('装完没有 skin.json —— zip 结构不对');
525
+ }
526
+ note('[skins] installed ' + id + ' (' + files + ' files)');
527
+ sendJson(res, 200, { ok: true, id, files, bytes: buf.length, installed: true });
528
+ } catch (err) {
529
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 清理失败不影响应答 */ }
530
+ const msg = String((err && err.message) || err);
531
+ note('[skins] install ' + id + ' failed: ' + msg);
532
+ sendJson(res, 502, { ok: false, error: 'install-failed', detail: msg });
533
+ }
534
+ },
535
+ }));
536
+
537
+ disposers.push(webServer.register({
538
+ kind: 'exact',
539
+ path: `${base}/skins/activate`,
540
+ handler: async (req, res) => {
541
+ const id = await readIdBody(req);
542
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
543
+ const harness = harnessRoot();
544
+ if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
545
+ if (!existsSync(join(harness, SKINS_DIR_NAME, id, 'skin.json'))) {
546
+ sendJson(res, 404, { ok: false, error: 'not-installed' });
547
+ return;
548
+ }
549
+ const file = join(harness, ACTIVE_FILE);
550
+ let doc = {};
551
+ try {
552
+ doc = JSON.parse(readFileSync(file, 'utf8')) || {};
553
+ } catch { /* 文件不存在 / 坏掉 ⇒ 用最小骨架重建 */ }
554
+ // 只改 `active`:background 那一段是皮肤中心自己的设置,一个字都不能丢。
555
+ const previous = typeof doc.active === 'string' ? doc.active : null;
556
+ doc.active = id;
557
+ if (typeof doc.initialized !== 'boolean') doc.initialized = true;
558
+ try {
559
+ // ⚠️ 这里必须先写一次 `null`,再写目标 id —— 不是画蛇添足。
560
+ //
561
+ // 皮肤中心客户端的收敛逻辑(lib/client.js,watchPersistedSelection):
562
+ //
563
+ // applied = persisted === undefined ? undefined
564
+ // : (stamp === null || stamp === persisted ? persisted : undefined);
565
+ // // tick: next === applied ⇒ return ← 相同就永远不动
566
+ //
567
+ // 页面启动时若 `html[data-dsh-skin]` 为空,它会把**持久化的值直接当成已应用**。
568
+ // 于是我们写一个"和它记录里一样"的 id 时,轮询认为没有变化,皮肤永远不上。
569
+ // 先归零(写 null)能让它收敛到"无皮肤",紧接着写目标 id 就必然是一次变化,
570
+ // 轮询一定会应用 —— 这是绕开它这个初始化的唯一办法,且完全走它自己的路径。
571
+ if (previous === id) {
572
+ doc.active = null;
573
+ writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
574
+ await new Promise((r) => setTimeout(r, 900));
575
+ doc.active = id;
576
+ }
577
+ writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
578
+ note('[skins] activated ' + id + (previous === id ? ' (after clearing the same id)' : ''));
579
+ sendJson(res, 200, { ok: true, id, active: id, restartRequired: false, nudged: previous === id });
580
+ } catch (err) {
581
+ sendJson(res, 500, { ok: false, error: 'write-failed', detail: String(err && err.message || err) });
582
+ }
583
+ },
584
+ }));
585
+
586
+ disposers.push(webServer.register({
587
+ kind: 'exact',
588
+ path: `${base}/skins/remove`,
589
+ handler: async (req, res) => {
590
+ const id = await readIdBody(req);
591
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
592
+ const harness = harnessRoot();
593
+ if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
594
+ if (activeSkinId(harness) === id) {
595
+ sendJson(res, 409, { ok: false, error: 'skin-active' });
596
+ return;
597
+ }
598
+ const dir = join(harness, SKINS_DIR_NAME, id);
599
+ if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
600
+ try {
601
+ rmSync(dir, { recursive: true, force: true });
602
+ note('[skins] removed ' + id);
603
+ sendJson(res, 200, { ok: true, id, removed: true });
604
+ } catch (err) {
605
+ sendJson(res, 500, { ok: false, error: 'remove-failed', detail: String(err && err.message || err) });
606
+ }
607
+ },
608
+ }));
609
+
610
+ /* ── 热切换:把皮肤样式自己喂给浏览器 ──────────────────────────────────────
611
+ * 皮肤中心只在**页面加载时**应用皮肤(它的 host 侧把样式塞进文档、client 侧启动时拉
612
+ * `/api/skin-center/v2/active`),桌面壳里 `location.reload()` 又常常被吞掉 ⇒
613
+ * 用户点「使用」后界面永远不变。
614
+ *
615
+ * 所以这里补两条只读路由,把 `harness/skins/<id>/` 里的样式与素材直接发给页面:
616
+ * GET /skins/css?id=<id> → skin.css(`url()` 已重写成下面那条素材路由)
617
+ * GET /skins/asset?id=<id>&p=<rel> → 皮肤目录里的任意素材(图片/字体)
618
+ * 客户端把 CSS 挂成一个 `<link id="folia-skin-hot">` 追加在 `<head>` 末尾 ——
619
+ * 后加载的规则覆盖先前的,于是**不刷新也能换肤**;hooks(粒子等)不会跑,那部分
620
+ * 仍归皮肤中心管。
621
+ */
622
+
623
+ /** 读一套皮肤的 manifest,拿 `contributes.stylesheet`。 */
624
+ function skinStylesheetOf(dir) {
625
+ try {
626
+ const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
627
+ const rel = doc && doc.contributes && doc.contributes.stylesheet;
628
+ if (typeof rel !== 'string') return null;
629
+ const segs = safeRelPath(rel);
630
+ if (!segs || segs.includes('..')) return null;
631
+ return { rel: segs.join('/'), doc };
632
+ } catch {
633
+ return null;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * 取 manifests 里某个可选的样式类字段(当前用于 `patches`)。
639
+ * 与 stylesheet 同样的安全校验:只接受皮肤目录内的相对路径。
640
+ */
641
+ function skinAuxFileOf(dir, key) {
642
+ try {
643
+ const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
644
+ const rel = doc && doc.contributes && doc.contributes[key];
645
+ if (typeof rel !== 'string' || !rel) return null;
646
+ const segs = safeRelPath(rel);
647
+ if (!segs || segs.includes('..')) return null;
648
+ return segs.join('/');
649
+ } catch {
650
+ return null;
651
+ }
652
+ }
653
+
654
+ const ASSET_TYPES = {
655
+ '.css': 'text/css; charset=utf-8',
656
+ '.js': 'text/javascript; charset=utf-8',
657
+ '.json': 'application/json; charset=utf-8',
658
+ '.svg': 'image/svg+xml',
659
+ '.png': 'image/png',
660
+ '.jpg': 'image/jpeg',
661
+ '.jpeg': 'image/jpeg',
662
+ '.webp': 'image/webp',
663
+ '.gif': 'image/gif',
664
+ '.avif': 'image/avif',
665
+ '.mp4': 'video/mp4',
666
+ '.webm': 'video/webm',
667
+ '.woff': 'font/woff',
668
+ '.woff2': 'font/woff2',
669
+ '.ttf': 'font/ttf',
670
+ '.otf': 'font/otf',
671
+ };
672
+
673
+ /* ── 背景层数据 ────────────────────────────────────────────────────────────
674
+ * 一套皮肤的"背景"不在 CSS 里,而是 `contributes.backgroundMedia`:
675
+ * { light: { type: 'video'|'image', src: 'assets/x.mp4', scrim: '<css 渐变>' }, dark: {...} }
676
+ * 皮肤中心负责把这层画出来;它的 DOM 契约(从各皮肤 CSS 反推)是
677
+ * `body [data-dsh-skin-layer='background']`,z-index -2,视频/图片作为子元素
678
+ *(`future-window` 直接写 `> img`,`frutiger-aero` 用 `::after` 叠气泡)。
679
+ * 热切换时没人建这层 ⇒ 背景全空("水墨丹青没有背景动画"就是这个原因)。
680
+ */
681
+ disposers.push(webServer.register({
682
+ kind: 'exact',
683
+ path: `${base}/skins/manifest`,
684
+ handler: async (req, res) => {
685
+ const url = new URL(req.url || '/', 'http://x');
686
+ const id = url.searchParams.get('id') || '';
687
+ if (!ID_RE.test(id)) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
688
+ const harness = harnessRoot();
689
+ if (!harness) { sendJson(res, 503, { ok: false, error: 'harness-not-found' }); return; }
690
+ const dir = join(harness, SKINS_DIR_NAME, id);
691
+ let doc;
692
+ try {
693
+ doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
694
+ } catch {
695
+ sendJson(res, 404, { ok: false, error: 'no-manifest' });
696
+ return;
697
+ }
698
+ const c = (doc && doc.contributes) || {};
699
+ const media = c.backgroundMedia && typeof c.backgroundMedia === 'object' ? c.backgroundMedia : null;
700
+ /** `src` 相对路径 → 我们的素材路由(浏览器只认这条)。 */
701
+ const abs = (rel) => {
702
+ if (typeof rel !== 'string' || !rel) return '';
703
+ if (/^(?:[a-z]+:|\/)/i.test(rel)) return rel;
704
+ return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
705
+ };
706
+ const shape = (m) => (m && typeof m === 'object' ? {
707
+ type: m.type === 'video' ? 'video' : 'image',
708
+ url: abs(m.src),
709
+ scrim: typeof m.scrim === 'string' ? m.scrim : '',
710
+ } : null);
711
+ sendJson(res, 200, {
712
+ ok: true,
713
+ id,
714
+ name: typeof doc.name === 'string' ? doc.name : id,
715
+ backgroundMedia: media ? { light: shape(media.light), dark: shape(media.dark) } : null,
716
+ // ⚠️ hooks 的声明位置是 **`facets.client.entry`**,不是 `contributes.hooks`
717
+ //(契约见皮肤中心 contracts/hooks-api.d.ts:`facets.client.entry (hooks.mjs)`)。
718
+ // 之前读 contributes.hooks ⇒ 28 套带动效的皮肤全部"无动效",而那正是它们
719
+ // 看起来"显示不全"的部分(粒子/漂浮/交互)。这里两个位置都认,以 facets 为准。
720
+ hooks: (() => {
721
+ const facets = (doc && doc.facets) || null;
722
+ const client = facets && facets.client;
723
+ if (client && typeof client.entry === 'string' && client.entry) return client.entry;
724
+ return typeof c.hooks === 'string' ? c.hooks : '';
725
+ })(),
726
+ });
727
+ },
728
+ }));
729
+
730
+ disposers.push(webServer.register({
731
+ kind: 'exact',
732
+ path: `${base}/skins/css`,
733
+ handler: async (req, res) => {
734
+ const url = new URL(req.url || '/', 'http://x');
735
+ const id = url.searchParams.get('id') || '';
736
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
737
+ const harness = harnessRoot();
738
+ if (!harness) { res.statusCode = 503; res.end(); return; }
739
+ const dir = join(harness, SKINS_DIR_NAME, id);
740
+ const sheet = skinStylesheetOf(dir);
741
+ if (!sheet) { res.statusCode = 404; res.end(); return; }
742
+ let css;
743
+ try {
744
+ // ⚠️ 一套皮肤通常有**两份样式**:`contributes.stylesheet`(token 与结构)与
745
+ // `contributes.patches`(覆盖补丁)。**很多皮肤把全部改动写在 patches 里,
746
+ // stylesheet 故意留空**(例如 black-gold-vip 的 skin.css 只有一行注释)。
747
+ // 只下发 stylesheet ⇒ 换肤"看起来毫无变化"。两份都读,patches 放在后面覆盖。
748
+ const files = [];
749
+ files.push(sheet.rel);
750
+ const patchesRel = skinAuxFileOf(dir, 'patches');
751
+ if (patchesRel) files.push(patchesRel);
752
+ css = files.map((rel) => {
753
+ try { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); } catch { return ''; }
754
+ }).join('\n/* ---- patches ---- */\n');
755
+ } catch {
756
+ res.statusCode = 404;
757
+ res.end();
758
+ return;
759
+ }
760
+ // `url(...)` 全部改写成素材路由:CSS 是从 `/folia/skins/css` 下发的,相对路径会
761
+ // 解析到错误的前缀;改成绝对路径后,图片/字体才找得到。
762
+ const baseDir = sheet.rel.includes('/') ? sheet.rel.slice(0, sheet.rel.lastIndexOf('/')) : '';
763
+ const abs = (p) => {
764
+ if (/^(?:[a-z]+:|\/|#|data:)/i.test(p)) return p;
765
+ const rel = (baseDir ? baseDir + '/' : '') + p;
766
+ return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
767
+ };
768
+ css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, p) => `url("${abs(p.trim())}")`);
769
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
770
+ // 不缓存:换皮肤后必须立刻拿到新的一份,否则用户会看到上一套的样式。
771
+ res.setHeader('Cache-Control', 'no-store');
772
+ res.end(css);
773
+ },
774
+ }));
775
+
776
+ /* ── 路径式素材路由 ────────────────────────────────────────────────────────
777
+ * 皮肤的 hooks 契约(`contracts/hooks-api.d.ts`)给的是 `assetBase`:一个**同源 URL
778
+ * 前缀**,hooks 里自己拼 `assetBase + 'assets/x.webp'`。`?id=&p=` 这种查询式前缀拼不出
779
+ * 合法地址,所以这里再提供一条路径式:`/skins/files/<id>/<相对路径>`。
780
+ * 与 asset 完全同一套安全校验(id 白名单 + 路径段 + 越界检查)。
781
+ */
782
+ disposers.push(webServer.register({
783
+ kind: 'prefix',
784
+ path: `${base}/skins/files`,
785
+ handler: async (req, res) => {
786
+ let pathname = '/';
787
+ try { pathname = new URL(req.url || '/', 'http://x').pathname; } catch { res.statusCode = 400; res.end(); return; }
788
+ const prefix = `${base}/skins/files/`;
789
+ if (!pathname.startsWith(prefix)) { res.statusCode = 404; res.end(); return; }
790
+ const rest = decodeURIComponent(pathname.slice(prefix.length));
791
+ const slash = rest.indexOf('/');
792
+ const id = slash < 0 ? rest : rest.slice(0, slash);
793
+ const rel = slash < 0 ? '' : rest.slice(slash + 1);
794
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
795
+ const segs = safeRelPath(rel);
796
+ if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
797
+ const harness = harnessRoot();
798
+ if (!harness) { res.statusCode = 503; res.end(); return; }
799
+ const root = join(harness, SKINS_DIR_NAME, id);
800
+ const file = join(root, ...segs);
801
+ if (!resolve(file).startsWith(resolve(root) + sep)) { res.statusCode = 400; res.end(); return; }
802
+ if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end(); return; }
803
+ const ext = (file.slice(file.lastIndexOf('.')).toLowerCase() || '');
804
+ res.setHeader('Content-Type', ASSET_TYPES[ext] || 'application/octet-stream');
805
+ res.setHeader('Cache-Control', 'public, max-age=300');
806
+ res.end(readFileSync(file));
807
+ },
808
+ }));
809
+
810
+ /* ── 自己实现「作用域化」,不再依赖皮肤中心的接口 ────────────────────────────
811
+ *
812
+ * 皮肤中心的 `/api/skin-center/v2/skins/<id>/stylesheet` 会走 `transformSkinCss()`:
813
+ * 把**每条选择器**包进 `html[data-dsh-skin="<id>"]`。这一步决定皮肤能不能压住应用
814
+ * 自身的面板/令牌规则 —— 作用域化之后特异性更高。
815
+ *
816
+ * 但那条路由在网关后面:浏览器直连 401,宿主代发(哪怕透传全部请求头)也可能 401。
817
+ * 而"让每个用户把自己的会话凭证交给插件"不是可交付的方案。所以这里**自己算**:
818
+ * 读皮肤目录里的 CSS,做同一件事,输出等价结果。
819
+ *
820
+ * 转换规则:
821
+ * · 普通规则:`a, b` → `html[data-dsh-skin="x"] a, html[data-dsh-skin="x"] b`
822
+ * · 以 `:root` 开头的:开头换成作用域本身
823
+ * · 以 `html` 开头的:原样保留(作者自己已作用域化,别套两层)
824
+ * · `@media/@supports/@layer/@container`:**递归进去**处理内部规则
825
+ * · `@keyframes/@font-face/@page/@property/@import`:原样保留
826
+ * (关键帧内部是 `0% {}` 这种选择器,套作用域会直接毁掉动画)
827
+ */
828
+
829
+ /** 从 `openIdx`(指向 `{`)找配对的 `}`,跳过注释与字符串。找不到返回 -1。 */
830
+ function matchBrace(css, openIdx) {
831
+ let depth = 0;
832
+ for (let i = openIdx; i < css.length; i++) {
833
+ const ch = css[i];
834
+ if (ch === '/' && css[i + 1] === '*') {
835
+ const end = css.indexOf('*/', i + 2);
836
+ if (end < 0) return -1;
837
+ i = end + 1;
838
+ continue;
839
+ }
840
+ if (ch === '"' || ch === "'") {
841
+ const quote = ch;
842
+ for (i++; i < css.length; i++) {
843
+ if (css[i] === '\\') { i++; continue; }
844
+ if (css[i] === quote) break;
845
+ }
846
+ continue;
847
+ }
848
+ if (ch === '{') depth++;
849
+ else if (ch === '}') {
850
+ depth--;
851
+ if (depth === 0) return i;
852
+ }
853
+ }
854
+ return -1;
855
+ }
856
+
857
+ /** 单个选择器 → 作用域化后的选择器。 */
858
+ function scopeSelector(sel, scope) {
859
+ const t = sel.trim();
860
+ if (!t) return t;
861
+ if (/^:root\b/.test(t)) return t.replace(/^:root/, scope);
862
+ if (/^html\b/i.test(t)) return t; // 作者已作用域化,别套两层
863
+ return scope + ' ' + t;
864
+ }
865
+
866
+ /** 选择器列表 → 逐项作用域化。**不切括号内的逗号**(:is()/:not()/[a="b,c"])。 */
867
+ function scopeSelectorList(list, scope) {
868
+ const out = [];
869
+ let depth = 0;
870
+ let cur = '';
871
+ for (const ch of list) {
872
+ if (ch === '(' || ch === '[') depth++;
873
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
874
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
875
+ cur += ch;
876
+ }
877
+ out.push(cur);
878
+ return out.map((s) => scopeSelector(s, scope)).join(', ');
879
+ }
880
+
881
+ const GROUP_AT = /^@(media|supports|layer|container|scope)\b/i;
882
+ const KEEP_AT = /^@(keyframes|-webkit-keyframes|font-face|page|property|counter-style|import|charset|namespace)\b/i;
883
+
884
+ /** 把整段 CSS 作用域化。看不懂的结构就原样跳过该段(宁可少转,不可转坏)。 */
885
+ function scopeCss(css, id) {
886
+ const scope = `html[data-dsh-skin="${id}"]`;
887
+ let out = '';
888
+ let i = 0;
889
+ while (i < css.length) {
890
+ if (css[i] === '/' && css[i + 1] === '*') { // 注释原样保留
891
+ const end = css.indexOf('*/', i + 2);
892
+ if (end < 0) { out += css.slice(i); break; }
893
+ out += css.slice(i, end + 2);
894
+ i = end + 2;
895
+ continue;
896
+ }
897
+ if (/\s/.test(css[i])) { out += css[i++]; continue; }
898
+
899
+ if (css[i] === '@') {
900
+ const headEnd = css.indexOf('{', i);
901
+ const semi = css.indexOf(';', i);
902
+ if (semi >= 0 && (headEnd < 0 || semi < headEnd)) { // 无块 at-rule
903
+ out += css.slice(i, semi + 1);
904
+ i = semi + 1;
905
+ continue;
906
+ }
907
+ if (headEnd < 0) { out += css.slice(i); break; }
908
+ const head = css.slice(i, headEnd).trim();
909
+ const close = matchBrace(css, headEnd);
910
+ if (close < 0) { out += css.slice(i); break; }
911
+ const inner = css.slice(headEnd + 1, close);
912
+ // 分组 at-rule 递归进去;其余(关键帧/字体面/未知)原样保留
913
+ out += head + '{' + (GROUP_AT.test(head) ? scopeCss(inner, id) : inner) + '}';
914
+ i = close + 1;
915
+ continue;
916
+ }
917
+
918
+ const braceIdx = css.indexOf('{', i); // 普通规则
919
+ if (braceIdx < 0) { out += css.slice(i); break; }
920
+ const selector = css.slice(i, braceIdx);
921
+ const close = matchBrace(css, braceIdx);
922
+ if (close < 0) { out += css.slice(i); break; }
923
+ out += scopeSelectorList(selector, scope) + '{' + css.slice(braceIdx + 1, close) + '}';
924
+ i = close + 1;
925
+ }
926
+ return out;
927
+ }
928
+
929
+ /* ── 皮肤中心「加工后 CSS」的宿主端代理 ──────────────────────────────────────
930
+ * 皮肤中心的 `/skins/<id>/stylesheet|patches` 是 `transformSkinCss()` 的产物:
931
+ * **每条规则被强制包进 `html[data-dsh-skin="<id>"]`**,特异性高于应用自身规则 ——
932
+ * 只有那份 CSS 才能真正把面板压透。皮肤目录里的原始 CSS 压不住,于是"显示不全"。
933
+ *
934
+ * 为什么需要代理:浏览器直接请求 `/api/skin-center/v2/...` 被 DSH 网关挡掉
935
+ *(实测前端一直落到"未作用域化"的降级分支)。宿主端在本机回环上再发一次、并**带上
936
+ * 浏览器这次请求的 Cookie 与同源头**,就能拿到那份产物。
937
+ *
938
+ * 拿不到(皮肤中心没装 / 该皮肤不在它的 catalog 里)一律 404,客户端据此回退到原始 CSS。
939
+ */
940
+ disposers.push(webServer.register({
941
+ kind: 'exact',
942
+ path: `${base}/skins/scoped-css`,
943
+ handler: async (req, res) => {
944
+ const url = new URL(req.url || '/', 'http://x');
945
+ const id = url.searchParams.get('id') || '';
946
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
947
+ const host = req && req.headers && req.headers.host;
948
+ if (!host) { res.statusCode = 500; res.end(); return; }
949
+ const origin = 'http://' + host;
950
+ // 透传**全部**入站头(网关认的是这次会话的凭证,可能是 cookie,也可能是某个自定义
951
+ // 头;只挑几个字段必漏),再去掉逐跳头并补上同源语义。
952
+ // 实测:裸请求与"只带 sec-fetch-*"都回 401 ⇒ 凭证不在这几个头里。
953
+ const HOP_BY_HOP = new Set([
954
+ 'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'te', 'trailer',
955
+ 'proxy-authorization', 'proxy-authenticate', 'content-length', 'accept-encoding',
956
+ ]);
957
+ const fwdHeaders = {};
958
+ for (const [k, v] of Object.entries(req.headers || {})) {
959
+ const key = k.toLowerCase();
960
+ if (HOP_BY_HOP.has(key)) continue;
961
+ if (typeof v === 'string') fwdHeaders[key] = v;
962
+ else if (Array.isArray(v)) fwdHeaders[key] = v.join(', ');
963
+ }
964
+ fwdHeaders.origin = origin;
965
+ fwdHeaders.referer = origin + '/';
966
+ fwdHeaders['sec-fetch-site'] = 'same-origin';
967
+ fwdHeaders['sec-fetch-mode'] = 'cors';
968
+ fwdHeaders['sec-fetch-dest'] = 'empty';
969
+ fwdHeaders.accept = 'text/css,*/*;q=0.1';
970
+
971
+ for (const part of ['stylesheet', 'patches']) {
972
+ try {
973
+ const r = await fetch(origin + '/api/skin-center/v2/skins/' + encodeURIComponent(id) + '/' + part, {
974
+ headers: fwdHeaders,
975
+ signal: AbortSignal.timeout(SKINS_TIMEOUT_MS),
976
+ });
977
+ if (!r.ok) continue;
978
+ const text = await r.text();
979
+ if (!text || !text.trim()) continue;
980
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
981
+ res.setHeader('Cache-Control', 'no-store');
982
+ res.setHeader('X-Folia-Scoped', 'skin-center');
983
+ res.end(text);
984
+ return;
985
+ } catch { /* 这一段失败就试下一段 */ }
986
+ }
987
+
988
+ // 代理拿不到(网关拦截 / 没装皮肤中心)⇒ **自己作用域化**。
989
+ // 这条路不依赖任何凭证,也不依赖对方的进程,对所有用户一致成立。
990
+ const harness = harnessRoot();
991
+ if (!harness) { res.statusCode = 503; res.end(); return; }
992
+ const dir = join(harness, SKINS_DIR_NAME, id);
993
+ const sheet = skinStylesheetOf(dir);
994
+ if (!sheet) { res.statusCode = 404; res.end(); return; }
995
+ const raw = [];
996
+ try { raw.push(readFileSync(join(dir, ...sheet.rel.split('/')), 'utf8')); } catch { /* 主样式读不到就不能算成功 */ }
997
+ if (raw.length === 0) { res.statusCode = 404; res.end(); return; }
998
+ const patches = skinAuxFileOf(dir, 'patches');
999
+ if (patches) {
1000
+ try { raw.push(readFileSync(join(dir, ...patches.split('/')), 'utf8')); } catch { /* 补丁缺失可接受 */ }
1001
+ }
1002
+ // url() 先改写成我们的素材路由(作用域化只动选择器,不动声明)。
1003
+ const baseDir = sheet.rel.includes('/') ? sheet.rel.slice(0, sheet.rel.lastIndexOf('/')) : '';
1004
+ const absUrl = (p) => {
1005
+ if (/^(?:[a-z]+:|\/|#|data:)/i.test(p)) return p;
1006
+ const rel = (baseDir ? baseDir + '/' : '') + p;
1007
+ return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
1008
+ };
1009
+ const merged = raw.join('\n/* ---- patches ---- */\n')
1010
+ .replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, p) => `url("${absUrl(p.trim())}")`);
1011
+ const scoped = scopeCss(merged, id);
1012
+ note('[skins] scoped locally: ' + id + ' (' + scoped.length + ' bytes)');
1013
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
1014
+ res.setHeader('Cache-Control', 'no-store');
1015
+ res.setHeader('X-Folia-Scoped', 'local');
1016
+ res.end(scoped);
1017
+ },
1018
+ }));
1019
+
1020
+ disposers.push(webServer.register({
1021
+ kind: 'exact',
1022
+ path: `${base}/skins/asset`,
1023
+ handler: async (req, res) => {
1024
+ const url = new URL(req.url || '/', 'http://x');
1025
+ const id = url.searchParams.get('id') || '';
1026
+ const rel = url.searchParams.get('p') || '';
1027
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
1028
+ const segs = safeRelPath(rel);
1029
+ if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
1030
+ const harness = harnessRoot();
1031
+ if (!harness) { res.statusCode = 503; res.end(); return; }
1032
+ const root = join(harness, SKINS_DIR_NAME, id);
1033
+ const file = join(root, ...segs);
1034
+ if (!resolve(file).startsWith(resolve(root) + sep)) { res.statusCode = 400; res.end(); return; }
1035
+ if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end(); return; }
1036
+ const ext = (file.slice(file.lastIndexOf('.')).toLowerCase() || '');
1037
+ res.setHeader('Content-Type', ASSET_TYPES[ext] || 'application/octet-stream');
1038
+ res.setHeader('Cache-Control', 'public, max-age=300');
1039
+ res.end(readFileSync(file));
1040
+ },
1041
+ }));
1042
+
1043
+ disposers.push(webServer.register({
1044
+ kind: 'exact',
1045
+ path: `${base}/skins/preview`,
1046
+ handler: async (req, res) => {
1047
+ // 预览图代理:浏览器不能直连站点(跨境),也避免把站点 URL 散到客户端各处。
1048
+ const url = new URL(req.url || '/', 'http://x');
1049
+ const rel = url.searchParams.get('p') || '';
1050
+ const site = skinsSiteBase();
1051
+ if (!site) { res.statusCode = 503; res.end(); return; }
1052
+ const segs = safeRelPath(rel);
1053
+ if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
1054
+ try {
1055
+ const r = await fetch(site + '/' + segs.join('/'), {
1056
+ headers: { accept: 'image/*' },
1057
+ signal: AbortSignal.timeout(SKINS_TIMEOUT_MS),
1058
+ });
1059
+ if (!r.ok) { res.statusCode = 404; res.end(); return; }
1060
+ const buf = Buffer.from(await r.arrayBuffer());
1061
+ res.setHeader('Content-Type', r.headers.get('content-type') || 'image/jpeg');
1062
+ res.setHeader('Cache-Control', 'public, max-age=3600');
1063
+ res.end(buf);
1064
+ } catch {
1065
+ res.statusCode = 502;
1066
+ res.end();
1067
+ }
1068
+ },
1069
+ }));
1070
+ }
1071
+
1072
+ /* renameSync / cpSync 的薄封装:统一「同分区 rename、跨设备退化复制」这一处。 */
1073
+ function renameSyncSafe(from, to) { renameSync(from, to); }
1074
+ function cpSyncSafe(from, to) { cpSync(from, to, { recursive: true }); }