dsh-folia 4.14.3 → 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
@@ -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" ? "activate:" + ((payload && payload.id) || "") : "");
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";
@@ -18601,8 +18615,14 @@ window.__ModuleLoader__.load({
18601
18615
  // ⚠️ 只有 `=== false` 才下结论:`null` 表示"问不到它的 catalog"(未知),
18602
18616
  // 未知绝不能禁用按钮 —— 那会变成"替我猜错了就不让我点"。
18603
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
+ : "";
18604
18623
  const notListedWhy = notListed
18605
- ? weT("皮肤中心仍未收录这套(它按 manifest 规则筛选:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全)。点「刷新」会强制它对账一次;若仍显示这条,说明确实被它拒收")
18624
+ ? weT("皮肤中心未收录这套(它按自己的 manifest 规则筛选:id 必须等于目录名、必须 v2、name/nameEn/author/stylesheet 齐全、不允许未知顶层键)。可点「修复清单」按它的规则补一次;修完点刷新")
18625
+ + whyDetail
18606
18626
  : "";
18607
18627
  return React.createElement("div", {
18608
18628
  key: s.id,
@@ -18624,6 +18644,16 @@ window.__ModuleLoader__.load({
18624
18644
  title: notListed ? notListedWhy : (applyBlockedReason || undefined),
18625
18645
  onClick: () => act("activate", { id: s.id }, s.name),
18626
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,
18627
18657
  React.createElement("button", {
18628
18658
  className: "we-picker__btn", type: "button",
18629
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,
@@ -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`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.14.3",
3
+ "version": "4.15.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {