dsh-folia 4.14.2 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -18309,6 +18309,9 @@ window.__ModuleLoader__.load({
18309
18309
  function SkinsTabPanel() {
18310
18310
  const [state, setState] = React.useState(null);
18311
18311
  const [busy, setBusy] = React.useState("");
18312
+ // 给"静默对账"用的只读镜像:定时器/可见性回调里不能读闭包里的旧 `busy`(会一直看到 ""),
18313
+ // 用一个 ref 跟住最新值,操作进行中就不打扰用户。
18314
+ const busyRef = React.useRef("");
18312
18315
  const [note, setNote] = React.useState(null);
18313
18316
 
18314
18317
  const load = React.useCallback(async (force) => {
@@ -18356,11 +18359,33 @@ window.__ModuleLoader__.load({
18356
18359
 
18357
18360
  React.useEffect(() => { load(false); }, [load]);
18358
18361
 
18362
+ // 与外部改动**保持同步**:皮肤可能在皮肤中心那边被删/装,也可能在另一个窗口里操作。
18363
+ // 之前只在挂载时拉一次 ⇒ 那边删了,这边还列着(点了才失败,看起来像"没同步")。
18364
+ // 三条腿都只做"重拉一次列表",不做别的:
18365
+ // ① 页签重新可见(switch 回来)② 窗口重新获得焦点 ③ 每 20 秒的轻量对账
18366
+ // 轻量对账只在**没有正在进行的操作**时跑,避免把用户刚点的"安装中…"状态冲掉。
18367
+ React.useEffect(() => {
18368
+ if (typeof document === "undefined" || typeof window === "undefined") return undefined;
18369
+ let timer = null;
18370
+ const refreshQuietly = () => { try { if (!busyRef.current) load(false); } catch { /* ignore */ } };
18371
+ const onVisible = () => { try { if (document.visibilityState === "visible") refreshQuietly(); } catch { /* ignore */ } };
18372
+ try { document.addEventListener("visibilitychange", onVisible); } catch { /* ignore */ }
18373
+ try { window.addEventListener("focus", refreshQuietly); } catch { /* ignore */ }
18374
+ try { timer = window.setInterval(refreshQuietly, 20000); } catch { timer = null; }
18375
+ return () => {
18376
+ try { document.removeEventListener("visibilitychange", onVisible); } catch { /* ignore */ }
18377
+ try { window.removeEventListener("focus", refreshQuietly); } catch { /* ignore */ }
18378
+ if (timer !== null) { try { window.clearInterval(timer); } catch { /* ignore */ } }
18379
+ };
18380
+ }, [load]);
18381
+
18359
18382
  const act = async (kind, payload, label) => {
18360
18383
  // 结构性操作才锁整块 UI;`activate` 是幂等的、而且要让人能连点换皮肤,所以不锁。
18361
18384
  const structural = kind === "install" || kind === "remove";
18362
- if (structural) setBusy(kind + ":" + ((payload && payload.id) || ""));
18363
- if (kind === "activate") setBusy("activate:" + ((payload && payload.id) || ""));
18385
+ const busyKey = structural
18386
+ ? kind + ":" + ((payload && payload.id) || "")
18387
+ : (kind === "activate" || kind === "repair" ? kind + ":" + ((payload && payload.id) || "") : "");
18388
+ if (busyKey) { setBusy(busyKey); busyRef.current = busyKey; }
18364
18389
  setNote(null);
18365
18390
  try {
18366
18391
  if (kind === "activate") {
@@ -18426,14 +18451,28 @@ window.__ModuleLoader__.load({
18426
18451
  const r = (resp && resp.data) || null;
18427
18452
  if (resp && resp.ok && r && r.ok) {
18428
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
+ : "";
18429
18460
  setNote({
18430
18461
  ok: true,
18431
18462
  text: r.exists
18432
18463
  ? weT("已安装过:{name}", { name: label || "" })
18433
- : weT("已安装:{name} —— 点「应用」就能切换", { name: label || "" }),
18464
+ : weT("已安装:{name} —— 点「应用」就能切换", { name: label || "" }) + fixed,
18434
18465
  });
18435
18466
  }
18436
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
+ }
18437
18476
  await load(true);
18438
18477
  } else {
18439
18478
  const code = (r && r.error) || "failed";
@@ -18454,6 +18493,7 @@ window.__ModuleLoader__.load({
18454
18493
  setNote({ ok: false, text: weT("请求失败:{msg}", { msg: (e && e.message) || weT("网络错误") }) });
18455
18494
  } finally {
18456
18495
  setBusy("");
18496
+ busyRef.current = "";
18457
18497
  }
18458
18498
  };
18459
18499
 
@@ -18575,8 +18615,14 @@ window.__ModuleLoader__.load({
18575
18615
  // ⚠️ 只有 `=== false` 才下结论:`null` 表示"问不到它的 catalog"(未知),
18576
18616
  // 未知绝不能禁用按钮 —— 那会变成"替我猜错了就不让我点"。
18577
18617
  const notListed = s.inCatalog === false;
18618
+ // 它给出的**具体拒收原因**(catalog diagnostics 里 subject = 目录名)。
18619
+ // 有原因就直接显示原因,比我们复述规则更有用。
18620
+ const whyDetail = notListed && Array.isArray(s.catalogWarnings) && s.catalogWarnings.length
18621
+ ? ":" + s.catalogWarnings.slice(0, 2).join(";")
18622
+ : "";
18578
18623
  const notListedWhy = notListed
18579
- ? weT("皮肤中心仍未收录这套(它按 manifest 规则筛选:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全)。点「刷新」会强制它对账一次;若仍显示这条,说明确实被它拒收")
18624
+ ? weT("皮肤中心未收录这套(它按自己的 manifest 规则筛选:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全、不允许未知顶层键)。可点「修复清单」按它的规则补一次;修完点刷新")
18625
+ + whyDetail
18580
18626
  : "";
18581
18627
  return React.createElement("div", {
18582
18628
  key: s.id,
@@ -18598,6 +18644,16 @@ window.__ModuleLoader__.load({
18598
18644
  title: notListed ? notListedWhy : (applyBlockedReason || undefined),
18599
18645
  onClick: () => act("activate", { id: s.id }, s.name),
18600
18646
  }, busyThis ? weT("切换中…") : weT("应用")),
18647
+ // 未被皮肤中心收录时给一个**可执行**的出口:按它的规则就地修一次清单。
18648
+ // 只是补/裁 skin.json 的字段,不动 skin.css / hooks / 资产。
18649
+ notListed
18650
+ ? React.createElement("button", {
18651
+ className: "we-picker__btn", type: "button",
18652
+ disabled: Boolean(busy),
18653
+ title: notListedWhy,
18654
+ onClick: () => act("repair", { id: s.dirId || s.id }, s.name),
18655
+ }, busy === "repair:" + (s.dirId || s.id) ? weT("修复中…") : weT("修复清单"))
18656
+ : null,
18601
18657
  React.createElement("button", {
18602
18658
  className: "we-picker__btn", type: "button",
18603
18659
  disabled: Boolean(busy) || isActive,
@@ -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
- return { ...s, inCatalog: Boolean(hit), catalogWarnings: hit ? catalog.get(hit) : [] };
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 catalog = skinCenter === 'present' ? await fetchSkinCenterCatalog(hostHeader, force) : null;
411
- let installed = annotateCatalog(installedBase, catalog);
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
- catalog = await fetchSkinCenterCatalog(hostHeader, true);
414
- installed = annotateCatalog(installedBase, catalog);
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
- // 目录名以 skin.json 的 id 为准(不信任 URL/调用方给的名字)。
491
- const skinId = safeSkinId(manifest && manifest.id) || hinted;
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,
@@ -544,11 +649,24 @@ export function registerSkinsRoutes(webServer, c) {
544
649
  const dir = resolveInside(root, id);
545
650
  if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
546
651
  if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
652
+ // 删之前先看它是不是"当前正在用的那套":如果是,必须**先把选择清掉**再删目录,
653
+ // 否则 active 指向一个不存在的 id ⇒ 皮肤中心回退到内置默认款,用户看到的是
654
+ // "皮肤没换成我选的那套"(实测症状)。清空走同一套兜底链路(HTTP→状态文件)。
655
+ let clearedActive = false;
656
+ try {
657
+ const statePath = activeStatePath();
658
+ let cur = null;
659
+ try { cur = (JSON.parse(readFileSync(statePath, 'utf8')) || {}).active || null; } catch { cur = null; }
660
+ if (cur && (cur === id || normSkinId(cur) === normSkinId(id))) {
661
+ writeActiveStateFile(null);
662
+ clearedActive = true;
663
+ }
664
+ } catch { /* 清不掉也不阻塞删除:目录照删,只是 active 可能悬空(页签会显示"恢复默认外观") */ }
547
665
  try { rmSync(dir, { recursive: true, force: true }); } catch (e) {
548
666
  sendJson(res, 500, { ok: false, error: 'remove-failed', detail: String(e && e.message).slice(0, 160) });
549
667
  return;
550
668
  }
551
- sendJson(res, 200, { ok: true, id });
669
+ sendJson(res, 200, { ok: true, id, clearedActive });
552
670
  },
553
671
  }));
554
672
 
@@ -577,6 +695,31 @@ export function registerSkinsRoutes(webServer, c) {
577
695
  return out;
578
696
  }
579
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
+
580
723
  disposers.push(webServer.register({
581
724
  kind: 'exact',
582
725
  path: `${base}/skins/activate`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.14.2",
3
+ "version": "4.15.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {