dsh-folia 4.14.3 → 4.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +61 -5
- package/lib/routes/skins.js +145 -15
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -18384,7 +18384,7 @@ window.__ModuleLoader__.load({
|
|
|
18384
18384
|
const structural = kind === "install" || kind === "remove";
|
|
18385
18385
|
const busyKey = structural
|
|
18386
18386
|
? kind + ":" + ((payload && payload.id) || "")
|
|
18387
|
-
: (kind === "activate" ? "
|
|
18387
|
+
: (kind === "activate" || kind === "repair" ? kind + ":" + ((payload && payload.id) || "") : "");
|
|
18388
18388
|
if (busyKey) { setBusy(busyKey); busyRef.current = busyKey; }
|
|
18389
18389
|
setNote(null);
|
|
18390
18390
|
try {
|
|
@@ -18451,14 +18451,28 @@ window.__ModuleLoader__.load({
|
|
|
18451
18451
|
const r = (resp && resp.data) || null;
|
|
18452
18452
|
if (resp && resp.ok && r && r.ok) {
|
|
18453
18453
|
if (kind === "install") {
|
|
18454
|
+
// 安装时宿主会把 skin.json **规范化**成皮肤中心一定收的样子(它的规则比我们严:
|
|
18455
|
+
// id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全、不允许未知顶层键)。
|
|
18456
|
+
// 这一步做了什么要如实告诉用户,否则"为什么它动了我的清单"会变成新的疑问。
|
|
18457
|
+
const fixed = r.manifestFixed === true
|
|
18458
|
+
? weT("(已按皮肤中心规则规范化清单:{what})", { what: (r.manifestFixes || []).join("、") })
|
|
18459
|
+
: "";
|
|
18454
18460
|
setNote({
|
|
18455
18461
|
ok: true,
|
|
18456
18462
|
text: r.exists
|
|
18457
18463
|
? weT("已安装过:{name}", { name: label || "" })
|
|
18458
|
-
: weT("已安装:{name} —— 点「应用」就能切换", { name: label || "" }),
|
|
18464
|
+
: weT("已安装:{name} —— 点「应用」就能切换", { name: label || "" }) + fixed,
|
|
18459
18465
|
});
|
|
18460
18466
|
}
|
|
18461
18467
|
if (kind === "remove") setNote({ ok: true, text: weT("已卸载:{name}", { name: label || "" }) });
|
|
18468
|
+
if (kind === "repair") {
|
|
18469
|
+
setNote({
|
|
18470
|
+
ok: true,
|
|
18471
|
+
text: r.changed
|
|
18472
|
+
? weT("已按皮肤中心规则修复清单:{what} —— 点「刷新」后应能被收录", { what: (r.fixes || []).join("、") })
|
|
18473
|
+
: weT("这套清单本来就合规(没有可修的);若仍未被收录,把卡片上的原因发我"),
|
|
18474
|
+
});
|
|
18475
|
+
}
|
|
18462
18476
|
await load(true);
|
|
18463
18477
|
} else {
|
|
18464
18478
|
const code = (r && r.error) || "failed";
|
|
@@ -18556,8 +18570,15 @@ window.__ModuleLoader__.load({
|
|
|
18556
18570
|
|
|
18557
18571
|
const installed = (state && state.installed) || [];
|
|
18558
18572
|
const activeId = (state && state.active) || "";
|
|
18573
|
+
// 「皮肤中心记着哪套」≠「屏上现在是哪套」:壁纸在台上时皮肤会 withhold 自己,
|
|
18574
|
+
// 但它持久化的 active 不会自动清(我们 4.15.1 起在用户选壁纸时主动清一次)。
|
|
18575
|
+
// 这台机器的旧状态(或清理失败)仍可能出现"壁纸在播、页签却标使用中",所以这里先看
|
|
18576
|
+
// 我们的壁纸标记,标了就不把 active 说成"使用中",而是说"已让位"。
|
|
18577
|
+
const wallpaperOn = (() => {
|
|
18578
|
+
try { return Boolean(document && document.body && document.body.hasAttribute("data-we-wallpaper")); } catch { return false; }
|
|
18579
|
+
})();
|
|
18559
18580
|
/** 这个 id 是不是"正在用"的那套(两边都归一后比,站点 slug 可能带 skin- 前缀)。 */
|
|
18560
|
-
const isActiveSkin = (id) => Boolean(activeId) && normSkinId(activeId) === normSkinId(id);
|
|
18581
|
+
const isActiveSkin = (id) => Boolean(activeId) && !wallpaperOn && normSkinId(activeId) === normSkinId(id);
|
|
18561
18582
|
|
|
18562
18583
|
/**
|
|
18563
18584
|
* 站点条目 → 本机已装皮肤的真实 id(皮肤中心只认这个)。
|
|
@@ -18578,7 +18599,19 @@ window.__ModuleLoader__.load({
|
|
|
18578
18599
|
if (state) {
|
|
18579
18600
|
children.push(React.createElement("div", { key: "installed-head", className: "we-picker__hint" },
|
|
18580
18601
|
weT("本机已装({n})", { n: installed.length }),
|
|
18581
|
-
|
|
18602
|
+
// 壁纸在台上时不说"使用中":皮肤已让位,说了就是骗人(实测:壁纸在播却标皮肤使用中)
|
|
18603
|
+
activeId
|
|
18604
|
+
? (wallpaperOn
|
|
18605
|
+
? weT(" · 皮肤已让位(壁纸在使用中):{name}", { name: activeId })
|
|
18606
|
+
: weT(" · 使用中:{name}", { name: activeId }))
|
|
18607
|
+
: ""));
|
|
18608
|
+
if (activeId && wallpaperOn) {
|
|
18609
|
+
children.push(React.createElement("div", {
|
|
18610
|
+
key: "yield-note",
|
|
18611
|
+
className: "we-picker__hint",
|
|
18612
|
+
style: { margin: "0 0 6px", opacity: 0.8 },
|
|
18613
|
+
}, weT("当前是壁纸在台上,皮肤中心记着的这套没有在画。点「应用」会让皮肤上台(壁纸自动让位)。")));
|
|
18614
|
+
}
|
|
18582
18615
|
// 「恢复默认」:清空选择、回到官方外观。只有确实有皮肤在用的时候才有意义。
|
|
18583
18616
|
if (activeId) {
|
|
18584
18617
|
children.push(React.createElement("button", {
|
|
@@ -18601,8 +18634,14 @@ window.__ModuleLoader__.load({
|
|
|
18601
18634
|
// ⚠️ 只有 `=== false` 才下结论:`null` 表示"问不到它的 catalog"(未知),
|
|
18602
18635
|
// 未知绝不能禁用按钮 —— 那会变成"替我猜错了就不让我点"。
|
|
18603
18636
|
const notListed = s.inCatalog === false;
|
|
18637
|
+
// 它给出的**具体拒收原因**(catalog diagnostics 里 subject = 目录名)。
|
|
18638
|
+
// 有原因就直接显示原因,比我们复述规则更有用。
|
|
18639
|
+
const whyDetail = notListed && Array.isArray(s.catalogWarnings) && s.catalogWarnings.length
|
|
18640
|
+
? ":" + s.catalogWarnings.slice(0, 2).join(";")
|
|
18641
|
+
: "";
|
|
18604
18642
|
const notListedWhy = notListed
|
|
18605
|
-
? weT("
|
|
18643
|
+
? weT("皮肤中心未收录这套(它按自己的 manifest 规则筛选:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全、不允许未知顶层键)。可点「修复清单」按它的规则补一次;修完点刷新")
|
|
18644
|
+
+ whyDetail
|
|
18606
18645
|
: "";
|
|
18607
18646
|
return React.createElement("div", {
|
|
18608
18647
|
key: s.id,
|
|
@@ -18624,6 +18663,16 @@ window.__ModuleLoader__.load({
|
|
|
18624
18663
|
title: notListed ? notListedWhy : (applyBlockedReason || undefined),
|
|
18625
18664
|
onClick: () => act("activate", { id: s.id }, s.name),
|
|
18626
18665
|
}, busyThis ? weT("切换中…") : weT("应用")),
|
|
18666
|
+
// 未被皮肤中心收录时给一个**可执行**的出口:按它的规则就地修一次清单。
|
|
18667
|
+
// 只是补/裁 skin.json 的字段,不动 skin.css / hooks / 资产。
|
|
18668
|
+
notListed
|
|
18669
|
+
? React.createElement("button", {
|
|
18670
|
+
className: "we-picker__btn", type: "button",
|
|
18671
|
+
disabled: Boolean(busy),
|
|
18672
|
+
title: notListedWhy,
|
|
18673
|
+
onClick: () => act("repair", { id: s.dirId || s.id }, s.name),
|
|
18674
|
+
}, busy === "repair:" + (s.dirId || s.id) ? weT("修复中…") : weT("修复清单"))
|
|
18675
|
+
: null,
|
|
18627
18676
|
React.createElement("button", {
|
|
18628
18677
|
className: "we-picker__btn", type: "button",
|
|
18629
18678
|
disabled: Boolean(busy) || isActive,
|
|
@@ -21065,6 +21114,13 @@ window.__ModuleLoader__.load({
|
|
|
21065
21114
|
&& typeof skinYieldActive === "function" && skinYieldActive()) {
|
|
21066
21115
|
exitSkinYield("manual-pick", { skipRestore: true });
|
|
21067
21116
|
}
|
|
21117
|
+
// 「最后动作优先」的另一半:**用户选了壁纸** ⇒ 皮肤中心那边的 selection 也该清掉。
|
|
21118
|
+
// 否则它的 active 一直留着上一套皮肤,页签读了它就显示「使用中」——可屏上其实是壁纸
|
|
21119
|
+
//(它自己检测到 `body[data-we-wallpaper]` 就 withhold 了)。实测症状:壁纸在播、页签却标着
|
|
21120
|
+
// "皮肤使用中"。fire-and-forget:写失败不影响这次壁纸应用(页签按读到的事实显示)。
|
|
21121
|
+
if (id && opts && opts.fromManual) {
|
|
21122
|
+
try { apiPostJson("/skins/activate", { id: "" }, { parse: "always" }).catch(() => {}); } catch { /* ignore */ }
|
|
21123
|
+
}
|
|
21068
21124
|
reportClientDiag("apply", "id=" + String(id || "").slice(0, 40));
|
|
21069
21125
|
// 手动切换/清除/revalidate:取消进行中的轮换准备(staged/探测元素全部
|
|
21070
21126
|
// 释放),并丢弃任何滞留的就绪元素。轮换提交(fromRotation)例外 ——
|
package/lib/routes/skins.js
CHANGED
|
@@ -153,14 +153,17 @@ async function probeSkinCenter(hostHeader, force) {
|
|
|
153
153
|
// 快照缓存:写进 Map 而不是给模块级对象赋值 —— 后者正是 require-atomic-updates 判红的形态,
|
|
154
154
|
// 而这里本来就是「最后一次完整快照胜出」的语义,用 Map.set 更贴合且不触发该规则。
|
|
155
155
|
const catalogCacheStore = new Map();
|
|
156
|
-
function readCatalogCache() { return catalogCacheStore.get('v') || { at: 0, map: null }; }
|
|
157
|
-
function setCatalogCache(at, map) { catalogCacheStore.set('v', { at, map }); }
|
|
156
|
+
function readCatalogCache() { return catalogCacheStore.get('v') || { at: 0, map: null, diag: null }; }
|
|
157
|
+
function setCatalogCache(at, map, diag) { catalogCacheStore.set('v', { at, map, diag }); }
|
|
158
158
|
async function fetchSkinCenterCatalog(hostHeader, force) {
|
|
159
159
|
const now = Date.now();
|
|
160
160
|
const snapshot = readCatalogCache();
|
|
161
|
-
if (!force && snapshot.at && now - snapshot.at < 30000) return snapshot.map;
|
|
162
|
-
if (!hostHeader) return readCatalogCache().map;
|
|
161
|
+
if (!force && snapshot.at && now - snapshot.at < 30000) return { map: snapshot.map, diag: snapshot.diag };
|
|
162
|
+
if (!hostHeader) return { map: readCatalogCache().map, diag: readCatalogCache().diag };
|
|
163
163
|
let map = null;
|
|
164
|
+
// `diagnostics` 是它给出的**拒收原因**(subject = 目录名,errors = 具体字段问题)。
|
|
165
|
+
// 拿它挂在卡片 title 上,"未收录"才不是一句无解的话。
|
|
166
|
+
let diag = null;
|
|
164
167
|
try {
|
|
165
168
|
const r = await fetch('http://' + hostHeader + '/api/skin-center/v2/catalog', { signal: AbortSignal.timeout(5000) });
|
|
166
169
|
if (r.ok) {
|
|
@@ -171,10 +174,16 @@ async function fetchSkinCenterCatalog(hostHeader, force) {
|
|
|
171
174
|
const id = s && s.manifest && s.manifest.id;
|
|
172
175
|
if (typeof id === 'string' && id) map.set(id, Array.isArray(s.warnings) ? s.warnings : []);
|
|
173
176
|
}
|
|
177
|
+
const list = (doc && Array.isArray(doc.diagnostics)) ? doc.diagnostics : [];
|
|
178
|
+
diag = new Map();
|
|
179
|
+
for (const d of list) {
|
|
180
|
+
const subject = d && d.subject;
|
|
181
|
+
if (typeof subject === 'string' && subject) diag.set(subject, Array.isArray(d.errors) ? d.errors.map(String) : []);
|
|
182
|
+
}
|
|
174
183
|
}
|
|
175
|
-
} catch { map = readCatalogCache().map; }
|
|
176
|
-
setCatalogCache(now, map);
|
|
177
|
-
return map;
|
|
184
|
+
} catch { map = readCatalogCache().map; diag = readCatalogCache().diag; }
|
|
185
|
+
setCatalogCache(now, map, diag);
|
|
186
|
+
return { map, diag };
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
/** 站点列表缓存(30 秒):面板打开与"刷新"共用,避免每次都打站点。 */
|
|
@@ -188,13 +197,15 @@ const SITE_TTL_MS = 30000;
|
|
|
188
197
|
* `dirId`(目录名),且历史上站点 slug 可能带 `skin-` 前缀 ⇒ 三种形态都试,任一命中即算收录。
|
|
189
198
|
* `catalog === null`(问不到)时给 `null`:**未知不等于未收录**,界面不能据此禁用「应用」。
|
|
190
199
|
*/
|
|
191
|
-
function annotateCatalog(installed, catalog) {
|
|
200
|
+
function annotateCatalog(installed, catalog, diag) {
|
|
192
201
|
if (!catalog) return installed.map((s) => ({ ...s, inCatalog: null, catalogWarnings: [] }));
|
|
193
202
|
return installed.map((s) => {
|
|
194
203
|
const hit = catalog.has(s.id) ? s.id
|
|
195
204
|
: catalog.has(s.dirId) ? s.dirId
|
|
196
205
|
: catalog.has(normSkinId(s.id)) ? normSkinId(s.id) : null;
|
|
197
|
-
|
|
206
|
+
// 未收录时把它的 `diagnostics`(按目录名)附上:卡片 title 直接显示"为什么被拒"。
|
|
207
|
+
const why = hit || !diag ? [] : (diag.get(s.dirId) || diag.get(s.id) || []);
|
|
208
|
+
return { ...s, inCatalog: Boolean(hit), catalogWarnings: hit ? catalog.get(hit) : why };
|
|
198
209
|
});
|
|
199
210
|
}
|
|
200
211
|
|
|
@@ -372,6 +383,80 @@ function sendJson(res, code, payload) {
|
|
|
372
383
|
res.end(JSON.stringify(payload));
|
|
373
384
|
}
|
|
374
385
|
|
|
386
|
+
/**
|
|
387
|
+
* 把一份 skin.json 规范化成**皮肤中心一定收的样子**。
|
|
388
|
+
*
|
|
389
|
+
* 它的规则(0.4.12 `validateSkinManifestV2` 逐条读出来的,不是猜):
|
|
390
|
+
* · `skinManifestVersion === 2`
|
|
391
|
+
* · `id` 必须 `^[a-z][a-z0-9-]{0,31}$`,且**必须等于目录名**
|
|
392
|
+
* · `name` / `nameEn` / `author` 非空字符串;`version` 必须是 SemVer
|
|
393
|
+
* · `contributes.stylesheet` 必须是**存在的相对路径**
|
|
394
|
+
* · **顶层不允许未知键**(`unknown field` 是 error,不是 warning)
|
|
395
|
+
* · `accent` 若存在必须是 `#rrggbb`
|
|
396
|
+
*
|
|
397
|
+
* 站点里的包来自不同来源(有的还是 v1),常常缺 nameEn/author 或带 v1 键 ⇒ 被它整套排除,
|
|
398
|
+
* 用户看到的是"装好了、皮肤中心里没有、点了也不生效"。这里按上面几条补齐/裁掉,
|
|
399
|
+
* 原文件保留为 `skin.json.orig` 便于追溯。返回 `{ changed, fixes }` 供界面如实说明。
|
|
400
|
+
*/
|
|
401
|
+
function normalizeSkinManifest(manifestPath, base) {
|
|
402
|
+
const fixes = [];
|
|
403
|
+
let doc;
|
|
404
|
+
try { doc = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return { changed: false, fixes }; }
|
|
405
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { changed: false, fixes };
|
|
406
|
+
const dir = join(manifestPath, '..');
|
|
407
|
+
const before = JSON.stringify(doc);
|
|
408
|
+
const ALLOWED = new Set([
|
|
409
|
+
'$schema', 'skinManifestVersion', 'id', 'name', 'nameEn', 'version', 'author',
|
|
410
|
+
'tagline', 'description', 'tags', 'accent', 'order', 'preview', 'license', 'licenseUrl',
|
|
411
|
+
'noticeUrl', 'sourceUrl', 'attribution', 'requires', 'contributes', 'facets',
|
|
412
|
+
]);
|
|
413
|
+
for (const k of Object.keys(doc)) {
|
|
414
|
+
if (!ALLOWED.has(k)) { delete doc[k]; fixes.push('drop:' + k); }
|
|
415
|
+
}
|
|
416
|
+
if (doc.skinManifestVersion !== 2) { doc.skinManifestVersion = 2; fixes.push('v2'); }
|
|
417
|
+
const idOk = (v) => typeof v === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(v);
|
|
418
|
+
const wantId = idOk(doc.id) ? doc.id : (idOk(base.id) ? base.id : safeSlugId(base.slug || base.id || 'skin'));
|
|
419
|
+
if (doc.id !== wantId) { doc.id = wantId; fixes.push('id'); }
|
|
420
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
|
|
421
|
+
const name = str(doc.name) || str(base.title) || wantId;
|
|
422
|
+
if (doc.name !== name) { doc.name = name; fixes.push('name'); }
|
|
423
|
+
const nameEn = str(doc.nameEn) || name;
|
|
424
|
+
if (doc.nameEn !== nameEn) { doc.nameEn = nameEn; fixes.push('nameEn'); }
|
|
425
|
+
const author = str(doc.author) || 'folia';
|
|
426
|
+
if (doc.author !== author) { doc.author = author; fixes.push('author'); }
|
|
427
|
+
const ver = typeof doc.version === 'string' && /^\d+\.\d+\.\d+/.test(doc.version) ? doc.version : '0.1.0';
|
|
428
|
+
if (doc.version !== ver) { doc.version = ver; fixes.push('version'); }
|
|
429
|
+
if (!doc.contributes || typeof doc.contributes !== 'object' || Array.isArray(doc.contributes)) {
|
|
430
|
+
doc.contributes = {};
|
|
431
|
+
fixes.push('contributes');
|
|
432
|
+
}
|
|
433
|
+
const cssPath = str(doc.contributes.stylesheet);
|
|
434
|
+
const cssExists = cssPath && (() => { try { return statSync(join(dir, cssPath)).isFile(); } catch { return false; } })();
|
|
435
|
+
if (!cssExists) {
|
|
436
|
+
let pick = '';
|
|
437
|
+
try {
|
|
438
|
+
pick = readdirSync(dir).find((n) => n.toLowerCase().endsWith('.css') && statSync(join(dir, n)).isFile()) || '';
|
|
439
|
+
} catch { pick = ''; }
|
|
440
|
+
if (pick) { doc.contributes.stylesheet = pick; fixes.push('stylesheet'); }
|
|
441
|
+
else { delete doc.contributes.stylesheet; fixes.push('stylesheet-missing'); }
|
|
442
|
+
}
|
|
443
|
+
if (doc.accent !== undefined && !/^#[0-9a-fA-F]{6}$/.test(String(doc.accent))) { delete doc.accent; fixes.push('accent'); }
|
|
444
|
+
const after = JSON.stringify(doc);
|
|
445
|
+
if (after !== before) {
|
|
446
|
+
try { writeFileSync(manifestPath + '.orig', before, 'utf8'); } catch { /* 保留原文件失败不影响规范化 */ }
|
|
447
|
+
writeFileSync(manifestPath, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
448
|
+
return { changed: true, fixes };
|
|
449
|
+
}
|
|
450
|
+
return { changed: false, fixes };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 由任意字符串造一个合法 skin id(首字符必须是字母)。 */
|
|
454
|
+
function safeSlugId(raw) {
|
|
455
|
+
const s = String(raw || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 32);
|
|
456
|
+
if (/^[a-z][a-z0-9-]{0,31}$/.test(s)) return s;
|
|
457
|
+
return ('skin-' + s).replace(/[^a-z0-9-]/g, '-').slice(0, 32) || 'skin';
|
|
458
|
+
}
|
|
459
|
+
|
|
375
460
|
function readJsonBody(req, limit = 64 * 1024) {
|
|
376
461
|
return new Promise((resolve) => {
|
|
377
462
|
let acc = '';
|
|
@@ -407,11 +492,15 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
407
492
|
// 就被标成「皮肤中心未收录」,而打开它的设置页却是有的 —— 一次假红。
|
|
408
493
|
// 所以对账分两拍:先按缓存比,**出现"未收录"就强制重拉一次**再定论。
|
|
409
494
|
const hostHeader = req.headers && req.headers.host;
|
|
410
|
-
let
|
|
411
|
-
let
|
|
495
|
+
let snap = skinCenter === 'present' ? await fetchSkinCenterCatalog(hostHeader, force) : { map: null, diag: null };
|
|
496
|
+
let catalog = snap.map;
|
|
497
|
+
let diag = snap.diag;
|
|
498
|
+
let installed = annotateCatalog(installedBase, catalog, diag);
|
|
412
499
|
if (catalog && installed.some((s) => s.inCatalog === false)) {
|
|
413
|
-
|
|
414
|
-
|
|
500
|
+
snap = await fetchSkinCenterCatalog(hostHeader, true);
|
|
501
|
+
catalog = snap.map;
|
|
502
|
+
diag = snap.diag;
|
|
503
|
+
installed = annotateCatalog(installedBase, catalog, diag);
|
|
415
504
|
}
|
|
416
505
|
// ⚠️ 站点 slug 与本机皮肤 id **不总是同一个字符串**:站点上 slug 是全站唯一的,
|
|
417
506
|
// 撞名时上传工具会加 `skin-` 前缀(实测 blue-throated-bee-eater 同时是桌宠 id)。
|
|
@@ -487,8 +576,16 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
487
576
|
if (!manifestEntry) { sendJson(res, 422, { ok: false, error: 'no-skin-json' }); return; }
|
|
488
577
|
let manifest;
|
|
489
578
|
try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); } catch { sendJson(res, 422, { ok: false, error: 'bad-skin-json' }); return; }
|
|
490
|
-
//
|
|
491
|
-
|
|
579
|
+
// 目录名必须**同时**满足:安全(不越界)与皮肤中心的 id 规则(`^[a-z][a-z0-9-]{0,31}$`,
|
|
580
|
+
// 且等于目录名)。旧实现只用 safeSkinId(允许大写/下划线/点),于是 `My_Skin` 这种 id
|
|
581
|
+
// 会建出同名目录,而皮肤中心按严格规则把它整套排除 —— "装了但列表里没有"。
|
|
582
|
+
// 现在统一走 safeSlugId:合法就原样,不合法就规范化成合法 id。
|
|
583
|
+
const skinId = (() => {
|
|
584
|
+
const raw = String((manifest && manifest.id) || '');
|
|
585
|
+
if (raw && /^[a-z][a-z0-9-]{0,31}$/.test(raw)) return raw;
|
|
586
|
+
const fromManifest = raw && safeSkinId(raw);
|
|
587
|
+
return safeSlugId(fromManifest || hinted || 'skin');
|
|
588
|
+
})();
|
|
492
589
|
if (!skinId) { sendJson(res, 422, { ok: false, error: 'bad-skin-id' }); return; }
|
|
493
590
|
const dir = resolveInside(root, skinId);
|
|
494
591
|
if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
|
|
@@ -511,12 +608,20 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
511
608
|
writeFileSync(target, e.data);
|
|
512
609
|
written += 1;
|
|
513
610
|
}
|
|
611
|
+
// **规范化清单**:皮肤中心对 skin.json 有硬规则(id 必须 `^[a-z][a-z0-9-]{0,31}$`
|
|
612
|
+
// 且等于目录名、必须 v2、name/nameEn/author/version 齐全、stylesheet 必须是存在的相对路径、
|
|
613
|
+
// **不允许未知顶层键**)。站点上的包来自不同来源,常常缺字段或带 v1 键 ⇒ 它直接把整套
|
|
614
|
+
// 皮肤排除在 catalog 之外,表现为"能装、装完不在皮肤中心的列表里、点了也不生效"。
|
|
615
|
+
// 这里按它的规则把清单补齐/裁掉多余键,落地成它一定收的样子(原文件留在 skin.json.orig)。
|
|
616
|
+
const fixed = normalizeSkinManifest(join(dir, 'skin.json'), { id: skinId, slug: hinted, title: String(manifest.title || manifest.name || '') });
|
|
514
617
|
const check = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
|
|
515
618
|
if (!check || !check.id) throw new Error('manifest lost after write');
|
|
516
619
|
sendJson(res, 200, {
|
|
517
620
|
ok: true,
|
|
518
621
|
id: skinId,
|
|
519
622
|
name: String(check.name || check.nameEn || skinId),
|
|
623
|
+
manifestFixed: fixed.changed,
|
|
624
|
+
manifestFixes: fixed.fixes,
|
|
520
625
|
dir,
|
|
521
626
|
written,
|
|
522
627
|
skipped,
|
|
@@ -590,6 +695,31 @@ export function registerSkinsRoutes(webServer, c) {
|
|
|
590
695
|
return out;
|
|
591
696
|
}
|
|
592
697
|
|
|
698
|
+
/* ③b 修复清单:把**已经装在盘上**但不符合皮肤中心规则的 skin.json 就地规范化。
|
|
699
|
+
*
|
|
700
|
+
* 为什么需要它:安装路径已经会规范化(见 normalizeSkinManifest),但用户在此之前装的、
|
|
701
|
+
* 或从别处拷进来的皮肤仍然可能带着 v1 键/缺字段 ⇒ 皮肤中心整套排除、点了不生效。
|
|
702
|
+
* 让用户能就地修一次,比让他卸载重装更符合"别让他替我擦屁股"的原则。
|
|
703
|
+
*/
|
|
704
|
+
disposers.push(webServer.register({
|
|
705
|
+
kind: 'exact',
|
|
706
|
+
path: `${base}/skins/repair`,
|
|
707
|
+
handler: async (req, res) => {
|
|
708
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
|
|
709
|
+
const body = await readJsonBody(req);
|
|
710
|
+
const id = safeSkinId(body && body.id);
|
|
711
|
+
if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
|
|
712
|
+
const dir = resolveInside(skinsRoot(), id);
|
|
713
|
+
if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
|
|
714
|
+
const manifestPath = join(dir, 'skin.json');
|
|
715
|
+
if (!existsSync(manifestPath)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
|
|
716
|
+
let title = '';
|
|
717
|
+
try { const m = JSON.parse(readFileSync(manifestPath, 'utf8')); title = String(m.title || m.name || ''); } catch { /* 交给规范化去兜 */ }
|
|
718
|
+
const fixed = normalizeSkinManifest(manifestPath, { id, slug: id, title });
|
|
719
|
+
sendJson(res, 200, { ok: true, id, changed: fixed.changed, fixes: fixed.fixes });
|
|
720
|
+
},
|
|
721
|
+
}));
|
|
722
|
+
|
|
593
723
|
disposers.push(webServer.register({
|
|
594
724
|
kind: 'exact',
|
|
595
725
|
path: `${base}/skins/activate`,
|