dsh-folia 2.4.6 → 2.5.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 CHANGED
@@ -16230,6 +16230,41 @@ window.__ModuleLoader__.load({
16230
16230
  return code;
16231
16231
  }
16232
16232
 
16233
+ /**
16234
+ * 把一套皮肤的样式**当场**挂进文档。
16235
+ *
16236
+ * 为什么是 `<style>` 而不是 `<link>`:
16237
+ * · `<link rel=stylesheet>` 是**异步**的,加载完成的时机不定,容易输给皮肤中心随后
16238
+ * 重新注入的那份;
16239
+ * · `<style>` 一赋值就参与样式计算,且追加到 `<body>` **末尾** ⇒ 文档顺序最后,
16240
+ * 同等特异性下后写的赢。这是"不刷新换肤"能真正看见的关键。
16241
+ *
16242
+ * 仍然不跑 `hooks.mjs`(粒子等动效),那部分只有整页重载才生效。
16243
+ *
16244
+ * @param {string} id 皮肤 id
16245
+ * @returns {Promise<{ bytes: number, rules: number }>}
16246
+ */
16247
+ async function applySkinCss(id) {
16248
+ const resp = await apiFetch("/skins/css?id=" + encodeURIComponent(id) + "&t=" + Date.now(), {
16249
+ parse: false,
16250
+ cache: "no-store",
16251
+ });
16252
+ if (!resp || !resp.ok || !resp.response) throw new Error("css-http-" + ((resp && resp.status) || 0));
16253
+ const css = await resp.response.text();
16254
+ if (!css || !css.trim()) throw new Error("css-empty");
16255
+ let el = document.getElementById("folia-skin-hot");
16256
+ if (!el) {
16257
+ el = document.createElement("style");
16258
+ el.id = "folia-skin-hot";
16259
+ // 挂 body 末尾(不是 head):文档顺序最后 ⇒ 同等特异性下覆盖先前的规则。
16260
+ (document.body || document.head).appendChild(el);
16261
+ }
16262
+ el.textContent = css;
16263
+ let rules = 0;
16264
+ try { rules = (el.sheet && el.sheet.cssRules && el.sheet.cssRules.length) || 0; } catch { /* 读不到就报 0 */ }
16265
+ return { bytes: css.length, rules };
16266
+ }
16267
+
16233
16268
  function SkinsTabPanel() {
16234
16269
  const [list, setList] = React.useState([]);
16235
16270
  const [active, setActive] = React.useState(null);
@@ -16292,25 +16327,25 @@ window.__ModuleLoader__.load({
16292
16327
  setNote({ kind: "ok", text: weT("已安装:") + label });
16293
16328
  await load(true);
16294
16329
  } else if (kind === "activate") {
16330
+ // **当场换肤**:把皮肤样式同步注入文档,不刷新、不重启。
16331
+ // 注入结果直接写进提示里(字节数 / 规则条数):万一样式还是没盖住皮肤中心,
16332
+ // 这一行就是判断"是没注入"还是"注入了但被压住"的依据,不用再猜。
16333
+ let info = null;
16334
+ let applyErr = "";
16335
+ try {
16336
+ info = await applySkinCss(id);
16337
+ } catch (e) {
16338
+ applyErr = String((e && e.message) || e);
16339
+ }
16295
16340
  setNote({
16296
- kind: "ok",
16297
- text: weT("已切换到「") + label + weT("」,正在刷新界面…"),
16298
- // 皮肤中心**只在页面加载时**应用皮肤:它的 `POST /v2/active` 也只写状态文件,
16299
- // 不重绘。所以"真的换肤"只有重新加载这一页一条路 —— 见下面的自动跳转。
16341
+ kind: applyErr ? "err" : "ok",
16342
+ text: applyErr
16343
+ ? weT("已切换为「") + label + weT("」,但样式注入失败:") + applyErr
16344
+ : weT("已切换到「") + label + weT("」—— 已注入 ")
16345
+ + Math.round(info.bytes / 1024) + " KB / " + info.rules + weT(" 条样式规则"),
16346
+ // hooks(粒子等动效)只有整页重载才会跑:给一个可选入口,不强制。
16300
16347
  reload: true,
16301
16348
  });
16302
- // 一次点击就完成:这里自动重新加载,不再让用户点第二次。
16303
- // 用 `location.replace(href)` 而非 `reload()`:桌面壳里 reload 有时被吞掉
16304
- //(表现正是"点了没反应"),硬跳转更容易生效,且 replace 不往历史里塞条目。
16305
- try {
16306
- setTimeout(function tryApply() {
16307
- try {
16308
- window.location.replace(window.location.href);
16309
- } catch (e1) {
16310
- try { window.location.reload(); } catch (e2) { /* 交给手动按钮 */ }
16311
- }
16312
- }, 700);
16313
- } catch { /* 环境不支持 → 手动按钮兜底 */ }
16314
16349
  // 不再整表重拉:本地标记已经对了,省掉那 600 ms 的"没反应"窗口。
16315
16350
  } else if (kind === "remove") {
16316
16351
  setNote({ kind: "ok", text: weT("已删除:") + label });
@@ -16380,7 +16415,7 @@ window.__ModuleLoader__.load({
16380
16415
  React.createElement("span", null, weT("当前皮肤:") + activeName),
16381
16416
  React.createElement("span", {
16382
16417
  style: { fontSize: "11px", padding: "1px 6px", borderRadius: "999px", background: "rgba(142,230,168,0.16)", color: "#8ee6a8" },
16383
- }, weT("重启 DSH 生效")),
16418
+ }, weT("已应用")),
16384
16419
  ));
16385
16420
  }
16386
16421
 
@@ -16443,8 +16478,8 @@ window.__ModuleLoader__.load({
16443
16478
  try { window.location.reload(); } catch { /* 极端环境刷不动就让用户手动重启 */ }
16444
16479
  }
16445
16480
  },
16446
- title: weT("重新加载界面以应用新皮肤(对话与设置都在宿主侧,不会丢)"),
16447
- }, weT("立即生效"))
16481
+ title: weT("整页重载 —— 皮肤动效(粒子等)需要重载才跑;纯配色已即时生效"),
16482
+ }, weT("重载以播放动效"))
16448
16483
  : null,
16449
16484
  ));
16450
16485
  }
@@ -16509,8 +16544,6 @@ window.__ModuleLoader__.load({
16509
16544
  : React.createElement("div", { style: SKINS_STYLE.thumb }),
16510
16545
  React.createElement("div", { style: SKINS_STYLE.body },
16511
16546
  React.createElement("div", { style: SKINS_STYLE.title }, s.name || s.id),
16512
- React.createElement("div", { style: SKINS_STYLE.meta },
16513
- (s.author ? s.author : weT("未知作者")) + " · " + skinsFmtBytes(s.zipBytes)),
16514
16547
  s.tagline ? React.createElement("div", { style: SKINS_STYLE.meta }, s.tagline) : null,
16515
16548
  (s.tags || []).length
16516
16549
  ? React.createElement("div", { style: SKINS_STYLE.tags },
@@ -489,6 +489,136 @@ export function registerSkinRoutes(webServer, c) {
489
489
  },
490
490
  }));
491
491
 
492
+ /* ── 热切换:把皮肤样式自己喂给浏览器 ──────────────────────────────────────
493
+ * 皮肤中心只在**页面加载时**应用皮肤(它的 host 侧把样式塞进文档、client 侧启动时拉
494
+ * `/api/skin-center/v2/active`),桌面壳里 `location.reload()` 又常常被吞掉 ⇒
495
+ * 用户点「使用」后界面永远不变。
496
+ *
497
+ * 所以这里补两条只读路由,把 `harness/skins/<id>/` 里的样式与素材直接发给页面:
498
+ * GET /skins/css?id=<id> → skin.css(`url()` 已重写成下面那条素材路由)
499
+ * GET /skins/asset?id=<id>&p=<rel> → 皮肤目录里的任意素材(图片/字体)
500
+ * 客户端把 CSS 挂成一个 `<link id="folia-skin-hot">` 追加在 `<head>` 末尾 ——
501
+ * 后加载的规则覆盖先前的,于是**不刷新也能换肤**;hooks(粒子等)不会跑,那部分
502
+ * 仍归皮肤中心管。
503
+ */
504
+
505
+ /** 读一套皮肤的 manifest,拿 `contributes.stylesheet`。 */
506
+ function skinStylesheetOf(dir) {
507
+ try {
508
+ const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
509
+ const rel = doc && doc.contributes && doc.contributes.stylesheet;
510
+ if (typeof rel !== 'string') return null;
511
+ const segs = safeRelPath(rel);
512
+ if (!segs || segs.includes('..')) return null;
513
+ return { rel: segs.join('/'), doc };
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * 取 manifests 里某个可选的样式类字段(当前用于 `patches`)。
521
+ * 与 stylesheet 同样的安全校验:只接受皮肤目录内的相对路径。
522
+ */
523
+ function skinAuxFileOf(dir, key) {
524
+ try {
525
+ const doc = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
526
+ const rel = doc && doc.contributes && doc.contributes[key];
527
+ if (typeof rel !== 'string' || !rel) return null;
528
+ const segs = safeRelPath(rel);
529
+ if (!segs || segs.includes('..')) return null;
530
+ return segs.join('/');
531
+ } catch {
532
+ return null;
533
+ }
534
+ }
535
+
536
+ const ASSET_TYPES = {
537
+ '.css': 'text/css; charset=utf-8',
538
+ '.js': 'text/javascript; charset=utf-8',
539
+ '.json': 'application/json; charset=utf-8',
540
+ '.svg': 'image/svg+xml',
541
+ '.png': 'image/png',
542
+ '.jpg': 'image/jpeg',
543
+ '.jpeg': 'image/jpeg',
544
+ '.webp': 'image/webp',
545
+ '.gif': 'image/gif',
546
+ '.avif': 'image/avif',
547
+ '.woff': 'font/woff',
548
+ '.woff2': 'font/woff2',
549
+ '.ttf': 'font/ttf',
550
+ '.otf': 'font/otf',
551
+ };
552
+
553
+ disposers.push(webServer.register({
554
+ kind: 'exact',
555
+ path: `${base}/skins/css`,
556
+ handler: async (req, res) => {
557
+ const url = new URL(req.url || '/', 'http://x');
558
+ const id = url.searchParams.get('id') || '';
559
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
560
+ const harness = harnessRoot();
561
+ if (!harness) { res.statusCode = 503; res.end(); return; }
562
+ const dir = join(harness, SKINS_DIR_NAME, id);
563
+ const sheet = skinStylesheetOf(dir);
564
+ if (!sheet) { res.statusCode = 404; res.end(); return; }
565
+ let css;
566
+ try {
567
+ // ⚠️ 一套皮肤通常有**两份样式**:`contributes.stylesheet`(token 与结构)与
568
+ // `contributes.patches`(覆盖补丁)。**很多皮肤把全部改动写在 patches 里,
569
+ // stylesheet 故意留空**(例如 black-gold-vip 的 skin.css 只有一行注释)。
570
+ // 只下发 stylesheet ⇒ 换肤"看起来毫无变化"。两份都读,patches 放在后面覆盖。
571
+ const files = [];
572
+ files.push(sheet.rel);
573
+ const patchesRel = skinAuxFileOf(dir, 'patches');
574
+ if (patchesRel) files.push(patchesRel);
575
+ css = files.map((rel) => {
576
+ try { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); } catch { return ''; }
577
+ }).join('\n/* ---- patches ---- */\n');
578
+ } catch {
579
+ res.statusCode = 404;
580
+ res.end();
581
+ return;
582
+ }
583
+ // `url(...)` 全部改写成素材路由:CSS 是从 `/folia/skins/css` 下发的,相对路径会
584
+ // 解析到错误的前缀;改成绝对路径后,图片/字体才找得到。
585
+ const baseDir = sheet.rel.includes('/') ? sheet.rel.slice(0, sheet.rel.lastIndexOf('/')) : '';
586
+ const abs = (p) => {
587
+ if (/^(?:[a-z]+:|\/|#|data:)/i.test(p)) return p;
588
+ const rel = (baseDir ? baseDir + '/' : '') + p;
589
+ return `${base}/skins/asset?id=${encodeURIComponent(id)}&p=${encodeURIComponent(rel)}`;
590
+ };
591
+ css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, p) => `url("${abs(p.trim())}")`);
592
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
593
+ // 不缓存:换皮肤后必须立刻拿到新的一份,否则用户会看到上一套的样式。
594
+ res.setHeader('Cache-Control', 'no-store');
595
+ res.end(css);
596
+ },
597
+ }));
598
+
599
+ disposers.push(webServer.register({
600
+ kind: 'exact',
601
+ path: `${base}/skins/asset`,
602
+ handler: async (req, res) => {
603
+ const url = new URL(req.url || '/', 'http://x');
604
+ const id = url.searchParams.get('id') || '';
605
+ const rel = url.searchParams.get('p') || '';
606
+ if (!ID_RE.test(id)) { res.statusCode = 400; res.end(); return; }
607
+ const segs = safeRelPath(rel);
608
+ if (!segs || segs.includes('..')) { res.statusCode = 400; res.end(); return; }
609
+ const harness = harnessRoot();
610
+ if (!harness) { res.statusCode = 503; res.end(); return; }
611
+ const root = join(harness, SKINS_DIR_NAME, id);
612
+ const file = join(root, ...segs);
613
+ if (!resolve(file).startsWith(resolve(root) + sep)) { res.statusCode = 400; res.end(); return; }
614
+ if (!existsSync(file) || !statSync(file).isFile()) { res.statusCode = 404; res.end(); return; }
615
+ const ext = (file.slice(file.lastIndexOf('.')).toLowerCase() || '');
616
+ res.setHeader('Content-Type', ASSET_TYPES[ext] || 'application/octet-stream');
617
+ res.setHeader('Cache-Control', 'public, max-age=300');
618
+ res.end(readFileSync(file));
619
+ },
620
+ }));
621
+
492
622
  disposers.push(webServer.register({
493
623
  kind: 'exact',
494
624
  path: `${base}/skins/preview`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "2.4.6",
3
+ "version": "2.5.1",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {